feat(computer-use): provision cua-driver at install time and on toolset enable

Choosing Computer Use should be a config flip, not a hunt for
'hermes computer-use install'. Three provisioning rungs:

- install.sh / install.ps1 pre-install cua-driver (best-effort,
  non-fatal, time-boxed at 660s above the upstream installer's 600s
  lock window; --skip-computer-use / -SkipComputerUse to opt out;
  Termux and unwritable-/Applications skipped cleanly)
- PUT /api/tools/toolsets/{name} (dashboard + desktop toggle) spawns
  the background 'hermes tools post-setup cua_driver' action when the
  toolset is enabled while the binary is missing — previously the
  toggle 'saved' but the tool never appeared in the schema because
  check_computer_use_requirements() couldn't find the binary
- hermes tools interactive flow already installed via
  _toolset_needs_configuration_prompt/_POST_SETUP_INSTALLED (unchanged)

Docs: computer-use.md enabling section rewritten around the new flow;
installation.md documents --skip-computer-use.
This commit is contained in:
Teknium committed 2026-08-13 02:44:48 -07:00
1 parent ae56c97c60
commit 7060ac7bed
8 files changed
+367 -8

No files matched your search

+42
View File
@@ -175,11 +175,53 @@ async def toggle_toolset(name: str, body: ToolsetToggle, profile: Optional[str]
_save_platform_tools(config, target_platform, enabled)
await asyncio.to_thread(_run)
# Install-on-enable: when the newly enabled toolset's provider carries a
# post_setup hook with a registered, UNSATISFIED install-state predicate
# (cua-driver binary missing, etc. — see _POST_SETUP_INSTALLED), spawn the
# same background install `hermes tools` runs interactively. Without this,
# a dashboard/desktop toggle "saves" but the tool silently never appears
# in the schema because its check_fn can't find the binary — the exact
# dead-end that forced users to discover `hermes computer-use install`
# by hand. Best-effort: a spawn failure never fails the toggle.
post_setup_started: Optional[str] = None
if body.enabled and name not in _CONFIG_ONLY_TOOLSETS:
def _pending_install_key() -> Optional[str]:
from hermes_cli.tools_config import (
TOOL_CATEGORIES,
_post_setup_already_installed,
_visible_providers,
)
cat = TOOL_CATEGORIES.get(name)
if not cat:
return None
with _profile_scope(body.profile or profile):
config = load_config()
for prov in _visible_providers(cat, config):
key = prov.get("post_setup")
if key and not _post_setup_already_installed(key):
return key
return None
try:
pending_key = await asyncio.to_thread(_pending_install_key)
if pending_key:
_spawn_hermes_action(
_profile_cli_args(body.profile or profile)
+ ["tools", "post-setup", pending_key],
"tools-post-setup",
)
post_setup_started = pending_key
except Exception:
_log.exception("install-on-enable post-setup spawn failed for %s", name)
return {
"ok": True,
"name": name,
"platform": target_platform,
"enabled": body.enabled,
"post_setup_started": post_setup_started,
}
+52
View File
@@ -15,6 +15,7 @@
param(
[switch]$NoVenv,
[switch]$SkipSetup,
[switch]$SkipComputerUse,
[string]$Branch = "main",
# -Commit and -Tag are higher-precedence variants of -Branch for users
# who need reproducible installs (desktop installer pinning, CI, release
@@ -3169,6 +3170,7 @@ function Install-NodeDeps {
}
Install-BrowserUseCli
Install-CuaDriver
}
# The Browser Use CLI is the default browser backend when it is runnable
@@ -3213,6 +3215,56 @@ function Install-BrowserUseCli {
}
}
# cua-driver powers the computer_use toolset (background desktop control).
# Provision it at install time so enabling the tool later -- via `hermes
# tools`, the dashboard, or the desktop app -- is a config flip, not a
# surprise multi-minute binary fetch. Best-effort and non-fatal: the enable
# paths still lazy-install via install_cua_driver() (hermes_cli/tools_config)
# when this step was skipped or failed.
function Install-CuaDriver {
if ($SkipComputerUse) {
Write-Info "Skipping Computer Use (cua-driver) install (-SkipComputerUse)"
return
}
if (Get-Command cua-driver -ErrorAction SilentlyContinue) {
Write-Success "Computer Use driver (cua-driver) already installed"
return
}
Write-Info "Installing Computer Use driver (cua-driver)..."
$prevEAP = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
# Same upstream installer `hermes computer-use install` runs. Bounded
# via a background job: the upstream installer serializes with its own
# lock (600s stale window), so the ceiling sits above that -- matching
# Hermes' _CUA_INSTALLER_TIMEOUT (660s).
$job = Start-Job -ScriptBlock {
Invoke-RestMethod -UseBasicParsing "https://raw.githubusercontent.com/trycua/cua/main/libs/cua-driver/scripts/install.ps1" | Invoke-Expression
}
if (Wait-Job $job -Timeout 660) {
Receive-Job $job -ErrorAction SilentlyContinue | Out-Null
Remove-Job $job -Force -ErrorAction SilentlyContinue
if (Get-Command cua-driver -ErrorAction SilentlyContinue) {
Write-Success "Computer Use driver installed (enable via 'hermes tools' -> Computer Use)"
} else {
Write-Warn "Computer Use driver install did not complete -- it will install on demand when you enable the tool."
Write-Info "Install later with: hermes computer-use install"
}
} else {
Stop-Job $job -ErrorAction SilentlyContinue
Remove-Job $job -Force -ErrorAction SilentlyContinue
Write-Warn "Computer Use driver install timed out -- it will install on demand when you enable the tool."
Write-Info "Install later with: hermes computer-use install"
}
} catch {
Write-Warn "Computer Use driver install failed: $_"
Write-Info "Install later with: hermes computer-use install"
} finally {
$ErrorActionPreference = $prevEAP
}
}
# Clear the cached Electron download + any half-written unpacked output so the
# next `npm run pack` re-downloads and re-stages from scratch. A corrupt zip in
# the per-user Electron download cache - most often a partial download resumed
+56
View File
@@ -70,6 +70,7 @@ DETECTED_BROWSER_EXECUTABLE=""
USE_VENV=true
RUN_SETUP=true
SKIP_BROWSER=false
SKIP_COMPUTER_USE=false
NO_SKILLS=false
BRANCH="main"
INSTALL_COMMIT=""
@@ -106,6 +107,10 @@ while [[ $# -gt 0 ]]; do
SKIP_BROWSER=true
shift
;;
--skip-computer-use)
SKIP_COMPUTER_USE=true
shift
;;
--no-skills)
NO_SKILLS=true
shift
@@ -165,6 +170,7 @@ while [[ $# -gt 0 ]]; do
echo " --no-venv Don't create virtual environment"
echo " --skip-setup Skip interactive setup wizard"
echo " --skip-browser Skip Playwright/Chromium install (browser tools won't work)"
echo " --skip-computer-use Skip the cua-driver (Computer Use) install"
echo " --no-skills Start with a blank slate — seed no bundled skills, and"
echo " write \$HERMES_HOME/.no-bundled-skills so future"
echo " 'hermes update' runs never inject bundled skills either"
@@ -2434,6 +2440,54 @@ install_browser_use_cli() {
fi
}
install_computer_use_driver() {
# cua-driver powers the computer_use toolset (background desktop control).
# Provision it at install time so enabling the tool later — via
# `hermes tools`, the dashboard, or the desktop app — is a config flip,
# not a surprise multi-minute binary fetch (the confusion this fixes:
# users had to discover `hermes computer-use install` on their own).
# Best-effort and non-fatal: the enable paths still lazy-install via
# install_cua_driver() when this step was skipped or failed.
if [ "$SKIP_COMPUTER_USE" = true ]; then
log_info "Skipping Computer Use (cua-driver) install (--skip-computer-use)"
return 0
fi
case "$DISTRO" in
termux)
return 0
;;
esac
if command -v cua-driver >/dev/null 2>&1; then
log_success "Computer Use driver (cua-driver) already installed"
return 0
fi
# Non-admin macOS accounts can't receive the CuaDriver.app bundle in
# /Applications; skip cleanly instead of failing loudly (#47865 class).
if [ "$(uname -s)" = "Darwin" ] && [ -d /Applications ] && [ ! -w /Applications ]; then
log_info "Skipping Computer Use driver (cua-driver): /Applications is not writable"
return 0
fi
log_info "Installing Computer Use driver (cua-driver)..."
# Same upstream installer `hermes computer-use install` runs; time-boxed
# so a stalled GitHub download can't hang the Hermes install. The
# upstream installer serializes with its own lock (600s stale window),
# so give it a ceiling above that — matching Hermes'
# _CUA_INSTALLER_TIMEOUT (660s).
local cua_log
cua_log="$(mktemp)"
if run_with_timeout 660 /bin/bash -c \
'curl -fsSL https://raw.githubusercontent.com/trycua/cua/main/libs/cua-driver/scripts/install.sh | /bin/bash' \
>"$cua_log" 2>&1; then
log_success "Computer Use driver installed (enable via 'hermes tools' → Computer Use)"
else
log_warn "Computer Use driver install failed — it will install on demand when you enable the tool."
log_info "Install later with: hermes computer-use install"
tail -n 5 "$cua_log" >&2 || true
fi
rm -f "$cua_log"
}
run_setup_wizard() {
if [ "$RUN_SETUP" = false ]; then
log_info "Skipping setup wizard (--skip-setup)"
@@ -3241,6 +3295,7 @@ run_stage_body() {
install_node_deps
install_uv
install_browser_use_cli
install_computer_use_driver
;;
path)
detect_os
@@ -3357,6 +3412,7 @@ main() {
install_deps
install_node_deps
install_browser_use_cli
install_computer_use_driver
setup_path
copy_config_templates
run_setup_wizard
@@ -0,0 +1,135 @@
"""Install-on-enable for toolset toggles (dashboard/desktop PUT endpoint).
When a toolset is toggled ON via ``PUT /api/tools/toolsets/{name}`` and its
provider carries a post_setup hook with a registered, UNSATISFIED
install-state predicate (``_POST_SETUP_INSTALLED`` — today: cua-driver),
the endpoint spawns the same background ``hermes tools post-setup <key>``
action the interactive CLI flow runs. Without this, a GUI toggle "saves"
but the tool never appears in the schema because its check_fn can't find
the binary.
"""
import pytest
class TestToggleToolsetInstallOnEnable:
@pytest.fixture(autouse=True)
def _setup(self, monkeypatch, _isolate_hermes_home):
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip("fastapi/starlette not installed")
import hermes_state
from hermes_constants import get_hermes_home
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
monkeypatch.setattr(
hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db"
)
self.client = TestClient(app)
self.client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
def _spawn_recorder(self, monkeypatch):
import hermes_cli.web_server as web_server
calls = []
class _FakeProc:
pid = 4242
def _fake_spawn(subcommand, name, **kwargs):
calls.append((tuple(subcommand), name))
return _FakeProc()
monkeypatch.setattr(web_server, "_spawn_hermes_action", _fake_spawn)
return calls
def test_enable_computer_use_spawns_cua_install_when_binary_missing(
self, monkeypatch
):
import hermes_cli.tools_config as tools_config
calls = self._spawn_recorder(monkeypatch)
# Binary missing → the cua_driver predicate reports unsatisfied.
monkeypatch.setattr(
tools_config, "_resolved_cua_driver_cmd", lambda: None
)
resp = self.client.put(
"/api/tools/toolsets/computer_use", json={"enabled": True}
)
assert resp.status_code == 200
data = resp.json()
assert data["enabled"] is True
assert data["post_setup_started"] == "cua_driver"
assert calls, "expected a background post-setup spawn"
subcommand, action_name = calls[0]
assert subcommand[-3:] == ("tools", "post-setup", "cua_driver")
assert action_name == "tools-post-setup"
def test_enable_computer_use_skips_install_when_binary_present(
self, monkeypatch
):
import hermes_cli.tools_config as tools_config
calls = self._spawn_recorder(monkeypatch)
monkeypatch.setattr(
tools_config, "_resolved_cua_driver_cmd", lambda: "/usr/bin/cua-driver"
)
resp = self.client.put(
"/api/tools/toolsets/computer_use", json={"enabled": True}
)
assert resp.status_code == 200
assert resp.json()["post_setup_started"] is None
assert calls == []
def test_disable_never_spawns_install(self, monkeypatch):
import hermes_cli.tools_config as tools_config
calls = self._spawn_recorder(monkeypatch)
monkeypatch.setattr(
tools_config, "_resolved_cua_driver_cmd", lambda: None
)
resp = self.client.put(
"/api/tools/toolsets/computer_use", json={"enabled": False}
)
assert resp.status_code == 200
assert resp.json()["post_setup_started"] is None
assert calls == []
def test_spawn_failure_does_not_fail_the_toggle(self, monkeypatch):
import hermes_cli.tools_config as tools_config
import hermes_cli.web_server as web_server
monkeypatch.setattr(
tools_config, "_resolved_cua_driver_cmd", lambda: None
)
def _boom(subcommand, name, **kwargs):
raise RuntimeError("spawn exploded")
monkeypatch.setattr(web_server, "_spawn_hermes_action", _boom)
resp = self.client.put(
"/api/tools/toolsets/computer_use", json={"enabled": True}
)
assert resp.status_code == 200
data = resp.json()
assert data["ok"] is True
assert data["enabled"] is True
assert data["post_setup_started"] is None
def test_toolsets_without_pending_post_setup_unchanged(self, monkeypatch):
"""A toolset with no registered install predicate keeps today's
contract — no spawn, post_setup_started stays None."""
calls = self._spawn_recorder(monkeypatch)
resp = self.client.put(
"/api/tools/toolsets/web", json={"enabled": True}
)
assert resp.status_code == 200
assert resp.json()["post_setup_started"] is None
assert calls == []
+2
View File
@@ -2307,6 +2307,8 @@ class TestNewEndpoints:
"name": "discord",
"platform": "discord",
"enabled": True,
# Install-on-enable: no provider post_setup pending for discord.
"post_setup_started": None,
}
config = load_config()
@@ -0,0 +1,67 @@
"""Regression tests: installers provision cua-driver (Computer Use).
Policy: choosing Computer Use should be a config flip, not a surprise
multi-minute binary fetch. The installers pre-install cua-driver
(best-effort, skippable), and the dashboard toggle auto-installs when the
binary is still missing (see test_web_routers_tools_install_on_enable.py).
"""
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
INSTALL_SH = REPO_ROOT / "scripts" / "install.sh"
INSTALL_PS1 = REPO_ROOT / "scripts" / "install.ps1"
class TestInstallSh:
def test_has_skip_flag(self) -> None:
text = INSTALL_SH.read_text()
assert "--skip-computer-use)" in text
assert "SKIP_COMPUTER_USE=true" in text
assert "SKIP_COMPUTER_USE=false" in text # default off
assert "--skip-computer-use Skip the cua-driver" in text
def test_install_function_wired_into_main_and_stage(self) -> None:
text = INSTALL_SH.read_text()
assert "install_computer_use_driver() {" in text
# main() flow and the node-deps stage both run it.
assert text.count("install_computer_use_driver\n") >= 2
def test_install_is_timeboxed_above_upstream_lock_window(self) -> None:
"""The upstream installer serializes on a lock with a 600s stale
window; a ceiling below that reintroduces the self-perpetuating
wedge (#58762). Must stay >= 660."""
text = INSTALL_SH.read_text()
assert "run_with_timeout 660 /bin/bash -c" in text
def test_install_is_best_effort(self) -> None:
text = INSTALL_SH.read_text()
assert "Computer Use driver install failed" in text
assert "hermes computer-use install" in text
def test_skips_unwritable_applications_dir(self) -> None:
"""Non-admin macOS accounts can't receive CuaDriver.app (#47865
class) — skip cleanly instead of failing every install."""
text = INSTALL_SH.read_text()
assert '[ -d /Applications ] && [ ! -w /Applications ]' in text
class TestInstallPs1:
def test_has_skip_switch(self) -> None:
text = INSTALL_PS1.read_text()
assert "[switch]$SkipComputerUse," in text
assert "if ($SkipComputerUse)" in text
def test_install_function_wired_into_node_deps(self) -> None:
text = INSTALL_PS1.read_text()
assert "function Install-CuaDriver {" in text
assert " Install-CuaDriver\n" in text
def test_install_is_timeboxed_above_upstream_lock_window(self) -> None:
text = INSTALL_PS1.read_text()
assert "Wait-Job $job -Timeout 660" in text
def test_install_is_best_effort(self) -> None:
text = INSTALL_PS1.read_text()
assert "Computer Use driver install timed out" in text
assert "hermes computer-use install" in text
@@ -132,6 +132,8 @@ Running Hermes as a dedicated unprivileged user (e.g. a `hermes` systemd service
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash -s -- --skip-browser
```
The installer also pre-installs [`cua-driver`](../user-guide/features/computer-use.md) so the Computer Use toolset works the moment you enable it; pass `--skip-computer-use` to opt out (it will then install on demand when you enable the tool).
3. **Make `hermes` available to the service user's shells.** The installer writes the launcher to `~/.local/bin/hermes`. System service accounts often have a minimal PATH that doesn't include `~/.local/bin`. Either add it to the user's environment, or symlink the launcher into a system location:
```bash
# Option A — add to the service user's profile
@@ -40,10 +40,18 @@ no-foreground invariant, click-dispatch internals — see
## Enabling
Pick whichever path is most convenient — both run the same upstream
installer:
**Fresh installs already have the driver.** The Hermes installer
(`install.sh` / `install.ps1`) pre-installs `cua-driver` (best-effort;
pass `--skip-computer-use` / `-SkipComputerUse` to opt out), so enabling
Computer Use is just a config flip:
**Option 1: dedicated CLI command (most direct).**
- **`hermes tools`** → pick `🖱️ Computer Use` — installs the driver
automatically if it's still missing.
- **Dashboard / desktop app** → toggle the Computer Use toolset — if the
driver is missing, the toggle kicks off the install in the background
automatically (watch progress in the toolset panel).
**Manual fallback (older installs, skipped installer step):**
```
hermes computer-use install
@@ -53,11 +61,6 @@ This fetches and runs the upstream cua-driver installer — `install.sh`
on macOS/Linux, `install.ps1` on Windows. Use `hermes computer-use
status` to verify the install.
**Option 2: enable the toolset interactively.**
1. Run `hermes tools`, pick `🖱️ Computer Use (macOS/Windows/Linux)`.
2. The setup runs the upstream installer (same as Option 1).
After installing, regardless of which path you took, grant the
platform-appropriate prereqs: