cua-driver refresh behavior on restricted macOS
Cluster 47865 · hermes cli. Data as of
Nothing open on GitHub. Fix merged in #58754. Check it covers your case.
The short answer
- Known?
- Yes. 5 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #58754. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
resolved, deciding PR #58754. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These issues and PRs concern how cua-driver refresh is handled when /Applications is not writable, typically on non-admin macOS systems. They also touch a related session startup timeout adjustment and phase timing logging.
Open issues0
Open PRs0
Fixes on record
-
#58754 fix(update): skip cua-driver refresh when /Applications is unwritable (salvage #47884)
files:
hermes_cli/config.py,hermes_cli/main.py,hermes_cli/tools_config.py,hermes_cli/web_server.py,tests/hermes_cli/test_install_cua_driver.pyShow the diff
-
hermes_cli/config.py@@ -2952,6 +2952,11 @@ def _ensure_hermes_home_managed(home: Path): # ignored paths — node_modules, venv, build outputs — # are never touched. "non_interactive_local_changes": "stash", + # Refresh an already-installed cua-driver during `hermes update`. + # The refresh is best-effort and macOS-only. Turn this off if the + # upstream installer is not appropriate for the machine, for example + # on non-admin accounts where `/Applications` is not writable. + "refresh_cua_driver": True, }, # Language Server Protocol — semantic diagnostics from real -
hermes_cli/main.py@@ -10243,7 +10243,23 @@ def _print_items(items, label, key, fallback_key=None): # predictable cadence (matches when they pull new agent code) without # adding startup latency or a per-launch GitHub API call. try: - if sys.platform in ("darwin", "win32", "linux") and shutil.which("cua-driver"): + refresh_cua_driver = True + try: + from hermes_cli.config import load_config + + _update_cfg = (load_config() or {}).get("updates", {}) + if isinstance(_update_cfg, dict): + refresh_cua_driver = bool( + _update_cfg.get("refresh_cua_driver", True) + ) + except Exception as cfg_exc: + logger.debug("Could not read updates.refresh_cua_driver: %s", cfg_exc) + + if ( + refresh_cua_driver + and sys.platform in ("darwin", "win32", "linux") + and shutil.which("cua-driver") + ): from hermes_cli.tools_config import install_cua_driver print() -
hermes_cli/tools_config.py@@ -705,6 +705,19 @@ def _pip_install( # no Python-side duplication. +def _cua_install_target_writable() -> bool: + """Return whether the upstream installer can write its app bundle target.""" + if sys.platform != "darwin": + return True + applications_dir = "/Applications" + try: + if not os.path.isdir(applications_dir): + return True + return os.access(applications_dir, os.W_OK) + except Exception: + return True + + def install_cua_driver(upgrade: bool = False) -> bool: """Install or refresh the cua-driver binary used by Computer Use. @@ -747,6 +760,14 @@ def install_cua_driver(upgrade: bool = False) -> bool: # Not installed → fresh install path (only when caller asked for it). if not binary and not upgrade: + if not _cua_install_target_writable(): + _print_info( + " /Applications is not writable; skipping cua-driver install." + ) + _print_info( + " Run from an admin account or install cua-driver manually." + ) + return False if not shutil.which(fetch_tool): _print_warning(f" {fetch_tool} not found — install manually:") _print_info(" https://github.com/trycua/cua/blob/main/libs/cua-driver/README.md") @@ -778,6 +799,15 @@ def install_cua_driver(upgrade: bool = False) -> bool: return True # upgrade=True path — refresh to the latest upstream release. + if not _cua_install_target_writable(): + _print_info( + " /Applications is not writable; skipping cua-driver refresh." + ) + _print_info( + " Run `hermes computer-use install --upgrade` from an admin account to update it." + ) + return bool(binary) + if not shutil.which(fetch_tool): _print_warning(f" {fetch_tool} not found — cannot refresh cua-driver.") return bool(binary) -
hermes_cli/web_server.py@@ -708,6 +708,14 @@ async def _token_auth_seam(request: Request, call_next): ), "options": ["stash", "discard"], }, + "updates.refresh_cua_driver": { + "type": "bool", + "description": ( + "Refresh an already-installed cua-driver during hermes update. " + "Disable this on non-admin macOS accounts where /Applications is " + "not writable." + ), + }, } # Categories with fewer fields get merged into "general" to avoid tab sprawl. -
tests/hermes_cli/test_install_cua_driver.py@@ -4,12 +4,12 @@ re-running it is the canonical upgrade path. ``install_cua_driver(upgrade=True)`` must: -* Be macOS-only — no-op silently on Linux/Windows so ``hermes update`` can - call it unconditionally without warning every non-macOS user. +* Be supported-platform-only — no-op silently elsewhere so ``hermes update`` + can call it unconditionally without warning unsupported-platform users. * Re-run the installer even when the binary is already on PATH (this is the fix for the "we only pulled cua-driver once on enable" complaint). * Preserve original ``upgrade=False`` behaviour for the toolset-enable flow: - skip if installed, install otherwise, warn on non-macOS. + skip if installed, install otherwise, warn on unsupported platforms. The pre-install arch probe that used to live alongside this function was deleted (see top-of-file comment in tools_config.py) — the upstream @@ -67,6 +67,41 @@ def test_upgrade_on_macos_without_binary_runs_installer(self): assert tools_config.install_cua_driver(upgrade=True) is True runner.assert_called_once() + def test_upgrade_on_macos_non_writable_applications_skips_refresh(self): + from hermes_cli import tools_config + + with patch("platform.system", return_value="Darwin"), \ + patch.object(tools_config.shutil, "which", + side_effect=lambda n: "/usr/local/bin/" + n + if n in {"cua-driver", "curl"} else None), \ + patch.object(tools_config, "_cua_install_target_writable", + return_value=False), \ + patch.object(tools_config, "_run_cua_driver_installer") as runner, \ + patch.object(tools_config, "_print_info") as info: + assert tools_config.install_cua_driver(upgrade=True) is True + runner.assert_not_called() + assert any( + "/Applications is not writable" in call.args[0] + for call in info.call_args_list + ) + + def test_fresh_install_on_macos_non_writable_applications_skips_install(self): + from hermes_cli import tools_config + + with patch("platform.system", return_value="Darwin"), \ + patch.object(tools_config.shutil, "which", + side_effect=lambda n: "/usr/bin/curl" if n == "curl" else None), \ + patch.object(tools_config, "_cua_install_target_writable", + return_value=False), \ + patch.object(tools_config, "_run_cua_driver_installer") as runner, \ + patch.object(tools_config, "_print_info") as info: + assert tools_config.install_cua_driver(upgrade=False) is False + runner.assert_not_called() + assert any( + "/Applications is not writable" in call.args[0] + for call in info.call_args_list + ) + def test_non_upgrade_on_macos_with_binary_skips_install(self): from hermes_cli import tools_config
-
-
#58760 fix(computer-use): raise cua-driver session startup timeout to 30s + phase timing log (salvage #57034)
files:
tools/computer_use/cua_backend.pyShow the diff
-
tools/computer_use/cua_backend.py@@ -567,6 +567,7 @@ async def _lifecycle_coro(self) -> None: different task than it was entered in" warning emitted by the previous _aenter/_aexit split. """ + import time as _time from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client from tools.environments.local import _sanitize_subprocess_env @@ -574,6 +575,7 @@ async def _lifecycle_coro(self) -> None: # Build the shutdown event on the loop's thread so the asyncio # primitive belongs to the correct loop. self._shutdown_event = asyncio.Event() + _t0 = _time.monotonic() try: if not cua_driver_binary_available(): @@ -583,6 +585,7 @@ async def _lifecycle_coro(self) -> None: # the MCP server, instead of hardcoding ["mcp"]. Falls back # transparently for older drivers / any discovery failure. command, args = _resolve_mcp_invocation(_CUA_DRIVER_CMD) + _t_manifest = _time.monotonic() params = StdioServerParameters( command=command, args=args, @@ -594,12 +597,20 @@ async def _lifecycle_coro(self) -> None: async with stdio_client(params) as (read, write): async with ClientSession(read, write) as session: await session.initialize() + _t_init = _time.monotonic() # Populate capabilities + capability_version BEFORE # exposing the session to callers, so the first # tool call already sees them. await self._populate_capabilities(session) self._session = session self._ready_event.set() + logger.info( + "cua-driver session ready in %.1fs " + "(manifest=%.1fs, mcp_init=%.1fs)", + _time.monotonic() - _t0, + _t_manifest - _t0, + _t_init - _t_manifest, + ) # Hold the contexts open until stop() / restart asks # us to wind down. Tool calls run as their own tasks # on the same loop and touch self._session directly. @@ -677,10 +688,10 @@ def _start_lifecycle_locked(self) -> None: self._lifecycle_future = asyncio.run_coroutine_threadsafe( self._lifecycle_coro(), loop ) - if not self._ready_event.wait(timeout=15.0): + if not self._ready_event.wait(timeout=30.0): # Best-effort: signal shutdown if the future is still alive. self._signal_shutdown_locked() - raise RuntimeError("cua-driver session never reached ready (timeout 15s)") + raise RuntimeError("cua-driver session never reached ready (timeout 30s)") # If setup failed, the lifecycle coroutine set _setup_error # before setting _ready_event. Re-raise it on the caller's thread. if self._setup_error is not None:
-
2 PRs closed without merging
From the close comments: merged via another change 2
Closed issues · 1
- Closed#47865Nonfatal: `hermes update` cua-driver refresh fails noisily on non-admin macOS (`/Applications` not writable)
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.40: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab