# NousResearch/hermes-agent · cua-driver refresh behavior on restricted macOS

## Open now

- 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.

## TL;DR

- 5 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 2 merged PR(s), 2 PR(s) closed without merging.
- A fix was merged to main in PR #58754. Verify it addresses your case.
- **Recommended:** read PR #58754 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-58754`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.40`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #47865: Nonfatal: \`hermes update\` cua-driver refresh fails noisily on non-admin macOS (\`/Applications\` not writable)

## Prior & competing PRs

- **#58754: fix(update): skip cua-driver refresh when /Applications is unwritable (salvage #47884)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - 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.py`
  - fix hunks:
  - `hermes_cli/config.py`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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.
     
    ```

    ```diff
    @@ -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")
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `tools/computer_use/cua_backend.py`
  - fix hunks:
  - `tools/computer_use/cua_backend.py`

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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():
    ```

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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.
    ```

    ```diff
    @@ -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:
    ```
- **#47884: fix(update): skip cua-driver refresh when Applications is unwritable**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `c118b0379`, `#58754`
- **#47899: fix(cli): skip cua-driver refresh when /Applications is not writable**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#47884`, `#58754`, `#57034`, `#58760`

## Fix status

- Merged to main in PR #58754 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-05`.
- Subsystem: `hermes_cli`
