mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
Merge pull request #69903 from NousResearch/bb/salvage-53841-no-overlay
fix(computer_use): disable cua-driver overlay by default on macOS/WSL (supersedes #53841)
This commit is contained in:
commit
93f8da55cb
4 changed files with 368 additions and 9 deletions
|
|
@ -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
|
||||
|
|
|
|||
247
tests/computer_use/test_cua_no_overlay.py
Normal file
247
tests/computer_use/test_cua_no_overlay.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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 ``<driver> --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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue