diff --git a/pyproject.toml b/pyproject.toml index f7d7067..61ba8c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,7 @@ cli = [ "typer>=0.12,<1", ] desktop = [ - "hai-drivers[desktop]>=0.1.0", + "hai-drivers[desktop]>=0.1.2", ] browser = [ "hai-drivers[web]>=0.1.0", diff --git a/src/hai_agents_cli/app.py b/src/hai_agents_cli/app.py index e8c26c6..9188a69 100644 --- a/src/hai_agents_cli/app.py +++ b/src/hai_agents_cli/app.py @@ -12,7 +12,8 @@ import typer from rich.console import Console -from rich.table import Table +from rich.markup import escape +from rich.table import Column, Table from hai_agents import Client, assert_request_under_limit, is_settled_session_status, wait_for_session from hai_agents.core.api_error import ApiError @@ -21,6 +22,7 @@ from hai_agents_common import credentials from hai_agents_common.credentials import absolute_share_url, make_client from hai_agents_common.jsonable import to_jsonable +from hai_agents_local import desktop as desktop_defaults from . import auth, mcp_hosts @@ -108,6 +110,25 @@ def logout() -> None: console.print(f"Removed {credentials.API_KEY_VAR} from {path}." if path else "No stored key found.") +@app.command() +def doctor(ctx: typer.Context) -> None: + """Diagnose this machine's hai setup: login, platform access, local browser/desktop. Read-only.""" + from .doctor import run_checks + + state = _state(ctx) + results = run_checks(api_key=state.api_key, base_url=state.base_url) + if state.json_output: + _print_json([{"name": r.name, "ok": r.ok, "detail": r.detail, "fix": r.fix} for r in results]) + else: + for r in results: + mark = "[bold green]ok[/bold green]" if r.ok else "[bold red]fail[/bold red]" + console.print(f"{mark} [bold]{r.name}[/bold] {escape(r.detail)}") + if r.fix is not None: + console.print(f" [dim]fix:[/dim] {escape(r.fix)}") + if not all(r.ok for r in results): + raise typer.Exit(1) + + @app.command() def whoami(ctx: typer.Context) -> None: """Show the resolved endpoint and authentication status.""" @@ -225,7 +246,8 @@ def list_sessions( _print_json(result) return - table = Table("ID", "Status", "Agent", "Created") + # The ID column never shrinks: a truncated UUID cannot be pasted into follow-up commands. + table = Table(Column("ID", min_width=36, no_wrap=True), "Status", "Agent", "Created") for item in result.items: url = getattr(item, "agent_view_url", None) id_cell = f"[link={url}]{item.id}[/link]" if url else item.id @@ -330,7 +352,7 @@ def status(ctx: typer.Context, session_id: str = typer.Argument(...)) -> None: if result.steps is not None: table.add_row("Steps", str(result.steps)) if result.error: - table.add_row("Error", result.error) + table.add_row("Error", escape(result.error)) console.print(table) @@ -425,7 +447,7 @@ def list_agents_command( return table = Table("Name", "Description") for item in result.items: - table.add_row(item.name, _truncate(item.description)) + table.add_row(item.name, escape(_truncate(item.description))) console.print(table) @@ -463,7 +485,7 @@ def list_skills_command( return table = Table("Name", "Description") for item in result.items: - table.add_row(item.name, _truncate(item.description)) + table.add_row(item.name, escape(_truncate(item.description))) console.print(table) @@ -586,11 +608,42 @@ def local_browser( def local_desktop( ctx: typer.Context, session_id: str | None = typer.Option(None, "--session-id", help="Session id to serve. Generated when omitted."), + max_width: int = typer.Option( + desktop_defaults.DEFAULT_MAX_WIDTH, "--max-width", help="Cap screenshot width in pixels. 0 disables." + ), + max_height: int = typer.Option(0, "--max-height", help="Cap screenshot height in pixels. 0 disables."), + image_format: str = typer.Option( + desktop_defaults.DEFAULT_IMAGE_FORMAT, "--image-format", help="Screenshot encoding: png, jpeg, or webp." + ), + quality: int = typer.Option( + desktop_defaults.DEFAULT_QUALITY, "--quality", help="Encoding quality (1-100) for jpeg/webp." + ), ) -> None: """Serve desktop commands on this machine's mouse, keyboard, and screen.""" from hai_agents_local import PyautoguiDesktopBridge - _run_bridge(_state(ctx), PyautoguiDesktopBridge, session_id) + if image_format not in ("png", "jpeg", "webp"): + _raise_cli_error(ValueError(f"--image-format must be png, jpeg, or webp; got {image_format!r}")) + _run_bridge( + _state(ctx), + PyautoguiDesktopBridge, + session_id, + max_width=max_width or None, + max_height=max_height or None, + image_format=image_format, + quality=quality, + ) + + +@local_app.command("stop") +def local_stop() -> None: + """Stop any in-flight local browser/desktop turn on this machine (same effect as double-Esc).""" + from hai_agents_local.killswitch import request_stop + + request_stop() + console.print( + "Stop requested: local bridges on this machine will halt, and SDK-attached sessions will be cancelled." + ) def _run_bridge(state: AppState, bridge_type: type[LocalBridge], session_id: str | None, **options: Any) -> None: @@ -604,12 +657,13 @@ def _run_bridge(state: AppState, bridge_type: type[LocalBridge], session_id: str session_id=session_id, **options, ) - except (RuntimeError, ValueError) as exc: + bridge.preflight() + except (RuntimeError, ValueError, PermissionError) as exc: _raise_cli_error(exc) console.print( f"[bold]Local {bridge.environment_kind} bridge[/bold] serving session id " - f"[cyan]{bridge.session_id}[/cyan]. Press Ctrl-C to stop." + f"[cyan]{bridge.session_id}[/cyan]. Press Ctrl-C to stop, or run `hai local stop` from any terminal." ) console.print( "[dim]Point a user_device environment at it: " @@ -619,20 +673,47 @@ def _run_bridge(state: AppState, bridge_type: type[LocalBridge], session_id: str asyncio.run(_serve_bridge(bridge)) except KeyboardInterrupt: console.print("Stopped.") + return except Exception as exc: _raise_cli_error(exc) + if not bridge.ready.is_set(): + _raise_cli_error(RuntimeError("the bridge was stopped during setup and never served")) async def _serve_bridge(bridge: Any) -> None: import signal + from hai_agents_local.killswitch import StopWatcher + loop = asyncio.get_running_loop() for sig in (signal.SIGINT, signal.SIGTERM): try: loop.add_signal_handler(sig, bridge.request_stop) except (NotImplementedError, RuntimeError, ValueError): pass - await bridge.run() + watcher = StopWatcher(lambda: loop.call_soon_threadsafe(bridge.request_stop)) + listener = _arm_kill_switch(bridge) + try: + await bridge.run() + finally: + watcher.stop() + if listener is not None: + listener.stop() + + +def _arm_kill_switch(bridge: Any) -> Any: + """Arm the double-Esc listener for desktop serving; a desktop turn steals focus, so Ctrl-C may be out of reach.""" + if bridge.environment_kind != "desktop" or not (sys.stdin.isatty() and sys.stdout.isatty()): + return None + from hai_agents_local.killswitch import KILL_SWITCH_ARMED_HINT, KILL_SWITCH_UNAVAILABLE_HINT, arm_esc_listener + + listener = arm_esc_listener() + if listener is not None: + console.print(f"[dim]{KILL_SWITCH_ARMED_HINT}[/dim]") + elif sys.platform == "darwin": + # A panic button that silently failed to arm is dangerous; always say so. + console.print(f"[yellow]{KILL_SWITCH_UNAVAILABLE_HINT}[/yellow]") + return listener app.add_typer(sessions_app, name="sessions") @@ -675,7 +756,7 @@ def _select_agent(state: AppState, client: Client) -> str: if len(desc) > 80: desc = desc[:77] + "..." line = f" [bold]{i}[/bold]. {item.name}" - console.print(f"{line} [dim]{desc}[/dim]" if desc else line) + console.print(f"{line} [dim]{escape(desc)}[/dim]" if desc else line) choice = typer.prompt("Agent number", type=int) if not 1 <= choice <= len(agents): _raise_cli_error(RuntimeError(f"Choice must be between 1 and {len(agents)}.")) @@ -695,7 +776,7 @@ def _print_run_result(result, json_output: bool, agent_view_url: str | None = No console.print(f"[bold]Session:[/bold] {result.id}") console.print(f"[bold]Status:[/bold] {_status_text(result.status)}") if result.answer is not None: - console.print(result.answer) + console.print(escape(result.answer)) def _print_ack(action: str, json_output: bool) -> None: @@ -717,10 +798,10 @@ def _print_watch_result(state: AppState, status_result, final) -> None: console.print(f"[bold]Status:[/bold] {_status_text(status_result.status)}") error = status_result.error or (final.error if final is not None else None) if error: - console.print(f"[bold]Error:[/bold] {error}") + console.print(f"[bold]Error:[/bold] {escape(error)}") answer = final.answer if final is not None else None if answer is not None: - console.print(answer) + console.print(escape(answer)) def _status_text(status) -> str: @@ -737,7 +818,7 @@ def _event_line(index: int, event) -> str: event_type = getattr(event, "type", "Event") data = getattr(event, "data", None) detail = _truncate(json.dumps(to_jsonable(data), sort_keys=True), 120) if data is not None else "" - return f"[dim]{index:>4}[/dim] [cyan]{event_type}[/cyan]" + (f" {detail}" if detail else "") + return f"[dim]{index:>4}[/dim] [cyan]{event_type}[/cyan]" + (f" {escape(detail)}" if detail else "") def _emit_watch_event(state: AppState, index: int, event) -> None: @@ -776,5 +857,6 @@ def _raise_cli_error(exc: Exception) -> NoReturn: message = f"API error {exc.status_code}: {message}" else: message = str(exc) - err_console.print(f"[red]Error:[/red] {message}") + # Escape: rich would otherwise eat brackets in messages, e.g. pip extras like hai-agents[browser]. + err_console.print(f"[red]Error:[/red] {escape(message)}") raise typer.Exit(1) diff --git a/src/hai_agents_cli/doctor.py b/src/hai_agents_cli/doctor.py new file mode 100644 index 0000000..83ab321 --- /dev/null +++ b/src/hai_agents_cli/doctor.py @@ -0,0 +1,107 @@ +"""`hai doctor`: read-only diagnostics for login, platform access, and local control, with fix-its.""" + +from __future__ import annotations + +import importlib.util +import socket +import sys +from dataclasses import dataclass + +from hai_agents_common import credentials + +DEFAULT_CHROME_DEBUG_PORT = 9222 + + +@dataclass(frozen=True) +class CheckResult: + name: str + ok: bool + detail: str + fix: str | None = None + + +def check_login(api_key: str | None) -> CheckResult: + if credentials.current_api_key(api_key): + return CheckResult("login", True, f"API key found ({credentials.source(api_key)})") + return CheckResult( + "login", + False, + "no API key configured", + fix=f"run `hai login`, set {credentials.API_KEY_VAR}, or pass --api-key", + ) + + +def check_platform(api_key: str | None, base_url: str | None) -> CheckResult: + endpoint = credentials.resolve_base_url(base_url) or "(SDK default)" + try: + client = credentials.make_client(api_key=api_key, base_url=base_url) + client.agents.list_agents(page=1, size=1) + except Exception as exc: + return CheckResult( + "platform", + False, + f"cannot query {endpoint}: {exc}", + fix="check the key and network; `hai whoami` shows the resolved endpoint", + ) + return CheckResult("platform", True, f"authenticated against {endpoint}") + + +def check_browser() -> CheckResult: + if importlib.util.find_spec("hai_drivers") is None or importlib.util.find_spec("hai_drivers.web") is None: + return CheckResult( + "browser", True, "local browser control not installed; add it with: pip install 'hai-agents[browser]'" + ) + if _port_in_use(DEFAULT_CHROME_DEBUG_PORT): + detail = f"deps installed; a debuggable Chrome is listening on port {DEFAULT_CHROME_DEBUG_PORT} (will attach)" + else: + detail = f"deps installed; port {DEFAULT_CHROME_DEBUG_PORT} is free (a Chrome will be launched on demand)" + return CheckResult("browser", True, detail) + + +def check_desktop() -> CheckResult: + if importlib.util.find_spec("hai_drivers") is None or importlib.util.find_spec("hai_drivers.desktop") is None: + return CheckResult( + "desktop", True, "local desktop control not installed; add it with: pip install 'hai-agents[desktop]'" + ) + if sys.platform != "darwin": + return CheckResult("desktop", True, "deps installed") + missing = _missing_macos_grants() + if missing: + from hai_agents_local.desktop import ACCESSIBILITY_SETTINGS_URL, SCREEN_RECORDING_SETTINGS_URL + + return CheckResult( + "desktop", + False, + f"deps installed, but this terminal is missing macOS grants: {', '.join(missing)}", + fix="grant them in System Settings -> Privacy & Security, then restart this terminal: " + f"{ACCESSIBILITY_SETTINGS_URL} and {SCREEN_RECORDING_SETTINGS_URL}", + ) + return CheckResult("desktop", True, "deps installed; Accessibility and Screen Recording granted") + + +def _missing_macos_grants() -> list[str]: + # Non-prompting variants keep the doctor read-only; the bridge preflight does the prompting. + from ApplicationServices import AXIsProcessTrusted + from Quartz import CGPreflightScreenCaptureAccess + + missing = [] + if not AXIsProcessTrusted(): + missing.append("Accessibility") + if not CGPreflightScreenCaptureAccess(): + missing.append("Screen Recording") + return missing + + +def _port_in_use(port: int) -> bool: + with socket.socket() as sock: + sock.settimeout(0.2) + return sock.connect_ex(("127.0.0.1", port)) == 0 + + +def run_checks(api_key: str | None, base_url: str | None) -> list[CheckResult]: + login = check_login(api_key) + checks = [login] + if login.ok: + checks.append(check_platform(api_key, base_url)) + checks.extend([check_browser(), check_desktop()]) + return checks diff --git a/src/hai_agents_local/bridge.py b/src/hai_agents_local/bridge.py index d04f5a9..ced9daf 100644 --- a/src/hai_agents_local/bridge.py +++ b/src/hai_agents_local/bridge.py @@ -5,6 +5,7 @@ import functools import inspect import logging +import random import threading import time import uuid @@ -29,6 +30,10 @@ POST_RESULT_TIMEOUT_S = 10.0 POST_RESULT_RETRIES = 2 FETCH_TRANSIENT_RETRIES = 2 +CHANNEL_SETUP_ATTEMPTS = 5 +# Must stay under the manager's READY_TIMEOUT_S so setup gives up with the real +# error instead of the manager's generic not-ready timeout firing first. +CHANNEL_SETUP_TIMEOUT_S = 45.0 MAX_RECONNECT_DELAY_S = 60.0 MIN_RATE_LIMIT_BACKOFF_S = 1.0 RESULT_CACHE_SIZE = 512 @@ -54,6 +59,8 @@ class LocalBridge(ABC, Generic[DriverT]): """Polls the command channel for session_id and dispatches each command to a local hai-drivers driver.""" environment_kind: ClassVar[str] + startup_hint: ClassVar[str | None] = None + """Appended to the manager's not-ready timeout error; names the common cause of a hung startup.""" def __init__( self, @@ -80,6 +87,13 @@ def __init__( self._stop_event = asyncio.Event() self._results: OrderedDict[str, tuple[Json, str | None]] = OrderedDict() + def preflight(self) -> None: + """Check host prerequisites before the bridge thread starts. + + Runs on the caller's thread, so OS permission prompts (macOS TCC dialogs) can appear; + create_driver later runs on a worker thread where they cannot. + """ + @abstractmethod def create_driver(self) -> DriverT: """Build the hai-drivers driver this bridge dispatches commands to.""" @@ -105,7 +119,8 @@ async def run(self) -> None: headers={"Accept": "application/json"}, auth=_BearerAuth(self.api_key), follow_redirects=True ) as client: exchange = CommandExchange(client, self.base_url) - await exchange.ensure_channel(self.session_id) + if not await self._open_channel(exchange): + return if self._driver is None: self._driver = await asyncio.to_thread(self.create_driver) self.ready.set() @@ -119,10 +134,57 @@ async def run(self) -> None: except Exception: logger.warning("driver teardown failed", exc_info=True) + async def _open_channel(self, exchange: CommandExchange) -> bool: + """Open the command channel, retrying rate limits and transient failures with jittered backoff. + + Retries stop at CHANNEL_SETUP_ATTEMPTS or the CHANNEL_SETUP_TIMEOUT_S deadline, whichever + comes first. False means a stop was requested mid-setup; definitive failures (auth, 4xx) raise. + """ + deadline = time.monotonic() + CHANNEL_SETUP_TIMEOUT_S + delay = 1.0 + for attempt in range(1, CHANNEL_SETUP_ATTEMPTS + 1): + try: + await exchange.ensure_channel(self.session_id) + return True + except RateLimitedError as exc: + backoff = max(exc.retry_after, MIN_RATE_LIMIT_BACKOFF_S) * (1 + random.uniform(0, 0.25)) + if attempt == CHANNEL_SETUP_ATTEMPTS or time.monotonic() + backoff > deadline: + raise RuntimeError( + "the platform kept rate-limiting command-channel setup; wait a minute and retry" + ) from exc + logger.warning( + "platform rate-limited channel setup (attempt %d/%d); retrying in %.1fs", + attempt, + CHANNEL_SETUP_ATTEMPTS, + backoff, + ) + if await self._interruptible_sleep(backoff): + return False + except httpx.HTTPError as exc: + backoff = delay * (1 + random.uniform(0, 0.25)) + definitive = isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code < 500 + if definitive or attempt == CHANNEL_SETUP_ATTEMPTS or time.monotonic() + backoff > deadline: + raise + logger.warning( + "channel setup failed (attempt %d/%d): %s; retrying in %.1fs", + attempt, + CHANNEL_SETUP_ATTEMPTS, + exc, + backoff, + ) + if await self._interruptible_sleep(backoff): + return False + delay = min(delay * 2, MAX_RECONNECT_DELAY_S) + return False + async def _poll_loop(self, exchange: CommandExchange) -> None: retry_delay = 1.0 + recreate_channel = False while not self._stop_event.is_set(): try: + if recreate_channel: + await exchange.ensure_channel(self.session_id) + recreate_channel = False started = time.monotonic() commands = await self._fetch_until_stop(exchange) retry_delay = 1.0 @@ -134,9 +196,10 @@ async def _poll_loop(self, exchange: CommandExchange) -> None: # Instant empty polls are paced so a misbehaving server cannot cause a busy loop. break except SessionNotFoundError: - # Channel was garbage-collected server-side; recreate and resume. + # Channel was garbage-collected server-side; recreate on the next iteration so + # rate limits and transient errors during recreation hit the handlers below. logger.warning("channel %s missing; recreating", self.session_id) - await exchange.ensure_channel(self.session_id) + recreate_channel = True if await self._interruptible_sleep(retry_delay): break retry_delay = min(retry_delay * 2, MAX_RECONNECT_DELAY_S) diff --git a/src/hai_agents_local/browser.py b/src/hai_agents_local/browser.py index 23e30fe..085ec9a 100644 --- a/src/hai_agents_local/browser.py +++ b/src/hai_agents_local/browser.py @@ -41,6 +41,10 @@ class SeleniumBrowserBridge(LocalBridge["SeleniumWebDriver"]): """Serves web environments by attaching Selenium to a Chrome debugging port on this machine.""" environment_kind = "web" + startup_hint = ( + "a stale Chrome or Chromium already holding the debug port (default 9222) is the usual cause; " + "quit it, or serve on another port with `hai local browser --debug-port `" + ) def __init__( self, diff --git a/src/hai_agents_local/desktop.py b/src/hai_agents_local/desktop.py index 85363cb..e0b1ac0 100644 --- a/src/hai_agents_local/desktop.py +++ b/src/hai_agents_local/desktop.py @@ -1,27 +1,102 @@ from __future__ import annotations -from typing import TYPE_CHECKING +import sys +from typing import TYPE_CHECKING, Literal -from .bridge import LocalBridge +from .bridge import LocalBridge, TokenSource if TYPE_CHECKING: - from hai_drivers.desktop.local import LocalDesktopDriver + from hai_drivers.desktop.interface import DesktopDriverInterface +ACCESSIBILITY_SETTINGS_URL = "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility" +SCREEN_RECORDING_SETTINGS_URL = "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture" -class PyautoguiDesktopBridge(LocalBridge["LocalDesktopDriver"]): - """Serves desktop environments (mouse, keyboard, screen, files, shell) on this machine via pyautogui.""" +ImageFormat = Literal["png", "jpeg", "webp"] + +DEFAULT_MAX_WIDTH = 1920 +DEFAULT_IMAGE_FORMAT: ImageFormat = "jpeg" +DEFAULT_QUALITY = 85 + + +def ensure_macos_input_permissions(prompt: bool = True) -> None: + """Fail fast when macOS would silently drop synthesized input, triggering the native grant prompts. + + Accessibility and Screen Recording are independent TCC grants; without the former, pyautogui + events are dropped with no error and an agent burns steps on screenshots that never change. + Prompting must happen on the main thread; pass prompt=False when checking from a worker. + """ + from ApplicationServices import AXIsProcessTrustedWithOptions, kAXTrustedCheckOptionPrompt + from Quartz import CGPreflightScreenCaptureAccess, CGRequestScreenCaptureAccess + + missing = [] + if not AXIsProcessTrustedWithOptions({kAXTrustedCheckOptionPrompt: prompt}): + missing.append("Accessibility (moves the mouse and types)") + if not (CGPreflightScreenCaptureAccess() or (prompt and CGRequestScreenCaptureAccess())): + missing.append("Screen Recording (reads the screen)") + if missing: + raise PermissionError( + "macOS blocks this Python process from controlling the desktop. Missing permissions: " + + "; ".join(missing) + + ". Grant them to the app that runs Python (your terminal) in System Settings -> " + "Privacy & Security, then restart that app and run again; grants only apply to a fresh process. " + f"Open the panes directly: {ACCESSIBILITY_SETTINGS_URL} and {SCREEN_RECORDING_SETTINGS_URL}" + ) + + +class PyautoguiDesktopBridge(LocalBridge["DesktopDriverInterface"]): + """Serves desktop environments (mouse, keyboard, screen, files, shell) on this machine via pyautogui. + + Screenshots are downscaled and encoded here, before they cross the network; set every + knob to None to serve raw native-resolution captures. + """ environment_kind = "desktop" - def create_driver(self) -> LocalDesktopDriver: + def __init__( + self, + environment_id: str | None = None, + *, + api_key: TokenSource, + base_url: str | None = None, + session_id: str | None = None, + max_width: int | None = DEFAULT_MAX_WIDTH, + max_height: int | None = None, + image_format: ImageFormat | None = DEFAULT_IMAGE_FORMAT, + quality: int = DEFAULT_QUALITY, + ) -> None: + super().__init__(environment_id, api_key=api_key, base_url=base_url, session_id=session_id) + self.max_width = max_width + self.max_height = max_height + self.image_format = image_format + self.quality = quality + + def preflight(self) -> None: + if sys.platform == "darwin": + ensure_macos_input_permissions(prompt=True) + + def create_driver(self) -> DesktopDriverInterface: # Runtime import: hai-drivers is absent unless installed with hai-agents[desktop]. try: from hai_drivers.desktop.local import LocalDesktopDriver + from hai_drivers.desktop.scaled import ScaledDesktopDriver except ImportError as exc: raise ImportError( - "Local desktop control requires extra deps. Install with: pip install 'hai-agents[desktop]'" + "Local desktop control requires hai-drivers>=0.1.2. Install with: pip install 'hai-agents[desktop]'" ) from exc - return LocalDesktopDriver() + if sys.platform == "darwin": + # create_driver runs on a bridge worker thread, where TCC prompts cannot appear; + # preflight() prompted earlier on the caller's thread, so only re-check here. + ensure_macos_input_permissions(prompt=False) + driver = LocalDesktopDriver() + if self.max_width is None and self.max_height is None and self.image_format is None: + return driver + return ScaledDesktopDriver( + driver, + max_width=self.max_width, + max_height=self.max_height, + image_format=self.image_format, + quality=self.quality, + ) def driver_interface(self) -> type: # Runtime import: hai-drivers is absent unless installed with hai-agents[desktop]. diff --git a/src/hai_agents_local/killswitch.py b/src/hai_agents_local/killswitch.py new file mode 100644 index 0000000..19e5598 --- /dev/null +++ b/src/hai_agents_local/killswitch.py @@ -0,0 +1,186 @@ +"""Out-of-band kill switch: a cross-process stop file, plus a global double-Esc listener on macOS. + +Ctrl-C is not a reliable panic button while an agent drives the mouse and keyboard: the terminal +may not have focus, and focus itself is what the agent is fighting you for. The stop file works +from any terminal (`hai local stop`); the Esc listener works from anywhere at all. +""" + +from __future__ import annotations + +import logging +import os +import threading +import time +from pathlib import Path +from typing import Any, Callable + +logger = logging.getLogger(__name__) + +STOP_PATH = Path(os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config")) / "hai" / "stop" +STOP_POLL_S = 0.25 +STOP_KEY_TAPS = 2 +STOP_KEY_WINDOW_S = 0.6 +# Carbon virtual key code for Esc; stable across keyboard layouts. +ESC_KEYCODE = 53 +TAP_START_TIMEOUT_S = 2.0 +TAP_STOP_JOIN_TIMEOUT_S = 2.0 + +KILL_SWITCH_ARMED_HINT = "kill switch armed: press Esc twice fast to stop" +KILL_SWITCH_UNAVAILABLE_HINT = ( + "double-Esc kill switch unavailable; grant Input Monitoring to this terminal in " + "System Settings -> Privacy & Security, or stop with `hai local stop`" +) + + +def request_stop(now: float | None = None) -> None: + """File a stop request for any in-flight local turn on this machine.""" + STOP_PATH.parent.mkdir(parents=True, exist_ok=True) + STOP_PATH.write_text(str(now if now is not None else time.time()), encoding="utf-8") + + +class StopSentinel: + """Reads the shared stop file; only stops filed after ``started_at`` count, so stale files are inert.""" + + def __init__(self, started_at: float) -> None: + self._started_at = started_at + + def stop_requested(self) -> bool: + # Total by design: an unreadable or malformed channel reads as "no stop", never a crash. + try: + requested_at = float(STOP_PATH.read_text(encoding="utf-8")) + except (OSError, ValueError): + return False + return requested_at > self._started_at + + +class StopWatcher: + """Daemon thread polling the stop channel; runs ``on_stop`` once when a stop is filed, then ends.""" + + def __init__(self, on_stop: Callable[[], None]) -> None: + self._sentinel = StopSentinel(time.time()) + self._on_stop = on_stop + self._done = threading.Event() + self._thread = threading.Thread(target=self._poll, daemon=True, name="hai-stop-watcher") + self._thread.start() + + @property + def active(self) -> bool: + return not self._done.is_set() + + def _poll(self) -> None: + while not self._done.wait(STOP_POLL_S): + if self._sentinel.stop_requested(): + self._done.set() + try: + self._on_stop() + except Exception: + logger.exception("kill-switch stop handler failed") + + def stop(self) -> None: + self._done.set() + + +class MultiTapDetector: + """Fires once when ``taps`` timestamps fall within ``window_s`` of each other, then resets.""" + + def __init__(self, taps: int = STOP_KEY_TAPS, window_s: float = STOP_KEY_WINDOW_S) -> None: + self._taps = taps + self._window_s = window_s + self._times: list[float] = [] + + def record(self, t: float) -> bool: + self._times = [seen for seen in self._times if t - seen <= self._window_s] + self._times.append(t) + if len(self._times) >= self._taps: + self._times.clear() + return True + return False + + +class QuartzEscTap: + """Listen-only global Esc tap on macOS that files a stop on a rapid double-Esc. + + The tap re-enables itself when macOS disables it: a heavy desktop turn floods the session with + synthetic input, listen-only taps fall behind the watchdog budget, and the OS switches them off + exactly when the panic button matters most. + """ + + def __init__(self) -> None: + self._detector = MultiTapDetector() + self._ready = threading.Event() + self._armed = False + self._quartz: Any = None + self._tap: Any = None + self._loop: Any = None + self._thread: threading.Thread | None = None + + def start(self) -> bool: + """Arm the tap on its own run-loop thread; False when Quartz or the Input Monitoring grant is missing.""" + self._thread = threading.Thread(target=self._run, daemon=True, name="hai-esc-tap") + self._thread.start() + self._ready.wait(timeout=TAP_START_TIMEOUT_S) + return self._armed + + def stop(self) -> None: + if self._quartz is not None and self._loop is not None: + self._quartz.CFRunLoopStop(self._loop) + if self._thread is not None: + self._thread.join(timeout=TAP_STOP_JOIN_TIMEOUT_S) + self._thread = None + self._loop = None + + def _run(self) -> None: + try: + import Quartz + except Exception: + logger.debug("Quartz is unavailable; the Esc kill switch is disabled") + self._ready.set() + return + self._quartz = Quartz + tap = Quartz.CGEventTapCreate( + Quartz.kCGSessionEventTap, + Quartz.kCGHeadInsertEventTap, + Quartz.kCGEventTapOptionListenOnly, + Quartz.CGEventMaskBit(Quartz.kCGEventKeyDown), + self._handler, + None, + ) + if tap is None: + logger.debug("could not create the Esc event tap (Input Monitoring not granted?)") + self._ready.set() + return + self._tap = tap + source = Quartz.CFMachPortCreateRunLoopSource(None, tap, 0) + self._loop = Quartz.CFRunLoopGetCurrent() + Quartz.CFRunLoopAddSource(self._loop, source, Quartz.kCFRunLoopDefaultMode) + Quartz.CGEventTapEnable(tap, True) + self._armed = True + self._ready.set() + Quartz.CFRunLoopRun() + + def _handler(self, proxy: Any, event_type: int, event: Any, refcon: Any) -> Any: + quartz = self._quartz + try: + if event_type in (quartz.kCGEventTapDisabledByTimeout, quartz.kCGEventTapDisabledByUserInput): + quartz.CGEventTapEnable(self._tap, True) + logger.warning("macOS disabled the Esc kill-switch tap; re-enabled it") + elif ( + event_type == quartz.kCGEventKeyDown + and quartz.CGEventGetIntegerValueField(event, quartz.kCGKeyboardEventKeycode) == ESC_KEYCODE + and self._detector.record(time.monotonic()) + ): + logger.warning("double-Esc detected; requesting stop") + request_stop() + except Exception: + logger.warning("Esc kill-switch handler failed", exc_info=True) + return event + + +def arm_esc_listener() -> QuartzEscTap | None: + """Arm the global double-Esc listener; None when unsupported here (non-macOS) or not permitted.""" + import sys + + if sys.platform != "darwin": + return None + tap = QuartzEscTap() + return tap if tap.start() else None diff --git a/src/hai_agents_local/manager.py b/src/hai_agents_local/manager.py index 40f8705..b10a388 100644 --- a/src/hai_agents_local/manager.py +++ b/src/hai_agents_local/manager.py @@ -40,6 +40,7 @@ def ensure(self, bridges: Sequence[LocalBridge]) -> list[str]: return started def _ensure_one(self, bridge: LocalBridge) -> bool: + bridge.preflight() displaced: list[_Runner] = [] with self._lock: runner = self._runners.get(bridge.session_id) @@ -56,9 +57,10 @@ def _ensure_one(self, bridge: LocalBridge) -> bool: other.notify_lost() try: if not runner.bridge.ready.wait(READY_TIMEOUT_S): + hint = f" ({bridge.startup_hint})" if bridge.startup_hint is not None else "" raise RuntimeError( f"local {bridge.environment_kind} bridge for environment {bridge.environment_id!r} " - f"was not ready after {READY_TIMEOUT_S:.0f}s" + f"was not ready after {READY_TIMEOUT_S:.0f}s{hint}" ) if runner.error is not None: raise RuntimeError( @@ -121,6 +123,10 @@ def _serve(self) -> None: asyncio.set_event_loop(self.loop) try: self.loop.run_until_complete(self.bridge.run()) + if not self.bridge.ready.is_set(): + # Stopped mid-setup (e.g. displaced during channel retry backoff): the finally + # below unblocks ensure(), which must see a failure, not a serving bridge. + self.error = RuntimeError("bridge was stopped before it became ready") except Exception as exc: self.error = exc if not sys.is_finalizing() and threading.main_thread().is_alive(): diff --git a/src/hai_agents_local/sessions.py b/src/hai_agents_local/sessions.py index 98beaf1..df7e351 100644 --- a/src/hai_agents_local/sessions.py +++ b/src/hai_agents_local/sessions.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import atexit import concurrent.futures import functools import inspect @@ -15,11 +16,23 @@ from .bridge import LocalBridge, TokenSource from .config import auto_bridges_enabled +from .killswitch import StopWatcher from .manager import ensure_bridges, stop_bridges from .routing import localize_agent logger = logging.getLogger(__name__) +# Runaway guards for sessions whose caller passes no budget: a local session left running (its +# serving process was SIGKILLed, the laptop slept) burns steps against a dead channel until it +# hits these. Explicit values, including None for unbounded, are respected. +DEFAULT_LOCAL_MAX_STEPS = 150 +DEFAULT_LOCAL_MAX_TIME_S = 1800.0 + + +def _apply_runaway_budgets(kwargs: typing.Dict[str, typing.Any]) -> None: + kwargs.setdefault("max_steps", DEFAULT_LOCAL_MAX_STEPS) + kwargs.setdefault("max_time_s", DEFAULT_LOCAL_MAX_TIME_S) + def _token_source(client_wrapper: typing.Any) -> TokenSource: return getattr(client_wrapper, "_async_token", None) or client_wrapper._get_api_key @@ -108,12 +121,9 @@ def _cancel_action( def cancel() -> None: logger.error("local bridge for session %s crashed; cancelling the session", session_id) + _deregister_exit_cancel(session_id) try: - from hai_agents.client import Client - - api_key = _resolve_token(_token_source(client_wrapper)) - client = Client(api_key=api_key, base_url=client_wrapper.get_base_url()) - client.sessions.cancel_session(session_id) + _cancel_remote_session(client_wrapper, session_id) except Exception: logger.exception("failed to cancel session %s after its local bridge crashed", session_id) finally: @@ -122,11 +132,74 @@ def cancel() -> None: return cancel +def _cancel_remote_session(client_wrapper: typing.Any, session_id: str) -> None: + from hai_agents.client import Client + + api_key = _resolve_token(_token_source(client_wrapper)) + client = Client(api_key=api_key, base_url=client_wrapper.get_base_url()) + client.sessions.cancel_session(session_id) + + +# Sessions that depend on this process's bridges, cancelled at interpreter exit: the bridges die +# with the process, so leaving those sessions running only burns steps against a dead channel. +_exit_cancels: typing.Dict[str, typing.Callable[[], None]] = {} +_exit_lock = threading.Lock() +_stop_watcher: typing.Optional[StopWatcher] = None + + +def _ensure_stop_watcher() -> StopWatcher: + """Arm kill-switch coverage, replacing a fired watcher; a fired one is spent.""" + global _stop_watcher + with _exit_lock: + if _stop_watcher is None or not _stop_watcher.active: + _stop_watcher = StopWatcher(_panic_stop) + return _stop_watcher + + +def _register_exit_cancel(client_wrapper: typing.Any, session_id: str) -> None: + def cancel_quietly() -> None: + # Best effort: the session may have finished long ago; the platform rejects the cancel then. + try: + _cancel_remote_session(client_wrapper, session_id) + logger.info("cancelled session %s at exit: its local bridge lives in this process", session_id) + except Exception as exc: + logger.debug("exit-time cancel of session %s skipped: %s", session_id, exc) + + with _exit_lock: + _exit_cancels[session_id] = cancel_quietly + _ensure_stop_watcher() + + +def _panic_stop() -> None: + logger.warning("kill switch fired: cancelling local sessions and stopping bridges") + _cancel_sessions_at_exit() + stop_bridges() + + +def _deregister_exit_cancel(session_id: str) -> None: + with _exit_lock: + _exit_cancels.pop(session_id, None) + + +def _cancel_sessions_at_exit() -> None: + with _exit_lock: + cancels = list(_exit_cancels.values()) + _exit_cancels.clear() + for cancel in cancels: + cancel() + + +atexit.register(_cancel_sessions_at_exit) + + class LocalSessionsClient(SessionsClient): @functools.wraps(SessionsClient.create_session) def create_session(self, **kwargs: typing.Any) -> typing.Any: wrapper = self._raw_client._client_wrapper bridges = _localize(wrapper, kwargs) + if bridges: + _apply_runaway_budgets(kwargs) + stop_watcher = _ensure_stop_watcher() if bridges else None watcher = _LossWatcher(bridges) started = ensure_bridges(bridges) try: @@ -136,8 +209,14 @@ def create_session(self, **kwargs: typing.Any) -> typing.Any: raise if bridges: cancel = _cancel_action(wrapper, bridges, session) - if cancel is not None and watcher.attach(cancel): - cancel() + if cancel is not None: + if watcher.attach(cancel): + cancel() + else: + _register_exit_cancel(wrapper, session.id) + if stop_watcher is not None and not stop_watcher.active: + # A stop was filed while bridges or the session were starting; apply it now. + _panic_stop() return session @@ -146,6 +225,9 @@ class LocalAsyncSessionsClient(AsyncSessionsClient): async def create_session(self, **kwargs: typing.Any) -> typing.Any: wrapper = self._raw_client._client_wrapper bridges = _localize(wrapper, kwargs) + if bridges: + _apply_runaway_budgets(kwargs) + stop_watcher = _ensure_stop_watcher() if bridges else None watcher = _LossWatcher(bridges) started = await asyncio.to_thread(ensure_bridges, bridges) try: @@ -155,7 +237,13 @@ async def create_session(self, **kwargs: typing.Any) -> typing.Any: raise if bridges: cancel = _cancel_action(wrapper, bridges, session) - if cancel is not None and watcher.attach(cancel): - # cancel_session blocks on HTTP; keep it off the event loop thread. - await asyncio.to_thread(cancel) + if cancel is not None: + if watcher.attach(cancel): + # cancel_session blocks on HTTP; keep it off the event loop thread. + await asyncio.to_thread(cancel) + else: + _register_exit_cancel(wrapper, session.id) + if stop_watcher is not None and not stop_watcher.active: + # A stop was filed while bridges or the session were starting; apply it now. + await asyncio.to_thread(_panic_stop) return session diff --git a/src/hai_agents_local/transport.py b/src/hai_agents_local/transport.py index 29fcb94..71e5a3e 100644 --- a/src/hai_agents_local/transport.py +++ b/src/hai_agents_local/transport.py @@ -66,6 +66,8 @@ async def ensure_channel(self, session_id: str) -> None: check = await self._client.get(f"{self._base}/api/v1/trajectories/{session_id}/") if check.status_code in {HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN}: raise AuthError(f"auth error checking channel ({check.status_code})") + if check.status_code == HTTPStatus.TOO_MANY_REQUESTS: + raise RateLimitedError(_retry_after(check)) if check.status_code == HTTPStatus.OK: return resp = await self._client.post( @@ -74,6 +76,8 @@ async def ensure_channel(self, session_id: str) -> None: ) if resp.status_code in {HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN}: raise AuthError(f"auth error creating channel ({resp.status_code})") + if resp.status_code == HTTPStatus.TOO_MANY_REQUESTS: + raise RateLimitedError(_retry_after(resp)) if resp.status_code == HTTPStatus.CONFLICT: return resp.raise_for_status() diff --git a/tests/test_local.py b/tests/test_local.py index 1a02ccc..0ba0613 100644 --- a/tests/test_local.py +++ b/tests/test_local.py @@ -1,5 +1,7 @@ import asyncio +import sys import threading +import types from typing import Any import httpx @@ -9,7 +11,7 @@ from hai_agents.sessions.client import SessionsClient from hai_agents_local import BridgeManager, LocalBridge, PyautoguiDesktopBridge, SeleniumBrowserBridge from hai_agents_local.config import AUTO_BRIDGE_ENV_VAR -from hai_agents_local.errors import AuthError +from hai_agents_local.errors import AuthError, RateLimitedError, SessionNotFoundError from hai_agents_local.routing import localize_agent from hai_agents_local.transport import Command, serialize_result @@ -119,6 +121,32 @@ def test_bridge_rejects_non_uuid_session_id(self): with pytest.raises(ValueError, match="must be a UUID"): PyautoguiDesktopBridge(api_key=API_KEY, session_id="my-laptop-1") + def test_desktop_screenshots_are_scaled_at_the_source(self, monkeypatch): + from hai_drivers.desktop.scaled import ScaledDesktopDriver + + _stub_local_driver(monkeypatch) + bridge = PyautoguiDesktopBridge(api_key=API_KEY, max_width=1280, image_format="webp", quality=70) + driver = bridge.create_driver() + assert isinstance(driver, ScaledDesktopDriver) + assert (driver._max_width, driver._image_format, driver._quality) == (1280, "webp", 70) + assert isinstance(driver._driver, _FakeLocalDriver) + + def test_desktop_bridge_with_all_knobs_off_serves_the_raw_driver(self, monkeypatch): + _stub_local_driver(monkeypatch) + bridge = PyautoguiDesktopBridge(api_key=API_KEY, max_width=None, image_format=None) + assert isinstance(bridge.create_driver(), _FakeLocalDriver) + + +class _FakeLocalDriver: + """Stands in for LocalDesktopDriver, whose module cannot even import without a display.""" + + +def _stub_local_driver(monkeypatch): + module = types.ModuleType("hai_drivers.desktop.local") + module.LocalDesktopDriver = _FakeLocalDriver + monkeypatch.setitem(sys.modules, "hai_drivers.desktop.local", module) + monkeypatch.setattr("hai_agents_local.desktop.ensure_macos_input_permissions", lambda prompt=True: None) + class TestAutoStart: def test_create_session_starts_bridges_and_stamps_matching_session_ids(self, monkeypatch): @@ -231,6 +259,105 @@ async def create_and_crash(self, **kw): assert cancelled == ["sess-3"] assert stopped == [bridge.session_id for bridge in bridges] + def test_interpreter_exit_cancels_sessions_served_by_this_process(self, monkeypatch): + from hai_agents_local import sessions as sessions_module + + cancelled: list = [] + stopped: list = [] + bridges: list = [] + self._crash_wiring(monkeypatch, cancelled, stopped, bridges) + monkeypatch.setattr(SessionsClient, "create_session", lambda self, **kw: type("S", (), {"id": "sess-4"})()) + Client(api_key=API_KEY).sessions.create_session(agent=dict(self._TWO_ENV_AGENT), messages="hi") + sessions_module._cancel_sessions_at_exit() + assert cancelled == ["sess-4"] + sessions_module._cancel_sessions_at_exit() + assert cancelled == ["sess-4"] + + def test_crash_cancel_deregisters_the_exit_hook(self, monkeypatch): + from hai_agents_local import sessions as sessions_module + + cancelled: list = [] + stopped: list = [] + bridges: list = [] + self._crash_wiring(monkeypatch, cancelled, stopped, bridges) + monkeypatch.setattr(SessionsClient, "create_session", lambda self, **kw: type("S", (), {"id": "sess-5"})()) + Client(api_key=API_KEY).sessions.create_session(agent=dict(self._TWO_ENV_AGENT), messages="hi") + bridges[0].on_crash() + sessions_module._cancel_sessions_at_exit() + assert cancelled == ["sess-5"] + + def test_local_sessions_get_default_runaway_budgets(self, monkeypatch): + from hai_agents_local.sessions import DEFAULT_LOCAL_MAX_STEPS, DEFAULT_LOCAL_MAX_TIME_S + + monkeypatch.setenv(AUTO_BRIDGE_ENV_VAR, "1") + captured: dict = {} + monkeypatch.setattr("hai_agents_local.sessions.ensure_bridges", lambda bridges: []) + monkeypatch.setattr(SessionsClient, "create_session", lambda self, **kw: captured.update(kw)) + client = Client(api_key=API_KEY) + client.sessions.create_session(agent=dict(self._TWO_ENV_AGENT), messages="hi") + assert captured["max_steps"] == DEFAULT_LOCAL_MAX_STEPS + assert captured["max_time_s"] == DEFAULT_LOCAL_MAX_TIME_S + captured.clear() + client.sessions.create_session(agent=dict(self._TWO_ENV_AGENT), messages="hi", max_steps=None, max_time_s=10) + assert captured["max_steps"] is None and captured["max_time_s"] == 10 + captured.clear() + client.sessions.create_session(agent="named-agent", messages="hi") + assert "max_steps" not in captured and "max_time_s" not in captured + + def test_kill_switch_stop_cancels_local_sessions(self, monkeypatch, tmp_path): + import time as _time + + from hai_agents_local import killswitch + from hai_agents_local import sessions as sessions_module + + monkeypatch.setattr(killswitch, "STOP_PATH", tmp_path / "stop") + monkeypatch.setattr(killswitch, "STOP_POLL_S", 0.02) + # Stop the watcher a previous test may have left, or it races this test's watcher for the stop. + if sessions_module._stop_watcher is not None: + sessions_module._stop_watcher.stop() + monkeypatch.setattr(sessions_module, "_stop_watcher", None) + cancelled: list = [] + stopped: list = [] + bridges: list = [] + self._crash_wiring(monkeypatch, cancelled, stopped, bridges) + monkeypatch.setattr("hai_agents_local.sessions.stop_bridges", lambda *args: stopped.append(args)) + monkeypatch.setattr(SessionsClient, "create_session", lambda self, **kw: type("S", (), {"id": "sess-ks"})()) + Client(api_key=API_KEY).sessions.create_session(agent=dict(self._TWO_ENV_AGENT), messages="hi") + killswitch.request_stop() + deadline = _time.monotonic() + 3.0 + while not stopped and _time.monotonic() < deadline: + _time.sleep(0.02) + assert cancelled == ["sess-ks"] + assert stopped == [()] + + def test_stop_filed_during_session_creation_cancels_the_new_session(self, monkeypatch, tmp_path): + import time as _time + + from hai_agents_local import killswitch + from hai_agents_local import sessions as sessions_module + + monkeypatch.setattr(killswitch, "STOP_PATH", tmp_path / "stop") + monkeypatch.setattr(killswitch, "STOP_POLL_S", 0.02) + if sessions_module._stop_watcher is not None: + sessions_module._stop_watcher.stop() + monkeypatch.setattr(sessions_module, "_stop_watcher", None) + cancelled: list = [] + stopped: list = [] + bridges: list = [] + self._crash_wiring(monkeypatch, cancelled, stopped, bridges) + monkeypatch.setattr("hai_agents_local.sessions.stop_bridges", lambda *args: stopped.append(args)) + + def create_while_stop_lands(self, **kw): + killswitch.request_stop() + deadline = _time.monotonic() + 3.0 + while sessions_module._stop_watcher.active and _time.monotonic() < deadline: + _time.sleep(0.02) + return type("S", (), {"id": "sess-mid"})() + + monkeypatch.setattr(SessionsClient, "create_session", create_while_stop_lands) + Client(api_key=API_KEY).sessions.create_session(agent=dict(self._TWO_ENV_AGENT), messages="hi") + assert cancelled == ["sess-mid"] + def test_no_bridges_and_no_stamping_when_disabled(self, monkeypatch): monkeypatch.setenv(AUTO_BRIDGE_ENV_VAR, "0") started: list = [] @@ -288,6 +415,101 @@ def _bridge(driver: Any) -> FakeBridge: return bridge +class TestChannelSetup: + async def test_429_is_retried_with_backoff_then_succeeds(self, monkeypatch): + import hai_agents_local.bridge as bridge_module + + async def instant_sleep(self, seconds: float) -> bool: + return False + + monkeypatch.setattr(FakeBridge, "_interruptible_sleep", instant_sleep) + calls = {"n": 0} + + def responder(request: httpx.Request) -> httpx.Response: + calls["n"] += 1 + return httpx.Response(429, headers={"Retry-After": "0"}) if calls["n"] < 3 else httpx.Response(200) + + bridge = FakeBridge(api_key="k") + async with httpx.AsyncClient(transport=httpx.MockTransport(responder)) as client: + exchange = bridge_module.CommandExchange(client, "http://test") + assert await bridge._open_channel(exchange) is True + assert calls["n"] == 3 + + async def test_429_gives_up_with_a_clear_error(self, monkeypatch): + async def instant_sleep(self, seconds: float) -> bool: + return False + + monkeypatch.setattr(FakeBridge, "_interruptible_sleep", instant_sleep) + from hai_agents_local.transport import CommandExchange + + bridge = FakeBridge(api_key="k") + async with httpx.AsyncClient( + transport=httpx.MockTransport(lambda request: httpx.Response(429, headers={"Retry-After": "0"})) + ) as client: + with pytest.raises(RuntimeError, match="rate-limiting"): + await bridge._open_channel(CommandExchange(client, "http://test")) + + async def test_auth_error_is_not_retried(self): + from hai_agents_local.transport import CommandExchange + + bridge = FakeBridge(api_key="k") + async with httpx.AsyncClient(transport=httpx.MockTransport(lambda request: httpx.Response(401))) as client: + with pytest.raises(AuthError): + await bridge._open_channel(CommandExchange(client, "http://test")) + + +class TestMacosPermissionPreflight: + def _fake_frameworks(self, monkeypatch, *, ax: bool, screen: bool): + import sys as _sys + import types + + calls = {"ax_prompts": [], "screen_requests": 0} + apps = types.ModuleType("ApplicationServices") + apps.kAXTrustedCheckOptionPrompt = "AXTrustedCheckOptionPrompt" + + def ax_check(options): + calls["ax_prompts"].append(options["AXTrustedCheckOptionPrompt"]) + return ax + + def screen_request(): + calls["screen_requests"] += 1 + return screen + + apps.AXIsProcessTrustedWithOptions = ax_check + quartz = types.ModuleType("Quartz") + quartz.CGPreflightScreenCaptureAccess = lambda: screen + quartz.CGRequestScreenCaptureAccess = screen_request + monkeypatch.setitem(_sys.modules, "ApplicationServices", apps) + monkeypatch.setitem(_sys.modules, "Quartz", quartz) + return calls + + def test_missing_grants_fail_fast_with_instructions(self, monkeypatch): + from hai_agents_local.desktop import ensure_macos_input_permissions + + self._fake_frameworks(monkeypatch, ax=False, screen=True) + with pytest.raises(PermissionError, match="Accessibility"): + ensure_macos_input_permissions() + self._fake_frameworks(monkeypatch, ax=True, screen=False) + with pytest.raises(PermissionError, match="Screen Recording"): + ensure_macos_input_permissions() + + def test_granted_passes(self, monkeypatch): + from hai_agents_local.desktop import ensure_macos_input_permissions + + self._fake_frameworks(monkeypatch, ax=True, screen=True) + ensure_macos_input_permissions() + + def test_prompt_false_never_triggers_dialogs(self, monkeypatch): + """create_driver re-checks from a worker thread, where TCC dialogs cannot appear.""" + from hai_agents_local.desktop import ensure_macos_input_permissions + + calls = self._fake_frameworks(monkeypatch, ax=False, screen=False) + with pytest.raises(PermissionError): + ensure_macos_input_permissions(prompt=False) + assert calls["ax_prompts"] == [False] + assert calls["screen_requests"] == 0 + + class TestBridgeProtocol: def test_bytes_results_are_base64(self): assert serialize_result(b"abc") == "YWJj" @@ -361,6 +583,35 @@ async def fetch_commands(self, *args: Any, **kwargs: Any) -> None: with pytest.raises(AuthError): await bridge._poll_loop(AuthFailingExchange()) + async def test_channel_recreation_backs_off_on_rate_limit(self, monkeypatch): + """A 429 while recreating a lost channel must back off and retry, not kill the bridge.""" + bridge = _bridge(FakeDriver()) + + async def instant_sleep(seconds: float) -> bool: + return False + + monkeypatch.setattr(bridge, "_interruptible_sleep", instant_sleep) + ensures = [] + + class Exchange: + def __init__(self) -> None: + self.fetches = 0 + + async def fetch_commands(self, *args: Any, **kwargs: Any) -> None: + self.fetches += 1 + if self.fetches == 1: + raise SessionNotFoundError("channel gone") + bridge.request_stop() + return None + + async def ensure_channel(self, session_id: str) -> None: + ensures.append(session_id) + if len(ensures) == 1: + raise RateLimitedError(retry_after=0.0) + + await bridge._poll_loop(Exchange()) + assert ensures == [bridge.session_id, bridge.session_id] + async def test_permanent_4xx_in_poll_loop_propagates(self): bridge = _bridge(FakeDriver()) @@ -440,6 +691,15 @@ async def run(self): assert isinstance(exc_info.value.__cause__, AuthError) assert not crashed.is_set() + def test_stop_during_setup_surfaces_as_startup_failure(self, manager): + class StoppedDuringSetupBridge(ServingBridge): + async def run(self): + self.request_stop() + + with pytest.raises(RuntimeError, match="failed to start"): + manager.ensure([StoppedDuringSetupBridge(api_key="k")]) + assert manager._runners == {} + def test_readiness_timeout_raises(self, manager, monkeypatch): class NeverReadyBridge(ServingBridge): async def run(self): @@ -527,3 +787,37 @@ async def run(self): bridge.on_crash = crashed.set manager.ensure([bridge]) assert crashed.wait(5.0) + + +class TestKillSwitch: + def test_double_tap_fires_only_within_the_window_then_resets(self): + from hai_agents_local.killswitch import MultiTapDetector + + detector = MultiTapDetector(taps=2, window_s=0.5) + assert not detector.record(1.0) + assert not detector.record(2.0) + assert detector.record(2.3) + assert not detector.record(2.4) + + def test_only_stops_filed_after_start_count(self, monkeypatch, tmp_path): + from hai_agents_local import killswitch + + monkeypatch.setattr(killswitch, "STOP_PATH", tmp_path / "stop") + sentinel = killswitch.StopSentinel(started_at=200.0) + assert not sentinel.stop_requested() + killswitch.request_stop(100.0) + assert not sentinel.stop_requested() + killswitch.request_stop(300.0) + assert sentinel.stop_requested() + + +class TestDoctor: + def test_missing_login_fails_with_fix_and_skips_the_platform_check(self, monkeypatch): + from hai_agents_cli import doctor + + monkeypatch.setattr(doctor.credentials, "current_api_key", lambda explicit=None: None) + results = doctor.run_checks(api_key=None, base_url=None) + by_name = {result.name: result for result in results} + assert not by_name["login"].ok and by_name["login"].fix is not None + assert "platform" not in by_name + assert {"browser", "desktop"} <= set(by_name) diff --git a/uv.lock b/uv.lock index 88c9b7d..6f58224 100644 --- a/uv.lock +++ b/uv.lock @@ -184,7 +184,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "hai-agents", extras = ["browser", "cli", "desktop"], marker = "extra == 'all'" }, - { name = "hai-drivers", extras = ["desktop"], marker = "extra == 'desktop'", specifier = ">=0.1.0" }, + { name = "hai-drivers", extras = ["desktop"], marker = "extra == 'desktop'", specifier = ">=0.1.2" }, { name = "hai-drivers", extras = ["web"], marker = "extra == 'browser'", specifier = ">=0.1.0" }, { name = "httpx", specifier = ">=0.23.0,<0.29.0" }, { name = "pydantic", specifier = ">=2.0,<3" }, @@ -201,25 +201,25 @@ dev = [ { name = "pytest-asyncio", specifier = ">=1.2,<2" }, { name = "python-dotenv", specifier = ">=1.2.2,<2" }, { name = "rich", specifier = ">=13,<16" }, - { name = "ruff", specifier = "==0.15.20" }, + { name = "ruff", specifier = "==0.15.21" }, { name = "typer", specifier = ">=0.12,<1" }, ] [[package]] name = "hai-drivers" -version = "0.1.1" +version = "0.1.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdownify" }, + { name = "pillow" }, { name = "pydantic" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/37/b9/0deb075f1d76f0b4c470ad0be7bb1476a224c375b6d98da95978e46025a8/hai_drivers-0.1.1-py3-none-any.whl", hash = "sha256:bdf6207e2226c488a432cf064acd49943c71e9ac4d90a2529e2e5f2f3cda59fb", size = 31073, upload-time = "2026-07-07T13:27:09Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/f4bf3bbd6340f1a24f5b760028d5370bdf2180d03fcc3bc274279750285e/hai_drivers-0.1.2-py3-none-any.whl", hash = "sha256:586d93678ad225d6face74c0bcab40b91deb8a5ca63258cbee0010b215c0568d", size = 34347, upload-time = "2026-07-15T16:11:31.487Z" }, ] [package.optional-dependencies] desktop = [ - { name = "pillow" }, { name = "pyautogui" }, { name = "pynput" }, ] @@ -866,27 +866,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.20" +version = "0.15.21" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/36/6f65aa9989acdec45d417192d8f4e7921931d8a6cf87ac74bce3eed98a8e/ruff-0.15.21.tar.gz", hash = "sha256:d0cfc841c572283c36548f82664a54ce6565567f1b0d5b4cf2caac693d8b7500", size = 4769401, upload-time = "2026-07-09T20:01:34.005Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, - { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, - { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, - { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, - { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, - { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, - { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, - { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, - { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, - { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, - { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, - { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, - { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, - { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, - { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, - { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c6/ede15cac6839f3dbce52565c8f5164a8210e669c7bc4decb03e5bdf47d0d/ruff-0.15.21-py3-none-linux_armv6l.whl", hash = "sha256:63ea0e965e5d73c90e95b2434beeafc70820536717f561b32ab6e777cb9bdf5d", size = 10854342, upload-time = "2026-07-09T20:00:53.998Z" }, + { url = "https://files.pythonhosted.org/packages/28/9d/d825b07ee7ea9e2d61df92a860033c94e06e7300d50a1c2653aac27d24fe/ruff-0.15.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0f212c5d7d54c01bbfe6dcab02b724a39300f3e34ed7acbe995ccb320a2c58bd", size = 11139539, upload-time = "2026-07-09T20:00:57.809Z" }, + { url = "https://files.pythonhosted.org/packages/f5/de/3b107712e642f063c7a9e0887c427b22cb44097de5aab36c05f2e280670c/ruff-0.15.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e6312e41bc96791299614995ea3a977c5857c3b5662b1ecef6755b02b87cb646", size = 10595437, upload-time = "2026-07-09T20:01:00.006Z" }, + { url = "https://files.pythonhosted.org/packages/9a/6f/b4523cc90ba239ede441447a19d0c968846a3012e5a0b0c5b62831a3d5e3/ruff-0.15.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01d65b4831c6b2a4ba8ee6faa84049d44d982b7a706e622c4094c509e51673be", size = 10990053, upload-time = "2026-07-09T20:01:02.187Z" }, + { url = "https://files.pythonhosted.org/packages/92/cc/c6a9872a5375f0628875481cf2f66b13d7d865bf3ca2e57f91c7e762d976/ruff-0.15.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c5a913a589120ce67933d5d05fd6ddbcc2481c6a054980ee767f7414c72b4fd", size = 10666096, upload-time = "2026-07-09T20:01:04.299Z" }, + { url = "https://files.pythonhosted.org/packages/ab/97/c621f7a17e097f1790fa3af6374138823b330b2d03fc38337945daca212c/ruff-0.15.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef04b681d02ad4dc9620f00f83ac5c22f652d0e9a9cfe431d219b16ad5ccc41", size = 11537011, upload-time = "2026-07-09T20:01:06.771Z" }, + { url = "https://files.pythonhosted.org/packages/ea/51/d928727e476e25ccc57c6f449ffd80241a651a973ad949d39cfb2a771d28/ruff-0.15.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16d090c0740916594157e75b80d666eab8e78083b39b3b0e1d698f4670a17b86", size = 12347101, upload-time = "2026-07-09T20:01:08.859Z" }, + { url = "https://files.pythonhosted.org/packages/1e/88/8cd62026802b16018ad06931d87997cf795ba2a6239ab659606c87d96bf0/ruff-0.15.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a10e74757dd65004d779b73e2f3c5210156d9980b41224d50d2ebcf1db51e67", size = 11572001, upload-time = "2026-07-09T20:01:11.092Z" }, + { url = "https://files.pythonhosted.org/packages/b2/97/f63084cf55444fc110e8cb985ebfcc592af47f597d44453d778cb81bc156/ruff-0.15.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bab0905d2f29e0d9fbc3c373ed23db0095edaa3f71f1f4f519ec15134d9e85c8", size = 11549239, upload-time = "2026-07-09T20:01:13.27Z" }, + { url = "https://files.pythonhosted.org/packages/9d/77/f107da4a2874b7715914b03f09ba9c54424de3ff8a1cc5d015d3ee2ce0ac/ruff-0.15.21-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:00eca240af5789fec6fe7df74c088cc1f9644ed83027113468efba7c92b94075", size = 11535340, upload-time = "2026-07-09T20:01:15.206Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e9/601deb322d3303a7bf212b0100ead6f2ee3f6a044d89c30f2f92bf83c731/ruff-0.15.21-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:262ab31557a75141325e32d3357f3597645a7f084e732b6b054dde428ecd9341", size = 10964048, upload-time = "2026-07-09T20:01:17.723Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2e/0f2176d1e99c15192caea19c8c3a0a955246b4cb4de795042eeb616345cd/ruff-0.15.21-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:659c4e7a4212f83306045ec7c5e5a356d16d9a6ef4ae0c7a4d872914fc655d9d", size = 10667055, upload-time = "2026-07-09T20:01:19.73Z" }, + { url = "https://files.pythonhosted.org/packages/48/60/abd74a02e0c4214f12a68becfd30af7165cfdcb0e661ecdc60bbb949c09a/ruff-0.15.21-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9e866eab611a5f959d36df2d10e446973a3610bc42b0c15b31dc27977d59c233", size = 11242043, upload-time = "2026-07-09T20:01:21.947Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c6/583075d8ccabb4b229345edcaf1545eb3d8d6be90f686a479d7e94088bbf/ruff-0.15.21-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e89bc93c0d3803ba870b55c29671bad9dc6d94bb1eb181b056b52eb05b52854f", size = 11648064, upload-time = "2026-07-09T20:01:24.023Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3c/37d0ecb729a7cc2d393ea7dce316fc585680f35d93b8d62139d7d0a3700c/ruff-0.15.21-py3-none-win32.whl", hash = "sha256:01f8d5be84823c172b389e123174f781f9daf86d6c58719d603f941932195cdd", size = 10896555, upload-time = "2026-07-09T20:01:26.941Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b8/e43466b2a6067ce91e669068f6e28d6c719a920f014b070d5c8731725de3/ruff-0.15.21-py3-none-win_amd64.whl", hash = "sha256:d4b8d9a2f0f12b816b50447f6eccb9f4bb01a6b82c86b50fb3b5354b458dc6d3", size = 12038772, upload-time = "2026-07-09T20:01:29.497Z" }, + { url = "https://files.pythonhosted.org/packages/dd/75/e90ab9aeece218a9fc5a5bc3ec97d0ee6bb3c4ff95869463c1de58e29a1c/ruff-0.15.21-py3-none-win_arm64.whl", hash = "sha256:6e83115d4b9377c1cbc13abf0e051f069fab0ef815ea0504a8a008cee24dd0a8", size = 11375265, upload-time = "2026-07-09T20:01:31.772Z" }, ] [[package]]