Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,471 @@
|
||||
"""Opt-in macOS smoke test for the installed cua-driver live MCP contract.
|
||||
|
||||
This script never installs, updates, or grants an existing browser profile. Start
|
||||
an isolated daemon separately, then point this script at its socket:
|
||||
|
||||
cua-driver serve --embedded --socket /tmp/hermes-cua-0-9-live.sock \
|
||||
--no-permissions-gate --no-overlay
|
||||
CUA_DRIVER_LIVE_SOCKET=/tmp/hermes-cua-0-9-live.sock \
|
||||
.venv/bin/python tests/computer_use/live_cua_0_9_smoke.py
|
||||
|
||||
The output deliberately excludes process IDs, window IDs, socket paths, and
|
||||
driver payloads. Each cell is classified as pass, structured_refusal,
|
||||
environment_unavailable, or unproven.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from mcp import ClientSession, StdioServerParameters
|
||||
from mcp.client.stdio import stdio_client
|
||||
|
||||
|
||||
def structured(result: Any) -> dict[str, Any]:
|
||||
value = getattr(result, "structuredContent", None)
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
dumped = result.model_dump(by_alias=True) if hasattr(result, "model_dump") else {}
|
||||
for key in ("structuredContent", "structured_content"):
|
||||
value = dumped.get(key)
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
for block in getattr(result, "content", []) or []:
|
||||
text = getattr(block, "text", None)
|
||||
if not isinstance(text, str):
|
||||
continue
|
||||
try:
|
||||
value = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
return {}
|
||||
|
||||
|
||||
def refusal_code(payload: dict[str, Any]) -> str | None:
|
||||
refusal = payload.get("refusal")
|
||||
return payload.get("code") or (
|
||||
refusal.get("code") if isinstance(refusal, dict) else None
|
||||
)
|
||||
|
||||
|
||||
def textedit_process_contains(pid: int, marker: str) -> bool:
|
||||
"""Read the exact throwaway process through the native AX script bridge."""
|
||||
script = """
|
||||
on run argv
|
||||
set targetPid to item 1 of argv as integer
|
||||
set markerText to item 2 of argv
|
||||
tell application "System Events"
|
||||
tell first application process whose unix id is targetPid
|
||||
set documentText to value of text area 1 of scroll area 1 of window 1
|
||||
end tell
|
||||
end tell
|
||||
return (documentText contains markerText) as text
|
||||
end run
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["osascript", "-e", script, "--", str(pid), marker],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return False
|
||||
return result.returncode == 0 and result.stdout.strip().lower() == "true"
|
||||
|
||||
|
||||
async def run_smoke(socket_path: str) -> dict[str, dict[str, Any]]:
|
||||
session_id = f"hermes-cua-live-{uuid.uuid4().hex[:8]}"
|
||||
params = StdioServerParameters(
|
||||
command="cua-driver",
|
||||
args=["mcp", "--embedded", "--socket", socket_path],
|
||||
)
|
||||
report: dict[str, dict[str, Any]] = {
|
||||
"foreground": {"classification": "unproven"},
|
||||
"typed_browser": {"classification": "unproven"},
|
||||
}
|
||||
launched_pid: int | None = None
|
||||
isolated_browser_pid: int | None = None
|
||||
browser_pid: int | None = None
|
||||
prior_foreground_pids: set[int] = set()
|
||||
file_descriptor, temporary_name = tempfile.mkstemp(
|
||||
prefix="hermes-cua-live-", suffix=".txt"
|
||||
)
|
||||
os.close(file_descriptor)
|
||||
smoke_path = Path(temporary_name)
|
||||
|
||||
try:
|
||||
async with stdio_client(params) as (read, write):
|
||||
async with ClientSession(read, write) as client:
|
||||
await client.initialize()
|
||||
await client.call_tool("start_session", {"session": session_id})
|
||||
try:
|
||||
before_windows = structured(
|
||||
await client.call_tool(
|
||||
"list_windows",
|
||||
{"on_screen_only": True, "session": session_id},
|
||||
)
|
||||
)
|
||||
prior_foreground_pids = {
|
||||
pid
|
||||
for row in before_windows.get("windows") or []
|
||||
if "textedit" in str(row.get("app_name") or "").lower()
|
||||
and isinstance((pid := row.get("pid")), int)
|
||||
}
|
||||
launched = structured(
|
||||
await client.call_tool(
|
||||
"launch_app",
|
||||
{
|
||||
"name": "TextEdit",
|
||||
"urls": [smoke_path.as_uri()],
|
||||
"creates_new_application_instance": True,
|
||||
"session": session_id,
|
||||
},
|
||||
)
|
||||
)
|
||||
launched_pid = launched.get("pid")
|
||||
windows = launched.get("windows") or []
|
||||
if isinstance(launched_pid, int) and not windows:
|
||||
await client.call_tool(
|
||||
"wait", {"seconds": 1, "session": session_id}
|
||||
)
|
||||
refreshed = structured(
|
||||
await client.call_tool(
|
||||
"list_windows",
|
||||
{"on_screen_only": True, "session": session_id},
|
||||
)
|
||||
)
|
||||
windows = [
|
||||
row
|
||||
for row in refreshed.get("windows") or []
|
||||
if row.get("pid") == launched_pid
|
||||
]
|
||||
window_id = windows[0].get("window_id") if windows else None
|
||||
if (
|
||||
not isinstance(launched_pid, int)
|
||||
or launched_pid in prior_foreground_pids
|
||||
or not isinstance(window_id, int)
|
||||
):
|
||||
report["foreground"] = {
|
||||
"classification": "environment_unavailable",
|
||||
"stage": "throwaway_target",
|
||||
}
|
||||
else:
|
||||
focus = await client.call_tool(
|
||||
"bring_to_front",
|
||||
{"pid": launched_pid, "window_id": window_id},
|
||||
)
|
||||
before = structured(
|
||||
await client.call_tool(
|
||||
"get_window_state",
|
||||
{
|
||||
"pid": launched_pid,
|
||||
"window_id": window_id,
|
||||
"session": session_id,
|
||||
},
|
||||
)
|
||||
)
|
||||
editor = next(
|
||||
(
|
||||
element
|
||||
for element in before.get("elements") or []
|
||||
if str(element.get("role") or "").lower()
|
||||
in {"axtextarea", "axtextfield"}
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not isinstance(editor, dict):
|
||||
report["foreground"] = {
|
||||
"classification": "unproven",
|
||||
"stage": "editor_discovery",
|
||||
}
|
||||
else:
|
||||
marker = "hermes foreground smoke"
|
||||
type_args = {
|
||||
"pid": launched_pid,
|
||||
"window_id": window_id,
|
||||
"element_index": editor.get("index"),
|
||||
"text": marker,
|
||||
"delivery_mode": "foreground",
|
||||
"session": session_id,
|
||||
}
|
||||
token = editor.get("element_token")
|
||||
if isinstance(token, str) and token:
|
||||
type_args["element_token"] = token
|
||||
typed = structured(
|
||||
await client.call_tool("type_text", type_args)
|
||||
)
|
||||
saved = structured(
|
||||
await client.call_tool(
|
||||
"hotkey",
|
||||
{
|
||||
"pid": launched_pid,
|
||||
"window_id": window_id,
|
||||
"keys": ["cmd", "s"],
|
||||
"delivery_mode": "foreground",
|
||||
"session": session_id,
|
||||
},
|
||||
)
|
||||
)
|
||||
await client.call_tool(
|
||||
"wait", {"seconds": 0.5, "session": session_id}
|
||||
)
|
||||
after = structured(
|
||||
await client.call_tool(
|
||||
"get_window_state",
|
||||
{
|
||||
"pid": launched_pid,
|
||||
"window_id": window_id,
|
||||
"session": session_id,
|
||||
},
|
||||
)
|
||||
)
|
||||
fresh_contains_marker = marker in json.dumps(
|
||||
after.get("elements") or []
|
||||
)
|
||||
native_document_confirmed = textedit_process_contains(
|
||||
launched_pid, marker
|
||||
)
|
||||
file_contains_marker = marker in smoke_path.read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
report["foreground"] = {
|
||||
"classification": (
|
||||
"pass"
|
||||
if not focus.isError
|
||||
and not refusal_code(typed)
|
||||
and not refusal_code(saved)
|
||||
and (
|
||||
typed.get("verified") is True
|
||||
or fresh_contains_marker
|
||||
or native_document_confirmed
|
||||
or file_contains_marker
|
||||
)
|
||||
else "unproven"
|
||||
),
|
||||
"focus_transport_ok": not focus.isError,
|
||||
"effect": typed.get("effect"),
|
||||
"verified": typed.get("verified"),
|
||||
"fresh_state": bool(after.get("elements")),
|
||||
"fresh_state_confirmed": fresh_contains_marker,
|
||||
"native_document_confirmed": (
|
||||
native_document_confirmed
|
||||
),
|
||||
"saved_file_confirmed": file_contains_marker,
|
||||
"action_schema_omitted_bring_to_front": (
|
||||
"bring_to_front" not in type_args
|
||||
),
|
||||
}
|
||||
|
||||
# Use only a driver-owned isolated profile. Never request,
|
||||
# mint, print, or persist an existing-profile grant token.
|
||||
listed = structured(
|
||||
await client.call_tool(
|
||||
"list_windows",
|
||||
{"on_screen_only": True, "session": session_id},
|
||||
)
|
||||
)
|
||||
browser_row = next(
|
||||
(
|
||||
row
|
||||
for row in listed.get("windows") or []
|
||||
if "chrome" in str(row.get("app_name") or "").lower()
|
||||
),
|
||||
None,
|
||||
)
|
||||
browser_pid = browser_row.get("pid") if browser_row else None
|
||||
browser_window = (
|
||||
browser_row.get("window_id") if browser_row else None
|
||||
)
|
||||
if not isinstance(browser_pid, int) or not isinstance(
|
||||
browser_window, int
|
||||
):
|
||||
report["typed_browser"] = {
|
||||
"classification": "environment_unavailable",
|
||||
"stage": "browser_target",
|
||||
}
|
||||
else:
|
||||
prepared = structured(
|
||||
await client.call_tool(
|
||||
"browser_prepare",
|
||||
{
|
||||
"pid": browser_pid,
|
||||
"window_id": browser_window,
|
||||
"allow_launch": True,
|
||||
"profile": {"mode": "isolated_new"},
|
||||
"session": session_id,
|
||||
},
|
||||
)
|
||||
)
|
||||
isolated_browser_pid = prepared.get("prepared_pid")
|
||||
code = refusal_code(prepared)
|
||||
if prepared.get("status") == "refused" or code:
|
||||
report["typed_browser"] = {
|
||||
"classification": "structured_refusal",
|
||||
"code": code,
|
||||
}
|
||||
else:
|
||||
prepared_pid = prepared.get("prepared_pid") or browser_pid
|
||||
await client.call_tool(
|
||||
"wait", {"seconds": 1, "session": session_id}
|
||||
)
|
||||
prepared_windows = structured(
|
||||
await client.call_tool(
|
||||
"list_windows",
|
||||
{
|
||||
"on_screen_only": True,
|
||||
"session": session_id,
|
||||
},
|
||||
)
|
||||
)
|
||||
prepared_row = next(
|
||||
(
|
||||
row
|
||||
for row in prepared_windows.get("windows") or []
|
||||
if row.get("pid") == prepared_pid
|
||||
),
|
||||
None,
|
||||
)
|
||||
prepared_window = (
|
||||
prepared_row.get("window_id")
|
||||
if prepared_row
|
||||
else browser_window
|
||||
)
|
||||
bound = structured(
|
||||
await client.call_tool(
|
||||
"get_browser_state",
|
||||
{
|
||||
"pid": prepared_pid,
|
||||
"window_id": prepared_window,
|
||||
"session": session_id,
|
||||
},
|
||||
)
|
||||
)
|
||||
tabs = bound.get("tabs") or []
|
||||
tab_id = tabs[0].get("tab_id") if tabs else None
|
||||
target_id = bound.get("target_id")
|
||||
if (
|
||||
bound.get("status") == "ok"
|
||||
and bound.get("binding_quality") == "exact"
|
||||
and bound.get("mutation_allowed") is True
|
||||
and isinstance(tab_id, str)
|
||||
and isinstance(target_id, str)
|
||||
):
|
||||
snapshot = structured(
|
||||
await client.call_tool(
|
||||
"get_browser_state",
|
||||
{
|
||||
"target_id": target_id,
|
||||
"tab_id": tab_id,
|
||||
"snapshot_format": "semantic_v2",
|
||||
"session": session_id,
|
||||
},
|
||||
)
|
||||
)
|
||||
navigated = structured(
|
||||
await client.call_tool(
|
||||
"browser_navigate",
|
||||
{
|
||||
"target_id": target_id,
|
||||
"tab_id": tab_id,
|
||||
"url": "about:blank",
|
||||
"session": session_id,
|
||||
},
|
||||
)
|
||||
)
|
||||
fresh = structured(
|
||||
await client.call_tool(
|
||||
"get_browser_state",
|
||||
{
|
||||
"target_id": target_id,
|
||||
"tab_id": tab_id,
|
||||
"snapshot_format": "semantic_v2",
|
||||
"session": session_id,
|
||||
},
|
||||
)
|
||||
)
|
||||
report["typed_browser"] = {
|
||||
"classification": "pass",
|
||||
"exact_binding": True,
|
||||
"mutation_allowed": True,
|
||||
"initial_snapshot": snapshot.get("status")
|
||||
in (None, "ok"),
|
||||
"mutation_transport": navigated.get("status")
|
||||
in (None, "ok"),
|
||||
"fresh_verification": fresh.get("status")
|
||||
in (None, "ok"),
|
||||
}
|
||||
else:
|
||||
report["typed_browser"] = {
|
||||
"classification": "unproven",
|
||||
"stage": "exact_binding",
|
||||
"code": refusal_code(bound),
|
||||
}
|
||||
finally:
|
||||
if (
|
||||
isinstance(launched_pid, int)
|
||||
and launched_pid not in prior_foreground_pids
|
||||
):
|
||||
await client.call_tool(
|
||||
"kill_app", {"pid": launched_pid, "session": session_id}
|
||||
)
|
||||
if (
|
||||
isinstance(isolated_browser_pid, int)
|
||||
and isolated_browser_pid != browser_pid
|
||||
):
|
||||
await client.call_tool(
|
||||
"kill_app",
|
||||
{"pid": isolated_browser_pid, "session": session_id},
|
||||
)
|
||||
await client.call_tool("end_session", {"session": session_id})
|
||||
finally:
|
||||
smoke_path.unlink(missing_ok=True)
|
||||
return report
|
||||
|
||||
|
||||
def main() -> int:
|
||||
report: dict[str, dict[str, Any]] = {
|
||||
"foreground": {"classification": "environment_unavailable"},
|
||||
"typed_browser": {"classification": "environment_unavailable"},
|
||||
}
|
||||
if sys.platform != "darwin":
|
||||
for cell in report.values():
|
||||
cell["stage"] = "macos_host_required"
|
||||
else:
|
||||
socket_path = os.environ.get(
|
||||
"CUA_DRIVER_LIVE_SOCKET", "/tmp/hermes-cua-0-9-live.sock"
|
||||
)
|
||||
if not Path(socket_path).is_socket():
|
||||
for cell in report.values():
|
||||
cell["stage"] = "isolated_daemon_required"
|
||||
else:
|
||||
try:
|
||||
report = asyncio.run(run_smoke(socket_path))
|
||||
except Exception as exc: # pragma: no cover - host/driver boundary
|
||||
report = {
|
||||
"foreground": {
|
||||
"classification": "environment_unavailable",
|
||||
"stage": "driver_connection",
|
||||
"error_type": type(exc).__name__,
|
||||
},
|
||||
"typed_browser": {
|
||||
"classification": "environment_unavailable",
|
||||
"stage": "driver_connection",
|
||||
"error_type": type(exc).__name__,
|
||||
},
|
||||
}
|
||||
print(json.dumps(report, indent=2, sort_keys=True))
|
||||
return int(any(cell.get("classification") != "pass" for cell in report.values()))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Tests for cua-driver subprocess teardown on interpreter exit.
|
||||
|
||||
``CuaDriverBackend`` spawns a long-lived ``cua-driver`` child process and
|
||||
caches it for the life of the Hermes process. Nothing ever tore it down, so
|
||||
the driver outlived the session that started it (#28152 item 3 — "Hermes does
|
||||
not keep the driver alive after tool completion"). #69903 fixed the *overlay*
|
||||
redraw loop that made the lingering process burn CPU, but not the lingering
|
||||
process itself.
|
||||
|
||||
``tools/computer_use/tool.py`` registers an ``atexit`` hook that stops the
|
||||
cached backend, mirroring ``browser_tool``'s
|
||||
``atexit.register(_emergency_cleanup_all_sessions)``.
|
||||
|
||||
These assert the behavior contract — the hook is registered, it stops a live
|
||||
backend, it is a no-op when nothing was ever started, and it never raises out
|
||||
of ``atexit`` — not a snapshot of the module's source.
|
||||
"""
|
||||
|
||||
import atexit
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from tools.computer_use import tool as cu_tool
|
||||
|
||||
|
||||
class TestAtexitTeardown:
|
||||
def test_shutdown_stops_a_live_backend(self):
|
||||
"""A cached backend is stopped when the interpreter exits."""
|
||||
fake = MagicMock()
|
||||
with patch.object(cu_tool, "_backend", fake):
|
||||
cu_tool._shutdown_backend_atexit()
|
||||
fake.stop.assert_called_once()
|
||||
|
||||
|
||||
|
||||
|
||||
def test_shutdown_stops_every_session_backend(self):
|
||||
"""Session-scoped caches are all drained, not only the legacy slot."""
|
||||
first = MagicMock()
|
||||
second = MagicMock()
|
||||
with patch.object(cu_tool, "_backend", None), \
|
||||
patch.object(cu_tool, "_backends", {"one": first, "two": second}), \
|
||||
patch.object(cu_tool, "_backend_call_locks", {}):
|
||||
cu_tool._shutdown_backend_atexit()
|
||||
first.stop.assert_called_once()
|
||||
second.stop.assert_called_once()
|
||||
assert cu_tool._backends == {}
|
||||
|
||||
def test_hook_is_registered_with_atexit(self):
|
||||
"""Importing the tool module registers the teardown hook.
|
||||
|
||||
Verified by unregistering and re-registering: atexit.unregister only
|
||||
removes a function that was actually registered, so a successful
|
||||
round-trip proves the import-time registration happened.
|
||||
"""
|
||||
atexit.unregister(cu_tool._shutdown_backend_atexit)
|
||||
try:
|
||||
with patch.object(cu_tool, "_backend", MagicMock()) as fake:
|
||||
# Re-register and fire the full atexit chain the way the
|
||||
# interpreter would, then confirm our hook ran.
|
||||
atexit.register(cu_tool._shutdown_backend_atexit)
|
||||
atexit._run_exitfuncs()
|
||||
fake.stop.assert_called_once()
|
||||
finally:
|
||||
atexit.unregister(cu_tool._shutdown_backend_atexit)
|
||||
atexit.register(cu_tool._shutdown_backend_atexit)
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Regression test: the cua-driver CLI-fallback transport must sanitize the
|
||||
subprocess environment like every other cua-driver spawn site.
|
||||
|
||||
``_CuaDriverSession._call_tool_via_cli()`` (the EAGAIN/silent-empty MCP
|
||||
fallback) invoked ``subprocess.run`` with no ``env=`` at all, so the
|
||||
third-party ``cua-driver`` binary inherited the full, unsanitized parent
|
||||
environment — including provider API keys and other Hermes-managed
|
||||
secrets that ``_lifecycle_coro``'s primary MCP spawn already strips via
|
||||
``_sanitize_subprocess_env(cua_driver_child_env())``.
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from tools.computer_use.cua_backend import _CuaDriverSession
|
||||
|
||||
|
||||
def _make_session() -> _CuaDriverSession:
|
||||
# _call_tool_via_cli() doesn't touch any instance state (bridge/session/
|
||||
# capabilities); bypass __init__ so the test doesn't need a real
|
||||
# _AsyncBridge.
|
||||
return object.__new__(_CuaDriverSession)
|
||||
|
||||
|
||||
def _fake_completed_process(stdout: str) -> MagicMock:
|
||||
proc = MagicMock()
|
||||
proc.stdout = stdout
|
||||
proc.stderr = ""
|
||||
proc.returncode = 0
|
||||
return proc
|
||||
|
||||
|
||||
def test_cli_fallback_strips_provider_secret_from_subprocess_env(monkeypatch):
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "«redacted:sk-…»")
|
||||
monkeypatch.setenv("PATH", "/usr/bin:/bin")
|
||||
monkeypatch.setattr(
|
||||
"tools.computer_use.cua_backend.resolve_cua_driver_cmd",
|
||||
lambda: "/resolved/cua-driver",
|
||||
)
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
captured["env"] = kwargs.get("env")
|
||||
return _fake_completed_process(json.dumps({"tree_markdown": "root"}))
|
||||
|
||||
monkeypatch.setattr("subprocess.run", fake_run)
|
||||
|
||||
session = _make_session()
|
||||
result = session._call_tool_via_cli("list_windows", {}, timeout=5.0)
|
||||
|
||||
assert result["isError"] is False
|
||||
assert captured["env"] is not None, "subprocess.run must receive an explicit env="
|
||||
assert "ANTHROPIC_API_KEY" not in captured["env"]
|
||||
# Sanitization filters secrets, not everything — an ordinary var survives.
|
||||
# Original PATH entries are preserved; the hermes console-script dir may
|
||||
# be prepended (see _sanitize_subprocess_env, issue #92998).
|
||||
assert captured["env"].get("PATH", "").endswith("/usr/bin:/bin")
|
||||
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
"""Tests for the cua-driver --no-overlay policy.
|
||||
|
||||
cua-driver's cursor overlay rendering loop can consume CPU indefinitely when
|
||||
idle (#28152, #47032), and on Linux/X11 its fullscreen always-on-top overlay
|
||||
window can wedge the desktop when a session ends uncleanly. Hermes passes
|
||||
``--no-overlay`` to suppress it when the ``computer_use.no_overlay`` config is
|
||||
enabled (or auto-detected on macOS, headless Linux / WSL2, and Linux X11).
|
||||
|
||||
These assert the behavior contract (auto-detect, explicit override, version
|
||||
probe), not specific config snapshots.
|
||||
"""
|
||||
|
||||
import os
|
||||
from unittest.mock import MagicMock, mock_open, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tools.computer_use import cua_backend
|
||||
|
||||
|
||||
class TestNoOverlayFlag:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.macos_only
|
||||
def test_config_load_failure_falls_through_to_auto_detect_macos(self):
|
||||
"""Unreadable config => auto-detect (macOS defaults to overlay off).
|
||||
|
||||
macOS-only: the auto-detect verdict IS ``sys.platform == "darwin"``,
|
||||
so a patched platform would only re-assert the patch.
|
||||
"""
|
||||
with patch("hermes_cli.config.load_config",
|
||||
side_effect=RuntimeError("boom")):
|
||||
assert cua_backend._cua_no_overlay() is True
|
||||
|
||||
@pytest.mark.linux_only
|
||||
def test_config_load_failure_falls_through_to_auto_detect_linux(self, monkeypatch):
|
||||
"""Unreadable config must not raise; headless Linux auto-detects off.
|
||||
|
||||
Linux-only: the auto-detect branch here keys off ``DISPLAY`` and
|
||||
``/proc/version``, neither of which exists to be probed elsewhere.
|
||||
"""
|
||||
monkeypatch.delenv("DISPLAY", raising=False)
|
||||
with patch("hermes_cli.config.load_config",
|
||||
side_effect=RuntimeError("boom")):
|
||||
assert cua_backend._cua_no_overlay() is True
|
||||
|
||||
@pytest.mark.linux_only
|
||||
def test_linux_x11_auto_detects_off(self, monkeypatch):
|
||||
"""X11 desktop (DISPLAY set, no Wayland) defaults the overlay off.
|
||||
|
||||
The X11 overlay is a fullscreen always-on-top all-workspaces window
|
||||
that can get stuck over every workspace after an unclean session end,
|
||||
wedging desktop input until the app restarts. Config must not need to
|
||||
opt out per-machine.
|
||||
"""
|
||||
monkeypatch.setenv("DISPLAY", ":0")
|
||||
monkeypatch.delenv("WAYLAND_DISPLAY", raising=False)
|
||||
monkeypatch.delenv("XDG_SESSION_TYPE", raising=False)
|
||||
with patch("hermes_cli.config.load_config", return_value={}):
|
||||
assert cua_backend._cua_no_overlay() is True
|
||||
|
||||
@pytest.mark.linux_only
|
||||
def test_linux_x11_explicit_session_type_also_off(self, monkeypatch):
|
||||
"""XDG_SESSION_TYPE=x11 without Wayland env is still X11."""
|
||||
monkeypatch.setenv("DISPLAY", ":0")
|
||||
monkeypatch.setenv("XDG_SESSION_TYPE", "x11")
|
||||
monkeypatch.delenv("WAYLAND_DISPLAY", raising=False)
|
||||
with patch("hermes_cli.config.load_config", return_value={}):
|
||||
assert cua_backend._cua_no_overlay() is True
|
||||
|
||||
@pytest.mark.linux_only
|
||||
def test_linux_wayland_keeps_overlay(self, monkeypatch):
|
||||
"""Wayland desktop keeps the overlay: the compositor owns the
|
||||
overlay surface lifecycle, so it cannot get stuck above every
|
||||
workspace the way an X11 window can."""
|
||||
monkeypatch.setenv("DISPLAY", ":0")
|
||||
monkeypatch.setenv("WAYLAND_DISPLAY", "wayland-0")
|
||||
monkeypatch.setenv("XDG_SESSION_TYPE", "wayland")
|
||||
with patch("hermes_cli.config.load_config", return_value={}):
|
||||
assert cua_backend._cua_no_overlay() is False
|
||||
|
||||
@pytest.mark.linux_only
|
||||
def test_linux_x11_explicit_false_overrides_auto_detect(self, monkeypatch):
|
||||
"""An explicit ``no_overlay: false`` must restore the cursor even on
|
||||
X11 — auto-detection is the default, never a hard lock."""
|
||||
monkeypatch.setenv("DISPLAY", ":0")
|
||||
monkeypatch.delenv("WAYLAND_DISPLAY", raising=False)
|
||||
monkeypatch.delenv("XDG_SESSION_TYPE", raising=False)
|
||||
with patch("hermes_cli.config.load_config",
|
||||
return_value={"computer_use": {"no_overlay": False}}):
|
||||
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_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_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_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
|
||||
|
||||
|
||||
class TestEmbeddedDaemonOverlayFlag:
|
||||
def test_serve_process_disables_overlay_when_policy_requires_it(self):
|
||||
daemon = cua_backend._EmbeddedCuaDaemon("/usr/bin/cua-driver", "unrestricted")
|
||||
process = MagicMock()
|
||||
process.poll.return_value = None
|
||||
status = MagicMock(returncode=0)
|
||||
|
||||
with patch.object(
|
||||
cua_backend,
|
||||
"_resolve_mcp_invocation",
|
||||
return_value=("/usr/bin/cua-driver", ["mcp"]),
|
||||
), patch.object(
|
||||
cua_backend, "_cua_no_overlay", return_value=True,
|
||||
), patch.object(
|
||||
cua_backend, "_cua_driver_supports_no_overlay", return_value=True,
|
||||
), patch.object(
|
||||
cua_backend.subprocess, "Popen", return_value=process,
|
||||
) as popen, patch.object(
|
||||
cua_backend.subprocess, "run", return_value=status,
|
||||
), patch.object(cua_backend.threading, "Thread"):
|
||||
daemon.start()
|
||||
|
||||
command = popen.call_args.args[0]
|
||||
assert command[:2] == ["/usr/bin/cua-driver", "serve"]
|
||||
assert "--no-overlay" in command
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Behavior contracts for computer_use latency knobs."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from tools.computer_use import cua_backend
|
||||
from tools.computer_use import tool as cu_tool
|
||||
|
||||
|
||||
def test_max_image_dimension_default():
|
||||
with patch("hermes_cli.config.load_config", return_value={}):
|
||||
assert cua_backend._computer_use_max_image_dimension() == 1456
|
||||
|
||||
|
||||
|
||||
|
||||
def test_capture_after_mode_default_som():
|
||||
with patch("hermes_cli.config.load_config", return_value={}):
|
||||
assert cu_tool._capture_after_mode() == "som"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_aux_vision_route_caches_per_provider_model(monkeypatch):
|
||||
cu_tool._AUX_VISION_ROUTE_CACHE.clear()
|
||||
calls = {"n": 0}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"agent.auxiliary_client._read_main_provider", lambda: "openai"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"agent.auxiliary_client._read_main_model", lambda: "gpt-test"
|
||||
)
|
||||
|
||||
def fake_load():
|
||||
calls["n"] += 1
|
||||
return {"auxiliary": {"vision": {}}}
|
||||
|
||||
monkeypatch.setattr("hermes_cli.config.load_config", fake_load)
|
||||
monkeypatch.setattr(
|
||||
"tools.computer_use.vision_routing.should_route_capture_to_aux_vision",
|
||||
lambda *a, **k: True,
|
||||
)
|
||||
|
||||
assert cu_tool._should_route_through_aux_vision() is True
|
||||
assert cu_tool._should_route_through_aux_vision() is True
|
||||
assert calls["n"] == 1
|
||||
@@ -0,0 +1,248 @@
|
||||
"""Regression tests: every remaining cua-driver spawn site must sanitize the
|
||||
subprocess environment.
|
||||
|
||||
PR #58889 fixed the CLI-fallback transport; review of that fix found four
|
||||
sibling spawn sites still handing the third-party ``cua-driver`` binary the
|
||||
full parent environment (provider API keys included):
|
||||
|
||||
- ``cua_backend._resolve_mcp_invocation`` (``cua-driver manifest``) — no
|
||||
``env=`` at all
|
||||
- ``cua_backend.cua_driver_update_check`` (``check-update --json``) —
|
||||
telemetry env but no secret sanitization
|
||||
- ``doctor._drive_health_report`` (``<binary> mcp``) — telemetry env only
|
||||
- ``permissions._run`` (every permission probe) — telemetry env only
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
SECRET = "sk-super-secret-should-not-leak"
|
||||
CREATE_NO_WINDOW = 0x08000000
|
||||
|
||||
|
||||
def _fake_completed_process(stdout: str) -> MagicMock:
|
||||
proc = MagicMock()
|
||||
proc.stdout = stdout
|
||||
proc.stderr = ""
|
||||
proc.returncode = 0
|
||||
return proc
|
||||
|
||||
|
||||
def _capture_run(captured, stdout=""):
|
||||
def fake_run(cmd, **kwargs):
|
||||
captured["cmd"] = cmd
|
||||
captured["env"] = kwargs.get("env")
|
||||
captured["creationflags"] = kwargs.get("creationflags")
|
||||
return _fake_completed_process(stdout)
|
||||
return fake_run
|
||||
|
||||
|
||||
def _assert_sanitized(captured):
|
||||
env = captured["env"]
|
||||
assert env is not None, "subprocess must receive an explicit env="
|
||||
assert "ANTHROPIC_API_KEY" not in env
|
||||
# Sanitization filters secrets, not everything — ordinary vars survive.
|
||||
_assert_path_preserved(env)
|
||||
# Confirms the telemetry helper still ran (default: telemetry disabled).
|
||||
assert env.get("CUA_DRIVER_RS_TELEMETRY_ENABLED") == "0"
|
||||
|
||||
|
||||
def _assert_path_preserved(env):
|
||||
"""Original PATH entries survive sanitization; the hermes console-script
|
||||
dir may be prepended (see _sanitize_subprocess_env, issue #92998) so we
|
||||
assert the contract, not byte equality."""
|
||||
from tools.environments.local import _resolve_hermes_bin_dir
|
||||
|
||||
path_val = env.get("PATH", "")
|
||||
assert path_val.endswith("/usr/bin:/bin"), path_val
|
||||
hermes_bin = _resolve_hermes_bin_dir()
|
||||
if hermes_bin and path_val != "/usr/bin:/bin":
|
||||
assert path_val.startswith(hermes_bin + os.pathsep), path_val
|
||||
|
||||
|
||||
def _patch_windows_hide_flags(monkeypatch, module):
|
||||
"""Pin the ``windows_hide_flags()`` seam so the console-hiding assertion
|
||||
is host-independent.
|
||||
|
||||
``windows_hide_flags`` is our own platform probe (CREATE_NO_WINDOW on
|
||||
Windows, ``0`` elsewhere). Patching that seam — rather than lying to the
|
||||
interpreter about ``sys.platform`` — keeps the real subject of these
|
||||
tests (does the spawn site forward its result to ``creationflags=``?)
|
||||
covered on every host.
|
||||
"""
|
||||
monkeypatch.setattr(
|
||||
module, "windows_hide_flags", lambda: CREATE_NO_WINDOW, raising=False
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_mcp_invocation_sanitizes_env(monkeypatch):
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", SECRET)
|
||||
monkeypatch.setenv("PATH", "/usr/bin:/bin")
|
||||
monkeypatch.delenv("HERMES_CUA_TELEMETRY", raising=False)
|
||||
|
||||
from tools.computer_use import cua_backend
|
||||
|
||||
captured = {}
|
||||
_patch_windows_hide_flags(monkeypatch, cua_backend)
|
||||
manifest = json.dumps({"mcp_invocation": {"command": "cua-driver", "args": ["mcp"]}})
|
||||
monkeypatch.setattr(
|
||||
cua_backend.subprocess, "run", _capture_run(captured, stdout=manifest)
|
||||
)
|
||||
|
||||
cmd, args = cua_backend._resolve_mcp_invocation("cua-driver")
|
||||
assert cmd == "cua-driver"
|
||||
_assert_sanitized(captured)
|
||||
assert captured["creationflags"] == CREATE_NO_WINDOW
|
||||
|
||||
|
||||
def test_update_check_sanitizes_env(monkeypatch):
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", SECRET)
|
||||
monkeypatch.setenv("PATH", "/usr/bin:/bin")
|
||||
monkeypatch.delenv("HERMES_CUA_TELEMETRY", raising=False)
|
||||
|
||||
from tools.computer_use import cua_backend
|
||||
|
||||
captured = {}
|
||||
_patch_windows_hide_flags(monkeypatch, cua_backend)
|
||||
payload = json.dumps({
|
||||
"current_version": "1.0.0",
|
||||
"latest_version": "1.0.0",
|
||||
"update_available": False,
|
||||
})
|
||||
# PATH is pinned to /usr/bin:/bin above, so the driver won't resolve;
|
||||
# pin it so the check reaches the (sanitized) subprocess spawn.
|
||||
monkeypatch.setattr(
|
||||
cua_backend, "resolve_cua_driver_cmd", lambda *a, **k: "cua-driver"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cua_backend.subprocess, "run", _capture_run(captured, stdout=payload)
|
||||
)
|
||||
|
||||
cua_backend.cua_driver_update_check(timeout=1.0)
|
||||
_assert_sanitized(captured)
|
||||
assert captured["creationflags"] == CREATE_NO_WINDOW
|
||||
|
||||
|
||||
def test_cli_fallback_sanitizes_env_and_hides_console_on_windows(monkeypatch):
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", SECRET)
|
||||
monkeypatch.setenv("PATH", "/usr/bin:/bin")
|
||||
monkeypatch.delenv("HERMES_CUA_TELEMETRY", raising=False)
|
||||
|
||||
from tools.computer_use import cua_backend
|
||||
|
||||
captured = {}
|
||||
_patch_windows_hide_flags(monkeypatch, cua_backend)
|
||||
# Hermetic CI has no cua-driver binary; pin the resolver so the test
|
||||
# exercises the spawn-env path instead of the install-hint early exit.
|
||||
monkeypatch.setattr(
|
||||
cua_backend, "resolve_cua_driver_cmd", lambda override=None: "cua-driver"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cua_backend.subprocess,
|
||||
"run",
|
||||
_capture_run(captured, stdout=json.dumps({"tree_markdown": "root"})),
|
||||
)
|
||||
|
||||
session = object.__new__(cua_backend._CuaDriverSession)
|
||||
result = session._call_tool_via_cli("list_windows", {}, timeout=5.0)
|
||||
|
||||
assert result["isError"] is False
|
||||
_assert_sanitized(captured)
|
||||
assert captured["creationflags"] == CREATE_NO_WINDOW
|
||||
|
||||
|
||||
def test_permissions_run_sanitizes_env(monkeypatch):
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", SECRET)
|
||||
monkeypatch.setenv("PATH", "/usr/bin:/bin")
|
||||
monkeypatch.delenv("HERMES_CUA_TELEMETRY", raising=False)
|
||||
|
||||
from tools.computer_use import permissions
|
||||
|
||||
captured = {}
|
||||
monkeypatch.setattr(
|
||||
permissions.subprocess, "run", _capture_run(captured, stdout="{}")
|
||||
)
|
||||
|
||||
permissions._run("cua-driver", "doctor", "--json", timeout=1.0)
|
||||
_assert_sanitized(captured)
|
||||
|
||||
|
||||
|
||||
|
||||
def test_doctor_spawn_sanitizes_env_and_hides_console_on_windows(monkeypatch):
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", SECRET)
|
||||
monkeypatch.setenv("PATH", "/usr/bin:/bin")
|
||||
monkeypatch.delenv("HERMES_CUA_TELEMETRY", raising=False)
|
||||
|
||||
from tools.computer_use import doctor
|
||||
|
||||
captured = {}
|
||||
_patch_windows_hide_flags(monkeypatch, doctor)
|
||||
proc = MagicMock()
|
||||
proc.stdout.readline.side_effect = [
|
||||
json.dumps({"jsonrpc": "2.0", "id": 1, "result": {}}),
|
||||
json.dumps({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"result": {
|
||||
"structuredContent": {
|
||||
"schema_version": "1",
|
||||
"overall": "ok",
|
||||
"checks": [],
|
||||
}
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
def fake_popen(cmd, **kwargs):
|
||||
captured["cmd"] = cmd
|
||||
captured["env"] = kwargs.get("env")
|
||||
captured["creationflags"] = kwargs.get("creationflags")
|
||||
return proc
|
||||
|
||||
monkeypatch.setattr(doctor.subprocess, "Popen", fake_popen)
|
||||
|
||||
report = doctor._drive_health_report("cua-driver", timeout=1.0)
|
||||
|
||||
assert report["overall"] == "ok"
|
||||
_assert_sanitized(captured)
|
||||
assert captured["creationflags"] == CREATE_NO_WINDOW
|
||||
|
||||
|
||||
def test_doctor_sanitized_env_helper(monkeypatch):
|
||||
"""The doctor MCP spawn site must pass the sanitized env to Popen.
|
||||
|
||||
Behavioral check: intercept subprocess.Popen at the `_open_mcp` spawn
|
||||
seam and assert the env it receives strips secrets and applies the
|
||||
telemetry opt-out (no source-text inspection — that breaks on any
|
||||
refactor with identical runtime behavior)."""
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", SECRET)
|
||||
monkeypatch.setenv("PATH", "/usr/bin:/bin")
|
||||
monkeypatch.delenv("HERMES_CUA_TELEMETRY", raising=False)
|
||||
|
||||
from tools.computer_use import doctor
|
||||
|
||||
env = doctor._sanitized_cua_env()
|
||||
assert "ANTHROPIC_API_KEY" not in env
|
||||
_assert_path_preserved(env)
|
||||
assert env.get("CUA_DRIVER_RS_TELEMETRY_ENABLED") == "0"
|
||||
|
||||
# The Popen spawn site must actually use the sanitized helper.
|
||||
captured = {}
|
||||
|
||||
class _FakeProc:
|
||||
stdin = None
|
||||
stdout = None
|
||||
stderr = None
|
||||
|
||||
def _fake_popen(*args, **kwargs):
|
||||
captured["env"] = kwargs.get("env")
|
||||
return _FakeProc()
|
||||
|
||||
monkeypatch.setattr(doctor.subprocess, "Popen", _fake_popen)
|
||||
doctor._open_mcp("cua-driver")
|
||||
spawn_env = captured["env"]
|
||||
assert spawn_env is not None, "_open_mcp must pass an explicit env"
|
||||
assert "ANTHROPIC_API_KEY" not in spawn_env
|
||||
assert spawn_env.get("CUA_DRIVER_RS_TELEMETRY_ENABLED") == "0"
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Tests for the cua-driver telemetry opt-in policy.
|
||||
|
||||
cua-driver ships anonymous PostHog telemetry ENABLED by default upstream.
|
||||
Hermes disables it unless the user opts in via
|
||||
``computer_use.cua_telemetry: true``. The policy is applied by injecting
|
||||
``CUA_DRIVER_RS_TELEMETRY_ENABLED=0`` into every cua-driver child env.
|
||||
|
||||
These assert the behavior contract (default disables, opt-in leaves the var
|
||||
untouched, config failure fails safe toward disabled), not specific config
|
||||
snapshots.
|
||||
"""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from tools.computer_use import cua_backend
|
||||
|
||||
|
||||
_VAR = "CUA_DRIVER_RS_TELEMETRY_ENABLED"
|
||||
|
||||
|
||||
class TestTelemetryDisabledFlag:
|
||||
|
||||
def test_explicit_false_disables(self):
|
||||
with patch("hermes_cli.config.load_config",
|
||||
return_value={"computer_use": {"cua_telemetry": False}}):
|
||||
assert cua_backend._cua_telemetry_disabled() is True
|
||||
|
||||
|
||||
def test_config_load_failure_fails_safe(self):
|
||||
# Unreadable config => default to disabling telemetry (privacy-safe).
|
||||
with patch("hermes_cli.config.load_config", side_effect=RuntimeError("boom")):
|
||||
assert cua_backend._cua_telemetry_disabled() is True
|
||||
|
||||
|
||||
|
||||
class TestChildEnv:
|
||||
def test_disabled_injects_var_zero(self):
|
||||
with patch.object(cua_backend, "_cua_telemetry_disabled", return_value=True):
|
||||
env = cua_backend.cua_driver_child_env({"PATH": "/usr/bin"})
|
||||
assert env[_VAR] == "0"
|
||||
# base env is preserved
|
||||
assert env["PATH"] == "/usr/bin"
|
||||
|
||||
|
||||
|
||||
def test_disabled_overrides_inherited_enabled(self):
|
||||
# Even if the parent process had telemetry enabled, the default policy
|
||||
# forces it off in the child.
|
||||
with patch.object(cua_backend, "_cua_telemetry_disabled", return_value=True):
|
||||
env = cua_backend.cua_driver_child_env({_VAR: "1"})
|
||||
assert env[_VAR] == "0"
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from tools.computer_use import cua_backend
|
||||
|
||||
|
||||
_VAR = "CUA_DRIVER_RS_ENABLE_WAYLAND"
|
||||
|
||||
|
||||
def _child_env(base_env, native_wayland):
|
||||
config = {"computer_use": {"native_wayland": native_wayland}}
|
||||
with patch("hermes_cli.config.load_config", return_value=config), \
|
||||
patch.object(cua_backend.sys, "platform", "linux"):
|
||||
return cua_backend.cua_driver_child_env(base_env)
|
||||
|
||||
|
||||
def test_configured_native_wayland_reaches_linux_wayland_child():
|
||||
assert _child_env({"WAYLAND_DISPLAY": "wayland-1"}, True)[_VAR] == "1"
|
||||
|
||||
|
||||
def test_native_wayland_not_injected_without_wayland_display_or_opt_in():
|
||||
assert _VAR not in _child_env({"DISPLAY": ":0"}, True)
|
||||
assert _VAR not in _child_env({"WAYLAND_DISPLAY": "wayland-1"}, False)
|
||||
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from tools.computer_use import cua_backend
|
||||
|
||||
|
||||
def test_wsl_windows_manifest_path_translates_to_drvfs():
|
||||
with patch("hermes_constants.is_wsl", return_value=True):
|
||||
assert cua_backend._wsl_windows_path_to_posix(
|
||||
r"C:\Users\Fernando\AppData\Local\cua-driver\cua-driver.exe"
|
||||
) == "/mnt/c/Users/Fernando/AppData/Local/cua-driver/cua-driver.exe"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_resolve_mcp_invocation_normalizes_windows_manifest_command_in_wsl():
|
||||
manifest = {
|
||||
"mcp_invocation": {
|
||||
"command": r"C:\Users\Fernando\AppData\Local\cua-driver\cua-driver.exe",
|
||||
"args": ["mcp"],
|
||||
}
|
||||
}
|
||||
proc = SimpleNamespace(returncode=0, stdout=json.dumps(manifest))
|
||||
with (
|
||||
patch.object(cua_backend.subprocess, "run", return_value=proc),
|
||||
patch("hermes_constants.is_wsl", return_value=True),
|
||||
):
|
||||
command, args = cua_backend._resolve_mcp_invocation("cua-driver")
|
||||
|
||||
assert command == "/mnt/c/Users/Fernando/AppData/Local/cua-driver/cua-driver.exe"
|
||||
assert args == ["mcp"]
|
||||
@@ -0,0 +1,431 @@
|
||||
"""Tests for ``tools.computer_use.doctor``.
|
||||
|
||||
The doctor module drives cua-driver's stable ``health_report`` MCP tool over
|
||||
stdio JSON-RPC and renders the structured response. Most of the surface is
|
||||
about parsing what cua-driver hands back, plus the exit-code contract
|
||||
downstream consumers (CI / `hermes update`) rely on:
|
||||
|
||||
* Exit 0 when overall == "ok"
|
||||
* Exit 1 when overall in ("degraded", "failed") — at least one check
|
||||
failed but the tool itself ran successfully
|
||||
* Exit 2 when the cua-driver binary is missing or the protocol breaks
|
||||
|
||||
We do NOT spin up a real cua-driver — that lives in the cua-driver
|
||||
integration test suite (libs/cua-driver/rust/tests/integration/
|
||||
test_health_report_mcp.py). Here we mock the subprocess and assert the
|
||||
Hermes-side adapter behaves correctly against the documented response
|
||||
shape.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from io import StringIO
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _fake_proc_with_responses(*responses: dict) -> MagicMock:
|
||||
"""Build a MagicMock subprocess.Popen handle that yields one JSON-RPC
|
||||
response per `readline()` call, then returns "" (EOF)."""
|
||||
lines = [json.dumps(r) + "\n" for r in responses] + [""]
|
||||
proc = MagicMock()
|
||||
proc.stdin = MagicMock()
|
||||
proc.stdout = MagicMock()
|
||||
proc.stdout.readline = MagicMock(side_effect=lines)
|
||||
proc.stderr = MagicMock()
|
||||
proc.stderr.read = MagicMock(return_value="")
|
||||
proc.wait = MagicMock(return_value=0)
|
||||
proc.kill = MagicMock()
|
||||
return proc
|
||||
|
||||
|
||||
def _ok_report() -> dict:
|
||||
"""Minimal well-formed health_report response."""
|
||||
return {
|
||||
"schema_version": "1",
|
||||
"platform": "darwin",
|
||||
"driver_version": "0.5.8",
|
||||
"overall": "ok",
|
||||
"checks": [
|
||||
{"name": "binary_version", "status": "pass", "message": "cua-driver 0.5.8"},
|
||||
{"name": "tcc_accessibility", "status": "pass", "message": "Accessibility is granted."},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _degraded_report() -> dict:
|
||||
"""Report with one failing check — overall=degraded."""
|
||||
return {
|
||||
"schema_version": "1",
|
||||
"platform": "darwin",
|
||||
"driver_version": "0.5.8",
|
||||
"overall": "degraded",
|
||||
"checks": [
|
||||
{"name": "binary_version", "status": "pass", "message": "cua-driver 0.5.8"},
|
||||
{
|
||||
"name": "bundle_identity",
|
||||
"status": "fail",
|
||||
"message": "Process has no CFBundleIdentifier.",
|
||||
"hint": "Run inside CuaDriver.app",
|
||||
"data": {"executable_path": "/tmp/cua-driver"},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _default_cli_version_matches_report(monkeypatch):
|
||||
"""Existing tests mock only the MCP Popen handshake. ``subprocess.run``
|
||||
(used for ``--version``) goes through Popen too, so without this the
|
||||
mock breaks version probing. Default to a CLI version that matches
|
||||
``_ok_report`` / ``_degraded_report`` (0.5.8); identity tests override.
|
||||
"""
|
||||
from tools.computer_use import doctor
|
||||
|
||||
monkeypatch.setattr(
|
||||
doctor,
|
||||
"_read_cli_version",
|
||||
lambda binary, timeout=5.0: "cua-driver 0.5.8",
|
||||
)
|
||||
|
||||
|
||||
# ── exit codes ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDoctorExitCodes:
|
||||
def test_ok_exits_0(self):
|
||||
from tools.computer_use import doctor
|
||||
|
||||
proc = _fake_proc_with_responses(
|
||||
{"jsonrpc": "2.0", "id": 1, "result": {}},
|
||||
{"jsonrpc": "2.0", "id": 2, "result": {"structuredContent": _ok_report()}},
|
||||
)
|
||||
with patch("shutil.which", return_value="/fake/cua-driver"), \
|
||||
patch("subprocess.Popen", return_value=proc), \
|
||||
patch("sys.stdout", new_callable=StringIO):
|
||||
code = doctor.run_doctor()
|
||||
assert code == 0
|
||||
|
||||
def test_degraded_exits_1(self):
|
||||
from tools.computer_use import doctor
|
||||
|
||||
proc = _fake_proc_with_responses(
|
||||
{"jsonrpc": "2.0", "id": 1, "result": {}},
|
||||
{"jsonrpc": "2.0", "id": 2, "result": {"structuredContent": _degraded_report()}},
|
||||
)
|
||||
with patch("shutil.which", return_value="/fake/cua-driver"), \
|
||||
patch("subprocess.Popen", return_value=proc), \
|
||||
patch("sys.stdout", new_callable=StringIO):
|
||||
code = doctor.run_doctor()
|
||||
assert code == 1
|
||||
|
||||
def test_failed_overall_exits_1(self):
|
||||
"""`failed` overall (every check failed) is also exit 1, not 2 —
|
||||
the tool ran successfully; the diagnosis was bad."""
|
||||
from tools.computer_use import doctor
|
||||
|
||||
report = _degraded_report()
|
||||
report["overall"] = "failed"
|
||||
proc = _fake_proc_with_responses(
|
||||
{"jsonrpc": "2.0", "id": 1, "result": {}},
|
||||
{"jsonrpc": "2.0", "id": 2, "result": {"structuredContent": report}},
|
||||
)
|
||||
with patch("shutil.which", return_value="/fake/cua-driver"), \
|
||||
patch("subprocess.Popen", return_value=proc), \
|
||||
patch("sys.stdout", new_callable=StringIO):
|
||||
code = doctor.run_doctor()
|
||||
assert code == 1
|
||||
|
||||
|
||||
def test_protocol_error_exits_2(self, capsys):
|
||||
"""An empty stdout response (driver crashed during handshake) is a
|
||||
protocol failure → exit 2."""
|
||||
from tools.computer_use import doctor
|
||||
|
||||
proc = MagicMock()
|
||||
proc.stdin = MagicMock()
|
||||
proc.stdout = MagicMock()
|
||||
proc.stdout.readline = MagicMock(return_value="") # EOF on initialize
|
||||
proc.stderr = MagicMock()
|
||||
proc.stderr.read = MagicMock(return_value="boom\n")
|
||||
proc.wait = MagicMock(return_value=0)
|
||||
proc.kill = MagicMock()
|
||||
|
||||
with patch("shutil.which", return_value="/fake/cua-driver"), \
|
||||
patch("subprocess.Popen", return_value=proc):
|
||||
code = doctor.run_doctor()
|
||||
assert code == 2
|
||||
# stderr should mention the failure
|
||||
captured = capsys.readouterr()
|
||||
assert "cua-driver" in captured.err.lower() or "health_report" in captured.err.lower()
|
||||
|
||||
|
||||
# ── response-shape parsing ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestResponseShapeParsing:
|
||||
def test_prefers_structuredContent(self):
|
||||
from tools.computer_use import doctor
|
||||
|
||||
proc = _fake_proc_with_responses(
|
||||
{"jsonrpc": "2.0", "id": 1, "result": {}},
|
||||
{"jsonrpc": "2.0", "id": 2, "result": {"structuredContent": _ok_report()}},
|
||||
)
|
||||
with patch("shutil.which", return_value="/fake/cua-driver"), \
|
||||
patch("subprocess.Popen", return_value=proc), \
|
||||
patch("sys.stdout", new_callable=StringIO) as out:
|
||||
doctor.run_doctor()
|
||||
# Header line includes driver version + platform + overall.
|
||||
text = out.getvalue()
|
||||
assert "darwin" in text
|
||||
assert "ok" in text
|
||||
|
||||
|
||||
def test_jsonrpc_error_response_exits_2(self, capsys):
|
||||
from tools.computer_use import doctor
|
||||
|
||||
proc = _fake_proc_with_responses(
|
||||
{"jsonrpc": "2.0", "id": 1, "result": {}},
|
||||
{"jsonrpc": "2.0", "id": 2, "error": {"code": -32601, "message": "method not found"}},
|
||||
)
|
||||
with patch("shutil.which", return_value="/fake/cua-driver"), \
|
||||
patch("subprocess.Popen", return_value=proc):
|
||||
code = doctor.run_doctor()
|
||||
assert code == 2
|
||||
assert "method not found" in capsys.readouterr().err
|
||||
|
||||
|
||||
# ── args / arg passthrough ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestArgPassthrough:
|
||||
def test_include_passed_through_to_tools_call(self):
|
||||
from tools.computer_use import doctor
|
||||
|
||||
proc = _fake_proc_with_responses(
|
||||
{"jsonrpc": "2.0", "id": 1, "result": {}},
|
||||
{"jsonrpc": "2.0", "id": 2, "result": {"structuredContent": _ok_report()}},
|
||||
)
|
||||
with patch("shutil.which", return_value="/fake/cua-driver"), \
|
||||
patch("subprocess.Popen", return_value=proc), \
|
||||
patch("sys.stdout", new_callable=StringIO):
|
||||
doctor.run_doctor(include=["binary_version", "tcc_accessibility"])
|
||||
|
||||
# Inspect the second write to stdin — the tools/call payload.
|
||||
writes = [call.args[0] for call in proc.stdin.write.call_args_list]
|
||||
call_payload = next(json.loads(w) for w in writes if "tools/call" in w)
|
||||
assert call_payload["params"]["arguments"]["include"] == [
|
||||
"binary_version", "tcc_accessibility",
|
||||
]
|
||||
|
||||
def test_skip_passed_through(self):
|
||||
from tools.computer_use import doctor
|
||||
|
||||
proc = _fake_proc_with_responses(
|
||||
{"jsonrpc": "2.0", "id": 1, "result": {}},
|
||||
{"jsonrpc": "2.0", "id": 2, "result": {"structuredContent": _ok_report()}},
|
||||
)
|
||||
with patch("shutil.which", return_value="/fake/cua-driver"), \
|
||||
patch("subprocess.Popen", return_value=proc), \
|
||||
patch("sys.stdout", new_callable=StringIO):
|
||||
doctor.run_doctor(skip=["bundle_identity"])
|
||||
writes = [call.args[0] for call in proc.stdin.write.call_args_list]
|
||||
call_payload = next(json.loads(w) for w in writes if "tools/call" in w)
|
||||
assert call_payload["params"]["arguments"]["skip"] == ["bundle_identity"]
|
||||
|
||||
|
||||
|
||||
# ── json output ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestJsonOutput:
|
||||
def test_json_output_is_parseable_round_trip(self):
|
||||
from tools.computer_use import doctor
|
||||
|
||||
proc = _fake_proc_with_responses(
|
||||
{"jsonrpc": "2.0", "id": 1, "result": {}},
|
||||
{"jsonrpc": "2.0", "id": 2, "result": {"structuredContent": _ok_report()}},
|
||||
)
|
||||
with patch("shutil.which", return_value="/fake/cua-driver"), \
|
||||
patch("subprocess.Popen", return_value=proc), \
|
||||
patch("sys.stdout", new_callable=StringIO) as out:
|
||||
doctor.run_doctor(json_output=True)
|
||||
# Verify the captured text round-trips through json.loads. Upstream
|
||||
# health_report keys are preserved; Hermes adds hermes_identity.
|
||||
parsed = json.loads(out.getvalue())
|
||||
report = _ok_report()
|
||||
for key, value in report.items():
|
||||
assert parsed[key] == value
|
||||
assert "hermes_identity" in parsed
|
||||
assert parsed["hermes_identity"]["resolved_binary"]
|
||||
|
||||
|
||||
# ── HERMES_CUA_DRIVER_CMD resolution ───────────────────────────────────────
|
||||
|
||||
|
||||
class TestDriverCmdResolution:
|
||||
def test_explicit_driver_cmd_arg_wins(self):
|
||||
from tools.computer_use import doctor
|
||||
|
||||
proc = _fake_proc_with_responses(
|
||||
{"jsonrpc": "2.0", "id": 1, "result": {}},
|
||||
{"jsonrpc": "2.0", "id": 2, "result": {"structuredContent": _ok_report()}},
|
||||
)
|
||||
with patch("shutil.which", return_value="/fake/explicit-binary") as which_mock, \
|
||||
patch("subprocess.Popen", return_value=proc), \
|
||||
patch("sys.stdout", new_callable=StringIO):
|
||||
doctor.run_doctor(driver_cmd="/custom/path/cua-driver")
|
||||
# shutil.which should have been called with the explicit arg, not
|
||||
# the env-var / default resolver.
|
||||
which_mock.assert_called_with("/custom/path/cua-driver")
|
||||
|
||||
def test_env_var_used_when_no_arg_given(self, monkeypatch):
|
||||
from tools.computer_use import doctor
|
||||
|
||||
monkeypatch.setenv("HERMES_CUA_DRIVER_CMD", "/env/path/cua-driver")
|
||||
proc = _fake_proc_with_responses(
|
||||
{"jsonrpc": "2.0", "id": 1, "result": {}},
|
||||
{"jsonrpc": "2.0", "id": 2, "result": {"structuredContent": _ok_report()}},
|
||||
)
|
||||
with patch("shutil.which", return_value="/env/path/cua-driver") as which_mock, \
|
||||
patch("subprocess.Popen", return_value=proc), \
|
||||
patch("sys.stdout", new_callable=StringIO), \
|
||||
patch("hermes_cli.tools_config._cua_driver_cmd", side_effect=Exception("force env")):
|
||||
# Force env-var resolution path inside run_doctor.
|
||||
doctor.run_doctor()
|
||||
which_mock.assert_called_with("/env/path/cua-driver")
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX user-local path regression")
|
||||
def test_user_local_driver_is_found_when_path_omits_it(self, tmp_path, monkeypatch):
|
||||
"""Doctor must inspect the same user-local driver as the runtime."""
|
||||
from tools.computer_use import doctor
|
||||
|
||||
driver = tmp_path / ".local" / "bin" / "cua-driver"
|
||||
driver.parent.mkdir(parents=True)
|
||||
driver.write_text("#!/bin/sh\nexit 0\n")
|
||||
driver.chmod(0o755)
|
||||
|
||||
monkeypatch.delenv("HERMES_CUA_DRIVER_CMD", raising=False)
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("PATH", "/usr/bin:/bin:/usr/sbin:/sbin")
|
||||
|
||||
with patch("tools.computer_use.doctor._drive_health_report", return_value=_ok_report()) as health, \
|
||||
patch("sys.stdout", new_callable=StringIO):
|
||||
assert doctor.run_doctor() == 0
|
||||
|
||||
health.assert_called_once_with(str(driver), include=(), skip=(), timeout=12.0)
|
||||
|
||||
|
||||
# ── cua-driver 0.10 unclassified health_report fallback ────────────────────
|
||||
|
||||
|
||||
def _unclassified_health_result() -> dict:
|
||||
"""MCP tools/call result shape from cua-driver 0.10.x denial."""
|
||||
return {
|
||||
"isError": True,
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": (
|
||||
"Permission denied: tool 'health_report' has no "
|
||||
"reviewed risk classification"
|
||||
),
|
||||
}
|
||||
],
|
||||
"structuredContent": {"exit_code": 1},
|
||||
}
|
||||
|
||||
|
||||
def _perms_ok_result() -> dict:
|
||||
return {
|
||||
"isError": False,
|
||||
"content": [{"type": "text", "text": "ok"}],
|
||||
"structuredContent": {
|
||||
"accessibility": True,
|
||||
"screen_recording": True,
|
||||
"screen_recording_capturable": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _list_apps_ok_result() -> dict:
|
||||
return {
|
||||
"isError": False,
|
||||
"content": [{"type": "text", "text": "Found 1 app"}],
|
||||
"structuredContent": {
|
||||
"apps": [{"name": "Finder", "pid": 1, "running": True}],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TestHealthReportFallback:
|
||||
"""cua-driver 0.10 marks health_report risk-unclassified → isError.
|
||||
|
||||
Doctor must NOT treat structuredContent={exit_code:1} as a real report
|
||||
(that produced '• cua-driver ? on ? — ?'). It synthesizes schema_version=1
|
||||
via check_permissions / list_apps / CLI --version instead.
|
||||
"""
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def test_extract_raises_health_report_unavailable_on_isError(self):
|
||||
from tools.computer_use import doctor
|
||||
|
||||
with __import__("pytest").raises(doctor.HealthReportUnavailable) as ei:
|
||||
doctor._extract_health_report_from_result(_unclassified_health_result())
|
||||
assert "Permission denied" in str(ei.value) or "unclassified" in str(ei.value).lower() or "risk" in str(ei.value).lower()
|
||||
|
||||
|
||||
|
||||
# ── binary identity (CLI --version vs health_report) ───────────────────────
|
||||
|
||||
|
||||
class TestDoctorVersionIdentity:
|
||||
def test_header_prefers_cli_version_on_mismatch(self):
|
||||
"""Windows has been observed reporting 0.8.3 via health_report while
|
||||
the resolved binary is 0.12.6 — doctor must surface the real version."""
|
||||
from tools.computer_use import doctor
|
||||
|
||||
proc = _fake_proc_with_responses(
|
||||
{"jsonrpc": "2.0", "id": 1, "result": {}},
|
||||
{"jsonrpc": "2.0", "id": 2, "result": {"structuredContent": _ok_report()}},
|
||||
)
|
||||
# _ok_report claims 0.5.8; CLI says 0.12.6
|
||||
with patch("shutil.which", return_value="/fake/cua-driver"), \
|
||||
patch("subprocess.Popen", return_value=proc), \
|
||||
patch.object(doctor, "_read_cli_version", return_value="cua-driver 0.12.6"), \
|
||||
patch("sys.stdout", new_callable=StringIO) as out:
|
||||
code = doctor.run_doctor()
|
||||
assert code == 0
|
||||
text = out.getvalue()
|
||||
assert "0.12.6" in text
|
||||
assert "version mismatch" in text.lower()
|
||||
assert "0.5.8" in text # health_report value still shown
|
||||
|
||||
|
||||
def test_matching_versions_no_mismatch_flag(self):
|
||||
from tools.computer_use import doctor
|
||||
|
||||
proc = _fake_proc_with_responses(
|
||||
{"jsonrpc": "2.0", "id": 1, "result": {}},
|
||||
{"jsonrpc": "2.0", "id": 2, "result": {"structuredContent": _ok_report()}},
|
||||
)
|
||||
with patch("shutil.which", return_value="/fake/cua-driver"), \
|
||||
patch("subprocess.Popen", return_value=proc), \
|
||||
patch.object(doctor, "_read_cli_version", return_value="cua-driver 0.5.8"), \
|
||||
patch("sys.stdout", new_callable=StringIO) as out:
|
||||
code = doctor.run_doctor(json_output=True)
|
||||
assert code == 0
|
||||
payload = json.loads(out.getvalue())
|
||||
assert payload["hermes_identity"]["version_mismatch"] is False
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Regression tests for Computer Use readiness under a thin GUI PATH."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX user-local path regression")
|
||||
def test_status_finds_user_local_driver_when_path_omits_it(tmp_path, monkeypatch):
|
||||
"""Desktop status must agree with the runtime resolver, not bare PATH."""
|
||||
from tools.computer_use import permissions
|
||||
|
||||
driver = tmp_path / ".local" / "bin" / "cua-driver"
|
||||
driver.parent.mkdir(parents=True)
|
||||
driver.write_text("#!/bin/sh\nexit 0\n")
|
||||
driver.chmod(0o755)
|
||||
|
||||
monkeypatch.delenv("HERMES_CUA_DRIVER_CMD", raising=False)
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("PATH", "/usr/bin:/bin:/usr/sbin:/sbin")
|
||||
|
||||
# No platform faking: ``~/.local/bin/cua-driver`` is a POSIX resolution
|
||||
# candidate on Linux exactly as on macOS, so the regression reproduces on
|
||||
# the host we actually run on.
|
||||
with patch.object(permissions, "_run", return_value=MagicMock(stdout="0.0.0")), \
|
||||
patch.object(permissions, "_doctor", return_value={"ok": True, "checks": []}):
|
||||
status = permissions.computer_use_status()
|
||||
|
||||
assert status["installed"] is True
|
||||
Reference in New Issue
Block a user