{"repo":"NousResearch/hermes-agent","public_id":"47865","display_label":"cua-driver refresh behavior on restricted macOS","summary":"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.","subsystem":"hermes_cli","counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":2,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":58754},"resolution":{"state":"resolved","by":"pr-58754"},"freshness":{"state":"verified","last_verified_at":"2026-07-05T10:15:50+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.4},"fix_edges":[{"issue_number":47865,"pr_number":58754,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":47865,"pr_number":58760,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":57025,"pr_number":58760,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":47865,"title":"Nonfatal: `hermes update` cua-driver refresh fails noisily on non-admin macOS (`/Applications` not writable)","url":null,"state":"closed"},{"kind":"pr","number":47884,"title":"fix(update): skip cua-driver refresh when Applications is unwritable","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"c118b0379","number":0},{"kind":"pr","value":"#58754","number":58754}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":47899,"title":"fix(cli): skip cua-driver refresh when /Applications is not writable","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#47884","number":47884},{"kind":"pr","value":"#58754","number":58754},{"kind":"pr","value":"#57034","number":57034},{"kind":"pr","value":"#58760","number":58760}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":58754,"title":"fix(update): skip cua-driver refresh when /Applications is unwritable (salvage #47884)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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.py"],"fix_hunks":[{"file":"hermes_cli/config.py","hunks":["@@ -2952,6 +2952,11 @@ def _ensure_hermes_home_managed(home: Path):\n         #               ignored paths — node_modules, venv, build outputs —\n         #               are never touched.\n         \"non_interactive_local_changes\": \"stash\",\n+        # Refresh an already-installed cua-driver during `hermes update`.\n+        # The refresh is best-effort and macOS-only. Turn this off if the\n+        # upstream installer is not appropriate for the machine, for example\n+        # on non-admin accounts where `/Applications` is not writable.\n+        \"refresh_cua_driver\": True,\n     },\n \n     # Language Server Protocol — semantic diagnostics from real"]},{"file":"hermes_cli/main.py","hunks":["@@ -10243,7 +10243,23 @@ def _print_items(items, label, key, fallback_key=None):\n         # predictable cadence (matches when they pull new agent code) without\n         # adding startup latency or a per-launch GitHub API call.\n         try:\n-            if sys.platform in (\"darwin\", \"win32\", \"linux\") and shutil.which(\"cua-driver\"):\n+            refresh_cua_driver = True\n+            try:\n+                from hermes_cli.config import load_config\n+\n+                _update_cfg = (load_config() or {}).get(\"updates\", {})\n+                if isinstance(_update_cfg, dict):\n+                    refresh_cua_driver = bool(\n+                        _update_cfg.get(\"refresh_cua_driver\", True)\n+                    )\n+            except Exception as cfg_exc:\n+                logger.debug(\"Could not read updates.refresh_cua_driver: %s\", cfg_exc)\n+\n+            if (\n+                refresh_cua_driver\n+                and sys.platform in (\"darwin\", \"win32\", \"linux\")\n+                and shutil.which(\"cua-driver\")\n+            ):\n                 from hermes_cli.tools_config import install_cua_driver\n \n                 print()"]},{"file":"hermes_cli/tools_config.py","hunks":["@@ -705,6 +705,19 @@ def _pip_install(\n # no Python-side duplication.\n \n \n+def _cua_install_target_writable() -> bool:\n+    \"\"\"Return whether the upstream installer can write its app bundle target.\"\"\"\n+    if sys.platform != \"darwin\":\n+        return True\n+    applications_dir = \"/Applications\"\n+    try:\n+        if not os.path.isdir(applications_dir):\n+            return True\n+        return os.access(applications_dir, os.W_OK)\n+    except Exception:\n+        return True\n+\n+\n def install_cua_driver(upgrade: bool = False) -> bool:\n     \"\"\"Install or refresh the cua-driver binary used by Computer Use.\n ","@@ -747,6 +760,14 @@ def install_cua_driver(upgrade: bool = False) -> bool:\n \n     # Not installed → fresh install path (only when caller asked for it).\n     if not binary and not upgrade:\n+        if not _cua_install_target_writable():\n+            _print_info(\n+                \"    /Applications is not writable; skipping cua-driver install.\"\n+            )\n+            _print_info(\n+                \"    Run from an admin account or install cua-driver manually.\"\n+            )\n+            return False\n         if not shutil.which(fetch_tool):\n             _print_warning(f\"    {fetch_tool} not found — install manually:\")\n             _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:\n         return True\n \n     # upgrade=True path — refresh to the latest upstream release.\n+    if not _cua_install_target_writable():\n+        _print_info(\n+            \"    /Applications is not writable; skipping cua-driver refresh.\"\n+        )\n+        _print_info(\n+            \"    Run `hermes computer-use install --upgrade` from an admin account to update it.\"\n+        )\n+        return bool(binary)\n+\n     if not shutil.which(fetch_tool):\n         _print_warning(f\"    {fetch_tool} not found — cannot refresh cua-driver.\")\n         return bool(binary)"]},{"file":"hermes_cli/web_server.py","hunks":["@@ -708,6 +708,14 @@ async def _token_auth_seam(request: Request, call_next):\n         ),\n         \"options\": [\"stash\", \"discard\"],\n     },\n+    \"updates.refresh_cua_driver\": {\n+        \"type\": \"bool\",\n+        \"description\": (\n+            \"Refresh an already-installed cua-driver during hermes update. \"\n+            \"Disable this on non-admin macOS accounts where /Applications is \"\n+            \"not writable.\"\n+        ),\n+    },\n }\n \n # Categories with fewer fields get merged into \"general\" to avoid tab sprawl."]},{"file":"tests/hermes_cli/test_install_cua_driver.py","hunks":["@@ -4,12 +4,12 @@\n re-running it is the canonical upgrade path. ``install_cua_driver(upgrade=True)``\n must:\n \n-* Be macOS-only — no-op silently on Linux/Windows so ``hermes update`` can\n-  call it unconditionally without warning every non-macOS user.\n+* Be supported-platform-only — no-op silently elsewhere so ``hermes update``\n+  can call it unconditionally without warning unsupported-platform users.\n * Re-run the installer even when the binary is already on PATH (this is the\n   fix for the \"we only pulled cua-driver once on enable\" complaint).\n * Preserve original ``upgrade=False`` behaviour for the toolset-enable flow:\n-  skip if installed, install otherwise, warn on non-macOS.\n+  skip if installed, install otherwise, warn on unsupported platforms.\n \n The pre-install arch probe that used to live alongside this function was\n 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):\n             assert tools_config.install_cua_driver(upgrade=True) is True\n             runner.assert_called_once()\n \n+    def test_upgrade_on_macos_non_writable_applications_skips_refresh(self):\n+        from hermes_cli import tools_config\n+\n+        with patch(\"platform.system\", return_value=\"Darwin\"), \\\n+             patch.object(tools_config.shutil, \"which\",\n+                          side_effect=lambda n: \"/usr/local/bin/\" + n\n+                                                 if n in {\"cua-driver\", \"curl\"} else None), \\\n+             patch.object(tools_config, \"_cua_install_target_writable\",\n+                          return_value=False), \\\n+             patch.object(tools_config, \"_run_cua_driver_installer\") as runner, \\\n+             patch.object(tools_config, \"_print_info\") as info:\n+            assert tools_config.install_cua_driver(upgrade=True) is True\n+            runner.assert_not_called()\n+            assert any(\n+                \"/Applications is not writable\" in call.args[0]\n+                for call in info.call_args_list\n+            )\n+\n+    def test_fresh_install_on_macos_non_writable_applications_skips_install(self):\n+        from hermes_cli import tools_config\n+\n+        with patch(\"platform.system\", return_value=\"Darwin\"), \\\n+             patch.object(tools_config.shutil, \"which\",\n+                          side_effect=lambda n: \"/usr/bin/curl\" if n == \"curl\" else None), \\\n+             patch.object(tools_config, \"_cua_install_target_writable\",\n+                          return_value=False), \\\n+             patch.object(tools_config, \"_run_cua_driver_installer\") as runner, \\\n+             patch.object(tools_config, \"_print_info\") as info:\n+            assert tools_config.install_cua_driver(upgrade=False) is False\n+            runner.assert_not_called()\n+            assert any(\n+                \"/Applications is not writable\" in call.args[0]\n+                for call in info.call_args_list\n+            )\n+\n     def test_non_upgrade_on_macos_with_binary_skips_install(self):\n         from hermes_cli import tools_config\n \n"]}]},{"kind":"pr","number":58760,"title":"fix(computer-use): raise cua-driver session startup timeout to 30s + phase timing log (salvage #57034)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tools/computer_use/cua_backend.py"],"fix_hunks":[{"file":"tools/computer_use/cua_backend.py","hunks":["@@ -567,6 +567,7 @@ async def _lifecycle_coro(self) -> None:\n         different task than it was entered in\" warning emitted by the\n         previous _aenter/_aexit split.\n         \"\"\"\n+        import time as _time\n         from mcp import ClientSession, StdioServerParameters\n         from mcp.client.stdio import stdio_client\n         from tools.environments.local import _sanitize_subprocess_env","@@ -574,6 +575,7 @@ async def _lifecycle_coro(self) -> None:\n         # Build the shutdown event on the loop's thread so the asyncio\n         # primitive belongs to the correct loop.\n         self._shutdown_event = asyncio.Event()\n+        _t0 = _time.monotonic()\n \n         try:\n             if not cua_driver_binary_available():","@@ -583,6 +585,7 @@ async def _lifecycle_coro(self) -> None:\n             # the MCP server, instead of hardcoding [\"mcp\"]. Falls back\n             # transparently for older drivers / any discovery failure.\n             command, args = _resolve_mcp_invocation(_CUA_DRIVER_CMD)\n+            _t_manifest = _time.monotonic()\n             params = StdioServerParameters(\n                 command=command,\n                 args=args,","@@ -594,12 +597,20 @@ async def _lifecycle_coro(self) -> None:\n             async with stdio_client(params) as (read, write):\n                 async with ClientSession(read, write) as session:\n                     await session.initialize()\n+                    _t_init = _time.monotonic()\n                     # Populate capabilities + capability_version BEFORE\n                     # exposing the session to callers, so the first\n                     # tool call already sees them.\n                     await self._populate_capabilities(session)\n                     self._session = session\n                     self._ready_event.set()\n+                    logger.info(\n+                        \"cua-driver session ready in %.1fs \"\n+                        \"(manifest=%.1fs, mcp_init=%.1fs)\",\n+                        _time.monotonic() - _t0,\n+                        _t_manifest - _t0,\n+                        _t_init - _t_manifest,\n+                    )\n                     # Hold the contexts open until stop() / restart asks\n                     # us to wind down. Tool calls run as their own tasks\n                     # on the same loop and touch self._session directly.","@@ -677,10 +688,10 @@ def _start_lifecycle_locked(self) -> None:\n         self._lifecycle_future = asyncio.run_coroutine_threadsafe(\n             self._lifecycle_coro(), loop\n         )\n-        if not self._ready_event.wait(timeout=15.0):\n+        if not self._ready_event.wait(timeout=30.0):\n             # Best-effort: signal shutdown if the future is still alive.\n             self._signal_shutdown_locked()\n-            raise RuntimeError(\"cua-driver session never reached ready (timeout 15s)\")\n+            raise RuntimeError(\"cua-driver session never reached ready (timeout 30s)\")\n         # If setup failed, the lifecycle coroutine set _setup_error\n         # before setting _ready_event. Re-raise it on the caller's thread.\n         if self._setup_error is not None:\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}