From ae9f35062f8a4b212fab4576d44602b57dc0303a Mon Sep 17 00:00:00 2001 From: Eva Date: Wed, 15 Jul 2026 00:41:11 +0700 Subject: [PATCH 01/13] fix(workbench): own Mac bridge source --- .github/workflows/_build-reusable.yml | 4 +- .github/workflows/pr-checks.yml | 1 - .../workflows/workbench-functional-smoke.yml | 14 +- CHANGELOG.md | 11 + packages/desktop/electron-builder.yml | 2 +- resources/evaos-beta/bridge/SOURCE.json | 9 + .../src/evaos_desktop_bridge/__init__.py | 3 + .../evaos_desktop_bridge/adapters/__init__.py | 1 + .../adapters/codex_app_server.py | 1151 ++++++ .../adapters/codex_macos.py | 1684 ++++++++ .../adapters/customer_mac.py | 3518 +++++++++++++++++ .../bridge/src/evaos_desktop_bridge/audit.py | 56 + .../evaos_desktop_bridge/behavior_harness.py | 574 +++ .../src/evaos_desktop_bridge/bundled_tools.py | 46 + .../capability_manifest.py | 191 + .../bridge/src/evaos_desktop_bridge/cli.py | 3144 +++++++++++++++ .../evaos_desktop_bridge/connector_server.py | 1374 +++++++ .../src/evaos_desktop_bridge/helper_ipc.py | 1197 ++++++ .../bridge/src/evaos_desktop_bridge/policy.py | 175 + .../src/evaos_desktop_bridge/pre_canary.py | 453 +++ .../src/evaos_desktop_bridge/qa_canary.py | 1234 ++++++ .../bridge/src/evaos_desktop_bridge/queue.py | 78 + .../src/evaos_desktop_bridge/redaction.py | 45 + .../bridge/src/evaos_desktop_bridge/schema.py | 53 + .../bridge/src/evaos_desktop_bridge/state.py | 238 ++ .../bridge/src/evaos_desktop_bridge/types.py | 13 + scripts/afterPack.js | 22 +- scripts/prepareEvaosDesktopBridgeResource.js | 228 +- .../afterPackBundledResources.test.ts | 46 + .../prepareEvaosDesktopBridgeResource.test.ts | 75 +- 30 files changed, 15506 insertions(+), 134 deletions(-) create mode 100644 resources/evaos-beta/bridge/SOURCE.json create mode 100644 resources/evaos-beta/bridge/src/evaos_desktop_bridge/__init__.py create mode 100644 resources/evaos-beta/bridge/src/evaos_desktop_bridge/adapters/__init__.py create mode 100644 resources/evaos-beta/bridge/src/evaos_desktop_bridge/adapters/codex_app_server.py create mode 100644 resources/evaos-beta/bridge/src/evaos_desktop_bridge/adapters/codex_macos.py create mode 100644 resources/evaos-beta/bridge/src/evaos_desktop_bridge/adapters/customer_mac.py create mode 100644 resources/evaos-beta/bridge/src/evaos_desktop_bridge/audit.py create mode 100644 resources/evaos-beta/bridge/src/evaos_desktop_bridge/behavior_harness.py create mode 100644 resources/evaos-beta/bridge/src/evaos_desktop_bridge/bundled_tools.py create mode 100644 resources/evaos-beta/bridge/src/evaos_desktop_bridge/capability_manifest.py create mode 100644 resources/evaos-beta/bridge/src/evaos_desktop_bridge/cli.py create mode 100644 resources/evaos-beta/bridge/src/evaos_desktop_bridge/connector_server.py create mode 100644 resources/evaos-beta/bridge/src/evaos_desktop_bridge/helper_ipc.py create mode 100644 resources/evaos-beta/bridge/src/evaos_desktop_bridge/policy.py create mode 100644 resources/evaos-beta/bridge/src/evaos_desktop_bridge/pre_canary.py create mode 100644 resources/evaos-beta/bridge/src/evaos_desktop_bridge/qa_canary.py create mode 100644 resources/evaos-beta/bridge/src/evaos_desktop_bridge/queue.py create mode 100644 resources/evaos-beta/bridge/src/evaos_desktop_bridge/redaction.py create mode 100644 resources/evaos-beta/bridge/src/evaos_desktop_bridge/schema.py create mode 100644 resources/evaos-beta/bridge/src/evaos_desktop_bridge/state.py create mode 100644 resources/evaos-beta/bridge/src/evaos_desktop_bridge/types.py diff --git a/.github/workflows/_build-reusable.yml b/.github/workflows/_build-reusable.yml index a2c4df3f28..9081f5f647 100644 --- a/.github/workflows/_build-reusable.yml +++ b/.github/workflows/_build-reusable.yml @@ -114,6 +114,7 @@ jobs: run: | SHORT=$(git rev-parse --short HEAD) echo "short=$SHORT" >> $GITHUB_OUTPUT + echo "sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT echo "Commit: $SHORT" - name: Setup Node.js @@ -782,8 +783,7 @@ jobs: GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN || github.token }} EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL: '1' - EVAOS_DESKTOP_BRIDGE_SOURCE_REF: ${{ vars.EVAOS_DESKTOP_BRIDGE_SOURCE_REF }} - EVAOS_DESKTOP_BRIDGE_SOURCE_TOKEN: ${{ secrets.EVAOS_DESKTOP_BRIDGE_SOURCE_TOKEN || secrets.GH_TOKEN || secrets.GITHUB_TOKEN || github.token }} + EVAOS_DESKTOP_BRIDGE_SOURCE_REF: ${{ steps.commit.outputs.sha }} - name: Validate macOS app staple inside DMG if: startsWith(matrix.platform, 'macos') diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 6fc9b51c66..52b4daba7f 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -492,7 +492,6 @@ jobs: CI: true GH_TOKEN: ${{ github.token }} EVAOS_DESKTOP_BRIDGE_ALLOW_PLACEHOLDER: '1' - EVAOS_DESKTOP_BRIDGE_SOURCE_TOKEN: ${{ secrets.EVAOS_DESKTOP_BRIDGE_SOURCE_TOKEN || github.token }} - name: Verify build artifacts exist shell: bash diff --git a/.github/workflows/workbench-functional-smoke.yml b/.github/workflows/workbench-functional-smoke.yml index ebe63955ed..69f6eef6a8 100644 --- a/.github/workflows/workbench-functional-smoke.yml +++ b/.github/workflows/workbench-functional-smoke.yml @@ -16,11 +16,6 @@ on: required: false default: true type: boolean - bridge_ref: - description: evaos-desktop-bridge ref to bundle; defaults to EVAOS_DESKTOP_BRIDGE_SOURCE_REF repo variable - required: false - default: '' - type: string concurrency: group: workbench-functional-smoke-${{ github.event.inputs.ref }} @@ -45,7 +40,6 @@ env: PYTHON_RUNTIME_RELEASE: '20260510' PYTHON_RUNTIME_ARM64_SHA256: '5a30271f8d345a5b02b0c9e4e31e0f1e1455a8e4a04fba95cd9762472abc3b17' PYTHON_RUNTIME_X64_SHA256: 'cd369e76973c3179bc578230d8615ab621968ed758c5e32f636eecef4ad79894' - WORKBENCH_SMOKE_BRIDGE_REF: ${{ inputs.bridge_ref || vars.EVAOS_DESKTOP_BRIDGE_SOURCE_REF }} WORKBENCH_SMOKE_REF: ${{ inputs.ref }} jobs: @@ -70,14 +64,16 @@ jobs: echo "short=$SHORT" >> "$GITHUB_OUTPUT" echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - - name: Validate immutable bridge ref + - name: Bind vendored bridge to checked-out Workbench commit shell: bash run: | set -euo pipefail + WORKBENCH_SMOKE_BRIDGE_REF="${{ steps.commit.outputs.sha }}" if [[ ! "$WORKBENCH_SMOKE_BRIDGE_REF" =~ ^[0-9a-fA-F]{40}$ ]]; then - echo "::error::Workbench functional smoke requires a full immutable evaos-desktop-bridge commit SHA." + echo "::error::Workbench functional smoke requires an exact evaOS-GUI commit SHA." exit 1 fi + echo "WORKBENCH_SMOKE_BRIDGE_REF=$WORKBENCH_SMOKE_BRIDGE_REF" >> "$GITHUB_ENV" - name: Setup Node.js uses: actions/setup-node@v4 @@ -180,9 +176,7 @@ jobs: shell: bash env: AIONUI_MANAGED_RESOURCES_BUNDLE: no-acp - EVAOS_DESKTOP_BRIDGE_DISABLE_DEFAULT_CANDIDATES: '1' EVAOS_DESKTOP_BRIDGE_SOURCE_REF: ${{ env.WORKBENCH_SMOKE_BRIDGE_REF }} - EVAOS_DESKTOP_BRIDGE_SOURCE_TOKEN: ${{ secrets.EVAOS_DESKTOP_BRIDGE_SOURCE_TOKEN || secrets.GH_TOKEN || secrets.GITHUB_TOKEN || github.token }} run: | set -euo pipefail echo "NODE_OPTIONS=${NODE_OPTIONS:-}" diff --git a/CHANGELOG.md b/CHANGELOG.md index 436f94a7b4..25d773fc89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,17 @@ - Removes repeated start actions after an unproven handoff or once Mac Access is already ready, and routes recovery through the existing status refresh. +### Workbench-Owned Mac Bridge + +- Moves the bundled Mac bridge source into evaOS-GUI so Workbench release and + functional-smoke builds no longer clone or require the deprecated bridge + repository. +- Routes current and legacy Workbench focus aliases only to + `/Applications/evaOS Workbench.app`, verifies the `evaOS Workbench` process, + and refuses the legacy `/Applications/evaOS.app` target. +- Binds the packaged bridge source digest and ownership provenance to the exact + evaOS-GUI release commit before signing. + ### Selected-Binding Mac-control Canary - Adds a separately acknowledged, staging-only live canary for the deployed diff --git a/packages/desktop/electron-builder.yml b/packages/desktop/electron-builder.yml index 7794e80620..c68645ba81 100644 --- a/packages/desktop/electron-builder.yml +++ b/packages/desktop/electron-builder.yml @@ -113,7 +113,7 @@ extraResources: to: bundled-aioncore - from: resources/hub to: hub - # evaOS Mac connector bridge, generated from evaos-desktop-bridge by scripts/prepareEvaosDesktopBridgeResource.js + # evaOS-GUI-owned Mac connector bridge, assembled by scripts/prepareEvaosDesktopBridgeResource.js - from: resources/Bridge-${arch} to: Bridge win: diff --git a/resources/evaos-beta/bridge/SOURCE.json b/resources/evaos-beta/bridge/SOURCE.json new file mode 100644 index 0000000000..71c5b75ae8 --- /dev/null +++ b/resources/evaos-beta/bridge/SOURCE.json @@ -0,0 +1,9 @@ +{ + "schema": "evaos-workbench-vendored-bridge-source/v1", + "owner": "100yenadmin/evaOS-GUI", + "sourcePath": "resources/evaos-beta/bridge/src/evaos_desktop_bridge", + "importedFrom": "electricsheephq/evaos-desktop-bridge", + "importedCommit": "9e3b7332a88fbdea22291923bfd10dd37494d92d", + "importedAt": "2026-07-14", + "status": "vendored" +} diff --git a/resources/evaos-beta/bridge/src/evaos_desktop_bridge/__init__.py b/resources/evaos-beta/bridge/src/evaos_desktop_bridge/__init__.py new file mode 100644 index 0000000000..b777c80c64 --- /dev/null +++ b/resources/evaos-beta/bridge/src/evaos_desktop_bridge/__init__.py @@ -0,0 +1,3 @@ +"""evaOS Desktop Bridge.""" + +__version__ = "0.1.0" diff --git a/resources/evaos-beta/bridge/src/evaos_desktop_bridge/adapters/__init__.py b/resources/evaos-beta/bridge/src/evaos_desktop_bridge/adapters/__init__.py new file mode 100644 index 0000000000..7e8e350a0d --- /dev/null +++ b/resources/evaos-beta/bridge/src/evaos_desktop_bridge/adapters/__init__.py @@ -0,0 +1 @@ +"""Desktop app adapters.""" diff --git a/resources/evaos-beta/bridge/src/evaos_desktop_bridge/adapters/codex_app_server.py b/resources/evaos-beta/bridge/src/evaos_desktop_bridge/adapters/codex_app_server.py new file mode 100644 index 0000000000..e5947f45c5 --- /dev/null +++ b/resources/evaos-beta/bridge/src/evaos_desktop_bridge/adapters/codex_app_server.py @@ -0,0 +1,1151 @@ +from __future__ import annotations + +import base64 +import hashlib +import json +import os +import secrets +import select +import signal +import shutil +import socket +import struct +import subprocess +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Callable, Protocol +from urllib.parse import urlparse + +from ..redaction import cap_text, redact_value +from ..schema import make_error +from ..types import CommandResult +from .codex_macos import RunnerResult, run_command + +ALLOWED_APP_SERVER_METHODS = frozenset( + { + "initialize", + "remoteControl/status/read", + "thread/list", + "thread/loaded/list", + "thread/read", + "thread/turns/list", + "getConversationSummary", + } +) + +FORBIDDEN_APP_SERVER_METHODS = frozenset( + { + "turn/start", + "turn/steer", + "turn/interrupt", + "thread/inject_items", + "thread/start", + "thread/resume", + "thread/fork", + "thread/rollback", + "thread/compact/start", + "thread/shellCommand", + "command/exec", + "command/exec/write", + "command/exec/terminate", + "fs/writeFile", + "fs/remove", + "config/value/write", + "config/batchWrite", + "plugin/install", + "plugin/uninstall", + "account/login/start", + "account/logout", + "remoteControl/enable", + "remoteControl/disable", + "remoteControl/approve", + "remoteControl/deny", + } +) + +APP_BUNDLE_CODEX = Path("/Applications/Codex.app/Contents/Resources/codex") +CONTROL_SOCKET_CANDIDATES = ( + Path.home() / ".codex" / "app-server-control" / "app-server-control.sock", + Path.home() / ".codex" / "app-server.sock", +) +CODEX_BIN_ENV = "EVAOS_CODEX_BIN" +TRANSPORT_ENV = "EVAOS_CODEX_APP_SERVER_TRANSPORT" +WS_URL_ENV = "EVAOS_CODEX_APP_SERVER_WS_URL" +SOCKET_PATH_ENV = "EVAOS_CODEX_APP_SERVER_SOCKET" +IDENTIFIER_MAX_CHARS = 240 +STATUS_MAX_CHARS = 1000 +EVENT_METHOD_MAX_CHARS = 160 +SUBSCRIBE_MAX_DURATION_MS = 30_000 +SUBSCRIBE_MAX_EVENTS = 200 +APP_SERVER_TIMEOUT_SECONDS = 10.0 +APP_SERVER_CLIENT_INFO = { + "name": "evaos-desktop-bridge", + "title": "evaOS Desktop Bridge", + "version": "0.6.6", +} +NOTIFICATION_METHODS = frozenset( + { + "turn/started", + "item/agentMessage/delta", + "turn/completed", + "thread/status/changed", + "remoteControl/status/changed", + } +) + + +class JsonRpcTransport(Protocol): + def send_json(self, payload: dict[str, Any]) -> None: + ... + + def read_line(self, deadline: float) -> str | None: + ... + + def close(self) -> None: + ... + + +@dataclass +class JsonRpcResponse: + ok: bool + payload: Any = None + error: str | None = None + notifications: list[dict[str, Any]] = field(default_factory=list) + + +@dataclass(frozen=True) +class TransportConfig: + mode: str + cli: str + ws_url: str | None = None + socket_path: Path | None = None + warnings: tuple[str, ...] = () + + +class LineProcessTransport: + def __init__(self, argv: list[str], *, timeout: float = 10.0) -> None: + self.argv = argv + self.process = subprocess.Popen( + argv, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + bufsize=1, + start_new_session=True, + ) + self.timeout = timeout + + def send_json(self, payload: dict[str, Any]) -> None: + if self.process.stdin is None: + raise RuntimeError("codex app-server stdin is unavailable") + self.process.stdin.write(json.dumps(payload, separators=(",", ":")) + "\n") + self.process.stdin.flush() + + def read_line(self, deadline: float) -> str | None: + if self.process.stdout is None: + return None + while time.monotonic() < deadline: + timeout = max(0.0, min(0.05, deadline - time.monotonic())) + ready, _, _ = select.select([self.process.stdout], [], [], timeout) + if ready: + line = self.process.stdout.readline() + if line: + return line.strip() + break + if self.process.poll() is not None: + break + return None + + def close(self) -> None: + _close_process_group(self.process) + + +class ProxyWebSocketProcessTransport: + def __init__(self, argv: list[str], *, timeout: float = 10.0) -> None: + self.argv = argv + self.process = subprocess.Popen( + argv, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=False, + bufsize=0, + start_new_session=True, + ) + self.timeout = timeout + self._buffer = b"" + try: + self._handshake() + except Exception: + self.close() + raise + + def _handshake(self) -> None: + key = base64.b64encode(secrets.token_bytes(16)).decode("ascii") + request = ( + "GET / HTTP/1.1\r\n" + "Host: localhost\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + f"Sec-WebSocket-Key: {key}\r\n" + "Sec-WebSocket-Version: 13\r\n" + "\r\n" + ).encode("ascii") + self._write_raw(request) + response = self._recv_until(b"\r\n\r\n", time.monotonic() + self.timeout).decode( + "latin1", + errors="replace", + ) + expected_accept = base64.b64encode( + hashlib.sha1((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode("ascii")).digest() + ).decode("ascii") + if " 101 " not in response or expected_accept not in response: + raise RuntimeError("Codex app-server proxy websocket handshake failed") + + def send_json(self, payload: dict[str, Any]) -> None: + encoded = json.dumps(payload, separators=(",", ":")).encode("utf-8") + self._write_raw(_build_websocket_frame(encoded, opcode=0x1)) + + def read_line(self, deadline: float) -> str | None: + while time.monotonic() < deadline: + try: + message = self._read_message(deadline) + except TimeoutError: + return None + if message is not None: + return message + return None + + def _read_message(self, deadline: float) -> str | None: + header = self._recv_exact(2, deadline) + if header is None: + raise TimeoutError + first, second = header + opcode = first & 0x0F + masked = bool(second & 0x80) + length = second & 0x7F + if length == 126: + extended = self._recv_exact(2, deadline) + if extended is None: + raise TimeoutError + length = struct.unpack("!H", extended)[0] + elif length == 127: + extended = self._recv_exact(8, deadline) + if extended is None: + raise TimeoutError + length = struct.unpack("!Q", extended)[0] + mask_key = self._recv_exact(4, deadline) if masked else None + payload = self._recv_exact(length, deadline) if length else b"" + if payload is None: + raise TimeoutError + if mask_key: + payload = bytes(byte ^ mask_key[index % 4] for index, byte in enumerate(payload)) + if opcode == 0x8: + return None + if opcode == 0x9: + self._write_raw(_build_websocket_frame(payload, opcode=0xA)) + return None + if opcode not in {0x1, 0x0}: + return None + return payload.decode("utf-8", errors="replace") + + def _recv_until(self, marker: bytes, deadline: float) -> bytes: + while marker not in self._buffer and time.monotonic() < deadline: + chunk = self._read_available(deadline) + if not chunk: + break + self._buffer += chunk + if marker not in self._buffer: + raise TimeoutError("Timed out waiting for Codex app-server proxy websocket handshake") + end = self._buffer.index(marker) + len(marker) + payload = self._buffer[:end] + self._buffer = self._buffer[end:] + return payload + + def _recv_exact(self, size: int, deadline: float) -> bytes | None: + while len(self._buffer) < size and time.monotonic() < deadline: + chunk = self._read_available(deadline) + if not chunk: + break + self._buffer += chunk + if len(self._buffer) < size: + return None + payload = self._buffer[:size] + self._buffer = self._buffer[size:] + return payload + + def _read_available(self, deadline: float) -> bytes: + if self.process.stdout is None: + return b"" + if self.process.poll() is not None: + return b"" + timeout = max(0.0, min(0.05, deadline - time.monotonic())) + ready, _, _ = select.select([self.process.stdout], [], [], timeout) + if not ready: + return b"" + try: + return os.read(self.process.stdout.fileno(), 4096) + except OSError: + return b"" + + def _write_raw(self, payload: bytes) -> None: + if self.process.stdin is None: + raise RuntimeError("codex app-server proxy stdin is unavailable") + self.process.stdin.write(payload) + self.process.stdin.flush() + + def close(self) -> None: + try: + self._write_raw(_build_websocket_frame(b"", opcode=0x8)) + except (BrokenPipeError, OSError, RuntimeError): + pass + _close_process_group(self.process) + + +class WebSocketTransport: + def __init__(self, url: str, *, timeout: float = 10.0) -> None: + parsed = urlparse(url) + if parsed.scheme != "ws" or not _is_loopback_host(parsed.hostname): + raise ValueError("Codex app-server websocket URLs must be loopback ws:// endpoints") + if parsed.path not in {"", "/"}: + raise ValueError("Codex app-server websocket URL must not include a path") + port = parsed.port + if port is None: + raise ValueError("Codex app-server websocket URL must include a port") + self.timeout = timeout + self._buffer = b"" + self.sock = socket.create_connection((parsed.hostname or "127.0.0.1", port), timeout=timeout) + try: + self.sock.settimeout(timeout) + self._handshake(parsed.hostname or "127.0.0.1", port) + except Exception: + try: + self.sock.close() + except OSError: + pass + raise + + def _handshake(self, host: str, port: int) -> None: + key = base64.b64encode(secrets.token_bytes(16)).decode("ascii") + request = ( + f"GET / HTTP/1.1\r\n" + f"Host: {host}:{port}\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + f"Sec-WebSocket-Key: {key}\r\n" + "Sec-WebSocket-Version: 13\r\n" + "\r\n" + ) + self.sock.sendall(request.encode("ascii")) + response_bytes = b"" + deadline = time.monotonic() + self.timeout + while b"\r\n\r\n" not in response_bytes and time.monotonic() < deadline: + self.sock.settimeout(max(0.01, deadline - time.monotonic())) + chunk = self.sock.recv(4096) + if not chunk: + break + response_bytes += chunk + header, separator, extra = response_bytes.partition(b"\r\n\r\n") + if not separator: + raise RuntimeError("Codex app-server websocket handshake failed") + self._buffer = extra + response = header.decode("latin1", errors="replace") + expected_accept = base64.b64encode( + hashlib.sha1((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode("ascii")).digest() + ).decode("ascii") + if " 101 " not in response or expected_accept not in response: + raise RuntimeError("Codex app-server websocket handshake failed") + + def send_json(self, payload: dict[str, Any]) -> None: + encoded = json.dumps(payload, separators=(",", ":")).encode("utf-8") + self.sock.sendall(_build_websocket_frame(encoded, opcode=0x1)) + + def read_line(self, deadline: float) -> str | None: + while time.monotonic() < deadline: + try: + message = self._read_message(deadline) + except TimeoutError: + return None + if message is not None: + return message + return None + + def _read_message(self, deadline: float) -> str | None: + header = self._recv_exact(2, deadline) + if header is None: + raise TimeoutError + first, second = header + opcode = first & 0x0F + masked = bool(second & 0x80) + length = second & 0x7F + if length == 126: + extended = self._recv_exact(2, deadline) + if extended is None: + raise TimeoutError + length = struct.unpack("!H", extended)[0] + elif length == 127: + extended = self._recv_exact(8, deadline) + if extended is None: + raise TimeoutError + length = struct.unpack("!Q", extended)[0] + mask_key = self._recv_exact(4, deadline) if masked else None + payload = self._recv_exact(length, deadline) if length else b"" + if payload is None: + raise TimeoutError + if mask_key: + payload = bytes(byte ^ mask_key[index % 4] for index, byte in enumerate(payload)) + if opcode == 0x8: + return None + if opcode == 0x9: + self.sock.sendall(_build_websocket_frame(payload, opcode=0xA)) + return None + if opcode not in {0x1, 0x0}: + return None + return payload.decode("utf-8", errors="replace") + + def _recv_exact(self, size: int, deadline: float) -> bytes | None: + while len(self._buffer) < size and time.monotonic() < deadline: + self.sock.settimeout(max(0.01, deadline - time.monotonic())) + chunk = self.sock.recv(size - len(self._buffer)) + if not chunk: + return None + self._buffer += chunk + if len(self._buffer) < size: + return None + payload = self._buffer[:size] + self._buffer = self._buffer[size:] + return payload + + def close(self) -> None: + try: + self.sock.sendall(_build_websocket_frame(b"", opcode=0x8)) + except OSError: + pass + self.sock.close() + + +def _build_websocket_frame(payload: bytes, *, opcode: int = 0x1, mask_key: bytes | None = None) -> bytes: + key = mask_key or secrets.token_bytes(4) + if len(key) != 4: + raise ValueError("websocket mask key must be 4 bytes") + first = 0x80 | (opcode & 0x0F) + length = len(payload) + if length < 126: + header = struct.pack("!BB", first, 0x80 | length) + elif length <= 0xFFFF: + header = struct.pack("!BBH", first, 0x80 | 126, length) + else: + header = struct.pack("!BBQ", first, 0x80 | 127, length) + masked = bytes(byte ^ key[index % 4] for index, byte in enumerate(payload)) + return header + key + masked + + +def _close_process_group(process: subprocess.Popen[Any]) -> None: + for stream in (process.stdin,): + try: + if stream is not None: + stream.close() + except Exception: + pass + if process.poll() is None: + _signal_process_group(process, signal.SIGTERM) + if process.poll() is None: + try: + process.wait(timeout=1.0) + except subprocess.TimeoutExpired: + _signal_process_group(process, signal.SIGKILL) + process.wait(timeout=1.0) + for stream in (process.stdout, process.stderr): + try: + if stream is not None: + stream.close() + except Exception: + pass + + +def _signal_process_group(process: subprocess.Popen[Any], sig: signal.Signals) -> None: + try: + os.killpg(process.pid, sig) + except ProcessLookupError: + return + except Exception: + try: + process.send_signal(sig) + except Exception: + pass + + +class CodexJsonRpcClient: + def __init__(self, transport_factory: Callable[[], JsonRpcTransport], *, timeout: float = 10.0) -> None: + self.transport_factory = transport_factory + self.timeout = timeout + self.transport: JsonRpcTransport | None = None + self._next_id = 1 + self.notifications: list[dict[str, Any]] = [] + self.initialize_payload: Any = None + + def __enter__(self) -> CodexJsonRpcClient: + self.transport = self.transport_factory() + try: + init_response = self._request_raw( + "initialize", + { + "clientInfo": APP_SERVER_CLIENT_INFO, + "capabilities": { + "experimentalApi": True, + "requestAttestation": False, + "optOutNotificationMethods": [], + }, + }, + ) + if not init_response.ok: + raise RuntimeError(init_response.error or "Codex app-server initialize failed") + self.initialize_payload = init_response.payload + self._send_notification("initialized") + except Exception: + if self.transport is not None: + try: + self.transport.close() + except Exception: + pass + self.transport = None + raise + return self + + def __exit__(self, exc_type: object, exc: object, tb: object) -> None: + if self.transport is not None: + self.transport.close() + self.transport = None + + def request(self, method: str, params: dict[str, Any] | None = None) -> JsonRpcResponse: + return self._request_raw(method, params or {}) + + def collect_notifications(self, *, duration_ms: int, max_events: int) -> list[dict[str, Any]]: + if self.transport is None: + raise RuntimeError("Codex JSON-RPC client is not connected") + deadline = time.monotonic() + max(0, duration_ms) / 1000 + events: list[dict[str, Any]] = [] + while time.monotonic() < deadline and len(events) < max_events: + line = self.transport.read_line(deadline) + if line is None: + continue + payload = self._parse_payload(line) + if payload is None: + continue + notification = self._notification_from_payload(payload) + if notification is not None: + self.notifications.append(notification) + events.append(notification) + return events + + def _send_notification(self, method: str) -> None: + if self.transport is None: + raise RuntimeError("Codex JSON-RPC client is not connected") + self.transport.send_json({"jsonrpc": "2.0", "method": method}) + + def _request_raw(self, method: str, params: dict[str, Any]) -> JsonRpcResponse: + if self.transport is None: + raise RuntimeError("Codex JSON-RPC client is not connected") + request_id = self._next_id + self._next_id += 1 + self.transport.send_json({"jsonrpc": "2.0", "id": request_id, "method": method, "params": params}) + deadline = time.monotonic() + self.timeout + while time.monotonic() < deadline: + line = self.transport.read_line(deadline) + if line is None: + continue + payload = self._parse_payload(line) + if payload is None: + continue + notification = self._notification_from_payload(payload) + if notification is not None: + self.notifications.append(notification) + continue + if payload.get("id") != request_id: + continue + if "error" in payload: + return JsonRpcResponse(ok=False, error=str(redact_value(payload["error"])), notifications=list(self.notifications)) + if "result" in payload: + return JsonRpcResponse(ok=True, payload=payload["result"], notifications=list(self.notifications)) + return JsonRpcResponse(ok=True, payload=payload, notifications=list(self.notifications)) + return JsonRpcResponse(ok=False, error=f"Timed out waiting for {method}", notifications=list(self.notifications)) + + def _parse_payload(self, line: str) -> dict[str, Any] | None: + try: + payload = json.loads(line) + except json.JSONDecodeError: + return None + return payload if isinstance(payload, dict) else None + + def _notification_from_payload(self, payload: dict[str, Any]) -> dict[str, Any] | None: + method = payload.get("method") + if not isinstance(method, str) or "id" in payload: + return None + params = payload.get("params") + return { + "method": method, + "params": params if isinstance(params, dict) else {}, + } + + +class CodexAppServerObserver: + def __init__( + self, + *, + runner: Callable[[list[str], float], RunnerResult] = run_command, + rpc_client: Callable[[str, dict[str, Any]], JsonRpcResponse] | None = None, + ) -> None: + self.runner = runner + self._custom_rpc_client = rpc_client is not None + self.rpc_client = rpc_client + + def status(self) -> CommandResult: + config = self._transport_config() + version = self._run([config.cli, "--version"], 5.0) + help_result = self._run([config.cli, "app-server", "--help"], 5.0) + cli_available = version.returncode == 0 and help_result.returncode == 0 + warnings = list(config.warnings) + cli_alignment = self._cli_alignment(config) + if cli_alignment["path_mismatch"] or cli_alignment["version_mismatch"]: + warnings.append("System codex differs from the Codex.app bundled CLI; evaOS Desktop Bridge is using the selected_cli path.") + rpc_probe: CommandResult | None = None + rpc_handshake_ok = False + if cli_available: + rpc_probe = self._probe_app_server(config) + rpc_handshake_ok = rpc_probe.ok + if not rpc_handshake_ok: + warnings.append("Codex app-server RPC handshake failed.") + else: + warnings.append("Codex app-server CLI is unavailable or not executable.") + codex_version = redact_value(version.stdout.strip()) if version.returncode == 0 else None + return CommandResult( + ok=True, + data={ + "available": cli_available and rpc_handshake_ok, + "cli_available": cli_available, + "rpc_handshake_ok": rpc_handshake_ok, + "selected_cli": { + "path": redact_value(config.cli), + "version": codex_version, + }, + "cli_alignment": cli_alignment, + "codex_version": codex_version, + "transport": config.mode, + "websocket_url": redact_value(config.ws_url), + "socket_path": redact_value(config.socket_path) if config.socket_path is not None else None, + "allowed_methods": sorted(ALLOWED_APP_SERVER_METHODS), + "controller_methods": [], + "forbidden_methods": sorted(FORBIDDEN_APP_SERVER_METHODS), + "read_only": True, + "rpc_probe": { + "method": "initialize", + "ok": rpc_handshake_ok, + "errors": rpc_probe.errors if rpc_probe is not None and not rpc_probe.ok else [], + }, + }, + warnings=warnings, + provenance={"source": "app_server"}, + ) + + def connections_status(self) -> CommandResult: + config = self._transport_config() + cli_alignment = self._cli_alignment(config) + system_cli_path = cli_alignment["system_cli"]["path"] + system_version = cli_alignment["system_cli"]["version"] + app_bundle_version = cli_alignment["app_bundle_cli"]["version"] + remote_help = self._run([config.cli, "remote-control", "--help"], 5.0) + daemon_version = self._run([config.cli, "app-server", "daemon", "version"], 5.0) + control_sockets = [{"path": redact_value(path), "exists": path.exists()} for path in CONTROL_SOCKET_CANDIDATES] + control_socket_ready = any(item["exists"] for item in control_sockets) + transport_status = self._probe_app_server(config) + remote_status = self.request("remoteControl/status/read", {}, cli=config.cli) + handshake_ok = transport_status.ok + connections_state = self._connections_state(remote_status.data if remote_status.ok else None) + warnings = list(config.warnings) + if cli_alignment["path_mismatch"] or cli_alignment["version_mismatch"]: + warnings.append("System codex differs from the Codex.app bundled CLI; evaOS Desktop Bridge is using the selected_cli path.") + if config.mode == "stdio": + warnings.append("Default stdio transport starts an isolated app-server process; loaded-thread results do not prove Codex Desktop UI attachment.") + if config.mode == "proxy" and not control_socket_ready: + warnings.append("Proxy transport was selected but no Codex app-server control socket is present.") + if remote_help.returncode != 0: + warnings.append("Codex native remote-control command was not detected.") + if config.mode == "websocket" and config.ws_url is None: + warnings.append("Websocket transport selected but no loopback websocket URL is configured.") + return CommandResult( + ok=True, + data={ + "desktop": { + "app_bundle_cli": { + "path": redact_value(APP_BUNDLE_CODEX), + "exists": APP_BUNDLE_CODEX.exists(), + "version": app_bundle_version, + }, + "system_cli": {"available": system_version is not None, "path": system_cli_path, "version": system_version}, + "cli_alignment": cli_alignment, + }, + "app_server": { + "available": handshake_ok, + "preferred_cli": redact_value(config.cli), + "transport": config.mode, + "socket_path": redact_value(config.socket_path) if config.socket_path is not None else None, + "handshake": "ok" if handshake_ok else "unavailable", + "initialize": transport_status.data if transport_status.ok else None, + "error": transport_status.errors[0]["message"] if transport_status.errors else None, + "loaded_thread_scope": "per_app_server_process_memory", + "stdio_isolated": config.mode == "stdio", + }, + "remote_control": { + "supported": remote_help.returncode == 0, + "status": remote_status.data if remote_status.ok else None, + "available": remote_status.ok, + "connections_state": connections_state, + "errors": remote_status.errors, + }, + "connections_state": connections_state, + "remote_control_command": { + "supported": remote_help.returncode == 0, + "checked_cli": redact_value(config.cli), + }, + "daemon": { + "version_available": daemon_version.returncode == 0, + "version_output": redact_value(daemon_version.stdout.strip()) if daemon_version.returncode == 0 else None, + "control_socket_ready": control_socket_ready, + "ready": daemon_version.returncode == 0 and control_socket_ready, + }, + "control_sockets": control_sockets, + "websocket": { + "configured": config.ws_url is not None, + "url": redact_value(config.ws_url), + "loopback_only": True, + "ready": handshake_ok and config.mode == "websocket", + }, + "live_notifications": { + "supported": True, + "methods": sorted(NOTIFICATION_METHODS), + }, + "safety": { + "read_only_default": True, + "controller_requires_confirmation": True, + "controller_requires_source_audit_id": True, + "generic_app_server_passthrough": False, + "session_db_reads": False, + }, + }, + warnings=warnings, + provenance={"source": "app_server", "app_server_method": "initialize,remoteControl/status/read"}, + ) + + def threads(self, *, max_items: int) -> CommandResult: + response = self.request("thread/list", {"limit": max_items}) + if not response.ok: + return response + raw_threads = _extract_result_array(response.data, keys=("threads", "items", "data")) + threads = [self._safe_thread(row, index) for index, row in enumerate(raw_threads[:max_items])] + return CommandResult( + ok=True, + data={ + "threads": threads, + "count": len(threads), + "max_items": max_items, + "source": "app_server", + "thread_state": "active" if threads else "idle", + }, + warnings=response.warnings, + provenance={"source": "app_server", "app_server_method": "thread/list"}, + ) + + def loaded_threads(self, *, max_items: int) -> CommandResult: + config = self._transport_config() + response = self.request("thread/loaded/list", {"limit": max_items}) + if not response.ok: + return response + warnings = list(response.warnings) + if config.mode == "stdio": + warnings.append("Loaded-thread inventory is scoped to the bridge's isolated stdio app-server process, not necessarily Codex Desktop's visible UI process.") + raw_threads = _extract_result_array(response.data, keys=("threads", "items", "data")) + threads = [ + { + "index": index, + "id": _cap_redacted_scalar(_thread_id_from_loaded_row(row), IDENTIFIER_MAX_CHARS), + "source": "app_server_loaded", + } + for index, row in enumerate(raw_threads[:max_items]) + ] + return CommandResult( + ok=True, + data={ + "threads": threads, + "count": len(threads), + "max_items": max_items, + "source": "app_server", + "thread_state": "active" if threads else "idle", + "transport": config.mode, + "socket_path": redact_value(config.socket_path) if config.socket_path is not None else None, + "loaded_thread_scope": "per_app_server_process_memory", + "stdio_isolated": config.mode == "stdio", + }, + warnings=warnings, + provenance={"source": "app_server", "app_server_method": "thread/loaded/list"}, + ) + + def subscribe(self, *, thread_id: str, duration_ms: int, max_chars: int = 4000) -> CommandResult: + validation_error = self._validate_identifier("thread_id", thread_id) + if validation_error is not None: + return validation_error + duration_ms = min(max(duration_ms, 1), SUBSCRIBE_MAX_DURATION_MS) + if self._custom_rpc_client and self.rpc_client is not None: + read_response = self.request("thread/read", {"threadId": thread_id, "includeTurns": False}) + if not read_response.ok: + return read_response + return CommandResult( + ok=True, + data={ + "thread_id": _cap_redacted_scalar(thread_id, IDENTIFIER_MAX_CHARS), + "duration_ms": duration_ms, + "events": [], + "event_count": 0, + "max_chars": max_chars, + "source": "app_server_read", + }, + provenance={"source": "app_server", "app_server_method": "thread/read"}, + ) + config = self._transport_config() + try: + with self._json_rpc_client(config) as client: + read_response = client.request("thread/read", {"threadId": thread_id, "includeTurns": False}) + if not read_response.ok: + return self._rpc_error_result("thread/read", read_response) + events = client.collect_notifications(duration_ms=duration_ms, max_events=SUBSCRIBE_MAX_EVENTS) + except Exception as exc: + return self._exception_result("thread/read", exc) + safe_events = [self._safe_event(event, max_chars=max_chars) for event in events] + return CommandResult( + ok=True, + data={ + "thread_id": _cap_redacted_scalar(thread_id, IDENTIFIER_MAX_CHARS), + "duration_ms": duration_ms, + "events": safe_events, + "event_count": len(safe_events), + "max_chars": max_chars, + "source": "app_server_read", + }, + provenance={"source": "app_server", "app_server_method": "thread/read"}, + ) + + def remote_control_status(self) -> CommandResult: + connections = self.connections_status() + data = { + "preferred_path": "codex_native_remote_control", + **connections.data, + "remote_control_status_read": { + "ok": connections.data.get("remote_control", {}).get("available") is True, + "data": connections.data.get("remote_control", {}).get("status"), + "errors": connections.data.get("remote_control", {}).get("errors", []), + }, + "safety": { + **connections.data.get("safety", {}), + "read_only_probe": True, + "native_remote_control_preferred": True, + "generic_app_server_mutations_exposed": False, + }, + } + return CommandResult( + ok=True, + data=data, + warnings=connections.warnings, + provenance={"source": "codex_native_remote_control", "read_only": True}, + ) + + def request(self, method: str, params: dict[str, Any] | None = None, *, cli: str | None = None) -> CommandResult: + if method not in ALLOWED_APP_SERVER_METHODS: + return self._method_not_allowed_result(method) + rpc = self._rpc(method, params or {}, cli=cli) + if not rpc.ok: + return self._rpc_error_result(method, rpc) + return CommandResult( + ok=True, + data=redact_value(rpc.payload if isinstance(rpc.payload, dict) else {"result": rpc.payload}), + provenance={"source": "app_server", "app_server_method": method}, + ) + + def _rpc(self, method: str, params: dict[str, Any], *, cli: str | None = None) -> JsonRpcResponse: + if self._custom_rpc_client and self.rpc_client is not None: + return self.rpc_client(method, params) + config = self._transport_config(cli=cli) + try: + with self._json_rpc_client(config) as client: + return client.request(method, params) + except Exception as exc: + return JsonRpcResponse(ok=False, error=str(exc)) + + def _probe_app_server(self, config: TransportConfig) -> CommandResult: + if self._custom_rpc_client and self.rpc_client is not None: + rpc = self.rpc_client("initialize", {}) + if not rpc.ok: + return self._rpc_error_result("initialize", rpc) + payload = rpc.payload if isinstance(rpc.payload, dict) else {"result": rpc.payload} + return CommandResult(ok=True, data=redact_value(payload), provenance={"source": "app_server", "app_server_method": "initialize"}) + try: + with self._json_rpc_client(config) as client: + payload = client.initialize_payload if isinstance(client.initialize_payload, dict) else {"result": client.initialize_payload} + return CommandResult(ok=True, data=redact_value(payload), provenance={"source": "app_server", "app_server_method": "initialize"}) + except Exception as exc: + return self._rpc_error_result("initialize", JsonRpcResponse(ok=False, error=str(exc))) + + def _json_rpc_client(self, config: TransportConfig) -> CodexJsonRpcClient: + return CodexJsonRpcClient(lambda: self._transport(config), timeout=10.0) + + def _transport(self, config: TransportConfig) -> JsonRpcTransport: + if config.mode == "websocket": + if config.ws_url is None: + raise RuntimeError("No Codex app-server websocket URL configured") + return WebSocketTransport(config.ws_url) + if config.mode == "proxy": + if config.socket_path is None: + raise RuntimeError("No Codex app-server control socket configured or found for proxy transport") + if not config.socket_path.exists(): + raise RuntimeError(f"Codex app-server control socket does not exist: {config.socket_path}") + argv = [config.cli, "app-server", "proxy"] + argv.extend(["--sock", str(config.socket_path)]) + return ProxyWebSocketProcessTransport(argv) + return LineProcessTransport([config.cli, "app-server", "--listen", "stdio://"]) + + def _transport_config(self, *, cli: str | None = None) -> TransportConfig: + warnings: list[str] = [] + preferred_cli = cli or self._resolve_codex_bin() + mode = os.environ.get(TRANSPORT_ENV, "").strip().lower() + ws_url = os.environ.get(WS_URL_ENV, "").strip() or None + socket_value = os.environ.get(SOCKET_PATH_ENV, "").strip() or None + socket_path = Path(socket_value).expanduser() if socket_value else None + if mode and mode not in {"stdio", "proxy", "websocket"}: + warnings.append(f"Ignoring invalid {TRANSPORT_ENV}={mode!r}; expected stdio, proxy, or websocket.") + mode = "" + if not mode: + if ws_url: + mode = "websocket" + elif socket_path is not None: + mode = "proxy" + else: + mode = "stdio" + if mode == "proxy" and socket_path is None: + socket_path = next((candidate for candidate in CONTROL_SOCKET_CANDIDATES if candidate.exists()), None) + if socket_path is None: + warnings.append("Proxy transport selected but no Codex app-server control socket was configured or found.") + if ws_url is not None and not self._valid_loopback_ws_url(ws_url): + warnings.append(f"Ignoring non-loopback {WS_URL_ENV}; only ws://127.0.0.1:PORT or ws://localhost:PORT is allowed.") + ws_url = None + if mode == "websocket": + mode = "stdio" + return TransportConfig(mode=mode, cli=preferred_cli, ws_url=ws_url, socket_path=socket_path, warnings=tuple(warnings)) + + def _resolve_codex_bin(self) -> str: + env_bin = os.environ.get(CODEX_BIN_ENV, "").strip() + if env_bin: + return env_bin + if APP_BUNDLE_CODEX.exists(): + return str(APP_BUNDLE_CODEX) + return shutil.which("codex") or "codex" + + def _command_version(self, command: list[str]) -> str | None: + result = self._run(command, 5.0) + if result.returncode != 0: + return None + return str(redact_value(result.stdout.strip())) or None + + def _cli_alignment(self, config: TransportConfig) -> dict[str, Any]: + system_cli_path = shutil.which("codex") + system_version = self._command_version(["codex", "--version"]) if system_cli_path is not None else None + app_bundle_exists = APP_BUNDLE_CODEX.exists() + app_bundle_version = self._command_version([str(APP_BUNDLE_CODEX), "--version"]) if app_bundle_exists else None + selected_path = config.cli + return { + "selected_cli": { + "path": redact_value(selected_path), + "is_app_bundle": selected_path == str(APP_BUNDLE_CODEX), + }, + "app_bundle_cli": { + "path": redact_value(APP_BUNDLE_CODEX), + "exists": app_bundle_exists, + "version": app_bundle_version, + }, + "system_cli": { + "available": system_cli_path is not None, + "path": redact_value(system_cli_path) if system_cli_path is not None else None, + "version": system_version, + }, + "path_mismatch": bool(app_bundle_exists and system_cli_path and system_cli_path != str(APP_BUNDLE_CODEX)), + "version_mismatch": bool(system_version and app_bundle_version and system_version != app_bundle_version), + "bridge_prefers_app_bundle": APP_BUNDLE_CODEX.exists() and not os.environ.get(CODEX_BIN_ENV, "").strip(), + } + + def _stdio_rpc(self, method: str, params: dict[str, Any], *, cli: str = "codex") -> JsonRpcResponse: + try: + with self._json_rpc_client(TransportConfig(mode="stdio", cli=cli)) as client: + if method == "initialize": + return JsonRpcResponse(ok=True, payload=client.initialize_payload, notifications=list(client.notifications)) + return client.request(method, params) + except Exception as exc: + return JsonRpcResponse(ok=False, error=str(exc)) + + def _close_stdio_process(self, process: subprocess.Popen[Any] | None) -> None: + if process is None: + return + _close_process_group(process) + + def _signal_process_group(self, process: subprocess.Popen[Any], sig: signal.Signals) -> None: + _signal_process_group(process, sig) + + def _run(self, command: list[str], timeout: float) -> RunnerResult: + try: + return self.runner(command, timeout) + except FileNotFoundError as exc: + return RunnerResult(returncode=127, stdout="", stderr=str(exc)) + + def _connections_state(self, payload: Any) -> str: + if not isinstance(payload, dict): + return "unavailable" + raw_state = payload.get("status") or payload.get("state") or payload.get("connectionState") + if not isinstance(raw_state, str): + return "unavailable" + normalized = raw_state.lower().replace("_", "-") + if normalized in {"disabled", "connecting", "connected", "errored", "unavailable"}: + return normalized + return "unavailable" + + def _safe_thread(self, row: Any, index: int) -> dict[str, Any]: + if not isinstance(row, dict): + row = {"value": row} + title = row.get("name") or row.get("title") or row.get("thread_name") or row.get("summary") or row.get("preview") or f"Thread {index + 1}" + capped_title, title_truncated = cap_text(str(redact_value(title)), 160) + thread_id = row.get("id") or row.get("thread_id") or row.get("threadId") + updated_at = row.get("updated_at") or row.get("updatedAt") + status = row.get("status") or row.get("state") + return { + "index": index, + "id": _cap_redacted_scalar(thread_id, IDENTIFIER_MAX_CHARS), + "title": capped_title, + "title_truncated": title_truncated, + "updated_at": None if updated_at is None else redact_value(str(updated_at)), + "status": _cap_redacted_value(status, STATUS_MAX_CHARS), + "source": "app_server", + } + + def _safe_event(self, event: dict[str, Any], *, max_chars: int) -> dict[str, Any]: + params = redact_value(event.get("params", {})) + text = json.dumps(params, sort_keys=True, default=str) + capped, truncated = cap_text(text, max_chars) + return { + "method": _cap_redacted_scalar(event.get("method"), EVENT_METHOD_MAX_CHARS), + "params_json": capped, + "params_truncated": truncated, + "source": "app_server_notification", + } + + def _validate_identifier(self, field: str, value: str | None) -> CommandResult | None: + if not isinstance(value, str) or not value.strip(): + return self._simple_error( + f"{field}_required", + f"Codex remote-control requires an explicit {field.replace('_', ' ')}.", + f"Pass --{field.replace('_', '-')} from a current Codex app-server observation.", + ) + if len(value) > 240 or any(char.isspace() for char in value): + return self._simple_error( + f"{field}_invalid", + f"Codex remote-control {field.replace('_', ' ')} is malformed.", + "Use the exact id returned by loaded-threads or live notifications.", + ) + return None + + def _method_not_allowed_result(self, method: str) -> CommandResult: + return CommandResult( + ok=False, + errors=[ + make_error( + code="app_server_method_not_allowed", + message=f"Codex app-server method '{method}' is outside the read-only allowlist.", + guidance="Use only named bridge commands; generic app-server RPC passthrough is disabled.", + ) + ], + provenance={"source": "app_server", "app_server_method": method}, + ) + + def _rpc_error_result(self, method: str, rpc: JsonRpcResponse) -> CommandResult: + return CommandResult( + ok=False, + data={"method": method}, + errors=[ + make_error( + code="app_server_unavailable", + message=rpc.error or "Unable to read from Codex app-server.", + guidance="Ensure Codex Desktop is running and the selected app-server transport is reachable.", + ) + ], + provenance={"source": "app_server", "app_server_method": method}, + ) + + def _exception_result(self, method: str, exc: Exception) -> CommandResult: + return self._rpc_error_result(method, JsonRpcResponse(ok=False, error=str(exc))) + + def _simple_error(self, code: str, message: str, guidance: str) -> CommandResult: + return CommandResult(ok=False, errors=[make_error(code=code, message=message, guidance=guidance)]) + + def _valid_loopback_ws_url(self, url: str) -> bool: + parsed = urlparse(url) + return parsed.scheme == "ws" and parsed.port is not None and _is_loopback_host(parsed.hostname) and parsed.path in {"", "/"} + + +def _extract_result_array(payload: dict[str, Any], *, keys: tuple[str, ...]) -> list[Any]: + for key in keys: + value = payload.get(key) + if isinstance(value, list): + return value + result = payload.get("result") + if isinstance(result, dict): + for key in keys: + value = result.get(key) + if isinstance(value, list): + return value + if isinstance(result, list): + return result + return [] + + +def _thread_id_from_loaded_row(row: Any) -> Any: + if isinstance(row, dict): + return row.get("id") or row.get("thread_id") or row.get("threadId") + return row + + +def _cap_redacted_scalar(value: Any, max_chars: int) -> str | None: + if value is None: + return None + capped, _ = cap_text(str(redact_value(value)), max_chars) + return capped + + +def _cap_redacted_value(value: Any, max_chars: int) -> Any: + redacted = redact_value(value) + if isinstance(redacted, str): + capped, _ = cap_text(redacted, max_chars) + return capped + if isinstance(redacted, dict): + capped_dict: dict[str, Any] = {} + for key, item in list(redacted.items())[:50]: + capped_key, _ = cap_text(str(redact_value(key)), IDENTIFIER_MAX_CHARS) + capped_dict[capped_key] = _cap_redacted_value(item, max_chars) + return capped_dict + if isinstance(redacted, list): + return [_cap_redacted_value(item, max_chars) for item in redacted[:50]] + return redacted + + +def _is_loopback_host(host: str | None) -> bool: + if host in {"localhost", "127.0.0.1", "::1"}: + return True + try: + return socket.gethostbyname(host or "") == "127.0.0.1" + except OSError: + return False diff --git a/resources/evaos-beta/bridge/src/evaos_desktop_bridge/adapters/codex_macos.py b/resources/evaos-beta/bridge/src/evaos_desktop_bridge/adapters/codex_macos.py new file mode 100644 index 0000000000..e985201223 --- /dev/null +++ b/resources/evaos-beta/bridge/src/evaos_desktop_bridge/adapters/codex_macos.py @@ -0,0 +1,1684 @@ +from __future__ import annotations + +import ctypes +import hashlib +import json +import os +import platform +import re +import subprocess +import sys +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable + +from ..audit import default_state_dir +from ..redaction import cap_text, redact_value +from ..schema import make_error, timestamp_utc +from ..types import CommandResult + +ACCESSIBILITY_GUIDANCE = ( + "Open System Settings > Privacy & Security > Accessibility and enable the terminal " + "or app running evaos-desktop-bridge, then rerun the command." +) +SCREEN_RECORDING_GUIDANCE = ( + "Open System Settings > Privacy & Security > Screen Recording and enable the terminal " + "or app running evaos-desktop-bridge, then rerun the command." +) +SUPPORT_CANARY_ENV = "EVAOS_SUPPORT_CANARY_CONTROLS" +VISIBLE_MESSAGE_MAX_CHARS = 4000 +VISIBLE_MESSAGE_WAIT_MAX_MS = 120_000 +VISIBLE_MESSAGE_WAIT_POLL_MIN_MS = 250 +VISIBLE_MESSAGE_WAIT_POLL_MAX_MS = 10_000 +VISIBLE_MESSAGE_WAIT_OBSERVATION_MAX = 25 +VISIBLE_MESSAGE_AX_MAX_NODES = 2500 +VISIBLE_MESSAGE_ACTIVE_TOKENS = ( + "awaiting response", + "thinking", + "working", + "running", + "generating", + "responding", + "stop generating", + "stop response", +) +VISIBLE_MESSAGE_DONE_TOKENS = ("done", "completed", "response complete") +VISIBLE_MESSAGE_ERROR_TOKENS = ("failed", "error") +THREAD_STATUS_LABELS = ( + "Awaiting response", + "Running", + "Working", + "Thinking", + "Needs approval", + "Approval needed", + "Queued", + "Done", + "Failed", + "Error", +) +THREAD_SECTION_LABELS = {"pinned", "projects", "chats", "recent", "show more"} +THREAD_CONTROL_LABELS = { + "archive chat", + "automation folders", + "automations", + "unarchive chat", + "pin chat", + "unpin chat", + "copy", + "copy message", + "new chat", + "new thread", + "search", + "settings", + "send", + "plugins", +} +THREAD_CONTROL_PREFIX_LABELS = { + "archive chat", + "automation folders", + "automations", + "pin chat", + "unarchive chat", + "unpin chat", +} +THREAD_TIME_RE = re.compile(r"^(?:now|yesterday|\d+\s?(?:s|m|h|d|w|mo|y))$", re.IGNORECASE) + + +@dataclass +class RunnerResult: + returncode: int + stdout: str + stderr: str + + +def run_command(command: list[str], timeout: float = 5.0) -> RunnerResult: + try: + completed = subprocess.run( + command, + check=False, + capture_output=True, + text=True, + timeout=timeout, + ) + except subprocess.TimeoutExpired: + display = command[:3] + ["..."] if len(command) > 3 else command + return RunnerResult( + returncode=124, + stdout="", + stderr=f"command timed out after {timeout:.1f}s: {display}", + ) + return RunnerResult( + returncode=completed.returncode, + stdout=completed.stdout, + stderr=completed.stderr, + ) + + +def check_accessibility_trusted() -> bool | None: + if platform.system() != "Darwin": + return None + try: + app_services = ctypes.cdll.LoadLibrary( + "/System/Library/Frameworks/ApplicationServices.framework/ApplicationServices" + ) + app_services.AXIsProcessTrusted.restype = ctypes.c_bool + return bool(app_services.AXIsProcessTrusted()) + except Exception: + return None + + +def check_screen_recording_trusted() -> bool | None: + if platform.system() != "Darwin": + return None + try: + core_graphics = ctypes.cdll.LoadLibrary( + "/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics" + ) + core_graphics.CGPreflightScreenCaptureAccess.restype = ctypes.c_bool + return bool(core_graphics.CGPreflightScreenCaptureAccess()) + except Exception: + return None + + +class MacOSCodexObserver: + AX_SNAPSHOT_SCRIPT = """ +import json +import subprocess +import sys + +pid = int(sys.argv[1]) +max_nodes = int(sys.argv[2]) +include_nodes = sys.argv[3] == "1" + +try: + import ApplicationServices as AS + import Quartz +except Exception as exc: + print(json.dumps({"ok": False, "error": f"pyobjc_missing: {exc}"})) + raise SystemExit(0) + + +def ax_value(element, attr): + try: + err, value = AS.AXUIElementCopyAttributeValue(element, attr, None) + except Exception: + return None + if err != 0: + return None + return value + + +def text_value(value): + if value is None: + return None + try: + return str(value) + except Exception: + return None + + +def bool_value(value): + if value is None: + return None + if isinstance(value, bool): + return value + try: + return bool(value) + except Exception: + return None + + +def rect_value(element): + pos = ax_value(element, AS.kAXPositionAttribute) + size = ax_value(element, AS.kAXSizeAttribute) + try: + ok_pos, point = AS.AXValueGetValue(pos, AS.kAXValueCGPointType, None) + ok_size, size_value = AS.AXValueGetValue(size, AS.kAXValueCGSizeType, None) + if ok_pos and ok_size: + return { + "x": int(point.x), + "y": int(point.y), + "width": int(size_value.width), + "height": int(size_value.height), + } + except Exception: + pass + try: + x, y = pos.x, pos.y + w, h = size.width, size.height + return {"x": int(x), "y": int(y), "width": int(w), "height": int(h)} + except Exception: + return None + + +def walk(element, rows, depth=0, window_index=None): + if len(rows) >= max_nodes: + return True + role = text_value(ax_value(element, AS.kAXRoleAttribute)) or "unknown" + name = text_value(ax_value(element, AS.kAXTitleAttribute)) or text_value(ax_value(element, AS.kAXDescriptionAttribute)) + rows.append({ + "role": role, + "name": name, + "depth": depth, + "window_index": window_index, + "bounds": rect_value(element), + "selected": bool_value(ax_value(element, AS.kAXSelectedAttribute)), + "focused": bool_value(ax_value(element, AS.kAXFocusedAttribute)), + }) + children = ax_value(element, AS.kAXChildrenAttribute) or [] + try: + child_iter = list(children) + except Exception: + child_iter = [] + for child in child_iter: + if walk(child, rows, depth + 1, window_index): + return True + return False + +frontmost = None +try: + frontmost = subprocess.run( + ["osascript", "-e", 'tell application "System Events" to get name of first application process whose frontmost is true'], + check=False, + capture_output=True, + text=True, + timeout=5, + ).stdout.strip() or None +except Exception: + pass + +app = AS.AXUIElementCreateApplication(pid) +windows = ax_value(app, AS.kAXWindowsAttribute) or [] +try: + windows_list = list(windows) +except Exception: + windows_list = [] + +window_rows = [] +node_rows = [] +truncated = False +for idx, window in enumerate(windows_list): + title = text_value(ax_value(window, AS.kAXTitleAttribute)) + role = text_value(ax_value(window, AS.kAXRoleAttribute)) + window_rows.append({ + "index": idx, + "title": title, + "role": role, + "bounds": rect_value(window), + "frontmost_app": frontmost, + "codex_frontmost": frontmost == "Codex", + }) + if include_nodes: + truncated = walk(window, node_rows, 0, idx) or truncated + +print(json.dumps({"ok": True, "windows": window_rows, "nodes": node_rows, "truncated": truncated})) +""".strip() + + def __init__( + self, + *, + runner: Callable[[list[str], float], RunnerResult] = run_command, + app_paths: list[Path] | None = None, + state_dir: Path | None = None, + platform_name: str | None = None, + accessibility_checker: Callable[[], bool | None] = check_accessibility_trusted, + screen_recording_checker: Callable[[], bool | None] = check_screen_recording_trusted, + now: Callable[[], str] = timestamp_utc, + ) -> None: + self.runner = runner + self._explicit_app_paths = app_paths is not None + self.app_paths = app_paths or [ + Path("/Applications/Codex.app"), + Path.home() / "Applications" / "Codex.app", + ] + self.state_dir = state_dir or default_state_dir() + self.platform_name = platform_name or platform.system() + self.accessibility_checker = accessibility_checker + self.screen_recording_checker = screen_recording_checker + self.now = now + + def status(self) -> CommandResult: + app_paths = self._visible_app_paths() + pid = self._codex_pid() + accessibility = self._permission_status("accessibility") + warnings: list[str] = [] + if self.platform_name != "Darwin": + warnings.append("macOS-only live desktop inspection is unavailable on this platform") + return CommandResult( + ok=True, + data={ + "platform": self.platform_name, + "app": { + "name": "Codex Desktop", + "process_name": "Codex", + "installed": bool(app_paths), + "running": pid is not None, + "pid": pid, + "paths": [redact_value(path) for path in app_paths], + }, + "permissions": { + "accessibility": accessibility, + "screen_recording": self._permission_status("screen_recording"), + }, + "safety": { + "read_only": True, + "sends_prompts": False, + "uses_internal_mutation_rpc": False, + "reads_session_databases": False, + }, + }, + warnings=warnings, + ) + + def focus(self, *, dry_run: bool = False) -> CommandResult: + if dry_run: + return CommandResult(ok=True, data={"would_focus": True, "focused": False}) + if self.platform_name != "Darwin": + return CommandResult( + ok=False, + data={"focused": False}, + errors=[ + make_error( + code="unsupported_platform", + message="Codex Desktop focus is only supported on macOS.", + guidance="Run this command on the macOS desktop host running Codex Desktop.", + ) + ], + ) + if self.accessibility_checker() is False: + return CommandResult( + ok=False, + data={"focused": False}, + errors=[ + make_error( + code="permission_missing", + message="Accessibility permission is required to focus Codex Desktop.", + guidance=ACCESSIBILITY_GUIDANCE, + permission="accessibility", + ) + ], + ) + if self._codex_pid() is None: + return CommandResult( + ok=False, + data={"focused": False}, + errors=[ + make_error( + code="codex_not_running", + message="Codex Desktop is not currently running.", + guidance="Open Codex Desktop manually, then rerun the focus command.", + ) + ], + ) + result = self.runner( + [ + "osascript", + "-e", + 'tell application "System Events" to set frontmost of process "Codex" to true', + ], + 5.0, + ) + if result.returncode != 0: + return CommandResult( + ok=False, + data={"focused": False}, + errors=[ + make_error( + code="focus_failed", + message="macOS refused to focus the visible Codex Desktop process.", + guidance=ACCESSIBILITY_GUIDANCE, + permission="accessibility", + ) + ], + warnings=[str(redact_value(result.stderr.strip()))] if result.stderr.strip() else [], + ) + return CommandResult(ok=True, data={"focused": True}) + + def snapshot(self, *, max_chars: int) -> CommandResult: + warnings: list[str] = [] + frontmost_app = self._osascript_value( + 'tell application "System Events" to get name of first application process whose frontmost is true' + ) + window_title = self._osascript_value( + 'tell application "System Events" to tell first application process whose frontmost is true to get name of front window' + ) + if frontmost_app is None: + warnings.append("frontmost app unavailable; Accessibility or Automation permission may be missing") + if window_title is None: + warnings.append("window title unavailable; Accessibility or Automation permission may be missing") + + capped_title, title_truncated = cap_text(redact_value(window_title), max_chars) + if title_truncated: + warnings.append("window title truncated") + capped_frontmost, frontmost_truncated = cap_text(redact_value(frontmost_app), max_chars) + if frontmost_truncated: + warnings.append("frontmost app truncated") + + codex_frontmost = frontmost_app == "Codex" + screenshot_path = None + if codex_frontmost: + screenshot_path = self._capture_screenshot(warnings) + else: + warnings.append("Codex Desktop is not frontmost; screenshot skipped to avoid capturing another app") + + return CommandResult( + ok=True, + data={ + "timestamp": self.now(), + "frontmost_app": capped_frontmost, + "window_title": capped_title if codex_frontmost else None, + "codex_frontmost": codex_frontmost, + "screenshot_path": redact_value(screenshot_path) if screenshot_path else None, + "max_chars": max_chars, + }, + warnings=warnings, + ) + + def frontmost(self) -> CommandResult: + frontmost_app = self._osascript_value( + 'tell application "System Events" to get name of first application process whose frontmost is true' + ) + window_title = self._osascript_value( + 'tell application "System Events" to tell first application process whose frontmost is true to get name of front window' + ) + return CommandResult( + ok=True, + data={ + "frontmost_app": redact_value(frontmost_app), + "window_title": redact_value(window_title) if frontmost_app == "Codex" else None, + "codex_frontmost": frontmost_app == "Codex", + }, + warnings=[] if frontmost_app else ["frontmost app unavailable; Accessibility or Automation permission may be missing"], + ) + + def windows(self, *, max_nodes: int = 1) -> CommandResult: + pid = self._codex_pid() + if pid is None: + return CommandResult( + ok=False, + data={"windows": []}, + errors=[ + make_error( + code="codex_not_running", + message="Codex Desktop is not currently running.", + guidance="Open Codex Desktop manually, then rerun the windows command.", + ) + ], + ) + payload, errors, warnings = self._ax_snapshot(pid=pid, max_nodes=max_nodes, include_nodes=False) + if payload is None: + return CommandResult(ok=False, data={"windows": []}, errors=errors, warnings=warnings) + windows = [self._safe_window(row) for row in payload.get("windows", [])] + return CommandResult(ok=True, data={"windows": windows, "count": len(windows)}, warnings=warnings) + + def threads(self, *, max_items: int) -> CommandResult: + pid = self._codex_pid() + if pid is None: + return CommandResult( + ok=False, + data={"threads": [], "count": 0, "max_items": max_items}, + errors=[ + make_error( + code="codex_not_running", + message="Codex Desktop is not currently running.", + guidance="Open Codex Desktop manually, then rerun the threads command.", + ) + ], + provenance={"source": "ax"}, + ) + payload, errors, warnings = self._ax_snapshot(pid=pid, max_nodes=max(max_items * 8, 80), include_nodes=True) + if payload is None: + return CommandResult(ok=False, data={"threads": [], "count": 0, "max_items": max_items}, errors=errors, warnings=warnings, provenance={"source": "ax"}) + threads = self._visible_threads_from_payload(payload, max_items=max_items) + return CommandResult( + ok=True, + data={"threads": threads, "count": len(threads), "max_items": max_items, "source": "ax"}, + warnings=warnings, + provenance={"source": "ax"}, + ) + + def select_thread(self, *, thread_id: str, dry_run: bool = False, max_items: int = 50) -> CommandResult: + if self.platform_name != "Darwin": + return CommandResult( + ok=False, + data={"selected": False, "would_select": dry_run}, + errors=[ + make_error( + code="unsupported_platform", + message="Codex Desktop visible thread selection is only supported on macOS.", + guidance="Run this command on the macOS desktop host running Codex Desktop.", + ) + ], + provenance={"source": "ax", "dry_run": dry_run, "selected_visible_target_id": thread_id}, + ) + if self.accessibility_checker() is False: + return CommandResult( + ok=False, + data={"selected": False, "would_select": dry_run}, + errors=[ + make_error( + code="permission_missing", + message="Accessibility permission is required to select a visible Codex thread.", + guidance=ACCESSIBILITY_GUIDANCE, + permission="accessibility", + ) + ], + provenance={"source": "ax", "dry_run": dry_run, "selected_visible_target_id": thread_id}, + ) + inventory = self.threads(max_items=max_items) + if not inventory.ok: + inventory.provenance.update({"dry_run": dry_run, "selected_visible_target_id": thread_id}) + return inventory + target = next((item for item in inventory.data.get("threads", []) if item.get("visible_id") == thread_id), None) + if target is None: + return CommandResult( + ok=False, + data={"selected": False, "would_select": dry_run, "thread_id": thread_id}, + errors=[ + make_error( + code="visible_thread_not_found", + message="The requested visible Codex thread id is not present in the current GUI inventory.", + guidance="Rerun `evaos-desktop-bridge codex threads --json` and choose a current visible_id.", + ) + ], + provenance={"source": "ax", "dry_run": dry_run, "selected_visible_target_id": thread_id}, + ) + center = target.get("center") + if not isinstance(center, dict) or center.get("x") is None or center.get("y") is None: + return CommandResult( + ok=False, + data={"selected": False, "would_select": dry_run, "thread_id": thread_id, "target": target}, + errors=[ + make_error( + code="visible_thread_not_selectable", + message="The visible thread candidate does not have safe screen coordinates.", + guidance="Use a candidate with bounds from the current AX inventory.", + ) + ], + provenance={"source": "ax", "dry_run": dry_run, "selected_visible_target_id": thread_id}, + ) + if dry_run: + return CommandResult( + ok=True, + data={"selected": False, "would_select": True, "thread_id": thread_id, "target": target}, + provenance={"source": "ax", "dry_run": True, "selected_visible_target_id": thread_id}, + ) + focus = self.focus(dry_run=False) + if not focus.ok: + focus.provenance.update({"dry_run": dry_run, "selected_visible_target_id": thread_id}) + return focus + result = self.runner( + ["osascript", "-e", f'tell application "System Events" to click at {{{int(center["x"])}, {int(center["y"])}}}'], + 5.0, + ) + if result.returncode != 0: + return CommandResult( + ok=False, + data={"selected": False, "thread_id": thread_id, "target": target}, + errors=[ + make_error( + code="visible_thread_select_failed", + message="macOS refused to select the visible Codex thread candidate.", + guidance=ACCESSIBILITY_GUIDANCE, + permission="accessibility", + ) + ], + warnings=[str(redact_value(result.stderr.strip()))] if result.stderr.strip() else [], + provenance={"source": "ax", "dry_run": False, "selected_visible_target_id": thread_id}, + ) + return CommandResult( + ok=True, + data={"selected": True, "thread_id": thread_id, "target": target}, + provenance={"source": "ax", "dry_run": False, "selected_visible_target_id": thread_id}, + ) + + def send_visible_message( + self, + *, + thread_id: str, + message: str, + dry_run: bool = True, + confirmed: bool = False, + wait_ms: int = 0, + poll_interval_ms: int = 2000, + ) -> CommandResult: + message = message.strip() + message_preview = self._safe_message_preview(message) + message_hash = self._short_hash(message) + wait_ms = max(0, min(VISIBLE_MESSAGE_WAIT_MAX_MS, int(wait_ms or 0))) + poll_interval_ms = max( + VISIBLE_MESSAGE_WAIT_POLL_MIN_MS, + min(VISIBLE_MESSAGE_WAIT_POLL_MAX_MS, int(poll_interval_ms or 2000)), + ) + provenance = { + "source": "codex_visible_gui", + "dry_run": dry_run, + "selected_visible_target_id": thread_id, + "message_hash": message_hash, + "wait_ms": wait_ms, + "poll_interval_ms": poll_interval_ms, + } + if not message: + return CommandResult( + ok=False, + data={"submitted": False, "would_submit": dry_run, "thread_id": thread_id, "message_preview": message_preview, "message_hash": message_hash}, + errors=[ + make_error( + code="visible_message_empty", + message="Codex visible GUI message cannot be empty.", + guidance="Pass a non-empty --message value.", + ) + ], + provenance=provenance, + ) + if len(message) > VISIBLE_MESSAGE_MAX_CHARS: + return CommandResult( + ok=False, + data={"submitted": False, "would_submit": dry_run, "thread_id": thread_id, "message_preview": message_preview, "message_hash": message_hash, "max_chars": VISIBLE_MESSAGE_MAX_CHARS}, + errors=[ + make_error( + code="visible_message_too_long", + message=f"Codex visible GUI message is capped at {VISIBLE_MESSAGE_MAX_CHARS} characters.", + guidance="Shorten the message, then rerun the dry-run approval flow.", + ) + ], + provenance=provenance, + ) + if self.platform_name != "Darwin": + return CommandResult( + ok=False, + data={"submitted": False, "would_submit": dry_run, "thread_id": thread_id, "message_preview": message_preview, "message_hash": message_hash}, + errors=[ + make_error( + code="unsupported_platform", + message="Codex Desktop visible GUI messaging is only supported on macOS.", + guidance="Run this command on the macOS desktop host running Codex Desktop.", + ) + ], + provenance=provenance, + ) + if self.accessibility_checker() is False: + return CommandResult( + ok=False, + data={"submitted": False, "would_submit": dry_run, "thread_id": thread_id, "message_preview": message_preview, "message_hash": message_hash}, + errors=[ + make_error( + code="permission_missing", + message="Accessibility permission is required to send a visible Codex GUI message.", + guidance=ACCESSIBILITY_GUIDANCE, + permission="accessibility", + ) + ], + provenance=provenance, + ) + if not dry_run and not confirmed: + return CommandResult( + ok=False, + data={"submitted": False, "would_submit": False, "thread_id": thread_id, "message_preview": message_preview, "message_hash": message_hash}, + errors=[ + make_error( + code="visible_message_confirmation_required", + message="Live Codex visible GUI messaging requires --confirm after a matching dry-run audit.", + guidance="Run the command with --dry-run first, then rerun with --live --confirm --approval-audit-id.", + ) + ], + provenance=provenance, + ) + current_thread = thread_id.strip().lower() in {"current", "visible-current", "current-visible"} + target: dict[str, Any] = { + "visible_id": "current", + "title": "Current visible Codex thread", + "source": "codex_visible_gui", + "selection_mode": "current_visible_thread", + } + if not current_thread: + inventory = self.threads(max_items=50) + if not inventory.ok: + inventory.provenance.update(provenance) + return inventory + found = next((item for item in inventory.data.get("threads", []) if item.get("visible_id") == thread_id), None) + if found is None: + return CommandResult( + ok=False, + data={"submitted": False, "would_submit": dry_run, "thread_id": thread_id, "message_preview": message_preview, "message_hash": message_hash}, + errors=[ + make_error( + code="visible_thread_not_found", + message="The requested visible Codex thread id is not present in the current GUI inventory.", + guidance="Rerun `evaos-desktop-bridge codex threads --json` and choose a current visible_id.", + ) + ], + provenance=provenance, + ) + target = found + center = target.get("center") + if not isinstance(center, dict) or center.get("x") is None or center.get("y") is None: + return CommandResult( + ok=False, + data={"submitted": False, "would_submit": dry_run, "thread_id": thread_id, "target": target, "message_preview": message_preview, "message_hash": message_hash}, + errors=[ + make_error( + code="visible_thread_not_selectable", + message="The visible thread candidate does not have safe screen coordinates.", + guidance="Use a candidate with bounds from the current AX inventory.", + ) + ], + provenance=provenance, + ) + target_safety_error = self._visible_message_target_safety_error(target=target, live=not dry_run) + if target_safety_error is not None: + return CommandResult( + ok=False, + data={"submitted": False, "would_submit": dry_run, "thread_id": thread_id, "target": target, "message_preview": message_preview, "message_hash": message_hash}, + errors=[target_safety_error], + provenance=provenance, + ) + preflight = self._visible_message_preflight() + if not preflight.ok: + preflight.provenance.update(provenance) + preflight.data.update({"thread_id": thread_id, "target": target, "message_preview": message_preview, "message_hash": message_hash}) + return preflight + composer = preflight.data.get("composer") + if dry_run: + return CommandResult( + ok=True, + data={ + "thread_id": thread_id, + "target": target, + "composer": composer, + "would_select": not current_thread, + "would_focus_composer": True, + "would_submit": True, + "would_poll_after_submit": wait_ms > 0, + "wait_ms": wait_ms, + "poll_interval_ms": poll_interval_ms, + "submitted": False, + "message_preview": message_preview, + "message_hash": message_hash, + "max_chars": VISIBLE_MESSAGE_MAX_CHARS, + }, + provenance=provenance, + ) + if not current_thread: + selected = self.select_thread(thread_id=thread_id, dry_run=False) + if not selected.ok: + selected.provenance.update(provenance) + return selected + verified = self._verify_selected_visible_thread(target) + if not verified.ok: + verified.provenance.update(provenance) + verified.data.update({"thread_id": thread_id, "target": target, "message_preview": message_preview, "message_hash": message_hash}) + return verified + after_select_preflight = self._visible_message_preflight() + if not after_select_preflight.ok: + after_select_preflight.provenance.update(provenance) + after_select_preflight.data.update({"thread_id": thread_id, "target": target, "message_preview": message_preview, "message_hash": message_hash}) + return after_select_preflight + composer = after_select_preflight.data.get("composer") + before_snapshot = self.snapshot(max_chars=1000) + composer_center = composer.get("center") if isinstance(composer, dict) else None + click_command = None + if isinstance(composer_center, dict) and composer_center.get("x") is not None and composer_center.get("y") is not None: + click_command = self.runner( + ["osascript", "-e", f'tell application "System Events" to click at {{{int(composer_center["x"])}, {int(composer_center["y"])}}}'], + 5.0, + ) + if click_command.returncode != 0: + return CommandResult( + ok=False, + data={"submitted": False, "thread_id": thread_id, "target": target, "composer": composer, "message_preview": message_preview, "message_hash": message_hash}, + errors=[ + make_error( + code="codex_visible_composer_focus_failed", + message="macOS refused to focus the visible Codex composer.", + guidance=ACCESSIBILITY_GUIDANCE, + permission="accessibility", + ) + ], + warnings=[str(redact_value(click_command.stderr.strip()))] if click_command.stderr.strip() else [], + provenance=provenance, + ) + type_result = self.runner( + [ + "osascript", + "-e", + f'tell application "System Events" to keystroke {self._applescript_string_expr(message)}', + "-e", + 'tell application "System Events" to key code 36', + ], + 10.0, + ) + if type_result.returncode != 0: + return CommandResult( + ok=False, + data={"submitted": False, "thread_id": thread_id, "target": target, "composer": composer, "message_preview": message_preview, "message_hash": message_hash}, + errors=[ + make_error( + code="codex_visible_message_submit_failed", + message="macOS refused the visible Codex GUI message submission.", + guidance=ACCESSIBILITY_GUIDANCE, + permission="accessibility", + ) + ], + warnings=[str(redact_value(type_result.stderr.strip()))] if type_result.stderr.strip() else [], + provenance=provenance, + ) + after_snapshot = self.snapshot(max_chars=1000) + before_snapshot_path = before_snapshot.data.get("screenshot_path") if before_snapshot.ok else None + after_snapshot_path = after_snapshot.data.get("screenshot_path") if after_snapshot.ok else None + post_send, post_send_warnings = self._visible_message_post_send_status(wait_ms=wait_ms, poll_interval_ms=poll_interval_ms) + return CommandResult( + ok=True, + data={ + "thread_id": thread_id, + "target": target, + "composer": composer, + "would_submit": False, + "submitted": True, + "message_preview": message_preview, + "message_hash": message_hash, + "before_snapshot": before_snapshot_path, + "after_snapshot": after_snapshot_path, + "post_send": post_send, + }, + warnings=before_snapshot.warnings + after_snapshot.warnings + post_send_warnings, + provenance={ + **provenance, + "before_snapshot_id": before_snapshot_path, + "after_snapshot_id": after_snapshot_path, + "post_send_state": post_send.get("state"), + "post_send_observation_count": post_send.get("observation_count"), + "selected_title_hash": target.get("title_hash"), + "current_visible_thread": current_thread, + }, + ) + + def continue_thread(self, *, title: str, prompt: str = "continue", dry_run: bool = False) -> CommandResult: + if not self._support_canary_enabled(): + return CommandResult( + ok=False, + data={"would_submit": dry_run, "submitted": False, "title": redact_value(title)}, + errors=[ + make_error( + code="support_canary_controls_not_enabled", + message="Codex visible continue fallback is support-VM-only and is disabled on this host.", + guidance=f"Enable {SUPPORT_CANARY_ENV}=1 only on the support canary Mac connector.", + ) + ], + provenance={"source": "codex_visible_fallback", "dry_run": dry_run, "support_only": True}, + ) + if prompt.strip().lower() != "continue": + return CommandResult( + ok=False, + data={"would_submit": dry_run, "submitted": False, "title": redact_value(title), "prompt_preview": self._safe_prompt_preview(prompt)}, + errors=[ + make_error( + code="codex_continue_prompt_not_allowed", + message="The support canary Codex fallback only allows the exact prompt 'continue'.", + guidance="Use Codex native remote-control for richer thread steering; do not expose generic prompt submission through this bridge.", + ) + ], + provenance={"source": "codex_visible_fallback", "dry_run": dry_run, "support_only": True}, + ) + inventory = self.threads(max_items=50) + if not inventory.ok: + inventory.provenance.update({"source": "codex_visible_fallback", "dry_run": dry_run, "support_only": True}) + return inventory + query = title.strip().lower() + matches = [ + item + for item in inventory.data.get("threads", []) + if query and query in str(item.get("title") or "").strip().lower() + ] + if len(matches) != 1: + return CommandResult( + ok=False, + data={"would_submit": dry_run, "submitted": False, "title": redact_value(title), "match_count": len(matches), "matches": matches[:10]}, + errors=[ + make_error( + code="codex_thread_title_not_unique", + message="The requested Codex thread title did not resolve to exactly one visible thread.", + guidance="Use `codex threads --json` to get current visible candidates, then narrow the title.", + ) + ], + provenance={"source": "codex_visible_fallback", "dry_run": dry_run, "support_only": True}, + ) + target = matches[0] + if dry_run: + delegated = self.send_visible_message(thread_id=str(target["visible_id"]), message=prompt.strip(), dry_run=True) + else: + delegated = self.send_visible_message(thread_id=str(target["visible_id"]), message=prompt.strip(), dry_run=False, confirmed=True) + delegated.data.update({"title": redact_value(title), "prompt_preview": self._safe_prompt_preview(prompt), "target": target}) + delegated.provenance.update({"source": "codex_visible_fallback", "support_only": True}) + return delegated + + def inspect(self, *, max_nodes: int) -> CommandResult: + status = self.status() + frontmost = self.frontmost() + windows = self.windows() + ax = self.ax_tree(max_nodes=max_nodes) + warnings = status.warnings + frontmost.warnings + windows.warnings + ax.warnings + errors = windows.errors + ax.errors + nodes = ax.data.get("nodes", []) if ax.ok else [] + buttons = [node for node in nodes if node.get("role") == "AXButton" and node.get("name")] + text_items = [node for node in nodes if node.get("name") and node.get("role") in {"AXStaticText", "AXTextField", "AXWebArea", "AXGroup"}] + return CommandResult( + ok=status.ok and frontmost.ok and windows.ok and ax.ok, + data={ + "status": status.data, + "frontmost": frontmost.data, + "windows": windows.data.get("windows", []), + "ax": { + "nodes": nodes, + "truncated": ax.data.get("truncated", False), + "max_nodes": max_nodes, + }, + "summary": { + "window_count": windows.data.get("count", 0), + "codex_frontmost": frontmost.data.get("codex_frontmost", False), + "visible_buttons": buttons[:20], + "visible_text": text_items[:20], + }, + }, + warnings=warnings, + errors=errors, + ) + + def ax_tree(self, *, max_nodes: int) -> CommandResult: + if self.platform_name != "Darwin": + return CommandResult( + ok=False, + data={"nodes": [], "truncated": False}, + errors=[ + make_error( + code="unsupported_platform", + message="Codex Desktop Accessibility tree inspection is only supported on macOS.", + guidance="Run this command on the macOS desktop host running Codex Desktop.", + ) + ], + ) + if self.accessibility_checker() is False: + return CommandResult( + ok=False, + data={"nodes": [], "truncated": False}, + errors=[ + make_error( + code="permission_missing", + message="Accessibility permission is required to read the visible Codex AX tree.", + guidance=ACCESSIBILITY_GUIDANCE, + permission="accessibility", + ) + ], + ) + pid = self._codex_pid() + if pid is None: + return CommandResult( + ok=False, + data={"nodes": [], "truncated": False}, + errors=[ + make_error( + code="codex_not_running", + message="Codex Desktop is not currently running.", + guidance="Open Codex Desktop manually, then rerun the ax-tree command.", + ) + ], + ) + payload, errors, warnings = self._ax_snapshot(pid=pid, max_nodes=max_nodes, include_nodes=True) + if payload is None: + return CommandResult(ok=False, data={"nodes": [], "truncated": False}, errors=errors, warnings=warnings) + nodes = [self._safe_node(row) for row in payload.get("nodes", [])][:max_nodes] + truncated = bool(payload.get("truncated")) + if truncated: + warnings.append(f"AX tree truncated at {max_nodes} nodes") + return CommandResult( + ok=True, + data={"nodes": nodes, "truncated": truncated, "max_nodes": max_nodes}, + warnings=warnings, + ) + + def _visible_app_paths(self) -> list[Path]: + if self._explicit_app_paths: + return self.app_paths + return [path for path in self.app_paths if path.exists()] + + def _codex_pid(self) -> int | None: + result = self.runner(["pgrep", "-x", "Codex"], 3.0) + if result.returncode == 0: + first = result.stdout.strip().splitlines()[0] if result.stdout.strip() else "" + try: + return int(first) + except ValueError: + pass + if self.platform_name != "Darwin": + return None + fallback = self.runner( + ["osascript", "-e", 'tell application "System Events" to get unix id of first application process whose name is "Codex"'], + 5.0, + ) + if fallback.returncode != 0: + return None + first = fallback.stdout.strip().splitlines()[0] if fallback.stdout.strip() else "" + try: + return int(first) + except ValueError: + return None + + def _permission_status(self, permission: str) -> dict[str, str]: + if permission == "accessibility": + trusted = self.accessibility_checker() + if trusted is True: + return {"status": "granted", "guidance": ACCESSIBILITY_GUIDANCE} + if trusted is False: + return {"status": "missing", "guidance": ACCESSIBILITY_GUIDANCE} + return {"status": "unknown", "guidance": ACCESSIBILITY_GUIDANCE} + if permission == "screen_recording": + trusted = self.screen_recording_checker() + if trusted is True: + return {"status": "granted", "guidance": SCREEN_RECORDING_GUIDANCE} + if trusted is False: + return {"status": "missing", "guidance": SCREEN_RECORDING_GUIDANCE} + return {"status": "unknown", "guidance": SCREEN_RECORDING_GUIDANCE} + return {"status": "unknown", "guidance": ACCESSIBILITY_GUIDANCE} + + def _osascript_value(self, script: str) -> str | None: + if self.platform_name != "Darwin": + return None + result = self.runner(["osascript", "-e", script], 5.0) + if result.returncode != 0: + return None + return result.stdout.strip() or None + + def _capture_screenshot(self, warnings: list[str]) -> Path | None: + if self.platform_name != "Darwin": + warnings.append("screenshot unavailable outside macOS") + return None + screenshot_dir = self.state_dir / "screenshots" + screenshot_dir.mkdir(parents=True, exist_ok=True) + filename = self.now().replace(":", "").replace("-", "").replace("Z", "Z") + ".png" + screenshot_path = screenshot_dir / filename + result = self.runner(["screencapture", "-x", str(screenshot_path)], 10.0) + if result.returncode != 0: + warnings.append("screenshot unavailable; Screen Recording permission may be missing") + return None + return screenshot_path + + def _ax_snapshot(self, *, pid: int, max_nodes: int, include_nodes: bool) -> tuple[dict[str, Any] | None, list[dict[str, Any]], list[str]]: + result = self.runner( + [sys.executable, "-c", self.AX_SNAPSHOT_SCRIPT, str(pid), str(max_nodes), "1" if include_nodes else "0"], + 20.0, + ) + warnings = [str(redact_value(result.stderr.strip()))] if result.stderr.strip() else [] + if result.returncode != 0: + return None, [ + make_error( + code="ax_tree_unavailable", + message="Unable to read the visible Codex Accessibility tree.", + guidance=ACCESSIBILITY_GUIDANCE, + permission="accessibility", + ) + ], warnings + try: + payload = json.loads(result.stdout.strip() or "{}") + except json.JSONDecodeError: + return None, [ + make_error( + code="ax_snapshot_parse_failed", + message="Unable to parse Codex Accessibility snapshot output.", + guidance="Check that pyobjc is installed in the Python environment running evaos-desktop-bridge.", + ) + ], warnings + if not payload.get("ok"): + return None, [ + make_error( + code="ax_dependency_missing", + message=str(redact_value(payload.get("error") or "Accessibility snapshot dependency missing.")), + guidance="Install pyobjc-framework-Quartz and pyobjc-framework-ApplicationServices in the bridge environment.", + ) + ], warnings + return payload, [], warnings + + def _safe_window(self, row: dict[str, Any]) -> dict[str, Any]: + title, _ = cap_text(redact_value(row.get("title")), 160) + role, _ = cap_text(str(redact_value(row.get("role") or "unknown")), 80) + return { + "index": row.get("index"), + "title": title, + "role": role, + "bounds": row.get("bounds"), + "codex_frontmost": bool(row.get("codex_frontmost")), + } + + def _safe_node(self, row: dict[str, Any]) -> dict[str, Any]: + role, _ = cap_text(str(redact_value(row.get("role") or "unknown")), 80) + name, _ = cap_text(str(redact_value(row.get("name"))) if row.get("name") else None, 160) + node = { + "role": role, + "name": name, + "depth": int(row.get("depth") or 0), + "window_index": row.get("window_index"), + } + if row.get("bounds") is not None: + node["bounds"] = row.get("bounds") + if row.get("selected") is not None: + node["selected"] = bool(row.get("selected")) + if row.get("focused") is not None: + node["focused"] = bool(row.get("focused")) + return node + + def _visible_threads_from_payload(self, payload: dict[str, Any], *, max_items: int) -> list[dict[str, Any]]: + candidates: list[dict[str, Any]] = [] + fallback_candidates: list[dict[str, Any]] = [] + seen: set[str] = set() + fallback_seen: set[str] = set() + current_project: str | None = None + in_projects = False + window_bounds_by_index = { + row.get("index"): row.get("bounds") + for row in payload.get("windows", []) + if isinstance(row, dict) + } + for row in payload.get("nodes", []): + node = self._safe_node(row) + role = str(node.get("role") or "") + name = node.get("name") + if not name or role not in {"AXButton", "AXStaticText", "AXTextField", "AXGroup", "AXRow", "AXLink"}: + continue + raw_name = str(name).strip() + lowered = raw_name.lower() + if self._is_thread_section_label(lowered): + in_projects = lowered == "projects" or in_projects + continue + if in_projects and role == "AXStaticText" and self._looks_like_project_header(raw_name): + current_project = str(redact_value(raw_name)) + continue + if self._is_action_only_thread_row(lowered, node) and len(fallback_candidates) < max_items: + fallback_key = json.dumps(node.get("bounds"), sort_keys=True) + if fallback_key in fallback_seen: + continue + fallback_seen.add(fallback_key) + fallback = self._unknown_thread_row(index=len(fallback_candidates), node=node, raw_name=raw_name, project=current_project) + if fallback["visible_id"] not in seen: + fallback_candidates.append(fallback) + seen.add(str(fallback["visible_id"])) + if self._is_thread_control_label(lowered): + continue + title, status, updated_label = self._split_thread_title_status(raw_name) + if not title or self._is_thread_control_label(title.lower()): + continue + if len(title) < 3 or title.lower() in {"codex", "vantage"}: + continue + visible_id = self._visible_thread_id(index=len(candidates), title=str(name), window_index=node.get("window_index")) + if visible_id in seen: + continue + seen.add(visible_id) + bounds = node.get("bounds") + center = None + if isinstance(bounds, dict): + try: + center = { + "x": int(bounds["x"]) + int(bounds["width"]) // 2, + "y": int(bounds["y"]) + int(bounds["height"]) // 2, + } + except Exception: + center = None + candidates.append( + { + "visible_id": visible_id, + "index": len(candidates), + "title": title, + "raw_title": raw_name, + "project": current_project, + "status": status, + "updated_label": updated_label, + "title_hash": self._short_hash(title), + "role": role, + "window_index": node.get("window_index"), + "bounds": bounds, + "window_bounds": window_bounds_by_index.get(node.get("window_index")), + "center": center, + "selected": bool(node.get("selected")) if node.get("selected") is not None else None, + "focused": bool(node.get("focused")) if node.get("focused") is not None else None, + "confidence": self._thread_confidence(role=role, center=center, status=status, updated_label=updated_label, project=current_project), + "source": "ax", + } + ) + if len(candidates) >= max_items: + break + if fallback_candidates: + fallback_right = 0 + for fallback in fallback_candidates: + bounds = fallback.get("bounds") + if isinstance(bounds, dict): + try: + fallback_right = max(fallback_right, int(bounds.get("x") or 0) + int(bounds.get("width") or 0)) + except Exception: + pass + sidebar_titled_candidates = [] + for candidate in candidates: + center = candidate.get("center") + try: + center_x = int(center.get("x")) if isinstance(center, dict) else None + except Exception: + center_x = None + if center_x is not None and center_x <= fallback_right + 120: + sidebar_titled_candidates.append(candidate) + if sidebar_titled_candidates: + return sidebar_titled_candidates[:max_items] + merged = fallback_candidates + candidates + return merged[:max_items] + return candidates[:max_items] + + def _visible_thread_id(self, *, index: int, title: str, window_index: Any) -> str: + digest = hashlib.sha256(f"{window_index}:{index}:{title}".encode("utf-8")).hexdigest()[:12] + return f"visible-{index}-{digest}" + + def _support_canary_enabled(self) -> bool: + return os.environ.get(SUPPORT_CANARY_ENV, "").strip().lower() in {"1", "true", "yes", "support"} + + def _safe_prompt_preview(self, prompt: str) -> str: + capped, _ = cap_text(redact_value(prompt.strip()), 80) + return capped or "" + + def _safe_message_preview(self, message: str) -> str: + capped, _ = cap_text(redact_value(message.strip()), 240) + return capped or "" + + def _short_hash(self, value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest()[:16] + + def _applescript_string_expr(self, value: str) -> str: + parts: list[str] = [] + literal: list[str] = [] + + def flush_literal() -> None: + if not literal: + return + escaped = "".join(literal).replace("\\", "\\\\").replace('"', '\\"') + parts.append(f'"{escaped}"') + literal.clear() + + index = 0 + while index < len(value): + if value.startswith("\r\n", index): + flush_literal() + parts.append("return") + index += 2 + continue + char = value[index] + if char in {"\n", "\r"}: + flush_literal() + parts.append("return") + elif char == "\t": + flush_literal() + parts.append("tab") + elif ord(char) < 32 or ord(char) == 127: + flush_literal() + parts.append(f"(ASCII character {ord(char)})") + else: + literal.append(char) + index += 1 + + flush_literal() + return " & ".join(parts) if parts else '""' + + def _visible_message_preflight(self) -> CommandResult: + frontmost = self.frontmost() + if not frontmost.ok or frontmost.data.get("codex_frontmost") is not True: + return CommandResult( + ok=False, + data={"composer": None, "codex_frontmost": frontmost.data.get("codex_frontmost", False)}, + errors=[ + make_error( + code="codex_not_frontmost", + message="Codex Desktop must be frontmost before sending a visible GUI message.", + guidance="Focus Codex Desktop and make sure the target thread is visible, then rerun the dry-run.", + ) + ], + warnings=frontmost.warnings, + provenance={"source": "codex_visible_gui"}, + ) + ax = self.ax_tree(max_nodes=VISIBLE_MESSAGE_AX_MAX_NODES) + if not ax.ok: + return ax + composer = self._find_visible_composer(ax.data.get("nodes", [])) + if composer is None: + return CommandResult( + ok=False, + data={"composer": None, "codex_frontmost": True}, + errors=[ + make_error( + code="codex_visible_composer_not_found", + message="No visible Codex composer was found in the current Accessibility tree.", + guidance="Open a loaded Codex thread with the message composer visible, then rerun the dry-run.", + ) + ], + warnings=ax.warnings, + provenance={"source": "codex_visible_gui"}, + ) + return CommandResult(ok=True, data={"composer": composer, "codex_frontmost": True}, warnings=ax.warnings, provenance={"source": "codex_visible_gui"}) + + def _visible_message_post_send_status(self, *, wait_ms: int, poll_interval_ms: int) -> tuple[dict[str, Any], list[str]]: + base = { + "state": "submitted_waiting", + "wait_ms": wait_ms, + "poll_interval_ms": poll_interval_ms, + "read_only_after_submit": True, + "observations": [], + "observation_count": 0, + } + if wait_ms <= 0: + return base, [] + + warnings: list[str] = [] + observations: list[dict[str, Any]] = [] + total_observations = 0 + elapsed_ms = 0 + last_state = "unknown" + implicit_idle_streak = 0 + while True: + observation = self._visible_message_wait_observation(max_chars=1000) + observation["index"] = total_observations + total_observations += 1 + if len(observations) < VISIBLE_MESSAGE_WAIT_OBSERVATION_MAX: + observations.append(observation) + state = str(observation.get("state") or "unknown") + last_state = state + explicit_idle = state == "idle" and observation.get("idle_confidence") == "explicit" + stable_implicit_idle = False + if state == "idle" and observation.get("idle_confidence") == "implicit_composer_visible": + implicit_idle_streak += 1 + stable_implicit_idle = implicit_idle_streak >= 2 + elif state != "idle": + implicit_idle_streak = 0 + if state in {"done", "error", "unavailable", "inconclusive"} or explicit_idle or stable_implicit_idle: + result = { + **base, + "state": state, + "last_observed_state": state, + "observations": observations, + "observation_count": total_observations, + "observations_truncated": total_observations > len(observations), + } + if stable_implicit_idle: + result["idle_confidence"] = "stable_implicit_composer_visible" + elif observation.get("idle_confidence"): + result["idle_confidence"] = observation.get("idle_confidence") + if observation.get("contamination_reason"): + result["contamination_reason"] = observation.get("contamination_reason") + warnings.append("Post-send Codex GUI observation became inconclusive; rerun in a quiet operator window before treating this as product failure.") + return result, warnings + if elapsed_ms >= wait_ms: + break + sleep_ms = min(poll_interval_ms, wait_ms - elapsed_ms) + if sleep_ms <= 0: + break + self._sleep_for_visible_message_poll(sleep_ms / 1000.0) + elapsed_ms += sleep_ms + + return { + **base, + "state": "timeout", + "last_observed_state": last_state, + "observations": observations, + "observation_count": total_observations, + "observations_truncated": total_observations > len(observations), + }, warnings + + def _visible_message_wait_observation(self, *, max_chars: int = 1000) -> dict[str, Any]: + timestamp = self.now() + frontmost = self.frontmost() + if not frontmost.ok or frontmost.data.get("codex_frontmost") is not True: + return { + "timestamp": timestamp, + "state": "inconclusive", + "contamination_reason": "codex_not_frontmost", + "codex_frontmost": bool(frontmost.data.get("codex_frontmost")), + "composer_visible": False, + "active_indicators": [], + "max_chars": max_chars, + } + + ax = self.ax_tree(max_nodes=VISIBLE_MESSAGE_AX_MAX_NODES) + if not ax.ok: + return { + "timestamp": timestamp, + "state": "inconclusive", + "contamination_reason": "ax_unavailable", + "codex_frontmost": True, + "composer_visible": False, + "active_indicators": [], + "max_chars": max_chars, + } + + nodes = ax.data.get("nodes", []) + composer_visible = self._find_visible_composer(nodes) is not None if isinstance(nodes, list) else False + active_indicators: list[str] = [] + done = False + error = False + if isinstance(nodes, list): + for node in nodes: + if not isinstance(node, dict): + continue + name = str(node.get("name") or "") + lowered = name.lower() + if any(token in lowered for token in VISIBLE_MESSAGE_ERROR_TOKENS): + error = True + if any(token in lowered for token in VISIBLE_MESSAGE_DONE_TOKENS): + done = True + if any(token in lowered for token in VISIBLE_MESSAGE_ACTIVE_TOKENS): + capped, _ = cap_text(redact_value(name), 120) + if capped and capped not in active_indicators and len(active_indicators) < 5: + active_indicators.append(capped) + + if error: + state = "error" + elif done: + state = "done" + elif active_indicators: + state = "submitted_waiting" + elif composer_visible: + state = "idle" + else: + state = "submitted_waiting" + + snapshot = self.snapshot(max_chars=max_chars) + screenshot_path = snapshot.data.get("screenshot_path") if snapshot.ok else None + return { + "timestamp": timestamp, + "state": state, + "idle_confidence": "implicit_composer_visible" if state == "idle" else None, + "codex_frontmost": True, + "composer_visible": composer_visible, + "active_indicators": active_indicators, + "screenshot_path": screenshot_path, + "max_chars": max_chars, + } + + def _sleep_for_visible_message_poll(self, seconds: float) -> None: + time.sleep(seconds) + + def _visible_message_target_safety_error(self, *, target: dict[str, Any], live: bool) -> dict[str, Any] | None: + bounds = target.get("bounds") + center = target.get("center") + if not self._bounds_have_positive_size(bounds) or not isinstance(center, dict): + return make_error( + code="visible_thread_not_selectable", + message="The visible thread candidate does not have positive on-screen bounds.", + guidance="Use a candidate with positive bounds from the current AX inventory.", + ) + window_bounds = target.get("window_bounds") + if isinstance(window_bounds, dict) and not self._point_inside_bounds(center, window_bounds): + return make_error( + code="visible_thread_offscreen", + message="The visible thread candidate center is outside the Codex window bounds.", + guidance="Rerun the thread inventory with the target row visible in the Codex window.", + ) + if live and target.get("selection_only") is True: + return make_error( + code="visible_thread_identity_not_verifiable", + message="Live visible GUI messaging requires a titled target row or the explicit current visible thread sentinel.", + guidance="Select the intended Codex thread first, then use --thread-id current, or use a visible titled medium-or-better candidate.", + ) + if live and target.get("selection_only") is not True and target.get("confidence") == "low": + return make_error( + code="visible_thread_identity_not_verifiable", + message="Live visible GUI messaging requires a titled, medium-or-better confidence thread candidate.", + guidance="Use a visible row with title/status evidence, or use dry-run/read-only mapping until Codex exposes enough AX identity.", + ) + return None + + def _verify_selected_visible_thread(self, target: dict[str, Any]) -> CommandResult: + inventory = self.threads(max_items=50) + if not inventory.ok: + inventory.provenance.update({"source": "codex_visible_gui"}) + return inventory + current = next((item for item in inventory.data.get("threads", []) if item.get("visible_id") == target.get("visible_id")), None) + if current is None: + return CommandResult( + ok=False, + data={"selection_verified": False}, + errors=[ + make_error( + code="visible_thread_not_found_after_select", + message="The target visible Codex thread disappeared after selection.", + guidance="Rerun thread-map and dry-run approval against the current visible UI.", + ) + ], + provenance={"source": "codex_visible_gui"}, + ) + if current.get("selected") is True or current.get("focused") is True: + return CommandResult(ok=True, data={"selection_verified": True, "selected_thread": current}, provenance={"source": "codex_visible_gui"}) + return CommandResult( + ok=False, + data={"selection_verified": False, "selected_thread": current}, + errors=[ + make_error( + code="visible_thread_selection_not_verified", + message="Codex did not expose the requested visible thread as selected after the click.", + guidance="Do not submit a live GUI message until AX confirms the selected target. Rerun thread-map after focusing the intended thread.", + ) + ], + provenance={"source": "codex_visible_gui"}, + ) + + def _find_visible_composer(self, nodes: list[dict[str, Any]]) -> dict[str, Any] | None: + for node in reversed(nodes): + role = str(node.get("role") or "") + if role not in {"AXTextArea", "AXTextField", "AXTextView", "AXComboBox"}: + continue + bounds = node.get("bounds") + center = None + if isinstance(bounds, dict): + try: + width = int(bounds.get("width") or 0) + height = int(bounds.get("height") or 0) + if width <= 0 or height <= 0: + continue + center = {"x": int(bounds["x"]) + width // 2, "y": int(bounds["y"]) + height // 2} + except Exception: + continue + if center is None: + continue + name = str(node.get("name") or "") + name_lower = name.lower() + if name_lower and not any(token in name_lower for token in ("message", "ask", "prompt", "codex", "input", "chat")): + continue + safe_name, _ = cap_text(redact_value(name), 120) + return {"role": role, "name": safe_name, "bounds": bounds, "center": center} + return None + + def _bounds_have_positive_size(self, bounds: Any) -> bool: + if not isinstance(bounds, dict): + return False + try: + return int(bounds.get("width") or 0) > 0 and int(bounds.get("height") or 0) > 0 + except Exception: + return False + + def _point_inside_bounds(self, point: dict[str, Any], bounds: dict[str, Any]) -> bool: + try: + x = int(point["x"]) + y = int(point["y"]) + bx = int(bounds["x"]) + by = int(bounds["y"]) + bw = int(bounds["width"]) + bh = int(bounds["height"]) + except Exception: + return False + return bx <= x <= bx + bw and by <= y <= by + bh + + def _is_thread_section_label(self, lowered: str) -> bool: + return lowered in THREAD_SECTION_LABELS + + def _is_thread_control_label(self, lowered: str) -> bool: + if lowered in THREAD_CONTROL_LABELS: + return True + for control in THREAD_CONTROL_PREFIX_LABELS: + if lowered.startswith(f"{control} ") or lowered.startswith(control): + return True + return any(control in lowered for control in ("archive chat", "unpin chat", "pin chat")) + + def _looks_like_project_header(self, name: str) -> bool: + if not name or len(name) > 80: + return False + lowered = name.strip().lower() + if self._is_thread_section_label(lowered) or self._is_thread_control_label(lowered): + return False + if THREAD_TIME_RE.match(lowered): + return False + return True + + def _split_thread_title_status(self, raw_name: str) -> tuple[str, str | None, str | None]: + parts = raw_name.strip().split() + updated_label = None + if parts and THREAD_TIME_RE.match(parts[-1]): + updated_label = parts[-1] + raw_name = " ".join(parts[:-1]).strip() + status = None + for label in THREAD_STATUS_LABELS: + suffix = f" {label}".lower() + if raw_name.lower().endswith(suffix): + status = label + raw_name = raw_name[: -len(label)].strip() + break + title, _ = cap_text(str(redact_value(raw_name.strip())), 160) + return title or "", status, updated_label + + def _thread_confidence(self, *, role: str, center: dict[str, int] | None, status: str | None, updated_label: str | None, project: str | None) -> str: + score = 0 + if center: + score += 2 + if role in {"AXButton", "AXRow", "AXGroup"}: + score += 1 + if status or updated_label: + score += 1 + if project: + score += 1 + if score >= 4: + return "high" + if score >= 2: + return "medium" + return "low" + + def _is_action_only_thread_row(self, lowered: str, node: dict[str, Any]) -> bool: + if "archive chat" not in lowered or ("unpin chat" not in lowered and "pin chat" not in lowered): + return False + bounds = node.get("bounds") + if not isinstance(bounds, dict): + return False + try: + width = int(bounds.get("width") or 0) + height = int(bounds.get("height") or 0) + except Exception: + return False + return width >= 120 and height > 0 + + def _unknown_thread_row(self, *, index: int, node: dict[str, Any], raw_name: str, project: str | None) -> dict[str, Any]: + bounds = node.get("bounds") + center = None + if isinstance(bounds, dict): + try: + center = { + "x": int(bounds["x"]) + int(bounds["width"]) // 2, + "y": int(bounds["y"]) + int(bounds["height"]) // 2, + } + except Exception: + center = None + _, _, updated_label = self._split_thread_title_status(raw_name) + title = f"Visible thread row {index + 1} (title unavailable)" + visible_id = self._visible_thread_id(index=index, title=f"title-unavailable:{updated_label or ''}:{bounds}", window_index=node.get("window_index")) + return { + "visible_id": visible_id, + "index": index, + "title": title, + "raw_title": "title_unavailable", + "project": project, + "status": None, + "updated_label": updated_label, + "title_hash": self._short_hash(title), + "role": str(node.get("role") or ""), + "window_index": node.get("window_index"), + "bounds": bounds, + "window_bounds": None, + "center": center, + "selected": bool(node.get("selected")) if node.get("selected") is not None else None, + "focused": bool(node.get("focused")) if node.get("focused") is not None else None, + "confidence": "low", + "source": "ax", + "title_available": False, + "selection_only": True, + } + + def _selection_only_live_target_allowed(self, target: dict[str, Any]) -> bool: + if target.get("title_available") is not False or target.get("selection_only") is not True: + return False + if not str(target.get("updated_label") or "").strip(): + return False + if not self._bounds_have_positive_size(target.get("bounds")): + return False + center = target.get("center") + if not isinstance(center, dict): + return False + window_bounds = target.get("window_bounds") + return not isinstance(window_bounds, dict) or self._point_inside_bounds(center, window_bounds) diff --git a/resources/evaos-beta/bridge/src/evaos_desktop_bridge/adapters/customer_mac.py b/resources/evaos-beta/bridge/src/evaos_desktop_bridge/adapters/customer_mac.py new file mode 100644 index 0000000000..86a99c05bb --- /dev/null +++ b/resources/evaos-beta/bridge/src/evaos_desktop_bridge/adapters/customer_mac.py @@ -0,0 +1,3518 @@ +from __future__ import annotations + +import base64 +import hashlib +import json +import os +import platform +import re +import select +import shutil +import struct +import subprocess +import sys +import time +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable, Protocol +from urllib.parse import urlparse + +from ..audit import append_audit, default_state_dir +from ..bundled_tools import bundled_bridge_bin_candidates +from ..helper_ipc import helper_client_from_environment +from ..redaction import cap_text, redact_value +from ..schema import make_error, timestamp_utc +from ..state import kill_control_session, read_control_session, start_control_session, stop_control_session, write_control_session +from ..types import CommandResult +from .codex_macos import ( + ACCESSIBILITY_GUIDANCE, + SCREEN_RECORDING_GUIDANCE, + RunnerResult, + check_accessibility_trusted, + check_screen_recording_trusted, + run_command, +) + +IPHONE_MIRRORING_APP = Path("/System/Applications/iPhone Mirroring.app") +SAFE_LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1"} +SAFE_BROWSER_APPS = {"Safari", "Google Chrome", "Arc", "Firefox", "Brave Browser"} +SAFE_LOCAL_SITE_ACTIONS = {"reload", "back", "forward"} +CONTROL_MODES = {"full_access", "ask_permission"} +AX_EDITABLE_VALUE_ROLES = {"AXTextField", "AXTextArea", "AXComboBox"} +WORKBENCH_CANONICAL_APP_PATH = Path("/Applications/evaOS Workbench.app") +WORKBENCH_PROCESS_NAME = "evaOS Workbench" +WORKBENCH_APP_ALIASES = { + "com.evaos.workbench", + "com.electricsheephq.evadesktop", + "evadesktop", + "evaos", + "evaos workbench", +} +PEEKABOO_BIN_CANDIDATES = ( + "peekaboo", + "evaos-connector-helper", + "/opt/homebrew/bin/peekaboo", + "/usr/local/bin/peekaboo", +) +CUA_DRIVER_BIN_CANDIDATES = ( + "cua-driver", + "/opt/homebrew/bin/cua-driver", + "/usr/local/bin/cua-driver", +) +MAC_CONTROL_ENGINE_ORDER = ( + "cua_driver", + "peekaboo", + "post_to_pid_helper", + "accessibility", + "system_events", +) +SAFE_IPHONE_ACTIONS = { + "home", + "app_switcher", + "spotlight", + "type_spotlight", + "open_app", + "tap_named_target", +} +GUARDED_IPHONE_ACTIONS = { + "scroll", + "swipe_left", + "swipe_right", + "swipe_up", + "swipe_down", + "type_approved_text", + "send_approved_message", +} +GUARDED_GESTURE_KEYS = { + "swipe_left": "123", + "swipe_right": "124", + "swipe_up": "126", + "swipe_down": "125", +} +SCROLL_DIRECTIONS = {"up": "126", "down": "125"} +SENSITIVE_APPS = { + "1Password", + "App Store", + "Keychain Access", + "Password", + "Passwords", + "Wallet", + "FaceTime", + "Messages", + "Mail", + "Signal", + "Telegram", + "WhatsApp", + "Camera", + "Photo Booth", + "System Settings", +} +SENSITIVE_IPHONE_APPS = SENSITIVE_APPS | { + "App Store", + "Authenticator", + "Bank", + "Camera", + "Contacts", + "FaceTime", + "Find My", + "Health", + "Home", + "Keychain", + "Mail", + "Messages", + "Phone", + "Photos", + "Safari", + "Settings", + "Shortcuts", + "Wallet", +} +DANGEROUS_IPHONE_TARGET_RE = re.compile( + r"\b(" + r"allow|approve|authenticate|buy|call|camera|checkout|confirm|continue with apple|" + r"delete|facetime|log in|login|microphone|pay|purchase|record|send|share|" + r"sign in|signin|subscribe|transfer|unlock" + r")\b", + re.IGNORECASE, +) +SAFE_TEXT_RE = re.compile(r"^[A-Za-z0-9 ._+@:/#-]{1,80}$") +APPROVED_TEXT_RE = re.compile(r"^[^\r\n]{1,240}$") +SNAPSHOT_MAX_AGE_SECONDS = 15 * 60 +SNAPSHOT_INLINE_MAX_BYTES = int(os.environ.get("EVAOS_DESKTOP_BRIDGE_INLINE_IMAGE_MAX_BYTES", str(3 * 1024 * 1024))) + + +class CustomerMacHelperClient(Protocol): + def dispatch(self, command: str, payload: dict[str, object], *, audit_id: str | None = None) -> CommandResult: + ... + + +class CuaDriverClient(Protocol): + def call(self, tool: str, arguments: dict[str, object], *, timeout: float = 10.0) -> CommandResult: + ... + + +class CuaDriverMcpClient: + """Tiny line-delimited JSON-RPC client for `cua-driver mcp`.""" + + def __init__(self, path: str) -> None: + self.path = path + + def call(self, tool: str, arguments: dict[str, object], *, timeout: float = 10.0) -> CommandResult: + request_id = 0 + deadline = time.monotonic() + timeout + stderr = "" + env = { + **os.environ, + "CUA_DRIVER_RS_TELEMETRY_ENABLED": "false", + "CUA_DRIVER_RS_UPDATE_CHECK": "false", + } + proc: subprocess.Popen[str] | None = None + + def next_id() -> int: + nonlocal request_id + request_id += 1 + return request_id + + try: + proc = subprocess.Popen( + [self.path, "mcp"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env, + ) + if proc.stdin is None or proc.stdout is None: + raise RuntimeError("cua-driver MCP did not expose stdio pipes") + + initialize_id = next_id() + self._send_mcp( + proc, + { + "jsonrpc": "2.0", + "id": initialize_id, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "evaos-desktop-bridge", "version": "0"}, + }, + }, + ) + initialized = self._read_mcp_response(proc, expected_id=initialize_id, deadline=deadline) + if initialized is None: + raise TimeoutError("cua-driver MCP did not return initialize") + if initialized.get("error") is not None: + return self._rpc_error_result(tool, initialized, stderr=stderr) + self._send_mcp(proc, {"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}}) + + # The bridge opens a short-lived MCP process per call. CUA sessions + # are meant for long-lived agents; passing a stable session id here + # makes cua-driver immediately end that session and ignore the tool + # call. Run cursor-less and let element_token/window ids carry state. + call_arguments = dict(arguments) + call_arguments.pop("session", None) + + tool_id = next_id() + self._send_mcp(proc, {"jsonrpc": "2.0", "id": tool_id, "method": "tools/call", "params": {"name": tool, "arguments": call_arguments}}) + response = self._read_mcp_response(proc, expected_id=tool_id, deadline=deadline) + if response is None: + raise TimeoutError("cua-driver MCP did not return a tool result") + + return self._tool_response_to_command_result(tool, response, stderr=stderr) + except TimeoutError: + return CommandResult( + ok=False, + data={"tool": tool, "engine": "cua_driver"}, + errors=[make_error(code="cua_driver_timeout", message="cua-driver MCP timed out before returning a tool result.", guidance="Falling back to the next Mac-control engine.")], + provenance={"source": "cua_driver", "tool": tool}, + ) + except subprocess.TimeoutExpired: + return CommandResult( + ok=False, + data={"tool": tool, "engine": "cua_driver"}, + errors=[make_error(code="cua_driver_timeout", message="cua-driver MCP timed out before returning a tool result.", guidance="Falling back to the next Mac-control engine.")], + provenance={"source": "cua_driver", "tool": tool}, + ) + except Exception as exc: + return CommandResult( + ok=False, + data={"tool": tool, "engine": "cua_driver"}, + errors=[make_error(code="cua_driver_unavailable", message="cua-driver MCP could not be started.", guidance="Install or restart CuaDriver.app; the bridge will fall back to the next engine.")], + warnings=[str(redact_value(exc))], + provenance={"source": "cua_driver", "tool": tool}, + ) + finally: + if proc is not None: + stderr = self._finish_mcp_process(proc) + + def _send_mcp(self, proc: subprocess.Popen[str], payload: dict[str, Any]) -> None: + if proc.stdin is None: + raise RuntimeError("cua-driver MCP stdin closed") + proc.stdin.write(json.dumps(payload, separators=(",", ":")) + "\n") + proc.stdin.flush() + + def _read_mcp_response(self, proc: subprocess.Popen[str], *, expected_id: int, deadline: float) -> dict[str, Any] | None: + if proc.stdout is None: + return None + while time.monotonic() < deadline: + remaining = max(0.0, deadline - time.monotonic()) + ready, _, _ = select.select([proc.stdout], [], [], min(remaining, 0.25)) + if not ready: + continue + line = proc.stdout.readline() + if not line: + if proc.poll() is not None: + return None + continue + line = line.strip() + if not line: + continue + try: + payload = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(payload, dict) and payload.get("id") == expected_id: + return payload + return None + + def _finish_mcp_process(self, proc: subprocess.Popen[str]) -> str: + try: + if proc.stdin is not None and not proc.stdin.closed: + proc.stdin.close() + except Exception: + pass + if proc.poll() is None: + try: + proc.terminate() + proc.wait(timeout=1.0) + except Exception: + try: + proc.kill() + except Exception: + pass + try: + if proc.stderr is not None: + return proc.stderr.read().strip() + except Exception: + pass + return "" + + def _rpc_error_result(self, tool: str, response: dict[str, Any], *, stderr: str) -> CommandResult: + error = response.get("error") if isinstance(response.get("error"), dict) else {} + return CommandResult( + ok=False, + data={"tool": tool, "engine": "cua_driver"}, + errors=[make_error(code="cua_driver_rpc_error", message=str(error.get("message") or "cua-driver MCP returned a JSON-RPC error."), guidance="Falling back to the next Mac-control engine.")], + warnings=[str(redact_value(stderr.strip()))] if stderr.strip() else [], + provenance={"source": "cua_driver", "tool": tool}, + ) + + def _tool_response_to_command_result(self, tool: str, response: dict[str, Any], *, stderr: str) -> CommandResult: + if not isinstance(response, dict): + return CommandResult( + ok=False, + data={"tool": tool, "engine": "cua_driver"}, + errors=[make_error(code="cua_driver_bad_response", message="cua-driver MCP returned no parseable tool response.", guidance="Falling back to the next Mac-control engine.")], + warnings=[str(redact_value(stderr.strip()))] if stderr.strip() else [], + provenance={"source": "cua_driver", "tool": tool}, + ) + if response.get("error") is not None: + return self._rpc_error_result(tool, response, stderr=stderr) + result = response.get("result") if isinstance(response.get("result"), dict) else {} + content = result.get("content") if isinstance(result.get("content"), list) else [] + text = "" + if content and isinstance(content[0], dict): + text = str(content[0].get("text") or "") + structured = result.get("structuredContent") if isinstance(result.get("structuredContent"), dict) else {} + is_error = bool(result.get("isError")) + return CommandResult( + ok=not is_error, + data={ + "tool": tool, + "text": redact_value(text), + "structured": redact_value(structured), + "engine": "cua_driver", + }, + errors=[] if not is_error else [make_error(code="cua_driver_tool_failed", message=text or "cua-driver tool failed.", guidance="Falling back to the next Mac-control engine.")], + warnings=[str(redact_value(stderr.strip()))] if stderr.strip() else [], + provenance={"source": "cua_driver", "tool": tool}, + ) + + +class CustomerMacObserver: + AX_TREE_SCRIPT = """ +import json +import sys + +pid = int(sys.argv[1]) +max_nodes = int(sys.argv[2]) + +try: + import ApplicationServices as AS +except Exception as exc: + print(json.dumps({"ok": False, "error": f"pyobjc_missing: {exc}"})) + raise SystemExit(0) + + +def ax_value(element, attr): + try: + err, value = AS.AXUIElementCopyAttributeValue(element, attr, None) + except Exception: + return None + if err != 0: + return None + return value + + +def text_value(value): + if value is None: + return None + try: + return str(value) + except Exception: + return None + + +def rect_value(element): + pos = ax_value(element, AS.kAXPositionAttribute) + size = ax_value(element, AS.kAXSizeAttribute) + try: + _, point = AS.AXValueGetValue(pos, AS.kAXValueCGPointType, None) + _, dimensions = AS.AXValueGetValue(size, AS.kAXValueCGSizeType, None) + return {"x": int(point.x), "y": int(point.y), "width": int(dimensions.width), "height": int(dimensions.height)} + except Exception: + return None + + +def actions(element): + try: + err, names = AS.AXUIElementCopyActionNames(element, None) + except Exception: + return [] + if err != 0: + return [] + try: + return [str(item) for item in list(names)] + except Exception: + return [] + + +def walk(element, rows, depth=0, window_index=None, path=None, sibling_index=0): + if len(rows) >= max_nodes: + return True + role = text_value(ax_value(element, AS.kAXRoleAttribute)) or "unknown" + name = text_value(ax_value(element, AS.kAXTitleAttribute)) or text_value(ax_value(element, AS.kAXDescriptionAttribute)) + identifier = text_value(ax_value(element, getattr(AS, "kAXIdentifierAttribute", "AXIdentifier"))) + segment = {"role": role, "index": int(sibling_index)} + if name: + segment["name"] = name + if identifier: + segment["identifier"] = identifier + ax_path = list(path or []) + [segment] + rows.append({ + "role": role, + "name": name, + "identifier": identifier, + "depth": depth, + "window_index": window_index, + "bounds": rect_value(element), + "actions": actions(element), + "ax_path": ax_path, + }) + children = ax_value(element, AS.kAXChildrenAttribute) or [] + try: + child_iter = list(children) + except Exception: + child_iter = [] + for child_index, child in enumerate(child_iter): + if walk(child, rows, depth + 1, window_index, ax_path, child_index): + return True + return False + + +app = AS.AXUIElementCreateApplication(pid) +windows = ax_value(app, AS.kAXWindowsAttribute) or [] +try: + windows_list = list(windows) +except Exception: + windows_list = [] + +nodes = [] +truncated = False +for idx, window in enumerate(windows_list): + truncated = walk(window, nodes, 0, idx, [], idx) or truncated + +print(json.dumps({"ok": True, "nodes": nodes, "truncated": truncated})) +""".strip() + + AX_PRESS_LABEL_SCRIPT = """ +import json +import sys + +pid = int(sys.argv[1]) +label = sys.argv[2] +max_nodes = int(sys.argv[3]) +press = sys.argv[4] == "1" + +try: + import ApplicationServices as AS +except Exception as exc: + print(json.dumps({"ok": False, "error": f"pyobjc_missing: {exc}"})) + raise SystemExit(0) + + +def ax_value(element, attr): + try: + err, value = AS.AXUIElementCopyAttributeValue(element, attr, None) + except Exception: + return None + if err != 0: + return None + return value + + +def text_value(value): + if value is None: + return None + try: + return str(value) + except Exception: + return None + + +def rect_value(element): + pos = ax_value(element, AS.kAXPositionAttribute) + size = ax_value(element, AS.kAXSizeAttribute) + try: + return {"x": int(pos.x), "y": int(pos.y), "width": int(size.width), "height": int(size.height)} + except Exception: + return None + + +def action_names(element): + try: + err, names = AS.AXUIElementCopyActionNames(element, None) + except Exception: + return [] + if err != 0: + return [] + try: + return [str(item) for item in list(names)] + except Exception: + return [] + + +def walk(element, rows, depth=0, window_index=None): + if len(rows) >= max_nodes: + return + role = text_value(ax_value(element, AS.kAXRoleAttribute)) or "unknown" + name = text_value(ax_value(element, AS.kAXTitleAttribute)) or text_value(ax_value(element, AS.kAXDescriptionAttribute)) + row = {"role": role, "name": name, "depth": depth, "window_index": window_index, "bounds": rect_value(element), "actions": action_names(element)} + if name == label: + rows.append((row, element)) + children = ax_value(element, AS.kAXChildrenAttribute) or [] + try: + child_iter = list(children) + except Exception: + child_iter = [] + for child in child_iter: + walk(child, rows, depth + 1, window_index) + + +app = AS.AXUIElementCreateApplication(pid) +windows = ax_value(app, AS.kAXWindowsAttribute) or [] +try: + windows_list = list(windows) +except Exception: + windows_list = [] + +matches = [] +for idx, window in enumerate(windows_list): + walk(window, matches, 0, idx) + +safe_matches = [row for row, _ in matches] +if press: + if len(matches) != 1: + print(json.dumps({"ok": False, "error": "target_label_not_unique", "matches": safe_matches})) + raise SystemExit(0) + row, element = matches[0] + if "AXPress" not in row.get("actions", []): + print(json.dumps({"ok": False, "error": "target_not_pressable", "matches": safe_matches})) + raise SystemExit(0) + err = AS.AXUIElementPerformAction(element, "AXPress") + if err != 0: + print(json.dumps({"ok": False, "error": f"ax_press_failed:{err}", "matches": safe_matches})) + raise SystemExit(0) + +print(json.dumps({"ok": True, "matches": safe_matches, "count": len(safe_matches), "pressed": press})) +""".strip() + + def __init__( + self, + *, + runner: Callable[[list[str], float], RunnerResult] = run_command, + state_dir: Path | None = None, + platform_name: str | None = None, + accessibility_checker: Callable[[], bool | None] = check_accessibility_trusted, + screen_recording_checker: Callable[[], bool | None] = check_screen_recording_trusted, + now: Callable[[], str] = timestamp_utc, + helper_client: CustomerMacHelperClient | None = None, + cua_driver_client: CuaDriverClient | None = None, + ) -> None: + self.runner = runner + self.state_dir = state_dir or default_state_dir() + self.platform_name = platform_name or platform.system() + self.accessibility_checker = accessibility_checker + self.screen_recording_checker = screen_recording_checker + self.now = now + self.helper_client = helper_client if helper_client is not None else helper_client_from_environment(state_dir=self.state_dir) + self.cua_driver_client = cua_driver_client + self._engine_status_cache: dict[str, tuple[float, dict[str, Any]]] = {} + + def status(self) -> CommandResult: + frontmost = self._frontmost_app() + mirroring_status = self.iphone_mirroring_status() + screen_sharing = self.screen_sharing_status() + control_engines = self._control_engine_status() + return CommandResult( + ok=True, + data={ + "platform": self.platform_name, + "device": self._device_identity(), + "frontmost_app": redact_value(frontmost), + "permissions": { + "accessibility": self._permission_status("accessibility"), + "screen_recording": self._permission_status("screen_recording"), + }, + "iphone_mirroring": mirroring_status.data, + "screen_sharing": screen_sharing.data, + "control_engines": control_engines, + "safety": self._safety_summary(), + }, + warnings=mirroring_status.warnings + screen_sharing.warnings, + ) + + def capabilities(self) -> CommandResult: + control_engines = self._control_engine_status() + return CommandResult( + ok=True, + data={ + "supported_targets": ["mac", "desktop", "browser", "local_site", "iphone_mirroring", "screen_sharing_status"], + "actions": { + "mac": ["status", "capabilities", "snapshot", "ax_tree", "app_focus"], + "control_session": ["status", "start", "stop", "kill_switch"], + "desktop": ["see", "click", "type", "scroll", "drag", "hotkey", "focus_app", "window", "menu", "browser_action"], + "local_site": ["open", "reload", "back", "forward"], + "iphone_mirroring": sorted(SAFE_IPHONE_ACTIONS | GUARDED_IPHONE_ACTIONS | {"see", "tap", "swipe", "type"}), + "screen_sharing": ["status"], + }, + "engines": control_engines, + "control_modes": { + "full_access": "Customer-granted session: live clicks, typing, scrolling, dragging, app/window/menu/browser, and iPhone Mirroring actions do not require per-action approval, but sensitive apps remain blocked.", + "ask_permission": "Same surface, but high-impact text/send-style actions require approval evidence.", + }, + "takeover_warning": { + "seconds": 10, + "effect": "Live Accessibility, mouse, keyboard, and iPhone actions wait until the operator-visible takeover warning finishes.", + }, + "forbidden": ["public_mac_ports", "hidden_shell", "credential_collection", "security_bypass"], + "approval_gates": { + "full_access": "No per-action approval once the customer starts a Full Access control session.", + "ask_permission": "High-impact actions require approval; low-level navigation stays continuous.", + "screen_sharing_enablement": "The bridge reports status only; private overlay pairing remains the release path.", + }, + }, + ) + + def control_status(self) -> CommandResult: + session = read_control_session(self.state_dir) + control_engines = self._control_engine_status() + return CommandResult( + ok=True, + data={ + "session": session, + "mode": session.get("mode"), + "active": bool(session.get("active")), + "ready": bool(session.get("ready")), + "takeover_warning": session.get("takeover_warning"), + "kill_switch": bool(session.get("kill_switch")), + "current_agent": session.get("agent_label"), + "current_app": redact_value(self._frontmost_app()), + "peekaboo": control_engines["peekaboo"], + "control_engines": control_engines, + "permissions": { + "accessibility": self._permission_status("accessibility"), + "screen_recording": self._permission_status("screen_recording"), + }, + }, + ) + + def control_start(self, *, mode: str, agent_label: str | None = None) -> CommandResult: + normalized = mode.replace("-", "_") + if normalized not in CONTROL_MODES: + return CommandResult( + ok=False, + data={"active": False, "mode": normalized}, + errors=[make_error(code="control_mode_invalid", message="Control mode must be full_access or ask_permission.", guidance="Start a customer-granted control session with --mode full-access or --mode ask-permission.")], + ) + existing = read_control_session(self.state_dir) + existing_warning = existing.get("takeover_warning") if isinstance(existing.get("takeover_warning"), dict) else {} + warning_reused = existing.get("active") is True and existing_warning.get("active") is True + session = start_control_session(mode=normalized, agent_label=agent_label, state_dir=self.state_dir) + if not warning_reused: + self._emit_takeover_warning(session) + session = write_control_session(session, self.state_dir) + return CommandResult( + ok=True, + data={ + "started": True, + "ready": bool(session.get("ready")), + "takeover_warning": session.get("takeover_warning"), + "takeover_warning_reused": warning_reused, + "session": session, + "peekaboo": self._peekaboo_status(), + }, + provenance={"source": "control_session", "takeover_warning": session.get("takeover_warning")}, + ) + + def control_stop(self) -> CommandResult: + session = stop_control_session(self.state_dir) + return CommandResult(ok=True, data={"stopped": True, "session": session}, provenance={"source": "control_session"}) + + def control_kill_switch(self) -> CommandResult: + session = kill_control_session(self.state_dir) + return CommandResult(ok=True, data={"killed": True, "session": session}, provenance={"source": "control_session", "kill_switch": True}) + + def desktop_see(self, *, max_chars: int = 4000, max_nodes: int = 200) -> CommandResult: + frontmost = self._frontmost_app() + sensitive_block = self._sensitive_app_observation_block(frontmost=frontmost, surface="desktop_see") + if sensitive_block is not None: + return sensitive_block + warnings: list[str] = [] + client = self._cua_driver_action_client() + if client is not None: + cua_seen = self._cua_driver_see(client=client, max_nodes=max_nodes) + if cua_seen.ok: + return cua_seen + warnings.extend(cua_seen.warnings) + warnings.extend(str(error.get("message") or error.get("code") or "cua-driver see failed") for error in cua_seen.errors) + peekaboo = self._peekaboo_status() + if peekaboo.get("available"): + peekaboo_seen = self._peekaboo_see(peekaboo=peekaboo, target="desktop", max_chars=max_chars, max_nodes=max_nodes) + if peekaboo_seen.ok: + return peekaboo_seen + warnings.extend(peekaboo_seen.warnings) + warnings.extend(str(error.get("message") or error.get("code") or "Peekaboo see failed") for error in peekaboo_seen.errors) + screenshot = self.snapshot(max_chars=max_chars) + ax = self.ax_tree(max_nodes=max_nodes) + snapshot_id = screenshot.data.get("snapshot_id") if screenshot.ok else None + elements = self._elements_from_ax(ax.data.get("nodes", []) if ax.ok else [], snapshot_id=snapshot_id) + if snapshot_id and elements: + self._write_snapshot_index(snapshot_id=snapshot_id, target="desktop", elements=elements, engine="ax_fallback") + return CommandResult( + ok=screenshot.ok or ax.ok, + data={ + "engine": "fallback", + "frontmost_app": redact_value(frontmost), + "snapshot_id": snapshot_id, + "peekaboo": peekaboo, + "peekaboo_output": None, + "peekaboo_truncated": False, + "screenshot": screenshot.data if screenshot.ok else None, + "ax": ax.data if ax.ok else None, + "elements": elements, + }, + warnings=warnings + screenshot.warnings + ax.warnings + ([] if peekaboo.get("available") else ["Peekaboo not installed; used built-in fallback."]), + errors=[] if screenshot.ok or ax.ok else screenshot.errors + ax.errors, + provenance={"source": "peekaboo_fallback"}, + ) + + def desktop_click( + self, + *, + target_label: str | None = None, + x: int | None = None, + y: int | None = None, + snapshot_id: str | None = None, + element_id: str | None = None, + dry_run: bool = False, + ) -> CommandResult: + resolved_label = target_label + resolved_engine = None + resolved_peekaboo_snapshot_id = None + resolved_peekaboo_element_id = None + resolved_ax_target: dict[str, Any] | None = None + require_snapshot_target = False + resolved_actions: list[Any] = [] + if snapshot_id and element_id is None and target_label is None and x is not None and y is not None: + resolved_point = self._resolve_snapshot_coordinates(snapshot_id=snapshot_id, x=x, y=y, expected_target="desktop") + if not resolved_point.ok: + return resolved_point + point = resolved_point.data["point"] + x = int(point["x"]) + y = int(point["y"]) + resolved_ax_target = resolved_point.data.get("ax_target") if isinstance(resolved_point.data.get("ax_target"), dict) else None + require_snapshot_target = True + else: + resolved = self._resolve_snapshot_target(snapshot_id=snapshot_id, element_id=element_id, target_label=target_label) + if not resolved.ok: + return resolved + resolved_engine = resolved.data.get("engine") + resolved_peekaboo_snapshot_id = resolved.data.get("peekaboo_snapshot_id") + resolved_peekaboo_element_id = resolved.data.get("peekaboo_element_id") + resolved_ax_target = resolved.data.get("ax_target") if isinstance(resolved.data.get("ax_target"), dict) else None + resolved_actions = resolved.data.get("actions") if isinstance(resolved.data.get("actions"), list) else [] + if resolved.data.get("point"): + point = resolved.data["point"] + x = int(point["x"]) + y = int(point["y"]) + resolved_label = str(resolved.data.get("target_label") or target_label or element_id or "") + target_label = None + if dry_run: + return CommandResult( + ok=True, + data={ + "clicked": False, + "would_click": True, + "target_label": resolved_label, + "snapshot_id": snapshot_id, + "element_id": element_id, + "point": self._point(x, y), + }, + ) + sensitive_block = self._sensitive_app_action_block(frontmost=self._frontmost_app(), action="click") + if sensitive_block is not None: + return sensitive_block + cua_warnings: list[str] = [] + if resolved_engine == "cua_driver": + client = self._cua_driver_action_client() + cua_target = self._cua_driver_target_from_resolved(resolved) + if client is not None and cua_target is not None: + cua_args = {**cua_target, "delivery_mode": "background", "session": "evaos-desktop-bridge"} + cua_result = client.call("click", cua_args, timeout=15.0) + acceptable, reason = self._cua_driver_result_acceptable(cua_result, tool="click") + if acceptable: + return CommandResult( + ok=True, + data={ + "clicked": True, + "target_label": resolved_label, + "snapshot_id": snapshot_id, + "element_id": element_id, + "point": self._point(x, y), + "engine": "cua_driver", + "cua": cua_result.data.get("structured") if isinstance(cua_result.data.get("structured"), dict) else {}, + }, + warnings=cua_result.warnings, + provenance={"source": "cua_driver"}, + ) + cua_warnings.extend(self._cua_driver_warning(reason or "cua-driver click failed")) + if resolved_ax_target is not None: + target_block = self._sensitive_ax_target_action_block(ax_target=resolved_ax_target, action="press") + if target_block is not None: + return target_block + inert_block = self._inert_ax_target_action_block(ax_target=resolved_ax_target, action="press") + if inert_block is not None: + return inert_block + if "AXPress" in {str(item) for item in resolved_actions}: + pressed = self._helper_ax_action( + action="press", + target=resolved_ax_target, + fallback_data={ + "clicked": False, + "target_label": resolved_label, + "snapshot_id": snapshot_id, + "element_id": element_id, + "point": self._point(x, y), + }, + ) + if pressed.ok: + pressed.data.update({"clicked": True, "target_label": resolved_label, "snapshot_id": snapshot_id, "element_id": element_id, "point": self._point(x, y)}) + pressed.warnings = cua_warnings + pressed.warnings + return pressed + peekaboo = self._peekaboo_status() + if peekaboo.get("available"): + if resolved_engine == "peekaboo" and resolved_peekaboo_snapshot_id and resolved_peekaboo_element_id: + result = self.runner( + [ + str(peekaboo["path"]), + "click", + "--snapshot", + str(resolved_peekaboo_snapshot_id), + "--on", + str(resolved_peekaboo_element_id), + "--json", + "--no-remote", + ], + 5.0, + ) + if result.returncode == 0: + return CommandResult( + ok=True, + data={ + "clicked": True, + "target_label": resolved_label, + "snapshot_id": snapshot_id, + "element_id": element_id, + "peekaboo_snapshot_id": redact_value(resolved_peekaboo_snapshot_id), + "peekaboo_element_id": resolved_peekaboo_element_id, + "point": self._point(x, y), + "engine": "peekaboo", + }, + warnings=cua_warnings + self._stderr_warning(result), + provenance={"source": "peekaboo"}, + ) + if target_label: + result = self.runner([str(peekaboo["path"]), "click", target_label, "--json", "--no-remote"], 15.0) + if result.returncode == 0: + return CommandResult(ok=True, data={"clicked": True, "target_label": target_label, "snapshot_id": snapshot_id, "element_id": element_id, "engine": "peekaboo"}, warnings=cua_warnings + self._stderr_warning(result), provenance={"source": "peekaboo"}) + elif x is not None and y is not None: + result = self.runner([str(peekaboo["path"]), "click", "--coords", f"{x},{y}", "--global-coords", "--json", "--no-remote"], 15.0) + if result.returncode == 0: + return CommandResult(ok=True, data={"clicked": True, "target_label": resolved_label, "snapshot_id": snapshot_id, "element_id": element_id, "point": self._point(x, y), "engine": "peekaboo"}, warnings=cua_warnings + self._stderr_warning(result), provenance={"source": "peekaboo"}) + if target_label: + pressed = self._press_frontmost_target(target_label=target_label) + if pressed.ok: + pressed.data["engine"] = "ax_fallback" + pressed.provenance = {"source": "ax_fallback"} + pressed.warnings = cua_warnings + pressed.warnings + return pressed + if x is None or y is None: + return CommandResult(ok=False, data={"clicked": False}, errors=[make_error(code="desktop_click_target_required", message="desktop_click requires target_label or x/y.", guidance="Prefer a visible target label from desktop_see; use coordinates only when labels are unavailable.")]) + result = self._mouse_action("click", x=x, y=y, target=resolved_ax_target, require_target=require_snapshot_target) + result.warnings = cua_warnings + result.warnings + return result + + def desktop_set_value( + self, + *, + snapshot_id: str, + element_id: str, + value: str, + attribute: str = "value", + dry_run: bool = False, + ) -> CommandResult: + if attribute not in {"value", "selected_text"}: + return CommandResult(ok=False, data={"set": False, "attribute": attribute}, errors=[make_error(code="desktop_set_value_attribute_invalid", message="desktop_set_value attribute must be value or selected_text.", guidance="Use the fixed AXValue or AXSelectedText setter only.")]) + if not isinstance(value, str) or value == "": + return CommandResult(ok=False, data={"set": False}, errors=[make_error(code="desktop_set_value_required", message="desktop_set_value requires non-empty text.", guidance="Pass exact approved text for the selected native field.")]) + if len(value) > 4000: + return CommandResult(ok=False, data={"set": False, "value_sha256": self._text_hash(value)}, errors=[make_error(code="desktop_set_value_too_long", message="desktop_set_value is capped at 4000 characters.", guidance="Split longer content or use an app-specific import path.")]) + if self._looks_like_secret(value): + return CommandResult(ok=False, data={"set": False, "value_sha256": self._text_hash(value)}, errors=[make_error(code="desktop_set_value_secret_blocked", message="desktop_set_value refuses token-like or password-like content.", guidance="Do not send credentials or secrets through agent desktop control.")]) + resolved = self._resolve_snapshot_target(snapshot_id=snapshot_id, element_id=element_id, target_label=None) + if not resolved.ok: + return resolved + ax_target = resolved.data.get("ax_target") if isinstance(resolved.data.get("ax_target"), dict) else None + if ax_target is None: + return CommandResult(ok=False, data={"set": False, "snapshot_id": snapshot_id, "element_id": element_id}, errors=[make_error(code="desktop_set_value_ax_target_required", message="desktop_set_value requires an Accessibility-backed snapshot element.", guidance="Run desktop_see with the built-in AX fallback or choose a native AX text field from the latest snapshot.")]) + role = str(resolved.data.get("role") or "") + if role == "AXSecureTextField" or re.search(r"password|passcode|token|secret", str(resolved.data.get("target_label") or ""), re.IGNORECASE): + return CommandResult(ok=False, data={"set": False, "role": role, "value_sha256": self._text_hash(value)}, errors=[make_error(code="desktop_set_value_secure_field_blocked", message="desktop_set_value is blocked for secure or credential-like fields.", guidance="Do not use desktop control to enter credentials or secrets.")]) + if role not in AX_EDITABLE_VALUE_ROLES: + return CommandResult(ok=False, data={"set": False, "role": role, "value_sha256": self._text_hash(value)}, errors=[make_error(code="desktop_set_value_non_text_field_blocked", message="desktop_set_value is blocked for non-text Accessibility roles.", guidance="Choose a native editable AXTextField, AXTextArea, or AXComboBox element from a fresh desktop_see snapshot.")]) + data = { + "set": False, + "would_set": dry_run, + "snapshot_id": snapshot_id, + "element_id": element_id, + "target_label": resolved.data.get("target_label"), + "role": role, + "attribute": "AXSelectedText" if attribute == "selected_text" else "AXValue", + "value_sha256": self._text_hash(value), + } + if dry_run: + return CommandResult(ok=True, data=data) + sensitive_block = self._sensitive_app_action_block(frontmost=self._frontmost_app(), action="set_value") + if sensitive_block is not None: + return sensitive_block + target_block = self._sensitive_ax_target_action_block(ax_target=ax_target, action="set_value") + if target_block is not None: + return target_block + inert_block = self._inert_ax_target_action_block(ax_target=ax_target, action="set_value") + if inert_block is not None: + return inert_block + action = "set_selected_text" if attribute == "selected_text" else "set_value" + result = self._helper_ax_action(action=action, target=ax_target, value=value, attribute=data["attribute"], fallback_data=data) + if result.ok: + result.data.update({**data, "set": True, "would_set": False, "engine": result.data.get("engine", "helper_ax")}) + return result + + def desktop_type(self, *, text: str, dry_run: bool = False) -> CommandResult: + if not isinstance(text, str) or text == "": + return CommandResult(ok=False, data={"typed": False}, errors=[make_error(code="desktop_text_required", message="desktop_type requires non-empty text.", guidance="Pass exact text to type into the focused field.")]) + if dry_run: + return CommandResult(ok=True, data={"typed": False, "would_type": True, "text_preview": self._safe_preview(text), "text_sha256": self._text_hash(text)}) + sensitive_block = self._sensitive_app_action_block(frontmost=self._frontmost_app(), action="type") + if sensitive_block is not None: + return sensitive_block + cua_warnings: list[str] = [] + client = self._cua_driver_action_client() + if client is not None: + pid, reason = self._cua_driver_frontmost_pid() + if pid is not None: + cua_result = client.call( + "type_text", + {"pid": pid, "text": text, "delivery_mode": "background", "session": "evaos-desktop-bridge"}, + timeout=20.0, + ) + acceptable, reason = self._cua_driver_result_acceptable(cua_result, tool="type_text") + if acceptable: + return CommandResult( + ok=True, + data={ + "typed": True, + "text_preview": self._safe_preview(text), + "text_sha256": self._text_hash(text), + "engine": "cua_driver", + "input_method": "type_text", + "cua": cua_result.data.get("structured") if isinstance(cua_result.data.get("structured"), dict) else {}, + }, + warnings=cua_result.warnings, + provenance={"source": "cua_driver"}, + ) + cua_warnings.extend(self._cua_driver_warning(reason or "cua-driver type_text failed")) + peekaboo = self._peekaboo_status() + if peekaboo.get("available"): + result = self.runner([str(peekaboo["path"]), "paste", "--text", text, "--json", "--no-remote"], 20.0) + if result.returncode == 0: + return CommandResult( + ok=True, + data={ + "typed": True, + "text_preview": self._safe_preview(text), + "text_sha256": self._text_hash(text), + "engine": "peekaboo", + "input_method": "paste", + }, + warnings=cua_warnings + self._stderr_warning(result), + provenance={"source": "peekaboo_paste"}, + ) + result = self.runner([str(peekaboo["path"]), "type", "--text", text, "--profile", "linear", "--json", "--no-remote"], 20.0) + if result.returncode == 0: + return CommandResult( + ok=True, + data={ + "typed": True, + "text_preview": self._safe_preview(text), + "text_sha256": self._text_hash(text), + "engine": "peekaboo", + "input_method": "type", + }, + warnings=cua_warnings + self._stderr_warning(result), + provenance={"source": "peekaboo_type"}, + ) + result = self._keystroke_arbitrary_text(text) + result.warnings = cua_warnings + result.warnings + return result + + def desktop_scroll(self, *, direction: str = "down", amount: int = 600, dry_run: bool = False) -> CommandResult: + if direction not in {"up", "down", "left", "right"}: + return CommandResult(ok=False, data={"scrolled": False, "direction": direction}, errors=[make_error(code="desktop_scroll_direction_invalid", message="desktop_scroll direction must be up, down, left, or right.", guidance="Use a named scroll direction.")]) + amount = max(1, min(int(amount), 5000)) + if dry_run: + return CommandResult(ok=True, data={"scrolled": False, "would_scroll": True, "direction": direction, "amount": amount}) + sensitive_block = self._sensitive_app_action_block(frontmost=self._frontmost_app(), action="scroll") + if sensitive_block is not None: + return sensitive_block + cua_warnings: list[str] = [] + client = self._cua_driver_action_client() + if client is not None: + pid, reason = self._cua_driver_frontmost_pid() + if pid is not None: + cua_amount = max(1, min(int(round(amount / 120)) or 1, 50)) + cua_result = client.call( + "scroll", + {"pid": pid, "direction": direction, "amount": cua_amount, "by": "line", "delivery_mode": "background", "session": "evaos-desktop-bridge"}, + timeout=10.0, + ) + acceptable, reason = self._cua_driver_result_acceptable(cua_result, tool="scroll") + if acceptable: + return CommandResult( + ok=True, + data={"scrolled": True, "direction": direction, "amount": amount, "engine": "cua_driver", "cua_amount": cua_amount}, + warnings=cua_result.warnings, + provenance={"source": "cua_driver"}, + ) + cua_warnings.extend(self._cua_driver_warning(reason or "cua-driver scroll failed")) + peekaboo = self._peekaboo_status() + if peekaboo.get("available"): + result = self.runner([str(peekaboo["path"]), "scroll", "--direction", direction, "--amount", str(amount), "--json"], 10.0) + if result.returncode == 0: + return CommandResult(ok=True, data={"scrolled": True, "direction": direction, "amount": amount, "engine": "peekaboo"}, warnings=cua_warnings + self._stderr_warning(result), provenance={"source": "peekaboo"}) + result = self._mouse_action("scroll", direction=direction, amount=amount) + result.warnings = cua_warnings + result.warnings + return result + + def desktop_drag(self, *, from_x: int, from_y: int, to_x: int, to_y: int, dry_run: bool = False) -> CommandResult: + if dry_run: + return CommandResult(ok=True, data={"dragged": False, "would_drag": True, "from": self._point(from_x, from_y), "to": self._point(to_x, to_y)}) + sensitive_block = self._sensitive_app_action_block(frontmost=self._frontmost_app(), action="drag") + if sensitive_block is not None: + return sensitive_block + peekaboo = self._peekaboo_status() + if peekaboo.get("available"): + result = self.runner( + [ + str(peekaboo["path"]), + "drag", + "--from-coords", + f"{from_x},{from_y}", + "--to-coords", + f"{to_x},{to_y}", + "--profile", + "human", + "--json", + "--no-remote", + ], + 20.0, + ) + if result.returncode == 0: + return CommandResult(ok=True, data={"dragged": True, "from": self._point(from_x, from_y), "to": self._point(to_x, to_y), "engine": "peekaboo"}, warnings=self._stderr_warning(result), provenance={"source": "peekaboo"}) + return self._mouse_action("drag", from_x=from_x, from_y=from_y, to_x=to_x, to_y=to_y) + + def desktop_hotkey(self, *, keys: str, dry_run: bool = False) -> CommandResult: + normalized = self._normalize_hotkey(keys) + if not normalized: + return CommandResult(ok=False, data={"pressed": False}, errors=[make_error(code="desktop_hotkey_required", message="desktop_hotkey requires keys like cmd+l or cmd+shift+4.", guidance="Use a plus-delimited hotkey string.")]) + if dry_run: + return CommandResult(ok=True, data={"pressed": False, "would_press": True, "keys": normalized}) + sensitive_block = self._sensitive_app_action_block(frontmost=self._frontmost_app(), action="hotkey") + if sensitive_block is not None: + return sensitive_block + cua_warnings: list[str] = [] + client = self._cua_driver_action_client() + if client is not None: + pid, reason = self._cua_driver_frontmost_pid() + if pid is not None: + cua_result = client.call( + "hotkey", + {"pid": pid, "keys": normalized.split("+"), "delivery_mode": "background", "session": "evaos-desktop-bridge"}, + timeout=10.0, + ) + acceptable, reason = self._cua_driver_result_acceptable(cua_result, tool="hotkey") + if acceptable: + return CommandResult( + ok=True, + data={"pressed": True, "keys": normalized, "engine": "cua_driver", "cua": cua_result.data.get("structured") if isinstance(cua_result.data.get("structured"), dict) else {}}, + warnings=cua_result.warnings, + provenance={"source": "cua_driver"}, + ) + cua_warnings.extend(self._cua_driver_warning(reason or "cua-driver hotkey failed")) + peekaboo = self._peekaboo_status() + if peekaboo.get("available"): + result = self.runner([str(peekaboo["path"]), "hotkey", "--keys", normalized, "--json", "--no-remote"], 10.0) + if result.returncode == 0: + return CommandResult(ok=True, data={"pressed": True, "keys": normalized, "engine": "peekaboo"}, warnings=cua_warnings + self._stderr_warning(result), provenance={"source": "peekaboo"}) + result = self._osascript_hotkey(normalized) + result.warnings = cua_warnings + result.warnings + return result + + def desktop_focus_app(self, *, app_name: str, dry_run: bool = False) -> CommandResult: + if not self._safe_app_name(app_name): + return CommandResult(ok=False, data={"focused": False}, errors=[make_error(code="app_name_not_allowed", message="App name is outside the supported character set.", guidance="Use a visible macOS app name.")]) + if self._is_sensitive_app(app_name): + return CommandResult(ok=False, data={"focused": False, "would_focus": dry_run, "app_name": app_name}, errors=[make_error(code="sensitive_app_blocked", message="This app is on the sensitive-app denylist.", guidance="Only request named actions against non-sensitive apps.")]) + workbench_focus = self._workbench_focus_result(app_name=app_name, dry_run=dry_run, verify_frontmost=True) + if workbench_focus is not None: + return workbench_focus + if dry_run: + return CommandResult(ok=True, data={"focused": False, "would_focus": True, "app_name": app_name}) + peekaboo = self._peekaboo_status() + if peekaboo.get("available"): + result = self.runner([str(peekaboo["path"]), "app", "switch", "--to", app_name, "--no-remote"], 10.0) + if result.returncode == 0: + return CommandResult(ok=True, data={"focused": True, "app_name": app_name, "engine": "peekaboo"}, warnings=self._stderr_warning(result), provenance={"source": "peekaboo"}) + result = self.runner(["open", "-a", app_name], 10.0) + if result.returncode != 0: + return CommandResult(ok=False, data={"focused": False, "app_name": app_name}, errors=[make_error(code="app_focus_failed", message="macOS refused to open or focus the requested app.", guidance="Verify the app is installed and visible.")], warnings=self._stderr_warning(result)) + return CommandResult(ok=True, data={"focused": True, "app_name": app_name, "engine": "macos_open"}) + + def desktop_window(self, *, action: str, dry_run: bool = False) -> CommandResult: + if action not in {"focus", "minimize", "maximize", "zoom", "close"}: + return CommandResult(ok=False, data={"performed": False, "action": action}, errors=[make_error(code="desktop_window_action_invalid", message="desktop_window action must be focus, minimize, maximize, or close.", guidance="Use a named window action.")]) + if dry_run: + return CommandResult(ok=True, data={"performed": False, "would_perform": True, "action": action}) + sensitive_block = self._sensitive_app_action_block(frontmost=self._frontmost_app(), action=f"window_{action}") + if sensitive_block is not None: + return sensitive_block + peekaboo_action = "maximize" if action == "zoom" else action + peekaboo = self._peekaboo_status() + if peekaboo.get("available"): + result = self.runner([str(peekaboo["path"]), "window", peekaboo_action, "--json", "--no-remote"], 20.0) + if result.returncode == 0: + return CommandResult(ok=True, data={"performed": True, "action": action, "peekaboo_action": peekaboo_action, "engine": "peekaboo"}, warnings=self._stderr_warning(result), provenance={"source": "peekaboo"}) + key = {"close": "w", "minimize": "m", "maximize": "f", "zoom": "f", "focus": "`"}[action] + combo = "cmd+" + key if action not in {"maximize", "zoom"} else "ctrl+cmd+f" + return self._osascript_hotkey(combo) + + def desktop_menu(self, *, menu_path: str, dry_run: bool = False) -> CommandResult: + if not isinstance(menu_path, str) or not menu_path.strip() or len(menu_path) > 240: + return CommandResult(ok=False, data={"performed": False}, errors=[make_error(code="desktop_menu_path_required", message="desktop_menu requires a menu path such as File > New Tab.", guidance="Use the visible app menu path.")]) + if dry_run: + return CommandResult(ok=True, data={"performed": False, "would_perform": True, "menu_path": menu_path}) + sensitive_block = self._sensitive_app_action_block(frontmost=self._frontmost_app(), action="menu") + if sensitive_block is not None: + return sensitive_block + frontmost = self._frontmost_app() + pid = self._pid_for_app(frontmost) + if pid is not None and self.helper_client is not None: + result = self._helper_ax_action( + action="menu", + target={"pid": pid, "app_name": frontmost, "process_name": self._process_name_for_pid(pid), "path": []}, + menu_path=menu_path, + fallback_data={"performed": False, "menu_path": menu_path}, + ) + if result.ok: + result.data.update({"performed": True, "menu_path": menu_path, "engine": result.data.get("engine", "helper_ax")}) + return result + if result.errors and result.errors[0].get("code") not in {"helper_ax_menu_not_found", "helper_ax_menu_failed"}: + return result + peekaboo = self._peekaboo_status() + if not peekaboo.get("available"): + return CommandResult(ok=False, data={"performed": False, "menu_path": menu_path}, errors=[make_error(code="peekaboo_required", message="desktop_menu requires Peekaboo for reliable menu traversal.", guidance="Install Peekaboo with brew install steipete/tap/peekaboo and approve Workbench permissions.")]) + result = self.runner([str(peekaboo["path"]), "menu", "click", "--path", menu_path, "--json", "--no-remote"], 20.0) + if result.returncode != 0: + return CommandResult(ok=False, data={"performed": False, "menu_path": menu_path}, errors=[make_error(code="desktop_menu_failed", message="Peekaboo could not perform the requested menu path.", guidance="Run desktop_see, verify the app is focused, then retry.")], warnings=self._stderr_warning(result)) + return CommandResult(ok=True, data={"performed": True, "menu_path": menu_path, "engine": "peekaboo"}, warnings=self._stderr_warning(result), provenance={"source": "peekaboo"}) + + def desktop_browser_action(self, *, action: str, url: str | None = None, dry_run: bool = False) -> CommandResult: + if action not in {"reload", "back", "forward", "new_tab", "open_url"}: + return CommandResult(ok=False, data={"performed": False, "action": action}, errors=[make_error(code="browser_action_invalid", message="desktop_browser_action action must be reload, back, forward, new_tab, or open_url.", guidance="Use one of the named browser actions.")]) + if action == "open_url" and (not url or urlparse(url).scheme not in {"http", "https"}): + return CommandResult(ok=False, data={"performed": False, "action": action, "url": redact_value(url)}, errors=[make_error(code="browser_url_invalid", message="open_url requires an http(s) URL.", guidance="Pass a normal website URL.")]) + if dry_run: + return CommandResult(ok=True, data={"performed": False, "would_perform": True, "action": action, "url": redact_value(url)}) + if action == "open_url": + peekaboo = self._peekaboo_status() + if peekaboo.get("available"): + result = self.runner([str(peekaboo["path"]), "open", str(url), "--wait-until-ready", "--json", "--no-remote"], 20.0) + if result.returncode == 0: + return CommandResult(ok=True, data={"performed": True, "action": action, "url": redact_value(url), "engine": "peekaboo"}, warnings=self._stderr_warning(result), provenance={"source": "peekaboo"}) + result = self.runner(["open", url], 10.0) + if result.returncode != 0: + return CommandResult(ok=False, data={"performed": False, "action": action, "url": redact_value(url)}, errors=[make_error(code="browser_open_url_failed", message="macOS could not open the URL.", guidance="Verify the URL and default browser.")], warnings=self._stderr_warning(result)) + return CommandResult(ok=True, data={"performed": True, "action": action, "url": redact_value(url), "engine": "macos_open"}) + hotkeys = {"reload": "cmd+r", "back": "cmd+[", "forward": "cmd+]", "new_tab": "cmd+t"} + return self.desktop_hotkey(keys=hotkeys[action], dry_run=False) + + def iphone_see(self, *, max_chars: int = 4000, max_nodes: int = 200) -> CommandResult: + status = self.iphone_mirroring_status() + if not status.data.get("running"): + return status + if status.data.get("frontmost") is not True: + if self._full_access_active(): + focus = self.iphone_mirroring_focus(dry_run=False) + if focus.ok: + status = self.iphone_mirroring_status() + if status.data.get("frontmost") is not True: + return CommandResult( + ok=False, + data={"target": "iphone_mirroring", "running": True, "frontmost": False}, + errors=[ + make_error( + code="iphone_mirroring_not_frontmost", + message="iPhone Mirroring is running but is not the visible frontmost app.", + guidance="Focus iPhone Mirroring from Workbench or an active control session, then rerun iphone_see.", + ) + ], + ) + peekaboo = self._peekaboo_status() + warnings: list[str] = [] + if peekaboo.get("available"): + seen = self._peekaboo_iphone_region_see(peekaboo=peekaboo) + if seen.ok: + return seen + warnings.extend(seen.warnings) + warnings.extend(str(error.get("message") or error.get("code") or "Peekaboo iPhone capture failed") for error in seen.errors) + seen = self.desktop_see(max_chars=max_chars, max_nodes=max_nodes) + seen.data["target"] = "iphone_mirroring" + seen.warnings = warnings + seen.warnings + return seen + + def iphone_tap( + self, + *, + target_label: str | None = None, + x: int | None = None, + y: int | None = None, + snapshot_id: str | None = None, + element_id: str | None = None, + dry_run: bool = False, + ) -> CommandResult: + if dry_run: + resolved_label = target_label + if snapshot_id and element_id is None and target_label is None and x is not None and y is not None: + resolved_point = self._resolve_snapshot_coordinates(snapshot_id=snapshot_id, x=x, y=y, expected_target="iphone_mirroring") + if not resolved_point.ok: + return resolved_point + point = resolved_point.data["point"] + x = int(point["x"]) + y = int(point["y"]) + else: + resolved = self._resolve_snapshot_target(snapshot_id=snapshot_id, element_id=element_id, target_label=target_label) + if not resolved.ok: + return resolved + if resolved.data.get("point"): + point = resolved.data["point"] + x = int(point["x"]) + y = int(point["y"]) + resolved_label = str(resolved.data.get("target_label") or target_label or element_id or "") + return CommandResult(ok=True, data={"performed": False, "would_tap": True, "target_label": resolved_label, "snapshot_id": snapshot_id, "element_id": element_id, "point": self._point(x, y)}) + focus = self.iphone_mirroring_focus(dry_run=False) + if not focus.ok: + return focus + if snapshot_id and element_id is None and target_label is None and x is not None and y is not None: + resolved_point = self._resolve_snapshot_coordinates(snapshot_id=snapshot_id, x=x, y=y, expected_target="iphone_mirroring") + if not resolved_point.ok: + return resolved_point + point = resolved_point.data["point"] + x = int(point["x"]) + y = int(point["y"]) + snapshot_id = None + return self.desktop_click(target_label=target_label, x=x, y=y, snapshot_id=snapshot_id, element_id=element_id, dry_run=False) + + def iphone_swipe(self, *, direction: str, dry_run: bool = False) -> CommandResult: + mapping = { + "left": "swipe_left", + "right": "swipe_right", + "up": "swipe_up", + "down": "swipe_down", + } + action = mapping.get(direction) + if action is None: + return CommandResult(ok=False, data={"performed": False, "direction": direction}, errors=[make_error(code="iphone_swipe_direction_invalid", message="iphone_swipe direction must be left, right, up, or down.", guidance="Use a named direction.")]) + return self.iphone_mirroring_action(action=action, dry_run=dry_run) + + def iphone_type(self, *, text: str, dry_run: bool = False) -> CommandResult: + if dry_run: + return CommandResult(ok=True, data={"performed": False, "would_type": True, "text_preview": self._safe_preview(text), "text_sha256": self._text_hash(text)}) + focus = self.iphone_mirroring_focus(dry_run=False) + if not focus.ok: + return focus + typed = self._keystroke_arbitrary_text(text) + if typed.ok: + typed.provenance = {"source": "system_events", "customer_control": True, "reason": "iphone_mirroring_exact_text"} + return typed + fallback = self.desktop_type(text=text, dry_run=False) + fallback.warnings = typed.warnings + ["System Events exact iPhone typing failed; used desktop text fallback."] + fallback.warnings + return fallback + + def snapshot(self, *, max_chars: int) -> CommandResult: + frontmost = self._frontmost_app() + sensitive_block = self._sensitive_app_observation_block(frontmost=frontmost, surface="screenshot") + if sensitive_block is not None: + return sensitive_block + screenshot_path = self._capture_screenshot([]) + title = self._front_window_title() + capped_title, title_truncated = cap_text(redact_value(title), max_chars) + warnings = ["window title truncated"] if title_truncated else [] + snapshot_id = self._new_snapshot_id("desktop") + image = self._image_artifact(screenshot_path, snapshot_id=snapshot_id) if screenshot_path else None + return CommandResult( + ok=screenshot_path is not None, + data={ + "snapshot_id": snapshot_id if screenshot_path else None, + "timestamp": self.now(), + "frontmost_app": redact_value(frontmost), + "window_title": capped_title, + "screenshot_path": redact_value(screenshot_path) if screenshot_path else None, + "screenshot": image, + "max_chars": max_chars, + }, + warnings=warnings if screenshot_path else warnings + ["screenshot unavailable; Screen Recording permission may be missing"], + ) + + def ax_tree(self, *, max_nodes: int) -> CommandResult: + frontmost = self._frontmost_app() + sensitive_block = self._sensitive_app_observation_block(frontmost=frontmost, surface="ax_tree") + if sensitive_block is not None: + return sensitive_block + if self.accessibility_checker() is False: + return CommandResult(ok=False, data={"nodes": [], "truncated": False}, errors=[self._permission_error("accessibility", "read the Mac Accessibility tree")]) + pid = self._pid_for_app(frontmost) if frontmost else None + if pid is None: + return CommandResult( + ok=False, + data={"nodes": [], "truncated": False, "frontmost_app": redact_value(frontmost)}, + errors=[make_error(code="frontmost_pid_not_found", message="Could not resolve a frontmost app PID.", guidance="Ensure the target app is visible and rerun status.")], + ) + payload, errors, warnings = self._ax_snapshot(pid=pid, max_nodes=max_nodes) + if payload is None: + return CommandResult(ok=False, data={"nodes": [], "truncated": False}, errors=errors, warnings=warnings) + process_name = self._process_name_for_pid(pid) + raw_nodes: list[Any] = [] + for row in payload.get("nodes", [])[:max_nodes]: + if isinstance(row, dict): + row = dict(row) + path = row.get("ax_path") + if isinstance(path, list): + row["ax_target"] = {"pid": pid, "app_name": frontmost, "process_name": process_name, "path": path} + raw_nodes.append(row) + nodes = [self._safe_node(row) for row in raw_nodes][:max_nodes] + truncated = bool(payload.get("truncated")) + if truncated: + warnings.append(f"AX tree truncated at {max_nodes} nodes") + return CommandResult(ok=True, data={"frontmost_app": redact_value(frontmost), "pid": pid, "nodes": nodes, "truncated": truncated, "max_nodes": max_nodes}, warnings=warnings) + + def app_focus(self, *, app_name: str, dry_run: bool = False) -> CommandResult: + if not self._safe_app_name(app_name): + return CommandResult(ok=False, data={"focused": False, "would_focus": dry_run}, errors=[make_error(code="app_name_not_allowed", message="App name is outside the safe named-action character set.", guidance="Use a visible macOS app name with letters, numbers, spaces, dots, underscores, plus, at-sign, slash, colon, hash, or hyphen.")]) + if self._is_sensitive_app(app_name): + return CommandResult(ok=False, data={"focused": False, "would_focus": dry_run, "app_name": app_name}, errors=[make_error(code="sensitive_app_blocked", message="This app is on the sensitive-app denylist.", guidance="Only request named actions against non-sensitive apps.")]) + workbench_focus = self._workbench_focus_result(app_name=app_name, dry_run=dry_run, verify_frontmost=True) + if workbench_focus is not None: + return workbench_focus + if dry_run: + return CommandResult(ok=True, data={"focused": False, "would_focus": True, "app_name": app_name}) + warnings: list[str] = [] + peekaboo = self._peekaboo_status() + if peekaboo.get("available"): + result = self.runner([str(peekaboo["path"]), "app", "switch", "--to", app_name, "--no-remote"], 10.0) + warnings.extend(self._stderr_warning(result)) + if result.returncode == 0 and self._wait_for_frontmost(app_name, timeout_seconds=2.0): + return CommandResult(ok=True, data={"focused": True, "app_name": app_name, "engine": "peekaboo", "frontmost": True}, warnings=warnings, provenance={"source": "peekaboo"}) + self._activate_app(app_name) + if self._wait_for_frontmost(app_name, timeout_seconds=2.0): + return CommandResult(ok=True, data={"focused": True, "app_name": app_name, "engine": "system_events", "frontmost": True}, warnings=warnings, provenance={"source": "system_events"}) + result = self.runner(["open", "-a", app_name], 10.0) + if result.returncode != 0: + return CommandResult(ok=False, data={"focused": False, "app_name": app_name}, errors=[make_error(code="app_focus_failed", message="macOS refused to open or focus the requested app.", guidance="Verify the app is installed and visible.")], warnings=self._stderr_warning(result)) + warnings.extend(self._stderr_warning(result)) + if self._wait_for_frontmost(app_name, timeout_seconds=3.0): + return CommandResult(ok=True, data={"focused": True, "app_name": app_name, "engine": "macos_open", "frontmost": True}, warnings=warnings, provenance={"source": "macos_open"}) + return CommandResult( + ok=False, + data={"focused": False, "app_name": app_name, "frontmost_app": redact_value(self._frontmost_app())}, + errors=[make_error(code="app_focus_not_frontmost", message="macOS opened the app, but it did not become the frontmost app.", guidance="Click the target app once or retry after closing competing modal windows.")], + warnings=warnings, + ) + + def local_site_open(self, *, url: str, dry_run: bool = False) -> CommandResult: + if not self._safe_local_url(url): + return CommandResult(ok=False, data={"opened": False, "would_open": dry_run, "url": redact_value(url)}, errors=[make_error(code="local_site_url_not_allowed", message="Only localhost, loopback, and .local http(s) URLs are allowed.", guidance="Use a customer-local website URL such as http://localhost:3000.")]) + if dry_run: + return CommandResult(ok=True, data={"opened": False, "would_open": True, "url": redact_value(url)}) + result = self.runner(["open", url], 10.0) + if result.returncode != 0: + return CommandResult(ok=False, data={"opened": False, "url": redact_value(url)}, errors=[make_error(code="local_site_open_failed", message="macOS refused to open the local website.", guidance="Verify the URL is reachable from the customer Mac.")], warnings=self._stderr_warning(result)) + return CommandResult(ok=True, data={"opened": True, "url": redact_value(url)}) + + def local_site_action(self, *, action: str, dry_run: bool = False) -> CommandResult: + if action not in SAFE_LOCAL_SITE_ACTIONS: + return CommandResult(ok=False, data={"performed": False, "would_perform": dry_run, "action": action}, errors=[make_error(code="local_site_action_not_allowed", message="This local-site action is not allowlisted.", guidance=f"Allowed actions: {', '.join(sorted(SAFE_LOCAL_SITE_ACTIONS))}.")]) + frontmost = self._frontmost_app() + if frontmost not in SAFE_BROWSER_APPS: + return CommandResult(ok=False, data={"performed": False, "action": action, "frontmost_app": redact_value(frontmost)}, errors=[make_error(code="browser_not_frontmost", message="A supported browser must be frontmost for local-site actions.", guidance="Open the local site first, then rerun the named browser action.")]) + current_url = self._front_browser_url(frontmost) + if not current_url or not self._safe_local_url(current_url): + return CommandResult(ok=False, data={"performed": False, "would_perform": dry_run, "action": action, "frontmost_app": frontmost, "current_url": redact_value(current_url)}, errors=[make_error(code="local_site_url_not_allowed", message="Local-site actions require the frontmost browser tab to be localhost, loopback, or .local.", guidance="Open the customer-local site first, then rerun the named browser action.")]) + if dry_run: + return CommandResult(ok=True, data={"performed": False, "would_perform": True, "action": action, "frontmost_app": frontmost, "current_url": redact_value(current_url)}) + key = {"reload": "r", "back": "[", "forward": "]"}[action] + result = self.runner(["osascript", "-e", f'tell application "System Events" to keystroke "{key}" using command down'], 5.0) + if result.returncode != 0: + return CommandResult(ok=False, data={"performed": False, "action": action}, errors=[make_error(code="local_site_action_failed", message="macOS refused the named browser action.", guidance=ACCESSIBILITY_GUIDANCE, permission="accessibility")], warnings=self._stderr_warning(result)) + return CommandResult(ok=True, data={"performed": True, "action": action, "frontmost_app": frontmost, "current_url": redact_value(current_url)}) + + def iphone_mirroring_status(self) -> CommandResult: + pid = self._pid_for_app("iPhone Mirroring") + frontmost = self._frontmost_app() + return CommandResult( + ok=True, + data={ + "installed": IPHONE_MIRRORING_APP.exists(), + "running": pid is not None, + "pid": pid, + "frontmost": frontmost == "iPhone Mirroring", + "window_title": redact_value(self._front_window_title()) if frontmost == "iPhone Mirroring" else None, + "supported_actions": sorted(SAFE_IPHONE_ACTIONS | GUARDED_IPHONE_ACTIONS), + "guarded_actions": sorted(GUARDED_IPHONE_ACTIONS), + "safety": { + "full_access_allows_customer_granted_control": True, + "ask_permission_gates_high_impact_actions": True, + "hidden_shell_public_ports_and_token_exfiltration_blocked": True, + "kill_switch_available": True, + }, + }, + ) + + def iphone_mirroring_focus(self, *, dry_run: bool = False) -> CommandResult: + focused = self.app_focus(app_name="iPhone Mirroring", dry_run=dry_run) + if dry_run or focused.ok: + return focused + bounds = self._iphone_window_bounds() + if bounds is not None: + x, y, width, _height = bounds + click_focus = self._mouse_action("click", x=x + max(24, width // 2), y=y + 16) + if click_focus.ok and self._wait_for_frontmost("iPhone Mirroring", timeout_seconds=2.0): + return CommandResult( + ok=True, + data={"focused": True, "app_name": "iPhone Mirroring", "engine": "window_click", "frontmost": True}, + warnings=focused.warnings + ["Clicked the iPhone Mirroring window chrome to recover focus."], + provenance={"source": "window_click"}, + ) + return focused + + def iphone_mirroring_action( + self, + *, + action: str, + text: str | None = None, + app_name: str | None = None, + target_label: str | None = None, + direction: str | None = None, + recipient_context: str | None = None, + dry_run: bool = False, + ) -> CommandResult: + if action not in SAFE_IPHONE_ACTIONS: + if action not in GUARDED_IPHONE_ACTIONS: + return CommandResult(ok=False, data={"performed": False, "would_perform": dry_run, "action": action}, errors=[make_error(code="iphone_action_not_allowed", message="This iPhone Mirroring action is not allowlisted.", guidance=f"Allowed actions: {', '.join(sorted(SAFE_IPHONE_ACTIONS | GUARDED_IPHONE_ACTIONS))}.")]) + if not IPHONE_MIRRORING_APP.exists(): + return CommandResult(ok=False, data={"performed": False, "action": action}, errors=[make_error(code="iphone_mirroring_not_installed", message="iPhone Mirroring.app is not installed on this Mac.", guidance="Use a supported macOS/iPhone pairing before enabling these tools.")]) + if action == "type_spotlight" and (not text or not SAFE_TEXT_RE.match(text)): + return self._unsafe_text_error(action, dry_run=dry_run) + if action == "type_approved_text" and (not text or not APPROVED_TEXT_RE.match(text)): + return self._approved_text_error(action, dry_run=dry_run) + if action == "send_approved_message": + if not text or not APPROVED_TEXT_RE.match(text): + return self._approved_text_error(action, dry_run=dry_run) + if not recipient_context or len(recipient_context.strip()) > 160: + return CommandResult(ok=False, data={"performed": False, "would_perform": dry_run, "action": action}, errors=[make_error(code="recipient_context_required", message="Approved message sends require the exact same-turn recipient/context description.", guidance="Provide a short human-approved recipient/context string for audit evidence.")]) + if target_label is None: + target_label = "Send" + if target_label.strip().lower() not in {"send", "send message"}: + return CommandResult(ok=False, data={"performed": False, "would_perform": dry_run, "action": action, "target_label": target_label}, errors=[make_error(code="send_target_label_not_allowed", message="Approved message sends may only press a visible Send control.", guidance="Use target label 'Send' or 'Send message'; do not route this through arbitrary visible labels.")]) + if action == "open_app" and (not app_name or not self._safe_app_name(app_name) or self._is_iphone_sensitive_app(app_name)): + return CommandResult(ok=False, data={"performed": False, "would_perform": dry_run, "action": action, "app_name": app_name}, errors=[make_error(code="iphone_app_name_not_allowed", message="The requested iPhone app name is not allowed for this named action.", guidance="Use a non-sensitive app name with safe characters, for example Calculator or Notes.")]) + if action == "tap_named_target" and (not target_label or not self._safe_app_name(target_label) or (self._is_dangerous_iphone_target(target_label) and not self._full_access_active())): + return CommandResult(ok=False, data={"performed": False, "would_perform": dry_run, "action": action, "target_label": target_label}, errors=[make_error(code="target_label_not_allowed", message="The requested visible target label is not safe.", guidance="Use an exact non-sensitive visible AX label; sends, calls, purchases, auth prompts, camera, microphone, and generic coordinates are blocked.")]) + if action == "scroll" and direction not in SCROLL_DIRECTIONS: + return CommandResult(ok=False, data={"performed": False, "would_perform": dry_run, "action": action, "direction": direction}, errors=[make_error(code="scroll_direction_required", message="iPhone scroll requires direction 'up' or 'down'.", guidance="Use a named direction only; generic coordinates are blocked.")]) + if dry_run: + return CommandResult( + ok=True, + data={ + "performed": False, + "would_perform": True, + "action": action, + "text_preview": self._safe_preview(text), + "text_sha256": self._text_hash(text) if text else None, + "app_name": app_name, + "target_label": target_label, + "direction": direction, + "recipient_context": self._safe_preview(recipient_context), + "guarded": action in GUARDED_IPHONE_ACTIONS, + }, + ) + status = self.iphone_mirroring_status() + if not status.data.get("installed"): + return CommandResult(ok=False, data={"performed": False, "action": action}, errors=[make_error(code="iphone_mirroring_not_installed", message="iPhone Mirroring.app is not installed on this Mac.", guidance="Use a supported macOS/iPhone pairing before enabling these tools.")]) + focus = self.iphone_mirroring_focus(dry_run=False) + if not focus.ok: + return focus + if action == "home": + return self._iphone_keyboard_action(action, "18") + if action == "app_switcher": + return self._iphone_keyboard_action(action, "19") + if action == "spotlight": + return self._iphone_keyboard_action(action, "20") + if action == "type_spotlight": + spotlight = self._iphone_keyboard_action("spotlight", "20") + if not spotlight.ok: + return spotlight + typed = self._keystroke_text(text) + if not typed.ok: + return typed + return CommandResult(ok=True, data={"performed": True, "action": action, "text_preview": self._safe_preview(text)}, provenance={"source": "iphone_mirroring"}) + if action == "open_app": + home = self._iphone_keyboard_action("home", "18") + if not home.ok: + return home + time.sleep(0.5) + spotlight = self._iphone_keyboard_action("spotlight", "20") + if not spotlight.ok: + return spotlight + time.sleep(0.5) + typed = self._keystroke_text(app_name) + if not typed.ok: + return typed + result = self.runner(["osascript", "-e", 'tell application "System Events" to key code 36'], 5.0) + if result.returncode != 0: + return CommandResult(ok=False, data={"performed": False, "action": action, "app_name": app_name}, errors=[make_error(code="iphone_open_app_failed", message="macOS refused the iPhone Mirroring app launch keystroke.", guidance=ACCESSIBILITY_GUIDANCE, permission="accessibility")], warnings=self._stderr_warning(result)) + return CommandResult( + ok=True, + data={ + "performed": True, + "action": action, + "app_name": app_name, + "verification_required": True, + "postcondition": "target_app_visible", + "postcondition_verified": False, + }, + warnings=[ + "The app launch keystroke was sent; run a settled visual iPhone Mirroring check to verify the target app is visible." + ], + provenance={"source": "iphone_mirroring", "postcondition": "target_app_visible", "postcondition_verified": False}, + ) + if action == "tap_named_target": + return self._press_iphone_target(target_label=target_label) + if action in GUARDED_GESTURE_KEYS: + vectors = { + "swipe_left": (-900, 0), + "swipe_right": (900, 0), + "swipe_up": (0, 900), + "swipe_down": (0, -900), + } + dx, dy = vectors[action] + return self._iphone_scroll_gesture(action, dx=dx, dy=dy) + if action == "scroll": + dy = 600 if direction == "up" else -600 + return self._iphone_scroll_gesture(action, dx=0, dy=dy, direction=direction) + if action == "type_approved_text": + typed = self._keystroke_approved_text(text) + if not typed.ok: + return typed + return CommandResult(ok=True, data={"performed": True, "action": action, "text_preview": self._safe_preview(text), "text_sha256": self._text_hash(text)}, provenance={"source": "iphone_mirroring", "customer_control": True}) + if action == "send_approved_message": + typed = self._keystroke_approved_text(text) + if not typed.ok: + return typed + pressed = self._press_iphone_target(target_label=target_label, allow_approved_send=True, action=action) + if not pressed.ok: + return pressed + return CommandResult( + ok=True, + data={ + "performed": True, + "action": action, + "target_label": target_label, + "text_preview": self._safe_preview(text), + "text_sha256": self._text_hash(text), + "recipient_context": self._safe_preview(recipient_context), + }, + provenance={"source": "iphone_mirroring_ax", "customer_control": True}, + ) + raise AssertionError(f"unhandled iPhone Mirroring action: {action}") + + def screen_sharing_status(self) -> CommandResult: + disabled = self._launchctl_disabled("com.apple.screensharing") + vnc_listening = self._tcp_port_listening("5900") + ard_listening = self._tcp_port_listening("3283") + enabled = (disabled is False) or vnc_listening or ard_listening + return CommandResult( + ok=True, + data={ + "enabled": enabled, + "launchctl_disabled": disabled, + "vnc_5900_listening": vnc_listening, + "ard_3283_listening": ard_listening, + "approval_required_to_enable": True, + "bridge_can_enable": False, + "recommended_acl": "tailnet-only paired customer VM to Mac connector and approved Screen Sharing ports", + }, + warnings=[] if enabled else ["Screen Sharing/Remote Management is not enabled; this bridge will not enable it without explicit approval."], + ) + + def _peekaboo_see(self, *, peekaboo: dict[str, Any], target: str, max_chars: int, max_nodes: int) -> CommandResult: + snapshot_id = self._new_snapshot_id(target) + screenshot_dir = self.state_dir / "screenshots" + screenshot_dir.mkdir(parents=True, exist_ok=True) + screenshot_path = screenshot_dir / f"{snapshot_id}.png" + result = self.runner( + [ + str(peekaboo["path"]), + "see", + "--json", + "--mode", + "frontmost", + "--capture-engine", + "classic", + "--no-remote", + "--path", + str(screenshot_path), + "--timeout-seconds", + "30", + ], + 40.0, + ) + warnings = self._stderr_warning(result) + if result.returncode != 0: + return CommandResult( + ok=False, + data={"engine": "peekaboo", "snapshot_id": None, "elements": []}, + warnings=warnings, + errors=[make_error(code="peekaboo_see_failed", message="Peekaboo could not capture visual evidence.", guidance="Check Peekaboo readiness, Accessibility, and Screen Recording permissions.")], + ) + try: + payload = json.loads(result.stdout.strip() or "{}") + except json.JSONDecodeError: + return CommandResult( + ok=False, + data={"engine": "peekaboo", "snapshot_id": None, "elements": []}, + warnings=warnings, + errors=[make_error(code="peekaboo_see_parse_failed", message="Peekaboo returned non-JSON visual evidence.", guidance="Run Peekaboo permissions/status from Workbench and retry.")], + ) + if payload.get("success") is not True: + error = payload.get("error") if isinstance(payload.get("error"), dict) else {} + return CommandResult( + ok=False, + data={"engine": "peekaboo", "snapshot_id": None, "elements": []}, + warnings=warnings, + errors=[ + make_error( + code=str(error.get("code") or "peekaboo_see_unsuccessful"), + message=str(redact_value(error.get("message") or "Peekaboo did not return a successful visual observation.")), + guidance="Check Peekaboo readiness, Accessibility, and Screen Recording permissions.", + ) + ], + ) + data = payload.get("data") if isinstance(payload.get("data"), dict) else {} + raw_path = data.get("screenshot_raw") + image_path = Path(str(raw_path)).expanduser() if raw_path else screenshot_path + image = self._image_artifact(image_path, snapshot_id=snapshot_id) if image_path.exists() else None + peekaboo_snapshot_id = str(data.get("snapshot_id") or "") + elements = self._elements_from_peekaboo(data.get("ui_elements", []), snapshot_id=snapshot_id, max_nodes=max_nodes) + if elements: + self._write_snapshot_index(snapshot_id=snapshot_id, target=target, elements=elements, engine="peekaboo", peekaboo_snapshot_id=peekaboo_snapshot_id or None) + observation = data.get("observation") if isinstance(data.get("observation"), dict) else {} + target_info = observation.get("target") if isinstance(observation.get("target"), dict) else {} + truncation = data.get("truncation") if isinstance(data.get("truncation"), dict) else None + if truncation and truncation.get("warning"): + warning, _ = cap_text(str(redact_value(truncation["warning"])), 240) + warnings.append(warning) + if len(elements) >= max_nodes and int(data.get("element_count") or 0) > max_nodes: + warnings.append(f"Peekaboo elements capped at {max_nodes}; rerun with a higher max_nodes value if needed.") + return CommandResult( + ok=image is not None or bool(elements), + data={ + "engine": "peekaboo", + "frontmost_app": redact_value(data.get("application_name") or self._frontmost_app()), + "window_title": redact_value(data.get("window_title")), + "snapshot_id": snapshot_id, + "peekaboo_snapshot_id": redact_value(peekaboo_snapshot_id) if peekaboo_snapshot_id else None, + "peekaboo": peekaboo, + "peekaboo_output": None, + "peekaboo_truncated": False, + "screenshot": {"screenshot": image} if image else None, + "ax": { + "source": "peekaboo", + "element_count": data.get("element_count"), + "interactable_count": data.get("interactable_count"), + "ui_map": redact_value(data.get("ui_map")), + }, + "elements": elements, + "capture": { + "mode": data.get("capture_mode"), + "target": redact_value(target_info), + "execution_time": data.get("execution_time"), + }, + }, + warnings=warnings, + errors=[] if image is not None or elements else [make_error(code="peekaboo_see_empty", message="Peekaboo succeeded but returned no screenshot or elements.", guidance="Bring a normal app window to the front and retry.")], + provenance={"source": "peekaboo_visual"}, + ) + + def _peekaboo_iphone_region_see(self, *, peekaboo: dict[str, Any]) -> CommandResult: + bounds = self._peekaboo_iphone_window_bounds(peekaboo=peekaboo) or self._iphone_window_bounds() + if bounds is None: + return CommandResult( + ok=False, + data={"target": "iphone_mirroring", "engine": "peekaboo", "snapshot_id": None, "elements": []}, + errors=[make_error(code="iphone_mirroring_window_not_found", message="Could not resolve the visible iPhone Mirroring window.", guidance="Open iPhone Mirroring, keep it visible, and retry.")], + ) + x, y, width, height = bounds + snapshot_id = self._new_snapshot_id("iphone_mirroring") + screenshot_dir = self.state_dir / "screenshots" + screenshot_dir.mkdir(parents=True, exist_ok=True) + screenshot_path = screenshot_dir / f"{snapshot_id}.png" + result = self.runner( + [ + str(peekaboo["path"]), + "image", + "--mode", + "area", + "--region", + f"{x},{y},{width},{height}", + "--path", + str(screenshot_path), + "--json", + "--no-remote", + ], + 20.0, + ) + warnings = self._stderr_warning(result) + if result.returncode != 0 or not screenshot_path.exists(): + return CommandResult( + ok=False, + data={"target": "iphone_mirroring", "engine": "peekaboo", "snapshot_id": None, "elements": []}, + warnings=warnings, + errors=[make_error(code="iphone_mirroring_region_capture_failed", message="Peekaboo could not capture the visible iPhone Mirroring region.", guidance="Verify Screen Recording permission and keep iPhone Mirroring visible.")], + ) + image = self._image_artifact(screenshot_path, snapshot_id=snapshot_id) + elements = [ + { + "element_id": "iphone-mirroring-window", + "snapshot_id": snapshot_id, + "label": "iPhone Mirroring window", + "role": "window", + "bounds": {"x": x, "y": y, "width": width, "height": height}, + "center": {"x": x + width // 2, "y": y + height // 2}, + "actions": ["click"], + "engine": "peekaboo_region", + } + ] + image_width = image.get("width") if image else None + image_height = image.get("height") if image else None + coordinate_space = { + "type": "window_region", + "origin": {"x": x, "y": y}, + "size": {"width": width, "height": height}, + "image_size": {"width": image_width, "height": image_height}, + "scale": {"x": (float(image_width) / float(width)) if image_width and width else 1.0, "y": (float(image_height) / float(height)) if image_height and height else 1.0}, + "tap_coordinates": "Pass x/y relative to this iPhone screenshot together with snapshot_id; the connector translates them to global screen coordinates.", + } + self._write_snapshot_index( + snapshot_id=snapshot_id, + target="iphone_mirroring", + elements=elements, + engine="peekaboo_region", + coordinate_space=coordinate_space, + ) + return CommandResult( + ok=image is not None, + data={ + "target": "iphone_mirroring", + "engine": "peekaboo", + "capture_engine": "peekaboo_region", + "frontmost_app": "iPhone Mirroring", + "window_title": "iPhone Mirroring", + "snapshot_id": snapshot_id, + "screenshot": {"screenshot": image} if image else None, + "coordinate_space": coordinate_space, + "elements": elements, + }, + warnings=warnings, + errors=[] if image is not None else [make_error(code="iphone_mirroring_artifact_failed", message="The iPhone screenshot was captured but could not be recorded as an artifact.", guidance="Retry iphone_see.")], + provenance={"source": "peekaboo_region", "customer_control": True}, + ) + + def _peekaboo_iphone_window_bounds(self, *, peekaboo: dict[str, Any]) -> tuple[int, int, int, int] | None: + result = self.runner([str(peekaboo["path"]), "window", "list", "--app", "iPhone Mirroring", "--json", "--no-remote"], 10.0) + if result.returncode != 0: + return None + try: + payload = json.loads(result.stdout.strip() or "{}") + except json.JSONDecodeError: + return None + data = payload.get("data") if isinstance(payload.get("data"), dict) else {} + windows = data.get("windows") if isinstance(data.get("windows"), list) else [] + candidates: list[tuple[int, int, int, int]] = [] + for item in windows: + if not isinstance(item, dict) or item.get("is_on_screen") is not True: + continue + title = str(item.get("window_title") or "") + if title != "iPhone Mirroring": + continue + bounds = item.get("bounds") + if not isinstance(bounds, dict): + continue + try: + x = int(bounds["x"]) + y = int(bounds["y"]) + width = int(bounds["width"]) + height = int(bounds["height"]) + except (KeyError, TypeError, ValueError): + continue + if width > 0 and height > 0: + candidates.append((x, y, width, height)) + if not candidates: + return None + return max(candidates, key=lambda row: row[2] * row[3]) + + def _peekaboo_status(self) -> dict[str, Any]: + path: str | None = None + result: RunnerResult | None = None + for candidate in (*bundled_bridge_bin_candidates(("peekaboo", "evaos-connector-helper")), *PEEKABOO_BIN_CANDIDATES): + if "/" in candidate: + result = self.runner([candidate, "--version"], 3.0) + if result.returncode == 0: + path = candidate + break + else: + found = shutil.which(candidate) + if found: + path = found + result = None + break + if not path: + return { + "available": False, + "install": "brew install steipete/tap/peekaboo", + "guidance": "Peekaboo gives agents the best Mac computer-control parity. Built-in Accessibility and PostToPid helper fallbacks remain available for core actions.", + } + if result is None: + result = self.runner([path, "--version"], 3.0) + version = result.stdout.strip() or result.stderr.strip() or None + return {"available": result.returncode == 0, "path": path, "version": redact_value(version)} + + def _cua_driver_status(self) -> dict[str, Any]: + actions_enabled = self._cua_driver_actions_enabled() + if self.cua_driver_client is not None: + return { + "preferred": True, + "mode": "background", + "protocol": "mcp_stdio", + "license": "MIT", + "active_for_actions": actions_enabled, + "adapter": "mcp_stdio" if actions_enabled else "disabled", + "available": True, + "source": "injected", + "guidance": "cua-driver is the primary background Mac-control engine when available. Peekaboo and built-in fallbacks remain active recovery paths.", + } + path: str | None = None + bundled_candidates = tuple(bundled_bridge_bin_candidates(("cua-driver",))) + source = "path" + for candidate in (*bundled_candidates, *CUA_DRIVER_BIN_CANDIDATES): + if "/" in candidate: + if Path(candidate).exists(): + path = candidate + source = "bundled" if candidate in bundled_candidates else "path" + break + else: + found = shutil.which(candidate) + if found: + path = found + break + base = { + "preferred": True, + "mode": "background", + "protocol": "mcp_stdio", + "license": "MIT", + "active_for_actions": False, + "adapter": "disabled" if not actions_enabled else "mcp_stdio", + "guidance": "cua-driver is the primary background Mac-control engine when available. Peekaboo and built-in fallbacks remain active recovery paths.", + } + if not path: + return { + **base, + "available": False, + "install": "Install cua-driver from trycua/cua and approve the required macOS permissions.", + } + result = self.runner([path, "--version"], 3.0) + version = result.stdout.strip() or result.stderr.strip() or None + return { + **base, + "available": result.returncode == 0, + "active_for_actions": bool(actions_enabled and result.returncode == 0), + "adapter": "mcp_stdio" if actions_enabled and result.returncode == 0 else "disabled", + "source": source, + "path": path, + "version": redact_value(version), + } + + def _cua_driver_actions_enabled(self) -> bool: + value = os.environ.get("EVAOS_DESKTOP_BRIDGE_CUA_ACTIONS", "1").strip().lower() + return value not in {"0", "false", "off", "disabled", "no"} + + def _cua_driver_action_client(self) -> CuaDriverClient | None: + if not self._cua_driver_actions_enabled(): + return None + if self.cua_driver_client is not None: + return self.cua_driver_client + status = self._cached_engine_probe("cua_driver", self._cua_driver_status) + path = status.get("path") + if status.get("available") is True and isinstance(path, str) and path: + self.cua_driver_client = CuaDriverMcpClient(path) + return self.cua_driver_client + return None + + def _cached_engine_probe(self, key: str, probe: Callable[[], dict[str, Any]], *, ttl_seconds: float = 10.0) -> dict[str, Any]: + now = time.monotonic() + cached = self._engine_status_cache.get(key) + if cached is not None and now - cached[0] <= ttl_seconds: + return dict(cached[1]) + status = probe() + self._engine_status_cache[key] = (now, dict(status)) + return status + + def _control_engine_status(self) -> dict[str, Any]: + cua_driver = self._cached_engine_probe("cua_driver", self._cua_driver_status) + peekaboo = self._cached_engine_probe("peekaboo", self._peekaboo_status) + if cua_driver.get("available") and cua_driver.get("active_for_actions"): + active_primary = "cua_driver" + selection = "cua_driver_primary" + elif peekaboo.get("available"): + active_primary = "peekaboo" + selection = "peekaboo_until_cua_actions_enabled" + elif self.helper_client is not None: + active_primary = "post_to_pid_helper" + selection = "fallback_post_to_pid_helper" + else: + active_primary = "accessibility" + selection = "fallback_accessibility_system_events" + return { + "order": list(MAC_CONTROL_ENGINE_ORDER), + "cua_driver": cua_driver, + "peekaboo": peekaboo, + "fallbacks": ["post_to_pid_helper", "accessibility", "system_events"], + "active_primary": active_primary, + "selection": selection, + } + + def _peekaboo_permission_status(self, permission: str) -> bool | None: + peekaboo = self._peekaboo_status() + if not peekaboo.get("available"): + return None + commands = ( + [str(peekaboo["path"]), "permissions", "status", "--json", "--no-remote"], + [str(peekaboo["path"]), "permissions", "--json", "--no-remote"], + [str(peekaboo["path"]), "list", "permissions", "--json"], + ) + wanted = "Screen Recording" if permission == "screen_recording" else "Accessibility" + for command in commands: + result = self.runner(command, 10.0) + if result.returncode != 0: + continue + try: + payload = json.loads(result.stdout.strip() or "{}") + except json.JSONDecodeError: + continue + data = payload.get("data") if isinstance(payload.get("data"), dict) else {} + permissions = data.get("permissions") if isinstance(data.get("permissions"), list) else [] + for item in permissions: + if isinstance(item, dict) and item.get("name") == wanted: + return bool(item.get("isGranted")) + return None + + def _cua_driver_result_acceptable(self, result: CommandResult, *, tool: str, require_verified: bool = False) -> tuple[bool, str | None]: + if not result.ok: + message = result.errors[0].get("message") if result.errors else f"cua-driver {tool} failed" + return False, f"cua-driver {tool} failed: {message}" + structured = result.data.get("structured") if isinstance(result.data.get("structured"), dict) else {} + escalation = structured.get("escalation") if isinstance(structured.get("escalation"), dict) else {} + recommended = str(escalation.get("recommended") or "").strip().lower() + if recommended == "foreground": + return False, f"cua-driver requested foreground escalation for {tool}" + if structured.get("degraded") is True or structured.get("off_space") is True: + return False, f"cua-driver returned degraded {tool} state" + if require_verified and structured.get("verified") is not True: + return False, f"cua-driver {tool} was not verified" + return True, None + + def _cua_driver_warning(self, reason: str | None) -> list[str]: + return [reason] if reason else [] + + def _cua_driver_target_from_resolved(self, resolved: CommandResult) -> dict[str, Any] | None: + target = resolved.data.get("cua_target") if isinstance(resolved.data.get("cua_target"), dict) else None + if not isinstance(target, dict): + return None + try: + pid = int(target["pid"]) + except (KeyError, TypeError, ValueError): + return None + args: dict[str, Any] = {"pid": pid} + try: + args["window_id"] = int(target["window_id"]) + except (KeyError, TypeError, ValueError): + pass + token = target.get("element_token") + if isinstance(token, str) and token: + args["element_token"] = token + try: + args["element_index"] = int(target["element_index"]) + except (KeyError, TypeError, ValueError): + pass + if "element_token" not in args and "element_index" not in args: + return None + return args + + def _cua_driver_frontmost_pid(self) -> tuple[int | None, str | None]: + frontmost = self._frontmost_app() + if not frontmost: + return None, "cua-driver target app could not be resolved" + if self._is_sensitive_app(frontmost): + return None, f"cua-driver blocked for sensitive app {frontmost}" + pid = self._pid_for_app(frontmost) + if pid is None: + return None, "cua-driver target pid could not be resolved" + return pid, None + + def _cua_driver_see(self, *, client: CuaDriverClient, max_nodes: int) -> CommandResult: + windows_result = client.call("list_windows", {"on_screen_only": True}, timeout=10.0) + acceptable, reason = self._cua_driver_result_acceptable(windows_result, tool="list_windows") + if not acceptable: + return CommandResult( + ok=False, + data={"engine": "cua_driver"}, + warnings=self._cua_driver_warning(reason), + errors=windows_result.errors, + provenance={"source": "cua_driver", "tool": "list_windows"}, + ) + structured = windows_result.data.get("structured") if isinstance(windows_result.data.get("structured"), dict) else {} + windows = structured.get("windows") if isinstance(structured.get("windows"), list) else [] + visible_windows: list[dict[str, Any]] = [] + for window in windows: + if not isinstance(window, dict): + continue + if window.get("is_on_screen") is False: + continue + try: + int(window["pid"]) + int(window["window_id"]) + except (KeyError, TypeError, ValueError): + continue + visible_windows.append(window) + if not visible_windows: + return CommandResult( + ok=False, + data={"engine": "cua_driver"}, + warnings=["cua-driver returned no visible windows"], + errors=[make_error(code="cua_driver_no_visible_windows", message="cua-driver returned no visible windows.", guidance="Falling back to the next Mac-control engine.")], + provenance={"source": "cua_driver", "tool": "list_windows"}, + ) + visible_windows.sort(key=lambda item: int(item.get("z_index") or 0), reverse=True) + window = visible_windows[0] + pid = int(window["pid"]) + window_id = int(window["window_id"]) + state_result = client.call( + "get_window_state", + { + "pid": pid, + "window_id": window_id, + "include_screenshot": False, + "max_elements": max_nodes, + "session": "evaos-desktop-bridge", + }, + timeout=15.0, + ) + acceptable, reason = self._cua_driver_result_acceptable(state_result, tool="get_window_state") + if not acceptable: + return CommandResult( + ok=False, + data={"engine": "cua_driver", "pid": pid, "window_id": window_id}, + warnings=self._cua_driver_warning(reason), + errors=state_result.errors, + provenance={"source": "cua_driver", "tool": "get_window_state"}, + ) + state = state_result.data.get("structured") if isinstance(state_result.data.get("structured"), dict) else {} + snapshot_id = f"snap-desktop-{uuid.uuid4().hex}" + elements = self._elements_from_cua(state.get("elements"), snapshot_id=snapshot_id, pid=pid, window_id=window_id, max_nodes=max_nodes) + if not elements: + return CommandResult( + ok=False, + data={"engine": "cua_driver", "pid": pid, "window_id": window_id, "snapshot_id": snapshot_id}, + warnings=["cua-driver returned no actionable desktop elements"], + errors=[make_error(code="cua_driver_no_elements", message="cua-driver returned no actionable desktop elements.", guidance="Falling back to the next Mac-control engine.")], + provenance={"source": "cua_driver", "tool": "get_window_state"}, + ) + self._write_snapshot_index(snapshot_id=snapshot_id, target="desktop", elements=elements, engine="cua_driver") + return CommandResult( + ok=True, + data={ + "engine": "cua_driver", + "frontmost_app": redact_value(window.get("app_name") or self._frontmost_app()), + "snapshot_id": snapshot_id, + "cua_window": { + "pid": pid, + "window_id": window_id, + "app_name": redact_value(window.get("app_name")), + "title": redact_value(window.get("title")), + }, + "elements": elements, + }, + warnings=windows_result.warnings + state_result.warnings, + provenance={"source": "cua_driver"}, + ) + + def _press_frontmost_target(self, *, target_label: str) -> CommandResult: + if self.accessibility_checker() is False: + return CommandResult(ok=False, data={"clicked": False, "target_label": target_label}, errors=[self._permission_error("accessibility", "click a visible target")]) + frontmost = self._frontmost_app() + pid = self._pid_for_app(frontmost) + if pid is None: + return CommandResult(ok=False, data={"clicked": False, "target_label": target_label, "frontmost_app": redact_value(frontmost)}, errors=[make_error(code="frontmost_pid_not_found", message="Could not resolve a frontmost app PID.", guidance="Focus the target app and rerun desktop_see.")]) + result = self.runner([sys.executable, "-c", self.AX_PRESS_LABEL_SCRIPT, str(pid), target_label, "700", "1"], 20.0) + warnings = self._stderr_warning(result) + if result.returncode != 0: + return CommandResult(ok=False, data={"clicked": False, "target_label": target_label}, errors=[make_error(code="desktop_target_lookup_unavailable", message="Unable to inspect visible targets.", guidance=ACCESSIBILITY_GUIDANCE, permission="accessibility")], warnings=warnings) + try: + payload = json.loads(result.stdout.strip() or "{}") + except json.JSONDecodeError: + return CommandResult(ok=False, data={"clicked": False, "target_label": target_label}, errors=[make_error(code="desktop_target_lookup_parse_failed", message="Unable to parse target lookup output.", guidance="Check pyobjc GUI dependencies in the bridge environment.")], warnings=warnings) + if not payload.get("ok"): + return CommandResult( + ok=False, + data={"clicked": False, "target_label": target_label, "matches": [self._safe_node(row) for row in payload.get("matches", [])]}, + errors=[make_error(code=str(payload.get("error") or "desktop_target_click_failed").split(":", 1)[0], message="The visible target could not be clicked.", guidance="Run desktop_see and use a unique visible label, or use coordinates as a fallback.")], + warnings=warnings, + ) + return CommandResult(ok=True, data={"clicked": True, "target_label": target_label, "matches": [self._safe_node(row) for row in payload.get("matches", [])]}, warnings=warnings, provenance={"source": "accessibility"}) + + def _mouse_action(self, action: str, **kwargs: Any) -> CommandResult: + if self.accessibility_checker() is False: + return CommandResult(ok=False, data={"performed": False, "action": action}, errors=[self._permission_error("accessibility", f"perform desktop {action}")]) + data: dict[str, Any] = {"performed": False, "action": action} + if action == "click": + data["point"] = self._point(kwargs["x"], kwargs["y"]) + elif action == "scroll": + data["direction"] = kwargs["direction"] + data["amount"] = kwargs["amount"] + elif action == "drag": + data["from"] = self._point(kwargs["from_x"], kwargs["from_y"]) + data["to"] = self._point(kwargs["to_x"], kwargs["to_y"]) + target = kwargs.get("target") if isinstance(kwargs.get("target"), dict) else None + require_target = kwargs.get("require_target") is True + if target is None and not require_target: + frontmost = self._frontmost_app() + pid = self._pid_for_app(frontmost) + process_name = self._process_name_for_pid(pid) + if pid is not None and process_name: + target = { + "pid": int(pid), + "app_name": frontmost, + "process_name": process_name, + "path": [], + } + if target is None: + return CommandResult( + ok=False, + data=data, + errors=[ + make_error( + code="post_to_pid_target_required", + message=f"desktop {action} requires a visible process target for per-process posting.", + guidance="Run desktop_see and choose a target element, or focus a non-sensitive app whose process identity can be resolved.", + ) + ], + ) + target_block = self._sensitive_ax_target_action_block(ax_target=target, action=action) + if target_block is not None: + return target_block + identity_block = self._ax_target_process_identity_block(ax_target=target, action=action) + if identity_block is not None: + return identity_block + browser_block = self._post_to_pid_browser_target_block(ax_target=target, action=action) + if browser_block is not None: + return browser_block + inert_block = self._inert_ax_target_action_block(ax_target=target, action=action) + if inert_block is not None: + return inert_block + if self.helper_client is None: + return CommandResult( + ok=False, + data={**data, "target": self._safe_ax_target(target)}, + errors=[ + make_error( + code="helper_required_for_post_to_pid", + message=f"desktop {action} requires the resident helper for CGEventPostToPid dispatch.", + guidance="Start the evaOS computer-use helper from Workbench before using Tier-2 desktop actuation.", + ) + ], + ) + + helper_audit_id = f"audit-helper-{uuid.uuid4().hex}" + payload: dict[str, object] = {"action": action, "target": target} + if action == "click": + payload.update({"x": int(kwargs["x"]), "y": int(kwargs["y"])}) + elif action == "scroll": + payload.update({"direction": str(kwargs["direction"]), "amount": int(kwargs["amount"])}) + elif action == "drag": + payload.update( + { + "from_x": int(kwargs["from_x"]), + "from_y": int(kwargs["from_y"]), + "to_x": int(kwargs["to_x"]), + "to_y": int(kwargs["to_y"]), + } + ) + self._append_helper_actuation_attempt(helper_audit_id=helper_audit_id, helper_command="mouse_action", payload=payload) + try: + result = self.helper_client.dispatch("mouse_action", payload, audit_id=helper_audit_id) + result.provenance.setdefault("helper_audit_id", helper_audit_id) + self._append_helper_actuation_result( + helper_audit_id=helper_audit_id, + helper_command="mouse_action", + payload=payload, + result=result, + ) + return result + except Exception as exc: + result = CommandResult( + ok=False, + data=data, + errors=[ + make_error( + code="helper_unavailable", + message=f"Persistent computer-use helper failed before performing desktop {action}.", + guidance="Restart the evaOS helper before using Tier-2 desktop actuation.", + ) + ], + warnings=[str(redact_value(exc))], + provenance={"source": "computer_use_helper"}, + ) + self._append_helper_actuation_result( + helper_audit_id=helper_audit_id, + helper_command="mouse_action", + payload=payload, + result=result, + ) + return result + + def _helper_ax_action( + self, + *, + action: str, + target: dict[str, Any], + fallback_data: dict[str, Any], + value: str | None = None, + attribute: str | None = None, + menu_path: str | None = None, + ) -> CommandResult: + identity_block = self._ax_target_process_identity_block(ax_target=target, action=action) + if identity_block is not None: + return identity_block + payload: dict[str, object] = {"action": action, "target": target} + if value is not None: + payload["value"] = value + if attribute is not None: + payload["attribute"] = attribute + if menu_path is not None: + payload["menu_path"] = menu_path + if self.helper_client is None: + return CommandResult( + ok=False, + data=fallback_data, + errors=[ + make_error( + code="helper_required_for_ax_action", + message="Tier-1 AX actions require the Workbench-managed computer-use helper.", + guidance="Start Mac Access from evaOS Workbench so semantic AX actions run under the signed helper identity.", + ) + ], + provenance={"source": "computer_use_helper"}, + ) + helper_audit_id = f"audit-helper-{uuid.uuid4().hex}" + audit_payload = dict(payload) + if value is not None: + audit_payload["value"] = "" + audit_payload["value_sha256"] = self._text_hash(value) + self._append_helper_actuation_attempt(helper_audit_id=helper_audit_id, helper_command="ax_action", payload=audit_payload) + try: + result = self.helper_client.dispatch("ax_action", payload, audit_id=helper_audit_id) + result.provenance.setdefault("helper_audit_id", helper_audit_id) + self._append_helper_actuation_result( + helper_audit_id=helper_audit_id, + helper_command="ax_action", + payload=audit_payload, + result=result, + ) + return result + except Exception as exc: + result = CommandResult( + ok=False, + data=fallback_data, + errors=[ + make_error( + code="helper_unavailable", + message=f"Persistent computer-use helper failed before performing AX {action}.", + guidance="Restart the evaOS helper or rerun after Mac Access reports ready.", + ) + ], + warnings=[str(redact_value(exc))], + provenance={"source": "computer_use_helper"}, + ) + self._append_helper_actuation_result( + helper_audit_id=helper_audit_id, + helper_command="ax_action", + payload=audit_payload, + result=result, + ) + return result + + def _append_helper_actuation_attempt( + self, + *, + helper_audit_id: str, + helper_command: str = "mouse_action", + payload: dict[str, object], + ) -> None: + append_audit( + command=f"helper.{helper_command}", + target="computer_use_helper", + args={"payload": payload}, + ok=True, + warnings=["helper actuation request authorized and recorded before IPC dispatch"], + errors=[], + provenance={ + "source": "computer_use_helper", + "helper_command": helper_command, + "helper_audit_id": helper_audit_id, + "audit_phase": "authorized_dispatch", + }, + state_dir=self.state_dir, + audit_id=helper_audit_id, + ) + + def _append_helper_actuation_result( + self, + *, + helper_audit_id: str, + helper_command: str = "mouse_action", + payload: dict[str, object], + result: CommandResult, + ) -> None: + append_audit( + command=f"helper.{helper_command}", + target="computer_use_helper", + args={"payload": payload}, + ok=result.ok, + warnings=result.warnings, + errors=result.errors, + provenance={ + **result.provenance, + "source": "computer_use_helper", + "helper_command": helper_command, + "helper_audit_id": helper_audit_id, + "audit_phase": "completion", + }, + state_dir=self.state_dir, + ) + + def _keystroke_arbitrary_text(self, text: str) -> CommandResult: + if len(text) > 4000: + return CommandResult(ok=False, data={"typed": False, "text_sha256": self._text_hash(text)}, errors=[make_error(code="desktop_text_too_long", message="desktop_type text is capped at 4000 characters per action.", guidance="Split longer text into smaller typed chunks.")]) + script = f'tell application "System Events" to keystroke "{self._escape_applescript(text)}"' + result = self.runner(["osascript", "-e", script], 20.0) + if result.returncode != 0: + return CommandResult(ok=False, data={"typed": False, "text_preview": self._safe_preview(text), "text_sha256": self._text_hash(text)}, errors=[make_error(code="desktop_text_entry_failed", message="macOS refused desktop text entry.", guidance=ACCESSIBILITY_GUIDANCE, permission="accessibility")], warnings=self._stderr_warning(result)) + return CommandResult(ok=True, data={"typed": True, "text_preview": self._safe_preview(text), "text_sha256": self._text_hash(text), "engine": "system_events"}, provenance={"source": "system_events"}) + + def _normalize_hotkey(self, keys: str) -> str | None: + if not isinstance(keys, str): + return None + parts = [part.strip().lower() for part in re.split(r"[+ ]+", keys) if part.strip()] + aliases = {"command": "cmd", "control": "ctrl", "option": "opt", "alt": "opt", "escape": "esc", "return": "enter"} + normalized = [aliases.get(part, part) for part in parts] + if not normalized or any(not re.fullmatch(r"[a-z0-9_\-\[\]`=,./;']+", part) for part in normalized): + return None + return "+".join(normalized) + + def _osascript_hotkey(self, keys: str) -> CommandResult: + parts = keys.split("+") + key = parts[-1] + modifiers = parts[:-1] + modifier_map = {"cmd": "command down", "shift": "shift down", "ctrl": "control down", "opt": "option down"} + using = [modifier_map[item] for item in modifiers if item in modifier_map] + special_key_codes = {"enter": "36", "esc": "53", "tab": "48", "space": "49", "left": "123", "right": "124", "down": "125", "up": "126"} + if key in special_key_codes: + script = f'tell application "System Events" to key code {special_key_codes[key]}' + elif len(key) == 1 or key in {"[", "]", "`", "-", "=", ",", ".", "/", ";", "'"}: + script = f'tell application "System Events" to keystroke "{self._escape_applescript(key)}"' + else: + return CommandResult(ok=False, data={"pressed": False, "keys": keys}, errors=[make_error(code="desktop_hotkey_key_unsupported", message="The fallback hotkey engine does not know that key.", guidance="Install Peekaboo for broader hotkey support.")]) + if using: + script += " using {" + ", ".join(using) + "}" + result = self.runner(["osascript", "-e", script], 5.0) + if result.returncode != 0: + return CommandResult(ok=False, data={"pressed": False, "keys": keys}, errors=[make_error(code="desktop_hotkey_failed", message="macOS refused the hotkey.", guidance=ACCESSIBILITY_GUIDANCE, permission="accessibility")], warnings=self._stderr_warning(result)) + return CommandResult(ok=True, data={"pressed": True, "keys": keys, "engine": "system_events"}, provenance={"source": "system_events"}) + + def _point(self, x: int | None, y: int | None) -> dict[str, int] | None: + if x is None or y is None: + return None + return {"x": int(x), "y": int(y)} + + def _iphone_keyboard_action(self, action: str, key_code: str, *, customer_control: bool = False, direction: str | None = None) -> CommandResult: + key_label = {"18": "1", "19": "2", "20": "3", "36": "enter"}.get(key_code) + peekaboo = self._peekaboo_status() + if key_label and peekaboo.get("available"): + keys = key_label if customer_control else f"cmd+{key_label}" + argv = [str(peekaboo["path"]), "hotkey", "--keys", keys, "--json", "--no-remote"] + result = self.runner(argv, 10.0) + if result.returncode == 0: + data: dict[str, Any] = {"performed": True, "action": action, "engine": "peekaboo", "keys": keys} + if direction: + data["direction"] = direction + return CommandResult(ok=True, data=data, warnings=self._stderr_warning(result), provenance={"source": "peekaboo", "customer_control": customer_control}) + script = f'tell application "System Events" to key code {key_code}' if customer_control else f'tell application "System Events" to key code {key_code} using command down' + result = self.runner(["osascript", "-e", script], 12.0) + if result.returncode != 0: + return CommandResult(ok=False, data={"performed": False, "action": action}, errors=[make_error(code="iphone_keyboard_action_failed", message="macOS refused the iPhone Mirroring keyboard shortcut.", guidance=ACCESSIBILITY_GUIDANCE, permission="accessibility")], warnings=self._stderr_warning(result)) + warnings = ["This live iPhone action uses the safest keyboard-equivalent lane; verify behavior in the iPhone Mirroring window."] if customer_control else [] + data: dict[str, Any] = {"performed": True, "action": action} + if direction: + data["direction"] = direction + return CommandResult(ok=True, data=data, warnings=warnings, provenance={"source": "iphone_mirroring", "customer_control": customer_control}) + + def _iphone_scroll_gesture(self, action: str, *, dx: int, dy: int, direction: str | None = None) -> CommandResult: + if self.accessibility_checker() is False: + return CommandResult(ok=False, data={"performed": False, "action": action, "direction": direction}, errors=[self._permission_error("accessibility", "send a named iPhone Mirroring gesture")]) + focus = self.iphone_mirroring_focus(dry_run=False) + if not focus.ok: + return focus + bounds = self._iphone_window_bounds() + if bounds is None: + return CommandResult(ok=False, data={"performed": False, "action": action, "direction": direction}, errors=[make_error(code="iphone_mirroring_not_running", message="iPhone Mirroring is not currently running.", guidance="Open iPhone Mirroring and rerun the named action.")]) + x, y, width, height = bounds + margin_x = max(32, int(width * 0.18)) + margin_y = max(64, int(height * 0.20)) + center_x = x + width // 2 + center_y = y + height // 2 + if dx < 0: + start = (x + width - margin_x, center_y) + end = (x + margin_x, center_y) + elif dx > 0: + start = (x + margin_x, center_y) + end = (x + width - margin_x, center_y) + elif dy > 0: + start = (center_x, y + height - margin_y) + end = (center_x, y + margin_y) + else: + start = (center_x, y + margin_y) + end = (center_x, y + height - margin_y) + peekaboo = self._peekaboo_status() + if peekaboo.get("available"): + result = self.runner( + [ + str(peekaboo["path"]), + "swipe", + "--from-coords", + f"{start[0]},{start[1]}", + "--to-coords", + f"{end[0]},{end[1]}", + "--duration", + "700", + "--profile", + "human", + "--json", + "--no-remote", + ], + 20.0, + ) + if result.returncode == 0: + data: dict[str, Any] = {"performed": True, "action": action, "gesture": "swipe", "from": self._point(*start), "to": self._point(*end), "engine": "peekaboo"} + if direction: + data["direction"] = direction + return CommandResult(ok=True, data=data, warnings=self._stderr_warning(result), provenance={"source": "peekaboo", "customer_control": True}) + pid = self._pid_for_app("iPhone Mirroring") + process_name = self._process_name_for_pid(pid) + target = {"pid": int(pid), "app_name": "iPhone Mirroring", "process_name": process_name, "path": []} if pid is not None and process_name else None + dragged = self._mouse_action("drag", from_x=start[0], from_y=start[1], to_x=end[0], to_y=end[1], target=target) + if not dragged.ok: + return CommandResult(ok=False, data={"performed": False, "action": action, "direction": direction, "from": self._point(*start), "to": self._point(*end)}, errors=dragged.errors, warnings=dragged.warnings) + data: dict[str, Any] = {"performed": True, "action": action, "gesture": "drag", "from": self._point(*start), "to": self._point(*end)} + if direction: + data["direction"] = direction + return CommandResult( + ok=True, + data=data, + warnings=dragged.warnings + ["Live iPhone gesture drags inside the visible iPhone Mirroring window; verify behavior before repeating."], + provenance={"source": "iphone_mirroring_drag", "customer_control": True}, + ) + + def _press_iphone_target(self, *, target_label: str, allow_approved_send: bool = False, action: str = "tap_named_target") -> CommandResult: + if self.accessibility_checker() is False: + return CommandResult(ok=False, data={"performed": False, "action": action, "target_label": target_label}, errors=[self._permission_error("accessibility", "tap a named iPhone Mirroring target")]) + pid = self._pid_for_app("iPhone Mirroring") + if pid is None: + return CommandResult(ok=False, data={"performed": False, "action": action, "target_label": target_label}, errors=[make_error(code="iphone_mirroring_not_running", message="iPhone Mirroring is not currently running.", guidance="Open iPhone Mirroring and rerun the named action.")]) + if self._is_dangerous_iphone_target(target_label) and not self._full_access_active() and not (allow_approved_send and target_label.strip().lower() in {"send", "send message"}): + return CommandResult(ok=False, data={"performed": False, "action": action, "target_label": target_label}, errors=[make_error(code="target_label_not_allowed", message="The requested visible target label is not safe.", guidance="Only the approved-message action may press a Send target after same-turn approval.")]) + result = self.runner([sys.executable, "-c", self.AX_PRESS_LABEL_SCRIPT, str(pid), target_label, "500", "1"], 20.0) + warnings = self._stderr_warning(result) + if result.returncode != 0: + return CommandResult(ok=False, data={"performed": False, "action": action, "target_label": target_label}, errors=[make_error(code="iphone_target_lookup_unavailable", message="Unable to inspect iPhone Mirroring targets.", guidance=ACCESSIBILITY_GUIDANCE, permission="accessibility")], warnings=warnings) + try: + payload = json.loads(result.stdout.strip() or "{}") + except json.JSONDecodeError: + return CommandResult(ok=False, data={"performed": False, "action": action, "target_label": target_label}, errors=[make_error(code="iphone_target_lookup_parse_failed", message="Unable to parse iPhone Mirroring target lookup output.", guidance="Check pyobjc GUI dependencies in the bridge environment.")], warnings=warnings) + if not payload.get("ok"): + code = str(payload.get("error") or "iphone_target_press_failed").split(":", 1)[0] + return CommandResult(ok=False, data={"performed": False, "action": action, "target_label": target_label, "matches": [self._safe_node(row) for row in payload.get("matches", [])]}, errors=[make_error(code=code, message="The named target could not be pressed safely.", guidance="Use exact visible labels only; do not fall back to generic coordinates.")], warnings=warnings) + return CommandResult(ok=True, data={"performed": True, "action": action, "target_label": target_label, "matches": [self._safe_node(row) for row in payload.get("matches", [])]}, warnings=warnings, provenance={"source": "iphone_mirroring_ax", "customer_control": allow_approved_send}) + + def _keystroke_text(self, text: str) -> CommandResult: + if not SAFE_TEXT_RE.match(text): + return self._unsafe_text_error("keystroke_text", dry_run=False) + typed = self._peekaboo_type_text(text) + if typed is not None: + return typed + script = f'tell application "System Events" to keystroke "{self._escape_applescript(text)}"' + result = self.runner(["osascript", "-e", script], 5.0) + if result.returncode != 0: + return CommandResult(ok=False, data={"typed": False, "text_preview": self._safe_preview(text)}, errors=[make_error(code="safe_text_entry_failed", message="macOS refused safe text entry.", guidance=ACCESSIBILITY_GUIDANCE, permission="accessibility")], warnings=self._stderr_warning(result)) + return CommandResult(ok=True, data={"typed": True, "text_preview": self._safe_preview(text), "engine": "system_events", "input_method": "keystroke"}, provenance={"source": "system_events", "customer_control": True, "reason": "iphone_mirroring_exact_text"}) + + def _keystroke_approved_text(self, text: str) -> CommandResult: + if not APPROVED_TEXT_RE.match(text): + return self._approved_text_error("keystroke_approved_text", dry_run=False) + typed = self._peekaboo_type_text(text, approved=True) + if typed is not None: + return typed + script = f'tell application "System Events" to keystroke "{self._escape_applescript(text)}"' + result = self.runner(["osascript", "-e", script], 5.0) + if result.returncode != 0: + return CommandResult(ok=False, data={"typed": False, "text_preview": self._safe_preview(text), "text_sha256": self._text_hash(text)}, errors=[make_error(code="approved_text_entry_failed", message="macOS refused approved text entry.", guidance=ACCESSIBILITY_GUIDANCE, permission="accessibility")], warnings=self._stderr_warning(result)) + return CommandResult(ok=True, data={"typed": True, "text_preview": self._safe_preview(text), "text_sha256": self._text_hash(text), "engine": "system_events", "input_method": "keystroke"}, provenance={"source": "system_events", "customer_control": True, "reason": "iphone_mirroring_approved_text"}) + + def _peekaboo_type_text(self, text: str, *, approved: bool = False) -> CommandResult | None: + peekaboo = self._peekaboo_status() + if not peekaboo.get("available"): + return None + result = self.runner([str(peekaboo["path"]), "type", "--text", text, "--app", "iPhone Mirroring", "--profile", "linear", "--json", "--no-remote"], 20.0) + if result.returncode != 0: + return None + data: dict[str, Any] = { + "typed": True, + "text_preview": self._safe_preview(text), + "engine": "peekaboo", + "input_method": "targeted_type", + } + if approved: + data["text_sha256"] = self._text_hash(text) + return CommandResult(ok=True, data=data, warnings=self._stderr_warning(result), provenance={"source": "peekaboo_type", "customer_control": True, "reason": "iphone_mirroring_approved_text" if approved else "iphone_mirroring_exact_text"}) + + def _ax_snapshot(self, *, pid: int, max_nodes: int) -> tuple[dict[str, Any] | None, list[dict[str, Any]], list[str]]: + result = self.runner([sys.executable, "-c", self.AX_TREE_SCRIPT, str(pid), str(max_nodes)], 20.0) + warnings = self._stderr_warning(result) + if result.returncode != 0: + return None, [make_error(code="ax_tree_unavailable", message="Unable to read the frontmost Accessibility tree.", guidance=ACCESSIBILITY_GUIDANCE, permission="accessibility")], warnings + try: + payload = json.loads(result.stdout.strip() or "{}") + except json.JSONDecodeError: + return None, [make_error(code="ax_snapshot_parse_failed", message="Unable to parse Accessibility snapshot output.", guidance="Check pyobjc GUI dependencies in the bridge environment.")], warnings + if not payload.get("ok"): + return None, [make_error(code="ax_dependency_missing", message=str(redact_value(payload.get("error") or "Accessibility dependency missing.")), guidance="Install pyobjc-framework-ApplicationServices in the bridge environment.")], warnings + return payload, [], warnings + + def _capture_screenshot(self, warnings: list[str]) -> Path | None: + if self.platform_name != "Darwin": + warnings.append("screenshot unavailable outside macOS") + return None + screenshot_dir = self.state_dir / "screenshots" + screenshot_dir.mkdir(parents=True, exist_ok=True) + filename = f"customer-mac-{self.now().replace(':', '').replace('-', '')}.png" + screenshot_path = screenshot_dir / filename + result = self.runner(["screencapture", "-x", str(screenshot_path)], 10.0) + if result.returncode != 0: + warnings.append("screenshot unavailable; Screen Recording permission may be missing") + return None + return screenshot_path + + def _frontmost_app(self) -> str | None: + return self._osascript_value('tell application "System Events" to get name of first application process whose frontmost is true') + + def _front_window_title(self) -> str | None: + return self._osascript_value('tell application "System Events" to tell first application process whose frontmost is true to get name of front window') + + def _front_browser_url(self, app_name: str | None) -> str | None: + if not app_name: + return None + if app_name == "Safari": + return self._osascript_value('tell application "Safari" to get URL of front document') + if app_name in {"Google Chrome", "Arc", "Brave Browser"}: + return self._osascript_value(f'tell application "{self._escape_applescript(app_name)}" to get URL of active tab of front window') + return None + + def _pid_for_app(self, app_name: str | None) -> int | None: + if not app_name: + return None + result = self.runner(["pgrep", "-x", app_name], 3.0) + if result.returncode == 0 and result.stdout.strip(): + try: + return int(result.stdout.strip().splitlines()[0]) + except ValueError: + pass + if self.platform_name != "Darwin": + return None + script = f'tell application "System Events" to get unix id of first application process whose name is "{self._escape_applescript(app_name)}"' + fallback = self.runner(["osascript", "-e", script], 5.0) + if fallback.returncode != 0 or not fallback.stdout.strip(): + return None + try: + return int(fallback.stdout.strip().splitlines()[0]) + except ValueError: + return None + + def _process_name_for_pid(self, pid: int | None) -> str | None: + if type(pid) is not int or pid <= 0: + return None + result = self.runner(["/bin/ps", "-p", str(pid), "-o", "comm="], 1.0) + if result.returncode != 0 or not result.stdout.strip(): + return None + return Path(result.stdout.strip().splitlines()[0]).name + + def _activate_app(self, app_name: str) -> bool: + if self.platform_name != "Darwin": + return False + result = self.runner(["osascript", "-e", f'tell application "{self._escape_applescript(app_name)}" to activate'], 5.0) + return result.returncode == 0 + + def _workbench_focus_result(self, *, app_name: str, dry_run: bool, verify_frontmost: bool) -> CommandResult | None: + if not self._is_workbench_app_alias(app_name): + return None + data = { + "focused": False, + "would_focus": dry_run, + "app_name": app_name, + "app_path": str(WORKBENCH_CANONICAL_APP_PATH), + "process_name": WORKBENCH_PROCESS_NAME, + } + if dry_run: + return CommandResult(ok=True, data=data) + if not WORKBENCH_CANONICAL_APP_PATH.exists(): + return CommandResult( + ok=False, + data=data, + errors=[ + make_error( + code="workbench_canonical_app_missing", + message="Canonical evaOS Workbench app is missing.", + guidance=f"Install the current Workbench at {WORKBENCH_CANONICAL_APP_PATH}; refusing app-name lookup to avoid stale EvaDesktop builds.", + ) + ], + ) + result = self.runner(["open", str(WORKBENCH_CANONICAL_APP_PATH)], 10.0) + warnings = self._stderr_warning(result) + if result.returncode != 0: + return CommandResult( + ok=False, + data=data, + errors=[ + make_error( + code="workbench_focus_failed", + message="macOS refused to open the canonical evaOS Workbench app.", + guidance=f"Verify {WORKBENCH_CANONICAL_APP_PATH} exists and can launch.", + ) + ], + warnings=warnings, + ) + data = {**data, "focused": True, "would_focus": False, "engine": "macos_open_path"} + if verify_frontmost and not self._wait_for_frontmost(WORKBENCH_PROCESS_NAME, timeout_seconds=3.0): + return CommandResult( + ok=False, + data={**data, "focused": False, "frontmost_app": redact_value(self._frontmost_app())}, + errors=[ + make_error( + code="workbench_focus_not_frontmost", + message="Canonical evaOS Workbench opened, but did not become frontmost.", + guidance="Close competing modal windows and retry; do not fall back to `open -a EvaDesktop`.", + ) + ], + warnings=warnings, + ) + return CommandResult(ok=True, data=data if not verify_frontmost else {**data, "frontmost": True}, warnings=warnings, provenance={"source": "macos_open_path"}) + + def _is_workbench_app_alias(self, app_name: str) -> bool: + normalized = " ".join(app_name.strip().split()).casefold() + if normalized.endswith(".app"): + normalized = normalized[:-4] + return normalized in WORKBENCH_APP_ALIASES + + def _wait_for_frontmost(self, app_name: str, *, timeout_seconds: float) -> bool: + if self.platform_name != "Darwin": + return False + deadline = datetime.now(timezone.utc).timestamp() + max(0.1, timeout_seconds) + while datetime.now(timezone.utc).timestamp() < deadline: + if self._frontmost_app() == app_name: + return True + time.sleep(0.2) + return self._frontmost_app() == app_name + + def _iphone_window_bounds(self) -> tuple[int, int, int, int] | None: + if self.platform_name != "Darwin": + return None + result = self.runner( + [ + "osascript", + "-e", + 'tell application "System Events" to tell process "iPhone Mirroring" to get {position, size} of front window', + ], + 5.0, + ) + if result.returncode != 0 or not result.stdout.strip(): + return None + parts = [part.strip() for part in result.stdout.replace("\n", ",").split(",") if part.strip()] + if len(parts) < 4: + return None + try: + x, y, width, height = (int(float(part)) for part in parts[:4]) + except ValueError: + return None + if width <= 0 or height <= 0: + return None + return x, y, width, height + + def _device_identity(self) -> dict[str, Any]: + uuid_value = self._ioreg_uuid() + digest_source = uuid_value or platform.node() or "unknown" + return { + "id": "mac-" + hashlib.sha256(digest_source.encode("utf-8")).hexdigest()[:16], + "hostname": redact_value(platform.node()), + "hardware_uuid_present": uuid_value is not None, + "arch": platform.machine(), + } + + def _ioreg_uuid(self) -> str | None: + if self.platform_name != "Darwin": + return None + result = self.runner(["ioreg", "-rd1", "-c", "IOPlatformExpertDevice"], 5.0) + if result.returncode != 0: + return None + match = re.search(r'"IOPlatformUUID" = "([^"]+)"', result.stdout) + return match.group(1) if match else None + + def _permission_status(self, permission: str) -> dict[str, str]: + if permission == "accessibility": + trusted = self._peekaboo_permission_status(permission) + if trusted is None: + trusted = self.accessibility_checker() + if trusted is True: + return {"status": "granted", "guidance": ACCESSIBILITY_GUIDANCE, "source": "peekaboo_or_bridge"} + if trusted is False: + return {"status": "missing", "guidance": ACCESSIBILITY_GUIDANCE, "source": "peekaboo_or_bridge"} + return {"status": "unknown", "guidance": ACCESSIBILITY_GUIDANCE, "source": "peekaboo_or_bridge"} + if permission == "screen_recording": + trusted = self._peekaboo_permission_status(permission) + if trusted is None: + trusted = self.screen_recording_checker() + if trusted is True: + return {"status": "granted", "guidance": SCREEN_RECORDING_GUIDANCE, "source": "peekaboo_or_bridge"} + if trusted is False: + return {"status": "missing", "guidance": SCREEN_RECORDING_GUIDANCE, "source": "peekaboo_or_bridge"} + return {"status": "unknown", "guidance": SCREEN_RECORDING_GUIDANCE, "source": "peekaboo_or_bridge"} + return {"status": "unknown", "guidance": ACCESSIBILITY_GUIDANCE, "source": "peekaboo_or_bridge"} + + def _permission_error(self, permission: str, action: str) -> dict[str, Any]: + return make_error(code="permission_missing", message=f"Accessibility permission is required to {action}.", guidance=ACCESSIBILITY_GUIDANCE, permission=permission) + + def _osascript_value(self, script: str) -> str | None: + if self.platform_name != "Darwin": + return None + result = self.runner(["osascript", "-e", script], 5.0) + if result.returncode != 0: + return None + return result.stdout.strip() or None + + def _launchctl_disabled(self, label: str) -> bool | None: + result = self.runner(["launchctl", "print-disabled", "system"], 5.0) + if result.returncode != 0: + return None + for line in result.stdout.splitlines(): + if label in line: + lowered = line.lower() + if "disabled" in lowered: + return True + if "enabled" in lowered: + return False + if "=> true" in lowered: + return True + if "=> false" in lowered: + return False + return None + + def _tcp_port_listening(self, port: str) -> bool: + result = self.runner(["/usr/sbin/lsof", "-nP", f"-iTCP:{port}", "-sTCP:LISTEN"], 5.0) + if result.returncode == 0 and bool(result.stdout.strip()): + return True + fallback = self.runner(["nc", "-z", "127.0.0.1", port], 5.0) + return fallback.returncode == 0 + + def _new_snapshot_id(self, target: str) -> str: + safe_target = re.sub(r"[^a-z0-9_-]+", "-", target.lower()).strip("-") or "desktop" + return f"snap-{safe_target}-{uuid.uuid4().hex}" + + def _emit_takeover_warning(self, session: dict[str, Any]) -> None: + if self.platform_name != "Darwin" or os.environ.get("EVAOS_DESKTOP_BRIDGE_DISABLE_TAKEOVER_WARNING_UI") == "1": + return + warning = session.get("takeover_warning") if isinstance(session.get("takeover_warning"), dict) else {} + if warning.get("active") is not True: + return + seconds = warning.get("seconds") if isinstance(warning.get("seconds"), int) else 10 + message = f"Taking over screen in {seconds} seconds. Yield the screen now." + escaped = self._escape_applescript(message) + notification_script = f'display notification "{escaped}" with title "evaOS Agent Control" subtitle "Taking over screen"' + beep_script = """ +repeat 6 times + beep + delay 0.35 +end repeat +""".strip() + signal_status: dict[str, Any] = {} + for key, command, timeout in [ + ("notification", ["osascript", "-e", notification_script], 3.0), + ("beep_loop", ["osascript", "-e", beep_script], 5.0), + ]: + try: + result = self.runner(command, timeout) + except Exception as exc: # noqa: BLE001 + signal_status[key] = {"available": False, "error": self._safe_signal_error(exc)} + else: + signal_status[key] = {"available": result.returncode == 0} + if result.returncode != 0: + error = result.stderr.strip() or result.stdout.strip() + if error: + signal_status[key]["error"] = str(redact_value(error))[:240] + sound = Path("/System/Library/Sounds/Basso.aiff") + if sound.exists(): + play_count = 0 + errors: list[str] = [] + for _ in range(3): + try: + result = self.runner(["afplay", str(sound)], 3.0) + except Exception as exc: # noqa: BLE001 + errors.append(self._safe_signal_error(exc)) + continue + if result.returncode == 0: + play_count += 1 + else: + error = result.stderr.strip() or result.stdout.strip() + if error: + errors.append(str(redact_value(error))[:240]) + signal_status["basso_sound"] = {"available": play_count > 0, "plays": play_count} + if errors: + signal_status["basso_sound"]["errors"] = errors[:3] + else: + signal_status["basso_sound"] = {"available": False, "reason": "sound_missing"} + session["takeover_alert_signal_status"] = signal_status + + def _safe_signal_error(self, exc: BaseException) -> str: + return str(redact_value(str(exc)))[:240] + + def _image_artifact(self, path: Path, *, snapshot_id: str) -> dict[str, Any] | None: + try: + raw = path.read_bytes() + except OSError: + return None + sha256 = hashlib.sha256(raw).hexdigest() + width, height = self._png_dimensions(raw) + artifact_dir = self.state_dir / "artifacts" + artifact_dir.mkdir(parents=True, exist_ok=True) + artifact_path = artifact_dir / f"{snapshot_id}.png" + if path != artifact_path: + try: + shutil.copyfile(path, artifact_path) + except OSError: + artifact_path = path + image: dict[str, Any] = { + "artifact_id": snapshot_id, + "artifact_url": f"/v1/artifacts/{snapshot_id}.png", + "artifact_path": redact_value(artifact_path), + "mime_type": "image/png", + "sha256": sha256, + "byte_count": len(raw), + "width": width, + "height": height, + } + if len(raw) <= SNAPSHOT_INLINE_MAX_BYTES: + image["bytes_base64"] = base64.b64encode(raw).decode("ascii") + else: + image["bytes_base64_omitted"] = True + image["bytes_base64_omitted_reason"] = f"image exceeds {SNAPSHOT_INLINE_MAX_BYTES} byte inline limit" + return image + + @staticmethod + def _png_dimensions(raw: bytes) -> tuple[int | None, int | None]: + if len(raw) >= 24 and raw.startswith(b"\x89PNG\r\n\x1a\n"): + try: + width, height = struct.unpack(">II", raw[16:24]) + return int(width), int(height) + except struct.error: + return None, None + return None, None + + def _elements_from_ax(self, nodes: list[Any], *, snapshot_id: str | None) -> list[dict[str, Any]]: + elements: list[dict[str, Any]] = [] + if not snapshot_id: + return elements + for index, item in enumerate(nodes): + if not isinstance(item, dict): + continue + bounds = item.get("bounds") + label = item.get("name") or item.get("role") + if not isinstance(bounds, dict) or not label: + continue + try: + x = int(bounds.get("x")) + y = int(bounds.get("y")) + width = int(bounds.get("width")) + height = int(bounds.get("height")) + except (TypeError, ValueError): + continue + if width <= 0 or height <= 0: + continue + label_text, _ = cap_text(str(redact_value(label)), 160) + elements.append( + { + "element_id": f"el-{index + 1:04d}", + "snapshot_id": snapshot_id, + "label": label_text, + "role": item.get("role"), + "bounds": {"x": x, "y": y, "width": width, "height": height}, + "center": {"x": x + width // 2, "y": y + height // 2}, + "actions": item.get("actions", []), + "engine": "ax_fallback", + "ax_target": item.get("ax_target") if isinstance(item.get("ax_target"), dict) else None, + } + ) + return elements + + def _elements_from_peekaboo(self, nodes: Any, *, snapshot_id: str, max_nodes: int) -> list[dict[str, Any]]: + elements: list[dict[str, Any]] = [] + if not isinstance(nodes, list): + return elements + for index, item in enumerate(nodes): + if len(elements) >= max_nodes: + break + if not isinstance(item, dict): + continue + bounds = item.get("bounds") + label = item.get("label") or item.get("title") or item.get("description") or item.get("role") + if not isinstance(bounds, dict) or not label: + continue + try: + x = int(round(float(bounds.get("x")))) + y = int(round(float(bounds.get("y")))) + width = int(round(float(bounds.get("width")))) + height = int(round(float(bounds.get("height")))) + except (TypeError, ValueError): + continue + if width <= 0 or height <= 0: + continue + label_text, _ = cap_text(str(redact_value(label)), 160) + role_text, _ = cap_text(str(redact_value(item.get("role") or "unknown")), 80) + actions: list[str] = [] + if item.get("is_actionable") is True: + actions.append("click") + element_id = str(item.get("id") or f"peekaboo-{index + 1:04d}") + elements.append( + { + "element_id": element_id, + "peekaboo_element_id": element_id, + "snapshot_id": snapshot_id, + "label": label_text, + "role": role_text, + "bounds": {"x": x, "y": y, "width": width, "height": height}, + "center": {"x": x + width // 2, "y": y + height // 2}, + "actions": actions, + "engine": "peekaboo", + } + ) + return elements + + def _elements_from_cua(self, nodes: Any, *, snapshot_id: str, pid: int, window_id: int, max_nodes: int) -> list[dict[str, Any]]: + elements: list[dict[str, Any]] = [] + if not isinstance(nodes, list): + return elements + for fallback_index, item in enumerate(nodes): + if len(elements) >= max_nodes: + break + if not isinstance(item, dict): + continue + frame = item.get("frame") + if not isinstance(frame, dict): + continue + try: + x = int(round(float(frame.get("x")))) + y = int(round(float(frame.get("y")))) + width = int(round(float(frame.get("w", frame.get("width"))))) + height = int(round(float(frame.get("h", frame.get("height"))))) + except (TypeError, ValueError): + continue + if width <= 0 or height <= 0: + continue + try: + element_index = int(item.get("element_index")) + except (TypeError, ValueError): + element_index = fallback_index + 1 + token = item.get("element_token") + label = item.get("label") or item.get("title") or item.get("description") or item.get("role") or f"Element {element_index}" + label_text, _ = cap_text(str(redact_value(label)), 160) + role_text, _ = cap_text(str(redact_value(item.get("role") or "unknown")), 80) + actions: list[str] = [] + if item.get("disabled") is not True: + actions.append("click") + cua_target: dict[str, Any] = { + "pid": int(pid), + "window_id": int(window_id), + "element_index": element_index, + } + if isinstance(token, str) and token: + cua_target["element_token"] = token + elements.append( + { + "element_id": f"cua-{element_index:04d}", + "snapshot_id": snapshot_id, + "label": label_text, + "role": role_text, + "bounds": {"x": x, "y": y, "width": width, "height": height}, + "center": {"x": x + width // 2, "y": y + height // 2}, + "actions": actions, + "engine": "cua_driver", + "cua_target": cua_target, + } + ) + return elements + + def _write_snapshot_index( + self, + *, + snapshot_id: str, + target: str, + elements: list[dict[str, Any]], + engine: str, + peekaboo_snapshot_id: str | None = None, + coordinate_space: dict[str, Any] | None = None, + ) -> None: + snapshot_dir = self.state_dir / "snapshots" + snapshot_dir.mkdir(parents=True, exist_ok=True) + payload = { + "snapshot_id": snapshot_id, + "target": target, + "engine": engine, + "peekaboo_snapshot_id": peekaboo_snapshot_id, + "coordinate_space": coordinate_space, + "timestamp": timestamp_utc(), + "elements": elements[:1000], + } + (snapshot_dir / f"{snapshot_id}.json").write_text(json.dumps(redact_value(payload), sort_keys=True) + "\n", encoding="utf-8") + + def _read_snapshot_payload(self, snapshot_id: str) -> CommandResult: + if not re.fullmatch(r"snap-[a-z0-9_-]+-[a-f0-9]{32}", snapshot_id): + return CommandResult( + ok=False, + data={"resolved": False, "snapshot_id": snapshot_id}, + errors=[make_error(code="snapshot_id_invalid", message="snapshot_id is not a valid evaOS visual snapshot id.", guidance="Use the snapshot_id exactly as returned by desktop_see or iphone_see.")], + ) + path = self.state_dir / "snapshots" / f"{snapshot_id}.json" + if not path.exists(): + return CommandResult( + ok=False, + data={"resolved": False, "snapshot_id": snapshot_id}, + errors=[make_error(code="snapshot_not_found", message="The requested visual snapshot is no longer available.", guidance="Run desktop_see or iphone_see again, then retry with the fresh snapshot_id.")], + ) + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return CommandResult( + ok=False, + data={"resolved": False, "snapshot_id": snapshot_id}, + errors=[make_error(code="snapshot_unreadable", message="The requested visual snapshot could not be read.", guidance="Run desktop_see or iphone_see again, then retry with the fresh snapshot_id.")], + ) + if self._snapshot_stale(payload.get("timestamp")): + return CommandResult( + ok=False, + data={"resolved": False, "snapshot_id": snapshot_id}, + errors=[make_error(code="snapshot_stale", message="The requested visual snapshot is stale.", guidance="Run desktop_see or iphone_see again so the agent acts on the current screen.")], + ) + return CommandResult(ok=True, data={"resolved": True, "snapshot_id": snapshot_id, "payload": payload}) + + def _resolve_snapshot_coordinates(self, *, snapshot_id: str, x: int, y: int, expected_target: str | None = None) -> CommandResult: + payload_result = self._read_snapshot_payload(snapshot_id) + if not payload_result.ok: + return payload_result + payload = payload_result.data["payload"] + if expected_target and payload.get("target") != expected_target: + return CommandResult( + ok=False, + data={"resolved": False, "snapshot_id": snapshot_id, "target": payload.get("target")}, + errors=[make_error(code="snapshot_target_mismatch", message="The snapshot target does not match this action.", guidance=f"Run {expected_target}_see again and use that fresh snapshot_id.")], + ) + coordinate_space = payload.get("coordinate_space") if isinstance(payload.get("coordinate_space"), dict) else {} + origin = coordinate_space.get("origin") if isinstance(coordinate_space.get("origin"), dict) else {} + logical_x = int(x) + logical_y = int(y) + if coordinate_space.get("type") == "window_region": + image_size = coordinate_space.get("image_size") if isinstance(coordinate_space.get("image_size"), dict) else {} + size = coordinate_space.get("size") if isinstance(coordinate_space.get("size"), dict) else {} + try: + image_width = float(image_size.get("width") or 0) + image_height = float(image_size.get("height") or 0) + width = float(size.get("width") or 0) + height = float(size.get("height") or 0) + except (TypeError, ValueError): + image_width = image_height = width = height = 0 + if image_width > 0 and width > 0: + logical_x = int(round(float(x) * width / image_width)) + if image_height > 0 and height > 0: + logical_y = int(round(float(y) * height / image_height)) + global_x = logical_x + int(origin.get("x") or 0) + global_y = logical_y + int(origin.get("y") or 0) + ax_target = self._ax_target_for_snapshot_point(payload, x=global_x, y=global_y) + return CommandResult( + ok=True, + data={ + "resolved": True, + "snapshot_id": snapshot_id, + "point": {"x": global_x, "y": global_y}, + "input_point": {"x": int(x), "y": int(y)}, + "logical_point": {"x": logical_x, "y": logical_y}, + "coordinate_space": coordinate_space or {"type": "global"}, + **({"ax_target": ax_target} if ax_target is not None else {}), + }, + ) + + def _ax_target_for_snapshot_point(self, payload: dict[str, Any], *, x: int, y: int) -> dict[str, Any] | None: + elements = payload.get("elements") if isinstance(payload.get("elements"), list) else [] + candidates: list[tuple[int, dict[str, Any]]] = [] + for item in elements: + if not isinstance(item, dict): + continue + ax_target = item.get("ax_target") + bounds = item.get("bounds") + if not isinstance(ax_target, dict) or not isinstance(bounds, dict): + continue + try: + bx = int(bounds.get("x")) + by = int(bounds.get("y")) + width = int(bounds.get("width")) + height = int(bounds.get("height")) + except (TypeError, ValueError): + continue + if width <= 0 or height <= 0: + continue + if bx <= x < bx + width and by <= y < by + height: + candidates.append((width * height, ax_target)) + if not candidates: + return None + candidates.sort(key=lambda row: row[0]) + return candidates[0][1] + + def _resolve_snapshot_target(self, *, snapshot_id: str | None, element_id: str | None, target_label: str | None) -> CommandResult: + if not snapshot_id and not element_id: + return CommandResult(ok=True) + if not snapshot_id: + return CommandResult( + ok=False, + data={"resolved": False, "element_id": element_id}, + errors=[make_error(code="snapshot_id_required", message="element_id clicks require the matching snapshot_id.", guidance="Run desktop_see or iphone_see and pass both snapshot_id and element_id from that result.")], + ) + payload_result = self._read_snapshot_payload(snapshot_id) + if not payload_result.ok: + return payload_result + payload = payload_result.data["payload"] + elements = payload.get("elements") if isinstance(payload.get("elements"), list) else [] + match: dict[str, Any] | None = None + if element_id: + for item in elements: + if isinstance(item, dict) and item.get("element_id") == element_id: + match = item + break + elif target_label: + normalized = target_label.strip().lower() + matches = [item for item in elements if isinstance(item, dict) and str(item.get("label") or "").strip().lower() == normalized] + if len(matches) == 1: + match = matches[0] + elif len(matches) > 1: + return CommandResult( + ok=False, + data={"resolved": False, "snapshot_id": snapshot_id, "target_label": target_label, "matches": matches[:10]}, + errors=[make_error(code="snapshot_target_ambiguous", message="Multiple snapshot elements match that label.", guidance="Use the element_id from desktop_see or iphone_see.")], + ) + if match is None: + return CommandResult( + ok=False, + data={"resolved": False, "snapshot_id": snapshot_id, "element_id": element_id, "target_label": target_label}, + errors=[make_error(code="snapshot_target_not_found", message="No element in that snapshot matched the requested target.", guidance="Use an element_id or target_label from the latest desktop_see or iphone_see result.")], + ) + point = match.get("center") + if not isinstance(point, dict): + bounds = match.get("bounds") + if not isinstance(bounds, dict): + return CommandResult( + ok=False, + data={"resolved": False, "snapshot_id": snapshot_id, "element_id": element_id}, + errors=[make_error(code="snapshot_target_missing_bounds", message="The matched element has no usable bounds.", guidance="Use x/y coordinates from the screenshot as a fallback.")], + ) + point = {"x": int(bounds["x"]) + int(bounds["width"]) // 2, "y": int(bounds["y"]) + int(bounds["height"]) // 2} + return CommandResult( + ok=True, + data={ + "resolved": True, + "snapshot_id": snapshot_id, + "element_id": match.get("element_id"), + "peekaboo_element_id": match.get("peekaboo_element_id"), + "target_label": match.get("label"), + "role": match.get("role"), + "actions": match.get("actions") if isinstance(match.get("actions"), list) else [], + "ax_target": match.get("ax_target") if isinstance(match.get("ax_target"), dict) else None, + "cua_target": match.get("cua_target") if isinstance(match.get("cua_target"), dict) else None, + "engine": match.get("engine") or payload.get("engine"), + "peekaboo_snapshot_id": payload.get("peekaboo_snapshot_id"), + "point": {"x": int(point["x"]), "y": int(point["y"])}, + }, + ) + + @staticmethod + def _snapshot_stale(timestamp: Any) -> bool: + if not isinstance(timestamp, str): + return True + try: + parsed = datetime.fromisoformat(timestamp.replace("Z", "+00:00")) + except ValueError: + return True + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return (datetime.now(timezone.utc) - parsed.astimezone(timezone.utc)).total_seconds() > SNAPSHOT_MAX_AGE_SECONDS + + def _full_access_active(self) -> bool: + session = read_control_session(self.state_dir) + return session.get("active") is True and session.get("mode") == "full_access" and session.get("kill_switch") is not True + + def _safe_node(self, row: dict[str, Any]) -> dict[str, Any]: + role, _ = cap_text(str(redact_value(row.get("role") or "unknown")), 80) + name, _ = cap_text(str(redact_value(row.get("name"))) if row.get("name") else None, 160) + node: dict[str, Any] = {"role": role, "name": name, "depth": int(row.get("depth") or 0), "window_index": row.get("window_index")} + bounds = row.get("bounds") + if isinstance(bounds, dict): + try: + node["bounds"] = { + "x": int(bounds.get("x")), + "y": int(bounds.get("y")), + "width": int(bounds.get("width")), + "height": int(bounds.get("height")), + } + except (TypeError, ValueError): + pass + actions = row.get("actions") + if isinstance(actions, list): + node["actions"] = [str(redact_value(item)) for item in actions[:20]] + identifier = row.get("identifier") + if isinstance(identifier, str) and identifier: + node["identifier"] = str(redact_value(identifier))[:160] + ax_target = row.get("ax_target") + if isinstance(ax_target, dict): + node["ax_target"] = self._safe_ax_target(ax_target) + return node + + def _safe_ax_target(self, target: dict[str, Any]) -> dict[str, Any]: + safe: dict[str, Any] = {} + pid = target.get("pid") + if type(pid) is int and pid > 0: + safe["pid"] = pid + app_name = target.get("app_name") + if isinstance(app_name, str) and app_name: + safe["app_name"] = redact_value(app_name) + process_name = target.get("process_name") + if isinstance(process_name, str) and process_name: + safe["process_name"] = redact_value(process_name) + path = target.get("path") + if isinstance(path, list): + safe_path: list[dict[str, Any]] = [] + for segment in path[:24]: + if not isinstance(segment, dict): + continue + safe_segment: dict[str, Any] = {} + role = segment.get("role") + if isinstance(role, str) and role.startswith("AX"): + safe_segment["role"] = role[:80] + name = segment.get("name") + if isinstance(name, str) and name: + safe_segment["name"] = str(redact_value(name))[:160] + identifier = segment.get("identifier") + if isinstance(identifier, str) and identifier: + safe_segment["identifier"] = str(redact_value(identifier))[:160] + index = segment.get("index") + if type(index) is int and index >= 0: + safe_segment["index"] = index + if safe_segment: + safe_path.append(safe_segment) + safe["path"] = safe_path + safe["path_hash"] = hashlib.sha256(json.dumps(safe_path, sort_keys=True).encode("utf-8")).hexdigest()[:16] + return safe + + def _safe_local_url(self, url: str) -> bool: + try: + parsed = urlparse(url) + except Exception: + return False + if parsed.scheme not in {"http", "https"}: + return False + host = parsed.hostname or "" + return host in SAFE_LOCAL_HOSTS or host.endswith(".local") + + def _safe_app_name(self, value: str | None) -> bool: + return bool(value and SAFE_TEXT_RE.match(value)) + + def _is_sensitive_app(self, app_name: str | None) -> bool: + if not app_name: + return False + return app_name in SENSITIVE_APPS + + def _sensitive_app_observation_block(self, *, frontmost: str | None, surface: str) -> CommandResult | None: + if not self._is_sensitive_app(frontmost): + return None + data: dict[str, Any] = {"frontmost_app": redact_value(frontmost)} + if surface == "screenshot": + data["screenshot_path"] = None + message = "The frontmost app is on the sensitive-app denylist; screenshot capture was blocked." + elif surface == "ax_tree": + data.update({"nodes": [], "truncated": False}) + message = "The frontmost app is on the sensitive-app denylist; AX tree capture was blocked." + else: + data.update({"engine": None, "snapshot_id": None, "elements": []}) + message = "The frontmost app is on the sensitive-app denylist; desktop visual observation was blocked." + return CommandResult( + ok=False, + data=data, + errors=[ + make_error( + code="sensitive_app_blocked", + message=message, + guidance="Move focus to a non-sensitive app and rerun the named observation command.", + ) + ], + ) + + def _sensitive_app_action_block(self, *, frontmost: str | None, action: str) -> CommandResult | None: + if not self._is_sensitive_app(frontmost): + return None + return CommandResult( + ok=False, + data={"performed": False, "action": action, "frontmost_app": redact_value(frontmost)}, + errors=[ + make_error( + code="sensitive_app_blocked", + message="The frontmost app is on the sensitive-app denylist; live desktop control was blocked.", + guidance="Move focus to a non-sensitive app and rerun the named control command.", + ) + ], + ) + + def _sensitive_ax_target_action_block(self, *, ax_target: dict[str, Any], action: str) -> CommandResult | None: + app_name = ax_target.get("app_name") + if not isinstance(app_name, str) or not self._is_sensitive_app(app_name): + return None + return CommandResult( + ok=False, + data={"performed": False, "action": action, "target_app": redact_value(app_name), "target_pid": ax_target.get("pid")}, + errors=[ + make_error( + code="sensitive_app_blocked", + message="The target app is on the sensitive-app denylist; background AX control was blocked.", + guidance="Use customer-visible non-sensitive apps only. Background AX target identity is checked separately from the frontmost app.", + ) + ], + ) + + def _ax_target_process_identity_block(self, *, ax_target: dict[str, Any], action: str) -> CommandResult | None: + pid = ax_target.get("pid") + process_name = ax_target.get("process_name") + if type(pid) is int and pid > 0 and isinstance(process_name, str) and process_name.strip(): + return None + return CommandResult( + ok=False, + data={"performed": False, "action": action, "target_pid": ax_target.get("pid")}, + errors=[ + make_error( + code="ax_target_process_identity_required", + message="AX action target process identity is missing; live action was blocked.", + guidance="Run desktop_see again so the snapshot includes process identity before dispatch.", + ) + ], + ) + + def _post_to_pid_browser_target_block(self, *, ax_target: dict[str, Any], action: str) -> CommandResult | None: + path = ax_target.get("path") + if isinstance(path, list) and path: + return None + app_name = ax_target.get("app_name") + process_name = ax_target.get("process_name") + names = {name for name in (app_name, process_name) if isinstance(name, str)} + if not any(name in SAFE_BROWSER_APPS or Path(name).name in SAFE_BROWSER_APPS for name in names): + return None + return CommandResult( + ok=False, + data={"performed": False, "action": action, "target_pid": ax_target.get("pid"), "target_app": redact_value(app_name)}, + errors=[ + make_error( + code="post_to_pid_browser_target_ambiguous", + message="Browser coordinate targets are not safe for Tier-2 per-process posting.", + guidance="Use browser/CDP/Playwright or a native Accessibility target with a fresh AX path; browser web content is treated as inert for PostToPid.", + ) + ], + ) + + def _inert_ax_target_action_block(self, *, ax_target: dict[str, Any], action: str) -> CommandResult | None: + path = ax_target.get("path") + if not isinstance(path, list): + return None + if not any(isinstance(segment, dict) and segment.get("role") == "AXWebArea" for segment in path): + return None + return CommandResult( + ok=False, + data={"performed": False, "action": action, "target_pid": ax_target.get("pid")}, + errors=[ + make_error( + code="ax_web_content_inert", + message="AX actions against browser web content are treated as inert and are not reported as success.", + guidance="Use the browser/CDP/Playwright strategy for web content instead of Tier-1 AX.", + ) + ], + ) + + def _is_iphone_sensitive_app(self, app_name: str | None) -> bool: + if not app_name: + return False + normalized = app_name.strip().lower() + return any(normalized == item.lower() for item in SENSITIVE_IPHONE_APPS) + + def _is_dangerous_iphone_target(self, label: str | None) -> bool: + return bool(label and DANGEROUS_IPHONE_TARGET_RE.search(label.strip())) + + def _safe_preview(self, text: str | None) -> str | None: + if text is None: + return None + capped, _ = cap_text(redact_value(text), 80) + return capped + + def _looks_like_secret(self, text: str) -> bool: + normalized = text.strip() + if re.search(r"(?i)(password|passwd|secret|token|api[_-]?key|authorization)\s*[:=]", normalized): + return True + if re.search(r"(?i)\b(bearer|basic)\s+[A-Za-z0-9._~+/=-]{16,}", normalized): + return True + if re.search(r"\b(?:sk|pk|rk|ghp|gho|github_pat|xox[baprs])[_-][A-Za-z0-9_=-]{16,}", normalized): + return True + compact = re.sub(r"\s+", "", normalized) + return len(compact) >= 48 and bool(re.fullmatch(r"[A-Za-z0-9_./+=:-]+", compact)) + + def _unsafe_text_error(self, action: str, *, dry_run: bool) -> CommandResult: + return CommandResult( + ok=False, + data={"performed": False, "would_perform": dry_run, "action": action}, + errors=[make_error(code="safe_text_required", message="Text entry is limited to short disposable/search-style text.", guidance="Use letters, numbers, spaces, and simple punctuation only; do not type secrets, messages, or private data.")], + ) + + def _approved_text_error(self, action: str, *, dry_run: bool) -> CommandResult: + return CommandResult( + ok=False, + data={"performed": False, "would_perform": dry_run, "action": action}, + errors=[make_error(code="approved_text_required", message="Approved text entry requires non-empty same-turn-approved text capped at 240 characters.", guidance="Do not type secrets. For messages, get exact human approval for the recipient/context and exact text first.")], + ) + + def _text_hash(self, text: str | None) -> str | None: + if text is None: + return None + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + def _escape_applescript(self, value: str) -> str: + return value.replace("\\", "\\\\").replace('"', '\\"') + + def _stderr_warning(self, result: RunnerResult) -> list[str]: + return [str(redact_value(result.stderr.strip()))] if result.stderr.strip() else [] + + def _safety_summary(self) -> dict[str, bool]: + return { + "full_access_allows_customer_granted_control": True, + "full_access_allows_coordinates": True, + "full_access_allows_typing": True, + "full_access_allows_sensitive_apps": False, + "sensitive_apps_blocked": True, + "hidden_shell_public_ports_and_token_exfiltration_blocked": True, + "append_only_audit_log": True, + "kill_switch_available": True, + } diff --git a/resources/evaos-beta/bridge/src/evaos_desktop_bridge/audit.py b/resources/evaos-beta/bridge/src/evaos_desktop_bridge/audit.py new file mode 100644 index 0000000000..7a4c822b33 --- /dev/null +++ b/resources/evaos-beta/bridge/src/evaos_desktop_bridge/audit.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import json +import os +import platform +import uuid +from pathlib import Path +from typing import Any + +from .redaction import redact_value +from .schema import SCHEMA_VERSION, timestamp_utc + +STATE_DIR_ENV = "EVAOS_DESKTOP_BRIDGE_STATE_DIR" + + +def default_state_dir() -> Path: + override = os.environ.get(STATE_DIR_ENV) + if override: + return Path(override).expanduser() + if platform.system() == "Darwin": + return Path.home() / "Library" / "Application Support" / "evaos-desktop-bridge" + return Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state")) / "evaos-desktop-bridge" + + +def append_audit( + *, + command: str, + target: str, + args: dict[str, Any], + ok: bool, + warnings: list[str], + errors: list[dict[str, Any]], + provenance: dict[str, Any] | None = None, + state_dir: Path | None = None, + audit_id: str | None = None, +) -> str: + if audit_id is not None and (not isinstance(audit_id, str) or not audit_id.startswith("audit-")): + raise ValueError("audit_id must start with audit-") + record_audit_id = audit_id or f"audit-{uuid.uuid4().hex}" + root = state_dir or default_state_dir() + root.mkdir(parents=True, exist_ok=True) + record = { + "schema_version": SCHEMA_VERSION, + "audit_id": record_audit_id, + "timestamp": timestamp_utc(), + "command": command, + "target": target, + "args": redact_value(args), + "ok": ok, + "warnings": redact_value(warnings), + "errors": redact_value(errors), + "provenance": redact_value(provenance or {}), + } + with (root / "audit.jsonl").open("a", encoding="utf-8") as handle: + handle.write(json.dumps(record, sort_keys=True, separators=(",", ":")) + "\n") + return record_audit_id diff --git a/resources/evaos-beta/bridge/src/evaos_desktop_bridge/behavior_harness.py b/resources/evaos-beta/bridge/src/evaos_desktop_bridge/behavior_harness.py new file mode 100644 index 0000000000..928ea40321 --- /dev/null +++ b/resources/evaos-beta/bridge/src/evaos_desktop_bridge/behavior_harness.py @@ -0,0 +1,574 @@ +from __future__ import annotations + +import argparse +import json +import math +import os +import subprocess +import sys +import time +import uuid +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Protocol + + +DEFAULT_ARTIFACT_ROOT = Path("/Volumes/LEXAR/Codex/evaos-desktop-bridge-issue130-runs") +SCRATCH_APP_TITLE = "Issue130ScratchApp" +SCRATCH_APP_PACKAGE = Path(__file__).resolve().parents[2] / "tests" / "fixtures" / "macos" / "Issue130ScratchApp" +TARGET_MARKER = "ISSUE130_TARGET_PIXELS" +OCCLUDER_MARKER = "ISSUE130_OCCLUDER" +DENIED_TEXT = "ISSUE130_DENIED_MUTATION" +SENSITIVE_OBSERVATION_SURFACES = ("desktop_see", "snapshot", "ax_tree") + + +@dataclass(frozen=True) +class BehaviorCommandResult: + ok: bool + data: dict[str, Any] = field(default_factory=dict) + errors: list[dict[str, Any]] = field(default_factory=list) + warnings: list[Any] = field(default_factory=list) + audit_id: str | None = None + raw: dict[str, Any] | None = None + + +@dataclass(frozen=True) +class BehaviorCheck: + id: str + description: str + status: str + evidence: dict[str, Any] = field(default_factory=dict) + errors: list[dict[str, Any]] = field(default_factory=list) + warnings: list[Any] = field(default_factory=list) + + +@dataclass(frozen=True) +class BehaviorReport: + run_id: str + started_at: str + suite: str + ok: bool + summary: dict[str, int] + checks: list[BehaviorCheck] + + def to_dict(self) -> dict[str, Any]: + return { + "run_id": self.run_id, + "started_at": self.started_at, + "suite": self.suite, + "ok": self.ok, + "summary": self.summary, + "checks": [asdict(check) for check in self.checks], + } + + +class Issue130Surface(Protocol): + def open_scratch_app(self) -> None: + ... + + def close_scratch_app(self) -> None: + ... + + def scratch_state(self) -> dict[str, Any]: + ... + + def frontmost_app(self) -> str | None: + ... + + def cursor_position(self) -> dict[str, int] | None: + ... + + def perform_increment(self) -> BehaviorCommandResult: + ... + + def perform_denied_text(self) -> BehaviorCommandResult: + ... + + def capture_occluded_target(self) -> BehaviorCommandResult: + ... + + def observe_sensitive_surface(self, surface: str) -> BehaviorCommandResult: + ... + + +def run_issue130_invariants(surface: Issue130Surface, *, run_id: str | None = None) -> BehaviorReport: + started_at = _timestamp() + checks: list[BehaviorCheck] = [] + run_id = run_id or f"issue130-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}-{uuid.uuid4().hex[:8]}" + try: + surface.open_scratch_app() + before_state = _safe_state(surface) + before_frontmost = _safe_frontmost(surface) + before_cursor = _safe_cursor(surface) + increment = surface.perform_increment() + after_state = _safe_state(surface) + after_frontmost = _safe_frontmost(surface) + after_cursor = _safe_cursor(surface) + checks.append(_check_intended_effect(before_state, after_state, increment)) + checks.append(_check_frontmost_unchanged(before_frontmost, after_frontmost)) + checks.append(_check_cursor_not_warped(before_cursor, after_cursor, increment)) + checks.append(_check_occluded_capture(surface.capture_occluded_target())) + denied_before = _safe_state(surface) + denied = surface.perform_denied_text() + denied_after = _safe_state(surface) + checks.append(_check_policy_denied_zero_effect(denied_before, denied_after, denied)) + checks.append(_check_sensitive_denylist(surface)) + except Exception as exc: # noqa: BLE001 - reports setup/runtime failure as harness data. + checks.append( + BehaviorCheck( + id="issue130_harness_runtime", + description="The Issue #130 behavior harness must start and finish cleanly.", + status="failed", + errors=[_error("issue130_harness_exception", str(exc), "Inspect the local harness report and scratch app logs.")], + ) + ) + finally: + try: + surface.close_scratch_app() + except Exception: + pass + return _report(run_id=run_id, started_at=started_at, checks=checks) + + +def write_issue130_report(report: BehaviorReport, *, artifact_dir: Path) -> dict[str, Path]: + artifact_dir.mkdir(parents=True, exist_ok=True) + json_path = artifact_dir / "issue130-behavior-report.json" + markdown_path = artifact_dir / "issue130-behavior-report.md" + json_path.write_text(json.dumps(report.to_dict(), indent=2, sort_keys=True) + "\n", encoding="utf-8") + markdown_path.write_text(_markdown_report(report), encoding="utf-8") + return {"json": json_path, "markdown": markdown_path} + + +class BridgeCliIssue130Surface: + def __init__( + self, + *, + artifact_dir: Path, + state_dir: Path, + repo_root: Path | None = None, + python_executable: str = sys.executable, + sensitive_app: str | None = None, + cursor_tolerance_px: int = 3, + ) -> None: + self.artifact_dir = artifact_dir + self.state_dir = state_dir + self.repo_root = repo_root or Path.cwd() + self.python_executable = python_executable + self.sensitive_app = sensitive_app + self.cursor_tolerance_px = cursor_tolerance_px + self.scratch = ScratchAppProcess(artifact_dir=artifact_dir, package_dir=_scratch_package_path(self.repo_root)) + + def open_scratch_app(self) -> None: + self.scratch.start() + self._run_cli(["customer-mac", "control", "start", "--mode", "full-access", "--agent-label", "Issue130 Behavior Harness", "--json"]) + + def close_scratch_app(self) -> None: + self._run_cli(["customer-mac", "control", "stop", "--json"], check=False) + self.scratch.stop() + + def scratch_state(self) -> dict[str, Any]: + return self.scratch.state() + + def frontmost_app(self) -> str | None: + payload = self._run_cli(["customer-mac", "status", "--json"], check=False).raw or {} + data = payload.get("data") if isinstance(payload.get("data"), dict) else {} + value = data.get("frontmost_app") + return value if isinstance(value, str) else None + + def cursor_position(self) -> dict[str, int] | None: + script = ( + "import json\n" + "try:\n" + " import Quartz\n" + " loc = Quartz.CGEventGetLocation(Quartz.CGEventCreate(None))\n" + " print(json.dumps({'x': int(loc.x), 'y': int(loc.y)}))\n" + "except Exception as exc:\n" + " print(json.dumps({'error': str(exc)}))\n" + ) + completed = subprocess.run([self.python_executable, "-c", script], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=5, check=False) + try: + payload = json.loads(completed.stdout.strip() or "{}") + except json.JSONDecodeError: + return None + if isinstance(payload.get("x"), int) and isinstance(payload.get("y"), int): + return {"x": payload["x"], "y": payload["y"]} + return None + + def perform_increment(self) -> BehaviorCommandResult: + self.scratch.hide_occluder() + self._activate_scratch() + return self._run_cli(["customer-mac", "desktop", "click", "--target-label", "Issue130 Increment", "--json"]) + + def perform_denied_text(self) -> BehaviorCommandResult: + self._activate_scratch() + self._run_cli(["customer-mac", "control", "stop", "--json"], check=False) + return self._run_cli(["customer-mac", "desktop", "type", "--text", DENIED_TEXT, "--json"], check=False) + + def capture_occluded_target(self) -> BehaviorCommandResult: + self.scratch.show_occluder() + self._activate_scratch() + result = self._run_cli(["customer-mac", "desktop", "see", "--json", "--max-chars", "4000", "--max-nodes", "200"], check=False) + text = _visible_text(result.data) + result.data.setdefault("target_window", "issue130-target") + result.data.setdefault("visible_marker", TARGET_MARKER if TARGET_MARKER in text else text[:200]) + return result + + def observe_sensitive_surface(self, surface: str) -> BehaviorCommandResult: + if not self.sensitive_app: + return BehaviorCommandResult( + ok=False, + data={"surface": surface}, + errors=[_error("issue130_sensitive_probe_not_configured", "No sensitive app was configured for the live denylist probe.", "Pass --sensitive-app 'System Settings' or another safe-to-open denylisted app for local acceptance.")], + ) + self._activate_app(self.sensitive_app) + command = { + "desktop_see": ["customer-mac", "desktop", "see", "--json"], + "snapshot": ["customer-mac", "snapshot", "--json"], + "ax_tree": ["customer-mac", "ax-tree", "--json"], + }[surface] + return self._run_cli(command, check=False) + + def _activate_scratch(self) -> None: + self._activate_app(SCRATCH_APP_TITLE) + + def _activate_app(self, app_name: str) -> None: + subprocess.run(["osascript", "-e", f'tell application "{app_name}" to activate'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=5, check=False) + deadline = time.monotonic() + 3.0 + while time.monotonic() < deadline: + if self.frontmost_app() == app_name: + return + time.sleep(0.15) + + def _run_cli(self, argv: list[str], *, check: bool = True) -> BehaviorCommandResult: + env = os.environ.copy() + env["EVAOS_DESKTOP_BRIDGE_STATE_DIR"] = str(self.state_dir) + src_path = str(self.repo_root / "src") + env["PYTHONPATH"] = src_path + (os.pathsep + env["PYTHONPATH"] if env.get("PYTHONPATH") else "") + completed = subprocess.run( + [self.python_executable, "-m", "evaos_desktop_bridge.cli", *argv], + cwd=self.repo_root, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=75, + check=False, + ) + try: + payload = json.loads(completed.stdout.strip() or "{}") + except json.JSONDecodeError: + payload = { + "ok": False, + "data": {}, + "warnings": [], + "errors": [_error("issue130_cli_non_json", completed.stderr or completed.stdout or "bridge command returned non-JSON", "Inspect the harness command output.")], + } + if check and payload.get("ok") is not True: + raise RuntimeError(f"bridge command failed: {' '.join(argv)}: {payload.get('errors')}") + return _command_result(payload) + + +class ScratchAppProcess: + def __init__(self, *, artifact_dir: Path, package_dir: Path) -> None: + self.artifact_dir = artifact_dir + self.package_dir = package_dir + self.state_path = artifact_dir / "scratch-state.json" + self.ready_path = artifact_dir / "scratch-ready.json" + self.command_path = artifact_dir / "scratch-command.json" + self.process: subprocess.Popen[str] | None = None + + def start(self) -> None: + self.artifact_dir.mkdir(parents=True, exist_ok=True) + for path in (self.state_path, self.ready_path, self.command_path): + path.unlink(missing_ok=True) + if not self.package_dir.exists(): + raise RuntimeError(f"Issue130 scratch app package not found: {self.package_dir}") + self.process = subprocess.Popen( + ["swift", "run", "Issue130ScratchApp", "--state", str(self.state_path), "--ready", str(self.ready_path), "--command", str(self.command_path)], + cwd=self.package_dir, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + text=True, + ) + deadline = time.monotonic() + 45.0 + while time.monotonic() < deadline: + if self.process.poll() is not None: + raise RuntimeError(f"Issue130 scratch app exited early with {self.process.returncode}.") + if self.ready_path.exists() and self.state_path.exists(): + return + time.sleep(0.2) + raise RuntimeError("Issue130 scratch app did not become ready before timeout.") + + def stop(self) -> None: + if self.process is None: + return + if self.process.poll() is None: + self.process.terminate() + try: + self.process.wait(timeout=5) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.wait(timeout=5) + self.process = None + + def state(self) -> dict[str, Any]: + deadline = time.monotonic() + 3.0 + while time.monotonic() < deadline: + try: + return json.loads(self.state_path.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError): + time.sleep(0.1) + raise RuntimeError("Issue130 scratch app state file is unavailable.") + + def show_occluder(self) -> None: + self._write_command("show_occluder") + + def hide_occluder(self) -> None: + self._write_command("hide_occluder") + + def _write_command(self, command: str) -> None: + self.command_path.write_text(json.dumps({"command": command, "nonce": uuid.uuid4().hex}), encoding="utf-8") + deadline = time.monotonic() + 3.0 + while time.monotonic() < deadline: + state = self.state() + if bool(state.get("occluder_visible")) == (command == "show_occluder"): + return + time.sleep(0.1) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Run evaOS issue #130 behavior/invariant harness.") + parser.add_argument("--suite", choices=("issue130",), default="issue130") + parser.add_argument("--artifact-dir", type=Path) + parser.add_argument("--repo-root", type=Path, default=Path.cwd()) + parser.add_argument("--state-dir", type=Path) + parser.add_argument("--sensitive-app", default=None, help="Optional denylisted app to focus for live sensitive-observation probes, for example System Settings.") + parser.add_argument("--operator-ack-live-control", action="store_true", help="Required because this harness opens a scratch app and may click/type through the bridge.") + args = parser.parse_args(argv) + if not args.operator_ack_live_control: + print("Refusing to run issue #130 live behavior harness without --operator-ack-live-control.", file=sys.stderr) + return 2 + run_root = args.artifact_dir or DEFAULT_ARTIFACT_ROOT / f"issue130-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}-{uuid.uuid4().hex[:8]}" + state_dir = args.state_dir or run_root / "state" + surface = BridgeCliIssue130Surface(artifact_dir=run_root, state_dir=state_dir, repo_root=args.repo_root, sensitive_app=args.sensitive_app) + report = run_issue130_invariants(surface) + paths = write_issue130_report(report, artifact_dir=run_root) + print(json.dumps({"ok": report.ok, "summary": report.summary, "artifact_dir": str(run_root), "reports": {key: str(path) for key, path in paths.items()}}, sort_keys=True)) + return 0 if report.ok else 1 + + +def _check_intended_effect(before: dict[str, Any], after: dict[str, Any], command: BehaviorCommandResult) -> BehaviorCheck: + before_counter = _int_value(before.get("counter")) + after_counter = _int_value(after.get("counter")) + passed = command.ok and before_counter is not None and after_counter == before_counter + 1 + return _check( + "intended_effect", + "A live scratch-app action must produce the intended effect.", + passed, + evidence={"before_counter": before_counter, "after_counter": after_counter, "command_ok": command.ok, "audit_id": command.audit_id}, + errors=[] if passed else [_error("issue130_intended_effect_missing", "The live action did not increment the scratch counter exactly once.", "Inspect target resolution and actuation behavior.")], + warnings=command.warnings, + ) + + +def _check_frontmost_unchanged(before: str | None, after: str | None) -> BehaviorCheck: + passed = bool(before) and before == after + return _check( + "frontmost_unchanged", + "A live scratch-app action must not steal focus away from the target app.", + passed, + evidence={"before_frontmost": before, "after_frontmost": after}, + errors=[] if passed else [_error("issue130_frontmost_changed", "The frontmost app changed during the live action.", "Use pid/window-targeted actuation that preserves focus.")], + ) + + +def _check_cursor_not_warped(before: dict[str, int] | None, after: dict[str, int] | None, command: BehaviorCommandResult) -> BehaviorCheck: + action_point = command.data.get("action_point") if isinstance(command.data.get("action_point"), dict) else command.data.get("point") + distance = _point_distance(before, after) + action_xy = {"x": action_point.get("x"), "y": action_point.get("y")} if isinstance(action_point, dict) else None + moved = before is not None and after is not None and before != after + warped_to_action = bool(moved and action_xy is not None and after == action_xy) + passed = before is not None and after is not None and distance is not None and distance <= 3 and not warped_to_action + return _check( + "cursor_not_warped", + "A live scratch-app action must not move the user's cursor to the action point.", + passed, + evidence={"before_cursor": before, "after_cursor": after, "distance_px": distance, "action_point": action_point}, + errors=[] if passed else [_error("issue130_cursor_warped", "The cursor moved during actuation or landed on the action point.", "Prefer AX/pid-targeted actuation over global CGHID mouse warping.")], + ) + + +def _check_occluded_capture(command: BehaviorCommandResult) -> BehaviorCheck: + marker = str(command.data.get("visible_marker") or "") + passed = command.ok and TARGET_MARKER in marker and OCCLUDER_MARKER not in marker + return _check( + "occluded_capture_target_pixels", + "Occluded capture must return the target window's pixels, not the covering window.", + passed, + evidence={"command_ok": command.ok, "target_window": command.data.get("target_window"), "visible_marker": marker[:240], "audit_id": command.audit_id}, + errors=[] if passed else [_error("issue130_occluded_target_pixels_missing", "The occluded capture did not expose the target-window marker.", "Implement or route through a target-window capture path before certifying background control.")], + warnings=command.warnings, + ) + + +def _check_policy_denied_zero_effect(before: dict[str, Any], after: dict[str, Any], command: BehaviorCommandResult) -> BehaviorCheck: + unchanged = before == after + denied = command.ok is False + policy_error_codes = { + "approval_audit_required", + "approval_required", + "control_kill_switch_active", + "control_session_required", + "permission_missing", + "policy_denied", + } + error_codes = set(_error_codes(command.errors)) + denied_by_policy = bool(error_codes & policy_error_codes) + passed = unchanged and denied and denied_by_policy + errors: list[dict[str, Any]] = [] + if not denied: + errors.append(_error("issue130_denied_command_succeeded", "A policy-denied live command returned ok:true.", "Live guarded commands must fail closed without approval/control.")) + if denied and not denied_by_policy: + errors.append(_error("issue130_denied_reason_unverified", "The denied command did not report a policy-denied error code.", "Assert policy-specific denial metadata to keep this invariant meaningful.")) + if not unchanged: + errors.append(_error("issue130_denied_mutated_state", "A policy-denied live command changed scratch-app state.", "Denied operations must have zero effect.")) + return _check( + "policy_denied_zero_effect", + "A policy-denied operation must have zero effect on scratch-app state.", + passed, + evidence={"before_state": before, "after_state": after, "command_ok": command.ok, "error_codes": sorted(error_codes), "denied_by_policy": denied_by_policy}, + errors=errors, + warnings=command.warnings, + ) + + +def _check_sensitive_denylist(surface: Issue130Surface) -> BehaviorCheck: + observations = {name: surface.observe_sensitive_surface(name) for name in SENSITIVE_OBSERVATION_SURFACES} + leaks: list[dict[str, Any]] = [] + evidence: dict[str, Any] = {} + for name, result in observations.items(): + codes = _error_codes(result.errors) + evidence[name] = {"ok": result.ok, "error_codes": codes, "audit_id": result.audit_id} + artifact = result.data.get("artifact_path") or result.data.get("screenshot_path") or result.data.get("screenshot") + if result.ok or "sensitive_app_blocked" not in codes or artifact: + leaks.append(_error("issue130_sensitive_surface_not_blocked", f"{name} did not fail closed with sensitive_app_blocked.", "Keep every observation path behind the shared sensitive-app denylist.")) + return _check( + "sensitive_denylist_all_observation_paths", + "The sensitive-app denylist must hold for desktop see, snapshot, and AX tree observation paths.", + not leaks, + evidence=evidence, + errors=leaks, + ) + + +def _report(*, run_id: str, started_at: str, checks: list[BehaviorCheck]) -> BehaviorReport: + summary = { + "total": len(checks), + "passed": sum(1 for check in checks if check.status == "passed"), + "failed": sum(1 for check in checks if check.status == "failed"), + "skipped": sum(1 for check in checks if check.status == "skipped"), + } + return BehaviorReport(run_id=run_id, started_at=started_at, suite="issue130", ok=summary["failed"] == 0 and summary["skipped"] == 0, summary=summary, checks=checks) + + +def _check(id: str, description: str, passed: bool, *, evidence: dict[str, Any], errors: list[dict[str, Any]], warnings: list[Any] | None = None) -> BehaviorCheck: + return BehaviorCheck(id=id, description=description, status="passed" if passed else "failed", evidence=evidence, errors=errors, warnings=warnings or []) + + +def _command_result(payload: dict[str, Any]) -> BehaviorCommandResult: + data = payload.get("data") if isinstance(payload.get("data"), dict) else {} + errors = payload.get("errors") if isinstance(payload.get("errors"), list) else [] + warnings = payload.get("warnings") if isinstance(payload.get("warnings"), list) else [] + return BehaviorCommandResult(ok=payload.get("ok") is True, data=data, errors=[error for error in errors if isinstance(error, dict)], warnings=warnings, audit_id=payload.get("audit_id") if isinstance(payload.get("audit_id"), str) else None, raw=payload) + + +def _safe_state(surface: Issue130Surface) -> dict[str, Any]: + state = surface.scratch_state() + return state if isinstance(state, dict) else {} + + +def _safe_frontmost(surface: Issue130Surface) -> str | None: + value = surface.frontmost_app() + return value if isinstance(value, str) and value.strip() else None + + +def _safe_cursor(surface: Issue130Surface) -> dict[str, int] | None: + value = surface.cursor_position() + if isinstance(value, dict) and isinstance(value.get("x"), int) and isinstance(value.get("y"), int): + return {"x": value["x"], "y": value["y"]} + return None + + +def _point_distance(before: dict[str, int] | None, after: dict[str, int] | None) -> float | None: + if before is None or after is None: + return None + return math.hypot(after["x"] - before["x"], after["y"] - before["y"]) + + +def _int_value(value: Any) -> int | None: + return value if isinstance(value, int) else None + + +def _error_codes(errors: list[dict[str, Any]]) -> list[str]: + return [str(error.get("code")) for error in errors if isinstance(error, dict) and error.get("code")] + + +def _error(code: str, message: str, guidance: str) -> dict[str, str]: + return {"code": code, "message": message, "guidance": guidance} + + +def _visible_text(data: dict[str, Any]) -> str: + chunks: list[str] = [] + + def collect(value: Any) -> None: + if isinstance(value, str): + chunks.append(value) + elif isinstance(value, list): + for item in value: + collect(item) + elif isinstance(value, dict): + for key in ("label", "title", "text", "value", "name", "description", "window_title", "frontmost_app"): + nested = value.get(key) + if isinstance(nested, str): + chunks.append(nested) + for nested in value.values(): + if isinstance(nested, (dict, list)): + collect(nested) + + collect(data) + return " ".join(chunks) + + +def _markdown_report(report: BehaviorReport) -> str: + lines = [ + "# Issue #130 Behavior Harness Report", + "", + f"- Run: `{report.run_id}`", + f"- Started: `{report.started_at}`", + f"- Suite: `{report.suite}`", + f"- OK: `{str(report.ok).lower()}`", + f"- Summary: {report.summary['passed']} passed, {report.summary['failed']} failed, {report.summary['skipped']} skipped, {report.summary['total']} total", + "", + "| Status | Check | Description |", + "| --- | --- | --- |", + ] + for check in report.checks: + lines.append(f"| {check.status} | `{check.id}` | {check.description} |") + lines.append("") + return "\n".join(lines) + + +def _scratch_package_path(repo_root: Path) -> Path: + candidate = repo_root / "tests" / "fixtures" / "macos" / "Issue130ScratchApp" + if candidate.exists(): + return candidate + return SCRATCH_APP_PACKAGE + + +def _timestamp() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/resources/evaos-beta/bridge/src/evaos_desktop_bridge/bundled_tools.py b/resources/evaos-beta/bridge/src/evaos_desktop_bridge/bundled_tools.py new file mode 100644 index 0000000000..bd2e22b146 --- /dev/null +++ b/resources/evaos-beta/bridge/src/evaos_desktop_bridge/bundled_tools.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import sys +from collections.abc import Iterable +from pathlib import Path + + +def bundled_bridge_bin_candidates(binary_names: Iterable[str], *, executable: str | None = None, argv0: str | None = None) -> tuple[str, ...]: + """Return installed Workbench Bridge/bin candidates before PATH/Homebrew fallbacks.""" + raw_paths = [executable if executable is not None else sys.executable] + if argv0 is not None: + raw_paths.append(argv0) + elif sys.argv: + raw_paths.append(sys.argv[0]) + + roots: list[Path] = [] + seen_roots: set[str] = set() + for raw in raw_paths: + if not raw: + continue + path = Path(raw).expanduser() + if not path.is_absolute(): + try: + path = path.resolve() + except OSError: + path = Path.cwd() / path + start = path if path.is_dir() else path.parent + for candidate in (start, *start.parents): + if candidate.name != "Bridge": + continue + key = str(candidate) + if key not in seen_roots: + roots.append(candidate) + seen_roots.add(key) + break + + results: list[str] = [] + seen_results: set[str] = set() + for root in roots: + for name in binary_names: + candidate = str(root / "bin" / name) + if candidate in seen_results: + continue + results.append(candidate) + seen_results.add(candidate) + return tuple(results) diff --git a/resources/evaos-beta/bridge/src/evaos_desktop_bridge/capability_manifest.py b/resources/evaos-beta/bridge/src/evaos_desktop_bridge/capability_manifest.py new file mode 100644 index 0000000000..bd647d8816 --- /dev/null +++ b/resources/evaos-beta/bridge/src/evaos_desktop_bridge/capability_manifest.py @@ -0,0 +1,191 @@ +from __future__ import annotations + +import base64 +import binascii +import hashlib +import hmac +import json +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Literal, cast + +CapabilityDecision = Literal["allowed", "requires_approval", "denied"] +ALLOWED_DECISIONS: set[str] = {"allowed", "requires_approval", "denied"} +EXPECTED_ALGORITHM = "HS256" +EXPECTED_ISSUER = "evaos-broker" +EXPECTED_AUDIENCE = "evaos-runtime" + + +class CapabilityManifestError(ValueError): + """Raised when a capability manifest cannot be trusted or parsed.""" + + +@dataclass(frozen=True) +class CapabilityBudget: + tokens_per_day: int | None = None + dollars_per_day: float | None = None + + +@dataclass(frozen=True) +class CapabilityManifest: + agent_id: str + owner_id: str + issued_at: datetime + expires_at: datetime + grants: dict[str, CapabilityDecision] + budget: CapabilityBudget + approval_channel: str + issuer: str + audience: str + + +def verify_hs256_manifest( + token: str, + secret: bytes | str, + *, + now: datetime | None = None, + issuer: str = EXPECTED_ISSUER, + audience: str = EXPECTED_AUDIENCE, +) -> CapabilityManifest: + """Verify and parse a signed evaOS Capability Manifest JWT.""" + if isinstance(secret, str): + secret = secret.encode("utf-8") + if not secret: + raise CapabilityManifestError("capability manifest secret is required") + parts = token.split(".") + if len(parts) != 3 or any(not part for part in parts): + raise CapabilityManifestError("capability manifest must be a three-part JWT") + header = _loads_json_object(_base64url_decode(parts[0]), "header") + if header.get("alg") != EXPECTED_ALGORITHM: + raise CapabilityManifestError("capability manifest algorithm must be HS256") + try: + signing_input = f"{parts[0]}.{parts[1]}".encode("ascii") + except UnicodeEncodeError as exc: + raise CapabilityManifestError("capability manifest base64url segment is invalid") from exc + expected_signature = hmac.new(secret, signing_input, hashlib.sha256).digest() + actual_signature = _base64url_decode(parts[2]) + if not hmac.compare_digest(actual_signature, expected_signature): + raise CapabilityManifestError("capability manifest signature is invalid") + payload = _loads_json_object(_base64url_decode(parts[1]), "payload") + manifest = _manifest_from_payload(payload) + if manifest.issuer != issuer: + raise CapabilityManifestError("capability manifest issuer is invalid") + if manifest.audience != audience: + raise CapabilityManifestError("capability manifest audience is invalid") + current_time = now or datetime.now(timezone.utc) + if current_time.tzinfo is None: + current_time = current_time.replace(tzinfo=timezone.utc) + if current_time > manifest.expires_at: + raise CapabilityManifestError("capability manifest expired") + if current_time < manifest.issued_at: + raise CapabilityManifestError("capability manifest is not yet valid") + return manifest + + +def decision_for_tool(manifest: CapabilityManifest, tool_name: str) -> CapabilityDecision: + return manifest.grants.get(tool_name, "denied") + + +def grant_summary(manifest: CapabilityManifest) -> dict[str, Any]: + grants: dict[str, list[str]] = {decision: [] for decision in sorted(ALLOWED_DECISIONS)} + for tool_name, decision in sorted(manifest.grants.items()): + grants[decision].append(tool_name) + return { + "agent_id": manifest.agent_id, + "owner_id": manifest.owner_id, + "expires_at": _format_utc(manifest.expires_at), + "approval_channel": manifest.approval_channel, + "budget": { + "tokens_per_day": manifest.budget.tokens_per_day, + "dollars_per_day": manifest.budget.dollars_per_day, + }, + "grants": grants, + } + + +def _manifest_from_payload(payload: dict[str, Any]) -> CapabilityManifest: + agent_id = _required_string(payload, "agent_id") + owner_id = _required_string(payload, "owner_id") + issued_at = _parse_instant(_required_string(payload, "issued_at"), "issued_at") + expires_at = _parse_instant(_required_string(payload, "expires_at"), "expires_at") + approval_channel = _required_string(payload, "approval_channel") + issuer = _required_string(payload, "iss") + audience = _required_string(payload, "aud") + raw_grants = payload.get("grants") + if not isinstance(raw_grants, dict) or not raw_grants: + raise CapabilityManifestError("capability manifest grants must be a non-empty object") + grants: dict[str, CapabilityDecision] = {} + for tool_name, decision in raw_grants.items(): + if not isinstance(tool_name, str) or not tool_name.strip(): + raise CapabilityManifestError("capability manifest grant tool names must be non-empty strings") + if not isinstance(decision, str) or decision not in ALLOWED_DECISIONS: + raise CapabilityManifestError(f"capability manifest grant decision is invalid for {tool_name}") + grants[tool_name.strip()] = cast(CapabilityDecision, decision) + budget = _budget_from_payload(payload.get("budget")) + if expires_at <= issued_at: + raise CapabilityManifestError("capability manifest expires_at must be after issued_at") + return CapabilityManifest( + agent_id=agent_id, + owner_id=owner_id, + issued_at=issued_at, + expires_at=expires_at, + grants=grants, + budget=budget, + approval_channel=approval_channel, + issuer=issuer, + audience=audience, + ) + + +def _budget_from_payload(value: Any) -> CapabilityBudget: + if value is None: + return CapabilityBudget() + if not isinstance(value, dict): + raise CapabilityManifestError("capability manifest budget must be an object") + tokens = value.get("tokens_per_day") + dollars = value.get("dollars_per_day") + if tokens is not None and (not isinstance(tokens, int) or isinstance(tokens, bool) or tokens < 0): + raise CapabilityManifestError("capability manifest budget.tokens_per_day must be a non-negative integer") + if dollars is not None and (not isinstance(dollars, (int, float)) or isinstance(dollars, bool) or float(dollars) < 0): + raise CapabilityManifestError("capability manifest budget.dollars_per_day must be a non-negative number") + return CapabilityBudget(tokens_per_day=tokens, dollars_per_day=float(dollars) if dollars is not None else None) + + +def _required_string(payload: dict[str, Any], key: str) -> str: + value = payload.get(key) + if not isinstance(value, str) or not value.strip(): + raise CapabilityManifestError(f"capability manifest {key} is required") + return value.strip() + + +def _parse_instant(value: str, key: str) -> datetime: + normalized = value[:-1] + "+00:00" if value.endswith("Z") else value + try: + parsed = datetime.fromisoformat(normalized) + except ValueError as exc: + raise CapabilityManifestError(f"capability manifest {key} must be ISO-8601") from exc + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def _format_utc(value: datetime) -> str: + return value.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def _base64url_decode(value: str) -> bytes: + try: + padded = value + ("=" * (-len(value) % 4)) + return base64.b64decode(padded.encode("ascii"), altchars=b"-_", validate=True) + except (ValueError, binascii.Error) as exc: + raise CapabilityManifestError("capability manifest base64url segment is invalid") from exc + + +def _loads_json_object(raw: bytes, label: str) -> dict[str, Any]: + try: + parsed = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise CapabilityManifestError(f"capability manifest {label} is not valid JSON") from exc + if not isinstance(parsed, dict): + raise CapabilityManifestError(f"capability manifest {label} must be an object") + return parsed diff --git a/resources/evaos-beta/bridge/src/evaos_desktop_bridge/cli.py b/resources/evaos-beta/bridge/src/evaos_desktop_bridge/cli.py new file mode 100644 index 0000000000..c28d119135 --- /dev/null +++ b/resources/evaos-beta/bridge/src/evaos_desktop_bridge/cli.py @@ -0,0 +1,3144 @@ +from __future__ import annotations + +import argparse +import hashlib +import io +import ipaddress +import json +import os +import plistlib +import re +import shutil +import socket +import stat +import subprocess +import sys +import time +from pathlib import Path +from typing import Any, Callable, TextIO + +from .adapters.codex_app_server import CodexAppServerObserver +from .adapters.codex_macos import MacOSCodexObserver +from .adapters.customer_mac import CustomerMacObserver +from .audit import append_audit, default_state_dir +from .bundled_tools import bundled_bridge_bin_candidates +from .connector_server import ( + build_diagnostics_payload, + build_ready_payload, + complete_enrollment_via_control, + read_token, + run_connector_server, +) +from .helper_ipc import HelperIpcError, UnixSocketHelperClient, default_helper_socket_path, read_helper_token, run_helper_server +from .policy import PolicyError, command_metadata, ensure_allowed +from .queue import append_queue_event, list_queue_events +from .redaction import redact_value +from .schema import build_envelope, make_error +from .state import approval_audit_freshness_error, read_audit_record, read_audit_tail, read_control_session, read_latest, write_latest +from .types import CommandResult + +LATEST_OBSERVATION_COMMANDS = frozenset( + { + "status", + "capabilities", + "helper.ping", + "codex.frontmost", + "codex.windows", + "codex.threads", + "codex.thread_map", + "codex.snapshot", + "codex.inspect", + "codex.ax_tree", + "codex.connections.status", + "codex.app_server.status", + "codex.app_server.threads", + "codex.app_server.loaded_threads", + "codex.app_server.subscribe", + "codex.app_server.remote_control_status", + "customer_mac.status", + "customer_mac.capabilities", + "customer_mac.control_status", + "customer_mac.desktop_see", + "customer_mac.iphone_see", + "customer_mac.snapshot", + "customer_mac.ax_tree", + "customer_mac.iphone_mirroring_status", + "customer_mac.screen_sharing_status", + } +) + +INLINE_SCREENSHOT_BYTES_OMITTED_REASON = ( + "inline screenshot bytes omitted from CLI JSON by default; " + "rerun the visual command with --include-screenshot-bytes to include them" +) + +WORKBENCH_RUNTIME_SCHEMA = "evaos.desktop_bridge.workbench_runtime.v1" +WORKBENCH_RUNTIME_CONTRACT_VERSION = 1 +BRIDGE_RUNTIME_SEMVER_PATTERN = re.compile( + r"(?P\d+)\.(?P\d+)\.(?P\d+)(?:[-+][0-9A-Za-z.-]+)?" +) +MINIMUM_WORKBENCH_RUNTIME_VERSION = (0, 1, 0) +_TAILSCALE_STATUS_UNSET = object() + +GUARDED_APPROVAL_FIELDS: dict[str, tuple[str, ...]] = { + "codex.select_thread": ("thread_id",), + "codex.send_visible_message": ("thread_id", "message_hash"), + "codex.continue_thread": ("title", "prompt"), + "customer_mac.app_focus": ("app_name",), + "customer_mac.local_site_open": ("url",), + "customer_mac.local_site_action": ("action",), + "customer_mac.iphone_mirroring_focus": (), + "customer_mac.iphone_mirroring_home": (), + "customer_mac.iphone_mirroring_app_switcher": (), + "customer_mac.iphone_mirroring_spotlight": (), + "customer_mac.iphone_mirroring_type_spotlight": ("text",), + "customer_mac.iphone_mirroring_open_app": ("app_name",), + "customer_mac.iphone_mirroring_tap_named_target": ("target_label",), + "customer_mac.iphone_mirroring_scroll": ("direction",), + "customer_mac.iphone_mirroring_swipe_left": (), + "customer_mac.iphone_mirroring_swipe_right": (), + "customer_mac.iphone_mirroring_swipe_up": (), + "customer_mac.iphone_mirroring_swipe_down": (), + "customer_mac.iphone_mirroring_type_approved_text": ("text",), + "customer_mac.iphone_mirroring_send_approved_message": ("text", "recipient_context", "target_label"), + "customer_mac.desktop_click": ("snapshot_id", "element_id", "target_label", "x", "y"), + "customer_mac.desktop_type": ("text",), + "customer_mac.desktop_set_value": ("snapshot_id", "element_id", "attribute", "value_hash"), + "customer_mac.desktop_scroll": ("direction", "amount"), + "customer_mac.desktop_drag": ("from_x", "from_y", "to_x", "to_y"), + "customer_mac.desktop_hotkey": ("keys",), + "customer_mac.desktop_focus_app": ("app_name",), + "customer_mac.desktop_window": ("action",), + "customer_mac.desktop_menu": ("menu_path",), + "customer_mac.desktop_browser_action": ("action", "url"), + "customer_mac.iphone_tap": ("snapshot_id", "element_id", "target_label", "x", "y"), + "customer_mac.iphone_swipe": ("direction",), + "customer_mac.iphone_type": ("text",), +} + +CODEX_SOURCE_AUDIT_FIELDS: dict[str, tuple[str, ...]] = {} + +CONTROL_SESSION_COMMANDS = frozenset( + { + "customer_mac.control_start", + "customer_mac.control_stop", + "customer_mac.control_kill_switch", + } +) + +CONTROLLED_LIVE_COMMANDS = frozenset( + { + "customer_mac.iphone_mirroring_focus", + "customer_mac.iphone_mirroring_home", + "customer_mac.iphone_mirroring_app_switcher", + "customer_mac.iphone_mirroring_spotlight", + "customer_mac.iphone_mirroring_type_spotlight", + "customer_mac.iphone_mirroring_open_app", + "customer_mac.iphone_mirroring_tap_named_target", + "customer_mac.iphone_mirroring_scroll", + "customer_mac.iphone_mirroring_swipe_left", + "customer_mac.iphone_mirroring_swipe_right", + "customer_mac.iphone_mirroring_swipe_up", + "customer_mac.iphone_mirroring_swipe_down", + "customer_mac.iphone_mirroring_type_approved_text", + "customer_mac.iphone_mirroring_send_approved_message", + "customer_mac.desktop_click", + "customer_mac.desktop_type", + "customer_mac.desktop_set_value", + "customer_mac.desktop_scroll", + "customer_mac.desktop_drag", + "customer_mac.desktop_hotkey", + "customer_mac.desktop_focus_app", + "customer_mac.desktop_window", + "customer_mac.desktop_menu", + "customer_mac.desktop_browser_action", + "customer_mac.iphone_tap", + "customer_mac.iphone_swipe", + "customer_mac.iphone_type", + } +) + +TAKEOVER_WARNING_GATED_COMMANDS = CONTROLLED_LIVE_COMMANDS | frozenset( + { + "customer_mac.app_focus", + "customer_mac.local_site_open", + "customer_mac.local_site_action", + } +) + +ASK_PERMISSION_HIGH_IMPACT_COMMANDS = frozenset( + { + "customer_mac.iphone_mirroring_swipe_left", + "customer_mac.iphone_mirroring_swipe_right", + "customer_mac.iphone_mirroring_swipe_up", + "customer_mac.iphone_mirroring_swipe_down", + "customer_mac.iphone_mirroring_type_approved_text", + "customer_mac.iphone_mirroring_send_approved_message", + "customer_mac.desktop_type", + "customer_mac.desktop_set_value", + "customer_mac.iphone_swipe", + "customer_mac.iphone_type", + } +) + +ASK_PERMISSION_RISK_WORDS = frozenset( + { + "approve", + "buy", + "confirm", + "delete", + "dispatch", + "like", + "match", + "pay", + "post", + "purchase", + "remove", + "send", + "submit", + "transfer", + "unlike", + } +) + +ASK_PERMISSION_SAFE_HOTKEYS = frozenset( + { + "cmd+[", + "cmd+]", + "cmd+l", + "cmd+r", + "cmd+t", + "escape", + "tab", + } +) + + +def _add_remote_control_flags(parser: argparse.ArgumentParser) -> None: + dry_run_group = parser.add_mutually_exclusive_group() + dry_run_group.add_argument("--dry-run", dest="dry_run", action="store_true", default=True, help="Report what would happen without mutating Codex Desktop.") + dry_run_group.add_argument("--live", dest="dry_run", action="store_false", help="Run the approved Codex Desktop remote-control action.") + parser.add_argument("--confirm", action="store_true", help="Required with --live.") + parser.add_argument("--source-audit-id", default=None, help="Audit id from the evidence/dry-run command that approved this live action.") + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="evaos-desktop-bridge", + description="evaOS Workbench connector for audited Mac and iPhone agent control.", + ) + subparsers = parser.add_subparsers(dest="scope") + + status_parser = subparsers.add_parser("status", help="Report desktop bridge and Codex Desktop status.") + status_parser.add_argument("--json", action="store_true", help="Emit JSON.") + status_parser.set_defaults(command_id="status", target="desktop") + + capabilities_parser = subparsers.add_parser("capabilities", help="Report the safe bridge capability surface.") + capabilities_parser.add_argument("--json", action="store_true", help="Emit JSON.") + capabilities_parser.set_defaults(command_id="capabilities", target="desktop") + + latest_parser = subparsers.add_parser("latest", help="Return the last observed bridge envelope.") + latest_parser.add_argument("--json", action="store_true", help="Emit JSON.") + latest_parser.set_defaults(command_id="latest", target="desktop") + + diagnostics_parser = subparsers.add_parser("diagnostics", help="Return redacted connector diagnostics for support.") + diagnostics_parser.add_argument("--json", action="store_true", help="Emit JSON.") + diagnostics_parser.add_argument( + "--token-file", + default=None, + help="Optional connector token file. Defaults to the bridge state directory when present.", + ) + diagnostics_parser.set_defaults(command_id="diagnostics", target="connector") + + ready_parser = subparsers.add_parser( + "ready", + help="Return connector readiness for release proof and support diagnostics.", + ) + ready_parser.add_argument("--json", action="store_true", help="Emit JSON.") + ready_parser.add_argument( + "--token-file", + default=None, + help="Optional connector token file. Defaults to the bridge state directory when present.", + ) + ready_parser.set_defaults(command_id="ready", target="connector") + + audit_parser = subparsers.add_parser("audit-tail", help="Return a redacted tail of the local audit log.") + audit_parser.add_argument("--json", action="store_true", help="Emit JSON.") + audit_parser.add_argument("--limit", type=_positive_int, default=20, help="Maximum audit records to return.") + audit_parser.set_defaults(command_id="audit_tail", target="desktop") + + permissions_parser = subparsers.add_parser("permissions", help="Prime macOS permission prompts for the bridge helper.") + permissions_subparsers = permissions_parser.add_subparsers(dest="permissions_command") + + permissions_prime_parser = permissions_subparsers.add_parser("prime", help="Request or check a macOS permission from the bridge helper process.") + permissions_prime_parser.add_argument("--json", action="store_true", help="Emit JSON.") + permissions_prime_parser.add_argument("--permission", required=True, choices=["accessibility", "screen-recording"], help="Permission to request/check.") + permissions_prime_parser.set_defaults(command_id="permissions.prime", target="desktop") + + helper_parser = subparsers.add_parser("helper", help="Run or inspect the local persistent computer-use helper.") + helper_subparsers = helper_parser.add_subparsers(dest="helper_command") + helper_run_parser = helper_subparsers.add_parser("run", help="Run the local Unix-socket computer-use helper daemon.") + helper_run_parser.add_argument("--socket-path", default=None, help="Unix socket path. Defaults to /tmp/evaos-helper-.sock.") + helper_run_parser.add_argument("--token-file", default=None, help="Capability-token file. Defaults to the bridge state directory.") + helper_run_parser.set_defaults(command_id="helper.run", target="computer_use_helper") + helper_ping_parser = helper_subparsers.add_parser("ping", help="Ping the local persistent computer-use helper.") + helper_ping_parser.add_argument("--json", action="store_true", help="Emit JSON.") + helper_ping_parser.add_argument("--socket-path", default=None, help="Unix socket path. Defaults to /tmp/evaos-helper-.sock.") + helper_ping_parser.add_argument("--token-file", default=None, help="Capability-token file. Defaults to the bridge state directory.") + helper_ping_parser.set_defaults(command_id="helper.ping", target="computer_use_helper") + + serve_parser = subparsers.add_parser("serve", help="Run the token-gated customer Mac connector HTTP server.") + serve_parser.add_argument("--host", default="127.0.0.1", help="Bind host. Prefer loopback or the paired Headscale interface.") + serve_parser.add_argument("--port", type=_positive_int, default=8765, help="Bind port.") + serve_parser.add_argument( + "--token-file", + default=None, + help="File containing the connector bearer token. Defaults to a per-user Application Support token.", + ) + serve_parser.set_defaults(command_id="serve", target="connector") + + service_parser = subparsers.add_parser("connector-service", help="Manage the local LaunchAgent-backed connector service.") + service_subparsers = service_parser.add_subparsers(dest="connector_service_command") + + for service_command, help_text in [ + ("status", "Report LaunchAgent, token, and loopback health status."), + ("start", "Start or kick the LaunchAgent-backed connector."), + ("stop", "Stop the LaunchAgent-backed connector."), + ]: + service_action = service_subparsers.add_parser(service_command, help=help_text) + service_action.add_argument("--json", action="store_true", help="Emit JSON.") + service_action.set_defaults(command_id=f"connector_service.{service_command}", target="connector") + + service_complete_parser = service_subparsers.add_parser( + "complete-enrollment", + help="Privately register the local connector with the broker for a Workbench pairing code.", + ) + service_complete_parser.add_argument("--json", action="store_true", help="Emit JSON.") + service_complete_parser.add_argument("--enrollment-code", required=True, help="Short-lived Workbench pairing code.") + service_complete_parser.add_argument("--customer-id", required=True, help="Customer id for the selected Workbench customer.") + service_complete_parser.add_argument("--device-name", default=None, help="Friendly Mac device name.") + service_complete_parser.add_argument("--device-identifier", default=None, help="Stable Workbench device identifier.") + service_complete_parser.set_defaults(command_id="connector_service.complete_enrollment", target="connector") + + queue_parser = subparsers.add_parser("queue", help="Eva/OpenClaw announcement queue commands.") + queue_subparsers = queue_parser.add_subparsers(dest="queue_command") + + queue_list_parser = queue_subparsers.add_parser("list", help="List local announcement queue events.") + queue_list_parser.add_argument("--json", action="store_true", help="Emit JSON.") + queue_list_parser.add_argument("--limit", type=_positive_int, default=20, help="Maximum queue events to return.") + queue_list_parser.set_defaults(command_id="queue.list", target="queue") + + queue_append_parser = queue_subparsers.add_parser("append", help="Append an Eva/OpenClaw announcement queue event.") + queue_append_parser.add_argument("--json", action="store_true", help="Emit JSON.") + queue_append_parser.add_argument("--kind", required=True, help="Queue kind: idle, approval_needed, done, error, attention.") + queue_append_parser.add_argument("--source-audit-id", required=True, help="Source audit id for provenance.") + queue_append_parser.add_argument("--message", default=None, help="Optional capped announcement message.") + queue_append_parser.set_defaults(command_id="queue.append", target="queue") + + codex_parser = subparsers.add_parser("codex", help="Codex Desktop passive observer commands.") + codex_subparsers = codex_parser.add_subparsers(dest="codex_command") + + frontmost_parser = codex_subparsers.add_parser("frontmost", help="Report the current frontmost app without capturing screenshots.") + frontmost_parser.add_argument("--json", action="store_true", help="Emit JSON.") + frontmost_parser.set_defaults(command_id="codex.frontmost", target="codex") + + windows_parser = codex_subparsers.add_parser("windows", help="List visible Codex Desktop windows via Accessibility.") + windows_parser.add_argument("--json", action="store_true", help="Emit JSON.") + windows_parser.set_defaults(command_id="codex.windows", target="codex") + + threads_parser = codex_subparsers.add_parser("threads", help="List visible Codex Desktop thread candidates from GUI state.") + threads_parser.add_argument("--json", action="store_true", help="Emit JSON.") + threads_parser.add_argument("--max-items", type=_positive_int, default=50, help="Maximum visible thread candidates to return.") + threads_parser.set_defaults(command_id="codex.threads", target="codex") + + thread_map_parser = codex_subparsers.add_parser("thread-map", help="Join visible Codex GUI thread candidates with read-only app-server thread summaries.") + thread_map_parser.add_argument("--json", action="store_true", help="Emit JSON.") + thread_map_parser.add_argument("--max-items", type=_positive_int, default=50, help="Maximum visible/app-server thread candidates to return.") + thread_map_parser.set_defaults(command_id="codex.thread_map", target="codex") + + focus_parser = codex_subparsers.add_parser("focus", help="Focus the visible Codex Desktop app.") + focus_parser.add_argument("--json", action="store_true", help="Emit JSON.") + focus_parser.add_argument("--dry-run", action="store_true", help="Report what would happen without focusing.") + focus_parser.set_defaults(command_id="codex.focus", target="codex") + + select_parser = codex_subparsers.add_parser("select-thread", help="Select an already-visible Codex Desktop thread candidate.") + select_parser.add_argument("--json", action="store_true", help="Emit JSON.") + select_parser.add_argument("--thread-id", required=True, help="Visible thread id from codex threads output.") + select_parser.add_argument("--dry-run", action="store_true", help="Report what would happen without clicking/selecting.") + select_parser.add_argument("--approval-audit-id", default=None, help="Audit id from the approving dry-run/evidence record.") + select_parser.set_defaults(command_id="codex.select_thread", target="codex") + + send_visible_parser = codex_subparsers.add_parser("send-visible-message", help="Guarded visible GUI action: send an approved message through the frontmost Codex Desktop composer.") + send_visible_parser.add_argument("--json", action="store_true", help="Emit JSON.") + send_visible_parser.add_argument("--thread-id", required=True, help="Visible thread id from codex threads/thread-map output.") + send_visible_message_group = send_visible_parser.add_mutually_exclusive_group(required=True) + send_visible_message_group.add_argument("--message", default=None, help="Approved message text to send through the visible Codex composer.") + send_visible_message_group.add_argument("--message-file", default=None, help="Path to a UTF-8 file containing the approved message; preferred for plugin/connector wrappers.") + send_visible_group = send_visible_parser.add_mutually_exclusive_group() + send_visible_group.add_argument("--dry-run", dest="dry_run", action="store_true", default=True, help="Report what would happen without typing or submitting.") + send_visible_group.add_argument("--live", dest="dry_run", action="store_false", help="Type and submit the approved message after matching dry-run approval.") + send_visible_parser.add_argument("--confirm", action="store_true", help="Required with --live.") + send_visible_parser.add_argument("--approval-audit-id", default=None, help="Audit id from the approving dry-run/evidence record.") + send_visible_parser.add_argument("--wait-ms", type=_nonnegative_int, default=0, help="After a live send, poll read-only visible state for up to this many milliseconds.") + send_visible_parser.add_argument("--poll-interval-ms", type=_positive_int, default=2000, help="Read-only post-send poll interval in milliseconds.") + send_visible_parser.set_defaults(command_id="codex.send_visible_message", target="codex") + + continue_parser = codex_subparsers.add_parser("continue-thread", help="Support-only visible fallback: select a visible Codex thread by title and submit the exact prompt 'continue'.") + continue_parser.add_argument("--json", action="store_true", help="Emit JSON.") + continue_parser.add_argument("--title", required=True, help="Visible Codex thread title query, for example SDK Docs.") + continue_parser.add_argument("--prompt", default="continue", help="Must be exactly 'continue'.") + continue_parser.add_argument("--dry-run", action="store_true", help="Report what would happen without selecting/submitting.") + continue_parser.add_argument("--approval-audit-id", default=None, help="Audit id from the approving dry-run/evidence record.") + continue_parser.set_defaults(command_id="codex.continue_thread", target="codex") + + snapshot_parser = codex_subparsers.add_parser("snapshot", help="Capture safe visible Codex Desktop state.") + snapshot_parser.add_argument("--json", action="store_true", help="Emit JSON.") + snapshot_parser.add_argument("--max-chars", type=_positive_int, default=4000, help="Maximum visible text chars.") + snapshot_parser.set_defaults(command_id="codex.snapshot", target="codex") + + inspect_parser = codex_subparsers.add_parser("inspect", help="Return a compact page map of visible Codex Desktop state.") + inspect_parser.add_argument("--json", action="store_true", help="Emit JSON.") + inspect_parser.add_argument("--max-nodes", type=_positive_int, default=120, help="Maximum AX nodes to include.") + inspect_parser.set_defaults(command_id="codex.inspect", target="codex") + + ax_parser = codex_subparsers.add_parser("ax-tree", help="Capture a capped Accessibility tree summary.") + ax_parser.add_argument("--json", action="store_true", help="Emit JSON.") + ax_parser.add_argument("--max-nodes", type=_positive_int, default=200, help="Maximum AX nodes to return.") + ax_parser.set_defaults(command_id="codex.ax_tree", target="codex") + + connections_parser = codex_subparsers.add_parser("connections", help="Codex Desktop native connection/status commands.") + connections_subparsers = connections_parser.add_subparsers(dest="connections_command") + + connections_status_parser = connections_subparsers.add_parser("status", help="Report Codex Desktop, app-server, remote-control, websocket, and notification readiness.") + connections_status_parser.add_argument("--json", action="store_true", help="Emit JSON.") + connections_status_parser.set_defaults(command_id="codex.connections.status", target="codex") + + app_server_parser = codex_subparsers.add_parser("app-server", help="Read-only Codex app-server seam commands.") + app_server_subparsers = app_server_parser.add_subparsers(dest="app_server_command") + + app_server_status_parser = app_server_subparsers.add_parser("status", help="Report read-only app-server availability and allowlist.") + app_server_status_parser.add_argument("--json", action="store_true", help="Emit JSON.") + app_server_status_parser.set_defaults(command_id="codex.app_server.status", target="codex") + + app_server_threads_parser = app_server_subparsers.add_parser("threads", help="Read Codex threads through the app-server read allowlist.") + app_server_threads_parser.add_argument("--json", action="store_true", help="Emit JSON.") + app_server_threads_parser.add_argument("--max-items", type=_positive_int, default=50, help="Maximum app-server thread summaries to return.") + app_server_threads_parser.set_defaults(command_id="codex.app_server.threads", target="codex") + + app_server_loaded_parser = app_server_subparsers.add_parser("loaded-threads", help="Read Codex Desktop's currently loaded app-server thread ids.") + app_server_loaded_parser.add_argument("--json", action="store_true", help="Emit JSON.") + app_server_loaded_parser.add_argument("--max-items", type=_positive_int, default=50, help="Maximum loaded thread ids to return.") + app_server_loaded_parser.set_defaults(command_id="codex.app_server.loaded_threads", target="codex") + + app_server_subscribe_parser = app_server_subparsers.add_parser("subscribe", help="Read buffered Codex app-server notifications for a loaded thread.") + app_server_subscribe_parser.add_argument("--json", action="store_true", help="Emit JSON.") + app_server_subscribe_parser.add_argument("--thread-id", required=True, help="Loaded Codex app-server thread id.") + app_server_subscribe_parser.add_argument("--duration-ms", type=_positive_int, default=1000, help="How long to buffer notifications.") + app_server_subscribe_parser.add_argument("--max-chars", type=_positive_int, default=4000, help="Maximum JSON chars per notification payload.") + app_server_subscribe_parser.set_defaults(command_id="codex.app_server.subscribe", target="codex") + + app_server_remote_parser = app_server_subparsers.add_parser("remote-control-status", help="Probe Codex native remote-control readiness without enabling or mutating it.") + app_server_remote_parser.add_argument("--json", action="store_true", help="Emit JSON.") + app_server_remote_parser.set_defaults(command_id="codex.app_server.remote_control_status", target="codex") + + customer_mac_parser = subparsers.add_parser("customer-mac", help="Customer Mac connector commands.") + customer_mac_subparsers = customer_mac_parser.add_subparsers(dest="customer_mac_command") + + mac_status_parser = customer_mac_subparsers.add_parser("status", help="Report customer Mac connector readiness.") + mac_status_parser.add_argument("--json", action="store_true", help="Emit JSON.") + mac_status_parser.set_defaults(command_id="customer_mac.status", target="customer_mac") + + mac_capabilities_parser = customer_mac_subparsers.add_parser("capabilities", help="Report supported named customer Mac actions.") + mac_capabilities_parser.add_argument("--json", action="store_true", help="Emit JSON.") + mac_capabilities_parser.set_defaults(command_id="customer_mac.capabilities", target="customer_mac") + + control_parser = customer_mac_subparsers.add_parser("control", help="Manage customer-granted agent control sessions.") + control_subparsers = control_parser.add_subparsers(dest="control_command") + + control_status_parser = control_subparsers.add_parser("status", help="Report Full Access / Ask Permission session state.") + control_status_parser.add_argument("--json", action="store_true", help="Emit JSON.") + control_status_parser.set_defaults(command_id="customer_mac.control_status", target="customer_mac") + + control_start_parser = control_subparsers.add_parser("start", help="Start a customer-granted agent control session.") + control_start_parser.add_argument("--json", action="store_true", help="Emit JSON.") + control_start_parser.add_argument("--mode", choices=["full-access", "ask-permission"], default="full-access", help="Control mode for this session.") + control_start_parser.add_argument("--agent-label", default=None, help="Human-readable current agent label.") + control_start_parser.set_defaults(command_id="customer_mac.control_start", target="customer_mac") + + control_stop_parser = control_subparsers.add_parser("stop", help="Stop the active agent control session.") + control_stop_parser.add_argument("--json", action="store_true", help="Emit JSON.") + control_stop_parser.set_defaults(command_id="customer_mac.control_stop", target="customer_mac") + + control_kill_parser = control_subparsers.add_parser("kill-switch", help="Immediately stop and block future connector control commands.") + control_kill_parser.add_argument("--json", action="store_true", help="Emit JSON.") + control_kill_parser.set_defaults(command_id="customer_mac.control_kill_switch", target="customer_mac") + + desktop_parser = customer_mac_subparsers.add_parser("desktop", help="Full-access desktop computer-control commands.") + desktop_subparsers = desktop_parser.add_subparsers(dest="desktop_command") + + desktop_see_parser = desktop_subparsers.add_parser("see", help="See the current desktop through Peekaboo or fallback screen/AX capture.") + desktop_see_parser.add_argument("--json", action="store_true", help="Emit JSON.") + desktop_see_parser.add_argument("--max-chars", type=_positive_int, default=4000, help="Maximum visible text chars.") + desktop_see_parser.add_argument("--max-nodes", type=_positive_int, default=200, help="Maximum AX nodes.") + desktop_see_parser.add_argument( + "--include-screenshot-bytes", + action="store_true", + help="Opt in to inline screenshot bytes in JSON output.", + ) + desktop_see_parser.set_defaults(command_id="customer_mac.desktop_see", target="customer_mac") + + desktop_click_parser = desktop_subparsers.add_parser("click", help="Click a visible target label or x/y point.") + desktop_click_parser.add_argument("--json", action="store_true", help="Emit JSON.") + desktop_click_parser.add_argument("--target-label", default=None, help="Visible target label to click.") + desktop_click_parser.add_argument("--snapshot-id", default=None, help="Snapshot id returned by desktop_see.") + desktop_click_parser.add_argument("--element-id", default=None, help="Element id returned by desktop_see.") + desktop_click_parser.add_argument("--x", type=int, default=None, help="Screen x coordinate fallback.") + desktop_click_parser.add_argument("--y", type=int, default=None, help="Screen y coordinate fallback.") + desktop_click_parser.add_argument("--dry-run", action="store_true", help="Report what would happen without clicking.") + desktop_click_parser.add_argument("--approval-audit-id", default=None, help="Dry-run audit id required in Ask Permission mode for high-impact actions.") + desktop_click_parser.set_defaults(command_id="customer_mac.desktop_click", target="customer_mac") + + desktop_type_parser = desktop_subparsers.add_parser("type", help="Type exact text into the focused field.") + desktop_type_parser.add_argument("--json", action="store_true", help="Emit JSON.") + desktop_type_parser.add_argument("--text", required=True, help="Exact text to type.") + desktop_type_parser.add_argument("--dry-run", action="store_true", help="Report what would happen without typing.") + desktop_type_parser.add_argument("--approval-audit-id", default=None, help="Dry-run audit id required in Ask Permission mode.") + desktop_type_parser.set_defaults(command_id="customer_mac.desktop_type", target="customer_mac") + + desktop_set_value_parser = desktop_subparsers.add_parser("set-value", help="Set an AX-backed native text field from a fresh desktop_see snapshot.") + desktop_set_value_parser.add_argument("--json", action="store_true", help="Emit JSON.") + desktop_set_value_parser.add_argument("--snapshot-id", required=True, help="Snapshot id returned by desktop_see.") + desktop_set_value_parser.add_argument("--element-id", required=True, help="AX element id returned by desktop_see.") + desktop_set_value_group = desktop_set_value_parser.add_mutually_exclusive_group(required=True) + desktop_set_value_group.add_argument("--value", help="Exact value to set; secrets and secure fields are blocked.") + desktop_set_value_group.add_argument("--value-file", help="Path to a UTF-8 file containing the exact value to set.") + desktop_set_value_parser.add_argument("--attribute", choices=["value", "selected_text"], default="value", help="Fixed AX attribute setter.") + desktop_set_value_parser.add_argument("--dry-run", action="store_true", help="Report what would happen without setting text.") + desktop_set_value_parser.add_argument("--approval-audit-id", default=None, help="Dry-run audit id required in Ask Permission mode.") + desktop_set_value_parser.set_defaults(command_id="customer_mac.desktop_set_value", target="customer_mac") + + desktop_scroll_parser = desktop_subparsers.add_parser("scroll", help="Scroll the focused surface.") + desktop_scroll_parser.add_argument("--json", action="store_true", help="Emit JSON.") + desktop_scroll_parser.add_argument("--direction", choices=["up", "down", "left", "right"], default="down", help="Scroll direction.") + desktop_scroll_parser.add_argument("--amount", type=_positive_int, default=600, help="Scroll amount.") + desktop_scroll_parser.add_argument("--dry-run", action="store_true", help="Report what would happen without scrolling.") + desktop_scroll_parser.add_argument("--approval-audit-id", default=None, help="Dry-run audit id required in Ask Permission mode for high-impact actions.") + desktop_scroll_parser.set_defaults(command_id="customer_mac.desktop_scroll", target="customer_mac") + + desktop_drag_parser = desktop_subparsers.add_parser("drag", help="Drag from one point to another.") + desktop_drag_parser.add_argument("--json", action="store_true", help="Emit JSON.") + desktop_drag_parser.add_argument("--from-x", type=int, required=True, help="Start x coordinate.") + desktop_drag_parser.add_argument("--from-y", type=int, required=True, help="Start y coordinate.") + desktop_drag_parser.add_argument("--to-x", type=int, required=True, help="End x coordinate.") + desktop_drag_parser.add_argument("--to-y", type=int, required=True, help="End y coordinate.") + desktop_drag_parser.add_argument("--dry-run", action="store_true", help="Report what would happen without dragging.") + desktop_drag_parser.add_argument("--approval-audit-id", default=None, help="Dry-run audit id required in Ask Permission mode for high-impact actions.") + desktop_drag_parser.set_defaults(command_id="customer_mac.desktop_drag", target="customer_mac") + + desktop_hotkey_parser = desktop_subparsers.add_parser("hotkey", help="Press a hotkey such as cmd+l.") + desktop_hotkey_parser.add_argument("--json", action="store_true", help="Emit JSON.") + desktop_hotkey_parser.add_argument("--keys", required=True, help="Plus-delimited keys, for example cmd+l.") + desktop_hotkey_parser.add_argument("--dry-run", action="store_true", help="Report what would happen without pressing.") + desktop_hotkey_parser.add_argument("--approval-audit-id", default=None, help="Dry-run audit id required in Ask Permission mode for high-impact actions.") + desktop_hotkey_parser.set_defaults(command_id="customer_mac.desktop_hotkey", target="customer_mac") + + desktop_focus_parser = desktop_subparsers.add_parser("focus-app", help="Focus a Mac app by name.") + desktop_focus_parser.add_argument("--json", action="store_true", help="Emit JSON.") + desktop_focus_parser.add_argument("--app-name", required=True, help="Visible macOS app name.") + desktop_focus_parser.add_argument("--dry-run", action="store_true", help="Report what would happen without focusing.") + desktop_focus_parser.add_argument("--approval-audit-id", default=None, help="Dry-run audit id required in Ask Permission mode for high-impact actions.") + desktop_focus_parser.set_defaults(command_id="customer_mac.desktop_focus_app", target="customer_mac") + + desktop_window_parser = desktop_subparsers.add_parser("window", help="Perform a named window action.") + desktop_window_parser.add_argument("--json", action="store_true", help="Emit JSON.") + desktop_window_parser.add_argument("--action", choices=["focus", "minimize", "maximize", "zoom", "close"], required=True, help="Window action.") + desktop_window_parser.add_argument("--dry-run", action="store_true", help="Report what would happen without acting.") + desktop_window_parser.add_argument("--approval-audit-id", default=None, help="Dry-run audit id required in Ask Permission mode for high-impact actions.") + desktop_window_parser.set_defaults(command_id="customer_mac.desktop_window", target="customer_mac") + + desktop_menu_parser = desktop_subparsers.add_parser("menu", help="Choose a visible menu path through Peekaboo.") + desktop_menu_parser.add_argument("--json", action="store_true", help="Emit JSON.") + desktop_menu_parser.add_argument("--menu-path", required=True, help="Menu path, for example File > New Tab.") + desktop_menu_parser.add_argument("--dry-run", action="store_true", help="Report what would happen without acting.") + desktop_menu_parser.add_argument("--approval-audit-id", default=None, help="Dry-run audit id required in Ask Permission mode for high-impact actions.") + desktop_menu_parser.set_defaults(command_id="customer_mac.desktop_menu", target="customer_mac") + + desktop_browser_parser = desktop_subparsers.add_parser("browser-action", help="Perform a browser action on the focused Mac browser.") + desktop_browser_parser.add_argument("--json", action="store_true", help="Emit JSON.") + desktop_browser_parser.add_argument("--action", choices=["reload", "back", "forward", "new_tab", "open_url"], required=True, help="Browser action.") + desktop_browser_parser.add_argument("--url", default=None, help="URL for open_url.") + desktop_browser_parser.add_argument("--dry-run", action="store_true", help="Report what would happen without acting.") + desktop_browser_parser.add_argument("--approval-audit-id", default=None, help="Dry-run audit id required in Ask Permission mode for high-impact actions.") + desktop_browser_parser.set_defaults(command_id="customer_mac.desktop_browser_action", target="customer_mac") + + mac_snapshot_parser = customer_mac_subparsers.add_parser("snapshot", help="Capture a safe screenshot unless a sensitive app is frontmost.") + mac_snapshot_parser.add_argument("--json", action="store_true", help="Emit JSON.") + mac_snapshot_parser.add_argument("--max-chars", type=_positive_int, default=4000, help="Maximum visible text chars.") + mac_snapshot_parser.set_defaults(command_id="customer_mac.snapshot", target="customer_mac") + + mac_ax_parser = customer_mac_subparsers.add_parser("ax-tree", help="Capture a capped Accessibility tree for the frontmost non-sensitive app.") + mac_ax_parser.add_argument("--json", action="store_true", help="Emit JSON.") + mac_ax_parser.add_argument("--max-nodes", type=_positive_int, default=200, help="Maximum AX nodes to return.") + mac_ax_parser.set_defaults(command_id="customer_mac.ax_tree", target="customer_mac") + + mac_focus_parser = customer_mac_subparsers.add_parser("app-focus", help="Focus a named non-sensitive Mac app.") + mac_focus_parser.add_argument("--json", action="store_true", help="Emit JSON.") + mac_focus_parser.add_argument("--app-name", required=True, help="Visible macOS app name.") + mac_focus_parser.add_argument("--dry-run", action="store_true", help="Report what would happen without focusing.") + mac_focus_parser.add_argument("--approval-audit-id", default=None, help="Audit id from the approving dry-run/evidence record.") + mac_focus_parser.set_defaults(command_id="customer_mac.app_focus", target="customer_mac") + + local_site_parser = customer_mac_subparsers.add_parser("local-site", help="Customer-local website commands.") + local_site_subparsers = local_site_parser.add_subparsers(dest="local_site_command") + + local_site_open_parser = local_site_subparsers.add_parser("open", help="Open a localhost, loopback, or .local website.") + local_site_open_parser.add_argument("--json", action="store_true", help="Emit JSON.") + local_site_open_parser.add_argument("--url", required=True, help="Local website URL.") + local_site_open_parser.add_argument("--dry-run", action="store_true", help="Report what would happen without opening.") + local_site_open_parser.add_argument("--approval-audit-id", default=None, help="Audit id from the approving dry-run/evidence record.") + local_site_open_parser.set_defaults(command_id="customer_mac.local_site_open", target="customer_mac") + + local_site_action_parser = local_site_subparsers.add_parser("action", help="Run a named browser action against the frontmost local-site browser.") + local_site_action_parser.add_argument("--json", action="store_true", help="Emit JSON.") + local_site_action_parser.add_argument("--action", required=True, choices=["reload", "back", "forward"], help="Named browser action.") + local_site_action_parser.add_argument("--dry-run", action="store_true", help="Report what would happen without acting.") + local_site_action_parser.add_argument("--approval-audit-id", default=None, help="Audit id from the approving dry-run/evidence record.") + local_site_action_parser.set_defaults(command_id="customer_mac.local_site_action", target="customer_mac") + + iphone_parser = customer_mac_subparsers.add_parser("iphone-mirroring", help="Named iPhone Mirroring commands.") + iphone_subparsers = iphone_parser.add_subparsers(dest="iphone_command") + + iphone_status_parser = iphone_subparsers.add_parser("status", help="Report iPhone Mirroring readiness and supported actions.") + iphone_status_parser.add_argument("--json", action="store_true", help="Emit JSON.") + iphone_status_parser.set_defaults(command_id="customer_mac.iphone_mirroring_status", target="customer_mac") + + iphone_see_parser = iphone_subparsers.add_parser("see", help="See the visible iPhone Mirroring surface.") + iphone_see_parser.add_argument("--json", action="store_true", help="Emit JSON.") + iphone_see_parser.add_argument("--max-chars", type=_positive_int, default=4000, help="Maximum visible text chars.") + iphone_see_parser.add_argument("--max-nodes", type=_positive_int, default=200, help="Maximum AX nodes.") + iphone_see_parser.add_argument( + "--include-screenshot-bytes", + action="store_true", + help="Opt in to inline screenshot bytes in JSON output.", + ) + iphone_see_parser.set_defaults(command_id="customer_mac.iphone_see", target="customer_mac") + + iphone_tap_v2_parser = iphone_subparsers.add_parser("tap", help="Tap/click a visible iPhone label or fallback point.") + iphone_tap_v2_parser.add_argument("--json", action="store_true", help="Emit JSON.") + iphone_tap_v2_parser.add_argument("--target-label", default=None, help="Visible target label.") + iphone_tap_v2_parser.add_argument("--snapshot-id", default=None, help="Snapshot id returned by iphone_see.") + iphone_tap_v2_parser.add_argument("--element-id", default=None, help="Element id returned by iphone_see.") + iphone_tap_v2_parser.add_argument("--x", type=int, default=None, help="Screen x coordinate fallback.") + iphone_tap_v2_parser.add_argument("--y", type=int, default=None, help="Screen y coordinate fallback.") + iphone_tap_v2_parser.add_argument("--dry-run", action="store_true", help="Report what would happen without tapping.") + iphone_tap_v2_parser.add_argument("--approval-audit-id", default=None, help="Dry-run audit id required in Ask Permission mode for high-impact actions.") + iphone_tap_v2_parser.set_defaults(command_id="customer_mac.iphone_tap", target="customer_mac") + + iphone_swipe_v2_parser = iphone_subparsers.add_parser("swipe", help="Swipe the focused iPhone Mirroring window.") + iphone_swipe_v2_parser.add_argument("--json", action="store_true", help="Emit JSON.") + iphone_swipe_v2_parser.add_argument("--direction", choices=["left", "right", "up", "down"], required=True, help="Swipe direction.") + iphone_swipe_v2_parser.add_argument("--dry-run", action="store_true", help="Report what would happen without swiping.") + iphone_swipe_v2_parser.add_argument("--approval-audit-id", default=None, help="Dry-run audit id required in Ask Permission mode for high-impact actions.") + iphone_swipe_v2_parser.set_defaults(command_id="customer_mac.iphone_swipe", target="customer_mac") + + iphone_type_v2_parser = iphone_subparsers.add_parser("type", help="Type text into the focused iPhone Mirroring field.") + iphone_type_v2_parser.add_argument("--json", action="store_true", help="Emit JSON.") + iphone_type_v2_parser.add_argument("--text", required=True, help="Exact text to type.") + iphone_type_v2_parser.add_argument("--dry-run", action="store_true", help="Report what would happen without typing.") + iphone_type_v2_parser.add_argument("--approval-audit-id", default=None, help="Dry-run audit id required in Ask Permission mode.") + iphone_type_v2_parser.set_defaults(command_id="customer_mac.iphone_type", target="customer_mac") + + iphone_focus_parser = iphone_subparsers.add_parser("focus", help="Focus iPhone Mirroring.") + iphone_focus_parser.add_argument("--json", action="store_true", help="Emit JSON.") + iphone_focus_parser.add_argument("--dry-run", action="store_true", help="Report what would happen without focusing.") + iphone_focus_parser.add_argument("--approval-audit-id", default=None, help="Audit id from the approving dry-run/evidence record.") + iphone_focus_parser.set_defaults(command_id="customer_mac.iphone_mirroring_focus", target="customer_mac") + + for subcommand, command_id, help_text in [ + ("home", "customer_mac.iphone_mirroring_home", "Send the iPhone Mirroring Home named action."), + ("app-switcher", "customer_mac.iphone_mirroring_app_switcher", "Send the iPhone Mirroring App Switcher named action."), + ("spotlight", "customer_mac.iphone_mirroring_spotlight", "Send the iPhone Mirroring Spotlight named action."), + ]: + parser_for_action = iphone_subparsers.add_parser(subcommand, help=help_text) + parser_for_action.add_argument("--json", action="store_true", help="Emit JSON.") + parser_for_action.add_argument("--dry-run", action="store_true", help="Report what would happen without acting.") + parser_for_action.add_argument("--approval-audit-id", default=None, help="Audit id from the approving dry-run/evidence record.") + parser_for_action.set_defaults(command_id=command_id, target="customer_mac") + + iphone_scroll_parser = iphone_subparsers.add_parser("scroll", help="Approval-gated action: scroll the focused iPhone Mirroring window by named direction.") + iphone_scroll_parser.add_argument("--json", action="store_true", help="Emit JSON.") + iphone_scroll_parser.add_argument("--direction", choices=["up", "down"], default="down", help="Named scroll direction.") + iphone_scroll_parser.add_argument("--dry-run", action="store_true", help="Report what would happen without acting.") + iphone_scroll_parser.add_argument("--approval-audit-id", default=None, help="Audit id from the approving dry-run/evidence record.") + iphone_scroll_parser.set_defaults(command_id="customer_mac.iphone_mirroring_scroll", target="customer_mac") + + for subcommand, command_id, help_text in [ + ("swipe-left", "customer_mac.iphone_mirroring_swipe_left", "Approval-gated action: swipe left in the focused iPhone Mirroring window."), + ("swipe-right", "customer_mac.iphone_mirroring_swipe_right", "Approval-gated action: swipe right in the focused iPhone Mirroring window."), + ("swipe-up", "customer_mac.iphone_mirroring_swipe_up", "Approval-gated action: swipe up in the focused iPhone Mirroring window."), + ("swipe-down", "customer_mac.iphone_mirroring_swipe_down", "Approval-gated action: swipe down in the focused iPhone Mirroring window."), + ]: + swipe_parser = iphone_subparsers.add_parser(subcommand, help=help_text) + swipe_parser.add_argument("--json", action="store_true", help="Emit JSON.") + swipe_parser.add_argument("--dry-run", action="store_true", help="Report what would happen without acting.") + swipe_parser.add_argument("--approval-audit-id", default=None, help="Audit id from the approving dry-run/evidence record.") + swipe_parser.set_defaults(command_id=command_id, target="customer_mac") + + iphone_type_parser = iphone_subparsers.add_parser("type-spotlight", help="Type short disposable/search text into iPhone Spotlight.") + iphone_type_parser.add_argument("--json", action="store_true", help="Emit JSON.") + iphone_type_parser.add_argument("--text", required=True, help="Short disposable/search text.") + iphone_type_parser.add_argument("--dry-run", action="store_true", help="Report what would happen without typing.") + iphone_type_parser.add_argument("--approval-audit-id", default=None, help="Audit id from the approving dry-run/evidence record.") + iphone_type_parser.set_defaults(command_id="customer_mac.iphone_mirroring_type_spotlight", target="customer_mac") + + iphone_open_app_parser = iphone_subparsers.add_parser("open-app", help="Open a non-sensitive iPhone app through Spotlight.") + iphone_open_app_parser.add_argument("--json", action="store_true", help="Emit JSON.") + iphone_open_app_parser.add_argument("--app-name", required=True, help="Non-sensitive iPhone app name.") + iphone_open_app_parser.add_argument("--dry-run", action="store_true", help="Report what would happen without opening.") + iphone_open_app_parser.add_argument("--approval-audit-id", default=None, help="Audit id from the approving dry-run/evidence record.") + iphone_open_app_parser.set_defaults(command_id="customer_mac.iphone_mirroring_open_app", target="customer_mac") + + iphone_tap_parser = iphone_subparsers.add_parser("tap-named-target", help="Press an exact visible iPhone Mirroring AX label.") + iphone_tap_parser.add_argument("--json", action="store_true", help="Emit JSON.") + iphone_tap_parser.add_argument("--target-label", required=True, help="Exact visible target label.") + iphone_tap_parser.add_argument("--dry-run", action="store_true", help="Report what would happen without pressing.") + iphone_tap_parser.add_argument("--approval-audit-id", default=None, help="Audit id from the approving dry-run/evidence record.") + iphone_tap_parser.set_defaults(command_id="customer_mac.iphone_mirroring_tap_named_target", target="customer_mac") + + iphone_type_approved_parser = iphone_subparsers.add_parser("type-approved-text", help="Approval-gated action: type exact same-turn-approved text into the focused iPhone Mirroring window.") + iphone_type_approved_parser.add_argument("--json", action="store_true", help="Emit JSON.") + iphone_type_approved_parser.add_argument("--text", required=True, help="Exact same-turn-approved text, capped at 240 chars.") + iphone_type_approved_parser.add_argument("--dry-run", action="store_true", help="Report what would happen without typing.") + iphone_type_approved_parser.add_argument("--approval-audit-id", default=None, help="Audit id from the approving dry-run/evidence record.") + iphone_type_approved_parser.set_defaults(command_id="customer_mac.iphone_mirroring_type_approved_text", target="customer_mac") + + iphone_send_parser = iphone_subparsers.add_parser("send-approved-message", help="Full Access action: type and send one exact message through visible iPhone Mirroring; Ask Permission gates it.") + iphone_send_parser.add_argument("--json", action="store_true", help="Emit JSON.") + iphone_send_parser.add_argument("--text", required=True, help="Exact same-turn-approved message text, capped at 240 chars.") + iphone_send_parser.add_argument("--recipient-context", required=True, help="Short human-approved recipient/context for audit evidence.") + iphone_send_parser.add_argument("--target-label", default="Send", help="Exact visible send control label.") + iphone_send_parser.add_argument("--dry-run", action="store_true", help="Report what would happen without sending.") + iphone_send_parser.add_argument("--approval-audit-id", default=None, help="Audit id from the approving dry-run/evidence record.") + iphone_send_parser.set_defaults(command_id="customer_mac.iphone_mirroring_send_approved_message", target="customer_mac") + + screen_sharing_parser = customer_mac_subparsers.add_parser("screen-sharing", help="Screen Sharing/Remote Management status commands.") + screen_sharing_subparsers = screen_sharing_parser.add_subparsers(dest="screen_sharing_command") + screen_sharing_status_parser = screen_sharing_subparsers.add_parser("status", help="Report whether Screen Sharing is enabled.") + screen_sharing_status_parser.add_argument("--json", action="store_true", help="Emit JSON.") + screen_sharing_status_parser.set_defaults(command_id="customer_mac.screen_sharing_status", target="customer_mac") + + return parser + + +def main( + argv: list[str] | None = None, + *, + observer_factory: Callable[[], MacOSCodexObserver] | None = None, + customer_mac_factory: Callable[[], CustomerMacObserver] | None = None, + app_server_factory: Callable[[], CodexAppServerObserver] | None = None, + stdout: TextIO = sys.stdout, + stderr: TextIO = sys.stderr, + state_dir: Path | None = None, +) -> int: + parser = build_parser() + try: + args = parser.parse_args(argv) + except SystemExit as exc: + return int(exc.code) + + if not hasattr(args, "command_id"): + parser.print_help(stdout) + return 0 + + command_id = args.command_id + target = args.target + args.state_dir = state_dir + if command_id == "serve": + token = read_token(args.token_file, state_dir=state_dir, auto_create=True) + run_connector_server( + host=args.host, + port=args.port, + token=token, + command_runner=lambda bridge_argv: _run_bridge_argv(bridge_argv, state_dir=state_dir), + state_dir=state_dir, + owner_provider=_connector_http_owner_summary, + ) + return 0 + + if command_id == "diagnostics": + token = _read_connector_token_optional(args.token_file, state_dir=state_dir) + result = _build_cli_diagnostics_payload(token=token, token_file=args.token_file, state_dir=state_dir) + if getattr(args, "json", None) is True: + stdout.write(json.dumps(redact_value(result), sort_keys=True) + "\n") + else: + ready = result.get("connector", {}).get("ready", {}) if isinstance(result.get("connector"), dict) else {} + status = "ready" if ready.get("ok") is True else "not ready" + stdout.write(f"evaOS desktop bridge diagnostics: {status}\n") + blockers = ready.get("blockers") if isinstance(ready.get("blockers"), list) else [] + for blocker in blockers: + if isinstance(blocker, dict): + stdout.write(f"- {blocker.get('code') or 'unknown'}: {blocker.get('message') or ''}\n") + return 0 + + if command_id == "ready": + token = _read_connector_token_optional(args.token_file, state_dir=state_dir) + result = _build_cli_ready_payload(token=token, token_file=args.token_file, state_dir=state_dir) + if getattr(args, "json", None) is True: + stdout.write(json.dumps(redact_value(result), sort_keys=True) + "\n") + else: + status = "ready" if result.get("ok") is True else "not ready" + stdout.write(f"evaOS desktop bridge connector: {status}\n") + blockers = result.get("blockers") if isinstance(result.get("blockers"), list) else [] + for blocker in blockers: + if isinstance(blocker, dict): + stdout.write(f"- {blocker.get('code') or 'unknown'}: {blocker.get('message') or ''}\n") + return 0 if result.get("ok") is True else 2 + + if command_id == "helper.run": + token = read_helper_token(token_file=args.token_file, state_dir=state_dir, auto_create=True) + run_helper_server( + socket_path=Path(args.socket_path).expanduser() if args.socket_path else default_helper_socket_path(state_dir), + token=token, + ) + return 0 + + if command_id == "connector_service.complete_enrollment": + result = _complete_connector_service_enrollment(args, state_dir=state_dir) + stdout.write(json.dumps(redact_value(result), sort_keys=True) + "\n") + return 0 if result.get("ok") is True else 2 + + try: + if command_id == "codex.send_visible_message": + args.message = _resolve_visible_message_arg(args) + args.message_hash = _short_hash(str(getattr(args, "message", "") or "").strip()) + if command_id == "customer_mac.desktop_set_value": + args.value = _resolve_desktop_set_value_arg(args) + args.value_hash = _short_hash(str(getattr(args, "value", "") or "")) + ensure_allowed(command_id) + observer = observer_factory() if observer_factory is not None else MacOSCodexObserver(state_dir=state_dir) + customer_mac = customer_mac_factory() if customer_mac_factory is not None else CustomerMacObserver(state_dir=state_dir) + app_server = app_server_factory() if app_server_factory is not None else CodexAppServerObserver() + result = _validate_guarded_approval(command_id, args, state_dir) + if result.ok: + result = _run_command(command_id, observer, customer_mac, app_server, args) + except PolicyError as exc: + result = CommandResult(ok=False, errors=[exc.error]) + except Exception as exc: + result = CommandResult( + ok=False, + errors=[ + { + "code": "command_failed", + "message": str(exc), + "guidance": "Rerun with the same command after checking local macOS permissions and bridge logs.", + } + ], + ) + + audit_id = append_audit( + command=command_id, + target=target, + args=_audit_args(command_id, args), + ok=result.ok, + warnings=result.warnings, + errors=result.errors, + provenance={ + **command_metadata(command_id), + **result.provenance, + "dry_run": getattr(args, "dry_run", None), + "selected_visible_target_id": getattr(args, "thread_id", None), + "source_audit_id": getattr(args, "source_audit_id", None), + "approval_audit_id": getattr(args, "approval_audit_id", None), + }, + state_dir=state_dir, + ) + envelope = build_envelope( + command=command_id, + target=target, + ok=result.ok, + data=result.data, + warnings=result.warnings, + errors=result.errors, + audit_id=audit_id, + ) + if not getattr(args, "include_screenshot_bytes", False): + envelope = _omit_inline_screenshot_bytes(envelope) + if command_id in LATEST_OBSERVATION_COMMANDS: + write_latest(envelope, state_dir=state_dir) + stdout.write(json.dumps(envelope, sort_keys=True) + "\n") + return 0 if result.ok else 2 + + +def entrypoint() -> None: + raise SystemExit(main()) + + +def _run_bridge_argv(argv: list[str], *, state_dir: Path | None = None) -> tuple[int, str]: + stdout = io.StringIO() + stderr = io.StringIO() + exit_code = main(argv, stdout=stdout, stderr=stderr, state_dir=state_dir) + output = stdout.getvalue() or stderr.getvalue() + return exit_code, output + + +def _short_hash(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest()[:16] + + +def _omit_inline_screenshot_bytes(value: Any) -> Any: + if isinstance(value, dict): + sanitized: dict[str, Any] = {} + omitted = False + omitted_reason: str | None = None + for key, item in value.items(): + if key == "bytes_base64": + omitted = True + continue + if key == "bytes_base64_omitted": + omitted = omitted or bool(item) + continue + if key == "bytes_base64_omitted_reason": + if isinstance(item, str) and item.strip(): + omitted_reason = item + continue + sanitized[key] = _omit_inline_screenshot_bytes(item) + if omitted: + sanitized["inline_screenshot_bytes_omitted"] = True + sanitized["inline_screenshot_bytes_omitted_reason"] = omitted_reason or INLINE_SCREENSHOT_BYTES_OMITTED_REASON + return sanitized + if isinstance(value, list): + return [_omit_inline_screenshot_bytes(item) for item in value] + return value + + +def _message_preview(value: str, *, limit: int = 240) -> str: + text = str(value or "").strip() + if len(text) > limit: + return text[:limit] + return text + + +def _resolve_visible_message_arg(args: argparse.Namespace) -> str: + message_file = getattr(args, "message_file", None) + if isinstance(message_file, str) and message_file.strip(): + return Path(message_file).expanduser().read_text(encoding="utf-8") + return str(getattr(args, "message", "") or "") + + +def _resolve_desktop_set_value_arg(args: argparse.Namespace) -> str: + value_file = getattr(args, "value_file", None) + if isinstance(value_file, str) and value_file.strip(): + return Path(value_file).expanduser().read_text(encoding="utf-8") + return str(getattr(args, "value", "") or "") + + +def _audit_args(command_id: str, args: argparse.Namespace) -> dict[str, object]: + excluded = {"command_id", "target", "state_dir"} + if command_id == "codex.send_visible_message": + excluded.add("message") + excluded.add("message_file") + if command_id == "customer_mac.desktop_set_value": + excluded.add("value") + excluded.add("value_file") + values = {key: value for key, value in vars(args).items() if key not in excluded} + if command_id == "codex.send_visible_message": + values["message_hash"] = getattr(args, "message_hash", _short_hash(str(getattr(args, "message", "") or "").strip())) + values["message_preview"] = _message_preview(str(getattr(args, "message", "") or "")) + if command_id == "customer_mac.desktop_set_value": + values["value_hash"] = getattr(args, "value_hash", _short_hash(str(getattr(args, "value", "") or ""))) + return values + + +def _run_command( + command_id: str, + observer: MacOSCodexObserver, + customer_mac: CustomerMacObserver, + app_server: CodexAppServerObserver, + args: argparse.Namespace, +) -> CommandResult: + if command_id == "status": + result = observer.status() + return CommandResult( + ok=result.ok, + data={ + **result.data, + "bridge_runtime": _workbench_runtime_compatibility(_bridge_runtime_version()), + }, + warnings=result.warnings, + errors=result.errors, + provenance=result.provenance, + ) + if command_id == "capabilities": + return CommandResult(ok=True, data=_capabilities()) + if command_id == "latest": + latest = read_latest(getattr(args, "state_dir", None)) + if latest is None: + return CommandResult( + ok=False, + errors=[ + { + "code": "latest_not_found", + "message": "No latest observation has been recorded yet.", + "guidance": "Run evaos-desktop-bridge status --json or a codex observer command first.", + } + ], + ) + return CommandResult(ok=True, data={"latest": latest}) + if command_id == "audit_tail": + records = read_audit_tail(limit=args.limit, state_dir=getattr(args, "state_dir", None)) + return CommandResult(ok=True, data={"records": records, "count": len(records), "limit": args.limit}, provenance={"source": "audit"}) + if command_id == "permissions.prime": + return _prime_permission(args.permission) + if command_id == "helper.ping": + return _helper_ping(args, state_dir=getattr(args, "state_dir", None)) + if command_id.startswith("connector_service."): + public_result = _run_public_connector_service(command_id.split(".", 1)[1], state_dir=getattr(args, "state_dir", None)) + return CommandResult( + ok=public_result.get("ok") is True, + data=public_result, + provenance={"source": "connector_service"}, + ) + if command_id == "queue.list": + return list_queue_events(limit=args.limit, state_dir=getattr(args, "state_dir", None)) + if command_id == "queue.append": + return append_queue_event( + kind=args.kind, + source_audit_id=args.source_audit_id, + message=args.message, + state_dir=getattr(args, "state_dir", None), + ) + if command_id == "codex.frontmost": + return observer.frontmost() + if command_id == "codex.windows": + return observer.windows() + if command_id == "codex.threads": + return observer.threads(max_items=args.max_items) + if command_id == "codex.thread_map": + return _build_codex_thread_map(observer=observer, app_server=app_server, max_items=args.max_items) + if command_id == "codex.focus": + return observer.focus(dry_run=args.dry_run) + if command_id == "codex.select_thread": + return observer.select_thread(thread_id=args.thread_id, dry_run=args.dry_run) + if command_id == "codex.send_visible_message": + return observer.send_visible_message( + thread_id=args.thread_id, + message=args.message, + dry_run=args.dry_run, + confirmed=args.confirm, + wait_ms=args.wait_ms, + poll_interval_ms=args.poll_interval_ms, + ) + if command_id == "codex.continue_thread": + return observer.continue_thread(title=args.title, prompt=args.prompt, dry_run=args.dry_run) + if command_id == "codex.snapshot": + return observer.snapshot(max_chars=args.max_chars) + if command_id == "codex.inspect": + return observer.inspect(max_nodes=args.max_nodes) + if command_id == "codex.ax_tree": + return observer.ax_tree(max_nodes=args.max_nodes) + if command_id == "codex.connections.status": + return app_server.connections_status() + if command_id == "codex.app_server.status": + return app_server.status() + if command_id == "codex.app_server.threads": + return app_server.threads(max_items=args.max_items) + if command_id == "codex.app_server.loaded_threads": + return app_server.loaded_threads(max_items=args.max_items) + if command_id == "codex.app_server.subscribe": + return app_server.subscribe(thread_id=args.thread_id, duration_ms=args.duration_ms, max_chars=args.max_chars) + if command_id == "codex.app_server.remote_control_status": + return app_server.remote_control_status() + if command_id == "customer_mac.status": + return customer_mac.status() + if command_id == "customer_mac.capabilities": + return customer_mac.capabilities() + if command_id == "customer_mac.control_status": + return customer_mac.control_status() + if command_id == "customer_mac.control_start": + return customer_mac.control_start(mode=args.mode, agent_label=args.agent_label) + if command_id == "customer_mac.control_stop": + return customer_mac.control_stop() + if command_id == "customer_mac.control_kill_switch": + return customer_mac.control_kill_switch() + if command_id == "customer_mac.desktop_see": + return customer_mac.desktop_see(max_chars=args.max_chars, max_nodes=args.max_nodes) + if command_id == "customer_mac.desktop_click": + return customer_mac.desktop_click(target_label=args.target_label, x=args.x, y=args.y, snapshot_id=args.snapshot_id, element_id=args.element_id, dry_run=args.dry_run) + if command_id == "customer_mac.desktop_type": + return customer_mac.desktop_type(text=args.text, dry_run=args.dry_run) + if command_id == "customer_mac.desktop_set_value": + return customer_mac.desktop_set_value(snapshot_id=args.snapshot_id, element_id=args.element_id, value=args.value, attribute=args.attribute, dry_run=args.dry_run) + if command_id == "customer_mac.desktop_scroll": + return customer_mac.desktop_scroll(direction=args.direction, amount=args.amount, dry_run=args.dry_run) + if command_id == "customer_mac.desktop_drag": + return customer_mac.desktop_drag(from_x=args.from_x, from_y=args.from_y, to_x=args.to_x, to_y=args.to_y, dry_run=args.dry_run) + if command_id == "customer_mac.desktop_hotkey": + return customer_mac.desktop_hotkey(keys=args.keys, dry_run=args.dry_run) + if command_id == "customer_mac.desktop_focus_app": + return customer_mac.desktop_focus_app(app_name=args.app_name, dry_run=args.dry_run) + if command_id == "customer_mac.desktop_window": + return customer_mac.desktop_window(action=args.action, dry_run=args.dry_run) + if command_id == "customer_mac.desktop_menu": + return customer_mac.desktop_menu(menu_path=args.menu_path, dry_run=args.dry_run) + if command_id == "customer_mac.desktop_browser_action": + return customer_mac.desktop_browser_action(action=args.action, url=args.url, dry_run=args.dry_run) + if command_id == "customer_mac.snapshot": + return customer_mac.snapshot(max_chars=args.max_chars) + if command_id == "customer_mac.ax_tree": + return customer_mac.ax_tree(max_nodes=args.max_nodes) + if command_id == "customer_mac.app_focus": + return customer_mac.app_focus(app_name=args.app_name, dry_run=args.dry_run) + if command_id == "customer_mac.local_site_open": + return customer_mac.local_site_open(url=args.url, dry_run=args.dry_run) + if command_id == "customer_mac.local_site_action": + return customer_mac.local_site_action(action=args.action, dry_run=args.dry_run) + if command_id == "customer_mac.iphone_mirroring_status": + return customer_mac.iphone_mirroring_status() + if command_id == "customer_mac.iphone_see": + return customer_mac.iphone_see(max_chars=args.max_chars, max_nodes=args.max_nodes) + if command_id == "customer_mac.iphone_tap": + return customer_mac.iphone_tap(target_label=args.target_label, x=args.x, y=args.y, snapshot_id=args.snapshot_id, element_id=args.element_id, dry_run=args.dry_run) + if command_id == "customer_mac.iphone_swipe": + return customer_mac.iphone_swipe(direction=args.direction, dry_run=args.dry_run) + if command_id == "customer_mac.iphone_type": + return customer_mac.iphone_type(text=args.text, dry_run=args.dry_run) + if command_id == "customer_mac.iphone_mirroring_focus": + return customer_mac.iphone_mirroring_focus(dry_run=args.dry_run) + if command_id == "customer_mac.iphone_mirroring_home": + return customer_mac.iphone_mirroring_action(action="home", dry_run=args.dry_run) + if command_id == "customer_mac.iphone_mirroring_app_switcher": + return customer_mac.iphone_mirroring_action(action="app_switcher", dry_run=args.dry_run) + if command_id == "customer_mac.iphone_mirroring_spotlight": + return customer_mac.iphone_mirroring_action(action="spotlight", dry_run=args.dry_run) + if command_id == "customer_mac.iphone_mirroring_type_spotlight": + return customer_mac.iphone_mirroring_action(action="type_spotlight", text=args.text, dry_run=args.dry_run) + if command_id == "customer_mac.iphone_mirroring_open_app": + return customer_mac.iphone_mirroring_action(action="open_app", app_name=args.app_name, dry_run=args.dry_run) + if command_id == "customer_mac.iphone_mirroring_tap_named_target": + return customer_mac.iphone_mirroring_action(action="tap_named_target", target_label=args.target_label, dry_run=args.dry_run) + if command_id == "customer_mac.iphone_mirroring_scroll": + return customer_mac.iphone_mirroring_action(action="scroll", direction=args.direction, dry_run=args.dry_run) + if command_id == "customer_mac.iphone_mirroring_swipe_left": + return customer_mac.iphone_mirroring_action(action="swipe_left", dry_run=args.dry_run) + if command_id == "customer_mac.iphone_mirroring_swipe_right": + return customer_mac.iphone_mirroring_action(action="swipe_right", dry_run=args.dry_run) + if command_id == "customer_mac.iphone_mirroring_swipe_up": + return customer_mac.iphone_mirroring_action(action="swipe_up", dry_run=args.dry_run) + if command_id == "customer_mac.iphone_mirroring_swipe_down": + return customer_mac.iphone_mirroring_action(action="swipe_down", dry_run=args.dry_run) + if command_id == "customer_mac.iphone_mirroring_type_approved_text": + return customer_mac.iphone_mirroring_action(action="type_approved_text", text=args.text, dry_run=args.dry_run) + if command_id == "customer_mac.iphone_mirroring_send_approved_message": + return customer_mac.iphone_mirroring_action(action="send_approved_message", text=args.text, recipient_context=args.recipient_context, target_label=args.target_label, dry_run=args.dry_run) + if command_id == "customer_mac.screen_sharing_status": + return customer_mac.screen_sharing_status() + raise PolicyError(command_id) + + +def _build_codex_thread_map( + *, + observer: MacOSCodexObserver, + app_server: CodexAppServerObserver, + max_items: int, +) -> CommandResult: + frontmost_result = observer.frontmost() + frontmost_data: dict[str, object] = {} + visible_send_ready = False + frontmost_warnings: list[str] = [] + if frontmost_result.ok: + frontmost_data = dict(frontmost_result.data) + visible_send_ready = frontmost_data.get("codex_frontmost") is True + frontmost_warnings.extend(frontmost_result.warnings) + if not visible_send_ready: + frontmost_warnings.append("Codex Desktop is not frontmost; live visible sends will fail until Codex is focused.") + else: + frontmost_data = {"codex_frontmost": False, "available": False} + frontmost_warnings.extend(frontmost_result.warnings) + frontmost_warnings.append("Codex Desktop frontmost state unavailable; live visible sends will fail closed until frontmost state can be verified.") + + visible_result = observer.threads(max_items=max_items) + if not visible_result.ok: + visible_result.provenance.update({"source": "codex_visible_gui"}) + return visible_result + app_result = app_server.threads(max_items=max_items) + warnings = frontmost_warnings + list(visible_result.warnings) + app_threads: list[dict[str, object]] = [] + app_server_available = app_result.ok + if app_result.ok: + app_threads = list(app_result.data.get("threads", [])) + warnings.extend(app_result.warnings) + else: + warnings.extend(app_result.warnings) + warnings.append("app-server thread summaries unavailable; returning visible GUI candidates without saved-thread matches") + + visible_threads = list(visible_result.data.get("threads", [])) + matches: list[dict[str, object]] = [] + matched_app_ids: set[str] = set() + for visible in visible_threads: + visible_title = str(visible.get("title") or "") + visible_norm = _normalize_title_for_match(visible_title) + best: dict[str, object] | None = None + best_score = 0 + match_reason = "normalized_title" + for app_thread in app_threads: + app_title = str(app_thread.get("title") or app_thread.get("name") or "") + app_norm = _normalize_title_for_match(app_title) + score = _title_match_score(visible_norm, app_norm) + if score > best_score: + best = app_thread + best_score = score + if best is None and visible.get("title_available") is False: + index = visible.get("index") + if isinstance(index, int) and 0 <= index < len(app_threads) and visible.get("updated_label"): + best = app_threads[index] + best_score = 2 + match_reason = "visible_order_title_hidden" + if best is not None and best_score > 0: + app_id = str(best.get("id") or "") + if app_id: + matched_app_ids.add(app_id) + matches.append( + { + "visible_id": visible.get("visible_id"), + "visible_title": visible_title, + "app_server_id": best.get("id"), + "app_server_title": best.get("title") or best.get("name"), + "confidence": "high" if best_score >= 3 else "medium", + "match_reason": match_reason, + } + ) + + return CommandResult( + ok=True, + data={ + "visible_threads": visible_threads, + "app_server_threads": app_threads, + "matches": matches, + "visible_count": len(visible_threads), + "app_server_count": len(app_threads), + "matched_count": len(matches), + "unmatched_visible_count": max(0, len(visible_threads) - len(matches)), + "unmatched_app_server_count": len([thread for thread in app_threads if str(thread.get("id") or "") not in matched_app_ids]), + "app_server_available": app_server_available, + "frontmost": frontmost_data, + "visible_send_ready": visible_send_ready, + "max_items": max_items, + "source": "codex_visible_gui_app_server_read", + }, + warnings=warnings, + provenance={"source": "codex_visible_gui_app_server_read"}, + ) + + +def _normalize_title_for_match(value: str) -> str: + lowered = "".join(char.lower() if char.isalnum() else " " for char in value) + return " ".join(lowered.split()) + + +def _title_match_score(left: str, right: str) -> int: + if not left or not right: + return 0 + if left == right: + return 3 + if left in right or right in left: + return 2 + left_words = set(left.split()) + right_words = set(right.split()) + if len(left_words & right_words) >= 2: + return 1 + return 0 + + +def _helper_ping(args: argparse.Namespace, *, state_dir: Path | None) -> CommandResult: + socket_path = Path(args.socket_path).expanduser() if args.socket_path else default_helper_socket_path(state_dir) + try: + token = read_helper_token(token_file=getattr(args, "token_file", None), state_dir=state_dir, auto_create=False) + except (HelperIpcError, OSError) as exc: + code = getattr(exc, "code", "helper_token_missing") + message = getattr(exc, "message", str(exc)) + return CommandResult( + ok=False, + data={"helper_socket": str(socket_path)}, + errors=[ + make_error( + code=code, + message=message, + guidance="Start the helper with `evaos-desktop-bridge helper run` so the token file is created, then retry ping.", + ) + ], + provenance={"source": "computer_use_helper"}, + ) + return UnixSocketHelperClient(socket_path=socket_path, token=token).dispatch("ping", {"client": "bridge"}) + + +def _validate_guarded_approval(command_id: str, args: argparse.Namespace, state_dir: Path | None) -> CommandResult: + if command_id in TAKEOVER_WARNING_GATED_COMMANDS and getattr(args, "dry_run", None) is False: + session = read_control_session(state_dir) + if session.get("kill_switch") is True: + return CommandResult( + ok=False, + data={"session": session}, + errors=[ + make_error( + code="control_kill_switch_active", + message="The customer Mac kill switch is active; live agent control commands are blocked.", + guidance="The customer must start a new control session in Workbench before agents can act again.", + ) + ], + ) + warning = session.get("takeover_warning") if isinstance(session.get("takeover_warning"), dict) else {} + if session.get("active") is True and warning.get("active") is True: + return _takeover_warning_active_result(warning) + if command_id in CONTROLLED_LIVE_COMMANDS and session.get("active") is True: + if session.get("mode") == "full_access": + return CommandResult(ok=True) + if session.get("mode") == "ask_permission" and not _ask_permission_requires_approval(command_id, args): + return CommandResult(ok=True) + + source_fields = CODEX_SOURCE_AUDIT_FIELDS.get(command_id) + if source_fields is not None and getattr(args, "dry_run", None) is False: + if getattr(args, "confirm", None) is not True: + return CommandResult(ok=True) + source_audit_id = getattr(args, "source_audit_id", None) + if not isinstance(source_audit_id, str) or not source_audit_id.strip().startswith("audit-"): + return _source_audit_required_result(command_id, source_fields) + record = read_audit_record(source_audit_id.strip(), state_dir=state_dir) + if record is None: + return _source_audit_required_result(command_id, source_fields, "source_audit_id was not found in the local audit log.") + if record.get("command") != command_id or record.get("ok") is not True: + return _source_audit_required_result(command_id, source_fields, "source_audit_id does not reference a successful dry-run for this command.") + record_args = record.get("args") + if not isinstance(record_args, dict) or record_args.get("dry_run") is not True: + return _source_audit_required_result(command_id, source_fields, "source_audit_id must reference a dry-run record.") + freshness_error = approval_audit_freshness_error(record) + if freshness_error is not None: + return _source_audit_required_result(command_id, source_fields, freshness_error.replace("approval_audit_id", "source_audit_id")) + for field in source_fields: + if record_args.get(field) != getattr(args, field, None): + return _source_audit_required_result(command_id, source_fields, f"source_audit_id does not match {field}.") + return CommandResult(ok=True) + + fields = GUARDED_APPROVAL_FIELDS.get(command_id) + if fields is None or getattr(args, "dry_run", None) is not False: + return CommandResult(ok=True) + if command_id == "codex.send_visible_message" and getattr(args, "confirm", None) is not True: + return CommandResult( + ok=False, + data={"required_fields": ["confirm", *fields]}, + errors=[ + make_error( + code="visible_message_confirmation_required", + message="Live Codex visible GUI messaging requires --confirm after a matching dry-run audit.", + guidance="Run the command with --dry-run first, then rerun with --live --confirm --approval-audit-id.", + ) + ], + ) + approval_audit_id = getattr(args, "approval_audit_id", None) + if not isinstance(approval_audit_id, str) or not approval_audit_id.strip(): + return _approval_required_result(command_id, fields) + record = read_audit_record(approval_audit_id.strip(), state_dir=state_dir) + if record is None: + return _approval_required_result(command_id, fields, "approval_audit_id was not found in the local audit log.") + if record.get("command") != command_id or record.get("ok") is not True: + return _approval_required_result(command_id, fields, "approval_audit_id does not reference a successful dry-run for this command.") + record_args = record.get("args") + if not isinstance(record_args, dict) or record_args.get("dry_run") is not True: + return _approval_required_result(command_id, fields, "approval_audit_id must reference a dry-run record.") + freshness_error = approval_audit_freshness_error(record) + if freshness_error is not None: + return _approval_required_result(command_id, fields, freshness_error) + for field in fields: + if record_args.get(field) != getattr(args, field, None): + return _approval_required_result(command_id, fields, f"approval_audit_id does not match {field}.") + return CommandResult(ok=True) + + +def _ask_permission_requires_approval(command_id: str, args: argparse.Namespace) -> bool: + if command_id in ASK_PERMISSION_HIGH_IMPACT_COMMANDS: + return True + if command_id in {"customer_mac.desktop_click", "customer_mac.iphone_tap"}: + label = getattr(args, "target_label", None) + if not isinstance(label, str) or not label.strip(): + return True + return _contains_risk_word(label) + if command_id == "customer_mac.desktop_hotkey": + keys = str(getattr(args, "keys", "") or "").strip().lower().replace("command", "cmd").replace(" ", "") + return keys not in ASK_PERMISSION_SAFE_HOTKEYS + if command_id == "customer_mac.desktop_window": + return str(getattr(args, "action", "") or "").strip().lower() == "close" + if command_id == "customer_mac.desktop_browser_action": + return str(getattr(args, "action", "") or "").strip().lower() in {"open_url"} + if command_id == "customer_mac.desktop_menu": + return _contains_risk_word(str(getattr(args, "menu_path", "") or "")) + return False + + +def _contains_risk_word(value: str) -> bool: + normalized = "".join(char.lower() if char.isalnum() else " " for char in value) + words = set(normalized.split()) + return any(word in words for word in ASK_PERMISSION_RISK_WORDS) + + +def _approval_required_result(command_id: str, fields: tuple[str, ...], message: str | None = None) -> CommandResult: + return CommandResult( + ok=False, + data={"required_fields": list(fields)}, + errors=[ + make_error( + code="approval_audit_required", + message=message or "Live guarded actions require a prior matching dry-run audit id.", + guidance=f"Run {command_id} with --dry-run first, then rerun the exact same action with --approval-audit-id set to that audit_id.", + ) + ], + ) + + +def _takeover_warning_active_result(warning: dict[str, object]) -> CommandResult: + seconds = warning.get("seconds") if isinstance(warning.get("seconds"), int) else 10 + return CommandResult( + ok=False, + data={"takeover_warning": warning}, + errors=[ + make_error( + code="control_takeover_warning_active", + message=f"Agent control is starting; live actions are blocked until the {seconds}-second takeover warning finishes.", + guidance="Wait for the visible takeover countdown to finish, then rerun the same live action. Read-only status, stop, and kill-switch remain available.", + ) + ], + provenance={"source": "control_session", "takeover_warning": warning}, + ) + + +def _source_audit_required_result(command_id: str, fields: tuple[str, ...], message: str | None = None) -> CommandResult: + return CommandResult( + ok=False, + data={"required_fields": list(fields)}, + errors=[ + make_error( + code="source_audit_id_required", + message=message or "Live Codex remote-control actions require a prior matching dry-run audit id.", + guidance=f"Run {command_id} with --dry-run first, then rerun the exact same action with --source-audit-id set to that audit_id.", + ) + ], + ) + + +def _positive_int(value: str) -> int: + parsed = int(value) + if parsed < 1: + raise argparse.ArgumentTypeError("value must be >= 1") + return parsed + + +def _nonnegative_int(value: str) -> int: + parsed = int(value) + if parsed < 0: + raise argparse.ArgumentTypeError("value must be >= 0") + return parsed + + +def _capabilities() -> dict[str, object]: + return { + "modes": { + "eyes": "Read-only visible desktop observation with redaction and caps.", + "hands": "Guarded visible focus/select only; Codex app-server mutation commands remain withheld until live loaded-thread acceptance passes.", + "brain": "Local Eva/OpenClaw announcement queue contract with external relay left to future sinks.", + }, + "commands": [ + {"id": command, "target": _target_for_command(command), **command_metadata(command)} + for command in [ + "status", + "capabilities", + "latest", + "audit_tail", + "permissions.prime", + "helper.ping", + "queue.list", + "queue.append", + "codex.frontmost", + "codex.windows", + "codex.threads", + "codex.thread_map", + "codex.focus", + "codex.select_thread", + "codex.send_visible_message", + "codex.continue_thread", + "codex.snapshot", + "codex.inspect", + "codex.ax_tree", + "codex.connections.status", + "codex.app_server.status", + "codex.app_server.threads", + "codex.app_server.loaded_threads", + "codex.app_server.subscribe", + "codex.app_server.remote_control_status", + "customer_mac.status", + "customer_mac.capabilities", + "customer_mac.control_status", + "customer_mac.control_start", + "customer_mac.control_stop", + "customer_mac.control_kill_switch", + "customer_mac.desktop_see", + "customer_mac.desktop_click", + "customer_mac.desktop_type", + "customer_mac.desktop_set_value", + "customer_mac.desktop_scroll", + "customer_mac.desktop_drag", + "customer_mac.desktop_hotkey", + "customer_mac.desktop_focus_app", + "customer_mac.desktop_window", + "customer_mac.desktop_menu", + "customer_mac.desktop_browser_action", + "customer_mac.snapshot", + "customer_mac.ax_tree", + "customer_mac.app_focus", + "customer_mac.local_site_open", + "customer_mac.local_site_action", + "customer_mac.iphone_mirroring_status", + "customer_mac.iphone_see", + "customer_mac.iphone_tap", + "customer_mac.iphone_swipe", + "customer_mac.iphone_type", + "customer_mac.iphone_mirroring_focus", + "customer_mac.iphone_mirroring_home", + "customer_mac.iphone_mirroring_app_switcher", + "customer_mac.iphone_mirroring_spotlight", + "customer_mac.iphone_mirroring_type_spotlight", + "customer_mac.iphone_mirroring_open_app", + "customer_mac.iphone_mirroring_tap_named_target", + "customer_mac.iphone_mirroring_scroll", + "customer_mac.iphone_mirroring_swipe_left", + "customer_mac.iphone_mirroring_swipe_right", + "customer_mac.iphone_mirroring_swipe_up", + "customer_mac.iphone_mirroring_swipe_down", + "customer_mac.iphone_mirroring_type_approved_text", + "customer_mac.iphone_mirroring_send_approved_message", + "customer_mac.screen_sharing_status", + ] + ], + "guarded_prompt_or_message_commands": ["codex.send_visible_message"], + "forbidden": [ + "unguarded_send_prompts_or_messages", + "type_into_codex", + "click_codex_controls", + "call_codex_internal_mutation_rpc", + "hijack_stdio_or_file_descriptors", + "read_session_databases_wholesale", + "expose_tokens_auth_files_or_full_home_paths", + "customer_mac_generic_remote_desktop", + "public_mac_ports", + "hidden_shell_or_applescript_passthrough", + "customer_mac_screen_sharing_enablement", + ], + "data_minimization": { + "redacts_home_paths": True, + "redacts_secret_like_values": True, + "caps_visible_text": True, + "caps_ax_nodes": True, + "skips_non_codex_screenshots": True, + "append_only_audit_log": True, + }, + } + + +def _prime_permission(permission: str) -> CommandResult: + normalized = "screen_recording" if permission == "screen-recording" else permission + if sys.platform != "darwin": + return CommandResult( + ok=False, + data={"permission": normalized, "status": "unsupported", "target": "evaOS Workbench"}, + errors=[ + make_error( + code="permission_platform_unsupported", + message="macOS permissions can only be requested on macOS.", + guidance="Run this from the Mac that will be paired with evaOS.", + ) + ], + ) + + permission_name = "Screen Recording" if normalized == "screen_recording" else "Accessibility" + peekaboo_path = _peekaboo_binary_path() + if not peekaboo_path: + _open_privacy_pane(normalized) + return CommandResult( + ok=False, + data={ + "permission": normalized, + "status": "unavailable", + "target": "evaOS Workbench", + "permission_holder": "evaOS Workbench", + }, + errors=[ + make_error( + code="peekaboo_permission_helper_missing", + message="Peekaboo is required to check Mac control permissions without prompting for Python.", + guidance="Reinstall evaOS Workbench so the bundled Peekaboo helper is available, then approve evaOS Workbench or the Peekaboo helper shown by macOS.", + permission=normalized, + ) + ], + ) + + grant_result = _run_peekaboo_permissions(peekaboo_path, ["grant", "--json"]) + status_result = _run_peekaboo_permissions(peekaboo_path, ["status", "--json"]) + event_result: dict[str, object] | None = None + if normalized == "accessibility": + event_result = _run_peekaboo_permissions(peekaboo_path, ["request-event-synthesizing", "--json"]) + status_result = _run_peekaboo_permissions(peekaboo_path, ["status", "--json"]) + + trusted = _peekaboo_permission_granted(status_result, permission_name) + if trusted is not True: + _open_privacy_pane(normalized) + + return CommandResult( + ok=True, + data={ + "permission": normalized, + "status": "granted" if trusted is True else "requested", + "target": "Peekaboo automation helper", + "executable": peekaboo_path, + "permission_holder": _peekaboo_permission_source(status_result), + "grant": grant_result, + "event_synthesizing": event_result, + "guidance": "Approve evaOS Workbench, Peekaboo, or the selected Peekaboo Bridge host shown by macOS. Python should not be the permission target for this flow.", + }, + ) + + +def _peekaboo_binary_path() -> str | None: + for candidate in (*bundled_bridge_bin_candidates(("peekaboo", "evaos-connector-helper")), *PEEKABOO_BIN_CANDIDATES): + if "/" in candidate: + if Path(candidate).exists(): + return candidate + else: + resolved = shutil.which(candidate) + if resolved: + return resolved + return None + + +def _run_peekaboo_permissions(peekaboo_path: str, args: list[str]) -> dict[str, object]: + command = [peekaboo_path, "permissions", *args] + try: + completed = subprocess.run( + command, + text=True, + capture_output=True, + timeout=12, + check=False, + ) + except Exception as exc: + return {"ok": False, "command": command[:3], "error": str(exc)} + try: + payload = json.loads(completed.stdout.strip() or "{}") + except json.JSONDecodeError: + payload = {} + return { + "ok": completed.returncode == 0, + "command": command[:3], + "returncode": completed.returncode, + "payload": payload if isinstance(payload, dict) else {}, + "stderr": completed.stderr[-1200:], + } + + +def _peekaboo_permission_granted(status_result: dict[str, object], permission_name: str) -> bool | None: + payload = status_result.get("payload") + if not isinstance(payload, dict): + return None + data = payload.get("data") + permissions: object + if isinstance(data, dict): + permissions = data.get("permissions") + else: + permissions = data + if not isinstance(permissions, list): + return None + for item in permissions: + if isinstance(item, dict) and item.get("name") == permission_name: + return bool(item.get("isGranted")) + return None + + +def _peekaboo_permission_source(status_result: dict[str, object]) -> str: + payload = status_result.get("payload") + if isinstance(payload, dict): + data = payload.get("data") + if isinstance(data, dict): + source = data.get("source") + if isinstance(source, str) and source: + return f"Peekaboo {source}" + return "Peekaboo Bridge host or bundled Peekaboo CLI" + + +def _open_privacy_pane(permission: str) -> None: + pane = "Privacy_ScreenCapture" if permission == "screen_recording" else "Privacy_Accessibility" + try: + subprocess.run( + ["open", f"x-apple.systempreferences:com.apple.preference.security?{pane}"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=3, + check=False, + ) + except Exception: + return + + +def _target_for_command(command: str) -> str: + if command.startswith("customer_mac."): + return "customer_mac" + if command.startswith("codex."): + return "codex" + if command.startswith("queue."): + return "queue" + if command.startswith("helper."): + return "computer_use_helper" + return "desktop" + + +DEFAULT_CONNECTOR_LABEL = "com.electricsheep.evaos-desktop-bridge" + + +def _connector_label_from_env(env: dict[str, str] | None = None) -> str: + env = env if env is not None else os.environ + raw_label = str(env.get("EVAOS_DESKTOP_BRIDGE_CONNECTOR_LABEL") or DEFAULT_CONNECTOR_LABEL).strip() + if not raw_label: + return DEFAULT_CONNECTOR_LABEL + allowed = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-_") + if any(char not in allowed for char in raw_label) or "/" in raw_label: + return DEFAULT_CONNECTOR_LABEL + return raw_label + + +CONNECTOR_LABEL = _connector_label_from_env() +CONNECTOR_PORT = 8765 +CONNECTOR_SYSTEM_PLIST = Path(f"/Library/LaunchAgents/{CONNECTOR_LABEL}.plist") +CONNECTOR_USER_PLIST = Path.home() / "Library" / "LaunchAgents" / f"{CONNECTOR_LABEL}.plist" +PEEKABOO_BIN_CANDIDATES = ( + "peekaboo", + "evaos-connector-helper", + "/opt/homebrew/bin/peekaboo", + "/usr/local/bin/peekaboo", +) + + +def _complete_connector_service_enrollment(args: argparse.Namespace, *, state_dir: Path | None = None) -> dict[str, object]: + enrollment_code = str(getattr(args, "enrollment_code", "") or "").strip() + customer_id = str(getattr(args, "customer_id", "") or "").strip() + device_name = str(getattr(args, "device_name", "") or "").strip() or socket.gethostname() or "Customer Mac" + device_identifier = str(getattr(args, "device_identifier", "") or "").strip() or socket.gethostname() + if not enrollment_code or not customer_id: + return { + "ok": False, + "action": "complete-enrollment", + "error": "enrollment_code_and_customer_id_required", + } + + status = _connector_service_status(state_dir=state_dir) + health = status.get("health") if isinstance(status.get("health"), dict) else {} + host_result = _connector_registration_host(status) + host = str(host_result.get("host") or "").strip() + if status.get("ok") is not True: + return { + "ok": False, + "action": "complete-enrollment", + "error": "connector_service_not_ready", + "status": _public_ready_connector_service_status(status), + } + if not host: + return { + "ok": False, + "action": "complete-enrollment", + "error": str(host_result.get("error") or "secure_network_link_required"), + "message": str( + host_result.get("message") + or "A secure tailnet or private connector host is required before pairing an agent to this Mac." + ), + "tailnet_available": bool(status.get("tailnet_ip")), + "health_reachable": bool(health.get("reachable")), + "health_host_kind": _connector_host_kind(str(health.get("host") or "")), + } + + try: + connector_token = read_token(None, state_dir=state_dir, auto_create=False) + except Exception as exc: + return { + "ok": False, + "action": "complete-enrollment", + "error": "connector_token_unavailable", + "message": str(exc), + } + if not connector_token: + return { + "ok": False, + "action": "complete-enrollment", + "error": "connector_token_unavailable", + } + + try: + result = complete_enrollment_via_control( + enrollment_code=enrollment_code, + connector_url=f"http://{_connector_url_host(host)}:{CONNECTOR_PORT}", + connector_token=connector_token, + device_name=device_name, + device_identifier=device_identifier, + ) + except Exception as exc: + return { + "ok": False, + "action": "complete-enrollment", + "error": "broker_complete_enrollment_failed", + "message": str(exc), + } + + device = result.get("device") if isinstance(result.get("device"), dict) else {} + headscale = result.get("headscale") if isinstance(result.get("headscale"), dict) else None + public_headscale = None + if headscale is not None: + public_headscale = { + key: value + for key, value in headscale.items() + if key not in {"preauth_key", "auth_key", "token", "secret"} + } + if any(key in headscale for key in ("preauth_key", "auth_key", "token", "secret")): + public_headscale["secret_material_returned"] = False + return { + "ok": bool(device.get("id")) or result.get("ok") is True, + "action": "complete-enrollment", + "customer_id": customer_id, + "device_id": device.get("id"), + "connector_registered": True, + "connector_token_last4": connector_token[-4:], + "headscale": public_headscale, + "raw_secrets_returned": False, + } + + +def _build_cli_ready_payload(*, token: str | None, token_file: str | None = None, state_dir: Path | None = None) -> dict[str, object]: + payload = dict(build_ready_payload(token=token, state_dir=state_dir)) + service_status = _connector_service_status(token=token, token_file=token_file, state_dir=state_dir) + health = service_status.get("health") if isinstance(service_status.get("health"), dict) else {} + blockers = list(payload.get("blockers") if isinstance(payload.get("blockers"), list) else []) + + if service_status.get("ready") is not True: + code = "connector_service_unreachable" if health.get("reachable") is not True else "connector_service_not_ready" + blockers.append( + { + "code": code, + "message": "Connector service is not ready; Workbench must start the signed bridge before Mac control is ready.", + "host_kind": _connector_host_kind(str(health.get("host") or "")), + } + ) + + if blockers: + payload["ok"] = False + payload["ready"] = False + payload["blockers"] = blockers + + payload["connector_service"] = _public_ready_connector_service_status(service_status) + return payload + + +def _build_cli_diagnostics_payload(*, token: str | None, token_file: str | None = None, state_dir: Path | None = None) -> dict[str, object]: + service_status = _connector_service_status(token=token, token_file=token_file, state_dir=state_dir) + return build_diagnostics_payload( + token=token, + state_dir=state_dir, + owner=_public_bridge_owner_from_status(service_status), + ) + + +def _connector_http_owner_summary() -> dict[str, object]: + return _bridge_owner_summary( + label=CONNECTOR_LABEL, + plist_path=_connector_plist_path(), + program_arguments=None, + ready=True, + active_program_path=_current_connector_program_path(), + ) + + +def _current_connector_program_path() -> Path | None: + candidates: list[Path] = [] + argv0 = str(sys.argv[0] or "").strip() + if argv0: + candidates.append(Path(argv0).expanduser()) + process_path = _process_program_path(os.getpid()) + if process_path is not None: + candidates.append(process_path) + if sys.executable: + candidates.append(Path(sys.executable).expanduser()) + + preferred: tuple[int, Path] | None = None + classification_rank = {"workbench_bundle": 0, "legacy_bundle": 1, "global_cli": 2} + for candidate in candidates: + app_path = _owner_app_path(candidate) + classification = _classify_bridge_owner(program_path=candidate, app_path=app_path, bundle_id=_owner_bundle_id(app_path), ready=True) + rank = classification_rank.get(classification) + if rank is None: + continue + if preferred is None or rank < preferred[0]: + preferred = (rank, candidate) + if preferred is not None: + return preferred[1] + return candidates[0] if candidates else None + + +def _public_ready_connector_service_status(status: dict[str, object]) -> dict[str, object]: + health = status.get("health") if isinstance(status.get("health"), dict) else {} + public: dict[str, object] = { + "ok": status.get("ok") is True, + "ready": status.get("ready") is True, + "managed_by": status.get("managed_by") if isinstance(status.get("managed_by"), str) else "unknown", + "token_present": status.get("token_present") is True, + "loaded": status.get("loaded") is True, + "running": status.get("running") is True, + "owner": _public_bridge_owner_from_status(status), + "health": { + "reachable": health.get("reachable") is True, + "ready": health.get("ready") is True, + "authenticated": health.get("authenticated") is True, + "host_kind": _connector_host_kind(str(health.get("host") or "")), + }, + } + _add_public_connector_evidence(public, status) + return public + + +def _read_connector_token_optional(token_file: str | None, *, state_dir: Path | None = None) -> str | None: + try: + return read_token(token_file, state_dir=state_dir, auto_create=False) + except (FileNotFoundError, ValueError): + return None + + +def _connector_token_path(token_file: str | None, *, state_dir: Path | None = None) -> Path: + if token_file: + return Path(token_file).expanduser() + return (state_dir or default_state_dir()) / "connector.token" + + +def _connector_token_value(token_file: str | None, *, token: str | None = None, state_dir: Path | None = None) -> str | None: + if isinstance(token, str) and token.strip(): + return token + try: + return read_token(token_file, state_dir=state_dir, auto_create=False) + except (FileNotFoundError, IsADirectoryError, PermissionError, UnicodeDecodeError, ValueError): + return None + + +def _connector_registration_host(status: dict[str, object]) -> dict[str, object]: + tailnet_ip = str(status.get("tailnet_ip") or "").strip() + if _is_safe_connector_registration_host(tailnet_ip): + return {"ok": True, "host": tailnet_ip, "source": "tailnet_ip"} + + health = status.get("health") if isinstance(status.get("health"), dict) else {} + health_host = str(health.get("host") or "").strip() + if health.get("reachable") is True and _is_safe_connector_registration_host(health_host): + return {"ok": True, "host": health_host, "source": "health_host"} + + return { + "ok": False, + "error": "tailnet_ip_required" if not tailnet_ip else "secure_network_link_required", + "message": ( + "A secure tailnet or private connector host is required before pairing an agent to this Mac. " + "Loopback, reserved, public, or unreachable connector health hosts are not registered with evaOS." + ), + } + + +def _is_safe_connector_registration_host(value: str) -> bool: + host = _normalized_connector_host(value) + if not host: + return False + lowered = host.lower() + if lowered in {"localhost", "localhost.localdomain"}: + return False + if lowered.endswith(".local"): + return True + try: + ip = ipaddress.ip_address(host) + except ValueError: + return False + if ip.is_loopback or ip.is_unspecified or ip.is_multicast or ip.is_reserved or ip.is_link_local: + return False + return _looks_like_tailnet_ipv4(host) or ip.is_private + + +def _connector_host_kind(value: str) -> str: + host = _normalized_connector_host(value) + if not host: + return "missing" + if host.lower() in {"localhost", "localhost.localdomain"}: + return "loopback" + try: + ip = ipaddress.ip_address(host) + except ValueError: + if host.lower().endswith(".local"): + return "local" + return "hostname" + if ip.is_loopback: + return "loopback" + if ip.is_unspecified: + return "unspecified" + if _looks_like_tailnet_ipv4(host): + return "tailnet" + if ip.is_private: + return "private" + return "public" + + +def _normalized_connector_host(value: str) -> str: + raw_host = str(value or "").strip() + if not raw_host or any(separator in raw_host for separator in ("/", "?", "#", "@")): + return "" + bracketed_host = raw_host.startswith("[") and raw_host.endswith("]") + host = raw_host[1:-1] if bracketed_host else raw_host + if not host: + return "" + try: + ipaddress.ip_address(host) + except ValueError: + if ":" in host: + return "" + return host + + +def _connector_url_host(host: str) -> str: + normalized = _normalized_connector_host(host) + try: + ip = ipaddress.ip_address(normalized) + except ValueError: + return normalized + return f"[{normalized}]" if ip.version == 6 else normalized + + +def _run_public_connector_service(action: str, *, state_dir: Path | None = None) -> dict[str, object]: + if action not in {"status", "start", "stop"}: + return { + "ok": False, + "action": action, + "error": "unsupported_connector_service_action", + "raw_output_returned": False, + } + + if action == "start": + start_result = _public_launchctl_result(_launchctl_start()) + bootstrap_ok = _public_launchctl_start_bootstrap_ok(start_result) + if bootstrap_ok: + status = _wait_for_public_connector_service_ready(state_dir=state_dir) + else: + status = _public_connector_service_probe_status(state_dir=state_dir) + payload: dict[str, object] = { + "ok": bootstrap_ok and status.get("ok") is True, + "action": action, + "launchctl": start_result, + "status": status, + } + launchctl_notes = _public_launchctl_start_notes(start_result, recovered=status.get("ok") is True) + if launchctl_notes: + payload["launchctl_notes"] = launchctl_notes + return payload + + if action == "stop": + stop_result = _public_launchctl_result(_launchctl_stop()) + status = _public_connector_service_probe_status(state_dir=state_dir) + return { + "ok": _public_launchctl_ok(stop_result), + "action": action, + "launchctl": stop_result, + "status": status, + } + + return _public_connector_service_probe_status(state_dir=state_dir) + + +def _wait_for_public_connector_service_ready( + *, + state_dir: Path | None = None, + timeout_seconds: float = 8.0, + poll_interval_seconds: float = 0.2, +) -> dict[str, object]: + deadline = time.monotonic() + timeout_seconds + status = _public_connector_service_probe_status(state_dir=state_dir) + while status.get("ok") is not True and time.monotonic() < deadline: + time.sleep(poll_interval_seconds) + status = _public_connector_service_probe_status(state_dir=state_dir) + return status + + +def _public_connector_service_probe_status(*, state_dir: Path | None = None) -> dict[str, object]: + domain = _launchctl_domain() + print_result = _run_launchctl(["print", f"{domain}/{CONNECTOR_LABEL}"]) + token_path = _connector_token_path(None, state_dir=state_dir) + plist_path = _connector_plist_path() + loaded = print_result["returncode"] == 0 + connector_token = _connector_token_value(None, state_dir=state_dir) + health = _connector_loopback_health(connector_token=connector_token) if connector_token else _connector_public_loopback_health() + reachable = health.get("reachable") is True + ready = connector_token is not None and health.get("ready") is True and health.get("authenticated") is True + running = loaded and ready + program_arguments = _connector_plist_program_arguments(plist_path) + active_program_path = _active_connector_process_program_path() if reachable else None + tailscale_status = _tailscale_status_snapshot() + status = { + "ok": ready, + "ready": ready, + "label": CONNECTOR_LABEL, + "domain": domain, + "managed_by": "launchagent" if running else "workbench-or-manual" if ready else "offline", + "plist_path": str(plist_path) if plist_path else None, + "plist_installed": plist_path is not None, + "token_path": str(token_path), + "token_present": connector_token is not None, + "loaded": loaded, + "running": running, + "tailnet_ip": _tailscale_ip(tailscale_status), + "private_network": _private_network_evidence(tailscale_status), + "bridge_runtime": _workbench_runtime_compatibility(_bridge_runtime_version()), + "health": health, + "owner": _bridge_owner_summary( + label=CONNECTOR_LABEL, + plist_path=plist_path, + program_arguments=program_arguments, + ready=ready, + active_program_path=active_program_path, + ), + "permission_target": _connector_permission_target( + "launchagent" if running else "workbench-or-manual" if ready else "offline", + health, + program_arguments, + ), + "guidance": _connector_service_guidance(plist_path, connector_token is not None, health), + } + return _public_connector_service_status(status) + + +def _public_connector_service_result(result: dict[str, object]) -> dict[str, object]: + nested_status = result.get("status") + if isinstance(nested_status, dict): + public: dict[str, object] = { + "ok": result.get("ok") is True, + "action": result.get("action") if isinstance(result.get("action"), str) else "connector-service", + "status": _public_connector_service_status(nested_status), + } + launchctl = result.get("launchctl") + if isinstance(launchctl, dict): + public["launchctl"] = _public_launchctl_result(launchctl) + launchctl_notes = _public_launchctl_notes(result.get("launchctl_notes")) + if launchctl_notes: + public["launchctl_notes"] = launchctl_notes + return public + if "health" in result or "managed_by" in result or "permission_target" in result: + return _public_connector_service_status(result) + return { + "ok": result.get("ok") is True, + "action": result.get("action") if isinstance(result.get("action"), str) else "connector-service", + "error": _public_connector_service_error(result.get("error")), + "raw_output_returned": False, + } + + +def _public_connector_service_error(value: object) -> str: + allowed = { + "connector_identity_unverified", + "connector_ready_probe_failed", + "connector_service_failed", + "connector_token_missing", + "unsupported_connector_service_action", + } + if isinstance(value, str) and value.strip() in allowed: + return value.strip() + return "connector_service_failed" + + +def _public_launchctl_result(result: dict[str, object]) -> dict[str, object]: + if "returncode" in result: + return { + "returncode": result.get("returncode"), + "stdout_present": bool(result.get("stdout")), + "stderr_present": bool(result.get("stderr")), + } + return {key: _public_launchctl_result(value) for key, value in result.items() if isinstance(key, str) and isinstance(value, dict)} + + +def _public_launchctl_ok(result: dict[str, object]) -> bool: + if "returncode" in result: + return result.get("returncode") == 0 + nested = [value for value in result.values() if isinstance(value, dict)] + return bool(nested) and all(_public_launchctl_ok(value) for value in nested) + + +def _public_launchctl_start_ok(result: dict[str, object]) -> bool: + bootstrap = result.get("bootstrap") + kickstart = result.get("kickstart") + return ( + isinstance(bootstrap, dict) + and isinstance(kickstart, dict) + and _public_launchctl_ok(bootstrap) + and _public_launchctl_ok(kickstart) + ) + + +def _public_launchctl_start_bootstrap_ok(result: dict[str, object]) -> bool: + bootstrap = result.get("bootstrap") + return isinstance(bootstrap, dict) and _public_launchctl_ok(bootstrap) + + +def _public_launchctl_start_notes(result: dict[str, object], *, recovered: bool) -> list[dict[str, object]]: + bootstrap = result.get("bootstrap") + kickstart = result.get("kickstart") + if not ( + isinstance(bootstrap, dict) + and isinstance(kickstart, dict) + and _public_launchctl_ok(bootstrap) + and not _public_launchctl_ok(kickstart) + ): + return [] + note: dict[str, object] = { + "type": "warning", + "code": "launchctl_kickstart_transient_failure" if recovered else "launchctl_kickstart_failed", + "stage": "kickstart", + "returncode": kickstart.get("returncode"), + "stdout_present": bool(kickstart.get("stdout_present")), + "stderr_present": bool(kickstart.get("stderr_present")), + } + if not recovered: + note["recovered"] = False + return [note] + + +def _public_launchctl_notes(value: object) -> list[dict[str, object]]: + if not isinstance(value, list): + return [] + notes: list[dict[str, object]] = [] + allowed_codes = {"launchctl_kickstart_transient_failure", "launchctl_kickstart_failed"} + allowed_stages = {"bootout", "bootstrap", "kickstart"} + allowed_types = {"warning", "info"} + for item in value: + if not isinstance(item, dict): + continue + note_type = item.get("type") + code = item.get("code") + stage = item.get("stage") + if note_type not in allowed_types or code not in allowed_codes or stage not in allowed_stages: + continue + public_note: dict[str, object] = { + "type": note_type, + "code": code, + "stage": stage, + "returncode": item.get("returncode") if isinstance(item.get("returncode"), int) else None, + "stdout_present": item.get("stdout_present") is True, + "stderr_present": item.get("stderr_present") is True, + } + if code == "launchctl_kickstart_failed": + public_note["recovered"] = item.get("recovered") is True + notes.append(public_note) + return notes + + +def _public_connector_service_status(status: dict[str, object]) -> dict[str, object]: + health = status.get("health") if isinstance(status.get("health"), dict) else {} + public: dict[str, object] = { + "ok": status.get("ok") is True, + "ready": status.get("ready") is True, + "label": status.get("label") if isinstance(status.get("label"), str) else CONNECTOR_LABEL, + "domain": status.get("domain") if isinstance(status.get("domain"), str) else _launchctl_domain(), + "managed_by": status.get("managed_by") if isinstance(status.get("managed_by"), str) else "unknown", + "plist_path": _public_path(status.get("plist_path")) if status.get("plist_path") else None, + "plist_installed": status.get("plist_installed") is True, + "token_path": _public_path(status.get("token_path")) if status.get("token_path") else None, + "token_present": status.get("token_present") is True, + "loaded": status.get("loaded") is True, + "running": status.get("running") is True, + "tailnet_available": bool(status.get("tailnet_ip")), + "health": { + "reachable": health.get("reachable") is True, + "ready": health.get("ready") is True, + "authenticated": health.get("authenticated") is True, + "host_kind": _connector_host_kind(str(health.get("host") or "")), + }, + "owner": _public_bridge_owner_from_status(status), + "guidance": _public_connector_guidance(status), + } + _add_public_connector_evidence(public, status) + permission_target = status.get("permission_target") + if isinstance(permission_target, dict): + public["permission_target"] = redact_value(permission_target) + return public + + +def _add_public_connector_evidence(public: dict[str, object], status: dict[str, object]) -> None: + private_network = _public_private_network_evidence(status.get("private_network")) + if private_network: + public["private_network"] = private_network + bridge_runtime = _public_workbench_runtime_compatibility(status.get("bridge_runtime")) + if bridge_runtime: + public["bridge_runtime"] = bridge_runtime + + +def _public_private_network_evidence(value: object) -> dict[str, bool]: + if not isinstance(value, dict): + return {} + allowed = { + "client_installed", + "client_running", + "enrolled", + "online", + } + return {key: item for key, item in value.items() if key in allowed and type(item) is bool} + + +def _public_workbench_runtime_compatibility(value: object) -> dict[str, object]: + if not isinstance(value, dict): + return {} + if value.get("schema") != WORKBENCH_RUNTIME_SCHEMA: + return {} + if value.get("contract_version") != WORKBENCH_RUNTIME_CONTRACT_VERSION: + return {} + version = value.get("version") + if not isinstance(version, str) or not version or len(version) > 64: + return {} + if version != "unknown" and BRIDGE_RUNTIME_SEMVER_PATTERN.fullmatch(version) is None: + return {} + version_compatible = _is_workbench_runtime_version_compatible(version) + return { + "schema": WORKBENCH_RUNTIME_SCHEMA, + "contract_version": WORKBENCH_RUNTIME_CONTRACT_VERSION, + "version": version, + "version_compatible": version_compatible, + "compatible": version_compatible, + } + + +def _public_bridge_owner_from_status(status: dict[str, object]) -> dict[str, object]: + owner = status.get("owner") + if isinstance(owner, dict): + return owner + program_arguments = status.get("program_arguments") if isinstance(status.get("program_arguments"), list) else None + plist_path = Path(status["plist_path"]) if isinstance(status.get("plist_path"), str) else None + return _bridge_owner_summary( + label=str(status.get("label") or CONNECTOR_LABEL), + plist_path=plist_path, + program_arguments=program_arguments, + ready=status.get("ready") is True, + ) + + +def _bridge_owner_summary( + *, + label: str, + plist_path: Path | None, + program_arguments: list[str] | None, + ready: bool, + active_program_path: Path | None = None, +) -> dict[str, object]: + configured_program_path = Path(program_arguments[0]).expanduser() if program_arguments else None + configured_app_path = _owner_app_path(configured_program_path) + configured_bundle_id = _owner_bundle_id(configured_app_path) + configured_classification = _classify_bridge_owner( + program_path=configured_program_path, + app_path=configured_app_path, + bundle_id=configured_bundle_id, + ready=ready, + ) + active_app_path = _owner_app_path(active_program_path) + active_bundle_id = _owner_bundle_id(active_app_path) + active_classification = _classify_bridge_owner( + program_path=active_program_path, + app_path=active_app_path, + bundle_id=active_bundle_id, + ready=ready, + ) + if active_classification == "workbench_bundle" and configured_classification != "workbench_bundle": + program_path = active_program_path + elif configured_classification == "workbench_bundle" and _is_runtime_detail_owner( + active_program_path, + active_classification, + configured_classification=configured_classification, + ): + program_path = configured_program_path + elif active_program_path is not None and not _is_runtime_detail_owner( + active_program_path, + active_classification, + configured_classification=configured_classification, + ): + program_path = active_program_path + else: + program_path = configured_program_path or active_program_path + app_path = _owner_app_path(program_path) + manifest_path = _owner_manifest_path(program_path, app_path) + manifest = _read_owner_manifest(manifest_path) + bundle_id = _owner_bundle_id(app_path) + return { + "label": label, + "plist_path": _typed_public_path(plist_path), + "program_path": _typed_public_path(program_path), + "app_path": _typed_public_path(app_path), + "source_commit": _owner_source_commit(manifest), + "manifest_path": _typed_public_path(manifest_path), + "bundle_id": bundle_id, + "classification": _classify_bridge_owner(program_path=program_path, app_path=app_path, bundle_id=bundle_id, ready=ready), + } + + +def _typed_public_path(path: Path | str | None) -> dict[str, object]: + if path is None: + return {"kind": "unknown"} + public_path = _public_path(path) + if not public_path: + return {"kind": "unknown"} + return {"kind": "path", "value": public_path} + + +def _public_path(path: Path | str | object | None) -> str | None: + if path is None: + return None + return str(redact_value(str(path))) + + +def _owner_app_path(program_path: Path | None) -> Path | None: + if program_path is None: + return None + for candidate in (program_path, *program_path.parents): + if candidate.suffix == ".app": + return candidate + return None + + +def _owner_manifest_path(program_path: Path | None, app_path: Path | None) -> Path | None: + candidates: list[Path] = [] + if program_path is not None: + candidates.append(program_path.parent / "manifest.json") + if app_path is not None: + candidates.append(app_path / "Contents" / "Resources" / "Bridge" / "manifest.json") + for candidate in candidates: + if candidate.exists(): + return candidate + return None + + +def _read_owner_manifest(manifest_path: Path | None) -> dict[str, object]: + if manifest_path is None: + return {} + try: + text = _read_bounded_regular_file_text(manifest_path, max_bytes=64 * 1024) + if text is None: + return {} + payload = json.loads(text) + except Exception: + return {} + return payload if isinstance(payload, dict) else {} + + +def _owner_source_commit(manifest: dict[str, object]) -> str | None: + for key in ("source_commit", "sourceCommit", "bridge_ref", "commit_ref", "commit", "git_sha", "sha"): + value = manifest.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return None + + +def _owner_bundle_id(app_path: Path | None) -> str | None: + if app_path is None: + return None + info_plist = app_path / "Contents" / "Info.plist" + try: + data = _read_bounded_regular_file_bytes(info_plist, max_bytes=256 * 1024) + if data is None: + return None + payload = plistlib.loads(data) + except Exception: + return None + bundle_id = payload.get("CFBundleIdentifier") if isinstance(payload, dict) else None + return bundle_id if isinstance(bundle_id, str) and bundle_id.strip() else None + + +def _read_bounded_regular_file_text(path: Path, *, max_bytes: int) -> str | None: + data = _read_bounded_regular_file_bytes(path, max_bytes=max_bytes) + return data.decode("utf-8", errors="replace") if data is not None else None + + +def _read_bounded_regular_file_bytes(path: Path, *, max_bytes: int) -> bytes | None: + file_stat = path.stat() + if not stat.S_ISREG(file_stat.st_mode) or file_stat.st_size > max_bytes: + return None + return path.read_bytes() + + +def _classify_bridge_owner(*, program_path: Path | None, app_path: Path | None, bundle_id: str | None = None, ready: bool) -> str: + if program_path is None: + return "unknown" if ready else "not_running" + app_name = app_path.name.lower() if app_path is not None else "" + if app_name == "evaos workbench.app" or bundle_id in {"com.evaos.workbench", "com.electricsheephq.EvaDesktop"}: + return "workbench_bundle" + if app_name == "evaos.app": + return "legacy_bundle" + program_text = str(program_path) + if program_text == "evaos-desktop-bridge" or program_text.startswith(("/opt/homebrew/bin/", "/usr/local/bin/")): + return "global_cli" + return "unknown" + + +def _is_python_interpreter_path(path: Path) -> bool: + name = path.name.lower() + return name == "python" or name.startswith("python3") or name.startswith("python.") + + +def _is_runtime_detail_owner(path: Path | None, classification: str, *, configured_classification: str | None = None) -> bool: + if path is None: + return False + name = path.name.lower() + if _is_python_interpreter_path(path): + return True + if name in {"loginwindow", "sh", "bash", "zsh", "fish", "dash"}: + return True + return classification == "unknown" and configured_classification == "workbench_bundle" + + +def _public_connector_guidance(status: dict[str, object]) -> list[object]: + guidance = status.get("guidance") + if not isinstance(guidance, list): + return [] + return [_redact_connector_transport_text(str(item)) for item in guidance if isinstance(item, str)] + + +def _redact_connector_transport_text(value: str) -> str: + redacted = str(redact_value(value)) + redacted = re.sub(r"https?://[^\s)>\"]+", "", redacted, flags=re.IGNORECASE) + redacted = re.sub( + r"\b(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)(?::\d{1,5})?\b", + "", + redacted, + ) + return re.sub(r"\b8765\b", "", redacted) + + +def _connector_service_status(*, token: str | None = None, token_file: str | None = None, state_dir: Path | None = None) -> dict[str, object]: + domain = _launchctl_domain() + print_result = _run_launchctl(["print", f"{domain}/{CONNECTOR_LABEL}"]) + connector_token = _connector_token_value(token_file, token=token, state_dir=state_dir) + health = _connector_loopback_health(connector_token=connector_token) + token_path = _connector_token_path(token_file, state_dir=state_dir) + plist_path = _connector_plist_path() + loaded = print_result["returncode"] == 0 + reachable = health["reachable"] is True + ready = health.get("ready") is True + running = loaded and ready + tailscale_status = _tailscale_status_snapshot() + tailnet_ip = _tailscale_ip(tailscale_status) + managed_by = "launchagent" if running else "workbench-or-manual" if ready else "offline" + program_arguments = _connector_plist_program_arguments(plist_path) + active_program_path = _active_connector_process_program_path() if reachable else None + owner = _bridge_owner_summary( + label=CONNECTOR_LABEL, + plist_path=plist_path, + program_arguments=program_arguments, + ready=ready, + active_program_path=active_program_path, + ) + return { + "ok": bool(connector_token and ready), + "ready": ready, + "label": CONNECTOR_LABEL, + "domain": domain, + "managed_by": managed_by, + "plist_path": str(plist_path) if plist_path else None, + "plist_installed": plist_path is not None, + "token_path": str(token_path), + "token_present": bool(connector_token), + "loaded": loaded, + "running": running, + "tailnet_ip": tailnet_ip, + "private_network": _private_network_evidence(tailscale_status), + "bridge_runtime": _workbench_runtime_compatibility(_bridge_runtime_version()), + "health": health, + "launchctl": print_result, + "owner": owner, + "permission_target": _connector_permission_target(managed_by, health, program_arguments), + "guidance": _connector_service_guidance(plist_path, token_path.exists(), health), + } + + +def _connector_service_guidance(plist_path: Path | None, token_present: bool, health: dict[str, object]) -> list[str]: + guidance: list[str] = [] + if plist_path is None: + guidance.append("Start the connector service once to install the per-user LaunchAgent.") + if not token_present: + guidance.append("Start the connector once to mint the per-user connector token.") + if health.get("reachable") is not True: + guidance.append("Start the connector service and confirm its health endpoint responds.") + return guidance + + +def _active_connector_process_program_path() -> Path | None: + pid = _active_connector_process_pid() + if pid is None: + return None + return _process_program_path(pid) + + +def _active_connector_process_pid() -> int | None: + lsof = shutil.which("lsof") or "/usr/sbin/lsof" + try: + completed = subprocess.run( + [lsof, "-nP", f"-iTCP:{CONNECTOR_PORT}", "-sTCP:LISTEN", "-Fp"], + text=True, + capture_output=True, + timeout=2, + check=False, + ) + except Exception: + return None + if completed.returncode != 0: + return None + for line in completed.stdout.splitlines(): + if line.startswith("p"): + try: + pid = int(line[1:]) + except ValueError: + continue + if pid > 0: + return pid + return None + + +def _process_program_path(pid: int) -> Path | None: + text_path = _process_text_path(pid) + if text_path is not None: + return text_path + try: + completed = subprocess.run( + ["/bin/ps", "-p", str(pid), "-o", "comm="], + text=True, + capture_output=True, + timeout=2, + check=False, + ) + except Exception: + return None + if completed.returncode != 0: + return None + path_text = completed.stdout.strip().splitlines()[0] if completed.stdout.strip() else "" + return Path(path_text).expanduser() if path_text else None + + +def _process_text_path(pid: int) -> Path | None: + lsof = shutil.which("lsof") or "/usr/sbin/lsof" + try: + completed = subprocess.run( + [lsof, "-nP", "-p", str(pid), "-d", "txt", "-Fn"], + text=True, + capture_output=True, + timeout=2, + check=False, + ) + except Exception: + return None + if completed.returncode != 0: + return None + for line in completed.stdout.splitlines(): + if line.startswith("n"): + path_text = line[1:].strip() + if path_text: + return Path(path_text).expanduser() + return None + + +def _launchctl_start() -> dict[str, object]: + plist_path = _ensure_connector_user_plist() + domain = _launchctl_domain() + bootout = _run_launchctl(["bootout", f"{domain}/{CONNECTOR_LABEL}"]) + bootstrap = _run_launchctl(["bootstrap", domain, str(plist_path)]) + kickstart = _run_launchctl(["kickstart", "-k", f"{domain}/{CONNECTOR_LABEL}"]) + return {"bootout": bootout, "bootstrap": bootstrap, "kickstart": kickstart} + + +def _launchctl_stop() -> dict[str, object]: + return _run_launchctl(["bootout", f"{_launchctl_domain()}/{CONNECTOR_LABEL}"]) + + +def _run_launchctl(args: list[str]) -> dict[str, object]: + try: + completed = subprocess.run( + ["launchctl", *args], + text=True, + capture_output=True, + timeout=8, + check=False, + ) + return { + "returncode": completed.returncode, + "stdout": completed.stdout[-2000:], + "stderr": completed.stderr[-2000:], + } + except Exception as exc: + return {"returncode": 1, "stderr": str(exc)} + + +def _connector_loopback_health(*, connector_token: str | None = None) -> dict[str, object]: + plist_path = _connector_plist_path() + host = _connector_plist_host(plist_path) or _tailscale_ip() or "127.0.0.1" + try: + health = _connector_http_get(host, "/health") + health_json = health.get("json") if isinstance(health.get("json"), dict) else {} + reachable = health.get("status_code") == 200 and health_json.get("service") == "evaos-desktop-bridge-connector" + result: dict[str, object] = { + "reachable": reachable, + "ready": False, + "authenticated": False, + "host": host, + "port": CONNECTOR_PORT, + "status_line": health.get("status_line") if isinstance(health.get("status_line"), str) else "", + } + if not reachable: + result["error"] = "connector_identity_unverified" + return result + if not connector_token: + result["error"] = "connector_token_missing" + return result + + diagnostics = _connector_http_get(host, "/v1/diagnostics", authorization=f"Bearer {connector_token}") + diagnostics_json = diagnostics.get("json") if isinstance(diagnostics.get("json"), dict) else {} + connector = diagnostics_json.get("connector") if isinstance(diagnostics_json.get("connector"), dict) else {} + ready_payload = connector.get("ready") if isinstance(connector.get("ready"), dict) else {} + authenticated = diagnostics.get("status_code") == 200 and diagnostics_json.get("schema") == "evaos.desktop_bridge.diagnostics.v1" + ready = ( + authenticated + and ready_payload.get("schema") == "evaos.desktop_bridge.ready.v1" + and ready_payload.get("ok") is True + and ready_payload.get("ready") is True + ) + result["authenticated"] = authenticated + result["ready"] = ready + result["ready_status_line"] = diagnostics.get("status_line") if isinstance(diagnostics.get("status_line"), str) else "" + if not ready: + result["error"] = "connector_ready_probe_failed" + return result + except Exception as exc: + return {"reachable": False, "host": host, "port": CONNECTOR_PORT, "error": str(exc)} + + +def _connector_public_loopback_health() -> dict[str, object]: + plist_path = _connector_plist_path() + host = _connector_plist_host(plist_path) or _tailscale_ip() or "127.0.0.1" + try: + ready_probe = _connector_http_get(host, "/ready") + ready_json = ready_probe.get("json") if isinstance(ready_probe.get("json"), dict) else {} + if ready_json.get("schema") == "evaos.desktop_bridge.ready.v1": + ready = ready_probe.get("status_code") == 200 and ready_json.get("ok") is True and ready_json.get("ready") is True + result: dict[str, object] = { + "reachable": True, + "ready": ready, + "authenticated": False, + "host": host, + "port": CONNECTOR_PORT, + "status_line": ready_probe.get("status_line") if isinstance(ready_probe.get("status_line"), str) else "", + } + if not ready: + result["error"] = "connector_ready_probe_failed" + return result + + health = _connector_http_get(host, "/health") + health_json = health.get("json") if isinstance(health.get("json"), dict) else {} + reachable = health.get("status_code") == 200 and health_json.get("service") == "evaos-desktop-bridge-connector" + result: dict[str, object] = { + "reachable": reachable, + "ready": reachable, + "authenticated": False, + "host": host, + "port": CONNECTOR_PORT, + "status_line": health.get("status_line") if isinstance(health.get("status_line"), str) else "", + } + if not reachable: + result["error"] = "connector_identity_unverified" + return result + except Exception as exc: + return {"reachable": False, "host": host, "port": CONNECTOR_PORT, "error": str(exc)} + + +def _connector_http_get(host: str, path: str, *, authorization: str | None = None) -> dict[str, object]: + connect_host = host[1:-1] if host.startswith("[") and host.endswith("]") else host + headers = [f"GET {path} HTTP/1.1", f"Host: {host}", "Connection: close"] + if authorization: + headers.append(f"Authorization: {authorization}") + request = ("\r\n".join(headers) + "\r\n\r\n").encode("utf-8") + with socket.create_connection((connect_host, CONNECTOR_PORT), timeout=1.0) as sock: + sock.settimeout(1.0) + sock.sendall(request) + chunks: list[bytes] = [] + while True: + try: + chunk = sock.recv(4096) + except TimeoutError: + break + if not chunk: + break + chunks.append(chunk) + if sum(len(item) for item in chunks) >= 65536: + break + data = b"".join(chunks) + text = data.decode("utf-8", errors="replace") + status_line = text.splitlines()[0] if text else "" + status_code = None + parts = status_line.split() + if len(parts) >= 2: + try: + status_code = int(parts[1]) + except ValueError: + status_code = None + _, _, body = text.partition("\r\n\r\n") + parsed_json: object | None = None + if body.strip(): + try: + parsed_json = json.loads(body) + except json.JSONDecodeError: + parsed_json = None + return {"status_code": status_code, "status_line": status_line, "json": parsed_json} + + +def _connector_permission_target( + managed_by: str, + health: dict[str, object], + program_arguments: list[str] | None, +) -> dict[str, object]: + launch_program = program_arguments[0] if program_arguments else None + target_paths = { + "bridge_executable": launch_program or _connector_program_path(), + "launch_program": launch_program, + "permission_holder": "Peekaboo Bridge host or bundled Peekaboo CLI", + "automation_engine": "Peekaboo", + } + if health.get("reachable") is not True: + return { + "name": "evaOS Workbench", + "mode": "not_running", + **target_paths, + "guidance": "Start the connector from Workbench, then approve evaOS Workbench or its bridge helper in Accessibility and Screen Recording.", + } + if managed_by == "launchagent": + return { + "name": "evaOS Connector", + "mode": "launchagent", + **target_paths, + "guidance": "If Accessibility or Screen Recording is missing, approve evaOS Workbench, evaOS Connector, or the selected Peekaboo helper shown in Privacy & Security.", + } + return { + "name": "evaOS Workbench / evaOS Connector", + "mode": "workbench_managed", + **target_paths, + "guidance": "Keep Workbench open and approve evaOS Workbench, evaOS Connector, or the selected Peekaboo helper in Accessibility and Screen Recording.", + } + + +def _tailscale_ip(status_snapshot: object = _TAILSCALE_STATUS_UNSET) -> str | None: + interface_ip = _active_tailnet_interface_ip() + if interface_ip: + return interface_ip + + status_ip = _online_tailscale_status_ip(status_snapshot) + if status_ip: + return status_ip + + commands: list[list[str]] = [] + seen: set[str] = set() + for candidate in [ + shutil.which("tailscale"), + "/opt/homebrew/bin/tailscale", + "/usr/local/bin/tailscale", + "tailscale", + ]: + if not candidate or candidate in seen: + continue + seen.add(candidate) + commands.append([candidate, "ip", "-4"]) + + for command in commands: + try: + completed = subprocess.run( + command, + text=True, + capture_output=True, + timeout=3, + check=False, + ) + except Exception: + continue + if completed.returncode != 0: + continue + for line in completed.stdout.splitlines(): + value = line.strip() + if _looks_like_tailnet_ipv4(value): + return value + return None + + +def _tailscale_client_installation_state() -> bool | None: + if sys.platform != "darwin": + return None + executable_candidates = ( + *_tailscale_app_cli_candidates(), + shutil.which("tailscale"), + "/opt/homebrew/bin/tailscale", + "/usr/local/bin/tailscale", + ) + if any(candidate and Path(candidate).is_file() for candidate in executable_candidates): + return True + app_candidates = ( + Path("/Applications/Tailscale.app"), + Path.home() / "Applications/Tailscale.app", + ) + return any(path.is_dir() for path in app_candidates) + + +def _tailscale_status_snapshot() -> dict[str, object] | None: + commands: list[str] = [] + for candidate in ( + *_tailscale_app_cli_candidates(), + shutil.which("tailscale"), + "/opt/homebrew/bin/tailscale", + "/usr/local/bin/tailscale", + "tailscale", + ): + if candidate and candidate not in commands: + commands.append(candidate) + for command in commands: + try: + completed = subprocess.run( + [command, "status", "--json"], + text=True, + capture_output=True, + timeout=3, + check=False, + ) + except Exception: + continue + if completed.returncode != 0: + continue + try: + payload = json.loads(completed.stdout) + except (json.JSONDecodeError, TypeError): + continue + if isinstance(payload, dict): + return payload + return None + + +def _tailscale_app_cli_candidates() -> tuple[str, str]: + return ( + "/Applications/Tailscale.app/Contents/MacOS/Tailscale", + str(Path.home() / "Applications/Tailscale.app/Contents/MacOS/Tailscale"), + ) + + +def _private_network_evidence(status_snapshot: object = _TAILSCALE_STATUS_UNSET) -> dict[str, bool]: + evidence: dict[str, bool] = {} + client_installed = _tailscale_client_installation_state() + if client_installed is not None: + evidence["client_installed"] = client_installed + + status = _tailscale_status_snapshot() if status_snapshot is _TAILSCALE_STATUS_UNSET else status_snapshot + if not isinstance(status, dict): + return evidence + evidence["client_installed"] = True + + backend_state = status.get("BackendState") + if backend_state == "Running": + evidence["client_running"] = True + elif backend_state in {"Stopped", "NeedsLogin", "NoState"}: + evidence["client_running"] = False + if backend_state == "NeedsLogin": + evidence["enrolled"] = False + + self_node = status.get("Self") + if isinstance(self_node, dict): + online = self_node.get("Online") + if online is False: + evidence["online"] = False + elif online is True and backend_state == "Running": + evidence["online"] = True + user_id = self_node.get("UserID") + if user_id not in (None, "", 0) and evidence.get("enrolled") is not False: + evidence["enrolled"] = True + return evidence + + +def _bridge_runtime_version() -> str: + try: + from importlib.metadata import version + + return version("evaos-desktop-bridge") + except Exception: + try: + from . import __version__ + + return __version__ + except Exception: + return "unknown" + + +def _workbench_runtime_compatibility(version: str) -> dict[str, object]: + normalized = version.strip() if isinstance(version, str) else "unknown" + if not normalized: + normalized = "unknown" + version_compatible = _is_workbench_runtime_version_compatible(normalized) + return { + "schema": WORKBENCH_RUNTIME_SCHEMA, + "contract_version": WORKBENCH_RUNTIME_CONTRACT_VERSION, + "version": normalized[:64], + "version_compatible": version_compatible, + "compatible": version_compatible, + } + + +def _active_tailnet_interface_ip() -> str | None: + try: + completed = subprocess.run( + ["/sbin/ifconfig"], + text=True, + capture_output=True, + timeout=3, + check=False, + ) + except Exception: + return None + if completed.returncode != 0: + return None + for line in completed.stdout.splitlines(): + parts = line.strip().split() + if len(parts) >= 2 and parts[0] == "inet" and _looks_like_tailnet_ipv4(parts[1]): + return parts[1] + return None + + +def _is_workbench_runtime_version_compatible(version: str) -> bool: + match = BRIDGE_RUNTIME_SEMVER_PATTERN.fullmatch(version) + if match is None: + return False + release = tuple(int(match.group(name)) for name in ("major", "minor", "patch")) + return release >= MINIMUM_WORKBENCH_RUNTIME_VERSION + + +def _online_tailscale_status_ip(status_snapshot: object = _TAILSCALE_STATUS_UNSET) -> str | None: + payload = _tailscale_status_snapshot() if status_snapshot is _TAILSCALE_STATUS_UNSET else status_snapshot + if not isinstance(payload, dict): + return None + if payload.get("BackendState") != "Running": + return None + self_node = payload.get("Self") + if not isinstance(self_node, dict) or self_node.get("Online") is not True: + return None + addresses = self_node.get("TailscaleIPs") + if not isinstance(addresses, list): + return None + for value in addresses: + if isinstance(value, str) and _looks_like_tailnet_ipv4(value): + return value + return None + + +def _looks_like_tailnet_ipv4(value: str) -> bool: + try: + address = ipaddress.ip_address(value) + except ValueError: + return False + if address.version != 4: + return False + return address in ipaddress.ip_network("100.64.0.0/10") + + +def _connector_plist_path() -> Path | None: + if CONNECTOR_USER_PLIST.exists(): + return CONNECTOR_USER_PLIST + if CONNECTOR_SYSTEM_PLIST.exists(): + return CONNECTOR_SYSTEM_PLIST + return None + + +def _ensure_connector_user_plist() -> Path: + host = os.environ.get("EVAOS_DESKTOP_BRIDGE_CONNECTOR_HOST") or _tailscale_ip() or "127.0.0.1" + program = _connector_program_path() + payload = _connector_plist_payload(program=program, host=host) + CONNECTOR_USER_PLIST.parent.mkdir(parents=True, exist_ok=True) + current: dict[str, object] | None = None + if CONNECTOR_USER_PLIST.exists(): + try: + current = plistlib.loads(CONNECTOR_USER_PLIST.read_bytes()) + except Exception: + current = None + if current != payload: + CONNECTOR_USER_PLIST.write_bytes(plistlib.dumps(payload, sort_keys=False)) + return CONNECTOR_USER_PLIST + + +def _connector_program_path() -> str: + argv0 = Path(sys.argv[0]).expanduser() + if argv0.exists(): + packaged_launcher = argv0.parent.parent.parent / "evaos-desktop-bridge" + if ( + argv0.name == "cli.py" + and argv0.parent.name == "evaos_desktop_bridge" + and argv0.parent.parent.name == "src" + and packaged_launcher.exists() + ): + return str(packaged_launcher.resolve()) + if argv0.name != "cli.py" or os.access(argv0, os.X_OK): + return str(argv0.resolve()) + resolved = shutil.which("evaos-desktop-bridge") + if resolved: + return resolved + return "evaos-desktop-bridge" + + +def _connector_plist_payload(*, program: str, host: str) -> dict[str, object]: + return { + "Label": CONNECTOR_LABEL, + "ProgramArguments": [ + program, + "serve", + "--host", + host, + "--port", + str(CONNECTOR_PORT), + ], + "RunAtLoad": True, + "KeepAlive": True, + "EnvironmentVariables": { + "EVAOS_DESKTOP_BRIDGE_MODE": "customer-mac-connector", + }, + } + + +def _connector_plist_host(plist_path: Path | None) -> str | None: + argv = _connector_plist_program_arguments(plist_path) + if not argv: + return None + try: + host_index = argv.index("--host") + 1 + except ValueError: + return None + if host_index >= len(argv): + return None + host = argv[host_index] + return host if isinstance(host, str) and host else None + + +def _connector_plist_program_arguments(plist_path: Path | None) -> list[str] | None: + if plist_path is None: + return None + try: + payload = plistlib.loads(plist_path.read_bytes()) + except Exception: + return None + argv = payload.get("ProgramArguments") + if not isinstance(argv, list): + return None + return [item for item in argv if isinstance(item, str)] + + +def _launchctl_domain() -> str: + return f"gui/{os.getuid()}" + + +if __name__ == "__main__": + entrypoint() diff --git a/resources/evaos-beta/bridge/src/evaos_desktop_bridge/connector_server.py b/resources/evaos-beta/bridge/src/evaos_desktop_bridge/connector_server.py new file mode 100644 index 0000000000..e0ef3d2472 --- /dev/null +++ b/resources/evaos-beta/bridge/src/evaos_desktop_bridge/connector_server.py @@ -0,0 +1,1374 @@ +from __future__ import annotations + +import json +import hashlib +import errno +import ipaddress +import os +import re +import secrets +import socket +import sys +import tempfile +import time +import urllib.request +from collections import deque +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any, Callable +from urllib.parse import urlparse + +from .audit import default_state_dir +from .schema import build_envelope, make_error +from .state import approval_audit_freshness_error, read_audit_record, read_control_session + +CommandRunner = Callable[[list[str]], tuple[int, str]] +OwnerProvider = Callable[[], dict[str, Any] | None] + +DIAGNOSTICS_SCHEMA = "evaos.desktop_bridge.diagnostics.v1" +READY_SCHEMA = "evaos.desktop_bridge.ready.v1" +SERVICE_EVENTS_FILE = "connector-service-events.jsonl" +MAX_SERVICE_EVENTS = 40 +URL_PATTERN = re.compile(r"https?://[^\s)>\"]+", re.IGNORECASE) +AUTH_HEADER_PATTERN = re.compile(r"(?i)(authorization\s*:\s*)(?:bearer\s+|basic\s+)?[A-Za-z0-9._~+/=-]+") +BEARER_TOKEN_PATTERN = re.compile(r"(?i)(bearer\s+)[A-Za-z0-9._~+/=-]{8,}") +SECRET_WORD_PATTERN = re.compile( + r"(?i)\b(?:api[_-]?key|auth|authorization|password|secret|token)[A-Za-z0-9_.-]*(?:\s*[:=]\s*|\s+)[A-Za-z0-9._~+/=-]{8,}" +) +IPV4_PATTERN = re.compile( + r"\b(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)(?::\d{1,5})?\b" +) + +CUSTOMER_MAC_CONTROL_URL = os.environ.get( + "EVAOS_CUSTOMER_MAC_CONTROL_URL", + "https://rhfojelkgtwcxnrfhtlj.supabase.co/functions/v1/customer-mac-control", +) + +GUARDED_REMOTE_COMMANDS = frozenset( + { + "codexSelectThread", + "codexSendVisibleMessage", + "codexContinueThread", + "customerMacAppFocus", + "customerMacLocalSiteOpen", + "customerMacLocalSiteAction", + "customerMacIphoneMirroringFocus", + "customerMacIphoneMirroringHome", + "customerMacIphoneMirroringAppSwitcher", + "customerMacIphoneMirroringSpotlight", + "customerMacIphoneMirroringTypeSpotlight", + "customerMacIphoneMirroringOpenApp", + "customerMacIphoneMirroringTapNamedTarget", + "customerMacIphoneMirroringScroll", + "customerMacIphoneMirroringSwipeLeft", + "customerMacIphoneMirroringSwipeRight", + "customerMacIphoneMirroringSwipeUp", + "customerMacIphoneMirroringSwipeDown", + "customerMacIphoneMirroringTypeApprovedText", + "customerMacIphoneMirroringSendApprovedMessage", + } +) + +CODEX_REMOTE_CONTROL_COMMANDS = frozenset() + +CODEX_REMOTE_CONTROL_APPROVAL: dict[str, tuple[str, tuple[str, ...]]] = {} + +CONTROLLED_REMOTE_COMMANDS = frozenset( + { + "customerMacIphoneMirroringFocus", + "customerMacIphoneMirroringHome", + "customerMacIphoneMirroringAppSwitcher", + "customerMacIphoneMirroringSpotlight", + "customerMacIphoneMirroringTypeSpotlight", + "customerMacIphoneMirroringOpenApp", + "customerMacIphoneMirroringTapNamedTarget", + "customerMacIphoneMirroringScroll", + "customerMacIphoneMirroringSwipeLeft", + "customerMacIphoneMirroringSwipeRight", + "customerMacIphoneMirroringSwipeUp", + "customerMacIphoneMirroringSwipeDown", + "customerMacIphoneMirroringTypeApprovedText", + "customerMacIphoneMirroringSendApprovedMessage", + "desktopClick", + "desktopType", + "desktopSetValue", + "desktopScroll", + "desktopDrag", + "desktopHotkey", + "desktopFocusApp", + "desktopWindow", + "desktopMenu", + "desktopBrowserAction", + "iphoneTap", + "iphoneSwipe", + "iphoneType", + } +) + +TAKEOVER_WARNING_REMOTE_COMMANDS = CONTROLLED_REMOTE_COMMANDS | frozenset( + { + "customerMacAppFocus", + "customerMacLocalSiteOpen", + "customerMacLocalSiteAction", + } +) + +GUARDED_REMOTE_COMMANDS = GUARDED_REMOTE_COMMANDS | CONTROLLED_REMOTE_COMMANDS + +KILL_SWITCH_REMOTE_COMMAND_ALLOWLIST = frozenset( + { + "customerMacControlStatus", + "customerMacControlKillSwitch", + } +) + +ASK_PERMISSION_HIGH_IMPACT_REMOTE_COMMANDS = frozenset( + { + "customerMacIphoneMirroringSwipeLeft", + "customerMacIphoneMirroringSwipeRight", + "customerMacIphoneMirroringSwipeUp", + "customerMacIphoneMirroringSwipeDown", + "customerMacIphoneMirroringTypeApprovedText", + "customerMacIphoneMirroringSendApprovedMessage", + "desktopSetValue", + "desktopType", + "iphoneSwipe", + "iphoneType", + } +) + +ASK_PERMISSION_RISK_WORDS = frozenset( + { + "approve", + "buy", + "confirm", + "delete", + "dispatch", + "like", + "match", + "pay", + "post", + "purchase", + "remove", + "send", + "submit", + "transfer", + "unlike", + } +) + +ASK_PERMISSION_SAFE_HOTKEYS = frozenset( + { + "cmd+[", + "cmd+]", + "cmd+l", + "cmd+r", + "cmd+t", + "escape", + "tab", + } +) + +CONNECTOR_COMMAND_ALIASES = { + "desktop_bridge_status": "status", + "desktop_bridge_capabilities": "capabilities", + "desktop_bridge_latest": "latest", + "desktop_bridge_audit_tail": "auditTail", + "desktop_bridge_queue_list": "queueList", + "desktop_bridge_queue_append": "queueAppend", + "desktop_bridge_codex_frontmost": "codexFrontmost", + "desktop_bridge_codex_windows": "codexWindows", + "desktop_bridge_codex_threads": "codexThreads", + "desktop_bridge_codex_thread_map": "codexThreadMap", + "desktop_bridge_codex_send_visible_message": "codexSendVisibleMessage", + "desktop_bridge_codex_continue_thread": "codexContinueThread", + "desktop_bridge_codex_select_thread": "codexSelectThread", + "desktop_bridge_codex_snapshot": "codexSnapshot", + "desktop_bridge_codex_inspect": "codexInspect", + "desktop_bridge_codex_ax_tree": "codexAxTree", + "desktop_bridge_codex_app_server_status": "codexAppServerStatus", + "desktop_bridge_codex_app_server_remote_control_status": "codexAppServerRemoteControlStatus", + "desktop_bridge_codex_app_server_threads": "codexAppServerThreads", + "desktop_bridge_codex_connections_status": "codexConnectionsStatus", + "desktop_bridge_codex_app_server_loaded_threads": "codexAppServerLoadedThreads", + "desktop_bridge_codex_live_status": "codexLiveStatus", + "customer_mac_status": "customerMacStatus", + "customer_mac_capabilities": "customerMacCapabilities", + "customer_mac_snapshot": "customerMacSnapshot", + "customer_mac_ax_tree": "customerMacAxTree", + "customer_mac_app_focus": "customerMacAppFocus", + "customer_mac_local_site_open": "customerMacLocalSiteOpen", + "customer_mac_local_site_action": "customerMacLocalSiteAction", + "customer_mac_iphone_mirroring_status": "customerMacIphoneMirroringStatus", + "customer_mac_iphone_mirroring_focus": "customerMacIphoneMirroringFocus", + "customer_mac_iphone_mirroring_home": "customerMacIphoneMirroringHome", + "customer_mac_iphone_mirroring_app_switcher": "customerMacIphoneMirroringAppSwitcher", + "customer_mac_iphone_mirroring_spotlight": "customerMacIphoneMirroringSpotlight", + "customer_mac_iphone_mirroring_type_spotlight": "customerMacIphoneMirroringTypeSpotlight", + "customer_mac_iphone_mirroring_open_app": "customerMacIphoneMirroringOpenApp", + "customer_mac_iphone_mirroring_tap_named_target": "customerMacIphoneMirroringTapNamedTarget", + "customer_mac_iphone_mirroring_scroll": "customerMacIphoneMirroringScroll", + "customer_mac_iphone_mirroring_swipe_left": "customerMacIphoneMirroringSwipeLeft", + "customer_mac_iphone_mirroring_swipe_right": "customerMacIphoneMirroringSwipeRight", + "customer_mac_iphone_mirroring_swipe_up": "customerMacIphoneMirroringSwipeUp", + "customer_mac_iphone_mirroring_swipe_down": "customerMacIphoneMirroringSwipeDown", + "customer_mac_iphone_mirroring_type_approved_text": "customerMacIphoneMirroringTypeApprovedText", + "customer_mac_iphone_mirroring_send_approved_message": "customerMacIphoneMirroringSendApprovedMessage", + "customer_mac_screen_sharing_status": "customerMacScreenSharingStatus", + "desktop_control_status": "customerMacControlStatus", + "desktop_control_start": "customerMacControlStart", + "desktop_control_stop": "customerMacControlStop", + "desktop_kill_switch": "customerMacControlKillSwitch", + "desktop_see": "desktopSee", + "desktop_click": "desktopClick", + "desktop_type": "desktopType", + "desktop_set_value": "desktopSetValue", + "desktop_scroll": "desktopScroll", + "desktop_drag": "desktopDrag", + "desktop_hotkey": "desktopHotkey", + "desktop_focus_app": "desktopFocusApp", + "desktop_window": "desktopWindow", + "desktop_menu": "desktopMenu", + "desktop_browser_action": "desktopBrowserAction", + "iphone_see": "iphoneSee", + "iphone_tap": "iphoneTap", + "iphone_swipe": "iphoneSwipe", + "iphone_type": "iphoneType", +} + + +def normalize_connector_command(command: str) -> str: + return CONNECTOR_COMMAND_ALIASES.get(command, command) + + +CONNECTOR_COMMAND_APPROVAL: dict[str, tuple[str, tuple[str, ...]]] = { + "codexSelectThread": ("codex.select_thread", ("thread_id",)), + "codexSendVisibleMessage": ("codex.send_visible_message", ("thread_id", "message_hash")), + "codexContinueThread": ("codex.continue_thread", ("title", "prompt")), + "customerMacAppFocus": ("customer_mac.app_focus", ("app_name",)), + "customerMacLocalSiteOpen": ("customer_mac.local_site_open", ("url",)), + "customerMacLocalSiteAction": ("customer_mac.local_site_action", ("action",)), + "customerMacIphoneMirroringFocus": ("customer_mac.iphone_mirroring_focus", ()), + "customerMacIphoneMirroringHome": ("customer_mac.iphone_mirroring_home", ()), + "customerMacIphoneMirroringAppSwitcher": ("customer_mac.iphone_mirroring_app_switcher", ()), + "customerMacIphoneMirroringSpotlight": ("customer_mac.iphone_mirroring_spotlight", ()), + "customerMacIphoneMirroringTypeSpotlight": ("customer_mac.iphone_mirroring_type_spotlight", ("text",)), + "customerMacIphoneMirroringOpenApp": ("customer_mac.iphone_mirroring_open_app", ("app_name",)), + "customerMacIphoneMirroringTapNamedTarget": ("customer_mac.iphone_mirroring_tap_named_target", ("target_label",)), + "customerMacIphoneMirroringScroll": ("customer_mac.iphone_mirroring_scroll", ("direction",)), + "customerMacIphoneMirroringSwipeLeft": ("customer_mac.iphone_mirroring_swipe_left", ()), + "customerMacIphoneMirroringSwipeRight": ("customer_mac.iphone_mirroring_swipe_right", ()), + "customerMacIphoneMirroringSwipeUp": ("customer_mac.iphone_mirroring_swipe_up", ()), + "customerMacIphoneMirroringSwipeDown": ("customer_mac.iphone_mirroring_swipe_down", ()), + "customerMacIphoneMirroringTypeApprovedText": ("customer_mac.iphone_mirroring_type_approved_text", ("text",)), + "customerMacIphoneMirroringSendApprovedMessage": ("customer_mac.iphone_mirroring_send_approved_message", ("text", "recipient_context", "target_label")), + "desktopClick": ("customer_mac.desktop_click", ("snapshot_id", "element_id", "target_label", "x", "y")), + "desktopType": ("customer_mac.desktop_type", ("text",)), + "desktopSetValue": ("customer_mac.desktop_set_value", ("snapshot_id", "element_id", "attribute", "value_hash")), + "desktopScroll": ("customer_mac.desktop_scroll", ("direction", "amount")), + "desktopDrag": ("customer_mac.desktop_drag", ("from_x", "from_y", "to_x", "to_y")), + "desktopHotkey": ("customer_mac.desktop_hotkey", ("keys",)), + "desktopFocusApp": ("customer_mac.desktop_focus_app", ("app_name",)), + "desktopWindow": ("customer_mac.desktop_window", ("action",)), + "desktopMenu": ("customer_mac.desktop_menu", ("menu_path",)), + "desktopBrowserAction": ("customer_mac.desktop_browser_action", ("action", "url")), + "iphoneTap": ("customer_mac.iphone_tap", ("snapshot_id", "element_id", "target_label", "x", "y")), + "iphoneSwipe": ("customer_mac.iphone_swipe", ("direction",)), + "iphoneType": ("customer_mac.iphone_type", ("text",)), +} + + +def build_bridge_argv(command: str, params: dict[str, Any] | None = None) -> list[str]: + command = normalize_connector_command(command) + params = params or {} + fixed: dict[str, list[str]] = { + "status": ["status", "--json"], + "capabilities": ["capabilities", "--json"], + "latest": ["latest", "--json"], + "codexFrontmost": ["codex", "frontmost", "--json"], + "codexWindows": ["codex", "windows", "--json"], + "codexConnectionsStatus": ["codex", "connections", "status", "--json"], + "codexAppServerStatus": ["codex", "app-server", "status", "--json"], + "codexAppServerRemoteControlStatus": ["codex", "app-server", "remote-control-status", "--json"], + "customerMacStatus": ["customer-mac", "status", "--json"], + "customerMacCapabilities": ["customer-mac", "capabilities", "--json"], + "customerMacControlStatus": ["customer-mac", "control", "status", "--json"], + "customerMacControlStop": ["customer-mac", "control", "stop", "--json"], + "customerMacControlKillSwitch": ["customer-mac", "control", "kill-switch", "--json"], + "customerMacIphoneMirroringStatus": ["customer-mac", "iphone-mirroring", "status", "--json"], + "customerMacScreenSharingStatus": ["customer-mac", "screen-sharing", "status", "--json"], + } + if command in fixed: + return fixed[command] + if command == "auditTail": + return ["audit-tail", "--json", "--limit", str(_clamp_int(params.get("limit"), 20, 1, 100))] + if command == "queueList": + return ["queue", "list", "--json", "--limit", str(_clamp_int(params.get("limit"), 20, 1, 100))] + if command == "queueAppend": + argv = [ + "queue", + "append", + "--json", + "--kind", + _required_string(params, "kind"), + "--source-audit-id", + _required_string(params, "source_audit_id"), + ] + if params.get("message"): + argv.extend(["--message", str(params["message"])]) + return argv + if command == "codexThreads": + return ["codex", "threads", "--json", "--max-items", str(_clamp_int(params.get("max_items"), 50, 1, 200))] + if command == "codexThreadMap": + return ["codex", "thread-map", "--json", "--max-items", str(_clamp_int(params.get("max_items"), 50, 1, 200))] + if command == "codexSelectThread": + return [ + "codex", + "select-thread", + "--json", + "--thread-id", + _required_string(params, "thread_id"), + *_dry_run_arg(params), + *_approval_arg(params), + ] + if command == "codexContinueThread": + return [ + "codex", + "continue-thread", + "--json", + "--title", + _required_string(params, "title"), + "--prompt", + str(params.get("prompt") or "continue"), + *_dry_run_arg(params), + *_approval_arg(params), + ] + if command == "codexSendVisibleMessage": + argv = [ + "codex", + "send-visible-message", + "--json", + "--thread-id", + _required_string(params, "thread_id"), + ] + message_file = params.get("message_file") + if isinstance(message_file, str) and message_file.strip(): + if params.get("_prepared_message_file") is not True: + raise ValueError("message_file is reserved for connector internals; provide message.") + argv.extend(["--message-file", message_file.strip()]) + else: + argv.extend(["--message", _required_string(params, "message")]) + if params.get("dry_run") is False: + argv.append("--live") + if params.get("confirm") is True: + argv.append("--confirm") + else: + argv.append("--dry-run") + argv.extend(_approval_arg(params)) + if params.get("wait_ms") is not None: + argv.extend(["--wait-ms", str(_clamp_int(params.get("wait_ms"), 0, 0, 120_000))]) + if params.get("poll_interval_ms") is not None: + argv.extend(["--poll-interval-ms", str(_clamp_int(params.get("poll_interval_ms"), 2000, 250, 10_000))]) + return argv + if command == "codexSnapshot": + return ["codex", "snapshot", "--json", "--max-chars", str(_clamp_int(params.get("max_chars"), 4000, 1, 20000))] + if command == "codexInspect": + return ["codex", "inspect", "--json", "--max-nodes", str(_clamp_int(params.get("max_nodes"), 120, 1, 1000))] + if command == "codexAxTree": + return ["codex", "ax-tree", "--json", "--max-nodes", str(_clamp_int(params.get("max_nodes"), 200, 1, 1000))] + if command == "codexAppServerThreads": + return ["codex", "app-server", "threads", "--json", "--max-items", str(_clamp_int(params.get("max_items"), 50, 1, 200))] + if command == "codexAppServerLoadedThreads": + return ["codex", "app-server", "loaded-threads", "--json", "--max-items", str(_clamp_int(params.get("max_items"), 50, 1, 200))] + if command == "codexLiveStatus": + return [ + "codex", + "app-server", + "subscribe", + "--json", + "--thread-id", + _required_string(params, "thread_id"), + "--duration-ms", + str(_clamp_int(params.get("duration_ms"), 1000, 1, 30000)), + ] + if command == "customerMacSnapshot": + return ["customer-mac", "snapshot", "--json", "--max-chars", str(_clamp_int(params.get("max_chars"), 4000, 1, 20000))] + if command == "customerMacAxTree": + return ["customer-mac", "ax-tree", "--json", "--max-nodes", str(_clamp_int(params.get("max_nodes"), 200, 1, 1000))] + if command == "customerMacControlStart": + return ["customer-mac", "control", "start", "--json", "--mode", str(params.get("mode") or "full-access"), *(_optional_string_arg(params, "agent_label", "--agent-label"))] + if command == "desktopSee": + return [ + "customer-mac", + "desktop", + "see", + "--json", + "--max-chars", + str(_clamp_int(params.get("max_chars"), 4000, 1, 20000)), + "--max-nodes", + str(_clamp_int(params.get("max_nodes"), 200, 1, 1000)), + ] + if command == "desktopClick": + argv = ["customer-mac", "desktop", "click", "--json", *_dry_run_arg(params), *_approval_arg(params)] + argv.extend(_optional_string_arg(params, "snapshot_id", "--snapshot-id")) + argv.extend(_optional_string_arg(params, "element_id", "--element-id")) + argv.extend(_optional_string_arg(params, "target_label", "--target-label")) + argv.extend(_optional_int_arg(params, "x", "--x")) + argv.extend(_optional_int_arg(params, "y", "--y")) + return argv + if command == "desktopType": + return ["customer-mac", "desktop", "type", "--json", "--text", _required_string(params, "text"), *_dry_run_arg(params), *_approval_arg(params)] + if command == "desktopSetValue": + argv = [ + "customer-mac", + "desktop", + "set-value", + "--json", + "--snapshot-id", + _required_string(params, "snapshot_id"), + "--element-id", + _required_string(params, "element_id"), + ] + value_file = params.get("value_file") + if isinstance(value_file, str) and value_file.strip(): + if params.get("_prepared_value_file") is not True: + raise ValueError("value_file is reserved for connector internals; provide value.") + argv.extend(["--value-file", value_file.strip()]) + else: + raise ValueError("desktopSetValue value must be materialized before building CLI argv.") + argv.extend(["--attribute", str(params.get("attribute") or "value")]) + argv.extend(_dry_run_arg(params)) + argv.extend(_approval_arg(params)) + return argv + if command == "desktopScroll": + return ["customer-mac", "desktop", "scroll", "--json", "--direction", str(params.get("direction") or "down"), "--amount", str(_clamp_int(params.get("amount"), 600, 1, 5000)), *_dry_run_arg(params), *_approval_arg(params)] + if command == "desktopDrag": + return [ + "customer-mac", + "desktop", + "drag", + "--json", + "--from-x", + str(_required_int(params, "from_x")), + "--from-y", + str(_required_int(params, "from_y")), + "--to-x", + str(_required_int(params, "to_x")), + "--to-y", + str(_required_int(params, "to_y")), + *_dry_run_arg(params), + *_approval_arg(params), + ] + if command == "desktopHotkey": + return ["customer-mac", "desktop", "hotkey", "--json", "--keys", _required_string(params, "keys"), *_dry_run_arg(params), *_approval_arg(params)] + if command == "desktopFocusApp": + return ["customer-mac", "desktop", "focus-app", "--json", "--app-name", _required_string(params, "app_name"), *_dry_run_arg(params), *_approval_arg(params)] + if command == "desktopWindow": + return ["customer-mac", "desktop", "window", "--json", "--action", _required_string(params, "action"), *_dry_run_arg(params), *_approval_arg(params)] + if command == "desktopMenu": + return ["customer-mac", "desktop", "menu", "--json", "--menu-path", _required_string(params, "menu_path"), *_dry_run_arg(params), *_approval_arg(params)] + if command == "desktopBrowserAction": + argv = ["customer-mac", "desktop", "browser-action", "--json", "--action", _required_string(params, "action"), *_dry_run_arg(params), *_approval_arg(params)] + argv.extend(_optional_string_arg(params, "url", "--url")) + return argv + if command == "customerMacAppFocus": + return ["customer-mac", "app-focus", "--json", "--app-name", _required_string(params, "app_name"), *_dry_run_arg(params), *_approval_arg(params)] + if command == "customerMacLocalSiteOpen": + return ["customer-mac", "local-site", "open", "--json", "--url", _required_string(params, "url"), *_dry_run_arg(params), *_approval_arg(params)] + if command == "customerMacLocalSiteAction": + return ["customer-mac", "local-site", "action", "--json", "--action", _required_string(params, "action"), *_dry_run_arg(params), *_approval_arg(params)] + if command == "customerMacIphoneMirroringFocus": + return ["customer-mac", "iphone-mirroring", "focus", "--json", *_dry_run_arg(params), *_approval_arg(params)] + if command == "iphoneSee": + return [ + "customer-mac", + "iphone-mirroring", + "see", + "--json", + "--max-chars", + str(_clamp_int(params.get("max_chars"), 4000, 1, 20000)), + "--max-nodes", + str(_clamp_int(params.get("max_nodes"), 200, 1, 1000)), + ] + if command == "iphoneTap": + argv = ["customer-mac", "iphone-mirroring", "tap", "--json", *_dry_run_arg(params), *_approval_arg(params)] + argv.extend(_optional_string_arg(params, "snapshot_id", "--snapshot-id")) + argv.extend(_optional_string_arg(params, "element_id", "--element-id")) + argv.extend(_optional_string_arg(params, "target_label", "--target-label")) + argv.extend(_optional_int_arg(params, "x", "--x")) + argv.extend(_optional_int_arg(params, "y", "--y")) + return argv + if command == "iphoneSwipe": + return ["customer-mac", "iphone-mirroring", "swipe", "--json", "--direction", _required_string(params, "direction"), *_dry_run_arg(params), *_approval_arg(params)] + if command == "iphoneType": + return ["customer-mac", "iphone-mirroring", "type", "--json", "--text", _required_string(params, "text"), *_dry_run_arg(params), *_approval_arg(params)] + if command == "customerMacIphoneMirroringHome": + return ["customer-mac", "iphone-mirroring", "home", "--json", *_dry_run_arg(params), *_approval_arg(params)] + if command == "customerMacIphoneMirroringAppSwitcher": + return ["customer-mac", "iphone-mirroring", "app-switcher", "--json", *_dry_run_arg(params), *_approval_arg(params)] + if command == "customerMacIphoneMirroringSpotlight": + return ["customer-mac", "iphone-mirroring", "spotlight", "--json", *_dry_run_arg(params), *_approval_arg(params)] + if command == "customerMacIphoneMirroringTypeSpotlight": + return ["customer-mac", "iphone-mirroring", "type-spotlight", "--json", "--text", _required_string(params, "text"), *_dry_run_arg(params), *_approval_arg(params)] + if command == "customerMacIphoneMirroringOpenApp": + return ["customer-mac", "iphone-mirroring", "open-app", "--json", "--app-name", _required_string(params, "app_name"), *_dry_run_arg(params), *_approval_arg(params)] + if command == "customerMacIphoneMirroringTapNamedTarget": + return [ + "customer-mac", + "iphone-mirroring", + "tap-named-target", + "--json", + "--target-label", + _required_string(params, "target_label"), + *_dry_run_arg(params), + *_approval_arg(params), + ] + if command == "customerMacIphoneMirroringScroll": + return ["customer-mac", "iphone-mirroring", "scroll", "--json", "--direction", str(params.get("direction") or "down"), *_dry_run_arg(params), *_approval_arg(params)] + if command == "customerMacIphoneMirroringSwipeLeft": + return ["customer-mac", "iphone-mirroring", "swipe-left", "--json", *_dry_run_arg(params), *_approval_arg(params)] + if command == "customerMacIphoneMirroringSwipeRight": + return ["customer-mac", "iphone-mirroring", "swipe-right", "--json", *_dry_run_arg(params), *_approval_arg(params)] + if command == "customerMacIphoneMirroringSwipeUp": + return ["customer-mac", "iphone-mirroring", "swipe-up", "--json", *_dry_run_arg(params), *_approval_arg(params)] + if command == "customerMacIphoneMirroringSwipeDown": + return ["customer-mac", "iphone-mirroring", "swipe-down", "--json", *_dry_run_arg(params), *_approval_arg(params)] + if command == "customerMacIphoneMirroringTypeApprovedText": + return ["customer-mac", "iphone-mirroring", "type-approved-text", "--json", "--text", _required_string(params, "text"), *_dry_run_arg(params), *_approval_arg(params)] + if command == "customerMacIphoneMirroringSendApprovedMessage": + return [ + "customer-mac", + "iphone-mirroring", + "send-approved-message", + "--json", + "--text", + _required_string(params, "text"), + "--recipient-context", + _required_string(params, "recipient_context"), + "--target-label", + str(params.get("target_label") or "Send"), + *_dry_run_arg(params), + *_approval_arg(params), + ] + raise ValueError(f"Unsupported connector command: {command}") + + +def _prepare_connector_params(command: str, params: dict[str, Any], *, state_dir: Path | None) -> tuple[dict[str, Any], list[Path]]: + command = normalize_connector_command(command) + if command not in {"codexSendVisibleMessage", "desktopSetValue"}: + return params, [] + if command == "codexSendVisibleMessage": + if isinstance(params.get("message_file"), str) and params.get("message_file", "").strip(): + raise ValueError("message_file is reserved for connector internals; provide message.") + if not isinstance(params.get("message"), str): + raise ValueError("message is required") + payload_key = "message" + file_key = "message_file" + prepared_flag = "_prepared_message_file" + prefix = "codex-visible-message-" + else: + if isinstance(params.get("value_file"), str) and params.get("value_file", "").strip(): + raise ValueError("value_file is reserved for connector internals; provide value.") + if not isinstance(params.get("value"), str): + raise ValueError("value is required") + payload_key = "value" + file_key = "value_file" + prepared_flag = "_prepared_value_file" + prefix = "desktop-set-value-" + root = (state_dir or default_state_dir()) / "tmp" + root.mkdir(parents=True, exist_ok=True) + fd, path_text = tempfile.mkstemp(prefix=prefix, suffix=".txt", dir=str(root), text=True) + path = Path(path_text) + try: + try: + os.write(fd, str(params[payload_key]).encode("utf-8")) + finally: + os.close(fd) + os.chmod(path, 0o600) + except Exception: + path.unlink(missing_ok=True) + raise + prepared = dict(params) + prepared.pop(payload_key, None) + prepared[file_key] = str(path) + prepared[prepared_flag] = True + return prepared, [path] + + +def run_connector_server( + *, + host: str, + port: int, + token: str | None, + command_runner: CommandRunner, + state_dir: Path | None = None, + owner_provider: OwnerProvider | None = None, +) -> None: + record_service_event( + "serve_starting", + "info", + "Connector HTTP server is starting.", + state_dir=state_dir, + details={"host": host, "port": port, "token_state": _token_state(token)}, + ) + if not token: + record_service_event( + "token_missing", + "blocker", + "Connector token is missing; command and enrollment endpoints fail closed.", + state_dir=state_dir, + details={"host": host, "port": port}, + ) + handler = _make_handler(token=token, command_runner=command_runner, state_dir=state_dir, owner_provider=owner_provider) + try: + server = ThreadingHTTPServer((host, port), handler) + except OSError as exc: + category = "port_in_use" if exc.errno == errno.EADDRINUSE else "bind_failed" + record_service_event( + category, + "blocker", + str(exc), + state_dir=state_dir, + details={"host": host, "port": port, "errno": exc.errno}, + ) + raise + record_service_event( + "serve_ready", + "info", + "Connector HTTP server is accepting requests.", + state_dir=state_dir, + details={"host": host, "port": port, "token_state": _token_state(token)}, + ) + server.serve_forever() + + +def read_token(path: str | None, *, state_dir: Path | None = None, auto_create: bool = False) -> str | None: + if not path: + token_path = (state_dir or default_state_dir()) / "connector.token" + else: + token_path = Path(path).expanduser() + if not token_path.exists(): + if not auto_create: + raise ValueError(f"connector token file does not exist: {token_path}") + token_path.parent.mkdir(parents=True, exist_ok=True) + token = secrets.token_urlsafe(48) + token_path.write_text(token + "\n", encoding="utf-8") + os.chmod(token_path, 0o600) + return token + token = token_path.read_text(encoding="utf-8").strip() + if not token: + if not auto_create: + raise ValueError(f"connector token file is empty: {token_path}") + token = secrets.token_urlsafe(48) + token_path.write_text(token + "\n", encoding="utf-8") + os.chmod(token_path, 0o600) + return token + + +def build_ready_payload(*, token: str | None, state_dir: Path | None = None) -> dict[str, Any]: + blockers = [] + if not token: + blockers.append( + { + "code": "token_missing", + "message": "Connector token is missing; command and enrollment endpoints fail closed.", + } + ) + ok = not blockers + return { + "ok": ok, + "schema": READY_SCHEMA, + "service": "evaos-desktop-bridge-connector", + "ready": ok, + "token_state": _token_state(token), + "blockers": blockers, + "control_session": _public_control_session(state_dir), + "service_events": read_service_events(state_dir=state_dir, limit=8), + } + + +def build_diagnostics_payload(*, token: str | None, state_dir: Path | None = None, owner: dict[str, Any] | None = None) -> dict[str, Any]: + root = state_dir or default_state_dir() + connector: dict[str, Any] = { + "token_state": _token_state(token), + "state_dir": _public_path(root), + "ready": build_ready_payload(token=token, state_dir=state_dir), + } + if owner is not None: + connector["owner"] = _sanitize_public_value(owner) + return { + "ok": True, + "schema": DIAGNOSTICS_SCHEMA, + "service": "evaos-desktop-bridge-connector", + "process": { + "pid": os.getpid(), + "executable": _public_path(sys.executable), + "argv0": _public_path(sys.argv[0]) if sys.argv else None, + }, + "bridge": { + "version": _bridge_version(), + "mode": _sanitize_public_text(os.environ.get("EVAOS_DESKTOP_BRIDGE_MODE") or "unknown"), + }, + "connector": connector, + "control_session": _public_control_session(state_dir), + "service_events": read_service_events(state_dir=state_dir, limit=MAX_SERVICE_EVENTS), + "redaction": { + "tokens": "redacted", + "urls": "redacted", + "ips": "redacted", + "auth_headers": "redacted", + }, + } + + +def record_service_event( + category: str, + level: str, + message: str, + *, + state_dir: Path | None = None, + details: dict[str, Any] | None = None, +) -> dict[str, Any]: + event = { + "timestamp": _now_iso(), + "category": category, + "level": level, + "message": _sanitize_public_text(message)[:500], + "details": _sanitize_public_value(details or {}), + } + root = state_dir or default_state_dir() + try: + root.mkdir(parents=True, exist_ok=True) + with (root / SERVICE_EVENTS_FILE).open("a", encoding="utf-8") as handle: + handle.write(json.dumps(event, sort_keys=True, separators=(",", ":")) + "\n") + except Exception: + pass + return event + + +def read_service_events(*, state_dir: Path | None = None, limit: int = MAX_SERVICE_EVENTS) -> list[dict[str, Any]]: + path = (state_dir or default_state_dir()) / SERVICE_EVENTS_FILE + if not path.exists(): + return [] + max_lines = max(1, limit) + try: + with path.open("r", encoding="utf-8") as handle: + lines = deque(handle, maxlen=max_lines) + except Exception: + return [] + events: list[dict[str, Any]] = [] + for line in lines: + try: + parsed = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(parsed, dict): + events.append(_sanitize_public_value(parsed)) + return events + + +def _public_control_session(state_dir: Path | None) -> dict[str, Any]: + session = read_control_session(state_dir) + if not isinstance(session, dict): + return {} + allowed_keys = { + "active", + "kill_switch", + "mode", + "started_at", + "stopped_at", + "updated_at", + "takeover_warning", + } + return { + key: _sanitize_public_value(value) + for key, value in session.items() + if key in allowed_keys + } + + +def _sanitize_public_value(value: Any) -> Any: + if isinstance(value, dict): + sanitized: dict[str, Any] = {} + for key, item in value.items(): + if not isinstance(key, str): + continue + if _is_sensitive_public_key(key): + sanitized[key] = "" + else: + sanitized[key] = _sanitize_public_value(item) + return sanitized + if isinstance(value, list): + return [_sanitize_public_value(item) for item in value[:MAX_SERVICE_EVENTS]] + if isinstance(value, str): + return _sanitize_public_text(value)[:500] + return value + + +def _sanitize_public_text(value: str) -> str: + redacted = URL_PATTERN.sub("", value) + redacted = AUTH_HEADER_PATTERN.sub(r"\1", redacted) + redacted = BEARER_TOKEN_PATTERN.sub(r"\1", redacted) + redacted = SECRET_WORD_PATTERN.sub("", redacted) + redacted = IPV4_PATTERN.sub("", redacted) + if _looks_like_ip_literal(redacted): + return "" + return redacted + + +def _is_sensitive_public_key(key: str) -> bool: + normalized = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "_", key).replace("-", "_").lower() + if normalized in {"token_state"}: + return False + sensitive_exact = { + "access_token", + "accesstoken", + "address", + "auth", + "auth_key", + "authkey", + "auth_header", + "auth_headers", + "authorization", + "connector_token", + "connector_url", + "host", + "ip", + "api_key", + "apikey", + "password", + "preauth_key", + "preauthkey", + "refresh_token", + "refreshtoken", + "secret", + "tailnet_ip", + "token", + "url", + } + sensitive_suffixes = ( + "_address", + "_auth", + "_auth_key", + "_auth_header", + "_authorization", + "_host", + "_ip", + "_api_key", + "_password", + "_preauth_key", + "_refresh_token", + "_secret", + "_token", + "_url", + ) + sensitive_parts = {"auth", "authorization", "password", "secret", "token"} + return ( + normalized in sensitive_exact + or normalized.endswith(sensitive_suffixes) + or any(part in sensitive_parts for part in normalized.split("_")) + ) + + +def _looks_like_ip_literal(value: str) -> bool: + try: + ipaddress.ip_address(value.strip("[]")) + return True + except ValueError: + return False + + +def _token_state(token: str | None) -> str: + return "present" if isinstance(token, str) and bool(token.strip()) else "missing" + + +def _bridge_version() -> str: + try: + from importlib.metadata import version + + return version("evaos-desktop-bridge") + except Exception: + return "unknown" + + +def _public_path(path: str | Path | None) -> str | None: + if path is None: + return None + text = str(path) + try: + home = str(Path.home()) + if text == home: + return "~" + if text.startswith(home + os.sep): + return "~" + text[len(home):] + except Exception: + pass + return text + + +def _now_iso() -> str: + return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + + +def _make_handler( + *, + token: str | None, + command_runner: CommandRunner, + state_dir: Path | None = None, + owner_provider: OwnerProvider | None = None, +) -> type[BaseHTTPRequestHandler]: + class ConnectorHandler(BaseHTTPRequestHandler): + server_version = "evaos-desktop-bridge-connector/0.1" + + def do_GET(self) -> None: # noqa: N802 + parsed = urlparse(self.path) + if parsed.path == "/health": + self._write_json(200, {"ok": True, "service": "evaos-desktop-bridge-connector"}) + return + if parsed.path == "/ready": + payload = build_ready_payload(token=token, state_dir=state_dir) + self._write_json(200 if payload["ok"] is True else 503, payload) + return + if parsed.path == "/v1/diagnostics": + if not token: + self._write_json(503, {"ok": False, "error": "token_missing", "schema": DIAGNOSTICS_SCHEMA}) + return + if not self._authorized(): + self._write_json(401, {"ok": False, "error": "connector_unauthorized", "schema": DIAGNOSTICS_SCHEMA}) + return + self._write_json(200, build_diagnostics_payload(token=token, state_dir=state_dir, owner=self._owner_summary())) + return + if parsed.path.startswith("/v1/artifacts/"): + self._serve_artifact(parsed.path.removeprefix("/v1/artifacts/")) + return + self._write_json(404, {"ok": False, "error": "not_found"}) + + def do_POST(self) -> None: # noqa: N802 + parsed = urlparse(self.path) + if parsed.path == "/v1/enrollment/complete": + self._complete_enrollment() + return + if parsed.path != "/v1/commands": + self._write_json(404, {"ok": False, "error": "not_found"}) + return + if not self._authorized(): + self._write_json(401, _error_envelope("connector.unauthorized", "connector", "connector_unauthorized", "Missing or invalid connector token.")) + return + try: + payload = self._read_json() + command = normalize_connector_command(str(payload.get("command") or "")) + params = payload.get("params") if isinstance(payload.get("params"), dict) else {} + kill_switch_error = _remote_kill_switch_error(command, state_dir=state_dir) + if kill_switch_error is not None: + self._write_json( + 403, + _error_envelope( + command or "connector.command", + "customer_mac", + "control_kill_switch_active", + kill_switch_error, + ), + ) + return + approval_error = _live_guarded_approval_error(command, params, state_dir=state_dir) + if approval_error is not None: + if "kill switch" in approval_error.lower(): + error_code = "control_kill_switch_active" + elif "takeover warning" in approval_error.lower(): + error_code = "control_takeover_warning_active" + else: + error_code = "approval_audit_required" + self._write_json( + 403, + _error_envelope( + command or "connector.command", + _target_for_connector_command(command), + error_code, + approval_error, + ), + ) + return + prepared_params, temp_paths = _prepare_connector_params(command, params, state_dir=state_dir) + try: + argv = build_bridge_argv(command, prepared_params) + exit_code, output = command_runner(argv) + finally: + for temp_path in temp_paths: + try: + temp_path.unlink(missing_ok=True) + except Exception: + pass + try: + response = json.loads(output) + except json.JSONDecodeError: + response = _error_envelope(command, "desktop", "bridge_output_invalid", output[:500]) + status = 200 if exit_code == 0 else 422 + self._write_json(status, response) + except Exception as exc: + self._write_json(400, _error_envelope("connector.command", "desktop", "connector_bad_request", str(exc))) + + def _complete_enrollment(self) -> None: + try: + payload = self._read_json() + enrollment_code = str(payload.get("enrollment_code") or "").strip() + if not enrollment_code: + self._write_json(400, {"ok": False, "error": "missing_enrollment_code"}) + return + if not token: + self._write_json(503, {"ok": False, "error": "connector_token_unavailable"}) + return + connector_url = _connector_url_from_request(self) + response = complete_enrollment_via_control( + enrollment_code=enrollment_code, + connector_url=connector_url, + connector_token=token, + device_name=str(payload.get("device_name") or socket.gethostname() or "Customer Mac"), + device_identifier=str(payload.get("device_identifier") or ""), + ) + self._write_json(200, {"ok": True, "data": response}) + except Exception as exc: + self._write_json(400, {"ok": False, "error": "enrollment_complete_failed", "message": str(exc)}) + + def _serve_artifact(self, artifact_name: str) -> None: + if not self._authorized(): + self._write_json(401, _error_envelope("connector.artifact", "customer_mac", "connector_unauthorized", "Missing or invalid connector token.")) + return + artifact_id = artifact_name.removesuffix(".png") + if not artifact_id.startswith("snap-") or "/" in artifact_id or ".." in artifact_id: + self._write_json(404, {"ok": False, "error": "artifact_not_found"}) + return + root = state_dir or default_state_dir() + path = root / "artifacts" / f"{artifact_id}.png" + if not path.exists(): + self._write_json(404, {"ok": False, "error": "artifact_not_found"}) + return + body = path.read_bytes() + self.send_response(200) + self.send_header("Content-Type", "image/png") + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format: str, *args: Any) -> None: # noqa: A002 + return + + def _owner_summary(self) -> dict[str, Any] | None: + if owner_provider is None: + return None + try: + owner = owner_provider() + except Exception: + return _owner_provider_error_summary() + return owner if isinstance(owner, dict) else _owner_provider_error_summary() + + def _authorized(self) -> bool: + header = self.headers.get("Authorization", "") + if not header.startswith("Bearer "): + return False + supplied = header.removeprefix("Bearer ").strip() + return bool(token) and secrets.compare_digest(supplied, token) + + def _read_json(self) -> dict[str, Any]: + length = int(self.headers.get("Content-Length", "0")) + if length > 65536: + raise ValueError("request body too large") + data = self.rfile.read(length) + parsed = json.loads(data.decode("utf-8")) + if not isinstance(parsed, dict): + raise ValueError("request body must be a JSON object") + return parsed + + def _write_json(self, status: int, payload: dict[str, Any]) -> None: + body = json.dumps(payload, sort_keys=True).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + return ConnectorHandler + + +def _owner_provider_error_summary() -> dict[str, Any]: + return { + "label": "com.electricsheep.evaos-desktop-bridge", + "plist_path": {"kind": "unknown"}, + "program_path": {"kind": "unknown"}, + "app_path": {"kind": "unknown"}, + "source_commit": None, + "manifest_path": {"kind": "unknown"}, + "bundle_id": None, + "classification": "owner_probe_failed", + } + + +def complete_enrollment_via_control( + *, + enrollment_code: str, + connector_url: str, + connector_token: str, + device_name: str, + device_identifier: str = "", +) -> dict[str, Any]: + body = { + "action": "complete_enrollment", + "enrollment_code": enrollment_code, + "device_name": device_name, + "device_identifier": device_identifier or None, + "connector_url": connector_url, + "connector_token": connector_token, + "tailnet_ip": _host_without_port(connector_url), + "capabilities": { + "connector": "evaos-desktop-bridge", + "openclaw_tools": "enabled", + "desktop_control": "full_access_or_ask_permission", + "iphone_mirroring": "visible_control_surface", + }, + "permission_state": { + "accessibility": "check_required", + "screen_recording": "check_required", + }, + } + request = urllib.request.Request( + CUSTOMER_MAC_CONTROL_URL, + data=json.dumps(body).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(request, timeout=12) as response: # noqa: S310 - fixed service URL or explicit env override. + data = response.read() + parsed = json.loads(data.decode("utf-8")) + return parsed if isinstance(parsed, dict) else {"response": parsed} + + +def _connector_url_from_request(handler: BaseHTTPRequestHandler) -> str: + host = (handler.headers.get("Host") or "").strip() + if not host: + server_host, server_port = handler.server.server_address[:2] + host = f"{server_host}:{server_port}" + return f"http://{host}" + + +def _host_without_port(url: str) -> str | None: + parsed = urlparse(url) + host = parsed.hostname + if not host or host in {"localhost", "127.0.0.1", "0.0.0.0"}: + return None + return host + + +def _live_guarded_without_approval(command: str, params: dict[str, Any]) -> bool: + command = normalize_connector_command(command) + if command not in GUARDED_REMOTE_COMMANDS: + return False + if params.get("dry_run") is not False: + return False + if command in CODEX_REMOTE_CONTROL_COMMANDS: + source = params.get("source_audit_id") + return params.get("confirm") is not True or not isinstance(source, str) or not source.strip().startswith("audit-") + approval = params.get("approval_audit_id") + return not isinstance(approval, str) or not approval.strip() + + +def _live_guarded_approval_error(command: str, params: dict[str, Any], *, state_dir: Path | None, require_lookup: bool = True) -> str | None: + command = normalize_connector_command(command) + if command in TAKEOVER_WARNING_REMOTE_COMMANDS and params.get("dry_run") is False: + session = read_control_session(state_dir) + if session.get("kill_switch") is True: + return "The customer Mac kill switch is active; live agent control commands are blocked." + warning = session.get("takeover_warning") if isinstance(session.get("takeover_warning"), dict) else {} + if session.get("active") is True and warning.get("active") is True: + seconds = warning.get("seconds") if isinstance(warning.get("seconds"), int) else 10 + return f"Agent control is starting; live actions are blocked until the {seconds}-second takeover warning finishes." + if command in CONTROLLED_REMOTE_COMMANDS and session.get("active") is True: + if session.get("mode") == "full_access": + return None + if session.get("mode") == "ask_permission" and not _ask_permission_requires_approval(command, params): + return None + + if command not in GUARDED_REMOTE_COMMANDS: + return None + if params.get("dry_run") is not False: + return None + if command == "codexSendVisibleMessage" and params.get("confirm") is not True: + return "Live Codex visible message actions require confirm=true." + if command in CODEX_REMOTE_CONTROL_COMMANDS: + source = params.get("source_audit_id") + if params.get("confirm") is not True: + return "Live Codex remote-control actions require confirm=true." + if not isinstance(source, str) or not source.strip().startswith("audit-"): + return "Live Codex remote-control actions require source_audit_id from a dry-run or evidence command." + command_id, fields = CODEX_REMOTE_CONTROL_APPROVAL[command] + record = read_audit_record(source.strip(), state_dir=state_dir) + if record is None: + return "source_audit_id was not found in the local audit log." + if record.get("command") != command_id or record.get("ok") is not True: + return "source_audit_id does not reference a successful dry-run for this command." + record_args = record.get("args") + if not isinstance(record_args, dict) or record_args.get("dry_run") is not True: + return "source_audit_id must reference a dry-run record." + freshness_error = approval_audit_freshness_error(record) + if freshness_error is not None: + return freshness_error.replace("approval_audit_id", "source_audit_id") + for field in fields: + if record_args.get(field) != _approval_field_value(command, params, field): + return f"source_audit_id does not match {field}." + return None + approval = params.get("approval_audit_id") + if not isinstance(approval, str) or not approval.strip(): + return "Live remote control actions require a prior dry-run and approval_audit_id." + if not require_lookup: + return None + command_id, fields = CONNECTOR_COMMAND_APPROVAL[command] + record = read_audit_record(approval.strip(), state_dir=state_dir) + if record is None: + return "approval_audit_id was not found in the local audit log." + if record.get("command") != command_id or record.get("ok") is not True: + return "approval_audit_id does not reference a successful dry-run for this command." + record_args = record.get("args") + if not isinstance(record_args, dict) or record_args.get("dry_run") is not True: + return "approval_audit_id must reference a dry-run record." + freshness_error = approval_audit_freshness_error(record) + if freshness_error is not None: + return freshness_error + for field in fields: + if record_args.get(field) != _approval_field_value(command, params, field): + return f"approval_audit_id does not match {field}." + return None + + +def _remote_kill_switch_error(command: str, *, state_dir: Path | None) -> str | None: + command = normalize_connector_command(command) + if command in KILL_SWITCH_REMOTE_COMMAND_ALLOWLIST: + return None + session = read_control_session(state_dir) + if session.get("kill_switch") is not True: + return None + if command == "customerMacControlStart": + return "The customer Mac kill switch is active; only the local Workbench app can start a new control session." + return "The customer Mac kill switch is active; remote connector commands are blocked until the local Workbench app starts a new control session." + + +def _ask_permission_requires_approval(command: str, params: dict[str, Any]) -> bool: + command = normalize_connector_command(command) + if command in ASK_PERMISSION_HIGH_IMPACT_REMOTE_COMMANDS: + return True + if command in {"desktopClick", "iphoneTap"}: + label = params.get("target_label") + if not isinstance(label, str) or not label.strip(): + return True + return _contains_risk_word(label) + if command == "desktopHotkey": + keys = str(params.get("keys") or "").strip().lower().replace("command", "cmd").replace(" ", "") + return keys not in ASK_PERMISSION_SAFE_HOTKEYS + if command == "desktopWindow": + return str(params.get("action") or "").strip().lower() == "close" + if command == "desktopBrowserAction": + return str(params.get("action") or "").strip().lower() in {"open_url"} + if command == "desktopMenu": + return _contains_risk_word(str(params.get("menu_path") or "")) + return False + + +def _contains_risk_word(value: str) -> bool: + normalized = "".join(char.lower() if char.isalnum() else " " for char in value) + words = set(normalized.split()) + return any(word in words for word in ASK_PERMISSION_RISK_WORDS) + + +def _approval_field_value(command: str, params: dict[str, Any], field: str) -> Any: + if field == "prompt" and command == "codexContinueThread": + return params.get("prompt") or "continue" + if field == "message_hash" and command == "codexSendVisibleMessage": + return hashlib.sha256(str(params.get("message") or "").strip().encode("utf-8")).hexdigest()[:16] + if field == "value_hash" and command == "desktopSetValue": + return hashlib.sha256(str(params.get("value") or "").encode("utf-8")).hexdigest()[:16] + if field == "direction" and command == "customerMacIphoneMirroringScroll": + return params.get("direction") or "down" + if field == "target_label" and command == "customerMacIphoneMirroringSendApprovedMessage": + return params.get("target_label") or "Send" + return params.get(field) + + +def _optional_string_arg(params: dict[str, Any], name: str, flag: str) -> list[str]: + value = params.get(name) + if not isinstance(value, str) or not value.strip(): + return [] + return [flag, value.strip()] + + +def _optional_int_arg(params: dict[str, Any], name: str, flag: str) -> list[str]: + value = params.get(name) + if value is None: + return [] + return [flag, str(_required_int(params, name))] + + +def _required_int(params: dict[str, Any], name: str) -> int: + value = params.get(name) + if isinstance(value, bool): + raise ValueError(f"{name} is required") + try: + return int(value) + except (TypeError, ValueError): + raise ValueError(f"{name} is required") from None + + +def _dry_run_arg(params: dict[str, Any]) -> list[str]: + return ["--dry-run"] if params.get("dry_run") is not False else [] + + +def _approval_arg(params: dict[str, Any]) -> list[str]: + approval = params.get("approval_audit_id") + if not isinstance(approval, str) or not approval.strip(): + return [] + return ["--approval-audit-id", approval.strip()] + + +def _codex_remote_control_args(params: dict[str, Any]) -> list[str]: + if params.get("dry_run") is not False: + return ["--dry-run"] + argv = ["--live"] + if params.get("confirm") is True: + argv.append("--confirm") + source = params.get("source_audit_id") + if isinstance(source, str) and source.strip(): + argv.extend(["--source-audit-id", source.strip()]) + return argv + + +def _clamp_int(value: Any, default: int, minimum: int, maximum: int) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + parsed = default + return max(minimum, min(maximum, parsed)) + + +def _required_string(params: dict[str, Any], name: str) -> str: + value = params.get(name) + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{name} is required") + return value + + +def _error_envelope(command: str, target: str, code: str, message: str) -> dict[str, Any]: + return build_envelope( + command=command, + target=target, + ok=False, + data={}, + warnings=[], + errors=[make_error(code=code, message=message, guidance="Check connector pairing, command shape, and approval state.")], + audit_id="connector-rejected", + ) + + +def _target_for_connector_command(command: str | None) -> str: + if isinstance(command, str): + if command.startswith("customerMac"): + return "customer_mac" + if command.startswith("codex"): + return "codex" + return "desktop" diff --git a/resources/evaos-beta/bridge/src/evaos_desktop_bridge/helper_ipc.py b/resources/evaos-beta/bridge/src/evaos_desktop_bridge/helper_ipc.py new file mode 100644 index 0000000000..98e0964c2f --- /dev/null +++ b/resources/evaos-beta/bridge/src/evaos_desktop_bridge/helper_ipc.py @@ -0,0 +1,1197 @@ +from __future__ import annotations + +import ctypes +import hashlib +import json +import os +import platform +import secrets +import socket +import stat +import struct +import subprocess +import sys +import time +import uuid +from collections.abc import Callable +from errno import ELOOP +from pathlib import Path +from typing import Any + +from .audit import default_state_dir +from .schema import make_error, timestamp_utc +from .types import CommandResult + +HELPER_IPC_SCHEMA_VERSION = "evaos.helper_ipc.v1" +HELPER_IPC_MAX_BYTES = 64 * 1024 +HELPER_IPC_ALLOWED_COMMANDS = frozenset({"ping", "mouse_action", "ax_action"}) +HELPER_USE_ENV = "EVAOS_DESKTOP_BRIDGE_USE_HELPER" +HELPER_SOCKET_ENV = "EVAOS_DESKTOP_BRIDGE_HELPER_SOCKET" +HELPER_TOKEN_FILE_ENV = "EVAOS_DESKTOP_BRIDGE_HELPER_TOKEN_FILE" +HELPER_RESPONSIBLE_BUNDLE_ID_ENV = "EVAOS_DESKTOP_BRIDGE_HELPER_RESPONSIBLE_BUNDLE_ID" +HELPER_RESPONSIBLE_APP_PATH_ENV = "EVAOS_DESKTOP_BRIDGE_HELPER_RESPONSIBLE_APP_PATH" +HELPER_ENFORCE_PERMISSIONS_ENV = "EVAOS_DESKTOP_BRIDGE_HELPER_ENFORCE_PERMISSIONS" +LEGACY_EVAOS_WORKBENCH_BUNDLE_ID = "com.electricsheephq.EvaDesktop" +EVAOS_WORKBENCH_BUNDLE_IDS = frozenset( + { + LEGACY_EVAOS_WORKBENCH_BUNDLE_ID, + "com.evaos.workbench", + } +) +ACCESSIBILITY_DEEP_LINK = "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility" +SCREEN_RECORDING_DEEP_LINK = "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture" + + +class HelperIpcError(ValueError): + def __init__(self, code: str, message: str) -> None: + super().__init__(message) + self.code = code + self.message = message + + +class QuartzMouseActionExecutor: + def __init__(self) -> None: + self._quartz: Any | None = None + + def __call__(self, command: str, payload: dict[str, Any]) -> dict[str, Any]: + if command != "mouse_action": + raise HelperIpcError("helper_ipc_command_not_allowed", "Helper executor only supports mouse_action.") + action = payload.get("action") + if action not in {"click", "scroll", "drag"}: + raise HelperIpcError("helper_ipc_bad_payload", "mouse_action requires action click, scroll, or drag.") + target = self._target_from_payload(payload) + web_content = self._target_path_contains_role(target, "AXWebArea") + if web_content: + return { + "ok": False, + "data": {"performed": False, "action": action, "target": self._target_summary(target), "engine": "helper_post_to_pid"}, + "warnings": [], + "errors": [ + { + "code": "helper_post_to_pid_web_content_inert", + "message": "CGEventPostToPid events against browser web content are treated as inert and are not reported as success.", + "guidance": "Route browser web content through the browser/Playwright strategy instead of Tier-2 process events.", + } + ], + } + try: + quartz = self._load_quartz() + source = quartz.CGEventSourceCreate(quartz.kCGEventSourceStateCombinedSessionState) + pid = int(target["pid"]) + if action == "click": + x = _required_int(payload, "x") + y = _required_int(payload, "y") + self._post_mouse(quartz, pid, source, quartz.kCGEventMouseMoved, x, y) + self._post_mouse(quartz, pid, source, quartz.kCGEventLeftMouseDown, x, y) + self._post_mouse(quartz, pid, source, quartz.kCGEventLeftMouseUp, x, y) + return { + "ok": True, + "data": {"performed": True, "clicked": True, "action": action, "point": {"x": x, "y": y}, "target": self._target_summary(target), "engine": "helper_post_to_pid"}, + "warnings": [], + "errors": [], + } + if action == "scroll": + direction = _required_string(payload, "direction") + amount = _required_int(payload, "amount") + if direction not in {"up", "down", "left", "right"}: + raise HelperIpcError("helper_ipc_bad_payload", "scroll direction must be up, down, left, or right.") + dy = amount if direction == "up" else -amount + dx = amount if direction == "left" else -amount if direction == "right" else 0 + if direction in {"up", "down"}: + dx = 0 + else: + dy = 0 + event = quartz.CGEventCreateScrollWheelEvent(source, quartz.kCGScrollEventUnitPixel, 2, dy, dx) + quartz.CGEventPostToPid(pid, event) + return { + "ok": True, + "data": {"performed": True, "scrolled": True, "action": action, "direction": direction, "amount": amount, "target": self._target_summary(target), "engine": "helper_post_to_pid"}, + "warnings": [], + "errors": [], + } + from_x = _required_int(payload, "from_x") + from_y = _required_int(payload, "from_y") + to_x = _required_int(payload, "to_x") + to_y = _required_int(payload, "to_y") + self._post_mouse(quartz, pid, source, quartz.kCGEventMouseMoved, from_x, from_y) + self._post_mouse(quartz, pid, source, quartz.kCGEventLeftMouseDown, from_x, from_y) + self._post_mouse(quartz, pid, source, quartz.kCGEventLeftMouseDragged, to_x, to_y) + self._post_mouse(quartz, pid, source, quartz.kCGEventLeftMouseUp, to_x, to_y) + return { + "ok": True, + "data": { + "performed": True, + "dragged": True, + "action": action, + "from": {"x": from_x, "y": from_y}, + "to": {"x": to_x, "y": to_y}, + "target": self._target_summary(target), + "engine": "helper_post_to_pid", + }, + "warnings": [], + "errors": [], + } + except HelperIpcError: + raise + except Exception as exc: + return { + "ok": False, + "data": {"performed": False, "action": action}, + "warnings": [], + "errors": [ + { + "code": "helper_mouse_action_failed", + "message": str(exc), + "guidance": "Verify Accessibility permission for the signed helper identity.", + } + ], + } + + def _load_quartz(self) -> Any: + if self._quartz is None: + import Quartz # type: ignore[import-not-found] + + self._quartz = Quartz + return self._quartz + + def _target_from_payload(self, payload: dict[str, Any]) -> dict[str, Any]: + target = payload.get("target") + if not isinstance(target, dict): + raise HelperIpcError("helper_ipc_bad_payload", "mouse_action requires a target with pid and process_name for per-process posting.") + pid = target.get("pid") + if type(pid) is not int or pid <= 0: + raise HelperIpcError("helper_ipc_bad_payload", "mouse_action target pid must be a positive integer.") + process_name = target.get("process_name") + if not isinstance(process_name, str) or not process_name.strip(): + raise HelperIpcError("helper_ipc_bad_payload", "mouse_action target process_name is required.") + actual = self._process_name_for_pid(pid) + if not actual: + raise HelperIpcError("helper_post_to_pid_target_unavailable", "mouse_action target process is not currently running.") + expected_base = Path(process_name.strip()).name + actual_base = Path(actual).name + if actual != process_name.strip() and actual_base != expected_base: + raise HelperIpcError("helper_post_to_pid_process_mismatch", "mouse_action target process no longer matches the audited snapshot.") + return target + + @staticmethod + def _process_name_for_pid(pid: int) -> str | None: + try: + completed = subprocess.run(["/bin/ps", "-p", str(pid), "-o", "comm="], text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=1.0) + except Exception: + return None + if completed.returncode != 0: + return None + value = completed.stdout.strip() + return value or None + + @staticmethod + def _target_path_contains_role(target: dict[str, Any], role: str) -> bool: + path = target.get("path") + return isinstance(path, list) and any(isinstance(segment, dict) and segment.get("role") == role for segment in path) + + @staticmethod + def _target_summary(target: dict[str, Any]) -> dict[str, Any]: + path = target.get("path") + path_hash = None + if isinstance(path, list): + path_hash = hashlib.sha256(json.dumps(path, sort_keys=True).encode("utf-8")).hexdigest()[:16] + return {"pid": target.get("pid"), "path_hash": path_hash} + + @staticmethod + def _post_mouse(quartz: Any, pid: int, source: Any, kind: int, x: int, y: int) -> None: + event = quartz.CGEventCreateMouseEvent(source, kind, (x, y), quartz.kCGMouseButtonLeft) + quartz.CGEventPostToPid(pid, event) + + +class AxActionExecutor: + PERFORM_ACTIONS = { + "press": "AXPress", + } + VALUE_ATTRIBUTES = { + "set_value": "AXValue", + "set_selected_text": "AXSelectedText", + } + EDITABLE_VALUE_ROLES = frozenset({"AXTextField", "AXTextArea", "AXComboBox"}) + ALLOWED_ACTIONS = frozenset((*PERFORM_ACTIONS.keys(), *VALUE_ATTRIBUTES.keys(), "menu")) + + def __init__(self) -> None: + self._as: Any | None = None + + def __call__(self, command: str, payload: dict[str, Any]) -> dict[str, Any]: + if command != "ax_action": + raise HelperIpcError("helper_ipc_command_not_allowed", "Helper AX executor only supports ax_action.") + action = _required_string(payload, "action") + if action not in self.ALLOWED_ACTIONS: + raise HelperIpcError("helper_ipc_bad_payload", "ax_action action is not allowed.") + target = _required_ax_target(payload) + try: + app_services = self._load_application_services() + identity_error = self._target_identity_error(action, target) + if identity_error is not None: + return identity_error + if action == "menu": + menu_path = _required_menu_path(payload) + self._perform_menu(app_services, target, menu_path) + return { + "ok": True, + "data": {"performed": True, "action": action, "menu_path": " > ".join(menu_path), "engine": "helper_ax"}, + "warnings": [], + "errors": [], + } + if _target_path_contains_role(target, "AXWebArea"): + return { + "ok": False, + "data": {"performed": False, "action": action, "engine": "helper_ax"}, + "warnings": [], + "errors": [ + { + "code": "helper_ax_web_content_inert", + "message": "AX actions against browser web content are treated as inert and are not reported as success.", + "guidance": "Route browser web content through the browser/Playwright strategy instead of Tier-1 AX.", + } + ], + } + element = self._resolve_target(app_services, target) + if action in self.PERFORM_ACTIONS: + native_action = self.PERFORM_ACTIONS[action] + err = app_services.AXUIElementPerformAction(element, native_action) + if err != 0: + return self._error(action, "helper_ax_action_failed", f"AX action {native_action} failed with code {err}.") + return { + "ok": True, + "data": { + "performed": True, + "action": action, + "native_action": native_action, + "target": self._target_summary(target), + "engine": "helper_ax", + }, + "warnings": [], + "errors": [], + } + value = _required_ax_value(payload) + role = self._text_value(self._ax_value(app_services, element, self._constant(app_services, "kAXRoleAttribute", "AXRole"))) + if role == "AXSecureTextField": + return self._error(action, "helper_ax_secure_field_blocked", "AX value setting is blocked for secure text fields.") + attribute = self.VALUE_ATTRIBUTES[action] + if role not in self.EDITABLE_VALUE_ROLES: + return self._error(action, "helper_ax_non_text_field_blocked", f"AX value setting is blocked for non-text role {role or 'unknown'}.") + if not self._attribute_settable(app_services, element, attribute): + return self._error(action, "helper_ax_attribute_not_settable", f"AX attribute {attribute} is not settable on the target element.") + err = app_services.AXUIElementSetAttributeValue(element, attribute, value) + if err != 0: + return self._error(action, "helper_ax_set_value_failed", f"AX attribute {attribute} failed with code {err}.") + return { + "ok": True, + "data": { + "performed": True, + "action": action, + "attribute": attribute, + "value_sha256": hashlib.sha256(value.encode("utf-8")).hexdigest(), + "target": self._target_summary(target), + "engine": "helper_ax", + }, + "warnings": [], + "errors": [], + } + except HelperIpcError: + raise + except Exception as exc: + return self._error(action, "helper_ax_action_failed", str(exc)) + + def _load_application_services(self) -> Any: + if self._as is None: + import ApplicationServices as AS # type: ignore[import-not-found] + + self._as = AS + return self._as + + @staticmethod + def _target_summary(target: dict[str, Any]) -> dict[str, Any]: + path = target.get("path") + path_hash = None + if isinstance(path, list): + path_hash = hashlib.sha256(json.dumps(path, sort_keys=True).encode("utf-8")).hexdigest()[:16] + return {"pid": target.get("pid"), "path_hash": path_hash} + + def _target_identity_error(self, action: str, target: dict[str, Any]) -> dict[str, Any] | None: + expected = target.get("process_name") + if not isinstance(expected, str) or not expected.strip(): + return None + actual = self._process_name_for_pid(int(target["pid"])) + expected_base = Path(expected.strip()).name + actual_base = Path(actual or "").name if actual else None + if actual_base == expected.strip() or actual_base == expected_base: + return None + return self._error(action, "helper_ax_target_process_mismatch", "AX target process no longer matches the audited snapshot.") + + @staticmethod + def _process_name_for_pid(pid: int) -> str | None: + try: + completed = subprocess.run(["/bin/ps", "-p", str(pid), "-o", "comm="], text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=1.0) + except Exception: + return None + if completed.returncode != 0: + return None + value = completed.stdout.strip() + return value or None + + def _resolve_target(self, app_services: Any, target: dict[str, Any]) -> Any: + pid = int(target["pid"]) + path = target.get("path") + if not isinstance(path, list) or not path: + raise HelperIpcError("helper_ipc_bad_payload", "ax_action target requires a non-empty path.") + current = app_services.AXUIElementCreateApplication(pid) + for depth, segment in enumerate(path): + if not isinstance(segment, dict): + raise HelperIpcError("helper_ipc_bad_payload", "ax_action target path entries must be objects.") + children = self._segment_children(app_services, current, segment, root=depth == 0) + current = self._resolve_segment(app_services, children, segment) + return current + + def _segment_children(self, app_services: Any, current: Any, segment: dict[str, Any], *, root: bool) -> list[Any]: + role = str(segment.get("role") or "") + if root and role == "AXWindow": + value = self._ax_value(app_services, current, self._constant(app_services, "kAXWindowsAttribute", "AXWindows")) + elif root and role == "AXMenuBar": + value = self._ax_value(app_services, current, self._constant(app_services, "kAXMenuBarAttribute", "AXMenuBar")) + return [value] if value is not None else [] + else: + value = self._ax_value(app_services, current, self._constant(app_services, "kAXChildrenAttribute", "AXChildren")) + try: + return list(value or []) + except Exception: + return [] + + def _resolve_segment(self, app_services: Any, candidates: list[Any], segment: dict[str, Any]) -> Any: + index = segment.get("index") + if type(index) is int and 0 <= index < len(candidates) and self._segment_matches(app_services, candidates[index], segment): + return candidates[index] + matches = [candidate for candidate in candidates if self._segment_matches(app_services, candidate, segment)] + if len(matches) == 1: + return matches[0] + if not matches: + raise HelperIpcError("helper_ax_target_not_found", "AX target path did not resolve to an element.") + raise HelperIpcError("helper_ax_target_ambiguous", "AX target path resolved to multiple elements.") + + def _segment_matches(self, app_services: Any, element: Any, segment: dict[str, Any]) -> bool: + expected_role = segment.get("role") + if isinstance(expected_role, str) and expected_role: + role = self._text_value(self._ax_value(app_services, element, self._constant(app_services, "kAXRoleAttribute", "AXRole"))) + if role != expected_role: + return False + expected_identifier = segment.get("identifier") + if isinstance(expected_identifier, str) and expected_identifier: + identifier = self._text_value(self._ax_value(app_services, element, self._constant(app_services, "kAXIdentifierAttribute", "AXIdentifier"))) + if identifier != expected_identifier: + return False + expected_name = segment.get("name") + if isinstance(expected_name, str) and expected_name: + names = { + self._text_value(self._ax_value(app_services, element, self._constant(app_services, "kAXTitleAttribute", "AXTitle"))), + self._text_value(self._ax_value(app_services, element, self._constant(app_services, "kAXDescriptionAttribute", "AXDescription"))), + self._text_value(self._ax_value(app_services, element, self._constant(app_services, "kAXValueAttribute", "AXValue"))), + } + if expected_name not in names: + return False + return True + + def _perform_menu(self, app_services: Any, target: dict[str, Any], menu_path: list[str]) -> None: + app = app_services.AXUIElementCreateApplication(int(target["pid"])) + menu_bar = self._ax_value(app_services, app, self._constant(app_services, "kAXMenuBarAttribute", "AXMenuBar")) + if menu_bar is None: + raise HelperIpcError("helper_ax_menu_not_found", "AX menu bar was not available for the target app.") + current = menu_bar + for index, label in enumerate(menu_path): + children = self._children(app_services, current) + matches = [child for child in children if self._element_name(app_services, child) == label] + if len(matches) != 1: + raise HelperIpcError("helper_ax_menu_not_found", "AX menu path did not resolve uniquely.") + current = matches[0] + err = app_services.AXUIElementPerformAction(current, "AXPress") + if err != 0: + raise HelperIpcError("helper_ax_menu_failed", f"AX menu action failed with code {err}.") + if index < len(menu_path) - 1: + time.sleep(0.05) + + def _children(self, app_services: Any, element: Any) -> list[Any]: + value = self._ax_value(app_services, element, self._constant(app_services, "kAXChildrenAttribute", "AXChildren")) + try: + return list(value or []) + except Exception: + return [] + + @staticmethod + def _attribute_settable(app_services: Any, element: Any, attribute: str) -> bool: + checker = getattr(app_services, "AXUIElementIsAttributeSettable", None) + if checker is None: + return True + try: + result = checker(element, attribute, None) + except TypeError: + try: + result = checker(element, attribute) + except Exception: + return True + except Exception: + return True + if isinstance(result, tuple) and len(result) >= 2: + err, settable = result[0], result[1] + return err == 0 and bool(settable) + if isinstance(result, bool): + return result + return True + + def _element_name(self, app_services: Any, element: Any) -> str | None: + return self._text_value(self._ax_value(app_services, element, self._constant(app_services, "kAXTitleAttribute", "AXTitle"))) or self._text_value( + self._ax_value(app_services, element, self._constant(app_services, "kAXDescriptionAttribute", "AXDescription")) + ) + + @staticmethod + def _ax_value(app_services: Any, element: Any, attr: str) -> Any: + try: + err, value = app_services.AXUIElementCopyAttributeValue(element, attr, None) + except Exception: + return None + if err != 0: + return None + return value + + @staticmethod + def _text_value(value: Any) -> str | None: + if value is None: + return None + try: + return str(value) + except Exception: + return None + + @staticmethod + def _constant(app_services: Any, name: str, fallback: str) -> str: + return str(getattr(app_services, name, fallback)) + + @staticmethod + def _error(action: str, code: str, message: str) -> dict[str, Any]: + return { + "ok": False, + "data": {"performed": False, "action": action, "engine": "helper_ax"}, + "warnings": [], + "errors": [ + { + "code": code, + "message": message, + "guidance": "Verify the target is a supported native Accessibility element and retry from a fresh desktop_see snapshot.", + } + ], + } + + +class ComputerUseHelperExecutor: + def __init__(self) -> None: + self._mouse = QuartzMouseActionExecutor() + self._ax = AxActionExecutor() + + def __call__(self, command: str, payload: dict[str, Any]) -> dict[str, Any]: + if command == "mouse_action": + return self._mouse(command, payload) + if command == "ax_action": + return self._ax(command, payload) + raise HelperIpcError("helper_ipc_command_not_allowed", "Helper executor does not support this command.") + + +class UnixSocketHelperClient: + def __init__(self, *, socket_path: Path | str, token: str, timeout: float = 2.0) -> None: + self.socket_path = Path(socket_path) + self.token = token + self.timeout = timeout + + def dispatch(self, command: str, payload: dict[str, object], *, audit_id: str | None = None) -> CommandResult: + request = build_helper_request( + command=command, + token=self.token, + request_id=f"req-{uuid.uuid4().hex}", + audit_id=audit_id, + payload=dict(payload), + ) + try: + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client: + client.settimeout(self.timeout) + client.connect(str(self.socket_path)) + client.sendall(encode_frame(request)) + response = _recv_frame(client) + except OSError as exc: + return CommandResult( + ok=False, + data={"helper_socket": str(self.socket_path), "command": command}, + errors=[ + make_error( + code="helper_unavailable", + message="Persistent computer-use helper is unavailable.", + guidance=f"Start the helper daemon for this user or disable helper mode. Detail: {exc}", + ) + ], + provenance={"source": "computer_use_helper"}, + ) + return _command_result_from_response(response) + + +class UnavailableHelperClient: + def __init__(self, *, code: str, message: str) -> None: + self.code = code + self.message = message + + def dispatch(self, command: str, payload: dict[str, object], *, audit_id: str | None = None) -> CommandResult: + return CommandResult( + ok=False, + data={"command": command}, + errors=[ + make_error( + code=self.code, + message=self.message, + guidance="Start the local evaOS computer-use helper or unset EVAOS_DESKTOP_BRIDGE_USE_HELPER to use supervised fallback mode.", + ) + ], + provenance={"source": "computer_use_helper"}, + ) + + +def make_capability_token() -> str: + return secrets.token_urlsafe(48) + + +def default_helper_socket_path(state_dir: Path | None = None) -> Path: + return Path("/tmp") / f"evaos-helper-{os.getuid()}.sock" + + +def default_helper_token_path(state_dir: Path | None = None) -> Path: + return (state_dir or default_state_dir()) / "computer-use-helper.token" + + +def read_helper_token(*, token_file: Path | str | None = None, state_dir: Path | None = None, auto_create: bool = False) -> str: + path = Path(token_file).expanduser() if token_file is not None else default_helper_token_path(state_dir) + if auto_create: + return _write_new_helper_token(path) + try: + value = _read_helper_token_file(path) + except FileNotFoundError: + raise HelperIpcError("helper_token_missing", "Helper token file does not exist.") from None + if value: + return value + raise HelperIpcError("helper_token_missing", "Helper token file is empty.") + + +def helper_client_from_environment(*, state_dir: Path | None = None) -> UnixSocketHelperClient | UnavailableHelperClient | None: + if os.environ.get(HELPER_USE_ENV) not in {"1", "true", "TRUE", "yes", "YES"}: + return None + socket_path = Path(os.environ.get(HELPER_SOCKET_ENV) or default_helper_socket_path(state_dir)).expanduser() + token_file = os.environ.get(HELPER_TOKEN_FILE_ENV) + try: + token = read_helper_token(token_file=token_file, state_dir=state_dir, auto_create=False) + except HelperIpcError as exc: + return UnavailableHelperClient(code=exc.code, message=exc.message) + return UnixSocketHelperClient(socket_path=socket_path, token=token) + + +def build_helper_request( + *, + command: str, + token: str, + request_id: str, + audit_id: str | None = None, + payload: dict[str, Any] | None = None, +) -> dict[str, Any]: + request: dict[str, Any] = { + "schema_version": HELPER_IPC_SCHEMA_VERSION, + "request_id": request_id, + "command": command, + "capability_token": token, + "payload": payload or {}, + } + if audit_id is not None: + request["audit_id"] = audit_id + return request + + +def handle_helper_request( + request: dict[str, Any], + *, + expected_token: str, + expected_uid: int | None, + peer_uid: int | None, + command_executor: Callable[[str, dict[str, Any]], dict[str, Any]] | None = None, + permission_checker: Callable[[], dict[str, Any]] | None = None, +) -> dict[str, Any]: + _authorize_request(request, expected_token=expected_token, expected_uid=expected_uid, peer_uid=peer_uid) + if request.get("schema_version") != HELPER_IPC_SCHEMA_VERSION: + raise HelperIpcError("helper_ipc_bad_schema", "Helper IPC request has an unsupported schema version.") + request_id = request.get("request_id") + if not isinstance(request_id, str) or not request_id: + raise HelperIpcError("helper_ipc_bad_request_id", "Helper IPC request id must be a non-empty string.") + command = request.get("command") + if not isinstance(command, str) or command not in HELPER_IPC_ALLOWED_COMMANDS: + raise HelperIpcError("helper_ipc_command_not_allowed", "Helper IPC command is not allowed.") + payload = request.get("payload") + if not isinstance(payload, dict): + raise HelperIpcError("helper_ipc_bad_payload", "Helper IPC request payload must be a JSON object.") + audit_id = request.get("audit_id") + if audit_id is not None and (not isinstance(audit_id, str) or not audit_id): + raise HelperIpcError("helper_ipc_bad_audit_id", "Helper IPC audit id must be a non-empty string when present.") + if command in {"mouse_action", "ax_action"}: + if not isinstance(audit_id, str) or not audit_id.startswith("audit-"): + raise HelperIpcError("helper_ipc_audit_required", f"Helper IPC {command} requires an audit id from the bridge actuation path.") + if command_executor is None: + raise HelperIpcError("helper_ipc_executor_unavailable", "Helper IPC actuation executor is not configured.") + permission_preflight = permission_checker() if permission_checker is not None else helper_permission_preflight() + preflight_errors = helper_permission_preflight_errors(permission_preflight) + if preflight_errors: + return { + "schema_version": HELPER_IPC_SCHEMA_VERSION, + "request_id": request_id, + "audit_id": audit_id, + "ok": False, + "timestamp": timestamp_utc(), + "data": {"performed": False, "command": command, "action": payload.get("action"), "permission_preflight": permission_preflight}, + "warnings": [], + "errors": preflight_errors, + } + executed = command_executor(command, payload) + data = executed.get("data") + if isinstance(data, dict): + executed = dict(executed) + executed["data"] = {**data, "permission_preflight": permission_preflight} + return _response_from_executor(request_id=request_id, audit_id=audit_id, executed=executed) + permission_preflight = permission_checker() if permission_checker is not None else helper_permission_preflight() + return { + "schema_version": HELPER_IPC_SCHEMA_VERSION, + "request_id": request_id, + "ok": True, + "timestamp": timestamp_utc(), + "data": { + "command": command, + "helper_mode": "resident_local" if command_executor is not None else "contract_only", + "actuation_enabled": command_executor is not None, + "permission_preflight": permission_preflight, + }, + "warnings": [], + "errors": [], + } + + +def _response_from_executor(*, request_id: str, audit_id: str, executed: dict[str, Any]) -> dict[str, Any]: + ok = executed.get("ok") + if type(ok) is not bool: + raise HelperIpcError("helper_ipc_bad_executor_response", "Helper IPC executor response must include ok as a boolean.") + data = executed.get("data", {}) + warnings = executed.get("warnings", []) + errors = executed.get("errors", []) + if not isinstance(data, dict) or not isinstance(warnings, list) or not isinstance(errors, list): + raise HelperIpcError("helper_ipc_bad_executor_response", "Helper IPC executor response has an invalid shape.") + return { + "schema_version": HELPER_IPC_SCHEMA_VERSION, + "request_id": request_id, + "audit_id": audit_id, + "ok": ok, + "timestamp": timestamp_utc(), + "data": data, + "warnings": warnings, + "errors": errors, + } + + +def run_helper_server( + *, + socket_path: Path | str, + token: str, + expected_uid: int | None = None, + command_executor: Callable[[str, dict[str, Any]], dict[str, Any]] | None = None, + permission_checker: Callable[[], dict[str, Any]] | None = None, + ready: Any | None = None, + max_requests: int | None = None, + peer_uid_getter: Callable[[socket.socket], int | None] | None = None, + connection_timeout: float = 2.0, +) -> None: + path = Path(socket_path) + path.parent.mkdir(parents=True, exist_ok=True) + _unlink_existing_socket(path, missing_ok=True, fail_on_non_socket=True) + executor = command_executor or ComputerUseHelperExecutor() + peer_uid = peer_uid_getter or socket_peer_uid + served = 0 + server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + server.bind(str(path)) + os.chmod(path, 0o600) + server.listen(8) + if ready is not None: + ready.set() + while max_requests is None or served < max_requests: + connection, _ = server.accept() + with connection: + connection.settimeout(connection_timeout) + request: dict[str, Any] | None = None + try: + request = _recv_frame(connection) + response = handle_helper_request( + request, + expected_token=token, + expected_uid=os.getuid() if expected_uid is None else expected_uid, + peer_uid=peer_uid(connection), + command_executor=executor, + permission_checker=permission_checker, + ) + except socket.timeout: + response = _error_response( + request=request, + code="helper_ipc_timeout", + message="Helper IPC connection timed out while reading a frame.", + ) + except HelperIpcError as exc: + response = _error_response(request=request, code=exc.code, message=exc.message) + except Exception as exc: + response = _error_response(request=request, code="helper_ipc_server_error", message=str(exc)) + _send_frame_best_effort(connection, response) + served += 1 + finally: + server.close() + try: + _unlink_existing_socket(path, missing_ok=True, fail_on_non_socket=True) + except HelperIpcError: + pass + + +def _send_frame_best_effort(connection: socket.socket, response: dict[str, Any]) -> bool: + try: + frame = encode_frame(response) + except (HelperIpcError, TypeError, ValueError, OverflowError): + try: + frame = encode_frame( + _error_response( + request=None, + code="helper_ipc_server_error", + message="Helper IPC response could not be encoded.", + ) + ) + except (HelperIpcError, TypeError, ValueError, OverflowError): + return False + try: + connection.sendall(frame) + return True + except (BrokenPipeError, ConnectionResetError, socket.timeout, OSError): + return False + + +def encode_frame(payload: dict[str, Any]) -> bytes: + body = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8") + if len(body) > HELPER_IPC_MAX_BYTES: + raise HelperIpcError("helper_ipc_payload_too_large", "Helper IPC payload exceeds the maximum frame size.") + return len(body).to_bytes(4, "big") + body + + +def decode_frame(frame: bytes) -> dict[str, Any]: + if len(frame) < 4: + raise HelperIpcError("helper_ipc_frame_truncated", "Helper IPC frame is missing its length prefix.") + length = int.from_bytes(frame[:4], "big") + if length > HELPER_IPC_MAX_BYTES: + raise HelperIpcError("helper_ipc_payload_too_large", "Helper IPC payload exceeds the maximum frame size.") + body = frame[4:] + if len(body) != length: + raise HelperIpcError("helper_ipc_frame_truncated", "Helper IPC frame length does not match its payload.") + try: + decoded = json.loads(body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise HelperIpcError("helper_ipc_bad_json", "Helper IPC frame payload is not valid JSON.") from exc + if not isinstance(decoded, dict): + raise HelperIpcError("helper_ipc_bad_payload", "Helper IPC frame payload must be a JSON object.") + return decoded + + +def _recv_frame(sock: socket.socket) -> dict[str, Any]: + prefix = _recv_exact(sock, 4) + length = int.from_bytes(prefix, "big") + if length > HELPER_IPC_MAX_BYTES: + raise HelperIpcError("helper_ipc_payload_too_large", "Helper IPC payload exceeds the maximum frame size.") + return decode_frame(prefix + _recv_exact(sock, length)) + + +def _recv_exact(sock: socket.socket, length: int) -> bytes: + chunks: list[bytes] = [] + remaining = length + while remaining > 0: + chunk = sock.recv(remaining) + if not chunk: + raise HelperIpcError("helper_ipc_frame_truncated", "Helper IPC frame ended before the expected payload length.") + chunks.append(chunk) + remaining -= len(chunk) + return b"".join(chunks) + + +def socket_peer_uid(sock: socket.socket) -> int | None: + if getattr(socket, "SO_PEERCRED", None) is not None: + try: + raw = sock.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize("3i")) + _pid, uid, _gid = struct.unpack("3i", raw) + return int(uid) + except OSError: + pass + if getattr(socket, "LOCAL_PEERCRED", None) is not None: + for level in (getattr(socket, "SOL_LOCAL", 0), socket.SOL_SOCKET): + try: + raw = sock.getsockopt(level, socket.LOCAL_PEERCRED, 256) + except OSError: + continue + if len(raw) >= 8: + try: + _version, uid = struct.unpack("ii", raw[:8]) + except struct.error: + continue + return int(uid) + return None + + +def _command_result_from_response(response: dict[str, Any]) -> CommandResult: + ok = response.get("ok") is True + data = response.get("data") + warnings = response.get("warnings") + errors = response.get("errors") + return CommandResult( + ok=ok, + data=data if isinstance(data, dict) else {}, + warnings=warnings if isinstance(warnings, list) else [], + errors=errors if isinstance(errors, list) else [], + provenance={"source": "computer_use_helper", "request_id": response.get("request_id"), "helper_audit_id": response.get("audit_id")}, + ) + + +def helper_permission_preflight( + *, + env: dict[str, str] | None = None, + platform_name: str | None = None, + accessibility_checker: Callable[[], bool | None] | None = None, + screen_recording_checker: Callable[[], bool | None] | None = None, + parent_process_path: str | None = None, +) -> dict[str, Any]: + environment = os.environ if env is None else env + current_platform = platform_name or platform.system() + responsible_bundle_id = environment.get(HELPER_RESPONSIBLE_BUNDLE_ID_ENV) or None + responsible_bundle_allowed = responsible_bundle_id in EVAOS_WORKBENCH_BUNDLE_IDS + responsible_app_path = environment.get(HELPER_RESPONSIBLE_APP_PATH_ENV) or None + enforced = environment.get(HELPER_ENFORCE_PERMISSIONS_ENV) in {"1", "true", "TRUE", "yes", "YES"} + parent_pid = os.getppid() + resolved_parent_process_path = parent_process_path if parent_process_path is not None else _parent_process_path(parent_pid) + parent_status = _parent_process_status(responsible_app_path, resolved_parent_process_path) + + if responsible_bundle_allowed and responsible_app_path and parent_status == "matched_responsible_app": + identity_status = "workbench_signed_app" + elif responsible_bundle_allowed and responsible_app_path: + identity_status = "parent_unverified" + elif responsible_bundle_id: + identity_status = "mismatch" + else: + identity_status = "unattributed_cli" + + if current_platform == "Darwin": + accessibility = _permission_status(accessibility_checker or check_accessibility_trusted) + screen_recording = _permission_status(screen_recording_checker or check_screen_recording_trusted) + else: + accessibility = "unknown" + screen_recording = "unknown" + + ok = ( + (not enforced or identity_status == "workbench_signed_app") + and (not enforced or current_platform != "Darwin" or (accessibility == "granted" and screen_recording == "granted")) + ) + return { + "ok": ok, + "enforced": enforced, + "platform": current_platform, + "identity": { + "expected_bundle_ids": sorted(EVAOS_WORKBENCH_BUNDLE_IDS), + "responsible_bundle_id": responsible_bundle_id, + "responsible_app_path": responsible_app_path, + "process_executable": sys.executable, + "parent_pid": parent_pid, + "parent_executable": resolved_parent_process_path, + "parent_status": parent_status, + "status": identity_status, + }, + "permissions": { + "accessibility": { + "status": accessibility, + "deep_link": ACCESSIBILITY_DEEP_LINK, + }, + "screen_recording": { + "status": screen_recording, + "deep_link": SCREEN_RECORDING_DEEP_LINK, + }, + }, + } + + +def helper_permission_preflight_errors(preflight: dict[str, Any]) -> list[dict[str, Any]]: + if preflight.get("ok") is True or preflight.get("enforced") is not True: + return [] + errors: list[dict[str, Any]] = [] + identity = preflight.get("identity") if isinstance(preflight.get("identity"), dict) else {} + if identity.get("status") != "workbench_signed_app": + errors.append( + make_error( + code="helper_identity_unverified", + message="Computer-use helper must be launched by the signed evaOS Workbench app before actuation.", + guidance="Start Mac Access from evaOS Workbench so macOS resolves helper permissions to the evaOS.app identity.", + ) + ) + permissions = preflight.get("permissions") if isinstance(preflight.get("permissions"), dict) else {} + missing: list[str] = [] + for key, label in (("accessibility", "Accessibility"), ("screen_recording", "Screen Recording")): + item = permissions.get(key) if isinstance(permissions.get(key), dict) else {} + if item.get("status") != "granted": + missing.append(label) + if missing: + noun = "permissions are" if len(missing) > 1 else "permission is" + errors.append( + make_error( + code="permission_missing", + message=f"{' and '.join(missing)} {noun} required before helper actuation.", + guidance=( + "Open System Settings > Privacy & Security and approve evaOS Workbench for Accessibility and Screen Recording. " + f"Accessibility: {ACCESSIBILITY_DEEP_LINK}; Screen Recording: {SCREEN_RECORDING_DEEP_LINK}" + ), + permission=",".join(name.lower().replace(" ", "_") for name in missing), + ) + ) + return errors + + +def _permission_status(checker: Callable[[], bool | None]) -> str: + try: + trusted = checker() + except Exception: + return "unknown" + if trusted is True: + return "granted" + if trusted is False: + return "missing" + return "unknown" + + +def _parent_process_path(pid: int) -> str | None: + if platform.system() != "Darwin": + return None + try: + completed = subprocess.run( + ["/bin/ps", "-p", str(pid), "-o", "comm="], + text=True, + capture_output=True, + timeout=2, + check=False, + ) + except Exception: + return None + if completed.returncode != 0: + return None + value = completed.stdout.strip() + return value or None + + +def _parent_process_status(responsible_app_path: str | None, parent_process_path: str | None) -> str: + if not responsible_app_path: + return "missing_responsible_app_path" + if not parent_process_path: + return "unknown" + app_path = str(Path(responsible_app_path).expanduser().resolve(strict=False)).rstrip("/") + parent_path = str(Path(parent_process_path).expanduser().resolve(strict=False)) + if parent_path == app_path or parent_path.startswith(f"{app_path}/"): + return "matched_responsible_app" + return "mismatch" + + +def check_accessibility_trusted() -> bool | None: + if platform.system() != "Darwin": + return None + try: + app_services = ctypes.cdll.LoadLibrary("/System/Library/Frameworks/ApplicationServices.framework/ApplicationServices") + app_services.AXIsProcessTrusted.restype = ctypes.c_bool + return bool(app_services.AXIsProcessTrusted()) + except Exception: + return None + + +def check_screen_recording_trusted() -> bool | None: + if platform.system() != "Darwin": + return None + try: + core_graphics = ctypes.cdll.LoadLibrary("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics") + core_graphics.CGPreflightScreenCaptureAccess.restype = ctypes.c_bool + return bool(core_graphics.CGPreflightScreenCaptureAccess()) + except Exception: + return None + + +def _write_new_helper_token(path: Path) -> str: + path.parent.mkdir(parents=True, exist_ok=True) + token = make_capability_token() + tmp = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp") + fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(token + "\n") + os.replace(tmp, path) + os.chmod(path, 0o600) + finally: + try: + tmp.unlink() + except FileNotFoundError: + pass + return token + + +def _read_helper_token_file(path: Path) -> str: + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) + try: + fd = os.open(path, flags) + except OSError as exc: + if exc.errno == ELOOP: + raise HelperIpcError("helper_token_unsafe", "Helper token file must not be a symlink.") from None + raise + try: + info = os.fstat(fd) + _validate_helper_token_stat(info) + return os.read(fd, 4096).decode("utf-8").strip() + finally: + os.close(fd) + + +def _validate_helper_token_stat(info: os.stat_result) -> None: + if stat.S_ISLNK(info.st_mode): + raise HelperIpcError("helper_token_unsafe", "Helper token file must not be a symlink.") + if not stat.S_ISREG(info.st_mode): + raise HelperIpcError("helper_token_unsafe", "Helper token path must be a regular file.") + if info.st_uid != os.getuid(): + raise HelperIpcError("helper_token_unsafe", "Helper token file must be owned by the current user.") + if info.st_mode & 0o077: + raise HelperIpcError("helper_token_unsafe", "Helper token file must not be readable or writable by group/other users.") + + +def _unlink_existing_socket(path: Path, *, missing_ok: bool, fail_on_non_socket: bool) -> None: + try: + info = path.lstat() + except FileNotFoundError: + if missing_ok: + return + raise + if not stat.S_ISSOCK(info.st_mode): + if fail_on_non_socket: + raise HelperIpcError("helper_socket_path_not_socket", "Helper socket path exists but is not a Unix socket.") + return + path.unlink() + + +def _error_response(*, request: dict[str, Any] | None, code: str, message: str) -> dict[str, Any]: + request_id = request.get("request_id") if isinstance(request, dict) else None + return { + "schema_version": HELPER_IPC_SCHEMA_VERSION, + "request_id": request_id if isinstance(request_id, str) and request_id else "unknown", + "ok": False, + "timestamp": timestamp_utc(), + "data": {}, + "warnings": [], + "errors": [ + { + "code": code, + "message": message, + "guidance": "Restart the local evaOS computer-use helper and retry through the audited bridge command.", + } + ], + } + + +def _authorize_request( + request: dict[str, Any], + *, + expected_token: str, + expected_uid: int | None, + peer_uid: int | None, +) -> None: + if type(expected_uid) is not int or expected_uid < 0: + raise HelperIpcError("helper_ipc_missing_peer_policy", "Helper IPC expected peer uid is not configured.") + supplied_token = request.get("capability_token") + if not isinstance(supplied_token, str) or not supplied_token: + raise HelperIpcError("helper_ipc_missing_token", "Helper IPC request is missing its capability token.") + if not expected_token or not secrets.compare_digest(supplied_token, expected_token): + raise HelperIpcError("helper_ipc_bad_token", "Helper IPC request has an invalid capability token.") + if type(peer_uid) is not int or peer_uid < 0: + raise HelperIpcError("helper_ipc_bad_peer", "Helper IPC peer uid is not authorized.") + if peer_uid != expected_uid: + raise HelperIpcError("helper_ipc_bad_peer", "Helper IPC peer uid is not authorized.") + + +def _required_int(payload: dict[str, Any], key: str) -> int: + value = payload.get(key) + if type(value) is not int: + raise HelperIpcError("helper_ipc_bad_payload", f"mouse_action payload requires integer {key}.") + return value + + +def _required_string(payload: dict[str, Any], key: str) -> str: + value = payload.get(key) + if not isinstance(value, str) or not value: + raise HelperIpcError("helper_ipc_bad_payload", f"Helper IPC payload requires string {key}.") + return value + + +def _required_ax_target(payload: dict[str, Any]) -> dict[str, Any]: + target = payload.get("target") + if not isinstance(target, dict): + raise HelperIpcError("helper_ipc_bad_payload", "ax_action payload requires target object.") + pid = target.get("pid") + if type(pid) is not int or pid <= 0: + raise HelperIpcError("helper_ipc_bad_payload", "ax_action target requires positive integer pid.") + process_name = target.get("process_name") + if not isinstance(process_name, str) or not process_name.strip() or len(process_name) > 240: + raise HelperIpcError("helper_ipc_bad_payload", "ax_action target requires process_name from the audited snapshot.") + path = target.get("path") + if path is not None: + if not isinstance(path, list): + raise HelperIpcError("helper_ipc_bad_payload", "ax_action target path must be a list.") + for segment in path: + if not isinstance(segment, dict): + raise HelperIpcError("helper_ipc_bad_payload", "ax_action target path entries must be objects.") + role = segment.get("role") + if not isinstance(role, str) or not role.startswith("AX") or len(role) > 80: + raise HelperIpcError("helper_ipc_bad_payload", "ax_action target path entries require AX role strings.") + for key in ("name", "identifier"): + value = segment.get(key) + if value is not None and (not isinstance(value, str) or len(value) > 240): + raise HelperIpcError("helper_ipc_bad_payload", f"ax_action target path {key} must be a short string when present.") + index = segment.get("index") + if index is not None and (type(index) is not int or index < 0): + raise HelperIpcError("helper_ipc_bad_payload", "ax_action target path index must be a non-negative integer when present.") + return target + + +def _required_ax_value(payload: dict[str, Any]) -> str: + value = payload.get("value") + if not isinstance(value, str) or not value: + raise HelperIpcError("helper_ipc_bad_payload", "ax_action set_value requires a non-empty string value.") + if len(value) > 4000: + raise HelperIpcError("helper_ipc_bad_payload", "ax_action set_value is capped at 4000 characters.") + return value + + +def _required_menu_path(payload: dict[str, Any]) -> list[str]: + raw = payload.get("menu_path") + if isinstance(raw, str): + parts = [part.strip() for part in raw.split(">") if part.strip()] + elif isinstance(raw, list): + parts = [str(part).strip() for part in raw if isinstance(part, str) and part.strip()] + else: + parts = [] + if not parts or len(parts) > 8 or any(len(part) > 80 for part in parts): + raise HelperIpcError("helper_ipc_bad_payload", "ax_action menu requires a short menu_path.") + return parts + + +def _target_path_contains_role(target: dict[str, Any], role: str) -> bool: + path = target.get("path") + if not isinstance(path, list): + return False + return any(isinstance(segment, dict) and segment.get("role") == role for segment in path) diff --git a/resources/evaos-beta/bridge/src/evaos_desktop_bridge/policy.py b/resources/evaos-beta/bridge/src/evaos_desktop_bridge/policy.py new file mode 100644 index 0000000000..2e5535c650 --- /dev/null +++ b/resources/evaos-beta/bridge/src/evaos_desktop_bridge/policy.py @@ -0,0 +1,175 @@ +from __future__ import annotations + +from typing import Any + +from .schema import make_error + +ALLOWED_COMMANDS = frozenset( + { + "status", + "capabilities", + "latest", + "audit_tail", + "permissions.prime", + "helper.ping", + "connector_service.status", + "connector_service.start", + "connector_service.stop", + "queue.list", + "queue.append", + "codex.frontmost", + "codex.windows", + "codex.threads", + "codex.thread_map", + "codex.focus", + "codex.select_thread", + "codex.send_visible_message", + "codex.continue_thread", + "codex.snapshot", + "codex.inspect", + "codex.ax_tree", + "codex.connections.status", + "codex.app_server.status", + "codex.app_server.threads", + "codex.app_server.loaded_threads", + "codex.app_server.subscribe", + "codex.app_server.remote_control_status", + "customer_mac.status", + "customer_mac.capabilities", + "customer_mac.control_status", + "customer_mac.control_start", + "customer_mac.control_stop", + "customer_mac.control_kill_switch", + "customer_mac.desktop_see", + "customer_mac.desktop_click", + "customer_mac.desktop_type", + "customer_mac.desktop_set_value", + "customer_mac.desktop_scroll", + "customer_mac.desktop_drag", + "customer_mac.desktop_hotkey", + "customer_mac.desktop_focus_app", + "customer_mac.desktop_window", + "customer_mac.desktop_menu", + "customer_mac.desktop_browser_action", + "customer_mac.snapshot", + "customer_mac.ax_tree", + "customer_mac.app_focus", + "customer_mac.local_site_open", + "customer_mac.local_site_action", + "customer_mac.iphone_mirroring_status", + "customer_mac.iphone_see", + "customer_mac.iphone_tap", + "customer_mac.iphone_swipe", + "customer_mac.iphone_type", + "customer_mac.iphone_mirroring_focus", + "customer_mac.iphone_mirroring_home", + "customer_mac.iphone_mirroring_app_switcher", + "customer_mac.iphone_mirroring_spotlight", + "customer_mac.iphone_mirroring_type_spotlight", + "customer_mac.iphone_mirroring_open_app", + "customer_mac.iphone_mirroring_tap_named_target", + "customer_mac.iphone_mirroring_scroll", + "customer_mac.iphone_mirroring_swipe_left", + "customer_mac.iphone_mirroring_swipe_right", + "customer_mac.iphone_mirroring_swipe_up", + "customer_mac.iphone_mirroring_swipe_down", + "customer_mac.iphone_mirroring_type_approved_text", + "customer_mac.iphone_mirroring_send_approved_message", + "customer_mac.screen_sharing_status", + } +) + +COMMAND_METADATA: dict[str, dict[str, Any]] = { + "status": {"mode": "read_only", "source": "process", "requires_permission": []}, + "capabilities": {"mode": "read_only", "source": "policy", "requires_permission": []}, + "latest": {"mode": "read_only", "source": "state", "requires_permission": []}, + "audit_tail": {"mode": "read_only", "source": "audit", "requires_permission": []}, + "permissions.prime": {"mode": "read_only", "source": "macos_tcc_prompt", "requires_permission": []}, + "helper.ping": {"mode": "read_only", "source": "computer_use_helper", "requires_permission": []}, + "connector_service.status": {"mode": "read_only", "source": "connector_service", "requires_permission": []}, + "connector_service.start": {"mode": "local_service_control", "source": "connector_service", "requires_permission": []}, + "connector_service.stop": {"mode": "local_service_control", "source": "connector_service", "requires_permission": []}, + "queue.list": {"mode": "read_only", "source": "queue", "requires_permission": []}, + "queue.append": {"mode": "read_only", "source": "queue", "requires_permission": []}, + "codex.frontmost": {"mode": "read_only", "source": "ax", "requires_permission": ["accessibility"]}, + "codex.windows": {"mode": "read_only", "source": "ax", "requires_permission": ["accessibility"]}, + "codex.threads": {"mode": "read_only", "source": "ax", "requires_permission": ["accessibility"]}, + "codex.thread_map": {"mode": "read_only", "source": "ax_app_server", "requires_permission": ["accessibility"]}, + "codex.focus": {"mode": "guarded_visible_action", "source": "ax", "requires_permission": ["accessibility"]}, + "codex.select_thread": {"mode": "guarded_visible_action", "source": "ax", "requires_permission": ["accessibility"]}, + "codex.send_visible_message": {"mode": "guarded_visible_message_action", "source": "codex_visible_gui", "requires_permission": ["accessibility"], "requires_approval": True, "prompt_scope": "approved_message"}, + "codex.continue_thread": {"mode": "support_canary_guarded_visible_action", "source": "ax", "requires_permission": ["accessibility"], "requires_approval": True, "support_only": True, "prompt_scope": "exact_continue_only"}, + "codex.snapshot": {"mode": "read_only", "source": "screenshot", "requires_permission": ["screen_recording"]}, + "codex.inspect": {"mode": "read_only", "source": "ax", "requires_permission": ["accessibility"]}, + "codex.ax_tree": {"mode": "read_only", "source": "ax", "requires_permission": ["accessibility"]}, + "codex.connections.status": {"mode": "read_only", "source": "app_server", "requires_permission": []}, + "codex.app_server.status": {"mode": "read_only", "source": "app_server", "requires_permission": []}, + "codex.app_server.threads": {"mode": "read_only", "source": "app_server", "requires_permission": []}, + "codex.app_server.loaded_threads": {"mode": "read_only", "source": "app_server", "requires_permission": []}, + "codex.app_server.subscribe": {"mode": "read_only", "source": "app_server", "requires_permission": []}, + "codex.app_server.remote_control_status": {"mode": "read_only", "source": "codex_native_remote_control", "requires_permission": []}, + "customer_mac.status": {"mode": "read_only", "source": "macos", "requires_permission": []}, + "customer_mac.capabilities": {"mode": "read_only", "source": "policy", "requires_permission": []}, + "customer_mac.control_status": {"mode": "read_only", "source": "control_session", "requires_permission": []}, + "customer_mac.control_start": {"mode": "control_session", "source": "customer_grant", "requires_permission": [], "starts_live_control": True}, + "customer_mac.control_stop": {"mode": "control_session", "source": "customer_grant", "requires_permission": [], "stops_live_control": True}, + "customer_mac.control_kill_switch": {"mode": "control_session", "source": "customer_grant", "requires_permission": [], "blocks_live_control": True}, + "customer_mac.desktop_see": {"mode": "read_only", "source": "peekaboo_or_screen_ax", "requires_permission": ["screen_recording", "accessibility"], "sensitive_app_block": True}, + "customer_mac.desktop_click": {"mode": "full_access_control", "source": "peekaboo_or_ax_post_to_pid_helper", "requires_permission": ["accessibility"], "requires_active_control_session": True}, + "customer_mac.desktop_type": {"mode": "full_access_control", "source": "peekaboo_or_system_events", "requires_permission": ["accessibility"], "requires_active_control_session": True, "high_impact_in_ask_permission": True}, + "customer_mac.desktop_set_value": {"mode": "full_access_control", "source": "computer_use_helper_ax", "requires_permission": ["accessibility"], "requires_active_control_session": True, "high_impact_in_ask_permission": True, "target_scope": "fresh_ax_snapshot_element"}, + "customer_mac.desktop_scroll": {"mode": "full_access_control", "source": "peekaboo_or_post_to_pid_helper", "requires_permission": ["accessibility"], "requires_active_control_session": True}, + "customer_mac.desktop_drag": {"mode": "full_access_control", "source": "peekaboo_or_post_to_pid_helper", "requires_permission": ["accessibility"], "requires_active_control_session": True}, + "customer_mac.desktop_hotkey": {"mode": "full_access_control", "source": "peekaboo_or_system_events", "requires_permission": ["accessibility"], "requires_active_control_session": True}, + "customer_mac.desktop_focus_app": {"mode": "full_access_control", "source": "peekaboo_or_macos_open", "requires_permission": [], "requires_active_control_session": True}, + "customer_mac.desktop_window": {"mode": "full_access_control", "source": "peekaboo_or_system_events", "requires_permission": ["accessibility"], "requires_active_control_session": True}, + "customer_mac.desktop_menu": {"mode": "full_access_control", "source": "peekaboo", "requires_permission": ["accessibility"], "requires_active_control_session": True}, + "customer_mac.desktop_browser_action": {"mode": "full_access_control", "source": "browser_keyboard", "requires_permission": ["accessibility"], "requires_active_control_session": True}, + "customer_mac.snapshot": {"mode": "read_only", "source": "screenshot", "requires_permission": ["screen_recording"], "sensitive_app_block": True}, + "customer_mac.ax_tree": {"mode": "read_only", "source": "ax", "requires_permission": ["accessibility"], "sensitive_app_block": True}, + "customer_mac.app_focus": {"mode": "guarded_visible_action", "source": "macos_open", "requires_permission": [], "requires_approval": True, "sensitive_app_block": True}, + "customer_mac.local_site_open": {"mode": "guarded_visible_action", "source": "macos_open", "requires_permission": [], "requires_approval": True, "url_scope": "localhost_loopback_local"}, + "customer_mac.local_site_action": {"mode": "guarded_visible_action", "source": "browser_keyboard", "requires_permission": ["accessibility"], "requires_approval": True, "allowlist": ["reload", "back", "forward"]}, + "customer_mac.iphone_mirroring_status": {"mode": "read_only", "source": "macos", "requires_permission": []}, + "customer_mac.iphone_see": {"mode": "read_only", "source": "peekaboo_or_screen_ax", "requires_permission": ["screen_recording", "accessibility"], "requires_frontmost_iphone_mirroring": True}, + "customer_mac.iphone_tap": {"mode": "full_access_control", "source": "peekaboo_or_ax_post_to_pid_helper", "requires_permission": ["accessibility"], "requires_active_control_session": True}, + "customer_mac.iphone_swipe": {"mode": "full_access_control", "source": "iphone_mirroring_post_to_pid_helper", "requires_permission": ["accessibility"], "requires_active_control_session": True}, + "customer_mac.iphone_type": {"mode": "full_access_control", "source": "peekaboo_or_system_events", "requires_permission": ["accessibility"], "requires_active_control_session": True, "high_impact_in_ask_permission": True}, + "customer_mac.iphone_mirroring_focus": {"mode": "full_access_control", "source": "macos_open", "requires_permission": [], "requires_active_control_session": True}, + "customer_mac.iphone_mirroring_home": {"mode": "full_access_control", "source": "iphone_mirroring_keyboard", "requires_permission": ["accessibility"], "requires_active_control_session": True}, + "customer_mac.iphone_mirroring_app_switcher": {"mode": "full_access_control", "source": "iphone_mirroring_keyboard", "requires_permission": ["accessibility"], "requires_active_control_session": True}, + "customer_mac.iphone_mirroring_spotlight": {"mode": "full_access_control", "source": "iphone_mirroring_keyboard", "requires_permission": ["accessibility"], "requires_active_control_session": True}, + "customer_mac.iphone_mirroring_type_spotlight": {"mode": "full_access_control", "source": "iphone_mirroring_keyboard", "requires_permission": ["accessibility"], "requires_active_control_session": True, "text_scope": "short_disposable_search_text"}, + "customer_mac.iphone_mirroring_open_app": {"mode": "full_access_control", "source": "iphone_mirroring_keyboard", "requires_permission": ["accessibility"], "requires_active_control_session": True}, + "customer_mac.iphone_mirroring_tap_named_target": {"mode": "full_access_control", "source": "iphone_mirroring_ax", "requires_permission": ["accessibility"], "requires_active_control_session": True}, + "customer_mac.iphone_mirroring_scroll": {"mode": "full_access_control", "source": "iphone_mirroring_post_to_pid_helper", "requires_permission": ["accessibility"], "requires_active_control_session": True}, + "customer_mac.iphone_mirroring_swipe_left": {"mode": "full_access_control", "source": "iphone_mirroring_post_to_pid_helper", "requires_permission": ["accessibility"], "requires_active_control_session": True, "high_impact_in_ask_permission": True}, + "customer_mac.iphone_mirroring_swipe_right": {"mode": "full_access_control", "source": "iphone_mirroring_post_to_pid_helper", "requires_permission": ["accessibility"], "requires_active_control_session": True, "high_impact_in_ask_permission": True}, + "customer_mac.iphone_mirroring_swipe_up": {"mode": "full_access_control", "source": "iphone_mirroring_post_to_pid_helper", "requires_permission": ["accessibility"], "requires_active_control_session": True, "high_impact_in_ask_permission": True}, + "customer_mac.iphone_mirroring_swipe_down": {"mode": "full_access_control", "source": "iphone_mirroring_post_to_pid_helper", "requires_permission": ["accessibility"], "requires_active_control_session": True, "high_impact_in_ask_permission": True}, + "customer_mac.iphone_mirroring_type_approved_text": {"mode": "full_access_control", "source": "iphone_mirroring_keyboard", "requires_permission": ["accessibility"], "requires_active_control_session": True, "high_impact_in_ask_permission": True}, + "customer_mac.iphone_mirroring_send_approved_message": {"mode": "full_access_control", "source": "iphone_mirroring_ax", "requires_permission": ["accessibility"], "requires_active_control_session": True, "high_impact_in_ask_permission": True}, + "customer_mac.screen_sharing_status": {"mode": "read_only", "source": "launchctl_lsof", "requires_permission": [], "bridge_can_enable": False}, +} + + +class PolicyError(RuntimeError): + def __init__(self, command: str) -> None: + self.command = command + self.error: dict[str, Any] = make_error( + code="command_not_allowed", + message=f"Command '{command}' is outside the Desktop Bridge MVP allowlist.", + guidance="Use one of the allowlisted bridge commands. Run evaos-desktop-bridge capabilities --json for the current surface.", + ) + super().__init__(self.error["message"]) + + +def ensure_allowed(command: str) -> str: + if command not in ALLOWED_COMMANDS: + raise PolicyError(command) + return command + + +def command_metadata(command: str) -> dict[str, Any]: + ensure_allowed(command) + return dict(COMMAND_METADATA[command]) diff --git a/resources/evaos-beta/bridge/src/evaos_desktop_bridge/pre_canary.py b/resources/evaos-beta/bridge/src/evaos_desktop_bridge/pre_canary.py new file mode 100644 index 0000000000..e24a4ef7b6 --- /dev/null +++ b/resources/evaos-beta/bridge/src/evaos_desktop_bridge/pre_canary.py @@ -0,0 +1,453 @@ +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Iterable, Sequence + +DEFAULT_CANONICAL_PATH = "/Applications/evaOS.app" +DEFAULT_BUNDLE_ID = "com.electricsheephq.EvaDesktop" +DEFAULT_TEAM_ID = "TC6MS3T6NN" +COMPUTER_USE_CLIENT_SUFFIX = "SkyComputerUseClient mcp" +# Optional developer/canary artifact locations. Missing roots are ignored, and +# callers can override them with --canary-artifact-root or +# EVAOS_CANARY_ARTIFACT_ROOTS. +DEFAULT_ARTIFACT_ROOTS = ( + "/Volumes/LEXAR/Codex/artifacts", + "/Volumes/LEXAR/Codex/evaos-provider-auth-96-canary", + "/Volumes/LEXAR/repos/evaos-desktop-bridge-worktrees", + "/Volumes/LEXAR/repos/worktrees", +) +ARTIFACT_SEARCH_MAX_DEPTH = "6" +CODEX_MCP_SURFACE = "codex-mcp" +BRIDGE_PEEKABOO_SURFACE = "bridge-peekaboo" +CONTROL_SURFACES = (CODEX_MCP_SURFACE, BRIDGE_PEEKABOO_SURFACE) + + +@dataclass(frozen=True) +class AppBundle: + path: str + bundle_id: str | None = None + version: str | None = None + build: str | None = None + team_id: str | None = None + + def to_dict(self) -> dict[str, Any]: + return { + "path": self.path, + "bundle_id": self.bundle_id, + "version": self.version, + "build": self.build, + "team_id": self.team_id, + } + + +@dataclass(frozen=True) +class ProcessInfo: + pid: int + command: str + path: str | None = None + kind: str = "other" + + def to_dict(self) -> dict[str, Any]: + return { + "pid": self.pid, + "command": self.command, + "path": self.path, + "kind": self.kind, + } + + +@dataclass(frozen=True) +class WorkbenchInventory: + registered_paths: tuple[str, ...] = () + app_bundles: tuple[AppBundle, ...] = () + processes: tuple[ProcessInfo, ...] = () + + def to_dict(self) -> dict[str, Any]: + return { + "registered_paths": list(self.registered_paths), + "app_bundles": [bundle.to_dict() for bundle in self.app_bundles], + "processes": [process.to_dict() for process in self.processes], + } + + +@dataclass(frozen=True) +class PreCanaryCheck: + code: str + status: str + message: str + evidence: str = "" + + def to_dict(self) -> dict[str, Any]: + return { + "code": self.code, + "status": self.status, + "message": self.message, + "evidence": self.evidence, + } + + +@dataclass(frozen=True) +class PreCanaryReport: + ok: bool + checks: tuple[PreCanaryCheck, ...] + summary: dict[str, Any] = field(default_factory=dict) + inventory: WorkbenchInventory = field(default_factory=WorkbenchInventory) + + def to_dict(self) -> dict[str, Any]: + return { + "ok": self.ok, + "summary": self.summary, + "checks": [check.to_dict() for check in self.checks], + "inventory": self.inventory.to_dict(), + } + + +def evaluate_inventory( + inventory: WorkbenchInventory, + *, + canonical_path: str = DEFAULT_CANONICAL_PATH, + bundle_id: str = DEFAULT_BUNDLE_ID, + expected_version: str | None = None, + expected_build: str | None = None, + expected_team_id: str | None = DEFAULT_TEAM_ID, + max_computer_use_helpers: int = 2, + control_surface: str = CODEX_MCP_SURFACE, +) -> PreCanaryReport: + checks: list[PreCanaryCheck] = [] + canonical = _bundle_by_path(inventory.app_bundles, canonical_path) + registered_duplicates = tuple(path for path in inventory.registered_paths if path != canonical_path) + stale_artifact_bundles = tuple( + bundle + for bundle in inventory.app_bundles + if bundle.path != canonical_path + and ( + bundle.bundle_id == bundle_id + or Path(bundle.path).name == "EvaDesktop.app" + ) + ) + running_workbench = tuple(process for process in inventory.processes if process.kind == "workbench") + running_duplicates = tuple(process for process in running_workbench if process.path != canonical_path) + translocated = tuple(process for process in running_workbench if "AppTranslocation" in process.command or (process.path and "AppTranslocation" in process.path)) + computer_use_helpers = tuple(process for process in inventory.processes if process.kind == "computer_use_helper") + + if canonical is None: + checks.append(_fail("canonical_workbench_missing", "Canonical Workbench app is missing.", canonical_path)) + else: + checks.append(_pass("canonical_workbench_present", "Canonical Workbench app is present.", _bundle_evidence(canonical))) + if canonical.bundle_id is None: + checks.append(_fail("canonical_bundle_id_unverifiable", "Canonical Workbench bundle id could not be verified.", f"expected {bundle_id}, found unknown")) + elif canonical.bundle_id != bundle_id: + checks.append(_fail("canonical_bundle_id_mismatch", "Canonical Workbench has the wrong bundle id.", f"expected {bundle_id}, found {canonical.bundle_id}")) + if expected_version: + if canonical.version is None: + checks.append(_fail("canonical_version_unverifiable", "Canonical Workbench version could not be verified.", f"expected {expected_version}, found unknown")) + elif canonical.version != expected_version: + checks.append(_fail("canonical_version_mismatch", "Canonical Workbench version does not match the requested canary.", f"expected {expected_version}, found {canonical.version}")) + if expected_build: + if canonical.build is None: + checks.append(_fail("canonical_build_unverifiable", "Canonical Workbench build could not be verified.", f"expected {expected_build}, found unknown")) + elif canonical.build != expected_build: + checks.append(_fail("canonical_build_mismatch", "Canonical Workbench build does not match the requested canary.", f"expected {expected_build}, found {canonical.build}")) + if expected_team_id: + if canonical.team_id is None: + checks.append(_fail("canonical_team_id_unverifiable", "Canonical Workbench signing team could not be verified.", f"expected {expected_team_id}, found unknown")) + elif canonical.team_id != expected_team_id: + checks.append(_fail("canonical_team_id_mismatch", "Canonical Workbench is signed by an unexpected team.", f"expected {expected_team_id}, found {canonical.team_id}")) + + if registered_duplicates: + checks.append( + _fail( + "duplicate_registered_workbench_app", + "Duplicate registered Workbench app bundles can make macOS open the wrong build.", + "\n".join(registered_duplicates), + ) + ) + else: + checks.append(_pass("registered_workbench_unique", "Only the canonical Workbench app is registered.", canonical_path)) + + if stale_artifact_bundles: + checks.append( + _fail( + "stale_workbench_app_bundle_present", + "Stale Workbench app bundles can be selected by macOS app-name lookup.", + "\n".join(_bundle_evidence(bundle) for bundle in stale_artifact_bundles), + ) + ) + + canonical_running = tuple(process for process in running_workbench if process.path == canonical_path) + if not canonical_running: + checks.append(_warn("canonical_workbench_not_running", "Canonical Workbench is not running yet.", canonical_path)) + else: + checks.append(_pass("canonical_workbench_running", "Canonical Workbench is running.", _pid_list(canonical_running))) + + if running_duplicates: + checks.append( + _fail( + "duplicate_running_workbench_app", + "A non-canonical Workbench app is running and can contaminate GUI evidence.", + _process_evidence(running_duplicates), + ) + ) + if translocated: + checks.append( + _fail( + "translocated_workbench_running", + "A translocated Workbench app is running from a quarantined/download location.", + _process_evidence(translocated), + ) + ) + + if len(computer_use_helpers) > max_computer_use_helpers and control_surface == BRIDGE_PEEKABOO_SURFACE: + checks.append( + _warn( + "codex_mcp_helper_count_high", + "Codex Computer Use MCP helpers are above the normal limit; continue only with the bridge/Peekaboo surface and do not rely on mcp__computer_use.", + f"{len(computer_use_helpers)} helpers running: {_pid_list(computer_use_helpers)}", + ) + ) + elif len(computer_use_helpers) > max_computer_use_helpers: + checks.append( + _fail( + "stale_computer_use_helpers", + "Too many Computer Use helper processes are running; restart/cleanup before GUI canary.", + f"{len(computer_use_helpers)} helpers running: {_pid_list(computer_use_helpers)}", + ) + ) + else: + checks.append( + _pass( + "computer_use_helper_count_ok", + "Computer Use helper process count is within the pre-canary limit.", + f"{len(computer_use_helpers)} helpers running", + ) + ) + + ok = all(check.status != "fail" for check in checks) + summary = { + "canonical_path": canonical_path, + "bundle_id": bundle_id, + "control_surface": control_surface, + "registered_count": len(inventory.registered_paths), + "running_workbench_count": len(running_workbench), + "computer_use_helper_count": len(computer_use_helpers), + } + return PreCanaryReport(ok=ok, checks=tuple(checks), summary=summary, inventory=inventory) + + +def gather_inventory( + *, + canonical_path: str = DEFAULT_CANONICAL_PATH, + bundle_id: str = DEFAULT_BUNDLE_ID, + artifact_roots: Sequence[str] | None = None, +) -> WorkbenchInventory: + registered_paths = tuple(_mdfind_bundle_paths(bundle_id)) + artifact_paths = tuple(_artifact_workbench_bundle_paths(artifact_roots=artifact_roots)) + bundle_paths = _unique_paths((*registered_paths, *artifact_paths, canonical_path)) + app_bundles = tuple(_read_app_bundle(path) for path in bundle_paths if Path(path).exists()) + processes = tuple(_process_inventory()) + return WorkbenchInventory(registered_paths=registered_paths, app_bundles=app_bundles, processes=processes) + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Pre-canary guard for evaOS Workbench GUI acceptance runs.") + parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON.") + parser.add_argument("--canonical-path", default=DEFAULT_CANONICAL_PATH) + parser.add_argument("--bundle-id", default=DEFAULT_BUNDLE_ID) + parser.add_argument("--expected-version") + parser.add_argument("--expected-build") + parser.add_argument("--expected-team-id", default=DEFAULT_TEAM_ID) + parser.add_argument("--max-computer-use-helpers", type=int, default=2) + parser.add_argument( + "--control-surface", + choices=CONTROL_SURFACES, + default=CODEX_MCP_SURFACE, + help=( + "GUI surface being canaried. codex-mcp keeps strict Computer Use helper limits; " + "bridge-peekaboo allows Codex MCP helper herds as warnings because control uses the bridge/Peekaboo path." + ), + ) + parser.add_argument( + "--canary-artifact-root", + action="append", + dest="artifact_roots", + help="Optional root to scan for stale EvaDesktop.app canary artifacts. Repeatable; overrides EVAOS_CANARY_ARTIFACT_ROOTS/default roots.", + ) + args = parser.parse_args(argv) + + inventory = gather_inventory(canonical_path=args.canonical_path, bundle_id=args.bundle_id, artifact_roots=args.artifact_roots) + report = evaluate_inventory( + inventory, + canonical_path=args.canonical_path, + bundle_id=args.bundle_id, + expected_version=args.expected_version, + expected_build=args.expected_build, + expected_team_id=args.expected_team_id, + max_computer_use_helpers=args.max_computer_use_helpers, + control_surface=args.control_surface, + ) + if args.json: + print(json.dumps(report.to_dict(), indent=2, sort_keys=True)) + else: + _print_human_report(report) + return 0 if report.ok else 2 + + +def _bundle_by_path(bundles: Iterable[AppBundle], path: str) -> AppBundle | None: + for bundle in bundles: + if bundle.path == path: + return bundle + return None + + +def _fail(code: str, message: str, evidence: str = "") -> PreCanaryCheck: + return PreCanaryCheck(code=code, status="fail", message=message, evidence=evidence) + + +def _pass(code: str, message: str, evidence: str = "") -> PreCanaryCheck: + return PreCanaryCheck(code=code, status="pass", message=message, evidence=evidence) + + +def _warn(code: str, message: str, evidence: str = "") -> PreCanaryCheck: + return PreCanaryCheck(code=code, status="warn", message=message, evidence=evidence) + + +def _bundle_evidence(bundle: AppBundle) -> str: + details = [bundle.path] + if bundle.version or bundle.build: + details.append(f"version={bundle.version or 'unknown'} build={bundle.build or 'unknown'}") + if bundle.team_id: + details.append(f"team={bundle.team_id}") + return " ".join(details) + + +def _pid_list(processes: Iterable[ProcessInfo]) -> str: + return ", ".join(str(process.pid) for process in processes) + + +def _process_evidence(processes: Iterable[ProcessInfo]) -> str: + return "\n".join(f"{process.pid} {process.path or process.command}" for process in processes) + + +def _unique_paths(paths: Iterable[str]) -> tuple[str, ...]: + seen: set[str] = set() + ordered: list[str] = [] + for path in paths: + if path and path not in seen: + seen.add(path) + ordered.append(path) + return tuple(ordered) + + +def _run(command: Sequence[str]) -> str: + try: + completed = subprocess.run(command, check=False, capture_output=True, text=True, timeout=10) + except (OSError, subprocess.TimeoutExpired): + return "" + if completed.returncode != 0 and not completed.stdout: + return "" + return completed.stdout + + +def _mdfind_bundle_paths(bundle_id: str) -> tuple[str, ...]: + output = _run(["mdfind", f"kMDItemCFBundleIdentifier == \"{bundle_id}\""]) + return tuple(line.strip() for line in output.splitlines() if line.strip()) + + +def _artifact_workbench_bundle_paths(*, artifact_roots: Sequence[str] | None = None) -> tuple[str, ...]: + paths: list[str] = [] + roots = tuple(artifact_roots) if artifact_roots is not None else _artifact_roots_from_environment() + for root in roots: + if not Path(root).exists(): + continue + output = _run(["find", root, "-maxdepth", ARTIFACT_SEARCH_MAX_DEPTH, "-type", "d", "-name", "EvaDesktop.app", "-prune", "-print"]) + paths.extend(line.strip() for line in output.splitlines() if line.strip()) + return tuple(paths) + + +def _artifact_roots_from_environment() -> tuple[str, ...]: + raw = os.environ.get("EVAOS_CANARY_ARTIFACT_ROOTS") + if not raw: + return DEFAULT_ARTIFACT_ROOTS + return tuple(part.strip() for part in raw.split(os.pathsep) if part.strip()) + + +def _plist_value(app_path: str, key: str) -> str | None: + output = _run(["/usr/libexec/PlistBuddy", "-c", f"Print :{key}", str(Path(app_path) / "Contents" / "Info.plist")]) + value = output.strip() + return value or None + + +def _codesign_team_id(app_path: str) -> str | None: + try: + completed = subprocess.run(["codesign", "-dv", app_path], check=False, capture_output=True, text=True, timeout=10) + except (OSError, subprocess.TimeoutExpired): + return None + combined = completed.stdout + "\n" + completed.stderr + match = re.search(r"TeamIdentifier=([A-Z0-9]+)", combined) + return match.group(1) if match else None + + +def _read_app_bundle(path: str) -> AppBundle: + return AppBundle( + path=path, + bundle_id=_plist_value(path, "CFBundleIdentifier"), + version=_plist_value(path, "CFBundleShortVersionString"), + build=_plist_value(path, "CFBundleVersion"), + team_id=_codesign_team_id(path), + ) + + +def _process_inventory() -> tuple[ProcessInfo, ...]: + output = _run(["ps", "-axo", "pid=,args="]) + processes: list[ProcessInfo] = [] + for line in output.splitlines(): + stripped = line.strip() + if not stripped: + continue + pid_text, _, command = stripped.partition(" ") + try: + pid = int(pid_text) + except ValueError: + continue + if "EvaDesktop.app/Contents/MacOS/EvaDesktop" in command or "/evaOS.app/Contents/MacOS/EvaDesktop" in command: + processes.append(ProcessInfo(pid=pid, command=command, path=_workbench_app_path_from_command(command), kind="workbench")) + elif _is_computer_use_mcp_helper(command): + processes.append(ProcessInfo(pid=pid, command=command, kind="computer_use_helper")) + elif "evaos_desktop_bridge.cli serve" in command or "evaos-desktop-bridge serve" in command: + processes.append(ProcessInfo(pid=pid, command=command, kind="desktop_bridge")) + return tuple(processes) + + +def _is_computer_use_mcp_helper(command: str) -> bool: + # Avoid false positives from shell commands that mention SkyComputerUseClient + # while cleaning up or inspecting helpers. Real helpers end with this argv. + return command.rstrip().endswith(COMPUTER_USE_CLIENT_SUFFIX) + + +def _workbench_app_path_from_command(command: str) -> str | None: + marker = ".app/Contents/MacOS/EvaDesktop" + index = command.find(marker) + if index == -1: + return None + executable_prefix = command[: index + len(".app")] + start = executable_prefix.rfind(" ") + return executable_prefix[start + 1 :] + + +def _print_human_report(report: PreCanaryReport) -> None: + status = "PASS" if report.ok else "FAIL" + print(f"evaOS Workbench pre-canary guard: {status}") + for check in report.checks: + evidence = f" — {check.evidence}" if check.evidence else "" + print(f"[{check.status.upper()}] {check.code}: {check.message}{evidence}") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/resources/evaos-beta/bridge/src/evaos_desktop_bridge/qa_canary.py b/resources/evaos-beta/bridge/src/evaos_desktop_bridge/qa_canary.py new file mode 100644 index 0000000000..9162417aa7 --- /dev/null +++ b/resources/evaos-beta/bridge/src/evaos_desktop_bridge/qa_canary.py @@ -0,0 +1,1234 @@ +from __future__ import annotations + +import argparse +import json +import os +import re +import struct +import subprocess +import sys +import time +import uuid +import urllib.error +import urllib.parse +import urllib.request +import zlib +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Protocol + +DEFAULT_RUN_ROOT = Path("/Volumes/LEXAR/Codex/evaos-workbench-qa-runs") +LIVE_CONTROL_SUITES = { + "all", + "full", + "full_access", + "primitive", + "desktop", + "iphone", + "desktop_scenario", + "iphone_scenario", + "ask_permission", + "real_world_optional", + "kill_switch", +} +ACTION_COMMANDS = { + "desktop_browser_action", + "desktop_click", + "desktop_drag", + "desktop_focus_app", + "desktop_hotkey", + "desktop_menu", + "desktop_scroll", + "desktop_type", + "desktop_window", + "iphone_swipe", + "iphone_tap", + "iphone_type", + "customer_mac_iphone_mirroring_app_switcher", + "customer_mac_iphone_mirroring_home", + "customer_mac_iphone_mirroring_open_app", + "customer_mac_iphone_mirroring_spotlight", +} +VISUAL_RETRY_COMMANDS = { + "desktop_see", + "iphone_see", + "customer_mac_snapshot", + "customer_mac_ax_tree", +} +REAL_WORLD_ENV_KEYS = ( + "QA_BUMBLE_TEXT", + "QA_SMS_CONTACT", + "QA_SMS_TEXT", + "QA_SOCIAL_APP", + "QA_SOCIAL_TEXT", +) +UNAVAILABLE_CODES = ( + "iphone_mirroring_not_running", + "iphone_mirroring_not_installed", + "permission_missing", + "screen_recording_missing", + "accessibility_missing", + "app_not_found", + "window_not_found", + "artifact_not_found", + "not_found", +) + + +@dataclass(frozen=True) +class CanaryStep: + id: str + suite: str + command: str + params: dict[str, Any] = field(default_factory=dict) + lane: str = "primitive" + description: str = "" + skip_on_unavailable: bool = False + expect_error_code: str | None = None + skip_if_unresolved: bool = False + env_required: tuple[str, ...] = () + requires_visual_evidence: bool = False + visual_assert: dict[str, Any] = field(default_factory=dict) + visual_assert_retries: int = 0 + visual_retry_delay_seconds: float = 1.0 + assert_from_step: str | None = None + delay_before_seconds: float = 0.0 + + +@dataclass +class SurfaceResponse: + payload: dict[str, Any] + ok: bool + audit_id: str | None + engine: str | None + snapshot_id: str | None + artifact_path: str | None + errors: list[dict[str, Any]] + warnings: list[Any] + + @classmethod + def from_payload(cls, payload: dict[str, Any], artifact_path: str | None = None) -> "SurfaceResponse": + data = payload.get("data") if isinstance(payload.get("data"), dict) else {} + errors = payload.get("errors") if isinstance(payload.get("errors"), list) else [] + warnings = payload.get("warnings") if isinstance(payload.get("warnings"), list) else [] + return cls( + payload=payload, + ok=payload.get("ok") is True, + audit_id=payload.get("audit_id") if isinstance(payload.get("audit_id"), str) else None, + engine=_extract_string(data, ("engine", "screenshot.engine", "screenshot.screenshot.engine", "image.engine")), + snapshot_id=_extract_string(data, ("snapshot_id", "screenshot.snapshot_id", "screenshot.screenshot.snapshot_id", "image.snapshot_id", "screenshot.image.snapshot_id")), + artifact_path=artifact_path + or _extract_string(data, ("vm_visual_artifact_path", "screenshot.vm_artifact_path", "screenshot.screenshot.vm_artifact_path", "image.vm_artifact_path")), + errors=[error for error in errors if isinstance(error, dict)], + warnings=warnings, + ) + + +@dataclass +class CanaryResult: + id: str + suite: str + lane: str + command: str + params_redacted: dict[str, Any] + ok: bool + status: str + audit_id: str | None + engine: str | None + snapshot_id: str | None + artifact_path: str | None + duration_ms: int + errors: list[dict[str, Any]] + warnings: list[Any] + payload: dict[str, Any] = field(repr=False, default_factory=dict) + + def to_report_dict(self) -> dict[str, Any]: + return { + "id": self.id, + "suite": self.suite, + "lane": self.lane, + "command": self.command, + "params_redacted": redact_for_report(self.params_redacted), + "ok": self.ok, + "status": self.status, + "audit_id": self.audit_id, + "engine": self.engine, + "snapshot_id": self.snapshot_id, + "artifact_path": self.artifact_path, + "duration_ms": self.duration_ms, + "errors": redact_for_report(self.errors), + "warnings": redact_for_report(self.warnings), + } + + +class CanarySurface(Protocol): + def run(self, command: str, params: dict[str, Any]) -> SurfaceResponse: + ... + + +class ConnectorSurface: + def __init__(self, *, connector_url: str, token: str, artifact_dir: Path) -> None: + self.connector_url = connector_url.rstrip("/") + self.token = token + self.artifact_dir = artifact_dir + + def run(self, command: str, params: dict[str, Any]) -> SurfaceResponse: + payload = self._post_json("/v1/commands", {"command": command, "params": params}) + artifact_path = self._materialize_visual_artifact(payload) + return SurfaceResponse.from_payload(payload, artifact_path=artifact_path) + + def _post_json(self, path: str, body: dict[str, Any]) -> dict[str, Any]: + request = urllib.request.Request( + self.connector_url + path, + data=json.dumps(body, separators=(",", ":")).encode("utf-8"), + method="POST", + headers={ + "Accept": "application/json", + "Authorization": f"Bearer {self.token}", + "Content-Type": "application/json", + }, + ) + command = str(body.get("command") or "connector.command") + try: + with urllib.request.urlopen(request, timeout=timeout_for_command(command) + 5) as response: # noqa: S310 - operator-supplied private connector URL. + return _loads_json_response(response.read()) + except urllib.error.HTTPError as exc: + body_bytes = exc.read() + try: + return _loads_json_response(body_bytes) + except ValueError: + return _error_payload(command=command, code="connector_http_error", message=body_bytes.decode("utf-8", errors="replace") or f"HTTP {exc.code}") + except TimeoutError: + return _error_payload(command=command, code="connector_timeout", message=f"{command} timed out after {timeout_for_command(command)} seconds.") + + def _materialize_visual_artifact(self, payload: dict[str, Any]) -> str | None: + data = payload.get("data") if isinstance(payload.get("data"), dict) else {} + artifact_url = _extract_string(data, ("image.artifact_url", "screenshot.artifact_url", "screenshot.screenshot.artifact_url", "screenshot.image.artifact_url")) + if not artifact_url: + return None + endpoint = urllib.parse.urljoin(self.connector_url + "/", artifact_url) + parsed_endpoint = urllib.parse.urlparse(endpoint) + parsed_connector = urllib.parse.urlparse(self.connector_url) + if parsed_endpoint.netloc != parsed_connector.netloc or not parsed_endpoint.path.startswith("/v1/artifacts/"): + payload.setdefault("warnings", []).append("Connector returned an artifact URL outside the paired connector.") + return None + request = urllib.request.Request(endpoint, method="GET", headers={"Authorization": f"Bearer {self.token}"}) + try: + with urllib.request.urlopen(request, timeout=15) as response: # noqa: S310 - validated same-origin connector artifact URL. + content = response.read() + except Exception as exc: # noqa: BLE001 - report evidence fetch failure without hiding command result. + payload.setdefault("warnings", []).append(f"Unable to fetch connector artifact: {exc}") + return None + snapshot_id = _extract_string(data, ("snapshot_id", "screenshot.snapshot_id", "screenshot.screenshot.snapshot_id", "image.artifact_id")) or Path(parsed_endpoint.path).stem + output_dir = self.artifact_dir / "evidence" + output_dir.mkdir(parents=True, exist_ok=True) + output_path = output_dir / f"{_safe_filename(snapshot_id)}.png" + output_path.write_bytes(content) + return str(output_path) + + +class OpenClawSurface: + def __init__(self, *, connector_url: str, token: str, artifact_dir: Path, repo_root: Path | None = None) -> None: + self.connector_url = connector_url + self.token = token + self.artifact_dir = artifact_dir + self.repo_root = repo_root or _resolve_repo_root() + + def run(self, command: str, params: dict[str, Any]) -> SurfaceResponse: + helper = self.repo_root / "openclaw-plugin" / "scripts" / "qa-run-bridge.mjs" + env = os.environ.copy() + env.update( + { + "EVAOS_DESKTOP_BRIDGE_URL": self.connector_url, + "EVAOS_DESKTOP_BRIDGE_TOKEN": self.token, + "EVAOS_DESKTOP_BRIDGE_ARTIFACT_DIR": str(self.artifact_dir), + } + ) + params_json = json.dumps(params, separators=(",", ":")) + completed = subprocess.run( + ["node", str(helper), command, "-"], + cwd=self.repo_root, + env=env, + input=params_json, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=timeout_for_command(command) + 30, + check=False, + ) + payload = _payload_from_completed_process(completed, command) + return SurfaceResponse.from_payload(payload) + + +class HermesSurface: + def __init__(self, *, connector_url: str, token: str, artifact_dir: Path, repo_root: Path | None = None) -> None: + self.connector_url = connector_url + self.token = token + self.artifact_dir = artifact_dir + self.repo_root = repo_root or _resolve_repo_root() + + def run(self, command: str, params: dict[str, Any]) -> SurfaceResponse: + env = os.environ.copy() + env.update( + { + "EVAOS_DESKTOP_BRIDGE_URL": self.connector_url, + "EVAOS_DESKTOP_BRIDGE_TOKEN": self.token, + "EVAOS_DESKTOP_BRIDGE_ARTIFACT_DIR": str(self.artifact_dir), + } + ) + adapter = self.repo_root / "hermes-adapter" / "bin" / "evaos-desktop-bridge-command" + params_json = json.dumps(params, separators=(",", ":")) + completed = subprocess.run( + [str(adapter), command, "-"], + cwd=self.repo_root, + env=env, + input=params_json, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=timeout_for_command(command) + 30, + check=False, + ) + payload = _payload_from_completed_process(completed, command) + return SurfaceResponse.from_payload(payload) + + +def run_steps(steps: list[CanaryStep], surface: CanarySurface) -> list[CanaryResult]: + results: list[CanaryResult] = [] + context: dict[str, CanaryResult] = {} + for step in steps: + missing_env = [key for key in step.env_required if not os.environ.get(key)] + if missing_env: + result = _skipped_result(step, f"Missing local-only QA env: {', '.join(missing_env)}") + results.append(result) + context[step.id] = result + continue + try: + params = _resolve_params(step.params, context) + except _UnresolvedPlaceholder as exc: + result = _skipped_result(step, str(exc)) if step.skip_if_unresolved else _failed_result(step, str(exc)) + results.append(result) + context[step.id] = result + continue + dependency_error = _scenario_dependency_error(step, context) + if dependency_error: + result = _failed_result(step, dependency_error, code="qa_required_visual_state_failed") + results.append(result) + context[step.id] = result + continue + if step.delay_before_seconds > 0: + time.sleep(step.delay_before_seconds) + started = time.monotonic() + try: + response = surface.run(step.command, params) + status, errors, warnings = _classify_step_response(response, step, context) + retry_attempt = 0 + while ( + retry_attempt < step.visual_assert_retries + and step.command in VISUAL_RETRY_COMMANDS + and status == "failed" + and _has_visual_assertion_failure(errors) + ): + retry_attempt += 1 + if step.visual_retry_delay_seconds > 0: + time.sleep(step.visual_retry_delay_seconds) + previous_snapshot_id = response.snapshot_id + response = surface.run(step.command, params) + status, errors, warnings = _classify_step_response(response, step, context) + warnings = [ + f"Retried transient visual assertion mismatch ({retry_attempt}/{step.visual_assert_retries}); previous snapshot: {previous_snapshot_id or 'none'}." + ] + warnings + duration_ms = int((time.monotonic() - started) * 1000) + result = CanaryResult( + id=step.id, + suite=step.suite, + lane=step.lane, + command=step.command, + params_redacted=params, + ok=response.ok, + status=status, + audit_id=response.audit_id, + engine=response.engine, + snapshot_id=response.snapshot_id, + artifact_path=response.artifact_path, + duration_ms=duration_ms, + errors=errors, + warnings=warnings, + payload=response.payload, + ) + except Exception as exc: # noqa: BLE001 - canary reports failures as data. + duration_ms = int((time.monotonic() - started) * 1000) + result = CanaryResult( + id=step.id, + suite=step.suite, + lane=step.lane, + command=step.command, + params_redacted=params, + ok=False, + status="failed", + audit_id=None, + engine=None, + snapshot_id=None, + artifact_path=None, + duration_ms=duration_ms, + errors=[{"code": "qa_step_exception", "message": str(exc), "guidance": "Inspect the QA canary report and connector logs."}], + warnings=[], + payload={}, + ) + results.append(result) + context[step.id] = result + return results + + +def classify_status(payload: dict[str, Any], *, skip_on_unavailable: bool = False, expect_error_code: str | None = None) -> str: + errors = payload.get("errors") if isinstance(payload.get("errors"), list) else [] + error_codes = {str(error.get("code") or "") for error in errors if isinstance(error, dict)} + if expect_error_code: + return "passed" if payload.get("ok") is not True and expect_error_code in error_codes else "failed" + if payload.get("ok") is True: + return "passed" + if skip_on_unavailable and (error_codes & set(UNAVAILABLE_CODES)): + return "skipped" + return "failed" + + +def _classify_step_response(response: SurfaceResponse, step: CanaryStep, context: dict[str, CanaryResult]) -> tuple[str, list[dict[str, Any]], list[Any]]: + status = classify_status(response.payload, skip_on_unavailable=step.skip_on_unavailable, expect_error_code=step.expect_error_code) + errors = list(response.errors) + warnings = list(response.warnings) + if status == "passed" and step.requires_visual_evidence and (not response.snapshot_id or not response.artifact_path): + status = "failed" + errors.append( + { + "code": "qa_visual_evidence_missing", + "message": "Visual see command returned ok:true without a snapshot id and materialized screenshot artifact.", + "guidance": "Fix screenshot artifact return/materialization before certifying this release.", + } + ) + if status == "passed" and step.visual_assert: + assertion_errors = _visual_assertion_errors(response.payload, _resolve_params(step.visual_assert, context), artifact_path=response.artifact_path) + if assertion_errors: + status = "failed" + errors.extend(assertion_errors) + return status, errors, warnings + + +def _has_visual_assertion_failure(errors: list[dict[str, Any]]) -> bool: + return any(error.get("code") == "qa_visual_assertion_failed" for error in errors if isinstance(error, dict)) + + +def timeout_for_command(command: str) -> int: + """Per-primitive timeout in seconds. Scenario suites may run many primitives.""" + if command == "desktop_control_start": + return 30 + if command in {"desktop_see", "iphone_see", "customer_mac_snapshot", "customer_mac_ax_tree"}: + return 60 + if command in {"desktop_click", "iphone_tap"}: + return 30 + if command in {"desktop_drag", "desktop_scroll", "iphone_swipe", "customer_mac_iphone_mirroring_scroll"}: + return 20 + if command in {"desktop_menu", "desktop_window", "desktop_browser_action", "desktop_focus_app", "customer_mac_iphone_mirroring_open_app"}: + return 20 + if command in { + "desktop_type", + "desktop_hotkey", + "iphone_type", + "customer_mac_iphone_mirroring_type_approved_text", + "customer_mac_iphone_mirroring_send_approved_message", + }: + return 15 + return 10 + + +def build_scenarios(suite: str, *, allow_real_world_actions: bool) -> list[CanaryStep]: + normalized = "full_access" if suite == "full" else suite + if normalized == "desktop": + normalized = "desktop_scenario" + if normalized == "iphone": + normalized = "iphone_scenario" + suites: dict[str, list[CanaryStep]] = { + "readiness": _readiness_steps(), + "codex": _codex_steps(), + "full_access": _full_access_steps(), + "primitive": _primitive_steps(), + "desktop_scenario": _desktop_scenario_steps(), + "iphone_scenario": _iphone_scenario_steps(), + "ask_permission": _ask_permission_steps(), + "kill_switch": _kill_switch_steps(), + "real_world_optional": _real_world_steps() if allow_real_world_actions else [], + } + if normalized == "all": + ordered = ["readiness", "codex", "full_access", "primitive", "desktop_scenario", "iphone_scenario", "ask_permission"] + if allow_real_world_actions: + ordered.append("real_world_optional") + return [step for name in ordered for step in suites[name]] + if normalized not in suites: + raise ValueError(f"unknown QA suite: {suite}") + return suites[normalized] + + +def write_reports( + *, + artifact_dir: Path, + run_id: str, + started_at: str, + version_under_test: str, + surface: str, + connector_url: str, + results: list[CanaryResult], +) -> dict[str, Path]: + artifact_dir.mkdir(parents=True, exist_ok=True) + summary = _summary(results) + report = { + "run_id": run_id, + "started_at": started_at, + "version_under_test": version_under_test, + "surface": surface, + "connector_url_redacted": redact_connector_url(connector_url), + "summary": summary, + "results": [result.to_report_dict() for result in results], + } + json_path = artifact_dir / "qa-report.json" + markdown_path = artifact_dir / "qa-report.md" + json_path.write_text(json.dumps(redact_for_report(report), indent=2, sort_keys=True) + "\n", encoding="utf-8") + markdown_path.write_text(_markdown_report(redact_for_report(report)), encoding="utf-8") + _sanitize_artifact_files(artifact_dir) + return {"json": json_path, "markdown": markdown_path} + + +def redact_for_report(value: Any) -> Any: + secrets = _secret_values() + if isinstance(value, dict): + redacted: dict[str, Any] = {} + for key, nested in value.items(): + lowered = str(key).lower() + if "token" in lowered or "secret" in lowered or lowered in {"authorization", "recipient_context", "text"}: + redacted[key] = "[redacted]" + elif lowered.endswith("url") and isinstance(nested, str): + redacted[key] = redact_connector_url(nested) + else: + redacted[key] = redact_for_report(nested) + return redacted + if isinstance(value, list): + return [redact_for_report(item) for item in value] + if isinstance(value, str): + redacted_string = value + for secret in secrets: + redacted_string = redacted_string.replace(secret, "[redacted]") + return redacted_string + return value + + +def redact_connector_url(raw_url: str) -> str: + try: + parsed = urllib.parse.urlparse(raw_url) + except Exception: + return raw_url + host = parsed.hostname or "" + redacted_host = host + parts = host.split(".") + if len(parts) == 4 and all(part.isdigit() for part in parts): + redacted_host = f"{parts[0]}.{parts[1]}.x.x" + netloc = redacted_host + if parsed.port: + netloc += f":{parsed.port}" + return urllib.parse.urlunparse((parsed.scheme, netloc, "", "", "", "")) + + +def _sanitize_artifact_files(artifact_dir: Path) -> None: + secrets = [secret for secret in _secret_values() if secret] + if not artifact_dir.exists(): + return + for path in artifact_dir.rglob("*"): + if not path.is_file() or path.stat().st_size > 5 * 1024 * 1024: + continue + try: + text = path.read_text(encoding="utf-8") + except UnicodeDecodeError: + continue + redacted = text + for secret in secrets: + redacted = redacted.replace(secret, "[redacted]") + redacted = re.sub( + r"(?im)^((?:export\s+)?[A-Z0-9_]*(?:TOKEN|SECRET|AUTHORIZATION|API_KEY)[A-Z0-9_]*=).+$", + r"\1[redacted]", + redacted, + ) + if redacted != text: + path.write_text(redacted, encoding="utf-8") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Run evaOS Workbench connector QA canaries.") + parser.add_argument("--connector-url", required=True) + parser.add_argument("--token-env", default="EVAOS_DESKTOP_BRIDGE_TOKEN") + parser.add_argument("--surface", choices=("connector", "openclaw", "hermes"), default="connector") + parser.add_argument( + "--suite", + choices=("readiness", "codex", "desktop", "iphone", "primitive", "desktop_scenario", "iphone_scenario", "full", "full_access", "ask_permission", "kill_switch", "real_world_optional", "all"), + default="readiness", + ) + parser.add_argument("--artifact-dir", type=Path) + parser.add_argument("--allow-real-world-actions", action="store_true") + parser.add_argument("--operator-ack-live-control", action="store_true", help="Required for suites that may move the mouse, keyboard, or iPhone Mirroring.") + parser.add_argument("--allow-skips", action="store_true", help="Exit 0 when required suites contain skipped rows; release certification should not use this.") + parser.add_argument("--repo-root", type=Path, help="Repository root containing openclaw-plugin/ and hermes-adapter/ for adapter surfaces.") + parser.add_argument("--version-under-test", default="local-dev") + args = parser.parse_args(argv) + + token = os.environ.get(args.token_env) + if not token: + parser.error(f"{args.token_env} is required") + if _suite_requires_operator_ack(args.suite, allow_real_world_actions=args.allow_real_world_actions) and not args.operator_ack_live_control: + print( + "Refusing to run live-control QA without operator acknowledgement. " + "Re-run with --operator-ack-live-control when the Mac/iPhone can be controlled.", + file=sys.stderr, + ) + return 2 + if _suite_requires_operator_ack(args.suite, allow_real_world_actions=args.allow_real_world_actions): + print( + "evaOS QA live-control warning: this suite may move the mouse, type, click, scroll, or operate iPhone Mirroring.", + file=sys.stderr, + ) + run_id = f"qa-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}-{uuid.uuid4().hex[:8]}" + artifact_dir = args.artifact_dir or DEFAULT_RUN_ROOT / run_id + started_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + if args.repo_root: + os.environ["EVAOS_DESKTOP_BRIDGE_QA_REPO_ROOT"] = str(args.repo_root) + surface = _surface_for_name(args.surface, connector_url=args.connector_url, token=token, artifact_dir=artifact_dir) + steps = build_scenarios(args.suite, allow_real_world_actions=args.allow_real_world_actions) + results = run_steps(steps, surface) + report_paths = write_reports( + artifact_dir=artifact_dir, + run_id=run_id, + started_at=started_at, + version_under_test=args.version_under_test, + surface=args.surface, + connector_url=args.connector_url, + results=results, + ) + ok = _run_successful(results, allow_skips=args.allow_skips) + print(json.dumps({"ok": ok, "run_id": run_id, "artifact_dir": str(artifact_dir), "reports": {key: str(path) for key, path in report_paths.items()}, "summary": _summary(results)}, sort_keys=True)) + return 0 if ok else 1 + + +def _surface_for_name(name: str, *, connector_url: str, token: str, artifact_dir: Path) -> CanarySurface: + if name == "connector": + return ConnectorSurface(connector_url=connector_url, token=token, artifact_dir=artifact_dir) + if name == "openclaw": + return OpenClawSurface(connector_url=connector_url, token=token, artifact_dir=artifact_dir) + if name == "hermes": + return HermesSurface(connector_url=connector_url, token=token, artifact_dir=artifact_dir) + raise ValueError(f"unknown surface: {name}") + + +def _suite_requires_operator_ack(suite: str, *, allow_real_world_actions: bool) -> bool: + normalized = "full_access" if suite == "full" else suite + if normalized in {"readiness", "codex"}: + return False + if normalized == "real_world_optional" and not allow_real_world_actions: + return False + return normalized in LIVE_CONTROL_SUITES + + +def _readiness_steps() -> list[CanaryStep]: + return [ + CanaryStep(id="readiness.bridge_status", suite="readiness", command="desktop_bridge_status"), + CanaryStep(id="readiness.customer_mac_status", suite="readiness", command="customer_mac_status"), + CanaryStep(id="readiness.customer_mac_capabilities", suite="readiness", command="customer_mac_capabilities"), + CanaryStep(id="readiness.control_status", suite="readiness", command="desktop_control_status"), + CanaryStep(id="readiness.audit_tail", suite="readiness", command="desktop_bridge_audit_tail", params={"limit": 20}), + CanaryStep(id="readiness.iphone_status", suite="readiness", command="customer_mac_iphone_mirroring_status", skip_on_unavailable=True), + ] + + +def _codex_steps() -> list[CanaryStep]: + return [ + CanaryStep(id="codex.frontmost", suite="codex", command="desktop_bridge_codex_frontmost", skip_on_unavailable=True), + CanaryStep(id="codex.windows", suite="codex", command="desktop_bridge_codex_windows", skip_on_unavailable=True), + CanaryStep(id="codex.threads", suite="codex", command="desktop_bridge_codex_threads", params={"max_items": 20}, skip_on_unavailable=True), + CanaryStep(id="codex.connections_status", suite="codex", command="desktop_bridge_codex_connections_status", skip_on_unavailable=True), + CanaryStep(id="codex.app_server_status", suite="codex", command="desktop_bridge_codex_app_server_status", skip_on_unavailable=True), + CanaryStep(id="codex.loaded_threads", suite="codex", command="desktop_bridge_codex_app_server_loaded_threads", params={"max_items": 20}, skip_on_unavailable=True), + CanaryStep(id="codex.remote_control_status", suite="codex", command="desktop_bridge_codex_app_server_remote_control_status", skip_on_unavailable=True), + ] + + +def _full_access_steps() -> list[CanaryStep]: + return [ + CanaryStep(id="full.start", suite="full_access", command="desktop_control_start", params={"mode": "full-access", "agent_label": "evaOS QA Canary"}), + CanaryStep(id="full.status", suite="full_access", command="desktop_control_status"), + CanaryStep(id="full.scroll_no_approval", suite="full_access", command="desktop_scroll", params={"direction": "down", "amount": 200, "dry_run": False}, delay_before_seconds=10.5), + CanaryStep(id="full.hotkey_no_approval", suite="full_access", command="desktop_hotkey", params={"keys": "escape", "dry_run": False}), + ] + + +def _primitive_steps() -> list[CanaryStep]: + return [ + CanaryStep(id="primitive.desktop_see", suite="primitive", lane="primitive", command="desktop_see", params={"max_chars": 4000, "max_nodes": 200}, requires_visual_evidence=True), + CanaryStep( + id="primitive.desktop_click_element", + suite="primitive", + lane="primitive", + command="desktop_click", + params={"snapshot_id": "${primitive.desktop_see.snapshot_id}", "element_id": "${primitive.desktop_see.first_element_id}", "dry_run": False}, + skip_on_unavailable=True, + ), + CanaryStep(id="primitive.desktop_click_coordinates", suite="primitive", lane="primitive", command="desktop_click", params={"snapshot_id": "${primitive.desktop_see.snapshot_id}", "x": 700, "y": 15, "dry_run": False}), + CanaryStep(id="primitive.desktop_type", suite="primitive", lane="primitive", command="desktop_type", params={"text": "evaOS QA smoke", "dry_run": False}), + CanaryStep(id="primitive.desktop_scroll", suite="primitive", lane="primitive", command="desktop_scroll", params={"direction": "down", "amount": 400, "dry_run": False}), + CanaryStep(id="primitive.desktop_drag", suite="primitive", lane="primitive", command="desktop_drag", params={"from_x": 180, "from_y": 180, "to_x": 260, "to_y": 260, "dry_run": False}), + CanaryStep(id="primitive.desktop_hotkey", suite="primitive", lane="primitive", command="desktop_hotkey", params={"keys": "escape", "dry_run": False}), + CanaryStep(id="primitive.iphone_focus", suite="primitive", lane="primitive", command="customer_mac_iphone_mirroring_focus", params={"dry_run": False}, skip_on_unavailable=True), + CanaryStep(id="primitive.iphone_open_calculator", suite="primitive", lane="primitive", command="customer_mac_iphone_mirroring_open_app", params={"app_name": "Calculator", "dry_run": False}, skip_on_unavailable=True), + CanaryStep(id="primitive.iphone_see", suite="primitive", lane="primitive", command="iphone_see", params={"max_chars": 4000, "max_nodes": 200}, skip_on_unavailable=True, requires_visual_evidence=True), + CanaryStep(id="primitive.iphone_tap_coordinates", suite="primitive", lane="primitive", command="iphone_tap", params={"snapshot_id": "${primitive.iphone_see.snapshot_id}", "x": 140, "y": 140, "dry_run": False}, skip_on_unavailable=True), + CanaryStep(id="primitive.iphone_type", suite="primitive", lane="primitive", command="iphone_type", params={"text": "evaOS QA", "dry_run": False}, skip_on_unavailable=True), + ] + + +def _desktop_scenario_steps() -> list[CanaryStep]: + return [ + CanaryStep(id="desktop_scenario.initial_see", suite="desktop_scenario", lane="scenario", command="desktop_see", params={"max_chars": 4000, "max_nodes": 200}, requires_visual_evidence=True), + CanaryStep(id="desktop_scenario.browser_open", suite="desktop_scenario", lane="scenario", command="desktop_browser_action", params={"action": "open_url", "url": "https://example.com", "dry_run": False}, assert_from_step="desktop_scenario.initial_see"), + CanaryStep(id="desktop_scenario.see_browser", suite="desktop_scenario", lane="scenario", command="desktop_see", params={"max_chars": 4000, "max_nodes": 200}, requires_visual_evidence=True, visual_assert={"expected_visible_text": "Example"}), + CanaryStep(id="desktop_scenario.escape", suite="desktop_scenario", lane="scenario", command="desktop_hotkey", params={"keys": "escape", "dry_run": False}, assert_from_step="desktop_scenario.see_browser"), + CanaryStep(id="desktop_scenario.menu_probe", suite="desktop_scenario", lane="scenario", command="desktop_menu", params={"menu_path": "Window", "dry_run": True}, skip_on_unavailable=True, assert_from_step="desktop_scenario.see_browser"), + ] + + +def _iphone_scenario_steps() -> list[CanaryStep]: + return [ + CanaryStep(id="iphone_scenario.focus", suite="iphone_scenario", lane="scenario", command="customer_mac_iphone_mirroring_focus", params={"dry_run": False}, skip_on_unavailable=True), + CanaryStep(id="iphone_scenario.pre_open_state", suite="iphone_scenario", lane="scenario", command="iphone_see", params={"max_chars": 4000, "max_nodes": 200}, skip_on_unavailable=True, requires_visual_evidence=True), + CanaryStep(id="iphone_scenario.open_calculator", suite="iphone_scenario", lane="scenario", command="customer_mac_iphone_mirroring_open_app", params={"app_name": "Calculator", "dry_run": False}, skip_on_unavailable=True, assert_from_step="iphone_scenario.pre_open_state"), + CanaryStep(id="iphone_scenario.see_calculator", suite="iphone_scenario", lane="scenario", command="iphone_see", params={"max_chars": 4000, "max_nodes": 200}, skip_on_unavailable=True, requires_visual_evidence=True, visual_assert={"expected_visible_text": "Calculator", "expected_image_state": "iphone_calculator"}, visual_assert_retries=2, visual_retry_delay_seconds=1.0), + CanaryStep(id="iphone_scenario.calculator_entry", suite="iphone_scenario", lane="scenario", command="iphone_type", params={"text": "1+1+1=", "dry_run": False}, skip_on_unavailable=True, assert_from_step="iphone_scenario.see_calculator"), + CanaryStep(id="iphone_scenario.see_result", suite="iphone_scenario", lane="scenario", command="iphone_see", params={"max_chars": 4000, "max_nodes": 200}, skip_on_unavailable=True, requires_visual_evidence=True, visual_assert={"expected_image_state": "iphone_calculator", "allowed_states": ["Calculator", "3"]}, visual_assert_retries=2, visual_retry_delay_seconds=1.0), + CanaryStep(id="iphone_scenario.home", suite="iphone_scenario", lane="scenario", command="customer_mac_iphone_mirroring_home", params={"dry_run": False}, skip_on_unavailable=True, assert_from_step="iphone_scenario.see_result"), + CanaryStep(id="iphone_scenario.see_home", suite="iphone_scenario", lane="scenario", command="iphone_see", params={"max_chars": 4000, "max_nodes": 200}, skip_on_unavailable=True, requires_visual_evidence=True), + CanaryStep(id="iphone_scenario.spotlight", suite="iphone_scenario", lane="scenario", command="customer_mac_iphone_mirroring_spotlight", params={"dry_run": False}, skip_on_unavailable=True, assert_from_step="iphone_scenario.see_home"), + CanaryStep(id="iphone_scenario.app_switcher", suite="iphone_scenario", lane="scenario", command="customer_mac_iphone_mirroring_app_switcher", params={"dry_run": False}, skip_on_unavailable=True, assert_from_step="iphone_scenario.see_home"), + ] + + +def _ask_permission_steps() -> list[CanaryStep]: + return [ + CanaryStep(id="ask.start", suite="ask_permission", command="desktop_control_start", params={"mode": "ask-permission", "agent_label": "evaOS QA Canary"}), + CanaryStep(id="ask.high_impact_denied", suite="ask_permission", command="desktop_type", params={"text": "evaOS QA ask permission", "dry_run": False}, expect_error_code="approval_audit_required", delay_before_seconds=10.5), + CanaryStep(id="ask.high_impact_dry_run", suite="ask_permission", command="desktop_type", params={"text": "evaOS QA ask permission", "dry_run": True}), + CanaryStep(id="ask.high_impact_approved", suite="ask_permission", command="desktop_type", params={"text": "evaOS QA ask permission", "dry_run": False, "approval_audit_id": "${ask.high_impact_dry_run.audit_id}"}, skip_if_unresolved=True), + ] + + +def _kill_switch_steps() -> list[CanaryStep]: + return [ + CanaryStep(id="kill.activate", suite="kill_switch", command="desktop_kill_switch"), + CanaryStep(id="kill.status", suite="kill_switch", command="desktop_control_status"), + CanaryStep(id="kill.blocks_control", suite="kill_switch", command="desktop_scroll", params={"direction": "down", "amount": 100, "dry_run": False}, expect_error_code="control_kill_switch_active"), + ] + + +def _real_world_steps() -> list[CanaryStep]: + return [ + CanaryStep(id="real.bumble_open", suite="real_world_optional", lane="real_world", command="customer_mac_iphone_mirroring_open_app", params={"app_name": "Bumble", "dry_run": False}, env_required=("QA_BUMBLE_TEXT",), skip_on_unavailable=True), + CanaryStep(id="real.bumble_see", suite="real_world_optional", lane="real_world", command="iphone_see", params={"max_chars": 4000, "max_nodes": 200}, env_required=("QA_BUMBLE_TEXT",), skip_on_unavailable=True, requires_visual_evidence=True, visual_assert={"expected_visible_text": "Bumble"}), + CanaryStep(id="real.bumble_swipe_left", suite="real_world_optional", lane="real_world", command="iphone_swipe", params={"direction": "left", "dry_run": False}, env_required=("QA_BUMBLE_TEXT",), skip_on_unavailable=True, assert_from_step="real.bumble_see"), + CanaryStep(id="real.bumble_text", suite="real_world_optional", lane="real_world", command="iphone_type", params={"text": "${env.QA_BUMBLE_TEXT}", "dry_run": False}, env_required=("QA_BUMBLE_TEXT",), skip_on_unavailable=True, assert_from_step="real.bumble_see"), + CanaryStep(id="real.sms_text", suite="real_world_optional", lane="real_world", command="iphone_type", params={"text": "${env.QA_SMS_TEXT}", "dry_run": False}, env_required=("QA_SMS_CONTACT", "QA_SMS_TEXT"), skip_on_unavailable=True), + CanaryStep(id="real.social_open", suite="real_world_optional", lane="real_world", command="customer_mac_iphone_mirroring_open_app", params={"app_name": "${env.QA_SOCIAL_APP}", "dry_run": False}, env_required=("QA_SOCIAL_APP", "QA_SOCIAL_TEXT"), skip_on_unavailable=True), + CanaryStep(id="real.social_see", suite="real_world_optional", lane="real_world", command="iphone_see", params={"max_chars": 4000, "max_nodes": 200}, env_required=("QA_SOCIAL_APP", "QA_SOCIAL_TEXT"), skip_on_unavailable=True, requires_visual_evidence=True, visual_assert={"expected_visible_text": "${env.QA_SOCIAL_APP}"}), + CanaryStep(id="real.social_text", suite="real_world_optional", lane="real_world", command="iphone_type", params={"text": "${env.QA_SOCIAL_TEXT}", "dry_run": False}, env_required=("QA_SOCIAL_APP", "QA_SOCIAL_TEXT"), skip_on_unavailable=True, assert_from_step="real.social_see"), + ] + + +def _resolve_params(params: dict[str, Any], context: dict[str, CanaryResult]) -> dict[str, Any]: + return {key: _resolve_value(value, context) for key, value in params.items()} + + +def _resolve_value(value: Any, context: dict[str, CanaryResult]) -> Any: + if isinstance(value, dict): + return {key: _resolve_value(nested, context) for key, nested in value.items()} + if isinstance(value, list): + return [_resolve_value(item, context) for item in value] + if not isinstance(value, str) or not value.startswith("${") or not value.endswith("}"): + return value + expression = value[2:-1] + if expression.startswith("env."): + env_key = expression.removeprefix("env.") + env_value = os.environ.get(env_key) + if not env_value: + raise _UnresolvedPlaceholder(f"{env_key} is not set") + return env_value + if expression.endswith(".audit_id"): + step_id = expression.removesuffix(".audit_id") + result = context.get(step_id) + if not result or not result.audit_id: + raise _UnresolvedPlaceholder(f"{step_id} did not produce an audit id") + return result.audit_id + if expression.endswith(".snapshot_id"): + step_id = expression.removesuffix(".snapshot_id") + result = context.get(step_id) + if not result or not result.snapshot_id: + raise _UnresolvedPlaceholder(f"{step_id} did not produce a snapshot id") + return result.snapshot_id + if expression.endswith(".first_element_id"): + step_id = expression.removesuffix(".first_element_id") + result = context.get(step_id) + element_id = _find_first_element_id(result.payload if result else {}) + if not element_id: + raise _UnresolvedPlaceholder(f"{step_id} did not expose an element id") + return element_id + raise _UnresolvedPlaceholder(f"unknown QA placeholder: {value}") + + +def _find_first_element_id(payload: dict[str, Any]) -> str | None: + candidates: list[Any] = [] + data = payload.get("data") + if isinstance(data, dict): + for key in ("elements", "items", "nodes"): + value = data.get(key) + if isinstance(value, list): + candidates.extend(value) + screenshot = data.get("screenshot") + if isinstance(screenshot, dict): + for key in ("elements", "items", "nodes"): + value = screenshot.get(key) + if isinstance(value, list): + candidates.extend(value) + for candidate in candidates: + if isinstance(candidate, dict): + for key in ("element_id", "id"): + value = candidate.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return None + + +def _scenario_dependency_error(step: CanaryStep, context: dict[str, CanaryResult]) -> str | None: + if step.lane not in {"scenario", "real_world"} or step.command not in ACTION_COMMANDS: + return None + if not step.assert_from_step: + return "Scenario live actions require assert_from_step so the harness acts from a verified visual state." + dependency = context.get(step.assert_from_step) + if not dependency: + return f"{step.assert_from_step} did not run before this live action." + if dependency.status != "passed": + return f"{step.assert_from_step} did not pass visual assertions." + if not dependency.snapshot_id or not dependency.artifact_path: + return f"{step.assert_from_step} did not produce visual evidence." + return None + + +def _visual_assertion_errors(payload: dict[str, Any], assertion: dict[str, Any], *, artifact_path: str | None = None) -> list[dict[str, Any]]: + data = payload.get("data") if isinstance(payload.get("data"), dict) else {} + text = _visible_text(data).lower() + frontmost_app = str(data.get("frontmost_app") or data.get("app") or data.get("active_app") or "").lower() + image_states = _visual_artifact_states(artifact_path) + errors: list[dict[str, Any]] = [] + expected_app = assertion.get("expected_app") + if isinstance(expected_app, str) and expected_app.strip() and not _visual_state_matches(expected_app, text=text, frontmost_app=frontmost_app, image_states=image_states): + errors.append(_visual_assertion_error(f"Expected app/state containing '{expected_app}' but saw '{frontmost_app or text[:80]}'.")) + expected_text = assertion.get("expected_visible_text") + if isinstance(expected_text, str) and expected_text.strip() and not _visual_state_matches(expected_text, text=text, frontmost_app=frontmost_app, image_states=image_states): + errors.append(_visual_assertion_error(f"Expected visible text '{expected_text}' was not found.")) + expected_label = assertion.get("expected_label") + if isinstance(expected_label, str) and expected_label.strip() and not _visual_state_matches(expected_label, text=text, frontmost_app=frontmost_app, image_states=image_states): + errors.append(_visual_assertion_error(f"Expected visible label '{expected_label}' was not found.")) + expected_image_state = assertion.get("expected_image_state") + if isinstance(expected_image_state, str) and expected_image_state.strip() and not _visual_image_state_matches(expected_image_state, image_states=image_states): + errors.append(_visual_assertion_error(f"Expected image state '{expected_image_state}' was not found in the materialized visual artifact.")) + allowed_states = assertion.get("allowed_states") + if isinstance(allowed_states, list) and allowed_states: + states = [str(state) for state in allowed_states if str(state).strip()] + if states and not any(_visual_state_matches(state, text=text, frontmost_app=frontmost_app, image_states=image_states) for state in states): + errors.append(_visual_assertion_error(f"None of the allowed states matched: {', '.join(str(state) for state in allowed_states)}.")) + return errors + + +def _visual_state_matches(expected: str, *, text: str, frontmost_app: str, image_states: set[str]) -> bool: + raw = expected.strip().lower() + normalized = _normalize_visual_state(expected) + normalized_text = _normalize_visual_state(text) + normalized_frontmost_app = _normalize_visual_state(frontmost_app) + return ( + raw in text + or raw in frontmost_app + or normalized in normalized_text + or normalized in normalized_frontmost_app + or _visual_image_state_matches(normalized, image_states=image_states) + ) + + +def _visual_image_state_matches(expected: str, *, image_states: set[str]) -> bool: + normalized = _normalize_visual_state(expected) + aliases = {normalized} + if normalized == "calculator": + aliases.add("iphone_calculator") + if normalized == "iphone_calculator": + aliases.add("calculator") + return bool(aliases & image_states) + + +def _normalize_visual_state(value: str) -> str: + return "_".join(value.strip().lower().split()) + + +def _visual_artifact_states(artifact_path: str | None) -> set[str]: + if not artifact_path: + return set() + ratios = _image_color_ratios_with_pillow(artifact_path) + if ratios is None: + ratios = _image_color_ratios_from_png(artifact_path) + if ratios is None: + return set() + states: set[str] = set() + if ratios["orange"] > 0.02 and ratios["dark"] > 0.25 and ratios["gray"] > 0.10: + states.update({"calculator", "iphone_calculator"}) + return states + + +def _image_color_ratios_with_pillow(artifact_path: str) -> dict[str, float] | None: + try: + from PIL import Image # type: ignore[import-not-found] + except Exception: + return None + try: + with Image.open(artifact_path) as image: + rgb_image = image.convert("RGB") + rgb_image.thumbnail((360, 360)) + pixels = list(rgb_image.getdata()) + except Exception: + return None + return _color_ratios_from_pixels(pixels) + + +def _image_color_ratios_from_png(artifact_path: str) -> dict[str, float] | None: + try: + raw = Path(artifact_path).read_bytes() + except OSError: + return None + if not raw.startswith(b"\x89PNG\r\n\x1a\n"): + return None + offset = 8 + width = 0 + height = 0 + bit_depth = 0 + color_type = 0 + interlace = 0 + idat = bytearray() + while offset + 8 <= len(raw): + chunk_length = struct.unpack(">I", raw[offset : offset + 4])[0] + chunk_type = raw[offset + 4 : offset + 8] + chunk_data_start = offset + 8 + chunk_data_end = chunk_data_start + chunk_length + if chunk_data_end + 4 > len(raw): + return None + chunk_data = raw[chunk_data_start:chunk_data_end] + if chunk_type == b"IHDR": + if chunk_length != 13: + return None + try: + width, height, bit_depth, color_type, _compression, _filter, interlace = struct.unpack(">IIBBBBB", chunk_data) + except struct.error: + return None + elif chunk_type == b"IDAT": + idat.extend(chunk_data) + elif chunk_type == b"IEND": + break + offset = chunk_data_end + 4 + if width <= 0 or height <= 0 or bit_depth != 8 or interlace != 0 or color_type not in {2, 6} or not idat: + return None + channels = 4 if color_type == 6 else 3 + row_stride = width * channels + try: + inflated = zlib.decompress(bytes(idat)) + except zlib.error: + return None + expected_minimum = (row_stride + 1) * height + if len(inflated) < expected_minimum: + return None + pixels: list[tuple[int, int, int]] = [] + previous = bytearray(row_stride) + cursor = 0 + sample_every = max(1, (width * height) // 130_000) + pixel_index = 0 + for _row_index in range(height): + filter_type = inflated[cursor] + cursor += 1 + scanline = bytearray(inflated[cursor : cursor + row_stride]) + cursor += row_stride + if filter_type == 1: + _png_unfilter_sub(scanline, channels) + elif filter_type == 2: + _png_unfilter_up(scanline, previous) + elif filter_type == 3: + _png_unfilter_average(scanline, previous, channels) + elif filter_type == 4: + _png_unfilter_paeth(scanline, previous, channels) + elif filter_type != 0: + return None + for index in range(0, row_stride, channels): + if pixel_index % sample_every == 0: + pixels.append((scanline[index], scanline[index + 1], scanline[index + 2])) + pixel_index += 1 + previous = scanline + return _color_ratios_from_pixels(pixels) + + +def _color_ratios_from_pixels(pixels: list[tuple[int, int, int]]) -> dict[str, float] | None: + total = len(pixels) + if total == 0: + return None + orange = sum(1 for red, green, blue in pixels if red > 190 and 95 < green < 190 and blue < 90 and red > green + 45) + dark = sum(1 for red, green, blue in pixels if red < 45 and green < 45 and blue < 45) + gray = sum( + 1 + for red, green, blue in pixels + if 45 < red < 180 and 45 < green < 180 and 45 < blue < 180 and max(red, green, blue) - min(red, green, blue) < 35 + ) + return {"orange": orange / total, "dark": dark / total, "gray": gray / total} + + +def _png_unfilter_sub(scanline: bytearray, bytes_per_pixel: int) -> None: + for index in range(bytes_per_pixel, len(scanline)): + scanline[index] = (scanline[index] + scanline[index - bytes_per_pixel]) & 0xFF + + +def _png_unfilter_up(scanline: bytearray, previous: bytearray) -> None: + for index in range(len(scanline)): + scanline[index] = (scanline[index] + previous[index]) & 0xFF + + +def _png_unfilter_average(scanline: bytearray, previous: bytearray, bytes_per_pixel: int) -> None: + for index in range(len(scanline)): + left = scanline[index - bytes_per_pixel] if index >= bytes_per_pixel else 0 + up = previous[index] + scanline[index] = (scanline[index] + ((left + up) // 2)) & 0xFF + + +def _png_unfilter_paeth(scanline: bytearray, previous: bytearray, bytes_per_pixel: int) -> None: + for index in range(len(scanline)): + left = scanline[index - bytes_per_pixel] if index >= bytes_per_pixel else 0 + up = previous[index] + upper_left = previous[index - bytes_per_pixel] if index >= bytes_per_pixel else 0 + scanline[index] = (scanline[index] + _paeth_predictor(left, up, upper_left)) & 0xFF + + +def _paeth_predictor(left: int, up: int, upper_left: int) -> int: + estimate = left + up - upper_left + distance_left = abs(estimate - left) + distance_up = abs(estimate - up) + distance_upper_left = abs(estimate - upper_left) + if distance_left <= distance_up and distance_left <= distance_upper_left: + return left + if distance_up <= distance_upper_left: + return up + return upper_left + + +def _visual_assertion_error(message: str) -> dict[str, str]: + return { + "code": "qa_visual_assertion_failed", + "message": message, + "guidance": "Run a fresh see command, verify the target app/screen, then retry the scenario action.", + } + + +def _visible_text(data: dict[str, Any]) -> str: + chunks: list[str] = [] + + def collect(value: Any) -> None: + if isinstance(value, str): + chunks.append(value) + return + if isinstance(value, list): + for item in value: + collect(item) + return + if isinstance(value, dict): + for key in ("label", "title", "text", "value", "name", "description", "frontmost_app", "app", "active_app"): + nested = value.get(key) + if isinstance(nested, str): + chunks.append(nested) + for key in ("elements", "items", "nodes", "screenshot", "image"): + if key in value: + collect(value[key]) + + collect(data) + return " ".join(chunks) + + +class _UnresolvedPlaceholder(ValueError): + pass + + +def _skipped_result(step: CanaryStep, message: str) -> CanaryResult: + return CanaryResult( + id=step.id, + suite=step.suite, + lane=step.lane, + command=step.command, + params_redacted=step.params, + ok=False, + status="skipped", + audit_id=None, + engine=None, + snapshot_id=None, + artifact_path=None, + duration_ms=0, + errors=[], + warnings=[message], + payload={}, + ) + + +def _failed_result(step: CanaryStep, message: str, *, code: str = "qa_placeholder_unresolved") -> CanaryResult: + return CanaryResult( + id=step.id, + suite=step.suite, + lane=step.lane, + command=step.command, + params_redacted=step.params, + ok=False, + status="failed", + audit_id=None, + engine=None, + snapshot_id=None, + artifact_path=None, + duration_ms=0, + errors=[{"code": code, "message": message, "guidance": "Inspect previous canary steps."}], + warnings=[], + payload={}, + ) + + +def _summary(results: list[CanaryResult]) -> dict[str, int]: + return { + "total": len(results), + "passed": sum(1 for result in results if result.status == "passed"), + "failed": sum(1 for result in results if result.status == "failed"), + "skipped": sum(1 for result in results if result.status == "skipped"), + } + + +def _run_successful(results: list[CanaryResult], *, allow_skips: bool) -> bool: + if any(result.status == "failed" for result in results): + return False + if allow_skips: + return True + return not any(result.status == "skipped" and result.suite != "real_world_optional" for result in results) + + +def _markdown_report(report: dict[str, Any]) -> str: + summary = report["summary"] + lines = [ + "# evaOS Workbench QA Canary Report", + "", + f"- Run: `{report['run_id']}`", + f"- Version under test: `{report['version_under_test']}`", + f"- Surface: `{report['surface']}`", + f"- Connector: `{report['connector_url_redacted']}`", + f"- Summary: {summary['passed']} passed, {summary['failed']} failed, {summary['skipped']} skipped, {summary['total']} total", + "", + "| Status | Lane | Suite | Step | Command | Audit | Engine | Snapshot | Artifact | Duration |", + "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |", + ] + for result in report["results"]: + lines.append( + "| {status} | {lane} | {suite} | `{id}` | `{command}` | `{audit}` | `{engine}` | `{snapshot}` | `{artifact}` | {duration}ms |".format( + status=result["status"], + lane=result.get("lane") or "", + suite=result["suite"], + id=result["id"], + command=result["command"], + audit=result.get("audit_id") or "", + engine=result.get("engine") or "", + snapshot=result.get("snapshot_id") or "", + artifact=result.get("artifact_path") or "", + duration=result.get("duration_ms") or 0, + ) + ) + lines.append("") + return "\n".join(lines) + + +def _payload_from_completed_process(completed: subprocess.CompletedProcess[str], command: str) -> dict[str, Any]: + stdout = (completed.stdout or "").strip() + if stdout: + try: + payload = json.loads(stdout) + if isinstance(payload, dict): + return payload + except json.JSONDecodeError: + pass + message = (completed.stderr or stdout or f"adapter exited {completed.returncode}").strip() + return _error_payload(command=command, code="qa_adapter_failed", message=message) + + +def _loads_json_response(body: bytes) -> dict[str, Any]: + parsed = json.loads(body.decode("utf-8")) + if not isinstance(parsed, dict): + raise ValueError("response body must be a JSON object") + return parsed + + +def _error_payload(*, command: str, code: str, message: str) -> dict[str, Any]: + return { + "schema_version": "2026-05-02.mvp1", + "command": command, + "target": "customer_mac", + "timestamp": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"), + "ok": False, + "data": {}, + "warnings": [], + "errors": [{"code": code, "message": message, "guidance": "Inspect the QA canary report and connector logs."}], + "audit_id": "qa-adapter-failed", + } + + +def _extract_string(source: dict[str, Any], paths: tuple[str, ...]) -> str | None: + for path in paths: + value: Any = source + for part in path.split("."): + if not isinstance(value, dict): + value = None + break + value = value.get(part) + if isinstance(value, str) and value.strip(): + return value.strip() + return None + + +def _safe_filename(value: str) -> str: + return "".join(char if char.isalnum() or char in "._-" else "-" for char in value)[:160] or "artifact" + + +def _resolve_repo_root() -> Path: + candidates: list[Path] = [] + env_root = os.environ.get("EVAOS_DESKTOP_BRIDGE_QA_REPO_ROOT") + if env_root: + candidates.append(Path(env_root).expanduser()) + candidates.append(Path.cwd()) + candidates.extend(Path.cwd().parents) + module_path = Path(__file__).resolve() + candidates.extend(module_path.parents) + for candidate in candidates: + if (candidate / "openclaw-plugin" / "dist" / "index.js").exists() and (candidate / "hermes-adapter" / "bin" / "evaos-desktop-bridge-command").exists(): + return candidate + return Path.cwd() + + +def _secret_values() -> list[str]: + values = [] + for key, value in os.environ.items(): + lowered = key.lower() + if value and ("token" in lowered or "secret" in lowered or key in REAL_WORLD_ENV_KEYS): + values.append(value) + return values + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/resources/evaos-beta/bridge/src/evaos_desktop_bridge/queue.py b/resources/evaos-beta/bridge/src/evaos_desktop_bridge/queue.py new file mode 100644 index 0000000000..74510306b2 --- /dev/null +++ b/resources/evaos-beta/bridge/src/evaos_desktop_bridge/queue.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import json +import uuid +from pathlib import Path +from typing import Any + +from .audit import default_state_dir +from .redaction import redact_value +from .schema import SCHEMA_VERSION, make_error, timestamp_utc +from .types import CommandResult + +QUEUE_FILE = "queue.jsonl" +ALLOWED_QUEUE_KINDS = frozenset({"idle", "approval_needed", "done", "error", "attention"}) + + +def append_queue_event( + *, + kind: str, + source_audit_id: str, + message: str | None = None, + payload: dict[str, Any] | None = None, + state_dir: Path | None = None, +) -> CommandResult: + if kind not in ALLOWED_QUEUE_KINDS: + return CommandResult( + ok=False, + errors=[ + make_error( + code="queue_kind_not_allowed", + message=f"Queue kind '{kind}' is not allowlisted.", + guidance=f"Use one of: {', '.join(sorted(ALLOWED_QUEUE_KINDS))}.", + ) + ], + provenance={"source": "queue"}, + ) + if not source_audit_id.startswith("audit-"): + return CommandResult( + ok=False, + errors=[ + make_error( + code="invalid_source_audit_id", + message="Queue events must reference a bridge audit id.", + guidance="Pass --source-audit-id with an audit-... value from a prior bridge command.", + ) + ], + provenance={"source": "queue"}, + ) + + root = state_dir or default_state_dir() + root.mkdir(parents=True, exist_ok=True) + record = { + "schema_version": SCHEMA_VERSION, + "queue_id": f"queue-{uuid.uuid4().hex}", + "timestamp": timestamp_utc(), + "kind": kind, + "source_audit_id": source_audit_id, + "message": message, + "payload": redact_value(payload or {}), + "status": "pending", + } + with (root / QUEUE_FILE).open("a", encoding="utf-8") as handle: + handle.write(json.dumps(redact_value(record), sort_keys=True, separators=(",", ":")) + "\n") + return CommandResult(ok=True, data={"event": record}, provenance={"source": "queue", "source_audit_id": source_audit_id}) + + +def list_queue_events(*, limit: int = 20, state_dir: Path | None = None) -> CommandResult: + root = state_dir or default_state_dir() + path = root / QUEUE_FILE + if not path.exists(): + return CommandResult(ok=True, data={"events": [], "count": 0, "limit": limit}, provenance={"source": "queue"}) + lines = path.read_text(encoding="utf-8").splitlines() + events: list[dict[str, Any]] = [] + for line in lines[-limit:]: + if not line.strip(): + continue + events.append(redact_value(json.loads(line))) + return CommandResult(ok=True, data={"events": events, "count": len(events), "limit": limit}, provenance={"source": "queue"}) diff --git a/resources/evaos-beta/bridge/src/evaos_desktop_bridge/redaction.py b/resources/evaos-beta/bridge/src/evaos_desktop_bridge/redaction.py new file mode 100644 index 0000000000..e3f768585e --- /dev/null +++ b/resources/evaos-beta/bridge/src/evaos_desktop_bridge/redaction.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + +SECRET_PATTERNS = ( + re.compile(r"sk-[A-Za-z0-9_-]{10,}"), + re.compile(r"(Bearer\s+)[A-Za-z0-9._-]{10,}", re.IGNORECASE), + re.compile(r"(?i)(authorization:\s*)[^\s]+"), +) +GENERIC_HOME_PATTERN = re.compile(r"/Users/[^/\s]+") + + +def cap_text(text: str | None, max_chars: int) -> tuple[str | None, bool]: + if text is None: + return None, False + if max_chars < 0: + max_chars = 0 + if len(text) <= max_chars: + return text, False + return text[:max_chars], True + + +def redact_string(value: str) -> str: + redacted = value.replace(str(Path.home()), "~") + redacted = GENERIC_HOME_PATTERN.sub("~", redacted) + redacted = SECRET_PATTERNS[0].sub("", redacted) + redacted = SECRET_PATTERNS[1].sub(r"\1", redacted) + redacted = SECRET_PATTERNS[2].sub(r"\1", redacted) + return redacted + + +def redact_value(value: Any) -> Any: + if isinstance(value, str): + return redact_string(value) + if isinstance(value, Path): + return redact_string(str(value)) + if isinstance(value, dict): + return {str(key): redact_value(item) for key, item in value.items()} + if isinstance(value, list): + return [redact_value(item) for item in value] + if isinstance(value, tuple): + return [redact_value(item) for item in value] + return value diff --git a/resources/evaos-beta/bridge/src/evaos_desktop_bridge/schema.py b/resources/evaos-beta/bridge/src/evaos_desktop_bridge/schema.py new file mode 100644 index 0000000000..1dbd9cdf1b --- /dev/null +++ b/resources/evaos-beta/bridge/src/evaos_desktop_bridge/schema.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any + +from .redaction import redact_value + +SCHEMA_VERSION = "2026-05-02.mvp1" + + +def timestamp_utc() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def make_error( + *, + code: str, + message: str, + guidance: str, + permission: str | None = None, +) -> dict[str, Any]: + error: dict[str, Any] = { + "code": code, + "message": message, + "guidance": guidance, + } + if permission is not None: + error["permission"] = permission + return error + + +def build_envelope( + *, + command: str, + target: str, + ok: bool, + data: dict[str, Any], + warnings: list[str], + errors: list[dict[str, Any]], + audit_id: str, + timestamp: str | None = None, +) -> dict[str, Any]: + return { + "schema_version": SCHEMA_VERSION, + "command": command, + "target": target, + "timestamp": timestamp or timestamp_utc(), + "ok": ok, + "data": redact_value(data), + "warnings": redact_value(warnings), + "errors": redact_value(errors), + "audit_id": audit_id, + } diff --git a/resources/evaos-beta/bridge/src/evaos_desktop_bridge/state.py b/resources/evaos-beta/bridge/src/evaos_desktop_bridge/state.py new file mode 100644 index 0000000000..d7a8a766f6 --- /dev/null +++ b/resources/evaos-beta/bridge/src/evaos_desktop_bridge/state.py @@ -0,0 +1,238 @@ +from __future__ import annotations + +import json +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +from .audit import default_state_dir +from .redaction import redact_value + +LATEST_FILE = "latest.json" +AUDIT_FILE = "audit.jsonl" +CONTROL_SESSION_FILE = "control-session.json" +APPROVAL_AUDIT_MAX_AGE_SECONDS = 15 * 60 +CONTROL_MODES = {"full_access", "ask_permission"} +TAKEOVER_WARNING_SECONDS = 10 + + +def latest_path(state_dir: Path | None = None) -> Path: + return (state_dir or default_state_dir()) / LATEST_FILE + + +def write_latest(envelope: dict[str, Any], state_dir: Path | None = None) -> Path: + root = state_dir or default_state_dir() + root.mkdir(parents=True, exist_ok=True) + path = root / LATEST_FILE + path.write_text(json.dumps(redact_value(envelope), sort_keys=True) + "\n", encoding="utf-8") + return path + + +def read_latest(state_dir: Path | None = None) -> dict[str, Any] | None: + path = latest_path(state_dir) + if not path.exists(): + return None + return redact_value(json.loads(path.read_text(encoding="utf-8"))) + + +def read_audit_tail(limit: int = 20, state_dir: Path | None = None) -> list[dict[str, Any]]: + if limit < 1: + raise ValueError("limit must be >= 1") + root = state_dir or default_state_dir() + path = root / AUDIT_FILE + if not path.exists(): + return [] + lines = path.read_text(encoding="utf-8").splitlines() + records: list[dict[str, Any]] = [] + for line in lines[-limit:]: + if not line.strip(): + continue + records.append(redact_value(json.loads(line))) + return records + + +def read_audit_record(audit_id: str, state_dir: Path | None = None) -> dict[str, Any] | None: + if not isinstance(audit_id, str) or not audit_id.startswith("audit-"): + return None + root = state_dir or default_state_dir() + path = root / AUDIT_FILE + if not path.exists(): + return None + for line in path.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + try: + record = json.loads(line) + except json.JSONDecodeError: + continue + if record.get("audit_id") == audit_id: + return redact_value(record) + return None + + +def approval_audit_freshness_error(record: dict[str, Any], *, max_age_seconds: int = APPROVAL_AUDIT_MAX_AGE_SECONDS) -> str | None: + timestamp = record.get("timestamp") + if not isinstance(timestamp, str) or not timestamp.strip(): + return "approval_audit_id has no timestamp; run a new dry-run." + try: + parsed = datetime.fromisoformat(timestamp.replace("Z", "+00:00")) + except ValueError: + return "approval_audit_id has an invalid timestamp; run a new dry-run." + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + age_seconds = (datetime.now(timezone.utc) - parsed.astimezone(timezone.utc)).total_seconds() + if age_seconds < -60: + return "approval_audit_id timestamp is in the future; run a new dry-run." + if age_seconds > max_age_seconds: + minutes = max(1, max_age_seconds // 60) + return f"approval_audit_id is older than {minutes} minutes; run a new dry-run." + return None + + +def control_session_path(state_dir: Path | None = None) -> Path: + return (state_dir or default_state_dir()) / CONTROL_SESSION_FILE + + +def default_control_session() -> dict[str, Any]: + return { + "active": False, + "mode": "ask_permission", + "agent_label": None, + "started_at": None, + "stopped_at": None, + "kill_switch": False, + "takeover_warning_started_at": None, + "takeover_warning_until": None, + "takeover_warning_seconds": TAKEOVER_WARNING_SECONDS, + "takeover_alert_signal_status": {}, + } + + +def read_control_session(state_dir: Path | None = None) -> dict[str, Any]: + path = control_session_path(state_dir) + if not path.exists(): + return annotate_control_session(default_control_session()) + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return annotate_control_session(default_control_session()) + if not isinstance(payload, dict): + return annotate_control_session(default_control_session()) + merged = default_control_session() + merged.update(redact_value(payload)) + if merged.get("mode") not in CONTROL_MODES: + merged["mode"] = "ask_permission" + merged["active"] = bool(merged.get("active")) + merged["kill_switch"] = bool(merged.get("kill_switch")) + return annotate_control_session(merged) + + +def write_control_session(payload: dict[str, Any], state_dir: Path | None = None) -> dict[str, Any]: + root = state_dir or default_state_dir() + root.mkdir(parents=True, exist_ok=True) + normalized = default_control_session() + normalized.update(payload) + normalized.pop("ready", None) + normalized.pop("takeover_warning", None) + if normalized.get("mode") not in CONTROL_MODES: + normalized["mode"] = "ask_permission" + path = root / CONTROL_SESSION_FILE + path.write_text(json.dumps(redact_value(normalized), sort_keys=True) + "\n", encoding="utf-8") + return annotate_control_session(normalized) + + +def start_control_session(*, mode: str, agent_label: str | None = None, state_dir: Path | None = None) -> dict[str, Any]: + normalized_mode = mode if mode in CONTROL_MODES else "ask_permission" + existing = read_control_session(state_dir) + now = datetime.now(timezone.utc).replace(microsecond=0) + warning = existing.get("takeover_warning") if isinstance(existing.get("takeover_warning"), dict) else {} + if existing.get("active") is True and warning.get("active") is True: + warning_started = existing.get("takeover_warning_started_at") + warning_until = existing.get("takeover_warning_until") + else: + warning_started = now.isoformat().replace("+00:00", "Z") + warning_until = (now + timedelta(seconds=TAKEOVER_WARNING_SECONDS)).isoformat().replace("+00:00", "Z") + return write_control_session( + { + "active": True, + "mode": normalized_mode, + "agent_label": agent_label.strip()[:160] if isinstance(agent_label, str) and agent_label.strip() else None, + "started_at": now.isoformat().replace("+00:00", "Z"), + "stopped_at": None, + "kill_switch": False, + "takeover_warning_started_at": warning_started, + "takeover_warning_until": warning_until, + "takeover_warning_seconds": TAKEOVER_WARNING_SECONDS, + }, + state_dir=state_dir, + ) + + +def stop_control_session(state_dir: Path | None = None) -> dict[str, Any]: + session = read_control_session(state_dir) + session["active"] = False + session["stopped_at"] = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + session["takeover_warning_started_at"] = None + session["takeover_warning_until"] = None + return write_control_session(session, state_dir=state_dir) + + +def kill_control_session(state_dir: Path | None = None) -> dict[str, Any]: + session = stop_control_session(state_dir) + session["kill_switch"] = True + return write_control_session(session, state_dir=state_dir) + + +def annotate_control_session(session: dict[str, Any]) -> dict[str, Any]: + session = dict(session) + warning = takeover_warning_state(session) + signal_status = session.get("takeover_alert_signal_status") + if isinstance(signal_status, dict): + warning["signal_status"] = signal_status + session["takeover_warning"] = warning + session["ready"] = bool(session.get("active")) and not bool(session.get("kill_switch")) and not warning["active"] + return session + + +def takeover_warning_state(session: dict[str, Any]) -> dict[str, Any]: + seconds = TAKEOVER_WARNING_SECONDS + raw_seconds = session.get("takeover_warning_seconds") + if isinstance(raw_seconds, int) and raw_seconds > 0: + seconds = raw_seconds + started_at = session.get("takeover_warning_started_at") + until = session.get("takeover_warning_until") + if not session.get("active") or not isinstance(until, str) or not until.strip(): + return { + "active": False, + "seconds": seconds, + "remaining_seconds": 0, + "started_at": started_at if isinstance(started_at, str) else None, + "until": until if isinstance(until, str) else None, + } + parsed = _parse_control_timestamp(until) + if parsed is None: + return { + "active": False, + "seconds": seconds, + "remaining_seconds": 0, + "started_at": started_at if isinstance(started_at, str) else None, + "until": until, + } + remaining = (parsed - datetime.now(timezone.utc)).total_seconds() + return { + "active": remaining > 0, + "seconds": seconds, + "remaining_seconds": max(0, int(remaining + 0.999)), + "started_at": started_at if isinstance(started_at, str) else None, + "until": until, + } + + +def _parse_control_timestamp(value: str) -> datetime | None: + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) diff --git a/resources/evaos-beta/bridge/src/evaos_desktop_bridge/types.py b/resources/evaos-beta/bridge/src/evaos_desktop_bridge/types.py new file mode 100644 index 0000000000..a55e77da0e --- /dev/null +++ b/resources/evaos-beta/bridge/src/evaos_desktop_bridge/types.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class CommandResult: + ok: bool + data: dict[str, Any] = field(default_factory=dict) + warnings: list[str] = field(default_factory=list) + errors: list[dict[str, Any]] = field(default_factory=list) + provenance: dict[str, Any] = field(default_factory=dict) diff --git a/scripts/afterPack.js b/scripts/afterPack.js index 0d19819c7c..366e9f1d88 100644 --- a/scripts/afterPack.js +++ b/scripts/afterPack.js @@ -11,7 +11,7 @@ const { } = require('./rebuildNativeModules'); const { normalizeManagedResourcesBundle } = require('../packages/shared-scripts/src/prepare-aioncore.js'); const { clearDmgRetryCompletionMarkers, markCompletedAfterPack } = require('./dmgRetryEligibility'); -const { verifyPythonRuntimeInventory } = require('./prepareEvaosDesktopBridgeResource'); +const { verifyPythonRuntimeInventory, verifyWorkbenchBridgeIdentity } = require('./prepareEvaosDesktopBridgeResource'); /** * afterPack hook for electron-builder @@ -378,6 +378,26 @@ function verifyEvaosDesktopBridgeResource(resourcesDir, electronPlatformName, ta throw new Error('Packaged evaOS desktop bridge is a diagnostic placeholder; release builds require a real bridge.'); } if (strictReleaseBridge) { + const expectedSourceCommit = String( + process.env.EVAOS_DESKTOP_BRIDGE_SOURCE_REF || manifest.sourceCommit || '' + ).trim(); + const sourceProvenance = manifest.sourceProvenance; + const packagedSourceIdentity = verifyWorkbenchBridgeIdentity( + path.join(resourcesDir, 'Bridge', 'src', 'evaos_desktop_bridge') + ); + if ( + !/^[0-9a-f]{40}$/i.test(expectedSourceCommit) || + manifest.sourceCommit !== expectedSourceCommit || + manifest.requestedSourceRef !== expectedSourceCommit || + manifest.sourcePath !== 'resources/evaos-beta/bridge' || + sourceProvenance?.schema !== 'evaos-workbench-vendored-bridge-source/v1' || + sourceProvenance?.owner !== '100yenadmin/evaOS-GUI' || + sourceProvenance?.status !== 'vendored' || + !/^[0-9a-f]{40}$/i.test(String(sourceProvenance?.importedCommit || '')) || + sourceProvenance?.sourceSha256 !== packagedSourceIdentity.sourceSha256 + ) { + throw new Error('Packaged evaOS desktop bridge is not bound to the exact evaOS-GUI-owned source.'); + } if (!targetArch || !PYTHON_RUNTIME_SOURCE_SHA256_BY_ARCH[targetArch]) { throw new Error('Packaged evaOS desktop bridge target architecture is required for strict release validation.'); } diff --git a/scripts/prepareEvaosDesktopBridgeResource.js b/scripts/prepareEvaosDesktopBridgeResource.js index 15be7f2c76..c0096501ec 100644 --- a/scripts/prepareEvaosDesktopBridgeResource.js +++ b/scripts/prepareEvaosDesktopBridgeResource.js @@ -9,9 +9,8 @@ const projectRoot = path.resolve(__dirname, '..'); const bridgeResourceDir = process.env.EVAOS_DESKTOP_BRIDGE_RESOURCE_DIR ? path.resolve(process.env.EVAOS_DESKTOP_BRIDGE_RESOURCE_DIR) : path.join(projectRoot, 'resources', 'Bridge'); -const bridgeSourceCacheDir = path.join(projectRoot, '.cache', 'evaos-desktop-bridge-source'); -const defaultBridgeSourceRepo = 'https://github.com/electricsheephq/evaos-desktop-bridge.git'; -const defaultBridgeSourceRef = 'main'; +const vendoredBridgeSourceDir = path.join(projectRoot, 'resources', 'evaos-beta', 'bridge'); +const vendoredBridgeProvenancePath = path.join(vendoredBridgeSourceDir, 'SOURCE.json'); const PLACEHOLDER_SOURCE = 'diagnostic-placeholder'; const PEEKABOO_LICENSE_RELATIVE_PATH = 'licenses/Peekaboo-LICENSE.txt'; const PYTHON_LICENSE_RELATIVE_PATH = 'licenses/CPython-LICENSE.txt'; @@ -50,38 +49,19 @@ function shouldRequireRealBridge() { ); } -function selectedBridgeSourceRef() { - return String(process.env.EVAOS_DESKTOP_BRIDGE_SOURCE_REF || '').trim() || defaultBridgeSourceRef; -} - -function isMutableBridgeSourceRef(ref) { - const normalized = String(ref || '') - .trim() - .toLowerCase(); - return !normalized || normalized === 'main' || normalized === 'master' || normalized === 'head'; +function selectedBridgeSourceRef(sourceCommit) { + return String(process.env.EVAOS_DESKTOP_BRIDGE_SOURCE_REF || sourceCommit || '').trim(); } function isFullCommitSha(ref) { return /^[0-9a-f]{40}$/i.test(String(ref || '').trim()); } -function shouldCloneBridgeRefAsBranch(ref) { - return !isFullCommitSha(ref); -} - function sourceCandidates() { - if (process.env.EVAOS_DESKTOP_BRIDGE_SOURCE_DIR) { - return [process.env.EVAOS_DESKTOP_BRIDGE_SOURCE_DIR]; - } if (process.env.EVAOS_DESKTOP_BRIDGE_DISABLE_DEFAULT_CANDIDATES === '1') { return []; } - if (process.env.EVAOS_DESKTOP_BRIDGE_SOURCE_REF) { - return []; - } - return [path.resolve(projectRoot, '..', 'evaos-desktop-bridge'), '/Volumes/LEXAR/repos/evaos-desktop-bridge'].filter( - Boolean - ); + return [vendoredBridgeSourceDir]; } function resolveBridgeSourceDir() { @@ -91,88 +71,15 @@ function resolveBridgeSourceDir() { return sourceDir; } } - return prepareBridgeSourceCheckout(); -} - -function prepareBridgeSourceCheckout() { - const repo = process.env.EVAOS_DESKTOP_BRIDGE_SOURCE_REPO || defaultBridgeSourceRepo; - const ref = selectedBridgeSourceRef(); - if (shouldRequireRealBridge() && isMutableBridgeSourceRef(ref)) { - throw new Error( - [ - 'Release builds require a pinned evaos-desktop-bridge source ref.', - 'Set EVAOS_DESKTOP_BRIDGE_SOURCE_REF to an approved tag or commit SHA, not main/master/HEAD.', - ].join(' ') - ); - } - const cloneRepo = repoWithToken(repo); - console.log(`evaos-desktop-bridge source was not found locally; fetching ${sanitizeRepoForLog(repo)}#${ref}`); - fs.rmSync(bridgeSourceCacheDir, { recursive: true, force: true }); - fs.mkdirSync(path.dirname(bridgeSourceCacheDir), { recursive: true }); - - if (shouldCloneBridgeRefAsBranch(ref)) { - try { - runGit(['clone', '--depth', '1', '--branch', ref, cloneRepo, bridgeSourceCacheDir], projectRoot, repo); - } catch { - fs.rmSync(bridgeSourceCacheDir, { recursive: true, force: true }); - runGit(['clone', '--depth', '1', cloneRepo, bridgeSourceCacheDir], projectRoot, repo); - runGit(['fetch', '--depth', '1', 'origin', ref], bridgeSourceCacheDir, repo); - runGit(['checkout', '--detach', 'FETCH_HEAD'], bridgeSourceCacheDir, repo); - } - } else { - runGit(['clone', '--depth', '1', cloneRepo, bridgeSourceCacheDir], projectRoot, repo); - runGit(['fetch', '--depth', '1', 'origin', ref], bridgeSourceCacheDir, repo); - runGit(['checkout', '--detach', 'FETCH_HEAD'], bridgeSourceCacheDir, repo); - } - - if (fs.existsSync(path.join(bridgeSourceCacheDir, 'src', 'evaos_desktop_bridge', 'cli.py'))) { - return bridgeSourceCacheDir; - } - throw new Error( [ - 'evaos-desktop-bridge source was not found.', - 'Set EVAOS_DESKTOP_BRIDGE_SOURCE_DIR to a checkout that contains src/evaos_desktop_bridge/cli.py,', - 'or set EVAOS_DESKTOP_BRIDGE_SOURCE_REPO/EVAOS_DESKTOP_BRIDGE_SOURCE_REF to a reachable bridge source.', + 'The evaOS-GUI-owned Workbench bridge source is missing.', + `Expected src/evaos_desktop_bridge/cli.py under ${vendoredBridgeSourceDir}.`, + 'Release builds do not fetch the deprecated external bridge repository.', ].join(' ') ); } -function repoWithToken(repo) { - const token = process.env.EVAOS_DESKTOP_BRIDGE_SOURCE_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_TOKEN || ''; - if (!token || !repo.startsWith('https://github.com/')) { - return repo; - } - return repo.replace('https://github.com/', `https://x-access-token:${encodeURIComponent(token)}@github.com/`); -} - -function sanitizeRepoForLog(repo) { - return repo.replace(/https:\/\/[^/@]+:[^/@]+@github\.com\//, 'https://github.com/'); -} - -function sanitizeCommandText(value, repo) { - return String(value || '') - .replaceAll(repoWithToken(repo), sanitizeRepoForLog(repo)) - .replace(/https:\/\/x-access-token:[^/@]+@github\.com\//g, 'https://github.com/'); -} - -function runGit(args, cwd, repoForRedaction) { - try { - return execFileSync('git', args, { - cwd, - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - }); - } catch (error) { - const stdout = sanitizeCommandText(error.stdout, repoForRedaction).trim(); - const stderr = sanitizeCommandText(error.stderr, repoForRedaction).trim(); - if (stdout) console.error(stdout); - if (stderr) console.error(stderr); - error.message = sanitizeCommandText(error.message, repoForRedaction); - throw error; - } -} - function gitValue(cwd, args) { try { return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); @@ -191,6 +98,77 @@ function copyDirectory(source, target) { }); } +function directorySha256(sourceDir) { + const source = path.resolve(sourceDir); + const entries = []; + const pending = [source]; + while (pending.length > 0) { + const directory = pending.pop(); + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + if (entry.name === '__pycache__') continue; + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + pending.push(entryPath); + } else if (entry.isFile()) { + entries.push(entryPath); + } else { + throw new Error(`Vendored Workbench bridge contains an unsupported filesystem entry: ${entryPath}`); + } + } + } + const hash = crypto.createHash('sha256'); + for (const entryPath of entries.sort()) { + const relativePath = path.relative(source, entryPath).split(path.sep).join('/'); + hash.update(relativePath); + hash.update('\0'); + hash.update(fs.readFileSync(entryPath)); + hash.update('\0'); + } + return hash.digest('hex'); +} + +function verifyWorkbenchBridgeIdentity(bridgePackageDir) { + const packageDir = path.resolve(bridgePackageDir); + const adapterPath = path.join(packageDir, 'adapters', 'customer_mac.py'); + const adapter = fs.readFileSync(adapterPath, 'utf8'); + const requiredIdentity = [ + 'WORKBENCH_CANONICAL_APP_PATH = Path("/Applications/evaOS Workbench.app")', + 'WORKBENCH_PROCESS_NAME = "evaOS Workbench"', + '"com.evaos.workbench",', + ]; + for (const requiredText of requiredIdentity) { + if (adapter.split(requiredText).length !== 2) { + throw new Error(`Vendored Workbench bridge identity is missing or ambiguous: ${requiredText}`); + } + } + if (adapter.includes('WORKBENCH_CANONICAL_APP_PATH = Path("/Applications/evaOS.app")')) { + throw new Error('Vendored Workbench bridge still targets the legacy /Applications/evaOS.app bundle.'); + } + return { sourceSha256: directorySha256(packageDir) }; +} + +function vendoredBridgeSourceMetadata(sourceDir = vendoredBridgeSourceDir) { + const resolvedSourceDir = path.resolve(sourceDir); + if (resolvedSourceDir !== path.resolve(vendoredBridgeSourceDir)) { + throw new Error('Workbench builds require the evaOS-GUI-owned vendored bridge source.'); + } + + const provenance = JSON.parse(fs.readFileSync(vendoredBridgeProvenancePath, 'utf8')); + if ( + provenance.schema !== 'evaos-workbench-vendored-bridge-source/v1' || + provenance.owner !== '100yenadmin/evaOS-GUI' || + provenance.status !== 'vendored' || + !isFullCommitSha(provenance.importedCommit) + ) { + throw new Error('Vendored Workbench bridge SOURCE.json is missing required ownership provenance.'); + } + + return { + ...provenance, + ...verifyWorkbenchBridgeIdentity(path.join(resolvedSourceDir, 'src', 'evaos_desktop_bridge')), + }; +} + function pythonRuntimeInventoryEntries(runtimeDir) { const resolvedRuntimeDir = path.resolve(runtimeDir); const runtimeMetadata = fs.lstatSync(resolvedRuntimeDir); @@ -555,7 +533,7 @@ if [ "\${1:-}" = "--version" ] || [ "\${1:-}" = "version" ]; then fi echo "evaos-desktop-bridge diagnostic placeholder: ${escapeForShellDoubleQuotes(reason)}" >&2 -echo "This PR/build artifact is not valid for Mac pairing release proof. Configure EVAOS_DESKTOP_BRIDGE_SOURCE_DIR or EVAOS_DESKTOP_BRIDGE_SOURCE_TOKEN for a real release build." >&2 +echo "This PR/build artifact is not valid for Mac pairing release proof. Restore the evaOS-GUI-owned vendored Workbench bridge source." >&2 exit 78 ` ); @@ -574,10 +552,19 @@ function writeManifest(manifest) { fs.writeFileSync(path.join(bridgeResourceDir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`); } -function bridgeManifest({ sourcePath, sourceCommit, sourceBranch, placeholder, placeholderReason, bundledTools }) { +function bridgeManifest({ + requestedSourceRef, + sourcePath, + sourceCommit, + sourceBranch, + sourceProvenance, + placeholder, + placeholderReason, + bundledTools, +}) { const manifest = { schema: 'evaos-desktop-bridge-resource/v1', - requestedSourceRef: selectedBridgeSourceRef(), + requestedSourceRef: selectedBridgeSourceRef(requestedSourceRef || sourceCommit), sourcePath, sourceCommit, sourceBranch, @@ -590,6 +577,9 @@ function bridgeManifest({ sourcePath, sourceCommit, sourceBranch, placeholder, p if (placeholderReason !== undefined) { manifest.placeholderReason = placeholderReason; } + if (sourceProvenance !== undefined) { + manifest.sourceProvenance = sourceProvenance; + } return manifest; } @@ -662,7 +652,10 @@ function preparePlaceholderBridgeResource(error) { throw error; } - const reason = sanitizeCommandText(error?.message || 'bridge source unavailable', defaultBridgeSourceRepo); + const reason = String(error?.message || 'bridge source unavailable').replace( + /https:\/\/x-access-token:[^/@]+@github\.com\//g, + 'https://github.com/' + ); console.warn('::warning::Using evaOS desktop bridge diagnostic placeholder for non-release build smoke.'); fs.rmSync(bridgeResourceDir, { recursive: true, force: true }); fs.mkdirSync(path.join(bridgeResourceDir, 'bin'), { recursive: true }); @@ -705,6 +698,17 @@ function main() { const bridgePackageTarget = path.join(bridgeResourceDir, 'src', 'evaos_desktop_bridge'); const bridgeBinDir = path.join(bridgeResourceDir, 'bin'); + const sourceProvenance = vendoredBridgeSourceMetadata(bridgeSourceDir); + const sourceRepositoryRoot = + path.resolve(bridgeSourceDir) === path.resolve(vendoredBridgeSourceDir) ? projectRoot : bridgeSourceDir; + const sourceCommit = gitValue(sourceRepositoryRoot, ['rev-parse', 'HEAD']); + const requestedSourceRef = selectedBridgeSourceRef(sourceCommit); + if (shouldRequireRealBridge() && (!isFullCommitSha(sourceCommit) || requestedSourceRef !== sourceCommit)) { + throw new Error( + 'Release builds require the vendored Workbench bridge manifest to match the exact evaOS-GUI commit.' + ); + } + fs.rmSync(bridgeResourceDir, { recursive: true, force: true }); fs.mkdirSync(bridgeBinDir, { recursive: true }); copyDirectory(bridgePackageSource, bridgePackageTarget); @@ -734,9 +738,14 @@ function main() { if (pythonRuntime) bundledTools.python = pythonRuntime; const manifest = bridgeManifest({ - sourcePath: bridgeSourceDir, - sourceCommit: gitValue(bridgeSourceDir, ['rev-parse', 'HEAD']), - sourceBranch: gitValue(bridgeSourceDir, ['rev-parse', '--abbrev-ref', 'HEAD']), + requestedSourceRef, + sourcePath: + path.resolve(bridgeSourceDir) === path.resolve(vendoredBridgeSourceDir) + ? path.relative(projectRoot, bridgeSourceDir).split(path.sep).join('/') + : bridgeSourceDir, + sourceCommit, + sourceBranch: gitValue(sourceRepositoryRoot, ['rev-parse', '--abbrev-ref', 'HEAD']), + sourceProvenance, placeholder: false, bundledTools, }); @@ -759,8 +768,9 @@ module.exports = { peekabooBundleMetadata, peekabooIdentity, resolveBridgeSourceDir, - shouldCloneBridgeRefAsBranch, sourceCandidates, + vendoredBridgeSourceMetadata, verifyPythonRuntimeInventory, + verifyWorkbenchBridgeIdentity, writePythonRuntimeInventory, }; diff --git a/tests/unit/bootstrap/afterPackBundledResources.test.ts b/tests/unit/bootstrap/afterPackBundledResources.test.ts index 6cca08701b..2740552344 100644 --- a/tests/unit/bootstrap/afterPackBundledResources.test.ts +++ b/tests/unit/bootstrap/afterPackBundledResources.test.ts @@ -17,6 +17,7 @@ const bridgeResource = require('../../../scripts/prepareEvaosDesktopBridgeResour inventorySha256: string; inventoryEntryCount: number; }; + verifyWorkbenchBridgeIdentity: (bridgePackageDir: string) => { sourceSha256: string }; }; const tempDirs: string[] = []; @@ -52,7 +53,9 @@ function writeMachOFixture(path: string): void { function writeBridgeFixture(resourcesDir: string, options: { helper?: boolean; nativeHelpers?: boolean } = {}): void { const bridgeDir = join(resourcesDir, 'Bridge'); + const bridgePackageDir = join(bridgeDir, 'src', 'evaos_desktop_bridge'); mkdirSync(join(bridgeDir, 'bin'), { recursive: true }); + mkdirSync(join(bridgePackageDir, 'adapters'), { recursive: true }); mkdirSync(join(bridgeDir, 'python', 'bin'), { recursive: true }); mkdirSync(join(bridgeDir, 'licenses'), { recursive: true }); const bridgePath = join(bridgeDir, 'evaos-desktop-bridge'); @@ -72,11 +75,34 @@ function writeBridgeFixture(resourcesDir: string, options: { helper?: boolean; n mkdirSync(join(bridgeDir, 'python', 'lib', 'python3.12', 'encodings'), { recursive: true }); writeFileSync(join(bridgeDir, 'python', 'lib', 'python3.12', 'encodings', '__init__.py'), '# fixture\n'); writeFileSync(join(bridgeDir, 'licenses', 'CPython-LICENSE.txt'), cpythonLicense); + writeFileSync( + join(bridgePackageDir, 'adapters', 'customer_mac.py'), + [ + 'from pathlib import Path', + 'WORKBENCH_CANONICAL_APP_PATH = Path("/Applications/evaOS Workbench.app")', + 'WORKBENCH_PROCESS_NAME = "evaOS Workbench"', + 'WORKBENCH_APP_ALIASES = {', + ' "com.evaos.workbench",', + '}', + '', + ].join('\n') + ); + const bridgeSourceIdentity = bridgeResource.verifyWorkbenchBridgeIdentity(bridgePackageDir); const inventoryMetadata = bridgeResource.writePythonRuntimeInventory(bridgeDir); writeFileSync( join(bridgeDir, 'manifest.json'), JSON.stringify({ placeholder: false, + requestedSourceRef: '82bb944d2c61586472fe3c8fe20b85f61ba0f4df', + sourcePath: 'resources/evaos-beta/bridge', + sourceCommit: '82bb944d2c61586472fe3c8fe20b85f61ba0f4df', + sourceProvenance: { + schema: 'evaos-workbench-vendored-bridge-source/v1', + owner: '100yenadmin/evaOS-GUI', + status: 'vendored', + importedCommit: '9e3b7332a88fbdea22291923bfd10dd37494d92d', + ...bridgeSourceIdentity, + }, bundledTools: { python: { version: '3.12.13', @@ -276,6 +302,26 @@ describe('afterPack bundled resource verification', () => { } }); + it('rejects a strict packaged bridge that targets the legacy evaOS app', () => { + const previous = process.env.EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL; + const resourcesDir = makeTempResources(); + writeBridgeFixture(resourcesDir, { helper: true, nativeHelpers: true }); + const adapterPath = join(resourcesDir, 'Bridge', 'src', 'evaos_desktop_bridge', 'adapters', 'customer_mac.py'); + writeFileSync( + adapterPath, + readFileSync(adapterPath, 'utf8').replace('/Applications/evaOS Workbench.app', '/Applications/evaOS.app') + ); + + try { + process.env.EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL = '1'; + expect(() => afterPack.verifyEvaosDesktopBridgeResource(resourcesDir, 'darwin', 'arm64')).toThrow( + /Workbench bridge identity|legacy \/Applications\/evaOS\.app/ + ); + } finally { + restoreEnv('EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL', previous); + } + }); + it('rejects a strict packaged runtime that changed after its inventory was written', () => { const previous = process.env.EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL; const resourcesDir = makeTempResources(); diff --git a/tests/unit/process/prepareEvaosDesktopBridgeResource.test.ts b/tests/unit/process/prepareEvaosDesktopBridgeResource.test.ts index ac533788ab..73d3e683ca 100644 --- a/tests/unit/process/prepareEvaosDesktopBridgeResource.test.ts +++ b/tests/unit/process/prepareEvaosDesktopBridgeResource.test.ts @@ -9,6 +9,7 @@ import { writeFileSync, } from 'node:fs'; import { createHash } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, expect, it, vi } from 'vitest'; @@ -31,9 +32,11 @@ type PythonRuntimeMetadata = { const bridgeResource = require('../../../scripts/prepareEvaosDesktopBridgeResource.js') as { bridgeManifest: (input: { + requestedSourceRef?: string; sourcePath: string; sourceCommit?: string; sourceBranch?: string; + sourceProvenance?: Record; placeholder: boolean; placeholderReason?: string; bundledTools?: { @@ -70,8 +73,8 @@ const bridgeResource = require('../../../scripts/prepareEvaosDesktopBridgeResour resourceDir?: string ) => { peekaboo: Record } | undefined; peekabooIdentity: (filePath: string, execute?: PeekabooVersionRunner) => { version: string; sourceSha256: string }; - shouldCloneBridgeRefAsBranch: (ref: string) => boolean; sourceCandidates: () => string[]; + vendoredBridgeSourceMetadata: (sourceDir?: string) => Record; }; const { copyDir } = require('builder-util/out/fs') as { copyDir: (source: string, destination: string) => Promise; @@ -275,7 +278,7 @@ describe('prepareEvaosDesktopBridgeResource', () => { } }); - it('does not use local mutable bridge checkouts when a source ref is pinned', () => { + it('uses the evaOS-GUI-owned vendored bridge despite deprecated source overrides', () => { const previousSourceDir = process.env.EVAOS_DESKTOP_BRIDGE_SOURCE_DIR; const previousSourceRef = process.env.EVAOS_DESKTOP_BRIDGE_SOURCE_REF; const previousDisableDefault = process.env.EVAOS_DESKTOP_BRIDGE_DISABLE_DEFAULT_CANDIDATES; @@ -285,11 +288,11 @@ describe('prepareEvaosDesktopBridgeResource', () => { delete process.env.EVAOS_DESKTOP_BRIDGE_DISABLE_DEFAULT_CANDIDATES; process.env.EVAOS_DESKTOP_BRIDGE_SOURCE_REF = '8cdc02cee0f1e5d53ae430a942848c721762b00a'; - expect(bridgeResource.sourceCandidates()).toEqual([]); + expect(bridgeResource.sourceCandidates()).toEqual([join(process.cwd(), 'resources', 'evaos-beta', 'bridge')]); - process.env.EVAOS_DESKTOP_BRIDGE_SOURCE_DIR = '/Volumes/LEXAR/repos/evaos-desktop-bridge'; + process.env.EVAOS_DESKTOP_BRIDGE_SOURCE_DIR = '/tmp/development-bridge'; - expect(bridgeResource.sourceCandidates()).toEqual(['/Volumes/LEXAR/repos/evaos-desktop-bridge']); + expect(bridgeResource.sourceCandidates()).toEqual([join(process.cwd(), 'resources', 'evaos-beta', 'bridge')]); } finally { restoreEnv('EVAOS_DESKTOP_BRIDGE_SOURCE_DIR', previousSourceDir); restoreEnv('EVAOS_DESKTOP_BRIDGE_SOURCE_REF', previousSourceRef); @@ -297,6 +300,63 @@ describe('prepareEvaosDesktopBridgeResource', () => { } }); + it('records owned bridge provenance and the current Workbench focus identity', () => { + const metadata = bridgeResource.vendoredBridgeSourceMetadata(); + const adapter = readFileSync( + join( + process.cwd(), + 'resources', + 'evaos-beta', + 'bridge', + 'src', + 'evaos_desktop_bridge', + 'adapters', + 'customer_mac.py' + ), + 'utf8' + ); + + expect(metadata).toMatchObject({ + schema: 'evaos-workbench-vendored-bridge-source/v1', + owner: '100yenadmin/evaOS-GUI', + status: 'vendored', + importedCommit: '9e3b7332a88fbdea22291923bfd10dd37494d92d', + }); + expect(metadata.sourceSha256).toMatch(/^[0-9a-f]{64}$/); + expect(adapter).toContain('WORKBENCH_CANONICAL_APP_PATH = Path("/Applications/evaOS Workbench.app")'); + expect(adapter).not.toContain('WORKBENCH_CANONICAL_APP_PATH = Path("/Applications/evaOS.app")'); + }); + + it('routes current and legacy aliases only to the current Workbench app', () => { + const sourceDir = join(process.cwd(), 'resources', 'evaos-beta', 'bridge', 'src'); + const script = [ + 'from pathlib import Path', + 'from tempfile import TemporaryDirectory', + 'from evaos_desktop_bridge.adapters.customer_mac import CustomerMacObserver', + 'with TemporaryDirectory() as state:', + ' observer = CustomerMacObserver(state_dir=Path(state), platform_name="Darwin")', + ' for alias in ("EvaDesktop", "evaOS", "evaOS Workbench", "com.evaos.workbench"):', + ' result = observer.app_focus(app_name=alias, dry_run=True)', + ' assert result.ok, (alias, result.errors)', + ' assert result.data["app_path"] == "/Applications/evaOS Workbench.app", (alias, result.data)', + ' assert result.data["process_name"] == "evaOS Workbench", (alias, result.data)', + 'print("ok")', + ].join('\n'); + + expect( + execFileSync('python3', ['-B', '-c', script], { + encoding: 'utf8', + env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1', PYTHONPATH: sourceDir }, + }).trim() + ).toBe('ok'); + }); + + it('rejects an external bridge source in every build mode', () => { + expect(() => bridgeResource.vendoredBridgeSourceMetadata('/tmp/external-bridge')).toThrow( + /evaOS-GUI-owned vendored bridge source/ + ); + }); + it('records the requested bridge source ref in packaged resource manifests', () => { const previousSourceRef = process.env.EVAOS_DESKTOP_BRIDGE_SOURCE_REF; const bridgeSha = '60f7e87aa373fbae5ac91b8e6c50b86cfe5e064b'; @@ -359,11 +419,6 @@ describe('prepareEvaosDesktopBridgeResource', () => { expect(JSON.stringify(manifest)).not.toContain('/opt/homebrew'); }); - it('does not try to clone a full bridge commit SHA as a branch name', () => { - expect(bridgeResource.shouldCloneBridgeRefAsBranch('60f7e87aa373fbae5ac91b8e6c50b86cfe5e064b')).toBe(false); - expect(bridgeResource.shouldCloneBridgeRefAsBranch('evaos-workbench-v0.6.27')).toBe(true); - }); - it('derives the bundled Peekaboo version and digest from the copied executable', () => { const dir = mkdtempSync(join(tmpdir(), 'evaos-peekaboo-identity-')); const executable = join(dir, 'peekaboo'); From 5607af9ea0c0940a170d6212d73df26eef939a0b Mon Sep 17 00:00:00 2001 From: Eva Date: Wed, 15 Jul 2026 01:07:34 +0700 Subject: [PATCH 02/13] fix(workbench): close vendored bridge release gaps --- CHANGELOG.md | 3 + .../mac-pairing-functional-proof-runbook.md | 25 +- .../common/evaos/nativeCompanionBoundary.ts | 28 +- .../goldenWorkbenchParityManifest.ts | 4 +- resources/evaos-beta/bridge/SOURCE.json | 2 +- .../adapters/customer_mac.py | 43 ++- .../bridge/src/evaos_desktop_bridge/cli.py | 319 +++++++++++++----- .../src/evaos_desktop_bridge/pre_canary.py | 67 +++- scripts/afterPack.js | 19 +- scripts/evaosBetaReleaseGate.js | 42 ++- scripts/prepareEvaosDesktopBridgeResource.js | 53 ++- .../afterPackBundledResources.test.ts | 34 +- .../unit/process/evaosBetaReleaseGate.test.ts | 50 ++- .../prepareEvaosDesktopBridgeResource.test.ts | 143 +++++++- 14 files changed, 673 insertions(+), 159 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25d773fc89..e6c39e08c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,9 @@ - Routes current and legacy Workbench focus aliases only to `/Applications/evaOS Workbench.app`, verifies the `evaOS Workbench` process, and refuses the legacy `/Applications/evaOS.app` target. +- Uses one authenticated connector-readiness snapshot across diagnostics and + status, and gives the larger authenticated diagnostics response its own + bounded deadline so a healthy Mac is not misreported as unauthenticated. - Binds the packaged bridge source digest and ownership provenance to the exact evaOS-GUI release commit before signing. diff --git a/docs/evaos/mac-pairing-functional-proof-runbook.md b/docs/evaos/mac-pairing-functional-proof-runbook.md index d81e5f8b80..b3c65a0abc 100644 --- a/docs/evaos/mac-pairing-functional-proof-runbook.md +++ b/docs/evaos/mac-pairing-functional-proof-runbook.md @@ -19,15 +19,22 @@ The required order is: ## Bridge Packaging Gate -Pull request build smoke may set `EVAOS_DESKTOP_BRIDGE_ALLOW_PLACEHOLDER=1` so CI can verify Electron packaging even when the private `evaos-desktop-bridge` source is not readable from a pull request runner. - -That placeholder is never release proof. Any public or signed release build must set or inherit `EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL=1` and must provide one of: - -- `EVAOS_DESKTOP_BRIDGE_SOURCE_DIR` pointing at a checkout with `src/evaos_desktop_bridge/cli.py` -- `EVAOS_DESKTOP_BRIDGE_SOURCE_TOKEN` with read access to `electricsheephq/evaos-desktop-bridge` -- `EVAOS_DESKTOP_BRIDGE_SOURCE_REPO` / `EVAOS_DESKTOP_BRIDGE_SOURCE_REF` for an approved, reachable bridge source - -For CI release builds, `EVAOS_DESKTOP_BRIDGE_SOURCE_REF` must be a pinned tag or commit SHA, not `main`, `master`, or `HEAD`. If those inputs are missing, mutable, or inaccessible, the release build must fail before a public artifact is created. +The Workbench bridge source is owned and vendored by `evaOS-GUI` at +`resources/evaos-beta/bridge`. Pull request build smoke may still set +`EVAOS_DESKTOP_BRIDGE_ALLOW_PLACEHOLDER=1` for explicit negative-path packaging +tests, but a normal checkout does not fetch bridge source from another repository. + +That placeholder is never release proof. Any public or signed release build must set or inherit `EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL=1` and must provide both: + +- the checked-out `resources/evaos-beta/bridge/src/evaos_desktop_bridge` package; +- `EVAOS_DESKTOP_BRIDGE_SOURCE_REF` set to the exact 40-character `evaOS-GUI` + checkout commit. + +The packaged manifest must record that same GUI commit, the owned source path, +the vendored ownership metadata, and the matching deterministic source digest. +External source-directory, repository, and token overrides are not release inputs. +If the owned source, exact commit binding, identity, or digest is missing or +different, the release build must fail before a public artifact is created. ## Functional Acceptance diff --git a/packages/desktop/src/common/evaos/nativeCompanionBoundary.ts b/packages/desktop/src/common/evaos/nativeCompanionBoundary.ts index 45a18bbd0e..677b933091 100644 --- a/packages/desktop/src/common/evaos/nativeCompanionBoundary.ts +++ b/packages/desktop/src/common/evaos/nativeCompanionBoundary.ts @@ -57,39 +57,29 @@ export interface EvaosNativeCompanionCanary { } export const EVAOS_NATIVE_COMPANION_BOUNDARY_VERSION = '2026-06-06.rc-parity'; +export const EVAOS_PACKAGED_BRIDGE_COMMAND = + '"/Applications/evaOS Workbench.app/Contents/Resources/Bridge/evaos-desktop-bridge"'; +const EVAOS_CANARY_ARTIFACT_DIR_ARG = + '"${EVAOS_CANARY_ARTIFACT_DIR:?Set EVAOS_CANARY_ARTIFACT_DIR to an empty evidence directory}"'; +const EVAOS_CONNECTOR_URL_ARG = + '"${EVAOS_DESKTOP_BRIDGE_URL:?Set EVAOS_DESKTOP_BRIDGE_URL to the selected connector URL}"'; export const EVAOS_NATIVE_COMPANION_CANARIES = [ { id: 'pre-canary-bridge-peekaboo', - command: 'PYTHONPATH=src python3 -m evaos_desktop_bridge.pre_canary --json --control-surface bridge-peekaboo', + command: `${EVAOS_PACKAGED_BRIDGE_COMMAND} pre-canary --json --control-surface bridge-peekaboo --artifact-dir ${EVAOS_CANARY_ARTIFACT_DIR_ARG}`, requiredArtifact: 'qa-report.json', forbidsSkips: true, }, { id: 'connector-all', - command: - 'PYTHONPATH=src python3 -m evaos_desktop_bridge.qa_canary --surface connector --suite all --operator-ack-live-control', - requiredArtifact: 'qa-report.json', - forbidsSkips: true, - }, - { - id: 'openclaw-all', - command: - 'PYTHONPATH=src python3 -m evaos_desktop_bridge.qa_canary --surface openclaw --suite all --operator-ack-live-control', - requiredArtifact: 'qa-report.json', - forbidsSkips: true, - }, - { - id: 'hermes-all', - command: - 'PYTHONPATH=src python3 -m evaos_desktop_bridge.qa_canary --surface hermes --suite all --operator-ack-live-control', + command: `${EVAOS_PACKAGED_BRIDGE_COMMAND} qa-canary --connector-url ${EVAOS_CONNECTOR_URL_ARG} --artifact-dir ${EVAOS_CANARY_ARTIFACT_DIR_ARG} --surface connector --suite all --operator-ack-live-control`, requiredArtifact: 'qa-report.json', forbidsSkips: true, }, { id: 'connector-kill-switch', - command: - 'PYTHONPATH=src python3 -m evaos_desktop_bridge.qa_canary --surface connector --suite kill_switch --operator-ack-live-control', + command: `${EVAOS_PACKAGED_BRIDGE_COMMAND} qa-canary --connector-url ${EVAOS_CONNECTOR_URL_ARG} --artifact-dir ${EVAOS_CANARY_ARTIFACT_DIR_ARG} --surface connector --suite kill_switch --operator-ack-live-control`, requiredArtifact: 'qa-report.json', forbidsSkips: true, }, diff --git a/packages/desktop/src/renderer/evaos/__fixtures__/goldenWorkbenchParityManifest.ts b/packages/desktop/src/renderer/evaos/__fixtures__/goldenWorkbenchParityManifest.ts index 16218ffaf8..f56750980c 100644 --- a/packages/desktop/src/renderer/evaos/__fixtures__/goldenWorkbenchParityManifest.ts +++ b/packages/desktop/src/renderer/evaos/__fixtures__/goldenWorkbenchParityManifest.ts @@ -6,10 +6,12 @@ import type { IEvaosAccountPolicyScope, IEvaosRuntimeKey } from '@/common/evaos/bridgeTypes'; +// Historical Swift Workbench parity evidence only. The current packaged Mac +// bridge is owned by evaOS-GUI under resources/evaos-beta/bridge. export const GOLDEN_WORKBENCH_RELEASE_BASELINE = { releaseTag: 'evaos-workbench-v0.6.27', releaseUrl: 'https://github.com/electricsheephq/evaos-desktop-bridge/releases/tag/evaos-workbench-v0.6.27', - sourceCheckout: '/Volumes/LEXAR/repos/evaos-desktop-bridge', + archivedSource: 'electricsheephq/evaos-desktop-bridge@evaos-workbench-v0.6.27', } as const; export type GoldenWorkbenchSidebarSection = diff --git a/resources/evaos-beta/bridge/SOURCE.json b/resources/evaos-beta/bridge/SOURCE.json index 71c5b75ae8..915353a855 100644 --- a/resources/evaos-beta/bridge/SOURCE.json +++ b/resources/evaos-beta/bridge/SOURCE.json @@ -3,7 +3,7 @@ "owner": "100yenadmin/evaOS-GUI", "sourcePath": "resources/evaos-beta/bridge/src/evaos_desktop_bridge", "importedFrom": "electricsheephq/evaos-desktop-bridge", - "importedCommit": "9e3b7332a88fbdea22291923bfd10dd37494d92d", + "importedCommit": "908e3cad8c5f11dca739bbfc2c697c3e6d52f79e", "importedAt": "2026-07-14", "status": "vendored" } diff --git a/resources/evaos-beta/bridge/src/evaos_desktop_bridge/adapters/customer_mac.py b/resources/evaos-beta/bridge/src/evaos_desktop_bridge/adapters/customer_mac.py index 86a99c05bb..c8521e7c87 100644 --- a/resources/evaos-beta/bridge/src/evaos_desktop_bridge/adapters/customer_mac.py +++ b/resources/evaos-beta/bridge/src/evaos_desktop_bridge/adapters/customer_mac.py @@ -16,7 +16,7 @@ from datetime import datetime, timezone from pathlib import Path from typing import Any, Callable, Protocol -from urllib.parse import urlparse +from urllib.parse import unquote, urlparse from ..audit import append_audit, default_state_dir from ..bundled_tools import bundled_bridge_bin_candidates @@ -49,6 +49,7 @@ "evaos", "evaos workbench", } +WORKBENCH_LEGACY_APP_PATHS = {"/applications/evaos.app"} PEEKABOO_BIN_CANDIDATES = ( "peekaboo", "evaos-connector-helper", @@ -579,6 +580,11 @@ def status(self) -> CommandResult: return CommandResult( ok=True, data={ + "proof_scope": { + "kind": "local_capability_posture", + "proves_broker_authentication": False, + "proves_vm_reachability": False, + }, "platform": self.platform_name, "device": self._device_identity(), "frontmost_app": redact_value(frontmost), @@ -1360,6 +1366,24 @@ def ax_tree(self, *, max_nodes: int) -> CommandResult: def app_focus(self, *, app_name: str, dry_run: bool = False) -> CommandResult: if not self._safe_app_name(app_name): return CommandResult(ok=False, data={"focused": False, "would_focus": dry_run}, errors=[make_error(code="app_name_not_allowed", message="App name is outside the safe named-action character set.", guidance="Use a visible macOS app name with letters, numbers, spaces, dots, underscores, plus, at-sign, slash, colon, hash, or hyphen.")]) + if self._is_legacy_workbench_app_path(app_name): + return CommandResult( + ok=False, + data={ + "focused": False, + "would_focus": False, + "app_name": app_name, + "app_path": str(WORKBENCH_CANONICAL_APP_PATH), + "process_name": WORKBENCH_PROCESS_NAME, + }, + errors=[ + make_error( + code="legacy_workbench_app_blocked", + message="The legacy evaOS app bundle is not a valid Mac-control target.", + guidance=f"Use the current Workbench at {WORKBENCH_CANONICAL_APP_PATH}.", + ) + ], + ) if self._is_sensitive_app(app_name): return CommandResult(ok=False, data={"focused": False, "would_focus": dry_run, "app_name": app_name}, errors=[make_error(code="sensitive_app_blocked", message="This app is on the sensitive-app denylist.", guidance="Only request named actions against non-sensitive apps.")]) workbench_focus = self._workbench_focus_result(app_name=app_name, dry_run=dry_run, verify_frontmost=True) @@ -2707,11 +2731,28 @@ def _workbench_focus_result(self, *, app_name: str, dry_run: bool, verify_frontm return CommandResult(ok=True, data=data if not verify_frontmost else {**data, "frontmost": True}, warnings=warnings, provenance={"source": "macos_open_path"}) def _is_workbench_app_alias(self, app_name: str) -> bool: + app_path = self._normalized_app_bundle_path(app_name) + if app_path == os.path.normpath(str(WORKBENCH_CANONICAL_APP_PATH)).casefold(): + return True normalized = " ".join(app_name.strip().split()).casefold() if normalized.endswith(".app"): normalized = normalized[:-4] return normalized in WORKBENCH_APP_ALIASES + def _is_legacy_workbench_app_path(self, app_name: str) -> bool: + return self._normalized_app_bundle_path(app_name) in WORKBENCH_LEGACY_APP_PATHS + + def _normalized_app_bundle_path(self, app_name: str) -> str | None: + raw_value = app_name.strip() + parsed = urlparse(raw_value) + if parsed.scheme: + if parsed.scheme.casefold() != "file" or parsed.netloc.casefold() not in {"", "localhost"}: + return None + raw_value = unquote(parsed.path) + if not os.path.isabs(raw_value): + return None + return os.path.normpath(raw_value).casefold() + def _wait_for_frontmost(self, app_name: str, *, timeout_seconds: float) -> bool: if self.platform_name != "Darwin": return False diff --git a/resources/evaos-beta/bridge/src/evaos_desktop_bridge/cli.py b/resources/evaos-beta/bridge/src/evaos_desktop_bridge/cli.py index c28d119135..28e1707ca7 100644 --- a/resources/evaos-beta/bridge/src/evaos_desktop_bridge/cli.py +++ b/resources/evaos-beta/bridge/src/evaos_desktop_bridge/cli.py @@ -1712,6 +1712,9 @@ def _connector_label_from_env(env: dict[str, str] | None = None) -> str: CONNECTOR_LABEL = _connector_label_from_env() CONNECTOR_PORT = 8765 +CONNECTOR_PUBLIC_PROBE_TIMEOUT_SECONDS = 1.0 +CONNECTOR_AUTHENTICATED_DIAGNOSTICS_TIMEOUT_SECONDS = 5.0 +CONNECTOR_HTTP_RESPONSE_LIMIT_BYTES = 65536 CONNECTOR_SYSTEM_PLIST = Path(f"/Library/LaunchAgents/{CONNECTOR_LABEL}.plist") CONNECTOR_USER_PLIST = Path.home() / "Library" / "LaunchAgents" / f"{CONNECTOR_LABEL}.plist" PEEKABOO_BIN_CANDIDATES = ( @@ -1814,18 +1817,40 @@ def _complete_connector_service_enrollment(args: argparse.Namespace, *, state_di } -def _build_cli_ready_payload(*, token: str | None, token_file: str | None = None, state_dir: Path | None = None) -> dict[str, object]: +def _build_cli_ready_payload( + *, + token: str | None, + token_file: str | None = None, + state_dir: Path | None = None, + service_status: dict[str, object] | None = None, +) -> dict[str, object]: payload = dict(build_ready_payload(token=token, state_dir=state_dir)) - service_status = _connector_service_status(token=token, token_file=token_file, state_dir=state_dir) + if service_status is None: + service_status = _connector_service_status(token=token, token_file=token_file, state_dir=state_dir) health = service_status.get("health") if isinstance(service_status.get("health"), dict) else {} blockers = list(payload.get("blockers") if isinstance(payload.get("blockers"), list) else []) - if service_status.get("ready") is not True: - code = "connector_service_unreachable" if health.get("reachable") is not True else "connector_service_not_ready" + if service_status.get("ready") is not True and service_status.get("token_present") is True: + code = _connector_service_readiness_reason(service_status) + message = { + "connector_service_unreachable": "Connector service is unreachable; Workbench must start the signed bridge before Mac control is ready.", + "connector_authentication_rejected": "Connector service rejected the configured credential; Mac control is not ready.", + "connector_diagnostics_timeout": "Authenticated connector diagnostics exceeded the bounded readiness deadline; authentication was not disproven.", + "connector_diagnostics_incomplete": "Authenticated connector diagnostics ended before the complete response arrived; authentication was not disproven.", + "connector_diagnostics_invalid_json": "Authenticated connector diagnostics returned an invalid response; Mac control is not ready.", + "connector_health_timeout": "The short public connector health probe exceeded its bounded deadline; Mac control is not ready.", + "connector_health_incomplete": "The public connector health response ended before it was complete; Mac control is not ready.", + "connector_health_invalid_json": "The public connector health response was invalid; Mac control is not ready.", + "connector_identity_unverified": "Connector service identity is incompatible with this Workbench bridge; Mac control is not ready.", + "connector_connection_failed": "Connector diagnostics could not establish a connection; Mac control is not ready.", + "connector_diagnostics_http_error": "Connector diagnostics returned an unexpected HTTP response; Mac control is not ready.", + "connector_service_unauthenticated": "Connector service is reachable but did not authenticate; Mac control is not ready.", + "connector_service_not_ready": "Connector service is authenticated but not ready for Mac control.", + }.get(code, "Connector service is not ready for Mac control.") blockers.append( { "code": code, - "message": "Connector service is not ready; Workbench must start the signed bridge before Mac control is ready.", + "message": message, "host_kind": _connector_host_kind(str(health.get("host") or "")), } ) @@ -1841,11 +1866,20 @@ def _build_cli_ready_payload(*, token: str | None, token_file: str | None = None def _build_cli_diagnostics_payload(*, token: str | None, token_file: str | None = None, state_dir: Path | None = None) -> dict[str, object]: service_status = _connector_service_status(token=token, token_file=token_file, state_dir=state_dir) - return build_diagnostics_payload( + payload = build_diagnostics_payload( token=token, state_dir=state_dir, owner=_public_bridge_owner_from_status(service_status), ) + connector = payload.get("connector") if isinstance(payload.get("connector"), dict) else {} + connector["ready"] = _build_cli_ready_payload( + token=token, + token_file=token_file, + state_dir=state_dir, + service_status=service_status, + ) + payload["connector"] = connector + return payload def _connector_http_owner_summary() -> dict[str, object]: @@ -1889,6 +1923,7 @@ def _public_ready_connector_service_status(status: dict[str, object]) -> dict[st public: dict[str, object] = { "ok": status.get("ok") is True, "ready": status.get("ready") is True, + "readiness_reason": _connector_service_readiness_reason(status), "managed_by": status.get("managed_by") if isinstance(status.get("managed_by"), str) else "unknown", "token_present": status.get("token_present") is True, "loaded": status.get("loaded") is True, @@ -2068,50 +2103,7 @@ def _wait_for_public_connector_service_ready( def _public_connector_service_probe_status(*, state_dir: Path | None = None) -> dict[str, object]: - domain = _launchctl_domain() - print_result = _run_launchctl(["print", f"{domain}/{CONNECTOR_LABEL}"]) - token_path = _connector_token_path(None, state_dir=state_dir) - plist_path = _connector_plist_path() - loaded = print_result["returncode"] == 0 - connector_token = _connector_token_value(None, state_dir=state_dir) - health = _connector_loopback_health(connector_token=connector_token) if connector_token else _connector_public_loopback_health() - reachable = health.get("reachable") is True - ready = connector_token is not None and health.get("ready") is True and health.get("authenticated") is True - running = loaded and ready - program_arguments = _connector_plist_program_arguments(plist_path) - active_program_path = _active_connector_process_program_path() if reachable else None - tailscale_status = _tailscale_status_snapshot() - status = { - "ok": ready, - "ready": ready, - "label": CONNECTOR_LABEL, - "domain": domain, - "managed_by": "launchagent" if running else "workbench-or-manual" if ready else "offline", - "plist_path": str(plist_path) if plist_path else None, - "plist_installed": plist_path is not None, - "token_path": str(token_path), - "token_present": connector_token is not None, - "loaded": loaded, - "running": running, - "tailnet_ip": _tailscale_ip(tailscale_status), - "private_network": _private_network_evidence(tailscale_status), - "bridge_runtime": _workbench_runtime_compatibility(_bridge_runtime_version()), - "health": health, - "owner": _bridge_owner_summary( - label=CONNECTOR_LABEL, - plist_path=plist_path, - program_arguments=program_arguments, - ready=ready, - active_program_path=active_program_path, - ), - "permission_target": _connector_permission_target( - "launchagent" if running else "workbench-or-manual" if ready else "offline", - health, - program_arguments, - ), - "guidance": _connector_service_guidance(plist_path, connector_token is not None, health), - } - return _public_connector_service_status(status) + return _public_connector_service_status(_connector_service_status(state_dir=state_dir)) def _public_connector_service_result(result: dict[str, object]) -> dict[str, object]: @@ -2237,11 +2229,40 @@ def _public_launchctl_notes(value: object) -> list[dict[str, object]]: return notes +def _connector_service_readiness_reason(status: dict[str, object]) -> str: + if status.get("ready") is True: + return "ready" + if status.get("token_present") is not True: + return "token_missing" + health = status.get("health") if isinstance(status.get("health"), dict) else {} + error = health.get("error") + safe_probe_reasons = { + "connector_authentication_rejected", + "connector_connection_failed", + "connector_diagnostics_http_error", + "connector_diagnostics_incomplete", + "connector_diagnostics_invalid_json", + "connector_diagnostics_timeout", + "connector_health_incomplete", + "connector_health_invalid_json", + "connector_health_timeout", + "connector_identity_unverified", + } + if isinstance(error, str) and error in safe_probe_reasons: + return error + if health.get("reachable") is not True: + return "connector_service_unreachable" + if health.get("authenticated") is not True: + return "connector_service_unauthenticated" + return "connector_service_not_ready" + + def _public_connector_service_status(status: dict[str, object]) -> dict[str, object]: health = status.get("health") if isinstance(status.get("health"), dict) else {} public: dict[str, object] = { "ok": status.get("ok") is True, "ready": status.get("ready") is True, + "readiness_reason": _connector_service_readiness_reason(status), "label": status.get("label") if isinstance(status.get("label"), str) else CONNECTOR_LABEL, "domain": status.get("domain") if isinstance(status.get("domain"), str) else _launchctl_domain(), "managed_by": status.get("managed_by") if isinstance(status.get("managed_by"), str) else "unknown", @@ -2523,7 +2544,7 @@ def _connector_service_status(*, token: str | None = None, token_file: str | Non plist_path = _connector_plist_path() loaded = print_result["returncode"] == 0 reachable = health["reachable"] is True - ready = health.get("ready") is True + ready = bool(connector_token) and health.get("ready") is True and health.get("authenticated") is True running = loaded and ready tailscale_status = _tailscale_status_snapshot() tailnet_ip = _tailscale_ip(tailscale_status) @@ -2680,7 +2701,11 @@ def _connector_loopback_health(*, connector_token: str | None = None) -> dict[st plist_path = _connector_plist_path() host = _connector_plist_host(plist_path) or _tailscale_ip() or "127.0.0.1" try: - health = _connector_http_get(host, "/health") + health = _connector_http_get( + host, + "/health", + timeout_seconds=CONNECTOR_PUBLIC_PROBE_TIMEOUT_SECONDS, + ) health_json = health.get("json") if isinstance(health.get("json"), dict) else {} reachable = health.get("status_code") == 200 and health_json.get("service") == "evaos-desktop-bridge-connector" result: dict[str, object] = { @@ -2692,13 +2717,18 @@ def _connector_loopback_health(*, connector_token: str | None = None) -> dict[st "status_line": health.get("status_line") if isinstance(health.get("status_line"), str) else "", } if not reachable: - result["error"] = "connector_identity_unverified" + result["error"] = _connector_probe_failure_reason(health, phase="health") return result if not connector_token: result["error"] = "connector_token_missing" return result - diagnostics = _connector_http_get(host, "/v1/diagnostics", authorization=f"Bearer {connector_token}") + diagnostics = _connector_http_get( + host, + "/v1/diagnostics", + authorization=f"Bearer {connector_token}", + timeout_seconds=CONNECTOR_AUTHENTICATED_DIAGNOSTICS_TIMEOUT_SECONDS, + ) diagnostics_json = diagnostics.get("json") if isinstance(diagnostics.get("json"), dict) else {} connector = diagnostics_json.get("connector") if isinstance(diagnostics_json.get("connector"), dict) else {} ready_payload = connector.get("ready") if isinstance(connector.get("ready"), dict) else {} @@ -2713,17 +2743,25 @@ def _connector_loopback_health(*, connector_token: str | None = None) -> dict[st result["ready"] = ready result["ready_status_line"] = diagnostics.get("status_line") if isinstance(diagnostics.get("status_line"), str) else "" if not ready: - result["error"] = "connector_ready_probe_failed" + result["error"] = ( + "connector_ready_probe_failed" + if authenticated + else _connector_probe_failure_reason(diagnostics, phase="diagnostics") + ) return result - except Exception as exc: - return {"reachable": False, "host": host, "port": CONNECTOR_PORT, "error": str(exc)} + except Exception: + return {"reachable": False, "ready": False, "authenticated": False, "host": host, "port": CONNECTOR_PORT, "error": "connector_connection_failed"} def _connector_public_loopback_health() -> dict[str, object]: plist_path = _connector_plist_path() host = _connector_plist_host(plist_path) or _tailscale_ip() or "127.0.0.1" try: - ready_probe = _connector_http_get(host, "/ready") + ready_probe = _connector_http_get( + host, + "/ready", + timeout_seconds=CONNECTOR_PUBLIC_PROBE_TIMEOUT_SECONDS, + ) ready_json = ready_probe.get("json") if isinstance(ready_probe.get("json"), dict) else {} if ready_json.get("schema") == "evaos.desktop_bridge.ready.v1": ready = ready_probe.get("status_code") == 200 and ready_json.get("ok") is True and ready_json.get("ready") is True @@ -2739,7 +2777,11 @@ def _connector_public_loopback_health() -> dict[str, object]: result["error"] = "connector_ready_probe_failed" return result - health = _connector_http_get(host, "/health") + health = _connector_http_get( + host, + "/health", + timeout_seconds=CONNECTOR_PUBLIC_PROBE_TIMEOUT_SECONDS, + ) health_json = health.get("json") if isinstance(health.get("json"), dict) else {} reachable = health.get("status_code") == 200 and health_json.get("service") == "evaos-desktop-bridge-connector" result: dict[str, object] = { @@ -2753,33 +2795,89 @@ def _connector_public_loopback_health() -> dict[str, object]: if not reachable: result["error"] = "connector_identity_unverified" return result - except Exception as exc: - return {"reachable": False, "host": host, "port": CONNECTOR_PORT, "error": str(exc)} - - -def _connector_http_get(host: str, path: str, *, authorization: str | None = None) -> dict[str, object]: + except Exception: + return {"reachable": False, "ready": False, "authenticated": False, "host": host, "port": CONNECTOR_PORT, "error": "connector_connection_failed"} + + +def _connector_probe_failure_reason(probe: dict[str, object], *, phase: str) -> str: + status_code = probe.get("status_code") + outcome = probe.get("outcome") + if outcome == "timeout": + return f"connector_{phase}_timeout" + if outcome == "incomplete_response": + return f"connector_{phase}_incomplete" + if outcome == "invalid_json": + return f"connector_{phase}_invalid_json" + if outcome == "connection_failed": + return "connector_connection_failed" + if phase == "diagnostics" and outcome == "complete" and status_code in {401, 403}: + return "connector_authentication_rejected" + if status_code == 200: + return "connector_identity_unverified" + if phase == "diagnostics": + return "connector_diagnostics_http_error" + return "connector_service_unreachable" + + +def _connector_http_get( + host: str, + path: str, + *, + authorization: str | None = None, + timeout_seconds: float = CONNECTOR_PUBLIC_PROBE_TIMEOUT_SECONDS, +) -> dict[str, object]: connect_host = host[1:-1] if host.startswith("[") and host.endswith("]") else host headers = [f"GET {path} HTTP/1.1", f"Host: {host}", "Connection: close"] if authorization: headers.append(f"Authorization: {authorization}") request = ("\r\n".join(headers) + "\r\n\r\n").encode("utf-8") - with socket.create_connection((connect_host, CONNECTOR_PORT), timeout=1.0) as sock: - sock.settimeout(1.0) - sock.sendall(request) - chunks: list[bytes] = [] - while True: - try: - chunk = sock.recv(4096) - except TimeoutError: - break - if not chunk: - break - chunks.append(chunk) - if sum(len(item) for item in chunks) >= 65536: - break + bounded_timeout = max(0.01, float(timeout_seconds)) + deadline = time.monotonic() + bounded_timeout + chunks: list[bytes] = [] + timed_out = False + response_limit_reached = False + framed_response_complete = False + try: + with socket.create_connection((connect_host, CONNECTOR_PORT), timeout=bounded_timeout) as sock: + remaining = deadline - time.monotonic() + if remaining <= 0: + return _connector_http_probe_result("timeout") + sock.settimeout(remaining) + sock.sendall(request) + received_bytes = 0 + while received_bytes < CONNECTOR_HTTP_RESPONSE_LIMIT_BYTES: + remaining = deadline - time.monotonic() + if remaining <= 0: + timed_out = True + break + sock.settimeout(remaining) + try: + chunk = sock.recv(min(4096, CONNECTOR_HTTP_RESPONSE_LIMIT_BYTES - received_bytes)) + except TimeoutError: + timed_out = True + break + if not chunk: + break + chunks.append(chunk) + received_bytes += len(chunk) + if _connector_http_declared_body_complete(b"".join(chunks)): + framed_response_complete = True + break + response_limit_reached = received_bytes >= CONNECTOR_HTTP_RESPONSE_LIMIT_BYTES and not framed_response_complete + except TimeoutError: + return _connector_http_probe_result("timeout") + except OSError: + return _connector_http_probe_result("connection_failed") + + if timed_out: + return _connector_http_probe_result("timeout") + if response_limit_reached: + return _connector_http_probe_result("incomplete_response") + data = b"".join(chunks) - text = data.decode("utf-8", errors="replace") - status_line = text.splitlines()[0] if text else "" + header_bytes, separator, body_bytes = data.partition(b"\r\n\r\n") + header_text = header_bytes.decode("iso-8859-1", errors="replace") + status_line = header_text.splitlines()[0] if header_text else "" status_code = None parts = status_line.split() if len(parts) >= 2: @@ -2787,14 +2885,61 @@ def _connector_http_get(host: str, path: str, *, authorization: str | None = Non status_code = int(parts[1]) except ValueError: status_code = None - _, _, body = text.partition("\r\n\r\n") - parsed_json: object | None = None - if body.strip(): + + if not separator: + return _connector_http_probe_result("incomplete_response", status_code=status_code, status_line=status_line) + + content_length: int | None = None + for header_line in header_text.splitlines()[1:]: + name, separator, value = header_line.partition(":") + if separator and name.strip().lower() == "content-length": + try: + content_length = int(value.strip()) + except ValueError: + return _connector_http_probe_result("incomplete_response", status_code=status_code, status_line=status_line) + break + if content_length is not None: + if content_length < 0 or len(body_bytes) < content_length: + return _connector_http_probe_result("incomplete_response", status_code=status_code, status_line=status_line) + body_bytes = body_bytes[:content_length] + + try: + parsed_json = json.loads(body_bytes.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + return _connector_http_probe_result("invalid_json", status_code=status_code, status_line=status_line) + return _connector_http_probe_result("complete", status_code=status_code, status_line=status_line, parsed_json=parsed_json) + + +def _connector_http_probe_result( + outcome: str, + *, + status_code: int | None = None, + status_line: str = "", + parsed_json: object | None = None, +) -> dict[str, object]: + return { + "outcome": outcome, + "status_code": status_code, + "status_line": status_line, + "json": parsed_json, + } + + +def _connector_http_declared_body_complete(data: bytes) -> bool: + header_bytes, separator, body_bytes = data.partition(b"\r\n\r\n") + if not separator: + return False + header_text = header_bytes.decode("iso-8859-1", errors="replace") + for header_line in header_text.splitlines()[1:]: + name, separator, value = header_line.partition(":") + if not separator or name.strip().lower() != "content-length": + continue try: - parsed_json = json.loads(body) - except json.JSONDecodeError: - parsed_json = None - return {"status_code": status_code, "status_line": status_line, "json": parsed_json} + content_length = int(value.strip()) + except ValueError: + return False + return content_length >= 0 and len(body_bytes) >= content_length + return False def _connector_permission_target( diff --git a/resources/evaos-beta/bridge/src/evaos_desktop_bridge/pre_canary.py b/resources/evaos-beta/bridge/src/evaos_desktop_bridge/pre_canary.py index e24a4ef7b6..24a8a2d38e 100644 --- a/resources/evaos-beta/bridge/src/evaos_desktop_bridge/pre_canary.py +++ b/resources/evaos-beta/bridge/src/evaos_desktop_bridge/pre_canary.py @@ -10,8 +10,13 @@ from pathlib import Path from typing import Any, Iterable, Sequence -DEFAULT_CANONICAL_PATH = "/Applications/evaOS.app" -DEFAULT_BUNDLE_ID = "com.electricsheephq.EvaDesktop" +DEFAULT_CANONICAL_PATH = "/Applications/evaOS Workbench.app" +DEFAULT_BUNDLE_ID = "com.evaos.workbench" +LEGACY_BUNDLE_IDS = {"com.electricsheephq.EvaDesktop"} +WORKBENCH_APP_NAMES = {"evaOS Workbench.app", "evaOS.app", "EvaDesktop.app"} +WORKBENCH_EXECUTABLE_PATTERN = re.compile( + r'^\s*"?(?P/.*?\.app)/Contents/MacOS/(?:evaOS Workbench|EvaDesktop)"?(?:\s|$)' +) DEFAULT_TEAM_ID = "TC6MS3T6NN" COMPUTER_USE_CLIENT_SUFFIX = "SkyComputerUseClient mcp" # Optional developer/canary artifact locations. Missing roots are ignored, and @@ -128,8 +133,8 @@ def evaluate_inventory( for bundle in inventory.app_bundles if bundle.path != canonical_path and ( - bundle.bundle_id == bundle_id - or Path(bundle.path).name == "EvaDesktop.app" + bundle.bundle_id in {bundle_id, *LEGACY_BUNDLE_IDS} + or Path(bundle.path).name in WORKBENCH_APP_NAMES ) ) running_workbench = tuple(process for process in inventory.processes if process.kind == "workbench") @@ -247,7 +252,11 @@ def gather_inventory( bundle_id: str = DEFAULT_BUNDLE_ID, artifact_roots: Sequence[str] | None = None, ) -> WorkbenchInventory: - registered_paths = tuple(_mdfind_bundle_paths(bundle_id)) + registered_paths = _unique_paths( + path + for candidate_bundle_id in (bundle_id, *sorted(LEGACY_BUNDLE_IDS)) + for path in _mdfind_bundle_paths(candidate_bundle_id) + ) artifact_paths = tuple(_artifact_workbench_bundle_paths(artifact_roots=artifact_roots)) bundle_paths = _unique_paths((*registered_paths, *artifact_paths, canonical_path)) app_bundles = tuple(_read_app_bundle(path) for path in bundle_paths if Path(path).exists()) @@ -264,6 +273,7 @@ def main(argv: Sequence[str] | None = None) -> int: parser.add_argument("--expected-build") parser.add_argument("--expected-team-id", default=DEFAULT_TEAM_ID) parser.add_argument("--max-computer-use-helpers", type=int, default=2) + parser.add_argument("--artifact-dir", type=Path, help="Write qa-report.json into this evidence directory.") parser.add_argument( "--control-surface", choices=CONTROL_SURFACES, @@ -292,8 +302,11 @@ def main(argv: Sequence[str] | None = None) -> int: max_computer_use_helpers=args.max_computer_use_helpers, control_surface=args.control_surface, ) + payload = report.to_dict() + if args.artifact_dir is not None: + _write_report(payload, args.artifact_dir) if args.json: - print(json.dumps(report.to_dict(), indent=2, sort_keys=True)) + print(json.dumps(payload, indent=2, sort_keys=True)) else: _print_human_report(report) return 0 if report.ok else 2 @@ -318,6 +331,13 @@ def _warn(code: str, message: str, evidence: str = "") -> PreCanaryCheck: return PreCanaryCheck(code=code, status="warn", message=message, evidence=evidence) +def _write_report(payload: dict[str, Any], artifact_dir: Path) -> Path: + artifact_dir.mkdir(parents=True, exist_ok=True) + report_path = artifact_dir / "qa-report.json" + report_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + return report_path + + def _bundle_evidence(bundle: AppBundle) -> str: details = [bundle.path] if bundle.version or bundle.build: @@ -366,7 +386,26 @@ def _artifact_workbench_bundle_paths(*, artifact_roots: Sequence[str] | None = N for root in roots: if not Path(root).exists(): continue - output = _run(["find", root, "-maxdepth", ARTIFACT_SEARCH_MAX_DEPTH, "-type", "d", "-name", "EvaDesktop.app", "-prune", "-print"]) + name_predicates: list[str] = [] + for name in sorted(WORKBENCH_APP_NAMES): + if name_predicates: + name_predicates.append("-o") + name_predicates.extend(("-name", name)) + output = _run( + [ + "find", + root, + "-maxdepth", + ARTIFACT_SEARCH_MAX_DEPTH, + "-type", + "d", + "(", + *name_predicates, + ")", + "-prune", + "-print", + ] + ) paths.extend(line.strip() for line in output.splitlines() if line.strip()) return tuple(paths) @@ -416,8 +455,9 @@ def _process_inventory() -> tuple[ProcessInfo, ...]: pid = int(pid_text) except ValueError: continue - if "EvaDesktop.app/Contents/MacOS/EvaDesktop" in command or "/evaOS.app/Contents/MacOS/EvaDesktop" in command: - processes.append(ProcessInfo(pid=pid, command=command, path=_workbench_app_path_from_command(command), kind="workbench")) + workbench_path = _workbench_app_path_from_command(command) + if workbench_path is not None: + processes.append(ProcessInfo(pid=pid, command=command, path=workbench_path, kind="workbench")) elif _is_computer_use_mcp_helper(command): processes.append(ProcessInfo(pid=pid, command=command, kind="computer_use_helper")) elif "evaos_desktop_bridge.cli serve" in command or "evaos-desktop-bridge serve" in command: @@ -432,13 +472,8 @@ def _is_computer_use_mcp_helper(command: str) -> bool: def _workbench_app_path_from_command(command: str) -> str | None: - marker = ".app/Contents/MacOS/EvaDesktop" - index = command.find(marker) - if index == -1: - return None - executable_prefix = command[: index + len(".app")] - start = executable_prefix.rfind(" ") - return executable_prefix[start + 1 :] + match = WORKBENCH_EXECUTABLE_PATTERN.match(command) + return match.group("path") if match else None def _print_human_report(report: PreCanaryReport) -> None: diff --git a/scripts/afterPack.js b/scripts/afterPack.js index 366e9f1d88..7a79a318f6 100644 --- a/scripts/afterPack.js +++ b/scripts/afterPack.js @@ -11,7 +11,11 @@ const { } = require('./rebuildNativeModules'); const { normalizeManagedResourcesBundle } = require('../packages/shared-scripts/src/prepare-aioncore.js'); const { clearDmgRetryCompletionMarkers, markCompletedAfterPack } = require('./dmgRetryEligibility'); -const { verifyPythonRuntimeInventory, verifyWorkbenchBridgeIdentity } = require('./prepareEvaosDesktopBridgeResource'); +const { + bridgeWrapperScript, + verifyPythonRuntimeInventory, + verifyWorkbenchBridgeIdentity, +} = require('./prepareEvaosDesktopBridgeResource'); /** * afterPack hook for electron-builder @@ -378,10 +382,11 @@ function verifyEvaosDesktopBridgeResource(resourcesDir, electronPlatformName, ta throw new Error('Packaged evaOS desktop bridge is a diagnostic placeholder; release builds require a real bridge.'); } if (strictReleaseBridge) { - const expectedSourceCommit = String( - process.env.EVAOS_DESKTOP_BRIDGE_SOURCE_REF || manifest.sourceCommit || '' - ).trim(); + const expectedSourceCommit = String(process.env.EVAOS_DESKTOP_BRIDGE_SOURCE_REF || '').trim(); const sourceProvenance = manifest.sourceProvenance; + const bridgeWrapper = manifest.bundledTools?.bridgeWrapper; + const packagedBridgeWrapperSha256 = crypto.createHash('sha256').update(fs.readFileSync(bridgePath)).digest('hex'); + const expectedBridgeWrapperSha256 = crypto.createHash('sha256').update(bridgeWrapperScript()).digest('hex'); const packagedSourceIdentity = verifyWorkbenchBridgeIdentity( path.join(resourcesDir, 'Bridge', 'src', 'evaos_desktop_bridge') ); @@ -394,7 +399,11 @@ function verifyEvaosDesktopBridgeResource(resourcesDir, electronPlatformName, ta sourceProvenance?.owner !== '100yenadmin/evaOS-GUI' || sourceProvenance?.status !== 'vendored' || !/^[0-9a-f]{40}$/i.test(String(sourceProvenance?.importedCommit || '')) || - sourceProvenance?.sourceSha256 !== packagedSourceIdentity.sourceSha256 + sourceProvenance?.sourceSha256 !== packagedSourceIdentity.sourceSha256 || + bridgeWrapper?.schema !== 'evaos-workbench-bridge-wrapper/v1' || + bridgeWrapper?.path !== 'evaos-desktop-bridge' || + bridgeWrapper?.sourceSha256 !== packagedBridgeWrapperSha256 || + packagedBridgeWrapperSha256 !== expectedBridgeWrapperSha256 ) { throw new Error('Packaged evaOS desktop bridge is not bound to the exact evaOS-GUI-owned source.'); } diff --git a/scripts/evaosBetaReleaseGate.js b/scripts/evaosBetaReleaseGate.js index f103b25b20..2c8b4194af 100644 --- a/scripts/evaosBetaReleaseGate.js +++ b/scripts/evaosBetaReleaseGate.js @@ -5,6 +5,7 @@ const os = require('os'); const path = require('path'); const { createHash } = require('crypto'); const { execFileSync } = require('child_process'); +const { bridgeWrapperScript } = require('./prepareEvaosDesktopBridgeResource'); const TRUTHY_VALUES = new Set(['1', 'true', 'yes', 'on', 'evaos-beta']); const LIVE_CANARY_VERIFIER_SHA256 = '692d88c72217b44f7957d78228748991ff65a12afda253c03b365a30b63e6127'; @@ -2225,7 +2226,7 @@ function assertMacosAutoUpdateMetadata(outputDir, releaseTargetPlatforms) { } } -function inspectMacosZipBridgePayload(zipPath) { +function inspectMacosZipBridgePayload(zipPath, expectedSourceCommit) { const script = [ 'import hashlib', 'import json', @@ -2245,6 +2246,8 @@ function inspectMacosZipBridgePayload(zipPath) { `expected_python_license_path = "${PYTHON_RUNTIME_LICENSE_PATH}"`, `expected_python_license_sha256 = "${PYTHON_RUNTIME_LICENSE_SHA256}"`, `expected_python_packages = ${JSON.stringify(PYTHON_RUNTIME_PACKAGES)}`, + `expected_bridge_wrapper_sha256 = "${createHash('sha256').update(bridgeWrapperScript()).digest('hex')}"`, + `expected_source_commit = ${JSON.stringify(String(expectedSourceCommit || ''))}`, 'macho_magics = {"feedface", "feedfacf", "cefaedfe", "cffaedfe", "cafebabe", "cafebabf", "bebafeca", "bfbafeca"}', 'expected_cpu_types = {"arm64": 0x0100000c, "x64": 0x01000007}', 'def thin_macho_cpu(data):', @@ -2313,7 +2316,7 @@ function inspectMacosZipBridgePayload(zipPath) { ' return False', ' resolved = posixpath.normpath(posixpath.join(posixpath.dirname(relative_path), target))', ' return resolved != ".." and not resolved.startswith("../")', - 'result = {"zipLayoutValid": False, "singleAppRoot": False, "hasBridgeExecutable": False, "hasBridgeManifest": False, "hasPeekaboo": False, "hasConnectorHelper": False, "hasPeekabooLicense": False, "executableModesValid": False, "peekabooMachO": False, "connectorHelperMachO": False, "manifestPlaceholderFalse": False, "manifestSourceDigestValid": False, "manifestLicenseMetadataValid": False, "licenseDigestValid": False, "licenseNoticeValid": False, "hasPythonRuntime": False, "hasPythonLauncher": False, "pythonLauncherValid": False, "pythonRuntimeMachO": False, "pythonRuntimeArchValid": False, "hasPythonLicense": False, "pythonManifestValid": False, "pythonLicenseDigestValid": False, "hasPythonControlModules": False, "pythonObjcArchValid": False, "pythonInventoryValid": False, "hasPythonStdlibSentinel": False, "hasPythonNativeSentinels": False, "pythonNativeSentinelsExecutable": False}', + 'result = {"zipLayoutValid": False, "singleAppRoot": False, "hasBridgeExecutable": False, "hasBridgeManifest": False, "hasPeekaboo": False, "hasConnectorHelper": False, "hasPeekabooLicense": False, "executableModesValid": False, "bridgeWrapperValid": False, "bridgeSourceDigestValid": False, "bridgeCommitBindingValid": False, "peekabooMachO": False, "connectorHelperMachO": False, "manifestPlaceholderFalse": False, "manifestSourceDigestValid": False, "manifestLicenseMetadataValid": False, "licenseDigestValid": False, "licenseNoticeValid": False, "hasPythonRuntime": False, "hasPythonLauncher": False, "pythonLauncherValid": False, "pythonRuntimeMachO": False, "pythonRuntimeArchValid": False, "hasPythonLicense": False, "pythonManifestValid": False, "pythonLicenseDigestValid": False, "hasPythonControlModules": False, "pythonObjcArchValid": False, "pythonInventoryValid": False, "hasPythonStdlibSentinel": False, "hasPythonNativeSentinels": False, "pythonNativeSentinelsExecutable": False}', 'with zipfile.ZipFile(path) as archive:', ' infos = archive.infolist()', ' names = [info.filename for info in infos]', @@ -2350,7 +2353,33 @@ function inspectMacosZipBridgePayload(zipPath) { ' manifest = {}', ' peekaboo = manifest.get("bundledTools", {}).get("peekaboo", {}) if isinstance(manifest, dict) else {}', ' python_runtime = manifest.get("bundledTools", {}).get("python", {}) if isinstance(manifest, dict) else {}', + ' bridge_wrapper = manifest.get("bundledTools", {}).get("bridgeWrapper", {}) if isinstance(manifest, dict) else {}', + ' source_provenance = manifest.get("sourceProvenance", {}) if isinstance(manifest, dict) else {}', ' result["manifestPlaceholderFalse"] = manifest.get("placeholder") is False if isinstance(manifest, dict) else False', + ' imported_commit = str(source_provenance.get("importedCommit", ""))', + ' result["bridgeCommitBindingValid"] = manifest.get("sourceCommit") == expected_source_commit and manifest.get("requestedSourceRef") == expected_source_commit and manifest.get("sourcePath") == "resources/evaos-beta/bridge" and source_provenance.get("schema") == "evaos-workbench-vendored-bridge-source/v1" and source_provenance.get("owner") == "100yenadmin/evaOS-GUI" and source_provenance.get("status") == "vendored" and len(imported_commit) == 40 and all(char in "0123456789abcdefABCDEF" for char in imported_commit)', + ' if result["hasBridgeExecutable"]:', + ' wrapper_sha256 = sha256_info(archive, entries["evaos-desktop-bridge"])', + ' result["bridgeWrapperValid"] = bridge_wrapper.get("schema") == "evaos-workbench-bridge-wrapper/v1" and bridge_wrapper.get("path") == "evaos-desktop-bridge" and bridge_wrapper.get("sourceSha256") == wrapper_sha256 == expected_bridge_wrapper_sha256', + ' bridge_source_prefix = "src/evaos_desktop_bridge/"', + ' bridge_source_entries = []', + ' bridge_source_layout_valid = True', + ' for suffix, info in entries.items():', + ' if not suffix.startswith(bridge_source_prefix):', + ' continue', + ' relative_path = suffix[len(bridge_source_prefix):]', + ' if not relative_path or "__pycache__" in relative_path.split("/") or not stat.S_ISREG(zip_mode(info)):', + ' bridge_source_layout_valid = False', + ' continue', + ' bridge_source_entries.append((relative_path, info))', + ' if bridge_source_layout_valid and bridge_source_entries:', + ' bridge_source_hash = hashlib.sha256()', + ' for relative_path, info in sorted(bridge_source_entries, key=lambda item: item[0]):', + ' bridge_source_hash.update(relative_path.encode("utf-8"))', + ' bridge_source_hash.update(b"\\0")', + ' bridge_source_hash.update(archive.read(info))', + ' bridge_source_hash.update(b"\\0")', + ' result["bridgeSourceDigestValid"] = source_provenance.get("sourceSha256") == bridge_source_hash.hexdigest()', ' result["manifestSourceDigestValid"] = peekaboo.get("version") == expected_version and peekaboo.get("sourceSha256") == expected_source_sha256', ' result["manifestLicenseMetadataValid"] = peekaboo.get("license") == "MIT" and peekaboo.get("licensePath") == expected_license_path', ' result["pythonManifestValid"] = python_runtime.get("version") == expected_python_version and python_runtime.get("architecture") == expected_python_arch and python_runtime.get("sourceSha256") == expected_python_source_sha256[expected_python_arch] and python_runtime.get("sourceUrl") == expected_python_source_url[expected_python_arch] and python_runtime.get("license") == "Python-2.0" and python_runtime.get("licensePath") == expected_python_license_path and python_runtime.get("licenseSha256") == expected_python_license_sha256 and python_runtime.get("packages") == expected_python_packages', @@ -2480,18 +2509,21 @@ function macosZipAssetNames(outputDir, releaseTargetPlatforms) { return names.filter((name) => /-mac-|darwin-|arm64|x64/.test(name)); } -function assertMacosZipBridgePayload(outputDir, releaseTargetPlatforms) { +function assertMacosZipBridgePayload(outputDir, releaseTargetPlatforms, expectedSourceCommit) { const zipNames = macosZipAssetNames(outputDir, releaseTargetPlatforms); if (releaseTargetPlatforms !== 'windows' && zipNames.length === 0) { throw new Error('Release manifest verification requires a macOS ZIP payload for Electron auto-update.'); } for (const zipName of zipNames) { - const probe = inspectMacosZipBridgePayload(path.join(outputDir, zipName)); + const probe = inspectMacosZipBridgePayload(path.join(outputDir, zipName), expectedSourceCommit); assertZipBridgeProbe(probe, 'zipLayoutValid', zipName, 'safe ZIP layout'); assertZipBridgeProbe(probe, 'singleAppRoot', zipName, 'exactly one .app root'); assertZipBridgeProbe(probe, 'hasBridgeExecutable', zipName, 'executable'); assertZipBridgeProbe(probe, 'hasBridgeManifest', zipName, 'manifest'); + assertZipBridgeProbe(probe, 'bridgeWrapperValid', zipName, 'canonical launcher digest'); + assertZipBridgeProbe(probe, 'bridgeSourceDigestValid', zipName, 'GUI-owned Python source digest'); + assertZipBridgeProbe(probe, 'bridgeCommitBindingValid', zipName, 'exact GUI commit binding'); assertZipBridgeProbe(probe, 'hasPeekaboo', zipName, 'Peekaboo binary'); assertZipBridgeProbe(probe, 'peekabooMachO', zipName, 'Peekaboo binary Mach-O shape'); assertZipBridgeProbe(probe, 'hasConnectorHelper', zipName, 'connector helper'); @@ -2561,7 +2593,7 @@ function verifyReleaseManifest(outputDir, tag, env = process.env) { } assertMacosAutoUpdateMetadata(outputDir, releaseTargetPlatforms); - assertMacosZipBridgePayload(outputDir, releaseTargetPlatforms); + assertMacosZipBridgePayload(outputDir, releaseTargetPlatforms, manifest.releaseCommit); verifyReleaseProvenance(manifest, env); return true; } diff --git a/scripts/prepareEvaosDesktopBridgeResource.js b/scripts/prepareEvaosDesktopBridgeResource.js index c0096501ec..26098b8fb9 100644 --- a/scripts/prepareEvaosDesktopBridgeResource.js +++ b/scripts/prepareEvaosDesktopBridgeResource.js @@ -16,6 +16,7 @@ const PEEKABOO_LICENSE_RELATIVE_PATH = 'licenses/Peekaboo-LICENSE.txt'; const PYTHON_LICENSE_RELATIVE_PATH = 'licenses/CPython-LICENSE.txt'; const PYTHON_RUNTIME_INVENTORY_RELATIVE_PATH = 'python-runtime-inventory.json'; const PYTHON_RUNTIME_INVENTORY_SCHEMA = 'evaos-python-runtime-inventory/v1'; +const BRIDGE_WRAPPER_METADATA_SCHEMA = 'evaos-workbench-bridge-wrapper/v1'; const TRUE_VALUES = new Set(['1', 'true', 'yes', 'on', 'evaos-beta']); const MACHO_MAGICS = new Set([ @@ -88,6 +89,24 @@ function gitValue(cwd, args) { } } +function assertVendoredBridgeSourceMatchesHead(runGit = execFileSync) { + const sourcePath = path.relative(projectRoot, vendoredBridgeSourceDir).split(path.sep).join('/'); + let status; + try { + status = runGit('git', ['status', '--porcelain=v1', '--untracked-files=all', '--', sourcePath], { + cwd: projectRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + } catch (error) { + throw new Error(`Unable to verify the vendored Workbench bridge against the evaOS-GUI HEAD: ${error.message}`); + } + if (String(status || '').trim()) { + throw new Error('Release builds require the vendored Workbench bridge source and provenance to match HEAD.'); + } + return true; +} + function copyDirectory(source, target) { fs.rmSync(target, { recursive: true, force: true }); fs.mkdirSync(path.dirname(target), { recursive: true }); @@ -417,7 +436,19 @@ fi mkdir -p "$CACHE_ROOT/pycache" 2>/dev/null || true export PYTHONPYCACHEPREFIX="$CACHE_ROOT/pycache" -exec "$PYTHON_BIN" -P -m evaos_desktop_bridge.cli "$@" +PYTHON_MODULE="evaos_desktop_bridge.cli" +case "\${1:-}" in + pre-canary) + PYTHON_MODULE="evaos_desktop_bridge.pre_canary" + shift + ;; + qa-canary) + PYTHON_MODULE="evaos_desktop_bridge.qa_canary" + shift + ;; +esac + +exec "$PYTHON_BIN" -P -B -m "$PYTHON_MODULE" "$@" `; } @@ -520,6 +551,14 @@ function writeBridgeExecutable() { return wrapperPath; } +function bridgeWrapperMetadata(filePath) { + return { + schema: BRIDGE_WRAPPER_METADATA_SCHEMA, + path: 'evaos-desktop-bridge', + sourceSha256: sha256File(filePath), + }; +} + function writePlaceholderWrapper(reason) { const wrapperPath = path.join(bridgeResourceDir, 'evaos-desktop-bridge'); fs.writeFileSync( @@ -564,7 +603,7 @@ function bridgeManifest({ }) { const manifest = { schema: 'evaos-desktop-bridge-resource/v1', - requestedSourceRef: selectedBridgeSourceRef(requestedSourceRef || sourceCommit), + requestedSourceRef: String(requestedSourceRef || sourceCommit || '').trim(), sourcePath, sourceCommit, sourceBranch, @@ -698,6 +737,9 @@ function main() { const bridgePackageTarget = path.join(bridgeResourceDir, 'src', 'evaos_desktop_bridge'); const bridgeBinDir = path.join(bridgeResourceDir, 'bin'); + if (shouldRequireRealBridge()) { + assertVendoredBridgeSourceMatchesHead(); + } const sourceProvenance = vendoredBridgeSourceMetadata(bridgeSourceDir); const sourceRepositoryRoot = path.resolve(bridgeSourceDir) === path.resolve(vendoredBridgeSourceDir) ? projectRoot : bridgeSourceDir; @@ -734,7 +776,10 @@ function main() { requireMachOReleaseBinary(path.join(bridgeBinDir, 'peekaboo'), 'bundled Peekaboo helper'); requireMachOReleaseBinary(helperPath, 'bundled evaOS connector helper'); } - const bundledTools = peekabooBundleMetadata(peekabooBinary) || {}; + const bundledTools = { + ...(peekabooBundleMetadata(peekabooBinary) || {}), + bridgeWrapper: bridgeWrapperMetadata(bridgeExecutable), + }; if (pythonRuntime) bundledTools.python = pythonRuntime; const manifest = bridgeManifest({ @@ -760,7 +805,9 @@ if (require.main === module) { } module.exports = { + assertVendoredBridgeSourceMatchesHead, bridgeManifest, + bridgeWrapperMetadata, bridgeWrapperScript, installPythonRuntime, installPeekabooLicense, diff --git a/tests/unit/bootstrap/afterPackBundledResources.test.ts b/tests/unit/bootstrap/afterPackBundledResources.test.ts index 2740552344..0bf872da3b 100644 --- a/tests/unit/bootstrap/afterPackBundledResources.test.ts +++ b/tests/unit/bootstrap/afterPackBundledResources.test.ts @@ -2,7 +2,7 @@ import { createRequire } from 'node:module'; import { chmodSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync, mkdirSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; const require = createRequire(import.meta.url); const cpythonLicense = readFileSync(join(__dirname, '../../fixtures/licenses/CPython-3.12.13-LICENSE.txt')); @@ -12,6 +12,8 @@ const afterPack = require('../../../scripts/afterPack.js') as { verifyEvaosDesktopBridgeResource: (resourcesDir: string, electronPlatformName: string, targetArch?: string) => void; }; const bridgeResource = require('../../../scripts/prepareEvaosDesktopBridgeResource.js') as { + bridgeWrapperMetadata: (filePath: string) => { schema: string; path: string; sourceSha256: string }; + bridgeWrapperScript: () => string; writePythonRuntimeInventory: (resourceDir: string) => { inventoryPath: string; inventorySha256: string; @@ -21,6 +23,8 @@ const bridgeResource = require('../../../scripts/prepareEvaosDesktopBridgeResour }; const tempDirs: string[] = []; +const strictSourceCommit = '82bb944d2c61586472fe3c8fe20b85f61ba0f4df'; +let previousSourceRef: string | undefined; function makeTempResources(): string { const dir = mkdtempSync(join(tmpdir(), 'evaos-afterpack-')); @@ -60,7 +64,8 @@ function writeBridgeFixture(resourcesDir: string, options: { helper?: boolean; n mkdirSync(join(bridgeDir, 'licenses'), { recursive: true }); const bridgePath = join(bridgeDir, 'evaos-desktop-bridge'); const peekabooPath = join(bridgeDir, 'bin', 'peekaboo'); - writeExecutableScript(bridgePath); + writeFileSync(bridgePath, bridgeResource.bridgeWrapperScript()); + chmodSync(bridgePath, 0o755); if (options.nativeHelpers) { writeMachOFixture(peekabooPath); } else { @@ -100,10 +105,11 @@ function writeBridgeFixture(resourcesDir: string, options: { helper?: boolean; n schema: 'evaos-workbench-vendored-bridge-source/v1', owner: '100yenadmin/evaOS-GUI', status: 'vendored', - importedCommit: '9e3b7332a88fbdea22291923bfd10dd37494d92d', + importedCommit: '908e3cad8c5f11dca739bbfc2c697c3e6d52f79e', ...bridgeSourceIdentity, }, bundledTools: { + bridgeWrapper: bridgeResource.bridgeWrapperMetadata(bridgePath), python: { version: '3.12.13', sourceSha256: '5a30271f8d345a5b02b0c9e4e31e0f1e1455a8e4a04fba95cd9762472abc3b17', @@ -155,7 +161,13 @@ function writeBridgeFixture(resourcesDir: string, options: { helper?: boolean; n } } +beforeEach(() => { + previousSourceRef = process.env.EVAOS_DESKTOP_BRIDGE_SOURCE_REF; + process.env.EVAOS_DESKTOP_BRIDGE_SOURCE_REF = strictSourceCommit; +}); + afterEach(() => { + restoreEnv('EVAOS_DESKTOP_BRIDGE_SOURCE_REF', previousSourceRef); for (const dir of tempDirs.splice(0)) { rmSync(dir, { force: true, recursive: true }); } @@ -302,6 +314,22 @@ describe('afterPack bundled resource verification', () => { } }); + it('rejects a strict packaged bridge whose launcher changed after preparation', () => { + const previous = process.env.EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL; + const resourcesDir = makeTempResources(); + writeBridgeFixture(resourcesDir, { helper: true, nativeHelpers: true }); + writeExecutableScript(join(resourcesDir, 'Bridge', 'evaos-desktop-bridge')); + + try { + process.env.EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL = '1'; + expect(() => afterPack.verifyEvaosDesktopBridgeResource(resourcesDir, 'darwin', 'arm64')).toThrow( + /exact evaOS-GUI-owned source/ + ); + } finally { + restoreEnv('EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL', previous); + } + }); + it('rejects a strict packaged bridge that targets the legacy evaOS app', () => { const previous = process.env.EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL; const resourcesDir = makeTempResources(); diff --git a/tests/unit/process/evaosBetaReleaseGate.test.ts b/tests/unit/process/evaosBetaReleaseGate.test.ts index 625d604af1..5afa49c63b 100644 --- a/tests/unit/process/evaosBetaReleaseGate.test.ts +++ b/tests/unit/process/evaosBetaReleaseGate.test.ts @@ -37,6 +37,9 @@ const releaseGate = require('../../../scripts/evaosBetaReleaseGate.js') as { verifyRcProof: (proofDir: string, tag: string, env: Record) => boolean; writeRcProofTemplate: (proofDir: string, tag: string) => unknown; }; +const bridgeResource = require('../../../scripts/prepareEvaosDesktopBridgeResource.js') as { + bridgeWrapperScript: () => string; +}; const afterSign = require('../../../scripts/afterSign.js') as { (context: unknown): Promise; default: (context: unknown) => Promise; @@ -162,9 +165,14 @@ function writeMacosBridgeZip( omitStdlibSentinel?: boolean; signedPythonMutation?: boolean; secondAppRoot?: boolean; + tamperBridgeWrapper?: boolean; + tamperBridgeSource?: boolean; + bridgeSourceCommit?: string; } = {} ) { + const bridgeWrapperBase64 = Buffer.from(bridgeResource.bridgeWrapperScript()).toString('base64'); const script = [ + 'import base64', 'import hashlib', 'import json', 'import pathlib', @@ -202,6 +210,10 @@ function writeMacosBridgeZip( 'regular_inventory_symlink = sys.argv[28] == "1"', 'inventory_directory_archive_mode = int(sys.argv[29]) if sys.argv[29] else None', 'inventory_file_archive_mode = int(sys.argv[30]) if sys.argv[30] else None', + 'tamper_bridge_wrapper = sys.argv[31] == "1"', + 'tamper_bridge_source = sys.argv[32] == "1"', + 'bridge_source_commit = sys.argv[33]', + `bridge_wrapper_bytes = base64.b64decode("${bridgeWrapperBase64}")`, 'app_root = zip_path.stem.replace("-mac-arm64", "").replace("-mac-x64", "") + ".app"', 'python_arch = "arm64" if "arm64" in zip_path.name else "x64"', 'python_source_sha256 = "5a30271f8d345a5b02b0c9e4e31e0f1e1455a8e4a04fba95cd9762472abc3b17" if python_arch == "arm64" else "cd369e76973c3179bc578230d8615ab621968ed758c5e32f636eecef4ad79894"', @@ -268,7 +280,16 @@ function writeMacosBridgeZip( 'inventory = {"schema": "evaos-python-runtime-inventory/v1", "entries": inventory_entries}', 'inventory_bytes = (json.dumps(inventory, indent=2) + "\\n").encode()', 'python_metadata = {"version": "3.12.13", "architecture": python_arch, "sourceSha256": python_source_sha256, "sourceUrl": python_source_url, "packages": python_packages, "license": "Python-2.0", "licensePath": "licenses/CPython-LICENSE.txt", "licenseSha256": python_license_sha256, "inventoryPath": "python-runtime-inventory.json", "inventorySha256": hashlib.sha256(inventory_bytes).hexdigest(), "inventoryEntryCount": len(inventory_entries)}', - 'manifest = {"placeholder": False, "bundledTools": {"peekaboo": {"version": "3.8.0", "sourceSha256": source_sha256, "license": "MIT", "licensePath": "licenses/Peekaboo-LICENSE.txt", "licenseSha256": license_sha256}, "python": python_metadata}}', + 'bridge_source_files = {"__init__.py": b"__version__ = \\\"0.6.27\\\"\\n", "cli.py": b"# bridge fixture\\n"}', + 'bridge_source_hash = hashlib.sha256()', + 'for relative_path, contents in sorted(bridge_source_files.items()):', + ' bridge_source_hash.update(relative_path.encode())', + ' bridge_source_hash.update(b"\\0")', + ' bridge_source_hash.update(contents)', + ' bridge_source_hash.update(b"\\0")', + 'bridge_wrapper_metadata = {"schema": "evaos-workbench-bridge-wrapper/v1", "path": "evaos-desktop-bridge", "sourceSha256": hashlib.sha256(bridge_wrapper_bytes).hexdigest()}', + 'source_provenance = {"schema": "evaos-workbench-vendored-bridge-source/v1", "owner": "100yenadmin/evaOS-GUI", "status": "vendored", "importedCommit": "908e3cad8c5f11dca739bbfc2c697c3e6d52f79e", "sourceSha256": bridge_source_hash.hexdigest()}', + 'manifest = {"placeholder": False, "requestedSourceRef": bridge_source_commit, "sourcePath": "resources/evaos-beta/bridge", "sourceCommit": bridge_source_commit, "sourceProvenance": source_provenance, "bundledTools": {"bridgeWrapper": bridge_wrapper_metadata, "peekaboo": {"version": "3.8.0", "sourceSha256": source_sha256, "license": "MIT", "licensePath": "licenses/Peekaboo-LICENSE.txt", "licenseSha256": license_sha256}, "python": python_metadata}}', 'def write_regular(archive, name, data, mode=0o644):', ' info = zipfile.ZipInfo(name)', ' info.create_system = 3', @@ -289,7 +310,11 @@ function writeMacosBridgeZip( ' archive.writestr(info, b"")', 'with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:', ' bridge_prefix = f"{app_root}/Contents/Resources/Bridge"', - ' write_regular(archive, f"{bridge_prefix}/evaos-desktop-bridge", b"#!/usr/bin/env bash\\n", 0o644 if non_executable_payload == "bridge" else 0o755)', + ' packaged_wrapper_bytes = b"#!/bin/sh\\nexit 0\\n" if tamper_bridge_wrapper else bridge_wrapper_bytes', + ' write_regular(archive, f"{bridge_prefix}/evaos-desktop-bridge", packaged_wrapper_bytes, 0o644 if non_executable_payload == "bridge" else 0o755)', + ' for relative_path, contents in bridge_source_files.items():', + ' packaged_contents = contents + b"# tampered\\n" if tamper_bridge_source and relative_path == "cli.py" else contents', + ' write_regular(archive, f"{bridge_prefix}/src/evaos_desktop_bridge/{relative_path}", packaged_contents)', ' if not omit_peekaboo:', ' write_regular(archive, f"{bridge_prefix}/bin/peekaboo", bytes.fromhex("cafebabe00000000"), 0o644 if non_executable_payload == "peekaboo" else 0o755)', ' write_regular(archive, f"{bridge_prefix}/bin/evaos-connector-helper", bytes.fromhex("cafebabe00000000"), 0o644 if non_executable_payload == "helper" else 0o755)', @@ -358,6 +383,9 @@ function writeMacosBridgeZip( options.regularInventorySymlink ? '1' : '0', String(options.inventoryDirectoryArchiveMode ?? ''), String(options.inventoryFileArchiveMode ?? ''), + options.tamperBridgeWrapper ? '1' : '0', + options.tamperBridgeSource ? '1' : '0', + options.bridgeSourceCommit || 'abc123', ]); } @@ -2378,6 +2406,16 @@ describe('evaOS beta release gate', () => { options: { sourceSha256: '0'.repeat(64) }, expected: /source digest/, }, + { + name: 'tampered bridge launcher', + options: { tamperBridgeWrapper: true }, + expected: /canonical launcher digest/, + }, + { + name: 'tampered GUI-owned bridge source', + options: { tamperBridgeSource: true }, + expected: /GUI-owned Python source digest/, + }, { name: 'self-consistent altered CPython license', options: { tamperPythonLicense: true }, @@ -2665,8 +2703,12 @@ describe('evaOS beta release gate', () => { try { fs.writeFileSync(path.join(dir, 'evaOS Workbench-2.1.10-evaos-beta.0-mac-arm64.dmg'), 'mac-arm64'); fs.writeFileSync(path.join(dir, 'evaOS Workbench-2.1.10-evaos-beta.0-mac-x64.dmg'), 'mac-x64'); - writeMacosBridgeZip(path.join(dir, 'evaOS Workbench-2.1.10-evaos-beta.0-mac-arm64.zip')); - writeMacosBridgeZip(path.join(dir, 'evaOS Workbench-2.1.10-evaos-beta.0-mac-x64.zip')); + writeMacosBridgeZip(path.join(dir, 'evaOS Workbench-2.1.10-evaos-beta.0-mac-arm64.zip'), { + bridgeSourceCommit: sourceSha, + }); + writeMacosBridgeZip(path.join(dir, 'evaOS Workbench-2.1.10-evaos-beta.0-mac-x64.zip'), { + bridgeSourceCommit: sourceSha, + }); fs.writeFileSync( path.join(dir, 'latest-arm64-mac.yml'), "minimumSystemVersion: '24.0.0'\npath: evaOS Workbench-2.1.10-evaos-beta.0-mac-arm64.zip\n" diff --git a/tests/unit/process/prepareEvaosDesktopBridgeResource.test.ts b/tests/unit/process/prepareEvaosDesktopBridgeResource.test.ts index 73d3e683ca..b4fcc44774 100644 --- a/tests/unit/process/prepareEvaosDesktopBridgeResource.test.ts +++ b/tests/unit/process/prepareEvaosDesktopBridgeResource.test.ts @@ -13,6 +13,10 @@ import { execFileSync } from 'node:child_process'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, expect, it, vi } from 'vitest'; +import { + EVAOS_NATIVE_COMPANION_CANARIES, + EVAOS_PACKAGED_BRIDGE_COMMAND, +} from '../../../packages/desktop/src/common/evaos/nativeCompanionBoundary'; type PeekabooVersionRunner = (filePath: string, args: string[], options: Record) => string; @@ -31,6 +35,9 @@ type PythonRuntimeMetadata = { }; const bridgeResource = require('../../../scripts/prepareEvaosDesktopBridgeResource.js') as { + assertVendoredBridgeSourceMatchesHead: ( + runGit?: (command: string, args: string[], options: Record) => string + ) => boolean; bridgeManifest: (input: { requestedSourceRef?: string; sourcePath: string; @@ -49,6 +56,7 @@ const bridgeResource = require('../../../scripts/prepareEvaosDesktopBridgeResour }; }; }) => Record; + bridgeWrapperMetadata: (filePath: string) => { schema: string; path: string; sourceSha256: string }; bridgeWrapperScript: () => string; installPythonRuntime: (sourcePath?: string, resourceDir?: string) => PythonRuntimeMetadata | undefined; writePythonRuntimeInventory: (resourceDir: string) => { @@ -92,7 +100,9 @@ describe('prepareEvaosDesktopBridgeResource', () => { expect(wrapper).toContain('export PYTHONPATH="$BRIDGE_DIR/src"'); expect(wrapper).toContain('CACHE_ROOT="$HOME/Library/Caches/evaos-desktop-bridge"'); expect(wrapper).toContain('export PYTHONPYCACHEPREFIX="$CACHE_ROOT/pycache"'); - expect(wrapper).toContain('exec "$PYTHON_BIN" -P -m evaos_desktop_bridge.cli "$@"'); + expect(wrapper).toContain('PYTHON_MODULE="evaos_desktop_bridge.pre_canary"'); + expect(wrapper).toContain('PYTHON_MODULE="evaos_desktop_bridge.qa_canary"'); + expect(wrapper).toContain('exec "$PYTHON_BIN" -P -B -m "$PYTHON_MODULE" "$@"'); expect(wrapper).not.toContain('${PYTHONPATH:+:$PYTHONPATH}'); expect(wrapper).not.toContain('site-packages'); expect(wrapper).not.toContain('/opt/homebrew/bin/python3'); @@ -101,6 +111,44 @@ describe('prepareEvaosDesktopBridgeResource', () => { expect(wrapper).not.toContain('Install Python 3'); }); + it('exports RC canaries through the installed Workbench bridge and bundled Python', () => { + expect(EVAOS_PACKAGED_BRIDGE_COMMAND).toBe( + '"/Applications/evaOS Workbench.app/Contents/Resources/Bridge/evaos-desktop-bridge"' + ); + expect(EVAOS_NATIVE_COMPANION_CANARIES.map((canary) => canary.id)).toEqual([ + 'pre-canary-bridge-peekaboo', + 'connector-all', + 'connector-kill-switch', + ]); + for (const canary of EVAOS_NATIVE_COMPANION_CANARIES) { + expect(canary.command).toMatch( + /^"\/Applications\/evaOS Workbench\.app\/Contents\/Resources\/Bridge\/evaos-desktop-bridge" (pre-canary|qa-canary) / + ); + expect(canary.command).toContain('--artifact-dir "${EVAOS_CANARY_ARTIFACT_DIR:'); + expect(canary.command).not.toContain('PYTHONPATH='); + expect(canary.command).not.toMatch(/\bpython3\b/); + if (canary.id !== 'pre-canary-bridge-peekaboo') { + expect(canary.command).toContain('--connector-url "${EVAOS_DESKTOP_BRIDGE_URL:'); + } + expect(canary.requiredArtifact).toBe('qa-report.json'); + expect(canary.forbidsSkips).toBe(true); + } + }); + + it('rejects dirty or untracked vendored bridge bytes in strict provenance checks', () => { + expect(() => + bridgeResource.assertVendoredBridgeSourceMatchesHead( + () => ' M resources/evaos-beta/bridge/src/evaos_desktop_bridge/cli.py\n' + ) + ).toThrow(/match HEAD/); + expect(() => + bridgeResource.assertVendoredBridgeSourceMatchesHead( + () => '?? resources/evaos-beta/bridge/src/evaos_desktop_bridge/injected.py\n' + ) + ).toThrow(/match HEAD/); + expect(bridgeResource.assertVendoredBridgeSourceMatchesHead(() => '')).toBe(true); + }); + it('requires a bundled Python runtime for strict release packaging', () => { const previousRequireReal = process.env.EVAOS_DESKTOP_BRIDGE_REQUIRE_REAL; const previousRuntimeDir = process.env.EVAOS_DESKTOP_BRIDGE_PYTHON_RUNTIME_DIR; @@ -320,7 +368,7 @@ describe('prepareEvaosDesktopBridgeResource', () => { schema: 'evaos-workbench-vendored-bridge-source/v1', owner: '100yenadmin/evaOS-GUI', status: 'vendored', - importedCommit: '9e3b7332a88fbdea22291923bfd10dd37494d92d', + importedCommit: '908e3cad8c5f11dca739bbfc2c697c3e6d52f79e', }); expect(metadata.sourceSha256).toMatch(/^[0-9a-f]{64}$/); expect(adapter).toContain('WORKBENCH_CANONICAL_APP_PATH = Path("/Applications/evaOS Workbench.app")'); @@ -335,11 +383,94 @@ describe('prepareEvaosDesktopBridgeResource', () => { 'from evaos_desktop_bridge.adapters.customer_mac import CustomerMacObserver', 'with TemporaryDirectory() as state:', ' observer = CustomerMacObserver(state_dir=Path(state), platform_name="Darwin")', - ' for alias in ("EvaDesktop", "evaOS", "evaOS Workbench", "com.evaos.workbench"):', + ' for alias in ("EvaDesktop", "evaOS", "evaOS Workbench", "com.evaos.workbench", "/Applications/evaOS Workbench.app"):', ' result = observer.app_focus(app_name=alias, dry_run=True)', ' assert result.ok, (alias, result.errors)', ' assert result.data["app_path"] == "/Applications/evaOS Workbench.app", (alias, result.data)', ' assert result.data["process_name"] == "evaOS Workbench", (alias, result.data)', + ' def unexpected_runner(*_args, **_kwargs):', + ' raise AssertionError("legacy app path reached a command runner")', + ' guarded = CustomerMacObserver(state_dir=Path(state), platform_name="Darwin", runner=unexpected_runner)', + ' for legacy_path in ("/Applications/evaOS.app", "file:///Applications/evaOS.app"):', + ' blocked = guarded.app_focus(app_name=legacy_path, dry_run=False)', + ' assert not blocked.ok, (legacy_path, blocked.data)', + ' assert blocked.errors[0]["code"] == "legacy_workbench_app_blocked", (legacy_path, blocked.errors)', + 'print("ok")', + ].join('\n'); + + expect( + execFileSync('python3', ['-B', '-c', script], { + encoding: 'utf8', + env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1', PYTHONPATH: sourceDir }, + }).trim() + ).toBe('ok'); + }); + + it('retains the authenticated readiness and bounded diagnostics P0 fixes', () => { + const sourceDir = join(process.cwd(), 'resources', 'evaos-beta', 'bridge', 'src'); + const script = [ + 'from evaos_desktop_bridge import cli as bridge_cli', + 'calls = []', + 'bridge_cli._connector_plist_path = lambda: None', + 'bridge_cli._connector_plist_host = lambda _path: None', + 'bridge_cli._tailscale_ip = lambda *args, **kwargs: None', + 'def fake_get(host, path, *, authorization=None, timeout_seconds=1.0):', + ' calls.append((path, timeout_seconds, authorization is not None))', + ' if path == "/health":', + ' return {"outcome": "complete", "status_code": 200, "status_line": "HTTP/1.1 200 OK", "json": {"service": "evaos-desktop-bridge-connector"}}', + ' return {"outcome": "complete", "status_code": 200, "status_line": "HTTP/1.1 200 OK", "json": {"schema": "evaos.desktop_bridge.diagnostics.v1", "connector": {"ready": {"schema": "evaos.desktop_bridge.ready.v1", "ok": True, "ready": True}}}}', + 'bridge_cli._connector_http_get = fake_get', + 'health = bridge_cli._connector_loopback_health(connector_token="fixture-token")', + 'assert health["ready"] is True, health', + 'assert health["authenticated"] is True, health', + 'assert calls == [("/health", 1.0, False), ("/v1/diagnostics", 5.0, True)], calls', + 'service_status = {"ok": False, "ready": False, "token_present": True, "loaded": True, "running": False, "managed_by": "offline", "health": {"reachable": True, "ready": False, "authenticated": False, "host": "127.0.0.1", "error": "connector_diagnostics_timeout"}}', + 'service_calls = 0', + 'def fake_status(**_kwargs):', + ' global service_calls', + ' service_calls += 1', + ' return service_status', + 'bridge_cli._connector_service_status = fake_status', + 'bridge_cli.build_diagnostics_payload = lambda **_kwargs: {"connector": {}}', + 'bridge_cli.build_ready_payload = lambda **_kwargs: {"ok": True, "ready": True, "blockers": []}', + 'diagnostics = bridge_cli._build_cli_diagnostics_payload(token="fixture-token")', + 'readiness = diagnostics["connector"]["ready"]', + 'assert service_calls == 1, service_calls', + 'assert readiness["ready"] is False, readiness', + 'assert [item["code"] for item in readiness["blockers"]] == ["connector_diagnostics_timeout"], readiness', + 'print("ok")', + ].join('\n'); + + expect( + execFileSync('python3', ['-B', '-c', script], { + encoding: 'utf8', + env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1', PYTHONPATH: sourceDir }, + }).trim() + ).toBe('ok'); + }); + + it('uses the current Workbench identity for packaged pre-canary inventory', () => { + const sourceDir = join(process.cwd(), 'resources', 'evaos-beta', 'bridge', 'src'); + const script = [ + 'from evaos_desktop_bridge import pre_canary', + 'from pathlib import Path', + 'from tempfile import TemporaryDirectory', + 'assert pre_canary.DEFAULT_CANONICAL_PATH == "/Applications/evaOS Workbench.app"', + 'assert pre_canary.DEFAULT_BUNDLE_ID == "com.evaos.workbench"', + 'current = pre_canary.AppBundle(path=pre_canary.DEFAULT_CANONICAL_PATH, bundle_id=pre_canary.DEFAULT_BUNDLE_ID, version="2.1.36", build="2.1.36", team_id=pre_canary.DEFAULT_TEAM_ID)', + 'current_process = pre_canary.ProcessInfo(pid=1, command="/Applications/evaOS Workbench.app/Contents/MacOS/evaOS Workbench", path=pre_canary.DEFAULT_CANONICAL_PATH, kind="workbench")', + 'inventory = pre_canary.WorkbenchInventory(registered_paths=(pre_canary.DEFAULT_CANONICAL_PATH,), app_bundles=(current,), processes=(current_process,))', + 'report = pre_canary.evaluate_inventory(inventory, expected_version="2.1.36", expected_build="2.1.36")', + 'assert report.ok, report.to_dict()', + 'assert pre_canary._workbench_app_path_from_command(current_process.command) == pre_canary.DEFAULT_CANONICAL_PATH', + 'with TemporaryDirectory() as artifact_dir:', + ' report_path = pre_canary._write_report(report.to_dict(), Path(artifact_dir))', + ' assert report_path.name == "qa-report.json" and report_path.is_file()', + 'legacy = pre_canary.AppBundle(path="/Applications/evaOS.app", bundle_id="com.electricsheephq.EvaDesktop")', + 'legacy_inventory = pre_canary.WorkbenchInventory(registered_paths=(pre_canary.DEFAULT_CANONICAL_PATH, legacy.path), app_bundles=(current, legacy), processes=(current_process,))', + 'legacy_report = pre_canary.evaluate_inventory(legacy_inventory)', + 'assert not legacy_report.ok, legacy_report.to_dict()', + 'assert "stale_workbench_app_bundle_present" in {check.code for check in legacy_report.checks}', 'print("ok")', ].join('\n'); @@ -360,12 +491,14 @@ describe('prepareEvaosDesktopBridgeResource', () => { it('records the requested bridge source ref in packaged resource manifests', () => { const previousSourceRef = process.env.EVAOS_DESKTOP_BRIDGE_SOURCE_REF; const bridgeSha = '60f7e87aa373fbae5ac91b8e6c50b86cfe5e064b'; + const unrelatedEnvSha = '8cdc02cee0f1e5d53ae430a942848c721762b00a'; try { - process.env.EVAOS_DESKTOP_BRIDGE_SOURCE_REF = bridgeSha; + process.env.EVAOS_DESKTOP_BRIDGE_SOURCE_REF = unrelatedEnvSha; expect( bridgeResource.bridgeManifest({ + requestedSourceRef: bridgeSha, sourcePath: '/tmp/evaos-desktop-bridge', sourceCommit: bridgeSha, sourceBranch: 'HEAD', @@ -384,7 +517,7 @@ describe('prepareEvaosDesktopBridgeResource', () => { placeholderReason: 'source unavailable', }) ).toMatchObject({ - requestedSourceRef: bridgeSha, + requestedSourceRef: '', sourceCommit: undefined, placeholder: true, placeholderReason: 'source unavailable', From a6b64459bb35fd197468fb057c1957bbbe17620d Mon Sep 17 00:00:00 2001 From: Eva Date: Wed, 15 Jul 2026 02:50:16 +0700 Subject: [PATCH 03/13] fix(evaos): harden Workbench Mac enrollment release gates --- .github/workflows/evaos-beta-rc-canary.yml | 146 +++++++ .github/workflows/evaos-live-canary-proof.yml | 12 +- .../desktop/src/common/adapter/ipcBridge.ts | 16 +- .../desktop/src/common/evaos/bridgeTypes.ts | 16 + .../common/evaos/nativeCompanionBoundary.ts | 16 +- .../process/services/evaosBrokerSession.ts | 42 +- .../services/evaosNativeCompanionStatus.ts | 229 ++++++++-- .../evaos/useEvaosNativeCompanionStatus.ts | 17 +- .../renderer/pages/native-companion/index.tsx | 41 +- .../adapters/customer_mac.py | 56 ++- .../candidate_identity.py | 198 +++++++++ .../bridge/src/evaos_desktop_bridge/cli.py | 14 +- .../evaos_desktop_bridge/connector_server.py | 15 + .../src/evaos_desktop_bridge/pre_canary.py | 81 +++- .../src/evaos_desktop_bridge/qa_canary.py | 313 +++++++++++++- scripts/afterPack.js | 6 +- scripts/evaosBetaReleaseGate.js | 408 +++++++++++++++++- scripts/prepareEvaosDesktopBridgeResource.js | 34 +- .../common-adapter/evaosIpcBridge.dom.test.ts | 27 ++ .../evaos/NativeCompanionPage.dom.test.tsx | 68 +++ tests/unit/evaos/evaosBrokerSession.test.ts | 34 ++ .../evaos/evaosNativeCompanionStatus.test.ts | 155 ++++++- ...vaosNativeCompanionStatusHook.dom.test.tsx | 25 ++ .../unit/process/evaosBetaReleaseGate.test.ts | 321 ++++++++++++-- .../prepareEvaosDesktopBridgeResource.test.ts | 181 +++++++- 25 files changed, 2284 insertions(+), 187 deletions(-) create mode 100644 resources/evaos-beta/bridge/src/evaos_desktop_bridge/candidate_identity.py diff --git a/.github/workflows/evaos-beta-rc-canary.yml b/.github/workflows/evaos-beta-rc-canary.yml index 9f23ca7626..e66ca2226b 100644 --- a/.github/workflows/evaos-beta-rc-canary.yml +++ b/.github/workflows/evaos-beta-rc-canary.yml @@ -299,6 +299,90 @@ jobs: NODE node scripts/evaosBetaReleaseGate.js write-rc-proof-template "$PROOF_DIR" "$TAG" + - name: Verify updater ZIP signature and notarization + env: + TAG: ${{ github.event.inputs.tag }} + TAG_COMMIT: ${{ steps.provenance.outputs.tag_commit }} + run: | + set -euo pipefail + ZIP_NAME=$(node - release-assets/latest-arm64-mac.yml <<'NODE' + const fs = require('fs'); + const metadataPath = process.argv[2]; + const text = fs.readFileSync(metadataPath, 'utf8'); + const refs = []; + for (const line of text.split(/\r?\n/)) { + const match = line.match(/^\s*(?:-\s*)?(?:path|url):\s*(.+?)\s*$/); + if (!match) continue; + let ref = match[1].trim().replace(/^['"]|['"]$/g, ''); + if (/^https?:\/\//i.test(ref)) ref = new URL(ref).pathname.split('/').pop(); + if (ref?.endsWith('.zip')) refs.push(ref); + } + const unique = [...new Set(refs)]; + if (unique.length !== 1 || !/arm64/i.test(unique[0])) { + throw new Error('latest-arm64-mac.yml must identify exactly one arm64 updater ZIP.'); + } + process.stdout.write(unique[0]); + NODE + ) + UPDATER_ZIP="release-assets/$ZIP_NAME" + if [ ! -f "$UPDATER_ZIP" ]; then + echo "::error::Updater metadata references missing arm64 ZIP: $ZIP_NAME" + exit 1 + fi + EXPECTED_SHA=$(node - release-assets/evaos-beta-release-manifest.json "$ZIP_NAME" <<'NODE' + const fs = require('fs'); + const [manifestPath, assetName] = process.argv.slice(2); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + const asset = (manifest.assets || []).find((candidate) => candidate.name === assetName); + if (!asset || !/^[0-9a-f]{64}$/i.test(String(asset.sha256 || ''))) { + throw new Error(`Trusted manifest does not bind updater ZIP ${assetName}.`); + } + process.stdout.write(asset.sha256); + NODE + ) + ACTUAL_SHA=$(shasum -a 256 "$UPDATER_ZIP" | awk '{print $1}') + if [ "$ACTUAL_SHA" != "$EXPECTED_SHA" ]; then + echo "::error::Updater ZIP checksum does not match the trusted release manifest." + exit 1 + fi + + EXTRACT_DIR="$RUNNER_TEMP/evaos-updater-zip-arm64" + rm -rf "$EXTRACT_DIR" + mkdir -p "$EXTRACT_DIR" + ditto -x -k "$UPDATER_ZIP" "$EXTRACT_DIR" + ZIP_APP=$(find "$EXTRACT_DIR" -maxdepth 2 -type d -name "$BETA_APP_NAME" | head -n 1) + if [ -z "$ZIP_APP" ]; then + echo "::error::Updater ZIP did not contain $BETA_APP_NAME." + exit 1 + fi + + codesign --verify --deep --strict --verbose=2 "$ZIP_APP" > "$PROOF_DIR/codesign-updater-zip-macos-arm64.txt" 2>&1 + xcrun stapler validate "$ZIP_APP" > "$PROOF_DIR/stapler-updater-zip-macos-arm64.txt" 2>&1 + spctl --assess --type execute --verbose "$ZIP_APP" > "$PROOF_DIR/spctl-updater-zip-macos-arm64.txt" 2>&1 + node - "$PROOF_DIR/updater-zip-macos-arm64.json" "$TAG" "$TAG_COMMIT" "$ZIP_NAME" "$ACTUAL_SHA" "$BETA_APP_NAME" <<'NODE' + const fs = require('fs'); + const [outputPath, tag, releaseCommit, assetName, sha256, appName] = process.argv.slice(2); + fs.writeFileSync( + outputPath, + `${JSON.stringify( + { + schema: 'evaos-updater-zip-trust/v1', + tag, + releaseCommit, + assetName, + sha256, + appName, + codesignVerified: true, + staplerVerified: true, + gatekeeperVerified: true, + }, + null, + 2 + )}\n` + ); + NODE + rm -rf "$EXTRACT_DIR" + - name: Install fallback and beta apps env: FALLBACK_APP_NAME: ${{ github.event.inputs.fallback_app_name }} @@ -321,6 +405,7 @@ jobs: fi rm -rf "/Applications/$app_name" ditto "$app_path" "/Applications/$app_name" + rm -rf "$extract_dir" hdiutil detach "$mount_dir" -quiet } @@ -340,6 +425,7 @@ jobs: fi rm -rf "/Applications/$app_name" ditto "$app_path" "/Applications/$app_name" + rm -rf "$extract_dir" } install_fallback_app() { @@ -427,6 +513,8 @@ jobs: spctl --assess --type execute --verbose "$BETA_APP" > "$PROOF_DIR/spctl-macos-arm64.txt" 2>&1 - name: Launch beta and audit feed isolation + env: + TAG_COMMIT: ${{ steps.provenance.outputs.tag_commit }} run: | set -euo pipefail BETA_APP="/Applications/$BETA_APP_NAME" @@ -534,6 +622,64 @@ jobs: printf '%s\n' "$STALE_RUNNING_APPS" exit 1 fi + + BRIDGE_COMMAND="$BETA_APP/Contents/Resources/Bridge/evaos-desktop-bridge" + if [ ! -x "$BRIDGE_COMMAND" ]; then + echo "::error::Installed Workbench bridge launcher is missing or not executable." + exit 1 + fi + PRE_CANARY_DIR="$PROOF_DIR/installed-candidate-pre-canary" + CONNECTOR_CANARY_DIR="$PROOF_DIR/installed-candidate-connector" + rm -rf "$PRE_CANARY_DIR" "$CONNECTOR_CANARY_DIR" + "$BRIDGE_COMMAND" pre-canary \ + --json \ + --control-surface bridge-peekaboo \ + --expected-version "$SHORT_VERSION" \ + --expected-build "$BUNDLE_VERSION" \ + --expected-source-commit "$TAG_COMMIT" \ + --artifact-dir "$PRE_CANARY_DIR" \ + > "$PROOF_DIR/installed-candidate-pre-canary.stdout.json" + cp "$PRE_CANARY_DIR/qa-report.json" "$PROOF_DIR/installed-candidate-pre-canary.json" + + TOKEN_FILE="$HOME/Library/Application Support/evaos-desktop-bridge/connector.token" + for _attempt in $(seq 1 30); do + if [ -s "$TOKEN_FILE" ] && curl --fail --silent --show-error --max-time 2 http://127.0.0.1:8765/health >/dev/null; then + break + fi + sleep 1 + done + if [ ! -s "$TOKEN_FILE" ]; then + echo "::error::Installed Workbench did not create its connector token." + exit 1 + fi + if ! curl --fail --silent --show-error --max-time 2 http://127.0.0.1:8765/health >/dev/null; then + echo "::error::Installed Workbench connector did not become reachable." + exit 1 + fi + CONNECTOR_TOKEN=$(tr -d '\r\n' < "$TOKEN_FILE") + if [ -z "$CONNECTOR_TOKEN" ]; then + echo "::error::Installed Workbench connector token is empty." + exit 1 + fi + EVAOS_DESKTOP_BRIDGE_TOKEN="$CONNECTOR_TOKEN" "$BRIDGE_COMMAND" qa-canary \ + --connector-url http://127.0.0.1:8765 \ + --artifact-dir "$CONNECTOR_CANARY_DIR" \ + --version-under-test "$SHORT_VERSION" \ + --build-under-test "$BUNDLE_VERSION" \ + --source-commit-under-test "$TAG_COMMIT" \ + --surface connector \ + --suite candidate \ + > "$PROOF_DIR/installed-candidate-connector.stdout.json" + cp "$CONNECTOR_CANARY_DIR/qa-report.json" "$PROOF_DIR/installed-candidate-connector.json" + if grep -R -F "$CONNECTOR_TOKEN" \ + "$PROOF_DIR/installed-candidate-pre-canary.json" \ + "$PROOF_DIR/installed-candidate-connector.json" \ + "$PROOF_DIR/installed-candidate-pre-canary.stdout.json" \ + "$PROOF_DIR/installed-candidate-connector.stdout.json" >/dev/null; then + echo "::error::Installed candidate proof leaked the connector token." + exit 1 + fi + unset CONNECTOR_TOKEN pkill -f "EvaOSWorkbench|evaOS Workbench" || true { diff --git a/.github/workflows/evaos-live-canary-proof.yml b/.github/workflows/evaos-live-canary-proof.yml index b5356b6d5a..a809350c33 100644 --- a/.github/workflows/evaos-live-canary-proof.yml +++ b/.github/workflows/evaos-live-canary-proof.yml @@ -23,7 +23,7 @@ on: default: false type: boolean mac_control_canary_ack: - description: 'Type evaos-mac-control-canary to acknowledge this creates and revokes one short-lived staging desktop session.' + description: 'Type evaos-mac-control-canary to acknowledge this creates and revokes one short-lived staging session and sends one Escape hotkey through the selected Mac connector.' required: false default: '' type: string @@ -225,6 +225,16 @@ jobs: if: github.event.inputs.run_live_canaries == 'true' && github.event.inputs.run_mac_control_canary == 'true' run: | set -euo pipefail + EXPECTED_SOURCE_SHA256=$(node - <<'NODE' + const gate = require('./scripts/evaosBetaReleaseGate.js'); + process.stdout.write(gate.committedBridgeSourceIdentity(process.env.GITHUB_SHA).sourceSha256); + NODE + ) + EXPECTED_VERSION=$(node -p "require('./package.json').version") + export AIONUI_EVAOS_MAC_CONTROL_CANARY_EXPECTED_SOURCE_COMMIT="$GITHUB_SHA" + export AIONUI_EVAOS_MAC_CONTROL_CANARY_EXPECTED_SOURCE_SHA256="$EXPECTED_SOURCE_SHA256" + export AIONUI_EVAOS_MAC_CONTROL_CANARY_EXPECTED_VERSION="$EXPECTED_VERSION" + export AIONUI_EVAOS_MAC_CONTROL_CANARY_EXPECTED_BUILD="$EXPECTED_VERSION" node scripts/evaosBrokerLiveCanary.js --mac-control > "$PROOF_DIR/mac-control-runtime.json" - name: Run follow-up live canaries diff --git a/packages/desktop/src/common/adapter/ipcBridge.ts b/packages/desktop/src/common/adapter/ipcBridge.ts index 6ea36c0441..be0f15b33f 100644 --- a/packages/desktop/src/common/adapter/ipcBridge.ts +++ b/packages/desktop/src/common/adapter/ipcBridge.ts @@ -113,6 +113,7 @@ import { } from './mappers/teamMapper'; const EVAOS_ELECTRON_PROVIDER_TIMEOUT_MS = 15000; +const EVAOS_NATIVE_COMPANION_ACTION_TIMEOUT_MS = 10 * 60 * 1000; const EVAOS_RUNTIME_SURFACE_PROTOCOL = 'evaos-runtime-surface:'; type EvaosElectronBridgeAPI = { @@ -126,12 +127,12 @@ type EvaosRendererWindow = Window & { __evaosProviderCallbacks?: Map void>; }; -function buildEvaosProvider(name: string) { +function buildEvaosProvider(name: string, options: { timeoutMs?: number } = {}) { const platformProvider = bridge.buildProvider(name); return { provider: platformProvider.provider, invoke(request: Request): Promise { - const electronRequest = invokeEvaosElectronProvider(name, request); + const electronRequest = invokeEvaosElectronProvider(name, request, options.timeoutMs); if (electronRequest) { return electronRequest; } @@ -140,7 +141,11 @@ function buildEvaosProvider(name: string) { }; } -function invokeEvaosElectronProvider(name: string, request: Request): Promise | undefined { +function invokeEvaosElectronProvider( + name: string, + request: Request, + timeoutMs = EVAOS_ELECTRON_PROVIDER_TIMEOUT_MS +): Promise | undefined { const rendererWindow = getEvaosRendererWindow(); const electronAPI = rendererWindow?.electronAPI; if (!rendererWindow || !electronAPI) { @@ -156,7 +161,7 @@ function invokeEvaosElectronProvider(name: string, request: R const timeout = rendererWindow.setTimeout(() => { callbacks.delete(callbackName); reject(new Error(`Timed out waiting for evaOS provider response: ${name}`)); - }, EVAOS_ELECTRON_PROVIDER_TIMEOUT_MS); + }, timeoutMs); callbacks.set(callbackName, (data) => { rendererWindow.clearTimeout(timeout); @@ -1592,7 +1597,8 @@ export const evaosNativeCompanion = { IEvaosNativeCompanionRepairActionRequest >('evaos.native-companion.open-repair-action'), runAction: buildEvaosProvider, IEvaosNativeCompanionActionRequest>( - 'evaos.native-companion.run-action' + 'evaos.native-companion.run-action', + { timeoutMs: EVAOS_NATIVE_COMPANION_ACTION_TIMEOUT_MS } ), }; diff --git a/packages/desktop/src/common/evaos/bridgeTypes.ts b/packages/desktop/src/common/evaos/bridgeTypes.ts index 59c8bd6dbd..b5547ce024 100644 --- a/packages/desktop/src/common/evaos/bridgeTypes.ts +++ b/packages/desktop/src/common/evaos/bridgeTypes.ts @@ -403,6 +403,21 @@ export interface IEvaosNativeCompanionConnectorGrant { auditId?: string; } +export type IEvaosPrivateNetworkEnrollmentDiagnosticCode = + | 'enrollment_setup_failed' + | 'enrollment_secret_cleanup_failed' + | 'enrollment_state_changed' + | 'tailscale_cli_failed'; + +export type IEvaosPrivateNetworkEnrollmentCancellationState = 'cancelled' | 'unconfirmed' | 'unconfirmed_not_found'; + +export interface IEvaosPrivateNetworkEnrollmentDiagnostic { + code: IEvaosPrivateNetworkEnrollmentDiagnosticCode; + exitCode?: string; + message?: string; + cancellationState?: IEvaosPrivateNetworkEnrollmentCancellationState; +} + export interface IEvaosNativeCompanionActionResult { action: IEvaosNativeCompanionAction; status: IEvaosNativeCompanionActionStatus; @@ -419,6 +434,7 @@ export interface IEvaosNativeCompanionActionResult { events?: IEvaosNativeCompanionAuditEvent[]; blockerReason?: IEvaosMacControlBlockerReason; bootstrapGrantId?: string; + enrollmentDiagnostic?: IEvaosPrivateNetworkEnrollmentDiagnostic; } export interface IEvaosWorkbenchDiagnosticPacketV1 { diff --git a/packages/desktop/src/common/evaos/nativeCompanionBoundary.ts b/packages/desktop/src/common/evaos/nativeCompanionBoundary.ts index 677b933091..e3f9de5e82 100644 --- a/packages/desktop/src/common/evaos/nativeCompanionBoundary.ts +++ b/packages/desktop/src/common/evaos/nativeCompanionBoundary.ts @@ -63,23 +63,33 @@ const EVAOS_CANARY_ARTIFACT_DIR_ARG = '"${EVAOS_CANARY_ARTIFACT_DIR:?Set EVAOS_CANARY_ARTIFACT_DIR to an empty evidence directory}"'; const EVAOS_CONNECTOR_URL_ARG = '"${EVAOS_DESKTOP_BRIDGE_URL:?Set EVAOS_DESKTOP_BRIDGE_URL to the selected connector URL}"'; +const EVAOS_EXPECTED_WORKBENCH_VERSION_ARG = + '"${EVAOS_WORKBENCH_EXPECTED_VERSION:?Set EVAOS_WORKBENCH_EXPECTED_VERSION to the exact candidate version}"'; +const EVAOS_EXPECTED_WORKBENCH_BUILD_ARG = + '"${EVAOS_WORKBENCH_EXPECTED_BUILD:?Set EVAOS_WORKBENCH_EXPECTED_BUILD to the exact candidate build}"'; +const EVAOS_EXPECTED_SOURCE_COMMIT_ARG = + '"${EVAOS_WORKBENCH_EXPECTED_SOURCE_COMMIT:?Set EVAOS_WORKBENCH_EXPECTED_SOURCE_COMMIT to the exact 40-character evaOS-GUI commit}"'; +const EVAOS_SELECTED_BINDING_PROOF_ARG = + '"${EVAOS_MAC_CONTROL_LIVE_CANARY_PROOF:?Set EVAOS_MAC_CONTROL_LIVE_CANARY_PROOF to the sanitized selected-binding callback proof}"'; +const EVAOS_SELECTED_BINDING_PROOF_RUN_ID_ARG = + '"${EVAOS_MAC_CONTROL_LIVE_CANARY_RUN_ID:?Set EVAOS_MAC_CONTROL_LIVE_CANARY_RUN_ID to the exact proof workflow run id}"'; export const EVAOS_NATIVE_COMPANION_CANARIES = [ { id: 'pre-canary-bridge-peekaboo', - command: `${EVAOS_PACKAGED_BRIDGE_COMMAND} pre-canary --json --control-surface bridge-peekaboo --artifact-dir ${EVAOS_CANARY_ARTIFACT_DIR_ARG}`, + command: `${EVAOS_PACKAGED_BRIDGE_COMMAND} pre-canary --json --control-surface bridge-peekaboo --expected-version ${EVAOS_EXPECTED_WORKBENCH_VERSION_ARG} --expected-build ${EVAOS_EXPECTED_WORKBENCH_BUILD_ARG} --expected-source-commit ${EVAOS_EXPECTED_SOURCE_COMMIT_ARG} --artifact-dir ${EVAOS_CANARY_ARTIFACT_DIR_ARG}`, requiredArtifact: 'qa-report.json', forbidsSkips: true, }, { id: 'connector-all', - command: `${EVAOS_PACKAGED_BRIDGE_COMMAND} qa-canary --connector-url ${EVAOS_CONNECTOR_URL_ARG} --artifact-dir ${EVAOS_CANARY_ARTIFACT_DIR_ARG} --surface connector --suite all --operator-ack-live-control`, + command: `${EVAOS_PACKAGED_BRIDGE_COMMAND} qa-canary --connector-url ${EVAOS_CONNECTOR_URL_ARG} --artifact-dir ${EVAOS_CANARY_ARTIFACT_DIR_ARG} --version-under-test ${EVAOS_EXPECTED_WORKBENCH_VERSION_ARG} --build-under-test ${EVAOS_EXPECTED_WORKBENCH_BUILD_ARG} --source-commit-under-test ${EVAOS_EXPECTED_SOURCE_COMMIT_ARG} --selected-binding-proof ${EVAOS_SELECTED_BINDING_PROOF_ARG} --selected-binding-proof-run-id ${EVAOS_SELECTED_BINDING_PROOF_RUN_ID_ARG} --surface connector --suite all --operator-ack-live-control`, requiredArtifact: 'qa-report.json', forbidsSkips: true, }, { id: 'connector-kill-switch', - command: `${EVAOS_PACKAGED_BRIDGE_COMMAND} qa-canary --connector-url ${EVAOS_CONNECTOR_URL_ARG} --artifact-dir ${EVAOS_CANARY_ARTIFACT_DIR_ARG} --surface connector --suite kill_switch --operator-ack-live-control`, + command: `${EVAOS_PACKAGED_BRIDGE_COMMAND} qa-canary --connector-url ${EVAOS_CONNECTOR_URL_ARG} --artifact-dir ${EVAOS_CANARY_ARTIFACT_DIR_ARG} --version-under-test ${EVAOS_EXPECTED_WORKBENCH_VERSION_ARG} --build-under-test ${EVAOS_EXPECTED_WORKBENCH_BUILD_ARG} --source-commit-under-test ${EVAOS_EXPECTED_SOURCE_COMMIT_ARG} --selected-binding-proof ${EVAOS_SELECTED_BINDING_PROOF_ARG} --selected-binding-proof-run-id ${EVAOS_SELECTED_BINDING_PROOF_RUN_ID_ARG} --surface connector --suite kill_switch --operator-ack-live-control`, requiredArtifact: 'qa-report.json', forbidsSkips: true, }, diff --git a/packages/desktop/src/process/services/evaosBrokerSession.ts b/packages/desktop/src/process/services/evaosBrokerSession.ts index 073f33876c..344d2f91dd 100644 --- a/packages/desktop/src/process/services/evaosBrokerSession.ts +++ b/packages/desktop/src/process/services/evaosBrokerSession.ts @@ -87,6 +87,7 @@ export const EVAOS_CUSTOMER_MAC_CONTROL_ENDPOINT = 'https://rhfojelkgtwcxnrfhtlj.supabase.co/functions/v1/customer-mac-control'; const PROVIDER_CONNECTION_PROOF_MAX_AGE_MS = 24 * 60 * 60 * 1000; +const PRIVATE_NETWORK_ENROLLMENT_BROKER_TIMEOUT_MS = 20_000; type EvaosBusinessBrowserActionKind = 'runtime_launch' | 'browser_open_url' | 'browser_stop'; const RELEASED_WORKBENCH_KEYCHAIN_SERVICE = 'com.electricsheephq.EvaDesktop.session'; const RELEASED_WORKBENCH_KEYCHAIN_ACCOUNT = 'desktop-session'; @@ -135,12 +136,14 @@ export type EvaosBrokerErrorCode = export class EvaosBrokerSessionError extends Error { readonly code: EvaosBrokerErrorCode; readonly status?: number; + readonly brokerErrorCode?: string; - constructor(code: EvaosBrokerErrorCode, message: string, status?: number) { + constructor(code: EvaosBrokerErrorCode, message: string, status?: number, brokerErrorCode?: string) { super(message); this.name = 'EvaosBrokerSessionError'; this.code = code; this.status = status; + this.brokerErrorCode = safeBrokerResponseCode(brokerErrorCode); } } @@ -881,7 +884,8 @@ export class EvaosBrokerSessionClient { device_identifier: deviceIdentifier, client_variant: clientVariant, }, - session + session, + { signal: AbortSignal.timeout(PRIVATE_NETWORK_ENROLLMENT_BROKER_TIMEOUT_MS) } ); } catch (error) { if (error instanceof EvaosBrokerSessionError && error.status === 401) { @@ -943,7 +947,8 @@ export class EvaosBrokerSessionClient { enrollment_id: enrollmentId, auth_key: authKey, }, - session + session, + { signal: AbortSignal.timeout(PRIVATE_NETWORK_ENROLLMENT_BROKER_TIMEOUT_MS) } ); } catch (error) { if (error instanceof EvaosBrokerSessionError && error.status === 401) { @@ -1206,8 +1211,8 @@ export class EvaosBrokerSessionClient { if (response.status === 401 && session) { this.clearRejectedSession(session); } - const message = await brokerHttpMessageFromResponse(response); - throw new EvaosBrokerSessionError('broker_http_error', message, response.status); + const details = await brokerHttpDetailsFromResponse(response); + throw new EvaosBrokerSessionError('broker_http_error', details.message, response.status, details.brokerErrorCode); } try { @@ -1236,7 +1241,8 @@ export class EvaosBrokerSessionClient { enrollment_id: enrollmentId, auth_key: authKey, }, - session + session, + { signal: AbortSignal.timeout(PRIVATE_NETWORK_ENROLLMENT_BROKER_TIMEOUT_MS) } ).catch((): void => undefined); } @@ -4884,22 +4890,30 @@ function stripUndefined(record: T): T { return record; } -async function brokerHttpMessageFromResponse(response: Response): Promise { +async function brokerHttpDetailsFromResponse( + response: Response +): Promise<{ message: string; brokerErrorCode?: string }> { const fallback = brokerHttpMessage(response.status); - if (!canSurfaceBrokerResponseMessage(response.status)) { - return fallback; - } - try { const record = asRecord(await response.clone().json()); - const message = safeText(record?.error ?? record?.message, 180); - return message ?? fallback; + const brokerErrorCode = safeBrokerResponseCode(record?.error_code ?? record?.code ?? record?.error); + const message = canSurfaceBrokerResponseMessage(response.status) + ? (safeText(record?.message ?? record?.error, 180) ?? fallback) + : fallback; + return { message, brokerErrorCode }; } catch { - return fallback; + return { message: fallback }; } } +function safeBrokerResponseCode(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const code = value.trim(); + if (!/^[a-z][a-z0-9_]{0,79}$/.test(code) || containsSecretMaterial(code)) return undefined; + return code; +} + function canSurfaceBrokerResponseMessage(status: number): boolean { return status === 400 || status === 409 || status === 422; } diff --git a/packages/desktop/src/process/services/evaosNativeCompanionStatus.ts b/packages/desktop/src/process/services/evaosNativeCompanionStatus.ts index 96c63f1a1f..d6c2e2f1a5 100644 --- a/packages/desktop/src/process/services/evaosNativeCompanionStatus.ts +++ b/packages/desktop/src/process/services/evaosNativeCompanionStatus.ts @@ -26,6 +26,8 @@ import type { IEvaosNativeCompanionControlMode, IEvaosMacControlBlockerReason, IEvaosNativeCompanionOpenResult, + IEvaosPrivateNetworkEnrollmentCancellationState, + IEvaosPrivateNetworkEnrollmentDiagnostic, IEvaosNativeCompanionPermissionView, IEvaosPrivateNetworkAuthorityDiagnostic, IEvaosNativeCompanionReadiness, @@ -65,7 +67,7 @@ const SECURE_NETWORK_APP_IDENTIFIERS: Record { + recordNativeCompanionDiagnosticEvent(deps, 'secure_network_enrollment_action_started'); const customerId = request.customerId?.trim(); if (!customerId || isAccountLikeCustomerId(customerId)) { + recordNativeCompanionDiagnosticEvent(deps, 'secure_network_enrollment_preflight_failed'); return nativeActionResult( 'secure_network_enroll', 'repair_required', @@ -2302,6 +2319,7 @@ async function runSecureNetworkEnrollmentAction( localNetwork.enrolled !== false || !deviceIdentifier ) { + recordNativeCompanionDiagnosticEvent(deps, 'secure_network_enrollment_preflight_failed'); return nativeActionResult( 'secure_network_enroll', 'repair_required', @@ -2316,6 +2334,7 @@ async function runSecureNetworkEnrollmentAction( const client = await verifiedSecureNetworkClient(deps); if (!client) { + recordNativeCompanionDiagnosticEvent(deps, 'secure_network_enrollment_preflight_failed'); return nativeActionResult( 'secure_network_enroll', 'repair_required', @@ -2335,6 +2354,7 @@ async function runSecureNetworkEnrollmentAction( deps.cancelPrivateNetworkEnrollment ?? ((input) => getDefaultEvaosBrokerSessionClient().cancelPrivateNetworkEnrollment(input)); let enrollment: EvaosPrivateNetworkEnrollment; + recordNativeCompanionDiagnosticEvent(deps, 'secure_network_enrollment_broker_request_started'); try { enrollment = await createEnrollment({ customerId, @@ -2343,6 +2363,7 @@ async function runSecureNetworkEnrollmentAction( clientVariant: client.clientVariant, }); } catch (error) { + recordNativeCompanionDiagnosticEvent(deps, 'secure_network_enrollment_broker_request_failed'); if (isBrokerSessionReconnectRequired(error)) { return nativeActionResult( 'secure_network_enroll', @@ -2374,29 +2395,24 @@ async function runSecureNetworkEnrollmentAction( latestNetwork.clientRunning !== true || latestNetwork.enrolled !== false ) { - let cancelled = false; - try { - const cancellation = await cancelEnrollment({ - customerId, - enrollmentId: enrollment.enrollmentId, - authKey: enrollment.authKey, - }); - cancelled = cancellation.cancelled; - } catch { - cancelled = false; - } + const cancellation = await attemptPrivateNetworkEnrollmentCancellation(cancelEnrollment, customerId, enrollment); + recordNativeCompanionDiagnosticEvent(deps, 'secure_network_enrollment_failed'); return nativeActionResult( 'secure_network_enroll', 'repair_required', - cancelled + cancellation.cancelled ? 'Private-network state changed before enrollment. Workbench cancelled the unused key safely; refresh before retrying.' : 'Private-network state changed before enrollment, and cancellation could not be confirmed. Wait for the short enrollment expiry before retrying.', { - sourcePointer: cancelled + sourcePointer: cancellation.cancelled ? 'native-companion:secure-network-enrollment-state-changed' : 'native-companion:secure-network-enrollment-cancel-unconfirmed', refreshRecommended: true, blockerReason: 'secure_network_link_required', + enrollmentDiagnostic: { + code: 'enrollment_state_changed', + cancellationState: cancellation.state, + }, } ); } @@ -2407,6 +2423,7 @@ async function runSecureNetworkEnrollmentAction( let secretReady = false; let cleanupFailed = false; let localEnrollmentSucceeded = false; + let enrollmentDiagnostic: IEvaosPrivateNetworkEnrollmentDiagnostic | undefined; try { secretDirectory = fs.mkdtempSync(join(tmpdir(), 'evaos-private-network-')); secretPath = join(secretDirectory, 'auth-key'); @@ -2417,25 +2434,42 @@ async function runSecureNetworkEnrollmentAction( mode: 0o600, }); secretReady = true; - } catch { + } catch (error) { recordNativeCompanionDiagnosticEvent(deps, 'secure_network_enrollment_setup_failed'); localEnrollmentSucceeded = false; + enrollmentDiagnostic = { + code: 'enrollment_setup_failed', + message: secureNetworkEnrollmentErrorText(error, enrollment), + }; } if (secretReady && secretPath) { try { + recordNativeCompanionDiagnosticEvent(deps, 'secure_network_enrollment_cli_started'); await execFile( client.commandPath, - ['login', `--login-server=${enrollment.loginServer}`, `--auth-key=file:${secretPath}`, '--timeout=20s'], + [ + 'up', + '--reset', + `--login-server=${enrollment.loginServer}`, + `--auth-key=file:${secretPath}`, + '--accept-dns=false', + '--timeout=90s', + ], { timeout: SECURE_NETWORK_ENROLL_TIMEOUT_MS, env: secureNetworkCliEnvironment(deps.env ?? process.env), } ); localEnrollmentSucceeded = true; - } catch { + } catch (error) { recordNativeCompanionDiagnosticEvent(deps, 'secure_network_enrollment_login_failed'); localEnrollmentSucceeded = false; + enrollmentDiagnostic = { + code: 'tailscale_cli_failed', + exitCode: secureNetworkEnrollmentExitCode(error), + message: secureNetworkEnrollmentErrorText(error, enrollment, secretPath), + }; } } @@ -2447,6 +2481,7 @@ async function runSecureNetworkEnrollmentAction( recordNativeCompanionDiagnosticEvent(deps, 'secure_network_enrollment_secret_unlink_failed'); localEnrollmentSucceeded = false; cleanupFailed = true; + enrollmentDiagnostic = { code: 'enrollment_secret_cleanup_failed' }; } } if (secretDirectory) { @@ -2456,6 +2491,7 @@ async function runSecureNetworkEnrollmentAction( recordNativeCompanionDiagnosticEvent(deps, 'secure_network_enrollment_secret_directory_cleanup_failed'); localEnrollmentSucceeded = false; cleanupFailed = true; + enrollmentDiagnostic = { code: 'enrollment_secret_cleanup_failed' }; } } @@ -2464,34 +2500,35 @@ async function runSecureNetworkEnrollmentAction( } if (!localEnrollmentSucceeded) { - let cancelled = false; - try { - const cancellation = await cancelEnrollment({ - customerId, - enrollmentId: enrollment.enrollmentId, - authKey: enrollment.authKey, - }); - cancelled = cancellation.cancelled; - } catch { - cancelled = false; + const cancellation = await attemptPrivateNetworkEnrollmentCancellation(cancelEnrollment, customerId, enrollment); + if (!cancellation.cancelled && !cleanupFailed) { + localEnrollmentSucceeded = await waitForSecureNetworkEnrollmentAfterAmbiguousFailure(bridgePath, deps); + } + if (!localEnrollmentSucceeded) { + recordNativeCompanionDiagnosticEvent(deps, 'secure_network_enrollment_failed'); + return nativeActionResult( + 'secure_network_enroll', + 'repair_required', + cancellation.cancelled + ? 'Tailscale could not use the one-use enrollment. Workbench cancelled it safely; reopen Tailscale and retry.' + : 'Tailscale could not use the one-use enrollment, and cancellation could not be confirmed. Wait for the short enrollment expiry before retrying.', + { + sourcePointer: cancellation.cancelled + ? 'native-companion:secure-network-enrollment-client-failed' + : 'native-companion:secure-network-enrollment-cancel-unconfirmed', + refreshRecommended: false, + blockerReason: 'secure_network_link_required', + enrollmentDiagnostic: { + ...(enrollmentDiagnostic ?? { code: 'tailscale_cli_failed' }), + cancellationState: cancellation.state, + }, + } + ); } - return nativeActionResult( - 'secure_network_enroll', - 'repair_required', - cancelled - ? 'Tailscale could not use the one-use enrollment. Workbench cancelled it safely; reopen Tailscale and retry.' - : 'Tailscale could not use the one-use enrollment, and cancellation could not be confirmed. Wait for the short enrollment expiry before retrying.', - { - sourcePointer: cancelled - ? 'native-companion:secure-network-enrollment-client-failed' - : 'native-companion:secure-network-enrollment-cancel-unconfirmed', - refreshRecommended: false, - blockerReason: 'secure_network_link_required', - } - ); } privateNetworkBootstrapGrants.set(privateNetworkBootstrapGrantKey(customerId, deviceIdentifier), enrollment.grantId); + recordNativeCompanionDiagnosticEvent(deps, 'secure_network_enrollment_submitted'); return nativeActionResult( 'secure_network_enroll', @@ -2517,6 +2554,61 @@ function recordNativeCompanionDiagnosticEvent( console.warn(`[evaOS Native Companion] ${eventCode}`); } +async function attemptPrivateNetworkEnrollmentCancellation( + cancelEnrollment: (request: { + customerId: string; + enrollmentId: string; + authKey: string; + }) => Promise<{ cancelled: true; enrollmentId: string }>, + customerId: string, + enrollment: EvaosPrivateNetworkEnrollment +): Promise { + try { + const cancellation = await cancelEnrollment({ + customerId, + enrollmentId: enrollment.enrollmentId, + authKey: enrollment.authKey, + }); + if (cancellation.cancelled === true) return { cancelled: true, state: 'cancelled' }; + } catch (error) { + if (isEvaosBrokerSessionError(error) && error.brokerErrorCode === 'headscale_preauth_expiry_unconfirmed') { + return { cancelled: false, state: 'unconfirmed_not_found' }; + } + } + return { cancelled: false, state: 'unconfirmed' }; +} + +function secureNetworkEnrollmentExitCode(error: unknown): string | undefined { + if (!error || typeof error !== 'object') return undefined; + const record = error as { code?: unknown; exitCode?: unknown }; + const value = record.exitCode ?? record.code; + if (typeof value === 'number' && Number.isSafeInteger(value)) return String(value); + if (typeof value === 'string' && /^[A-Za-z0-9_-]{1,32}$/.test(value)) return value; + return undefined; +} + +function secureNetworkEnrollmentErrorText( + error: unknown, + enrollment: EvaosPrivateNetworkEnrollment, + secretPath?: string +): string | undefined { + const stderr = readErrorStderr(error); + const message = error instanceof Error ? error.message : undefined; + let value = compactStrings([stderr, message]).join(' '); + if (!value) return undefined; + for (const sensitiveValue of [enrollment.authKey, enrollment.loginServer, secretPath] + .filter((candidate): candidate is string => Boolean(candidate)) + .toSorted((left, right) => right.length - left.length)) { + value = value.split(sensitiveValue).join('[redacted]'); + } + value = value + .replace(/--auth-key(?:=|\s+)\S+/gi, '[redacted]') + .replace(/\bfile:[^\s"')]+/gi, '[redacted-path]') + .replace(/(?:^|\s)(?:file:)?\/(?:Users|private|var|tmp)\/[^\s"'(),;]+/gi, ' [redacted-path]') + .replace(/\b(?:[A-Fa-f0-9]{0,4}:){2,}[A-Fa-f0-9]{0,4}\b/g, '[redacted-ip]'); + return safeBridgeErrorText(value); +} + async function waitForSecureNetworkEnrollmentAfterAmbiguousFailure( bridgePath: string, deps: EvaosNativeCompanionStatusDeps @@ -2524,7 +2616,12 @@ async function waitForSecureNetworkEnrollmentAfterAmbiguousFailure( const sleep = deps.sleep ?? defaultSleep; for (let attempt = 0; attempt < SECURE_NETWORK_ENROLL_SETTLE_ATTEMPTS; attempt += 1) { if (attempt > 0) await sleep(SECURE_NETWORK_ENROLL_SETTLE_DELAY_MS); - const connectorService = await runBridgeCommand(bridgePath, ['connector-service', 'status', '--json'], deps); + const connectorService = await runBridgeCommand( + bridgePath, + ['connector-service', 'status', '--json'], + deps, + SECURE_NETWORK_ENROLL_SETTLE_COMMAND_TIMEOUT_MS + ); if (privateNetworkEvidence(connectorService.data)?.enrolled === true) return true; } return false; @@ -3357,9 +3454,9 @@ function bridgeFailureDetail(result: BridgeCommandResult, fallback: string): str function safeBridgeErrorText(value: string | undefined): string | undefined { if (!value) return undefined; const secretFieldPattern = - /["']?\b(?:access[_-]?token|refresh[_-]?token|connector[_-]?(?:token|url)|desktop[_-]?session|provider[_-]?grant|api[_-]?key|password|credential|client[_-]?secret|service[_-]?role|grant[_-]?handle|private[_-]?key|session[_-]?key|auth[_-]?proof|token|secret)\b["']?\s*[:=]\s*["']?[^"'\s,)}]+["']?/gi; + /["']?\b(?:access[_-]?token|refresh[_-]?token|connector[_-]?(?:token|url)|desktop[_-]?session|provider[_-]?grant|api[_-]?key|auth[_-]?key|password|credential|client[_-]?secret|service[_-]?role|grant[_-]?handle|private[_-]?key|session[_-]?key|auth[_-]?proof|token|secret)\b["']?\s*[:=]\s*["']?[^"'\s,)}]+["']?/gi; const secretWordPattern = - /\b(?:access[_-]?token|refresh[_-]?token|connector[_-]?(?:token|url)|desktop[_-]?session|provider[_-]?grant|api[_-]?key|password|credential|client[_-]?secret|service[_-]?role|grant[_-]?handle|private[_-]?key|session[_-]?key|auth[_-]?proof|bearer|secret)\b[^\s,.;)]*/gi; + /\b(?:access[_-]?token|refresh[_-]?token|connector[_-]?(?:token|url)|desktop[_-]?session|provider[_-]?grant|api[_-]?key|auth[_-]?key|password|credential|client[_-]?secret|service[_-]?role|grant[_-]?handle|private[_-]?key|session[_-]?key|auth[_-]?proof|bearer|secret)\b[^\s,.;)]*/gi; const redacted = value .replace(secretFieldPattern, '[redacted]') .replace(secretWordPattern, '[redacted]') @@ -3371,6 +3468,17 @@ function safeBridgeErrorText(value: string | undefined): string | undefined { return redacted ? redacted.slice(0, 260) : undefined; } +function safePrivateNetworkEnrollmentDiagnosticText(value: string | undefined): string | undefined { + if (!value) return undefined; + return safeBridgeErrorText( + value + .replace(/--auth-key(?:=|\s+)\S+/gi, '[redacted]') + .replace(/\bfile:[^\s"')]+/gi, '[redacted-path]') + .replace(/(?:^|\s)(?:file:)?\/(?:Users|private|var|tmp)\/[^\s"'(),;]+/gi, ' [redacted-path]') + .replace(/\b(?:[A-Fa-f0-9]{0,4}:){2,}[A-Fa-f0-9]{0,4}\b/g, '[redacted-ip]') + ); +} + function isAccountLikeCustomerId(customerId: string): boolean { return customerId.includes('@'); } @@ -3509,6 +3617,35 @@ function nativeActionResult( events: options.events, blockerReason: rendererSafeMacControlBlockerReason(options.blockerReason), bootstrapGrantId: safeDiagnosticText(options.bootstrapGrantId), + enrollmentDiagnostic: rendererSafePrivateNetworkEnrollmentDiagnostic(options.enrollmentDiagnostic), + }; +} + +function rendererSafePrivateNetworkEnrollmentDiagnostic( + value: IEvaosPrivateNetworkEnrollmentDiagnostic | undefined +): IEvaosPrivateNetworkEnrollmentDiagnostic | undefined { + if (!value) return undefined; + const safeCodes = new Set([ + 'enrollment_setup_failed', + 'enrollment_secret_cleanup_failed', + 'enrollment_state_changed', + 'tailscale_cli_failed', + ]); + const safeCancellationStates = new Set([ + 'cancelled', + 'unconfirmed', + 'unconfirmed_not_found', + ]); + if (!safeCodes.has(value.code)) return undefined; + return { + code: value.code, + exitCode: + typeof value.exitCode === 'string' && /^[A-Za-z0-9_-]{1,32}$/.test(value.exitCode) ? value.exitCode : undefined, + message: safePrivateNetworkEnrollmentDiagnosticText(value.message), + cancellationState: + value.cancellationState && safeCancellationStates.has(value.cancellationState) + ? value.cancellationState + : undefined, }; } diff --git a/packages/desktop/src/renderer/evaos/useEvaosNativeCompanionStatus.ts b/packages/desktop/src/renderer/evaos/useEvaosNativeCompanionStatus.ts index ac34310afe..a9333d4c3a 100644 --- a/packages/desktop/src/renderer/evaos/useEvaosNativeCompanionStatus.ts +++ b/packages/desktop/src/renderer/evaos/useEvaosNativeCompanionStatus.ts @@ -181,7 +181,22 @@ export function useEvaosNativeCompanionStatus(enabled = true, customerId?: strin const runAction = useCallback( async (request: IEvaosNativeCompanionActionRequest): Promise => { - const response = await ipcBridge.evaosNativeCompanion.runAction.invoke(request); + let response: Awaited>; + try { + response = await ipcBridge.evaosNativeCompanion.runAction.invoke(request); + } catch { + return { + action: request.action, + status: 'failed', + message: 'Workbench connector action could not be reached.', + sourcePointer: + request.action === 'secure_network_enroll' + ? 'native-companion:secure-network-enrollment-action-unreachable' + : 'native-companion:action-unreachable', + auditIds: [], + refreshRecommended: true, + }; + } if (!response.success || !response.data) { return { action: request.action, diff --git a/packages/desktop/src/renderer/pages/native-companion/index.tsx b/packages/desktop/src/renderer/pages/native-companion/index.tsx index 6ed71bbd8c..97d95572d4 100644 --- a/packages/desktop/src/renderer/pages/native-companion/index.tsx +++ b/packages/desktop/src/renderer/pages/native-companion/index.tsx @@ -263,6 +263,9 @@ const NativeCompanionPage: React.FC = () => { setActionInFlight(request.action); setCopyMessage(null); setTakeoverCueWarning(null); + if (request.action === 'secure_network_enroll') { + setHandoffMessage('Connecting this Mac securely. This can take a few minutes; do not click again.'); + } const targetsMacControlCustomer = MAC_TARGET_BOUND_NATIVE_COMPANION_ACTIONS.has(request.action); const requestCustomerId = request.customerId ?? (targetsMacControlCustomer ? selectedPairingCustomerId : selectedCustomerId); @@ -556,6 +559,7 @@ const NativeCompanionPage: React.FC = () => { targets={pairableMacControlTargets} selectedCustomerId={selectedPairingCustomerId} selectedTarget={selectedPairingTarget} + disabled={actionInFlight !== null} onChange={handlePairingTargetChange} /> @@ -590,7 +594,7 @@ const NativeCompanionPage: React.FC = () => {