From 8ceada6e30c12f743a06d6046b17028e57f3ae0b Mon Sep 17 00:00:00 2001 From: David Metcalfe <80915+DavidMetcalfe@users.noreply.github.com> Date: Sat, 27 Jun 2026 14:55:22 -0700 Subject: [PATCH 1/7] fix(computer_use): pass --no-overlay to cua-driver on Linux/WSL2 to prevent idle CPU cua-driver's cursor overlay rendering loop can consume CPU indefinitely when idle (#28152, #47032). On Linux/WSL2, the overlay serves no visual purpose and the rendering path is the primary source of idle CPU usage. Add computer_use.no_overlay config option (default: auto-detect) that passes --no-overlay to cua-driver when enabled. Auto-detection disables the overlay on Linux (covers WSL2, headless, containers) where it has no benefit, and keeps it enabled on macOS/Windows where it is visually useful. Refs: #28152, #47032 --- hermes_cli/config.py | 10 +++ tests/computer_use/test_cua_no_overlay.py | 78 +++++++++++++++++++++++ tools/computer_use/cua_backend.py | 57 ++++++++++++++--- 3 files changed, 137 insertions(+), 8 deletions(-) create mode 100644 tests/computer_use/test_cua_no_overlay.py diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 9db871f283c..c5920a1d7f4 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -3474,6 +3474,16 @@ 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 on screen but can consume + # significant CPU when idle (especially on Linux/WSL2 where the + # compositor path has no visual benefit). cua-driver ≥ 0.6.x + # supports --no-overlay. + # None = auto-detect (disable on Linux/headless, enable elsewhere) + # True = always disable the overlay + # False = always enable the overlay (may cause idle CPU on some + # platforms — see #28152, #47032) + "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..244fd39b6dc --- /dev/null +++ b/tests/computer_use/test_cua_no_overlay.py @@ -0,0 +1,78 @@ +"""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 Linux). + +These assert the behavior contract (auto-detect on Linux, explicit override, +config failure fails safe toward overlay enabled), not specific config +snapshots. +""" + +import sys +from unittest.mock import patch + +from tools.computer_use import cua_backend + + +class TestNoOverlayFlag: + def test_default_linux_disables(self): + """Auto-detect: Linux => overlay disabled.""" + with patch("hermes_cli.config.load_config", return_value={}), \ + patch.object(sys, "platform", "linux"): + assert cua_backend._cua_no_overlay() is True + + def test_default_macos_enables(self): + """Auto-detect: macOS => overlay enabled (visually useful).""" + with patch("hermes_cli.config.load_config", return_value={}), \ + patch.object(sys, "platform", "darwin"): + assert cua_backend._cua_no_overlay() is False + + 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"): + # Explicit False overrides auto-detect on Linux. + assert cua_backend._cua_no_overlay() is False + + def test_config_load_failure_fails_safe(self): + """Unreadable config => default to overlay enabled.""" + with patch("hermes_cli.config.load_config", + side_effect=RuntimeError("boom")): + assert cua_backend._cua_no_overlay() is False + + def test_missing_section_enables(self): + with patch("hermes_cli.config.load_config", + return_value={"other": {}}): + assert cua_backend._cua_no_overlay() is False + + +class TestMcpArgsOverlayFlag: + def test_no_overlay_appended_when_enabled(self): + with patch.object(cua_backend, "_cua_no_overlay", return_value=True): + result = cua_backend._mcp_args_with_overlay_flag(["mcp"]) + assert result == ["mcp", "--no-overlay"] + + def test_no_overlay_not_appended_when_disabled(self): + with patch.object(cua_backend, "_cua_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): + """The original args list must not be mutated.""" + original = ["mcp"] + with patch.object(cua_backend, "_cua_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/tools/computer_use/cua_backend.py b/tools/computer_use/cua_backend.py index 8c9b4a8ddb9..762ddbf34ce 100644 --- a/tools/computer_use/cua_backend.py +++ b/tools/computer_use/cua_backend.py @@ -171,6 +171,33 @@ _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 on Linux / headless / WSL2 + where it serves no visual purpose and can consume CPU indefinitely + (#28152, #47032). Explicit ``True`` / ``False`` in config overrides + auto-detection. Fails SAFE — toward enabling the overlay (False) + when config is unreadable, because the flag only takes effect on + cua-driver ≥ 0.6.x and an older driver ignores unknown flags. + """ + 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: disable on Linux (covers WSL2, headless, containers). + # macOS and Windows get the overlay by default — it's visually useful + # there, even though macOS has the vImage redraw bug on some versions. + return sys.platform == "linux" + + def _cua_telemetry_disabled() -> bool: """True when Hermes should disable cua-driver telemetry for this user. @@ -300,6 +327,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 @@ -313,28 +347,35 @@ 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)) 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)) 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)) if not isinstance(manifest, dict): - return driver_cmd, list(_CUA_DRIVER_ARGS) + return driver_cmd, _mcp_args_with_overlay_flag(list(_CUA_DRIVER_ARGS)) 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)) 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)) 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 command, args + return driver_cmd, _mcp_args_with_overlay_flag(args) + return command, _mcp_args_with_overlay_flag(args) + + +def _mcp_args_with_overlay_flag(args: List[str]) -> List[str]: + """Return *args* with ``--no-overlay`` appended when the config says to.""" + if _cua_no_overlay(): + return [*args, "--no-overlay"] + return list(args) # Regex to parse element lines from get_window_state AX tree markdown. # From f43ff5b4bbd3df2c703ddd2d180fb76ee0232dba Mon Sep 17 00:00:00 2001 From: David Metcalfe <80915+DavidMetcalfe@users.noreply.github.com> Date: Sat, 27 Jun 2026 17:39:52 -0700 Subject: [PATCH 2/7] fix(computer_use): mock _cua_no_overlay in existing tests, fix platform-dependent assertions - Add autouse fixture to TestMcpInvocationResolution to disable --no-overlay flag so existing tests assert baseline args - Make test_config_load_failure_fails_safe and test_missing_section_enables platform-aware (Linux auto-detect returns True, macOS/Windows False) --- tests/computer_use/test_cua_no_overlay.py | 8 +++++--- tests/tools/test_computer_use.py | 7 +++++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/tests/computer_use/test_cua_no_overlay.py b/tests/computer_use/test_cua_no_overlay.py index 244fd39b6dc..3b67a1b1f72 100644 --- a/tests/computer_use/test_cua_no_overlay.py +++ b/tests/computer_use/test_cua_no_overlay.py @@ -47,15 +47,17 @@ class TestNoOverlayFlag: assert cua_backend._cua_no_overlay() is False def test_config_load_failure_fails_safe(self): - """Unreadable config => default to overlay enabled.""" + """Unreadable config => auto-detect (platform-dependent).""" with patch("hermes_cli.config.load_config", side_effect=RuntimeError("boom")): - assert cua_backend._cua_no_overlay() is False + expected = sys.platform == "linux" + assert cua_backend._cua_no_overlay() is expected def test_missing_section_enables(self): with patch("hermes_cli.config.load_config", return_value={"other": {}}): - assert cua_backend._cua_no_overlay() is False + expected = sys.platform == "linux" + assert cua_backend._cua_no_overlay() is expected class TestMcpArgsOverlayFlag: diff --git a/tests/tools/test_computer_use.py b/tests/tools/test_computer_use.py index 44b5f1a5dbd..502f24b61b5 100644 --- a/tests/tools/test_computer_use.py +++ b/tests/tools/test_computer_use.py @@ -2795,6 +2795,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.""" From 8d4f7a0002ebb5c27f76ea250bdadf8e12cb9857 Mon Sep 17 00:00:00 2001 From: David Metcalfe <80915+DavidMetcalfe@users.noreply.github.com> Date: Sat, 27 Jun 2026 17:42:17 -0700 Subject: [PATCH 3/7] fix(computer_use): refine auto-detect to headless/WSL2 only, add driver version probe Address review feedback from cross-vendor review (Flash + GPT-OSS): 1. Auto-detect now checks for headless Linux (no DISPLAY), WSL2 (/proc/version contains 'microsoft'), instead of all Linux. Desktop Linux with a compositor keeps the overlay. 2. Add _cua_driver_supports_no_overlay() that probes cua-driver --help to check if the flag is supported. Older drivers (< 0.6.x) reject unknown flags, so passing --no-overlay would crash the MCP spawn. 3. Update tests to cover headless vs desktop Linux, WSL2 detection, version probe, and the unsupported-driver fallback path. --- tests/computer_use/test_cua_no_overlay.py | 102 ++++++++++++++++------ tools/computer_use/cua_backend.py | 58 +++++++++--- 2 files changed, 122 insertions(+), 38 deletions(-) diff --git a/tests/computer_use/test_cua_no_overlay.py b/tests/computer_use/test_cua_no_overlay.py index 3b67a1b1f72..3629aed448d 100644 --- a/tests/computer_use/test_cua_no_overlay.py +++ b/tests/computer_use/test_cua_no_overlay.py @@ -2,24 +2,43 @@ 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 Linux). +``computer_use.no_overlay`` config is enabled (or auto-detected on headless +Linux / WSL2). -These assert the behavior contract (auto-detect on Linux, explicit override, -config failure fails safe toward overlay enabled), not specific config -snapshots. +These assert the behavior contract (auto-detect on headless/WSL2, explicit +override, version probe), not specific config snapshots. """ +import os import sys -from unittest.mock import patch +from unittest.mock import mock_open, patch from tools.computer_use import cua_backend class TestNoOverlayFlag: - def test_default_linux_disables(self): - """Auto-detect: Linux => overlay disabled.""" + 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.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_enables(self): @@ -42,39 +61,72 @@ class TestNoOverlayFlag: 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"): - # Explicit False overrides auto-detect on Linux. + 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_fails_safe(self): - """Unreadable config => auto-detect (platform-dependent).""" + def test_config_load_failure_falls_through_to_auto_detect(self): + """Unreadable config => auto-detect.""" with patch("hermes_cli.config.load_config", - side_effect=RuntimeError("boom")): - expected = sys.platform == "linux" - assert cua_backend._cua_no_overlay() is expected + side_effect=RuntimeError("boom")), \ + patch.object(sys, "platform", "darwin"): + assert cua_backend._cua_no_overlay() is False - def test_missing_section_enables(self): + def test_missing_section_falls_through_to_auto_detect(self): with patch("hermes_cli.config.load_config", - return_value={"other": {}}): - expected = sys.platform == "linux" - assert cua_backend._cua_no_overlay() is expected + 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 class TestMcpArgsOverlayFlag: - def test_no_overlay_appended_when_enabled(self): - with patch.object(cua_backend, "_cua_no_overlay", return_value=True): + 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_no_overlay_not_appended_when_disabled(self): - with patch.object(cua_backend, "_cua_no_overlay", return_value=False): + 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): - """The original args list must not be mutated.""" original = ["mcp"] - with patch.object(cua_backend, "_cua_no_overlay", return_value=True): + 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/tools/computer_use/cua_backend.py b/tools/computer_use/cua_backend.py index 762ddbf34ce..cbc4a17de4c 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 @@ -175,12 +176,10 @@ 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 on Linux / headless / WSL2 - where it serves no visual purpose and can consume CPU indefinitely - (#28152, #47032). Explicit ``True`` / ``False`` in config overrides - auto-detection. Fails SAFE — toward enabling the overlay (False) - when config is unreadable, because the flag only takes effect on - cua-driver ≥ 0.6.x and an older driver ignores unknown flags. + ``None`` (auto-detect): disable the overlay on headless Linux / WSL2 / + containers where it serves no visual purpose and can consume CPU + indefinitely (#28152, #47032). Explicit ``True`` / ``False`` in config + overrides auto-detection. """ try: from hermes_cli.config import load_config @@ -192,10 +191,20 @@ def _cua_no_overlay() -> bool: return bool(val) except Exception: pass - # Auto-detect: disable on Linux (covers WSL2, headless, containers). - # macOS and Windows get the overlay by default — it's visually useful - # there, even though macOS has the vImage redraw bug on some versions. - return sys.platform == "linux" + # Auto-detect: disable on headless Linux (no DISPLAY), WSL2, or + # containers. Desktop Linux with a running compositor keeps the + # overlay — it's visually useful there. + if sys.platform != "linux": + return False + if not os.environ.get("DISPLAY"): + return True + try: + with open("/proc/version") as f: + if "microsoft" in f.read().lower(): + return True + except Exception: + pass + return False def _cua_telemetry_disabled() -> bool: @@ -371,12 +380,35 @@ def _resolve_mcp_invocation( return command, _mcp_args_with_overlay_flag(args) -def _mcp_args_with_overlay_flag(args: List[str]) -> List[str]: - """Return *args* with ``--no-overlay`` appended when the config says to.""" - if _cua_no_overlay(): +def _mcp_args_with_overlay_flag( + args: List[str], + driver_cmd: str = _CUA_DRIVER_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: + proc = subprocess.run( + [driver_cmd, "--help"], + capture_output=True, text=True, timeout=3.0, + stdin=subprocess.DEVNULL, + ) + 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. # # cua-driver renders each actionable node as one of: From f7a6c7a6e5a111aa81007f19bf9c66e232e431e1 Mon Sep 17 00:00:00 2001 From: David Metcalfe <80915+DavidMetcalfe@users.noreply.github.com> Date: Sat, 27 Jun 2026 17:51:27 -0700 Subject: [PATCH 4/7] fix(computer_use): add explicit encoding to /proc/version open() --- tools/computer_use/cua_backend.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/computer_use/cua_backend.py b/tools/computer_use/cua_backend.py index cbc4a17de4c..6d2f6b5c6ff 100644 --- a/tools/computer_use/cua_backend.py +++ b/tools/computer_use/cua_backend.py @@ -199,7 +199,7 @@ def _cua_no_overlay() -> bool: if not os.environ.get("DISPLAY"): return True try: - with open("/proc/version") as f: + with open("/proc/version", encoding="utf-8") as f: if "microsoft" in f.read().lower(): return True except Exception: From 3d846897147ba6ec257cbe930c0f8b855960d0e8 Mon Sep 17 00:00:00 2001 From: David Metcalfe <80915+DavidMetcalfe@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:47:23 -0700 Subject: [PATCH 5/7] fix(computer_use): address sweeper feedback on --no-overlay subprocess + manifest probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hermes-sweeper review #4701565902 (2026-07-15) flagged two consistency issues in `_cua_driver_supports_no_overlay` and one additive-config concern: 1. `cua-backend.py:260` — the `cua-driver --help` support probe inherited the full parent environment. cua-driver is a third-party binary; every other spawn site in this file (manifest probe at `:214`, MCP spawn at `:697`, install probe at `:997`) uses `_sanitize_subprocess_env(cua_driver_child_env())`. The `--help` probe should match. This was a low-impact leak (only help output exits), but inconsistency is the wrong default for a third-party subprocess. 2. `cua_backend.py:238` — when the manifest returned a `command` different from the input `driver_cmd` parameter (e.g. a relocated executable at `/opt/relocated/cua-driver` while the system binary is at `/usr/bin/cua-driver`), the support probe ran against `_CUA_DRIVER_CMD` (the default) instead of the manifest-discovered `command`. Two failure modes: - The wrapper binary supports `--no-overlay` but the system binary doesn't → probe returns False → overlay kept despite capability. - The system binary supports `--no-overlay` but the wrapper doesn't → probe returns True → MCP spawn crashes on the unknown flag. 3. The original commit bumped `_config_version` 31→32 for an additive default (`computer_use.no_overlay: None`). AGENTS.md specifies that additive defaults in existing sections are handled by deep merge and should NOT trigger a version bump. After cherry-picking onto current `origin/main` (which is already at 33), the bump is effectively dropped — resolved to main's 33. Changes: - Add `env=_sanitize_subprocess_env(cua_driver_child_env())` to the `--help` subprocess (with the same import + rationale comment as the manifest probe). - Pass `driver_cmd=command` (or `driver_cmd=driver_cmd` for the fallback path) into `_mcp_args_with_overlay_flag`, so the support probe runs against the binary that will actually be launched. Tests (3 new): - `test_help_probe_passes_sanitized_env` — verifies `subprocess.run` is called with an `env=` kwarg. - `test_manifest_command_drives_support_probe` — verifies the probe runs against the manifest command when it differs from the input driver_cmd. - `test_fallback_uses_input_driver_cmd_for_support_probe` — verifies the fallback path (no command in manifest) uses the input driver_cmd. - `test_probe_distinguishes_support_between_binaries` — sanity check that the lru_cache key on `driver_cmd` prevents cross-binary cache leakage. File-revert negative test confirmed all three of the new "manifest/probe" tests are load-bearing: with the pre-fix code, they fail (probe runs against the default binary instead of the resolved one); with the fix, they pass. 20/20 tests in `tests/computer_use/test_cua_no_overlay.py` green. `TestMcpInvocationResolution` (8/8) still green. Refs: sweeper review #4701565902 --- tests/computer_use/test_cua_no_overlay.py | 109 ++++++++++++++++++++++ tools/computer_use/cua_backend.py | 13 ++- 2 files changed, 120 insertions(+), 2 deletions(-) diff --git a/tests/computer_use/test_cua_no_overlay.py b/tests/computer_use/test_cua_no_overlay.py index 3629aed448d..207b66ce9d2 100644 --- a/tests/computer_use/test_cua_no_overlay.py +++ b/tests/computer_use/test_cua_no_overlay.py @@ -103,6 +103,115 @@ class TestDriverSupportsNoOverlay: 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): diff --git a/tools/computer_use/cua_backend.py b/tools/computer_use/cua_backend.py index 6d2f6b5c6ff..547604e8448 100644 --- a/tools/computer_use/cua_backend.py +++ b/tools/computer_use/cua_backend.py @@ -376,8 +376,12 @@ def _resolve_mcp_invocation( 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, _mcp_args_with_overlay_flag(args) - return command, _mcp_args_with_overlay_flag(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( @@ -399,10 +403,15 @@ def _cua_driver_supports_no_overlay(driver_cmd: str) -> bool: 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 From f957fe376080522a799621e40ea84692b8b72424 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 23 Jul 2026 00:45:26 -0500 Subject: [PATCH 6/7] fix(computer_use): default --no-overlay on macOS for idle CPU Auto-detect now disables the cursor overlay on darwin as well as headless/WSL2 Linux. After start_session, also call set_agent_cursor_enabled(false) when the policy is on so older drivers without --no-overlay still tear the overlay down. Co-authored-by: David Metcalfe <80915+DavidMetcalfe@users.noreply.github.com> --- hermes_cli/config.py | 15 ++++++------- tests/computer_use/test_cua_no_overlay.py | 24 +++++++++++++-------- tools/computer_use/cua_backend.py | 26 +++++++++++++++++------ 3 files changed, 41 insertions(+), 24 deletions(-) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index c5920a1d7f4..b83f7973221 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -3474,15 +3474,14 @@ 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 on screen but can consume - # significant CPU when idle (especially on Linux/WSL2 where the - # compositor path has no visual benefit). cua-driver ≥ 0.6.x - # supports --no-overlay. - # None = auto-detect (disable on Linux/headless, enable elsewhere) + # 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 (may cause idle CPU on some - # platforms — see #28152, #47032) + # False = always enable the overlay "no_overlay": None, }, diff --git a/tests/computer_use/test_cua_no_overlay.py b/tests/computer_use/test_cua_no_overlay.py index 207b66ce9d2..0288bb4a094 100644 --- a/tests/computer_use/test_cua_no_overlay.py +++ b/tests/computer_use/test_cua_no_overlay.py @@ -1,12 +1,12 @@ """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 headless -Linux / WSL2). +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 on headless/WSL2, explicit -override, version probe), not specific config snapshots. +These assert the behavior contract (auto-detect, explicit override, version +probe), not specific config snapshots. """ import os @@ -41,11 +41,11 @@ class TestNoOverlayFlag: patch("builtins.open", mock_open(read_data=fake_version)): assert cua_backend._cua_no_overlay() is True - def test_default_macos_enables(self): - """Auto-detect: macOS => overlay enabled (visually useful).""" + 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 False + assert cua_backend._cua_no_overlay() is True def test_default_windows_enables(self): """Auto-detect: Windows => overlay enabled.""" @@ -68,10 +68,16 @@ class TestNoOverlayFlag: assert cua_backend._cua_no_overlay() is False def test_config_load_failure_falls_through_to_auto_detect(self): - """Unreadable config => auto-detect.""" + """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): diff --git a/tools/computer_use/cua_backend.py b/tools/computer_use/cua_backend.py index 547604e8448..f12ddc2d16b 100644 --- a/tools/computer_use/cua_backend.py +++ b/tools/computer_use/cua_backend.py @@ -176,10 +176,11 @@ 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 on headless Linux / WSL2 / - containers where it serves no visual purpose and can consume CPU - indefinitely (#28152, #47032). Explicit ``True`` / ``False`` in config - overrides auto-detection. + ``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 @@ -191,9 +192,11 @@ def _cua_no_overlay() -> bool: return bool(val) except Exception: pass - # Auto-detect: disable on headless Linux (no DISPLAY), WSL2, or - # containers. Desktop Linux with a running compositor keeps the - # overlay — it's visually useful there. + # 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"): @@ -1500,6 +1503,15 @@ 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. + if _cua_no_overlay(): + 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 From cdc123ec2f9043cd4a7e586c1b1843667a08601a Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 23 Jul 2026 01:10:21 -0500 Subject: [PATCH 7/7] fix(computer_use): only disable agent cursor after session handshake Guard the post-start set_agent_cursor_enabled on _session._started so call_tool cannot re-enter session.start() (matches the start_session lifecycle guard). --- tools/computer_use/cua_backend.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tools/computer_use/cua_backend.py b/tools/computer_use/cua_backend.py index f12ddc2d16b..546b51a6b41 100644 --- a/tools/computer_use/cua_backend.py +++ b/tools/computer_use/cua_backend.py @@ -1505,8 +1505,10 @@ class CuaDriverBackend(ComputerUseBackend): # 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. - if _cua_no_overlay(): + # 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: