diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 9db871f283c..b83f7973221 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -3474,6 +3474,15 @@ DEFAULT_CONFIG = { # every invocation (MCP backend, status, doctor, install). Set true # to let cua-driver use its own default (telemetry on). "cua_telemetry": False, + # Disable the cursor overlay rendered by cua-driver. The overlay + # shows where agent actions land but can peg a core when idle + # (macOS vImage redraw loop #47032; Linux/WSL2 idle spin #28152). + # cua-driver ≥ 0.6.x supports --no-overlay; Hermes also calls + # set_agent_cursor_enabled(false) after start_session when this is on. + # None = auto-detect (off on macOS + headless/WSL2 Linux; on elsewhere) + # True = always disable the overlay + # False = always enable the overlay + "no_overlay": None, }, # Hermes Desktop (Electron app) launch options. These only affect diff --git a/tests/computer_use/test_cua_no_overlay.py b/tests/computer_use/test_cua_no_overlay.py new file mode 100644 index 00000000000..0288bb4a094 --- /dev/null +++ b/tests/computer_use/test_cua_no_overlay.py @@ -0,0 +1,247 @@ +"""Tests for the cua-driver --no-overlay policy. + +cua-driver's cursor overlay rendering loop can consume CPU indefinitely when +idle (#28152, #47032). Hermes passes ``--no-overlay`` to suppress it when the +``computer_use.no_overlay`` config is enabled (or auto-detected on macOS and +headless Linux / WSL2). + +These assert the behavior contract (auto-detect, explicit override, version +probe), not specific config snapshots. +""" + +import os +import sys +from unittest.mock import mock_open, patch + +from tools.computer_use import cua_backend + + +class TestNoOverlayFlag: + def test_default_linux_headless_disables(self): + """Auto-detect: Linux without DISPLAY => overlay disabled.""" + with patch("hermes_cli.config.load_config", return_value={}), \ + patch.object(sys, "platform", "linux"), \ + patch.dict(os.environ, {}, clear=False): + os.environ.pop("DISPLAY", None) + assert cua_backend._cua_no_overlay() is True + + def test_default_linux_desktop_enables(self): + """Auto-detect: Linux with DISPLAY => overlay enabled.""" + with patch("hermes_cli.config.load_config", return_value={}), \ + patch.object(sys, "platform", "linux"), \ + patch.dict(os.environ, {"DISPLAY": ":0"}): + assert cua_backend._cua_no_overlay() is False + + def test_default_linux_wsl2_disables(self): + """Auto-detect: WSL2 (microsoft in /proc/version) => overlay disabled.""" + fake_version = "Linux version 5.15.0 (Microsoft@Microsoft.com)" + with patch("hermes_cli.config.load_config", return_value={}), \ + patch.object(sys, "platform", "linux"), \ + patch.dict(os.environ, {"DISPLAY": ":0"}), \ + patch("builtins.open", mock_open(read_data=fake_version)): + assert cua_backend._cua_no_overlay() is True + + def test_default_macos_disables(self): + """Auto-detect: macOS => overlay disabled (idle CPU / #47032).""" + with patch("hermes_cli.config.load_config", return_value={}), \ + patch.object(sys, "platform", "darwin"): + assert cua_backend._cua_no_overlay() is True + + def test_default_windows_enables(self): + """Auto-detect: Windows => overlay enabled.""" + with patch("hermes_cli.config.load_config", return_value={}), \ + patch.object(sys, "platform", "win32"): + assert cua_backend._cua_no_overlay() is False + + def test_explicit_true_overrides(self): + with patch("hermes_cli.config.load_config", + return_value={"computer_use": {"no_overlay": True}}): + assert cua_backend._cua_no_overlay() is True + + def test_explicit_false_overrides(self): + with patch("hermes_cli.config.load_config", + return_value={"computer_use": {"no_overlay": False}}), \ + patch.object(sys, "platform", "linux"), \ + patch.dict(os.environ, {}, clear=False): + os.environ.pop("DISPLAY", None) + # Explicit False overrides auto-detect on headless Linux. + assert cua_backend._cua_no_overlay() is False + + def test_config_load_failure_falls_through_to_auto_detect(self): + """Unreadable config => auto-detect (macOS defaults to disabled).""" + with patch("hermes_cli.config.load_config", + side_effect=RuntimeError("boom")), \ + patch.object(sys, "platform", "darwin"): + assert cua_backend._cua_no_overlay() is True + + def test_macos_explicit_false_keeps_overlay(self): + with patch("hermes_cli.config.load_config", + return_value={"computer_use": {"no_overlay": False}}), \ + patch.object(sys, "platform", "darwin"): + assert cua_backend._cua_no_overlay() is False + + def test_missing_section_falls_through_to_auto_detect(self): + with patch("hermes_cli.config.load_config", + return_value={"other": {}}), \ + patch.object(sys, "platform", "linux"), \ + patch.dict(os.environ, {"DISPLAY": ":0"}): + assert cua_backend._cua_no_overlay() is False + + +class TestDriverSupportsNoOverlay: + def test_returns_true_when_help_shows_flag(self): + fake_help = "Usage: cua-driver [OPTIONS] COMMAND\n --no-overlay Disable cursor overlay\n" + with patch("subprocess.run") as mock_run: + mock_run.return_value.stdout = fake_help + mock_run.return_value.stderr = "" + assert cua_backend._cua_driver_supports_no_overlay("cua-driver") is True + + def test_returns_false_when_help_lacks_flag(self): + fake_help = "Usage: cua-driver [OPTIONS] COMMAND\n" + with patch("subprocess.run") as mock_run: + mock_run.return_value.stdout = fake_help + mock_run.return_value.stderr = "" + cua_backend._cua_driver_supports_no_overlay.cache_clear() + assert cua_backend._cua_driver_supports_no_overlay("cua-driver") is False + + def test_returns_false_on_subprocess_error(self): + with patch("subprocess.run", side_effect=FileNotFoundError("no such file")): + cua_backend._cua_driver_supports_no_overlay.cache_clear() + assert cua_backend._cua_driver_supports_no_overlay("cua-driver") is False + + def test_help_probe_passes_sanitized_env(self): + """The ``--help`` subprocess must not leak provider credentials + via the inherited parent environment (third-party binary; same + policy as the manifest probe and MCP spawn). + """ + from unittest.mock import MagicMock + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock(stdout="--no-overlay in help", stderr="") + cua_backend._cua_driver_supports_no_overlay.cache_clear() + cua_backend._cua_driver_supports_no_overlay("cua-driver") + kwargs = mock_run.call_args.kwargs + assert "env" in kwargs, ( + "subprocess.run was called without env= — cua-driver is a " + "third-party binary and must not receive inherited secrets" + ) + # The sanitized env must come from the same helper the MCP + # spawn uses, so the policy is consistent across every + # cua-driver invocation in this file. + assert kwargs["env"] is not None + + +class TestMcpInvocationUsesResolvedCommand: + """Surface 8 (NousResearch/hermes-agent#47072) + sweeper feedback + #4701565902: when the manifest surfaces a relocated executable for + ``mcp_invocation.command``, the support probe must run against THAT + binary, not the system-resolved ``_CUA_DRIVER_CMD``. Otherwise a + wrapper/relocation with a different feature set either crashes on + the unknown flag (when the probe falsely reports support) or + silently keeps an unwanted overlay (when the probe falsely reports + no support). + """ + + @staticmethod + def _fake_run(stdout: str = "", returncode: int = 0): + from unittest.mock import MagicMock + def _run(*args, **kwargs): + proc = MagicMock() + proc.stdout = stdout + proc.returncode = returncode + return proc + return _run + + def test_manifest_command_drives_support_probe(self): + """When the manifest returns a distinct command, the support + probe runs against the manifest command, not the input + ``driver_cmd`` parameter. + """ + from unittest.mock import patch + from tools.computer_use.cua_backend import _resolve_mcp_invocation + + manifest = ( + '{"mcp_invocation":' + '{"command":"/opt/relocated/cua-driver","args":["mcp"]}}' + ) + with patch("subprocess.run", new=self._fake_run(stdout=manifest)), \ + patch.object(cua_backend, "_cua_no_overlay", return_value=True), \ + patch.object( + cua_backend, "_cua_driver_supports_no_overlay", + return_value=True, + ) as mock_probe: + cua_backend._cua_driver_supports_no_overlay.cache_clear() + cmd, args = _resolve_mcp_invocation("/usr/bin/cua-driver") + assert cmd == "/opt/relocated/cua-driver" + # The support probe must be called with the manifest-resolved + # command, not the input driver_cmd argument. + mock_probe.assert_called_with("/opt/relocated/cua-driver") + + def test_fallback_uses_input_driver_cmd_for_support_probe(self): + """When the manifest knows the args but NOT the command, the + input ``driver_cmd`` parameter is what gets launched and + probed. + """ + from unittest.mock import patch + from tools.computer_use.cua_backend import _resolve_mcp_invocation + + manifest = '{"mcp_invocation":{"args":["mcp"]}}' + with patch("subprocess.run", new=self._fake_run(stdout=manifest)), \ + patch.object(cua_backend, "_cua_no_overlay", return_value=True), \ + patch.object( + cua_backend, "_cua_driver_supports_no_overlay", + return_value=True, + ) as mock_probe: + cua_backend._cua_driver_supports_no_overlay.cache_clear() + cmd, args = _resolve_mcp_invocation("/my/local/cua-driver") + assert cmd == "/my/local/cua-driver" + # Fallback path: probe runs against the input driver_cmd. + mock_probe.assert_called_with("/my/local/cua-driver") + + def test_probe_distinguishes_support_between_binaries(self): + """Different binaries must produce independent support verdicts. + The cache is keyed on ``driver_cmd``; the same cached result + must not leak between the system binary and a manifest-relocated + one. + """ + with patch.object(cua_backend, "_cua_no_overlay", return_value=True), \ + patch.object( + cua_backend, "_cua_driver_supports_no_overlay", + side_effect=lambda cmd: cmd == "/opt/relocated/cua-driver", + ): + # System binary does NOT support, manifest binary DOES. + args = cua_backend._mcp_args_with_overlay_flag( + ["mcp"], driver_cmd="/usr/bin/cua-driver", + ) + assert "--no-overlay" not in args + args = cua_backend._mcp_args_with_overlay_flag( + ["mcp"], driver_cmd="/opt/relocated/cua-driver", + ) + assert "--no-overlay" in args + + +class TestMcpArgsOverlayFlag: + def test_appended_when_enabled_and_supported(self): + with patch.object(cua_backend, "_cua_no_overlay", return_value=True), \ + patch.object(cua_backend, "_cua_driver_supports_no_overlay", return_value=True): + result = cua_backend._mcp_args_with_overlay_flag(["mcp"]) + assert result == ["mcp", "--no-overlay"] + + def test_not_appended_when_disabled(self): + with patch.object(cua_backend, "_cua_no_overlay", return_value=False), \ + patch.object(cua_backend, "_cua_driver_supports_no_overlay", return_value=True): + result = cua_backend._mcp_args_with_overlay_flag(["mcp"]) + assert result == ["mcp"] + + def test_not_appended_when_driver_unsupported(self): + with patch.object(cua_backend, "_cua_no_overlay", return_value=True), \ + patch.object(cua_backend, "_cua_driver_supports_no_overlay", return_value=False): + result = cua_backend._mcp_args_with_overlay_flag(["mcp"]) + assert result == ["mcp"] + + def test_does_not_mutate_original_list(self): + original = ["mcp"] + with patch.object(cua_backend, "_cua_no_overlay", return_value=True), \ + patch.object(cua_backend, "_cua_driver_supports_no_overlay", return_value=True): + result = cua_backend._mcp_args_with_overlay_flag(original) + assert "--no-overlay" in result + assert "--no-overlay" not in original diff --git a/tests/tools/test_computer_use.py b/tests/tools/test_computer_use.py index a22d325a10e..a9ec592524b 100644 --- a/tests/tools/test_computer_use.py +++ b/tests/tools/test_computer_use.py @@ -2873,6 +2873,13 @@ class TestMcpInvocationResolution: fields, wrong types) falls back to the literal `["mcp"]` baseline. """ + @pytest.fixture(autouse=True) + def _no_overlay_off(self): + """Disable the --no-overlay flag so tests assert baseline args.""" + with patch("tools.computer_use.cua_backend._cua_no_overlay", + return_value=False): + yield + @staticmethod def _fake_run(stdout: str = "", returncode: int = 0, raises: Exception = None): """Build a patched subprocess.run that yields the supplied result.""" diff --git a/tools/computer_use/cua_backend.py b/tools/computer_use/cua_backend.py index 6f75f15922e..9ecc60bb4ee 100644 --- a/tools/computer_use/cua_backend.py +++ b/tools/computer_use/cua_backend.py @@ -38,6 +38,7 @@ from __future__ import annotations import asyncio import base64 import concurrent.futures +import functools import json import logging import os @@ -172,6 +173,44 @@ _DESKTOP_WINDOW_NAMES = ( _CUA_TELEMETRY_ENV_VAR = "CUA_DRIVER_RS_TELEMETRY_ENABLED" +def _cua_no_overlay() -> bool: + """True when Hermes should pass ``--no-overlay`` to cua-driver. + + Reads ``computer_use.no_overlay`` from config.yaml. Default is + ``None`` (auto-detect): disable the overlay where idle CPU burn is a + known failure mode — macOS (cursor-overlay vImage redraw loop, + #28152/#47032), headless Linux / WSL2 / containers — and keep it on + Windows / desktop Linux with a display. Explicit ``True`` / ``False`` + in config overrides auto-detection. + """ + try: + from hermes_cli.config import load_config + + cfg = load_config() or {} + cu = cfg.get("computer_use") or {} + val = cu.get("no_overlay") + if val is not None: + return bool(val) + except Exception: + pass + # Auto-detect: macOS overlay can peg a core indefinitely after a + # computer_use session (#47032). Prefer off until the driver teardown + # is solid; set computer_use.no_overlay: false to keep the cursor. + if sys.platform == "darwin": + return True + if sys.platform != "linux": + return False + if not os.environ.get("DISPLAY"): + return True + try: + with open("/proc/version", encoding="utf-8") as f: + if "microsoft" in f.read().lower(): + return True + except Exception: + pass + return False + + def _cua_telemetry_disabled() -> bool: """True when Hermes should disable cua-driver telemetry for this user. @@ -301,6 +340,13 @@ def _resolve_mcp_invocation( Falls back to ``(driver_cmd, ["mcp"])`` for older drivers that don't expose ``manifest``, or any indeterminate failure — the wrapper must not refuse to start just because the discovery hop failed. + + When ``computer_use.no_overlay`` is enabled (or auto-detected on + Linux), ``--no-overlay`` is appended to suppress the cursor overlay + rendering loop that can consume CPU indefinitely when idle + (#28152, #47032). Older drivers that don't recognise the flag will + reject it; callers should fall back to the no-overlay invocation on + spawn failure. """ try: from tools.environments.local import _sanitize_subprocess_env @@ -314,33 +360,72 @@ def _resolve_mcp_invocation( env=_sanitize_subprocess_env(cua_driver_child_env()), ) except Exception: - return driver_cmd, list(_CUA_DRIVER_ARGS) + return driver_cmd, _mcp_args_with_overlay_flag(list(_CUA_DRIVER_ARGS), driver_cmd=driver_cmd) out = (proc.stdout or "").strip() if proc.returncode != 0 or not out: - return driver_cmd, list(_CUA_DRIVER_ARGS) + return driver_cmd, _mcp_args_with_overlay_flag(list(_CUA_DRIVER_ARGS), driver_cmd=driver_cmd) try: manifest = json.loads(out) except (ValueError, TypeError): - return driver_cmd, list(_CUA_DRIVER_ARGS) + return driver_cmd, _mcp_args_with_overlay_flag(list(_CUA_DRIVER_ARGS), driver_cmd=driver_cmd) if not isinstance(manifest, dict): - return driver_cmd, list(_CUA_DRIVER_ARGS) + return driver_cmd, _mcp_args_with_overlay_flag(list(_CUA_DRIVER_ARGS), driver_cmd=driver_cmd) invocation = manifest.get("mcp_invocation") if not isinstance(invocation, dict): - return driver_cmd, list(_CUA_DRIVER_ARGS) + return driver_cmd, _mcp_args_with_overlay_flag(list(_CUA_DRIVER_ARGS), driver_cmd=driver_cmd) args = invocation.get("args") command = invocation.get("command") if not isinstance(args, list) or not all(isinstance(a, str) for a in args): - return driver_cmd, list(_CUA_DRIVER_ARGS) + return driver_cmd, _mcp_args_with_overlay_flag(list(_CUA_DRIVER_ARGS), driver_cmd=driver_cmd) if not isinstance(command, str) or not command: # The driver knows the subcommand but didn't surface its own path. # Keep our resolved driver_cmd; the args are still authoritative. - return driver_cmd, args + return driver_cmd, _mcp_args_with_overlay_flag(args, driver_cmd=driver_cmd) if not _has_path_separator(command): # A manifest may legitimately retain the generic ``cua-driver`` name. # Under a GUI's thin PATH that would lose the resolved user-local path # and fail at MCP spawn, so preserve the concrete command we verified. - return driver_cmd, args - return command, args + return driver_cmd, _mcp_args_with_overlay_flag(args, driver_cmd=driver_cmd) + # Manifest surfaced a relocated executable — probe THAT binary for + # `--no-overlay` support rather than the system-resolved one, so a + # wrapper/relocation with a different feature set doesn't crash on + # an unknown flag (or silently keep an unwanted overlay). + return command, _mcp_args_with_overlay_flag(args, driver_cmd=command) + + +def _mcp_args_with_overlay_flag( + args: List[str], + driver_cmd: str = _CUA_DRIVER_DEFAULT_CMD, +) -> List[str]: + """Return *args* with ``--no-overlay`` appended when configured and supported.""" + if _cua_no_overlay() and _cua_driver_supports_no_overlay(driver_cmd): + return [*args, "--no-overlay"] + return list(args) + + +@functools.lru_cache(maxsize=1) +def _cua_driver_supports_no_overlay(driver_cmd: str) -> bool: + """True if the installed cua-driver recognises ``--no-overlay``. + + Probes `` --help`` once and caches the result. Older + drivers (< 0.6.x) reject unknown flags, so passing ``--no-overlay`` + would crash the MCP spawn. + """ + try: + # cua-driver is a third-party binary — never hand it provider + # API keys via inherited env (same policy as the manifest probe + # and MCP spawn; #53503/#55709/#58889 lineage). + from tools.environments.local import _sanitize_subprocess_env + proc = subprocess.run( + [driver_cmd, "--help"], + capture_output=True, text=True, timeout=3.0, + stdin=subprocess.DEVNULL, + env=_sanitize_subprocess_env(cua_driver_child_env()), + ) + help_text = (proc.stdout or "") + (proc.stderr or "") + return "--no-overlay" in help_text + except Exception: + return False # Regex to parse element lines from get_window_state AX tree markdown. # @@ -1490,6 +1575,17 @@ class CuaDriverBackend(ComputerUseBackend): except Exception as e: logger.debug("cua-driver start_session failed (continuing anonymous): %s", e) + # Belt-and-suspenders when --no-overlay is unsupported or ignored: + # hide the agent cursor overlay via the session API so macOS idle + # redraw loops cannot keep burning CPU after the first action. Only + # once the handshake flipped `_started` — otherwise call_tool would + # re-enter session.start() (see _LIFECYCLE_CALLS). + if _cua_no_overlay() and self._session._started: + try: + self.set_agent_cursor_enabled(False, cursor_id=self._session_id) + except Exception as e: + logger.debug("cua-driver set_agent_cursor_enabled failed: %s", e) + def stop(self) -> None: # Tear the cua-driver session down before disconnecting so the # driver can clean up per-session state (cursor overlay, recording