mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-10-20 05:15:31 +00:00
feat(computer-use): use cua-driver's native check-update instead of a hardcoded version floor
Replace the per-OS MIN_CUA_DRIVER_VERSION soft-warning (hardcoded, rot-prone) with cua-driver's native check-update — the source-of-truth freshness check shipped in trycua/cua#1734 (`check-update --json` CLI verb / `check_for_update` MCP tool). - cua_backend: cua_driver_update_check() shells `check-update --json` (stdin=DEVNULL so a pre-#1734 driver that falls through to a stdin read fails fast instead of blocking; timeout-guarded). Returns None when indeterminate (verb absent / offline / error payload / unparseable) so callers stay quiet. cua_driver_update_nudge() formats the one-liner; the startup nudge runs off-thread so the (cached, ~20h) GitHub poll never blocks the first computer_use action. - status: `hermes computer-use status` reports current/latest and an "update available" line via the native check. - update: install_cua_driver(upgrade=True) skips the network re-install when the driver reports it's already on the latest release. - Graceful on pre-#1734 drivers (e.g. 0.2.18 on the dev host): verb absent → stay quiet (verified: returns None in ~0.4s). - tests: TestUpdateCheck replaces TestVersionWarning. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
649f4c8c1a
commit
1542511eb7
4 changed files with 153 additions and 125 deletions
|
|
@ -11920,13 +11920,19 @@ def main():
|
|||
else:
|
||||
print(f"cua-driver: installed at {path}")
|
||||
try:
|
||||
from tools.computer_use.cua_backend import cua_driver_version_warning
|
||||
warn = cua_driver_version_warning()
|
||||
if warn:
|
||||
print(f" ⚠ {warn}")
|
||||
from tools.computer_use.cua_backend import cua_driver_update_check
|
||||
st = cua_driver_update_check()
|
||||
if st and st.get("update_available"):
|
||||
latest = st.get("latest_version") or "?"
|
||||
print(f" ⬆ Update available: cua-driver {latest}.")
|
||||
print(" Run: hermes computer-use install --upgrade")
|
||||
elif st:
|
||||
print(" ✓ Up to date.")
|
||||
else:
|
||||
# Older driver (no check-update verb) or offline.
|
||||
print(" Refresh to latest: hermes computer-use install --upgrade")
|
||||
except Exception:
|
||||
pass
|
||||
print(" Refresh to latest: hermes computer-use install --upgrade")
|
||||
print(" Refresh to latest: hermes computer-use install --upgrade")
|
||||
return
|
||||
print("cua-driver: not installed")
|
||||
print(" Run: hermes computer-use install")
|
||||
|
|
|
|||
|
|
@ -800,6 +800,23 @@ def install_cua_driver(upgrade: bool = False) -> bool:
|
|||
if not _check_cua_driver_asset_for_arch():
|
||||
return bool(binary)
|
||||
|
||||
# Skip the (network) re-install when the driver itself reports it's already
|
||||
# on the latest release. Best-effort: an older driver (no check-update
|
||||
# verb) or an offline check returns None, in which case we fall through and
|
||||
# re-run the installer as before.
|
||||
if binary:
|
||||
try:
|
||||
from tools.computer_use.cua_backend import cua_driver_update_check
|
||||
_state = cua_driver_update_check()
|
||||
if _state is not None and not _state.get("update_available"):
|
||||
_print_success(
|
||||
f" {driver_cmd} is already on the latest release "
|
||||
f"({_state.get('current_version') or 'unknown'})."
|
||||
)
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if binary:
|
||||
# Show before/after version when we have a baseline. Best-effort.
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -1127,66 +1127,63 @@ class TestElementLabelParsing:
|
|||
assert labels[15] == "Search"
|
||||
|
||||
|
||||
class TestVersionWarning:
|
||||
"""cua_driver_version_warning(): soft, per-OS minimum-version check.
|
||||
class TestUpdateCheck:
|
||||
"""cua_driver_update_check() / _nudge(): native `check-update --json`.
|
||||
|
||||
macOS (Swift build) and the cross-platform Rust build version
|
||||
independently, so the floor is keyed by platform. Older → warn; equal /
|
||||
newer / local-dev / unknown-platform / undeterminable → stay quiet.
|
||||
Prefers cua-driver's source-of-truth update check over a hardcoded
|
||||
version floor. Stays quiet (None) when indeterminate: an old driver with
|
||||
no `check-update` verb, offline, an `error` payload, or unparseable output.
|
||||
"""
|
||||
|
||||
def test_older_than_floor_warns(self):
|
||||
from tools.computer_use import cua_backend
|
||||
with patch.object(cua_backend, "installed_cua_driver_version", return_value="0.2.10"), \
|
||||
patch("tools.computer_use.cua_backend.sys.platform", "win32"):
|
||||
msg = cua_backend.cua_driver_version_warning()
|
||||
assert msg is not None
|
||||
assert "0.2.10" in msg and "0.2.16" in msg # installed + floor
|
||||
|
||||
def test_equal_to_floor_is_quiet(self):
|
||||
from tools.computer_use import cua_backend
|
||||
with patch.object(cua_backend, "installed_cua_driver_version", return_value="0.2.16"), \
|
||||
patch("tools.computer_use.cua_backend.sys.platform", "win32"):
|
||||
assert cua_backend.cua_driver_version_warning() is None
|
||||
|
||||
def test_newer_than_floor_is_quiet(self):
|
||||
from tools.computer_use import cua_backend
|
||||
with patch.object(cua_backend, "installed_cua_driver_version", return_value="0.2.18"), \
|
||||
patch("tools.computer_use.cua_backend.sys.platform", "win32"):
|
||||
assert cua_backend.cua_driver_version_warning() is None
|
||||
|
||||
def test_local_build_exempt(self):
|
||||
from tools.computer_use import cua_backend
|
||||
with patch.object(cua_backend, "installed_cua_driver_version", return_value="0.0.0-local-release"), \
|
||||
patch("tools.computer_use.cua_backend.sys.platform", "win32"):
|
||||
assert cua_backend.cua_driver_version_warning() is None
|
||||
|
||||
def test_macos_floor_independent_of_rust_build(self):
|
||||
# darwin floor is 0.5.0; a fresh 0.5.x macOS build must not warn even
|
||||
# though it would be "newer" than the 0.2.x Rust floor.
|
||||
from tools.computer_use import cua_backend
|
||||
with patch.object(cua_backend, "installed_cua_driver_version", return_value="0.5.1"), \
|
||||
patch("tools.computer_use.cua_backend.sys.platform", "darwin"):
|
||||
assert cua_backend.cua_driver_version_warning() is None
|
||||
|
||||
def test_unknown_platform_is_quiet(self):
|
||||
from tools.computer_use import cua_backend
|
||||
with patch.object(cua_backend, "installed_cua_driver_version", return_value="0.0.1"), \
|
||||
patch("tools.computer_use.cua_backend.sys.platform", "freebsd13"):
|
||||
assert cua_backend.cua_driver_version_warning() is None
|
||||
|
||||
def test_undeterminable_version_is_quiet(self):
|
||||
from tools.computer_use import cua_backend
|
||||
with patch.object(cua_backend, "installed_cua_driver_version", return_value=None), \
|
||||
patch("tools.computer_use.cua_backend.sys.platform", "win32"):
|
||||
assert cua_backend.cua_driver_version_warning() is None
|
||||
|
||||
def test_installed_version_parses_cli_output(self):
|
||||
from tools.computer_use import cua_backend
|
||||
@staticmethod
|
||||
def _run_returning(stdout: str):
|
||||
fake = MagicMock()
|
||||
fake.stdout = "cua-driver 0.2.18\n"
|
||||
with patch("tools.computer_use.cua_backend.subprocess.run", return_value=fake):
|
||||
assert cua_backend.installed_cua_driver_version() == "0.2.18"
|
||||
fake.stdout = stdout
|
||||
return patch("tools.computer_use.cua_backend.subprocess.run", return_value=fake)
|
||||
|
||||
def test_update_available(self):
|
||||
from tools.computer_use import cua_backend
|
||||
payload = '{"current_version":"0.3.1","latest_version":"0.3.2","update_available":true}'
|
||||
with self._run_returning(payload):
|
||||
st = cua_backend.cua_driver_update_check()
|
||||
assert st is not None and st["update_available"] is True
|
||||
msg = cua_backend.cua_driver_update_nudge()
|
||||
assert msg is not None
|
||||
assert "0.3.2" in msg and "0.3.1" in msg
|
||||
|
||||
def test_up_to_date_is_quiet(self):
|
||||
from tools.computer_use import cua_backend
|
||||
payload = '{"current_version":"0.3.2","latest_version":"0.3.2","update_available":false}'
|
||||
with self._run_returning(payload):
|
||||
st = cua_backend.cua_driver_update_check()
|
||||
assert st is not None and st["update_available"] is False
|
||||
assert cua_backend.cua_driver_update_nudge() is None
|
||||
|
||||
def test_error_payload_is_indeterminate(self):
|
||||
from tools.computer_use import cua_backend
|
||||
payload = '{"current_version":"0.3.2","update_available":false,"error":"github 503"}'
|
||||
with self._run_returning(payload):
|
||||
assert cua_backend.cua_driver_update_check() is None
|
||||
assert cua_backend.cua_driver_update_nudge() is None
|
||||
|
||||
def test_old_driver_without_verb_is_quiet(self):
|
||||
# Drivers predating trycua/cua#1734 print usage to stderr; stdout empty.
|
||||
from tools.computer_use import cua_backend
|
||||
with self._run_returning(""):
|
||||
assert cua_backend.cua_driver_update_check() is None
|
||||
assert cua_backend.cua_driver_update_nudge() is None
|
||||
|
||||
def test_nonjson_output_is_quiet(self):
|
||||
from tools.computer_use import cua_backend
|
||||
with self._run_returning("cua-driver 0.2.18\n"):
|
||||
assert cua_backend.cua_driver_update_check() is None
|
||||
|
||||
def test_subprocess_failure_is_quiet(self):
|
||||
from tools.computer_use import cua_backend
|
||||
with patch("tools.computer_use.cua_backend.subprocess.run",
|
||||
side_effect=FileNotFoundError()):
|
||||
assert cua_backend.cua_driver_update_check() is None
|
||||
assert cua_backend.cua_driver_update_nudge() is None
|
||||
|
||||
|
||||
class TestLazyMcpInstall:
|
||||
|
|
@ -1202,7 +1199,7 @@ class TestLazyMcpInstall:
|
|||
|
||||
def test_start_lazy_installs_mcp(self):
|
||||
from tools.computer_use import cua_backend
|
||||
with patch.object(cua_backend, "_warn_if_cua_driver_outdated"), \
|
||||
with patch.object(cua_backend, "_maybe_nudge_update"), \
|
||||
patch("tools.lazy_deps.ensure") as mock_ensure, \
|
||||
patch.object(cua_backend._CuaDriverSession, "start") as mock_sess_start:
|
||||
cua_backend.CuaDriverBackend().start()
|
||||
|
|
@ -1218,7 +1215,7 @@ class TestLazyMcpInstall:
|
|||
unavailable = FeatureUnavailable(
|
||||
"tool.computer_use", ("mcp==1.26.0",), "lazy installs disabled"
|
||||
)
|
||||
with patch.object(cua_backend, "_warn_if_cua_driver_outdated"), \
|
||||
with patch.object(cua_backend, "_maybe_nudge_update"), \
|
||||
patch("tools.lazy_deps.ensure", side_effect=unavailable), \
|
||||
patch.object(cua_backend._CuaDriverSession, "start") as mock_sess_start:
|
||||
with pytest.raises(FeatureUnavailable):
|
||||
|
|
|
|||
|
|
@ -55,28 +55,18 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Version compatibility
|
||||
# Update checking
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# cua-driver ships as two independently-versioned products: the macOS Swift
|
||||
# build (~0.5.x) and the cross-platform Rust build, cua-driver-rs (~0.2.x),
|
||||
# used on Windows / Linux. Their version lines do NOT track each other, so the
|
||||
# minimum-supported version is per-OS.
|
||||
#
|
||||
# This is a SOFT floor: Hermes warns — it does not refuse to run — when the
|
||||
# installed binary is older than the version it was tested against. Newer is
|
||||
# always fine; local dev builds (0.0.0-local-*) are exempt. Bump these as
|
||||
# newer drivers are verified.
|
||||
# cua-driver ships a native `check-update` verb (and a `check_for_update` MCP
|
||||
# tool) that compares the installed binary against the latest GitHub release —
|
||||
# the source of truth — and caches the result (~20h). We prefer that over a
|
||||
# hardcoded version floor, which would rot and can't know what "latest" is.
|
||||
#
|
||||
# There is intentionally no version *pin* knob: the upstream installer always
|
||||
# fetches the latest release, so a `HERMES_CUA_DRIVER_VERSION` env var would
|
||||
# only have *looked* like it pinned without doing so. For a reproducible
|
||||
# version, point `HERMES_CUA_DRIVER_CMD` at a specific binary instead.
|
||||
MIN_CUA_DRIVER_VERSION = {
|
||||
"darwin": "0.5.0", # macOS Swift build
|
||||
"win32": "0.2.16", # cua-driver-rs
|
||||
"linux": "0.2.16", # cua-driver-rs (alpha)
|
||||
}
|
||||
# only have *looked* like it pinned. For a reproducible version, point
|
||||
# `HERMES_CUA_DRIVER_CMD` at a specific binary instead.
|
||||
|
||||
_CUA_DRIVER_CMD = os.environ.get("HERMES_CUA_DRIVER_CMD", "cua-driver")
|
||||
_CUA_DRIVER_ARGS = ["mcp"] # stdio MCP transport
|
||||
|
|
@ -117,61 +107,79 @@ def cua_driver_binary_available() -> bool:
|
|||
return bool(shutil.which(_CUA_DRIVER_CMD))
|
||||
|
||||
|
||||
def installed_cua_driver_version() -> Optional[str]:
|
||||
"""Return the installed cua-driver version (e.g. "0.2.18"), or None if it
|
||||
can't be determined. Best-effort; never raises."""
|
||||
try:
|
||||
out = subprocess.run(
|
||||
[_CUA_DRIVER_CMD, "--version"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
).stdout.strip()
|
||||
except Exception:
|
||||
return None
|
||||
# Output looks like "cua-driver 0.2.18"; pull the first semver token.
|
||||
m = re.search(r"\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.\-]+)?", out)
|
||||
return m.group(0) if m else None
|
||||
def cua_driver_update_check(*, timeout: float = 8.0) -> Optional[Dict[str, Any]]:
|
||||
"""Run ``cua-driver check-update --json`` and return its parsed state.
|
||||
|
||||
The payload mirrors the ``check_for_update`` MCP tool:
|
||||
``{current_version, latest_version, update_available, ...}``.
|
||||
|
||||
def cua_driver_version_warning() -> Optional[str]:
|
||||
"""Return a warning string if the installed cua-driver is older than the
|
||||
per-OS minimum Hermes is tested against; otherwise None.
|
||||
|
||||
Soft check — stays quiet (returns None) when the platform has no declared
|
||||
floor, the version can't be parsed, or it's a local dev build.
|
||||
Returns ``None`` (callers should stay quiet) when the result is
|
||||
indeterminate: the binary is missing, the driver is too old to support
|
||||
the verb (it predates trycua/cua#1734), the GitHub check failed (an
|
||||
``error`` field is set), or the output didn't parse. Best-effort; never
|
||||
raises.
|
||||
"""
|
||||
floor = MIN_CUA_DRIVER_VERSION.get(sys.platform)
|
||||
if not floor:
|
||||
return None
|
||||
installed = installed_cua_driver_version()
|
||||
if not installed:
|
||||
return None
|
||||
if installed.startswith("0.0.0") or "local" in installed:
|
||||
return None # local/dev build — intentionally exempt
|
||||
try:
|
||||
from packaging.version import Version
|
||||
if Version(installed) >= Version(floor):
|
||||
return None
|
||||
proc = subprocess.run(
|
||||
[_CUA_DRIVER_CMD, "check-update", "--json"],
|
||||
capture_output=True, text=True, timeout=timeout,
|
||||
# Some older drivers don't have the verb and fall through to a
|
||||
# stdin-reading mode rather than erroring — DEVNULL gives them EOF
|
||||
# so they exit fast instead of blocking until the timeout.
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except Exception:
|
||||
return None # unparseable → don't cry wolf
|
||||
return None
|
||||
out = (proc.stdout or "").strip()
|
||||
if not out:
|
||||
# Older drivers don't have the verb: usage goes to stderr, stdout empty.
|
||||
return None
|
||||
try:
|
||||
data = json.loads(out)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
if not isinstance(data, dict) or data.get("error"):
|
||||
# A failed check (exit 1) carries its reason in `error` — indeterminate.
|
||||
return None
|
||||
return data
|
||||
|
||||
|
||||
def cua_driver_update_nudge() -> Optional[str]:
|
||||
"""One-line "an update is available" message, or ``None`` when up to date,
|
||||
indeterminate, or the driver is too old to report."""
|
||||
state = cua_driver_update_check()
|
||||
if not state or not state.get("update_available"):
|
||||
return None
|
||||
latest = state.get("latest_version") or "?"
|
||||
current = state.get("current_version") or "?"
|
||||
return (
|
||||
f"cua-driver {installed} is older than {floor}, the minimum Hermes is "
|
||||
f"tested against on this platform — some computer_use actions may "
|
||||
f"misbehave. Update with `hermes computer-use install --upgrade`."
|
||||
f"cua-driver {latest} is available (you have {current}); "
|
||||
f"update with `hermes computer-use install --upgrade`."
|
||||
)
|
||||
|
||||
|
||||
_version_warned = False
|
||||
_update_checked = False
|
||||
|
||||
|
||||
def _warn_if_cua_driver_outdated() -> None:
|
||||
"""Emit the version-compatibility warning at most once per process."""
|
||||
global _version_warned
|
||||
if _version_warned:
|
||||
def _maybe_nudge_update() -> None:
|
||||
"""Emit an update nudge at most once per process, off-thread so the
|
||||
(cached, ~20h) GitHub poll never blocks the first computer_use action."""
|
||||
global _update_checked
|
||||
if _update_checked:
|
||||
return
|
||||
_version_warned = True
|
||||
msg = cua_driver_version_warning()
|
||||
if msg:
|
||||
logger.warning("computer_use: %s", msg)
|
||||
_update_checked = True
|
||||
|
||||
def _run() -> None:
|
||||
try:
|
||||
msg = cua_driver_update_nudge()
|
||||
except Exception:
|
||||
return
|
||||
if msg:
|
||||
logger.info("computer_use: %s", msg)
|
||||
|
||||
threading.Thread(
|
||||
target=_run, name="cua-driver-update-check", daemon=True
|
||||
).start()
|
||||
|
||||
|
||||
def cua_driver_install_hint() -> str:
|
||||
|
|
@ -509,7 +517,7 @@ class CuaDriverBackend(ComputerUseBackend):
|
|||
|
||||
# ── Lifecycle ──────────────────────────────────────────────────
|
||||
def start(self) -> None:
|
||||
_warn_if_cua_driver_outdated()
|
||||
_maybe_nudge_update()
|
||||
# The MCP client SDK (`mcp`) is an optional dependency (the
|
||||
# `computer-use` / `mcp` extras), not part of Hermes' minimal core.
|
||||
# Lazy-install it on first use — the same pattern every other optional
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue