Merge branch 'simp/r2-config-tools' into simp/integration2

This commit is contained in:
Teknium
2026-09-02 17:05:18 -07:00
14 changed files with 3695 additions and 4526 deletions
+505 -4427
View File
File diff suppressed because it is too large Load Diff
+939
View File
@@ -0,0 +1,939 @@
"""cua-driver installer, lock hygiene and pip-install helper for `hermes tools` / `hermes computer-use install`."""
from __future__ import annotations
import logging
import os
import shutil
import subprocess
import sys
from pathlib import Path
from typing import List, Optional
from hermes_cli.cli_output import (
print_info as _print_info, print_success as _print_success, print_warning as _print_warning,
)
logger = logging.getLogger("hermes_cli.tools_config")
def _post_setup_no_window_flags(*, streams_to_console: bool = False) -> int:
"""Win32 creationflags that stop post-setup children flashing a console.
CREATE_NO_WINDOW hides console grandchildren (npm, pip, powershell) while keeping stdio inheritable
(unlike DETACHED_PROCESS). Returns 0 on POSIX. ``streams_to_console`` children are only hidden when
our own stdout is not a console, so live installer output is never swallowed.
"""
from hermes_cli._subprocess_compat import windows_hide_flags
flags = windows_hide_flags()
if not flags:
return 0
if streams_to_console:
try:
if sys.stdout is not None and sys.stdout.isatty():
return 0
except Exception:
pass
return flags
def _cua_driver_cmd() -> str:
"""Return the configured cua-driver override, or the bare default name."""
return os.environ.get("HERMES_CUA_DRIVER_CMD", "").strip() or "cua-driver"
def _cua_version_summary(raw: str, *, limit: int = 120) -> str:
"""First non-empty line of ``--version`` output, bounded (an override may print a multi-line banner)."""
for line in (raw or "").splitlines():
text = line.strip()
if text:
return text[:limit]
return ""
def _resolved_cua_driver_cmd() -> Optional[str]:
"""Resolve cua-driver exactly as the runtime and Desktop status do."""
from tools.computer_use.cua_backend import resolve_cua_driver_cmd
return resolve_cua_driver_cmd()
def _cua_driver_env() -> dict:
"""cua-driver child env with the Hermes telemetry policy applied (``cua_backend.cua_driver_child_env``).
Falls back to the current environment if the helper can't be imported, so install/status never break.
"""
try:
from tools.computer_use.cua_backend import cua_driver_child_env
return cua_driver_child_env()
except Exception:
return dict(os.environ)
_CUA_DRIVER_CONTRACT_CACHE: dict = {}
def _cua_driver_contract_status(binary: Optional[str] = None) -> dict:
"""Inspect whether an installed driver supports Hermes' runtime contract."""
import time
from tools.computer_use.cua_backend import cua_driver_runtime_contract_status
resolved = binary or _resolved_cua_driver_cmd()
if not resolved:
return cua_driver_runtime_contract_status(None)
try:
stat = os.stat(resolved)
fingerprint = (resolved, stat.st_mtime_ns, stat.st_size)
except OSError:
return cua_driver_runtime_contract_status(resolved)
now = time.monotonic()
if (
_CUA_DRIVER_CONTRACT_CACHE.get("fingerprint") == fingerprint
and now - _CUA_DRIVER_CONTRACT_CACHE.get("checked_at", 0.0) < 30.0
):
return dict(_CUA_DRIVER_CONTRACT_CACHE["state"])
state = cua_driver_runtime_contract_status(resolved)
_CUA_DRIVER_CONTRACT_CACHE.update(fingerprint=fingerprint, checked_at=now, state=dict(state))
return state
def _cua_driver_install_ready() -> bool:
"""Return whether an existing driver needs no install-time repair."""
if not _cua_driver_contract_status().get("ready"):
return False
return sys.platform != "win32" or _cua_driver_autostart_registered_windows()
def _pip_install(args: List[str], *, timeout: int = 300, capture_output: bool = True):
"""Install Python packages from a post-setup hook.
Order: ``uv pip install`` (fast, needs no pip in the venv), then ``python -m pip install``, then
``python -m ensurepip --upgrade`` and retry pip. The last tier exists because the Windows installer
creates the venv via ``uv venv``, which does NOT seed pip, so bare ``-m pip`` failed on fresh installs.
"""
venv_root = Path(sys.executable).parent.parent
uv_env = {**os.environ, "VIRTUAL_ENV": str(venv_root)}
# Managed uv first: $HERMES_HOME/bin is never on PATH, so a bare which() misses the uv Hermes installed;
# ensure_uv() (not a pure lookup) because installing uv is in scope during setup.
from hermes_cli.managed_uv import ensure_uv
uv_bin = ensure_uv()
if uv_bin:
try:
result = subprocess.run(
[uv_bin, "pip", "install", *args],
capture_output=capture_output, text=True, encoding="utf-8", errors="replace", timeout=timeout,
env=uv_env,
creationflags=_post_setup_no_window_flags(streams_to_console=not capture_output),
)
if result.returncode == 0:
return result
# Fall through to pip — uv may have failed for a reason pip can handle.
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
pip_cmd = [sys.executable, "-m", "pip"]
try:
# Probe for pip; bootstrap via ensurepip if missing (uv venv lacks it).
probe = subprocess.run(
pip_cmd + ["--version"],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=15,
creationflags=_post_setup_no_window_flags(),
)
if probe.returncode != 0:
raise FileNotFoundError("pip not in venv")
except (subprocess.TimeoutExpired, FileNotFoundError):
try:
subprocess.run(
[sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=120, check=True,
creationflags=_post_setup_no_window_flags(),
)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
# Synthesize a result so callers see a clean failure path.
return subprocess.CompletedProcess(
pip_cmd, returncode=1, stdout="", stderr=f"pip not available and ensurepip failed: {e}",
)
return subprocess.run(
pip_cmd + ["install", *args],
capture_output=capture_output, text=True, encoding="utf-8", errors="replace", timeout=timeout,
creationflags=_post_setup_no_window_flags(streams_to_console=not capture_output),
)
# No pre-install release/asset probe: cua-driver-rs releases are all prereleases, which GitHub's
# `/releases/latest` skips (zero binary assets → every non-arm64 host skipped the install), and
# re-implementing upstream's tag resolution here would drift. Fresh installs run install.sh directly (it
# errors clean on a missing-arch asset); upgrades ask the binary via `cua_driver_update_check()`.
def _cua_install_target_writable() -> bool:
"""Return whether the upstream installer can write its app bundle target."""
if sys.platform != "darwin":
return True
try:
return not os.path.isdir("/Applications") or os.access("/Applications", os.W_OK)
except Exception:
return True
def _cua_driver_version(binary: str) -> Optional[str]:
"""``<binary> --version`` stdout (possibly ""), or None when the probe itself fails."""
try:
return subprocess.run(
[binary, "--version"],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=5, env=_cua_driver_env(),
creationflags=_post_setup_no_window_flags(),
).stdout.strip()
except Exception:
return None
def _confirmed_update_check(driver_cmd: str, require_confirmed_update: bool) -> tuple:
"""Ask the installed driver whether a newer release exists; returns ``(proceed, pin_version)``.
``proceed=False`` means stop with success (already latest, or indeterminate under
``require_confirmed_update``). An old driver (no check-update verb) or offline check yields None:
`hermes update` then keeps the installed version — an indeterminate check must never cost a
multi-minute silent reinstall on every update — while explicit `install --upgrade` falls through.
"""
try:
from tools.computer_use.cua_backend import cua_driver_update_check
_state = cua_driver_update_check()
except Exception:
_state = None
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 False, None
if _state is None and require_confirmed_update:
_print_info(
f" Could not confirm a newer {driver_cmd} release "
"(offline, rate-limited, or driver too old to check); "
"keeping the installed version."
)
_print_info(" Force a refresh with: hermes computer-use install --upgrade")
return False, None
confirmed_version = None
if _state is not None and _state.get("update_available"):
# Windows routine upgrades run unattended-safe (stdin closed, version pinned, ceiling
# _CUA_BACKGROUND_UPDATE_TIMEOUT, preflights skip in seconds); only contract repairs and fresh
# installs stay interactive-only, where upstream needs a human (autostart elevation, SmartScreen).
# Pin to the release check-update confirmed: `latest_version` comes from the GitHub Releases API so
# its assets exist, unlike the installer's baked version on `main`, which is bumped before assets
# are published and 404s unpinned. Malformed values are ignored → unpinned fallback.
import re as _re
_latest = str(_state.get("latest_version") or "").strip().lstrip("vV")
if _re.fullmatch(r"\d+(\.\d+)*", _latest):
confirmed_version = _latest
return True, confirmed_version
def install_cua_driver(
upgrade: bool = False,
require_confirmed_update: bool = False,
show_installer_progress: bool = True,
) -> bool:
"""Install or refresh the cua-driver binary used by Computer Use.
The upstream installer always pulls the latest release tag, so re-running it is the canonical way to
upgrade. ``upgrade=False`` (toolset enable flow) keeps a compatible installation, repairs an
old/incomplete one and installs when missing; ``upgrade=True`` always refreshes.
"""
import platform as _plat
system = _plat.system()
if system not in ("Darwin", "Windows", "Linux"):
if upgrade:
# Silent: `hermes update` calls this for every user.
return False
_print_warning(" Computer Use (cua-driver) is unsupported on this platform; skipping.")
return False
is_windows = system == "Windows"
is_linux = system == "Linux"
# install.ps1 is fetched via PowerShell's `irm`; macOS/Linux use curl | bash.
fetch_tool = "powershell" if is_windows else "curl"
driver_cmd = _cua_driver_cmd()
binary = _resolved_cua_driver_cmd()
# An explicit override is authoritative even when broken: installing the standard driver
# cannot repair the configured path and would mutate an unrelated installation.
override = os.environ.get("HERMES_CUA_DRIVER_CMD", "").strip()
if override and not binary:
_print_warning(f" HERMES_CUA_DRIVER_CMD does not resolve to an executable: {override}")
_print_info(" Fix or unset the override before running computer-use install.")
return False
# 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")
return False
return _run_cua_driver_installer(label="Installing")
# A driver failing Hermes' runtime contract (version floor, missing manifest verbs) is repaired
# regardless of mode. Hermes' minimum requirement IS the confirmation an upgrade is needed, so this
# path must not defer to the driver's `check-update` verb — a cached/indeterminate "no update"
# answer would pin users on an unusable driver forever.
contract = _cua_driver_contract_status(binary) if binary else None
repair_existing = bool(binary and contract and not contract.get("ready"))
# Compatible existing install: no download, just the host-specific setup upstream normally owns.
if binary and not upgrade and not repair_existing:
version = _cua_driver_version(binary)
if version is None:
_print_success(f" {driver_cmd} already installed.")
else:
_print_success(f" {driver_cmd} already installed: {version or 'unknown version'}")
if is_windows and not _repair_cua_driver_autostart_windows(binary, verbose=False):
_print_warning(" cua-driver is compatible, but Windows autostart repair failed.")
return False
_print_cua_platform_notes(is_windows, is_linux, fresh_install=False)
return True
if repair_existing:
version = contract.get("version") or "unknown version"
reason = contract.get("reason") or "required runtime features are missing"
_print_warning(
f" Found cua-driver {version}, but Hermes cannot use its current runtime contract: {reason}."
)
if os.environ.get("HERMES_CUA_DRIVER_CMD", "").strip():
_print_info(
" Update the binary selected by HERMES_CUA_DRIVER_CMD, or unset "
"the override and run: hermes computer-use install --upgrade"
)
return False
if is_windows and require_confirmed_update:
_print_info(
" Automatic Windows updates cannot safely run cua-driver's interactive repair installer."
)
_print_info(
" Repair it from an interactive terminal with: hermes computer-use install --upgrade"
)
return False
_print_info(" Repairing it with the current upstream installer.")
# 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)
confirmed_version = None
if binary and not repair_existing:
proceed, confirmed_version = _confirmed_update_check(driver_cmd, require_confirmed_update)
if not proceed:
return True
if is_windows and require_confirmed_update and not binary:
# Missing binary (enabled but never installed, or wiped by a failed install): an automatic Windows
# update must never launch install.ps1, which can demand console/UAC consent the hidden updater
# cannot provide.
_print_info(
" cua-driver is not installed; automatic Windows updates "
"cannot safely run its interactive installer."
)
_print_info(" Install it from an interactive terminal with: hermes computer-use install --upgrade")
return False
# Best-effort before/after version display.
before = (_cua_driver_version(binary) or "") if binary else ""
ok = _run_cua_driver_installer(
label="Repairing" if repair_existing else "Refreshing",
verbose=False,
pin_version=confirmed_version,
show_progress=show_installer_progress,
installer_timeout=_CUA_BACKGROUND_UPDATE_TIMEOUT if require_confirmed_update else None,
)
if ok and repair_existing:
repaired = _cua_driver_contract_status()
if not repaired.get("ready"):
_print_warning(
" cua-driver was reinstalled, but its runtime contract is still "
f"unusable: {repaired.get('reason') or 'unknown error'}."
)
_print_info(" Run: hermes computer-use doctor")
return False
if ok and before:
after = _cua_driver_version(binary)
if after and after != before:
_print_success(f" {driver_cmd} upgraded: {before} → {after}")
elif after:
_print_info(f" {driver_cmd} up to date: {after}")
return ok
# Ceiling for one upstream-installer run: must exceed the installer's own stale-lock recovery window
# (_install-rust.sh force-releases a dead holder's lock only after LOCK_STALE_AFTER_SECONDS=600; a shorter
# timeout kills every run before that fires — a permanent wedge). 660s = 600s + 60s headroom.
_CUA_INSTALLER_TIMEOUT = 660
# Grace for draining the installer's pipes after a timeout kill. The kill is best-effort (_reap_after_timeout)
# so the drain must be bounded: a surviving descendant holding the inherited stdout handle would otherwise
# make the read wait on an EOF that never comes. A successful kill closes the pipe at once, so this is free.
_CUA_INSTALLER_DRAIN_GRACE = 15
# Quiet unattended refreshes from ``hermes update``: bounded even when upstream waits on Read-Host or a
# consent prompt; explicit ``computer-use install --upgrade`` keeps the full ceiling. Safe because the
# lock/network preflights make a legitimate long wait impossible here.
_CUA_BACKGROUND_UPDATE_TIMEOUT = 120
# Upstream installer's stale-lock threshold (LOCK_STALE_AFTER_SECONDS in _install-rust.sh), so the
# pre-clear never yanks a lock a live-but-slow install still holds.
_CUA_LOCK_STALE_AFTER = 600
def _cua_install_home() -> "Path":
"""Package home shared by the upstream POSIX and Windows installers."""
return Path(os.environ.get("CUA_DRIVER_RS_HOME") or str(Path.home() / ".cua-driver"))
def _cua_install_lock_dir() -> "Path":
"""Path of the upstream installer's concurrent-install lock dir."""
return _cua_install_home() / "packages" / ".install.lock.d"
def _cua_windows_install_lock_file() -> "Path":
"""Path of install.ps1's FileShare::None lock file."""
return _cua_install_home() / "install.lock"
def _clear_stale_windows_cua_install_lock() -> None:
"""Delete install.ps1's lock file only when no process still holds it.
install.ps1 locks with ``FileShare::None``; mirror it with a zero-share ``CreateFileW`` probe and
``FILE_FLAG_DELETE_ON_CLOSE`` so an unlocked leftover is removed atomically, with no window in which
a new installer could acquire the file between probe and delete.
"""
lock_file = _cua_windows_install_lock_file()
try:
if not lock_file.is_file():
return
import ctypes as _ctypes
from ctypes import wintypes as _wintypes
# Win32 constants used by install.ps1's FileShare::None equivalent.
delete_access = 0x00010000
generic_read = 0x80000000
generic_write = 0x40000000
open_existing = 3
file_attribute_normal = 0x00000080
file_flag_delete_on_close = 0x04000000
kernel32 = _ctypes.WinDLL("kernel32", use_last_error=True)
create_file = kernel32.CreateFileW
create_file.argtypes = [
_wintypes.LPCWSTR, _wintypes.DWORD, _wintypes.DWORD, _wintypes.LPVOID,
_wintypes.DWORD, _wintypes.DWORD, _wintypes.HANDLE,
]
create_file.restype = _wintypes.HANDLE
close_handle = kernel32.CloseHandle
close_handle.argtypes = [_wintypes.HANDLE]
close_handle.restype = _wintypes.BOOL
handle = create_file(
str(lock_file),
generic_read | generic_write | delete_access,
0, # FileShare::None
None,
open_existing,
file_attribute_normal | file_flag_delete_on_close,
None,
)
invalid_handle = _wintypes.HANDLE(-1).value
if handle == invalid_handle:
logger.debug(
"Windows cua install lock at %s is still held or cannot be removed (winerror %s)",
lock_file, _ctypes.get_last_error(),
)
return
if not close_handle(handle):
logger.debug(
"could not close Windows cua install lock probe at %s (winerror %s)",
lock_file, _ctypes.get_last_error(),
)
return
if lock_file.exists():
logger.debug("Windows cua install lock probe succeeded but %s remains", lock_file)
return
logger.info("Cleared stale Windows cua-driver install lock at %s", lock_file)
_print_info(f" Cleared stale cua-driver install lock ({lock_file}).")
except Exception as e:
logger.debug("stale Windows cua install lock check failed: %s", e)
def _clear_stale_cua_install_lock() -> None:
"""Best-effort: remove a stale installer lock left by a dead holder.
POSIX stamps the holder pid into ``~/.cua-driver/packages/.install.lock.d/info``; Windows holds
``~/.cua-driver/install.lock`` open with ``FileShare::None``. Clear either artifact up front only
when its platform-specific liveness check proves that no install still holds it.
"""
if sys.platform == "win32":
_clear_stale_windows_cua_install_lock()
return
lock_dir = _cua_install_lock_dir()
try:
if not lock_dir.is_dir():
return
holder_pid = None
info = lock_dir / "info"
try:
for line in info.read_text(encoding="utf-8", errors="replace").splitlines():
if line.startswith("pid="):
holder_pid = int(line.split("=", 1)[1].strip())
break
except (OSError, ValueError):
holder_pid = None
if holder_pid is not None:
try:
os.kill(holder_pid, 0) # windows-footgun: ok — function early-returns on win32
return # holder alive → concurrent install running
except ProcessLookupError:
pass # dead holder → stale, clear below
except PermissionError:
return # alive but owned by someone else — treat as live
else:
# No readable pid: only clear if old enough that upstream itself would reclaim it.
import time as _time
try:
age = _time.time() - lock_dir.stat().st_mtime
except OSError:
return
if age < _CUA_LOCK_STALE_AFTER:
return
shutil.rmtree(lock_dir, ignore_errors=True)
logger.info("Cleared stale cua-driver install lock at %s", lock_dir)
_print_info(f" Cleared stale cua-driver install lock ({lock_dir}).")
except Exception as e:
logger.debug("stale cua install lock check failed: %s", e)
def _cua_install_lock_held() -> bool:
"""True when the upstream installer's lock is held by a LIVE process.
Called after ``_clear_stale_cua_install_lock()``: anything provably stale is already gone, so a
surviving lock artifact means a concurrent (or orphaned-but-alive) install owns it.
"""
try:
if sys.platform == "win32":
lock_file = _cua_windows_install_lock_file()
if not lock_file.is_file():
return False
# install.ps1 holds the file with FileShare::None — any open fails with a sharing violation
# while held. Surviving the stale-clear = held; confirm with an open probe.
try:
with open(lock_file, "r+b"):
return False # opened fine → not held (racy leftover)
except OSError: # sharing violation surfaces as PermissionError
return True
return _cua_install_lock_dir().is_dir()
except Exception as e:
logger.debug("cua install lock probe failed: %s", e)
return False
def _cua_release_endpoint_reachable(timeout: float = 5.0) -> bool:
"""Fast probe: can we reach GitHub's release download host at all?
When github.com is down the installer dies slowly inside its own retries and eats the whole unattended
ceiling; a 5s HEAD decides in seconds. Only a connection-level failure counts as unreachable — any
HTTP response (even 4xx/5xx) proves the path works.
"""
import urllib.error
import urllib.request
try:
req = urllib.request.Request("https://github.com/trycua/cua/releases", method="HEAD")
with urllib.request.urlopen(req, timeout=timeout):
return True
except urllib.error.HTTPError:
return True # server answered → reachable
except Exception as e:
logger.debug("cua release endpoint probe failed: %s", e)
return False
def _ps_single_quote(value: str) -> str:
"""Return a PowerShell single-quoted string literal."""
return "'" + value.replace("'", "''") + "'"
def _cua_driver_autostart_registered_windows() -> bool:
"""Return whether the Windows cua-driver scheduled task is registered."""
if sys.platform != "win32":
return False
try:
result = subprocess.run(
["schtasks.exe", "/Query", "/TN", "cua-driver-serve"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10,
)
except Exception:
return False
return result.returncode == 0
def _repair_cua_driver_autostart_windows(driver_cmd: str, *, verbose: bool) -> bool:
"""Best-effort repair for Windows installer autostart quoting failures.
Older install.ps1 builds interpolated the binary path into a PowerShell command string, which split at
the first space. If the scheduled task is missing, retry via Start-Process's structured ``-FilePath`` /
``-ArgumentList`` parameters instead.
"""
if sys.platform != "win32":
return True
if _cua_driver_autostart_registered_windows():
return True
binary = shutil.which(driver_cmd)
if not binary:
return False
ps = shutil.which("powershell") or shutil.which("powershell.exe") or "powershell"
ps_cmd = (
f"$exe = {_ps_single_quote(binary)}; "
"$proc = Start-Process -FilePath $exe "
"-ArgumentList @('autostart','enable') "
"-Verb RunAs -Wait -PassThru -ErrorAction Stop; "
"exit $proc.ExitCode"
)
if verbose:
_print_info(" Registering cua-driver auto-start...")
else:
_print_info(" Repairing cua-driver auto-start registration...")
try:
result = subprocess.run(
[ps, "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps_cmd],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=300,
env=_cua_driver_env(),
)
except subprocess.TimeoutExpired:
_print_warning(" cua-driver autostart registration timed out.")
return False
except Exception as exc:
_print_warning(f" cua-driver autostart registration failed: {exc}")
return False
if result.returncode == 0:
return True
tail = (result.stderr or result.stdout or "").strip().splitlines()[-3:]
_print_warning(" cua-driver autostart registration failed.")
for line in tail:
_print_info(f" {line[:200]}")
_print_info(" From an elevated shell, run: cua-driver autostart enable")
return False
def _remove_quietly(path: str) -> None:
try:
os.remove(path)
except OSError:
pass
def _print_cua_platform_notes(is_windows: bool, is_linux: bool, *, fresh_install: bool) -> None:
"""Host-specific follow-up notes after an install or a compatible-install check."""
if is_windows:
_print_info(" cua-driver may spawn a UIAccess worker (cua-driver-uia.exe);")
_print_info(" Windows/SmartScreen may prompt the first time it runs.")
elif is_linux:
_print_warning(" Linux support is alpha.")
elif fresh_install:
_print_info(" IMPORTANT — grant macOS permissions now:")
_print_info(" System Settings > Privacy & Security > Accessibility")
_print_info(" System Settings > Privacy & Security > Screen Recording")
_print_info(" Both must allow the terminal / Hermes process.")
else:
_print_info(" Grant macOS permissions if not done yet:")
_print_info(" System Settings > Privacy & Security > Accessibility")
_print_info(" System Settings > Privacy & Security > Screen Recording")
_CUA_INSTALL_PS1_URL = "https://raw.githubusercontent.com/trycua/cua/main/libs/cua-driver/scripts/install.ps1"
def _kill_installer_tree(proc, *, is_windows: bool) -> None:
"""Kill the installer and its descendants (best-effort)."""
import signal as _signal
try:
if not is_windows:
os.killpg(os.getpgid(proc.pid), _signal.SIGKILL) # windows-footgun: ok — POSIX branch only
else:
# PowerShell may leave download/install helpers alive after its direct process is killed. They
# inherit stdout and can keep communicate() and install.lock wedged, so kill the tree leaf-up.
import psutil as _psutil
try:
parent = _psutil.Process(proc.pid)
descendants = parent.children(recursive=True)
except _psutil.NoSuchProcess:
return
except _psutil.Error as e:
logger.debug(
"could not enumerate cua-driver installer tree for pid %s: %s", proc.pid, e
)
proc.kill()
return
for child in reversed(descendants):
try:
child.kill()
except _psutil.NoSuchProcess:
pass
except _psutil.Error as e:
logger.debug("could not kill cua-driver installer child pid %s: %s", child.pid, e)
try:
parent.kill()
except _psutil.NoSuchProcess:
pass
except _psutil.Error as e:
logger.debug("could not kill cua-driver installer parent pid %s: %s", proc.pid, e)
proc.kill()
except (OSError, ProcessLookupError):
proc.kill()
def _reap_after_timeout(proc, *, is_windows: bool) -> None:
"""Kill the installer tree, then drain its pipes under a deadline.
An unbounded drain blocks on an EOF that only arrives when someone kills a surviving descendant by
hand, so ``_CUA_INSTALLER_TIMEOUT`` would stop bounding anything.
"""
_kill_installer_tree(proc, is_windows=is_windows)
try:
drained_out, _ = proc.communicate(timeout=_CUA_INSTALLER_DRAIN_GRACE)
# The partial output names WHERE the installer was stuck (lock wait, consent prompt, download).
if drained_out:
logger.warning(
"cua-driver installer timed out; last output before kill:\n%s", drained_out[-2000:],
)
except subprocess.TimeoutExpired:
# Deliberately not closing proc.stdout: communicate()'s reader threads are still blocked on that
# handle and closing it underneath them races; they are daemon threads.
logger.debug(
"cua-driver installer pipes still open %ss after the kill — "
"abandoning the drain, a surviving descendant holds the inherited handle",
_CUA_INSTALLER_DRAIN_GRACE,
)
except (OSError, ValueError) as e:
logger.debug("cua-driver installer drain failed: %s", e)
def _cua_installer_command(is_windows: bool):
"""Return ``(install_cmd, manual_hint, script_path)`` or None when the POSIX download fails."""
if is_windows:
# Mirror the one-liner printed by cua_driver_install_hint().
ps_oneliner = f"irm {_CUA_INSTALL_PS1_URL} | iex"
install_cmd = ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps_oneliner]
manual_hint = f'powershell -NoProfile -ExecutionPolicy Bypass -Command "{ps_oneliner}"'
return install_cmd, manual_hint, None
# Download-then-exec instead of `bash -c "$(curl …)"`: no shell=True, no command substitution, and the
# script lands in a mkstemp file (unpredictable name, 0600) rather than a fixed /tmp path — avoiding
# both shell injection and a symlink/TOCTOU race. The manual hint stays the upstream one-liner.
import tempfile as _tempfile
install_url = "https://raw.githubusercontent.com/trycua/cua/main/libs/cua-driver/scripts/install.sh"
manual_hint = f'/bin/bash -c "$(curl -fsSL {install_url})"'
fd, script_path = _tempfile.mkstemp(prefix="cua-driver-install-", suffix=".sh")
os.close(fd)
try:
dl = subprocess.run(
["curl", "-fsSL", "-o", script_path, install_url],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=120,
)
failure = None if dl.returncode == 0 else (dl.stderr or "").strip()[:200]
except (subprocess.TimeoutExpired, OSError) as e:
failure = str(e)
if failure is not None:
_print_warning(f" cua-driver installer download failed: {failure}")
_remove_quietly(script_path)
return None
return ["/bin/bash", script_path], manual_hint, script_path
def _unattended_installer_preflight(install_cmd: list, is_windows: bool):
"""Fail FAST on the two conditions that otherwise consume the whole unattended ceiling.
1. Install lock held by a live process — upstream would poll it for up to LOCK_STALE_AFTER_SECONDS=600
before probing the holder (the 11-minute silent hang class).
2. Release host unreachable — the installer would die slowly inside its own retries; a 5s HEAD answers.
Returns the (possibly rewritten) install command, or None to skip this refresh. Explicit
`computer-use install --upgrade` runs never come here and keep upstream's full lock-recovery.
"""
if _cua_install_lock_held():
_print_info(
" Another cua-driver install is in progress (upstream "
"install lock is held) — skipping this refresh."
)
_print_info(" If no install is really running, retry with: hermes computer-use install --upgrade")
return None
if not _cua_release_endpoint_reachable():
_print_info(
" github.com is unreachable — skipping cua-driver refresh (will retry on the next update)."
)
return None
if is_windows:
# -NoAutoStart skips Register-CuaDriverAutostart — the ONLY branch of install.ps1 that self-elevates
# (UAC). Cost: an existing cua-driver-serve task keeps pointing at the previous binary until the
# next interactive upgrade. Scriptblock invocation (not `| iex`) is what lets us pass the parameter.
install_cmd = [
"powershell", "-NoProfile", "-ExecutionPolicy", "Bypass",
"-Command", f"$sc = irm {_CUA_INSTALL_PS1_URL}; & ([scriptblock]::Create($sc)) -NoAutoStart",
]
return install_cmd
def _run_cua_driver_installer(
label: str = "Installing",
verbose: bool = True,
pin_version: Optional[str] = None,
show_progress: bool = True,
installer_timeout: Optional[float] = None,
) -> bool:
"""Run the upstream cua-driver installer for this platform.
The scripts are idempotent: they always download the latest release, so re-running on an
already-installed system performs an upgrade. ``installer_timeout`` lets quiet callers use a shorter
ceiling without weakening the explicit install path's stale-lock recovery window.
"""
import platform as _plat
system = _plat.system()
is_windows = system == "Windows"
is_linux = system == "Linux"
prepared = _cua_installer_command(is_windows)
if prepared is None:
return False
install_cmd, manual_hint, script_path = prepared
if show_progress:
if verbose:
_print_info(f" {label} cua-driver (background computer-use)...")
else:
_print_info(f"→ {label} cua-driver (Computer Use)...")
driver_cmd = _cua_driver_cmd()
timeout = _CUA_INSTALLER_TIMEOUT if installer_timeout is None else installer_timeout
installer_env = _cua_driver_env()
if pin_version:
# Both upstream installers honour CUA_DRIVER_RS_VERSION over their baked default.
installer_env["CUA_DRIVER_RS_VERSION"] = pin_version
# A previous timed-out install can leave upstream's concurrent-install lock behind; clear it when
# provably stale so the refresh doesn't wedge waiting on a dead holder.
_clear_stale_cua_install_lock()
# Unattended refreshes (installer_timeout set by `hermes update`) preflight and may skip.
if installer_timeout is not None:
install_cmd = _unattended_installer_preflight(install_cmd, is_windows)
if install_cmd is None:
return False
# POSIX: own process group so a timeout kill takes out the whole `curl | bash` pipeline (and the exec'd
# _install-rust.sh), not just the outer shell — surviving grandchildren would keep holding the install
# lock and wedge every later run.
popen_kwargs = {}
if not is_windows:
popen_kwargs["start_new_session"] = True
try:
# Non-verbose (`hermes update` refresh): capture the installer's chatty "Next steps" wall and log it
# so a failure stays debuggable. Verbose interactive installs stream live.
if verbose:
proc = subprocess.Popen(
install_cmd, shell=False, env=installer_env,
creationflags=_post_setup_no_window_flags(streams_to_console=True),
**popen_kwargs
)
try:
proc.communicate(timeout=timeout)
except subprocess.TimeoutExpired:
_reap_after_timeout(proc, is_windows=is_windows)
raise
result = subprocess.CompletedProcess(install_cmd, proc.returncode, stdout=None, stderr=None)
else:
proc = subprocess.Popen(
install_cmd, shell=False, env=installer_env,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, encoding="utf-8", errors="replace",
creationflags=_post_setup_no_window_flags(),
**popen_kwargs
)
try:
out, _ = proc.communicate(timeout=timeout)
except subprocess.TimeoutExpired:
_reap_after_timeout(proc, is_windows=is_windows)
raise
result = subprocess.CompletedProcess(install_cmd, proc.returncode, stdout=out, stderr=None)
# During `hermes update`, sys.stdout is the mirroring _UpdateOutputStream whose `_log` handle is
# ~/.hermes/logs/update.log — write straight to it so the full installer output is kept
# (success AND failure) without echoing it to the terminal.
if result.stdout:
_update_log = getattr(sys.stdout, "_log", None)
if _update_log is not None:
try:
_update_log.write("\n--- cua-driver installer output ---\n" + result.stdout + "\n")
_update_log.flush()
except Exception:
pass
if result.returncode != 0:
logger.debug("cua-driver installer output:\n%s", result.stdout)
installed_binary = _resolved_cua_driver_cmd()
if result.returncode == 0 and installed_binary:
if is_windows and not _repair_cua_driver_autostart_windows(installed_binary, verbose=verbose):
_print_warning(" cua-driver installed, but auto-start was not registered.")
if verbose:
_print_success(f" {driver_cmd} installed.")
_print_cua_platform_notes(is_windows, is_linux, fresh_install=True)
return True
_print_warning(f" cua-driver {label.lower()} did not complete. Re-run manually:")
_print_info(f" {manual_hint}")
return False
except subprocess.TimeoutExpired:
_print_warning(f" cua-driver {label.lower()} timed out after {timeout}s.")
if not is_windows:
_print_info(
" If this repeats, a stale installer lock may be present — "
f"check {_cua_install_lock_dir()}"
)
_print_info(f" Re-run manually: {manual_hint}")
return False
except Exception as e:
_print_warning(f" cua-driver {label.lower()} failed: {e}")
return False
finally:
if script_path:
_remove_quietly(script_path)
+311
View File
@@ -0,0 +1,311 @@
"""MCP tool checklists and the non-interactive ``hermes tools enable|disable|list`` command."""
from __future__ import annotations
from typing import List, Set
from hermes_cli.cli_output import (
print_error as _print_error,
print_info as _print_info,
print_success as _print_success,
print_warning as _print_warning,
)
from hermes_cli.colors import Colors, color
from hermes_cli.toolset_scope import (
_TOOLSET_PLATFORM_RESTRICTIONS,
toolset_allowed_for_platform as _toolset_allowed_for_platform,
)
def _mcp_match_filter():
"""Runtime name-filter matcher (exact names or fnmatch globs), with a literal fallback.
Must use the SAME semantics as tools/mcp_tool.py registration — a literal ``in`` check renders
glob excludes (e.g. ``*team_member*`` from catalog default_excluded manifests) as if nothing
were excluded.
"""
try:
from tools.mcp_tool import matches_name_filter
return matches_name_filter
except ImportError: # pragma: no cover — defensive fallback
return lambda tool_name, patterns: tool_name in patterns
def _mcp_preselected(tool_names: List[str], include_set, exclude_set, match) -> Set[int]:
"""Indices of tools currently enabled: include mode, exclude mode, or all when unfiltered."""
if include_set:
return {i for i, tn in enumerate(tool_names) if match(tn, include_set)}
if exclude_set:
return {i for i, tn in enumerate(tool_names) if not match(tn, exclude_set)}
return set(range(len(tool_names)))
def _apply_mcp_checklist(server_name: str, tools_cfg: dict, tool_names: List[str], chosen: Set[int],
include_set, exclude_set, match) -> None:
"""Write a checklist result back as ``tools.include`` / ``tools.exclude``."""
exclude_mode = bool(exclude_set) and not include_set
if len(chosen) == len(tool_names) and not exclude_mode:
# All tools enabled — clear filters so tools the server adds later are auto-enabled.
tools_cfg.pop("exclude", None)
tools_cfg.pop("include", None)
elif exclude_mode:
# Exclude-mode server (catalog default_excluded / hand-written tools.exclude): stay in exclude
# mode — do NOT demote the dynamic filter to a frozen include list. Unchecked tools become literal
# excludes; re-checked literals are dropped; glob patterns are preserved (they intentionally keep
# matching tools the vendor ships later).
old_exclude = sorted(exclude_set or set())
glob_entries = [p for p in old_exclude if "*" in p or "?" in p or "[" in p]
literal_entries = {p for p in old_exclude if p not in glob_entries}
unchecked = {tn for i, tn in enumerate(tool_names) if i not in chosen}
checked = {tool_names[i] for i in chosen}
new_literals = (literal_entries - checked) | {tn for tn in unchecked if not match(tn, set(old_exclude))}
new_exclude = glob_entries + sorted(new_literals)
glob_shadowed = sorted(tn for tn in checked if glob_entries and match(tn, set(glob_entries)))
if glob_shadowed:
_print_warning(
f" {server_name}: {len(glob_shadowed)} re-enabled "
f"tool(s) still match glob exclude pattern(s) "
f"{glob_entries} and stay excluded — edit "
f"mcp_servers.{server_name}.tools.exclude in config.yaml "
"to enable them."
)
if new_exclude:
tools_cfg["exclude"] = new_exclude
else:
tools_cfg.pop("exclude", None)
tools_cfg.pop("include", None)
else:
tools_cfg["include"] = [tool_names[i] for i in sorted(chosen)]
tools_cfg.pop("exclude", None) # include-mode now; drop any legacy exclude block
def _configure_mcp_tools_interactive(config: dict):
"""Probe each MCP server for its tools, show a per-server curses checklist, and write the result
back as ``tools.exclude`` / ``tools.include`` entries in config.yaml."""
from hermes_cli.tools_config import save_config
from hermes_cli.curses_ui import curses_checklist
mcp_servers = config.get("mcp_servers") or {}
if not mcp_servers:
_print_info("No MCP servers configured.")
return
enabled_names = [k for k, v in mcp_servers.items() if v.get("enabled", True) not in {False, "false", "0", "no", "off"}]
if not enabled_names:
_print_info("All MCP servers are disabled.")
return
print()
print(color(" Discovering tools from MCP servers...", Colors.YELLOW))
print(color(f" Connecting to {len(enabled_names)} server(s): {', '.join(enabled_names)}", Colors.DIM))
try:
from tools.mcp_tool import probe_mcp_server_tools
server_tools = probe_mcp_server_tools()
except Exception as exc:
_print_error(f"Failed to probe MCP servers: {exc}")
return
if not server_tools:
_print_warning("Could not discover tools from any MCP server.")
_print_info("Check that server commands/URLs are correct and dependencies are installed.")
return
for name in enabled_names:
if name not in server_tools:
_print_warning(f" Could not connect to '{name}'")
total_tools = sum(len(tools) for tools in server_tools.values())
print(color(f" Found {total_tools} tool(s) across {len(server_tools)} server(s)", Colors.GREEN))
print()
any_changes = False
for server_name, tools in server_tools.items():
if not tools:
_print_info(f" {server_name}: no tools found")
continue
tools_cfg = mcp_servers.get(server_name, {}).get("tools") or {}
include_list = tools_cfg.get("include") or []
exclude_list = tools_cfg.get("exclude") or []
labels = []
for tool_name, description in tools:
desc_short = description[:70] + "..." if len(description) > 70 else description
labels.append(f"{tool_name} ({desc_short})" if desc_short else tool_name)
match = _mcp_match_filter()
tool_names = [t[0] for t in tools]
include_set = {str(p) for p in include_list} if include_list else None
exclude_set = {str(p) for p in exclude_list} if exclude_list else None
pre_selected = _mcp_preselected(tool_names, include_set, exclude_set, match)
chosen = curses_checklist(
f"MCP Server: {server_name} ({len(tools)} tools)", labels, pre_selected, cancel_returns=pre_selected,
)
if chosen == pre_selected:
_print_info(f" {server_name}: no changes")
continue
tools_cfg = mcp_servers.setdefault(server_name, {}).setdefault("tools", {})
_apply_mcp_checklist(server_name, tools_cfg, tool_names, chosen, include_set, exclude_set, match)
_print_success(f" {server_name}: {len(chosen)} enabled, {len(tools) - len(chosen)} disabled")
any_changes = True
if any_changes:
save_config(config)
print()
print(color(" ✓ MCP tool configuration saved", Colors.GREEN))
else:
print(color(" No changes to MCP tools", Colors.DIM))
def _apply_toolset_change(config: dict, platform: str, toolset_names: List[str], action: str):
"""Add or remove built-in toolsets for a platform."""
from hermes_cli.tools_config import _get_platform_tools, _save_platform_tools
enabled = _get_platform_tools(config, platform, include_default_mcp_servers=False)
updated = enabled - set(toolset_names) if action == "disable" else enabled | set(toolset_names)
_save_platform_tools(config, platform, updated)
def _apply_mcp_change(config: dict, targets: List[str], action: str) -> Set[str]:
"""Add or remove specific MCP tools from a server's exclude list."""
failed_servers: Set[str] = set()
mcp_servers = config.get("mcp_servers") or {}
for target in targets:
server_name, tool_name = target.split(":", 1)
if server_name not in mcp_servers:
failed_servers.add(server_name)
continue
tools_cfg = mcp_servers[server_name].setdefault("tools", {})
exclude = list(tools_cfg.get("exclude") or [])
if action == "disable":
if tool_name not in exclude:
exclude.append(tool_name)
else:
exclude = [t for t in exclude if t != tool_name]
tools_cfg["exclude"] = exclude
return failed_servers
def _print_tools_list(enabled_toolsets: set, mcp_servers: dict, platform: str = "cli"):
"""Print a summary of enabled/disabled toolsets and MCP tool filters."""
from hermes_cli.tools_config import CONFIGURABLE_TOOLSETS, _get_effective_configurable_toolsets
effective_all = _get_effective_configurable_toolsets()
effective = [(k, l, d) for (k, l, d) in effective_all if _toolset_allowed_for_platform(k, platform)]
builtin_keys = {ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS}
def _print_rows(entries):
for ts_key, label in entries:
status = (color("✓ enabled", Colors.GREEN) if ts_key in enabled_toolsets
else color("✗ disabled", Colors.RED))
print(f" {status} {ts_key} {color(label, Colors.DIM)}")
print(f"Built-in toolsets ({platform}):")
_print_rows((k, l) for k, l, _ in effective if k in builtin_keys)
plugin_entries = [(k, l) for k, l, _ in effective if k not in builtin_keys]
if plugin_entries:
print()
print(f"Plugin toolsets ({platform}):")
_print_rows(plugin_entries)
if mcp_servers:
print()
print("MCP servers:")
for srv_name, srv_cfg in mcp_servers.items():
tools_cfg = srv_cfg.get("tools") or {}
exclude = tools_cfg.get("exclude") or []
include = tools_cfg.get("include") or []
if include:
_print_info(f"{srv_name} [include only: {', '.join(include)}]")
elif exclude:
_print_info(f"{srv_name} [excluded: {color(', '.join(exclude), Colors.YELLOW)}]")
else:
_print_info(f"{srv_name} {color('all tools enabled', Colors.DIM)}")
def _known_tool_platforms() -> set[str]:
"""Return built-in plus discovered plugin platform names. Plugin platforms register at runtime, not
in the static CLI display registry, and must be recognized so an active plugin platform can audit
its authority."""
from hermes_cli.tools_config import PLATFORMS
known = set(PLATFORMS)
try:
from hermes_cli.plugins import discover_plugins
from gateway.platform_registry import platform_registry
discover_plugins() # idempotent
known.update(platform_registry.registered_names())
except Exception:
# Plugin discovery is optional: keep the built-in path when a plugin is malformed or deps are missing.
pass
return known
def tools_disable_enable_command(args):
"""Enable, disable, or list tools for a platform."""
from hermes_cli.tools_config import CONFIGURABLE_TOOLSETS, _get_platform_tools, _get_plugin_toolset_keys, load_config, save_config
action = args.tools_action
platform = getattr(args, "platform", "cli")
config = load_config()
valid_platforms = _known_tool_platforms()
if platform not in valid_platforms:
_print_error(f"Unknown platform '{platform}'. Valid: {', '.join(sorted(valid_platforms))}")
return
if action == "list":
_print_tools_list(_get_platform_tools(config, platform, include_default_mcp_servers=False),
config.get("mcp_servers") or {}, platform)
return
targets: List[str] = args.names
toolset_targets = [t for t in targets if ":" not in t]
mcp_targets = [t for t in targets if ":" in t]
valid_toolsets = {ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS} | _get_plugin_toolset_keys()
unknown_toolsets = [t for t in toolset_targets if t not in valid_toolsets]
if unknown_toolsets:
for name in unknown_toolsets:
_print_error(f"Unknown toolset '{name}'")
toolset_targets = [t for t in toolset_targets if t in valid_toolsets]
# Reject platform-scoped toolsets on platforms that don't allow them.
restricted_targets = [t for t in toolset_targets if not _toolset_allowed_for_platform(t, platform)]
if restricted_targets:
for name in restricted_targets:
allowed = sorted(_TOOLSET_PLATFORM_RESTRICTIONS.get(name) or set())
_print_error(f"Toolset '{name}' is not available on platform '{platform}' (only: {', '.join(allowed)})")
toolset_targets = [t for t in toolset_targets if t not in restricted_targets]
if toolset_targets:
_apply_toolset_change(config, platform, toolset_targets, action)
failed_servers: Set[str] = set()
if mcp_targets:
failed_servers = _apply_mcp_change(config, mcp_targets, action)
for srv in failed_servers:
_print_error(f"MCP server '{srv}' not found in config")
save_config(config)
successful = [
t for t in targets
if t not in unknown_toolsets
and t not in restricted_targets
and (":" not in t or t.split(":")[0] not in failed_servers)
]
if successful:
verb = "Disabled" if action == "disable" else "Enabled"
_print_success(f"{verb}: {', '.join(successful)}")
+550
View File
@@ -0,0 +1,550 @@
"""Post-setup install hooks and installed-state predicates for `hermes tools` provider rows."""
from __future__ import annotations
import logging
import os
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Set
from hermes_cli.cli_output import (
print_error as _print_error,
print_info as _print_info,
print_success as _print_success,
print_warning as _print_warning,
)
from hermes_cli.config import get_env_value
from hermes_cli.tools_config_cua import (
_cua_driver_install_ready,
_pip_install,
_post_setup_no_window_flags,
install_cua_driver,
)
logger = logging.getLogger("hermes_cli.tools_config")
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
def _ensure_browser_use_cli(*, verbose_hints: bool = False) -> None:
"""Install the Browser Use CLI if it isn't already runnable.
Primary driver engine for EVERY browser backend except Camofox (Firefox-based, no CDP surface).
MANAGED-FIRST: a browser-use on the user's PATH does NOT satisfy this check — only the
Hermes-managed ``$HERMES_HOME/bin`` copy does.
"""
_print_info(" Ensuring browser-use CLI (managed install)...")
try:
from tools.browser_use_cli import install_cli
ok, message = install_cli()
except Exception as exc: # pragma: no cover — defensive
ok, message = False, f"install failed: {exc}"
if ok:
_print_success(f" {message}")
else:
for line in str(message).splitlines():
_print_warning(f" {line[:200]}")
if shutil.which("uvx"):
_print_info(" Falling back to zero-install runs via `uvx browser-use`")
else:
_print_info(" Install manually: uv tool install browser-use (https://docs.astral.sh/uv/)")
if verbose_hints:
_print_info(" Local Chrome needs remote debugging: chrome://inspect/#remote-debugging")
_print_info(" Cloud browsers: browser-use auth login (or set BROWSER_USE_API_KEY)")
def _post_setup_lightpanda() -> None:
# Browser Use mode spawns ``lightpanda serve``; built-in tools go through agent-browser. No Chromium needed.
_ensure_browser_use_cli()
from tools.browser_lightpanda import LIGHTPANDA_INSTALL_HINT, find_lightpanda_binary
lightpanda_bin = find_lightpanda_binary()
if lightpanda_bin:
_print_success(f" Lightpanda found: {lightpanda_bin}")
else:
_print_warning(" lightpanda binary not found on PATH, ~/.lightpanda or ~/.local/bin")
_print_info(f" {LIGHTPANDA_INSTALL_HINT}")
if os.name == "nt":
_print_info(" Lightpanda has no native Windows build; run Hermes under WSL2.")
def _install_chromium(install_cmd: list[str]) -> None:
"""Run the agent-browser Chromium install command and report the outcome."""
_print_info(" Installing Chromium (~170MB one-time download)...")
try:
result = subprocess.run(
install_cmd,
capture_output=True, text=True, encoding="utf-8", errors="replace", cwd=str(PROJECT_ROOT), timeout=600,
creationflags=_post_setup_no_window_flags(),
)
if result.returncode == 0:
_print_success(" Chromium installed")
# Invalidate the cached "missing" flag so later check_browser_requirements() calls see the install.
import tools.browser_tool as _bt
_bt._cached_chromium_installed = None
else:
_print_warning(" Chromium install failed:")
tail = (result.stderr or result.stdout or "").strip().splitlines()[-3:]
for line in tail:
_print_info(f" {line[:200]}")
_print_info(" Run manually: npx agent-browser install --with-deps")
except subprocess.TimeoutExpired:
_print_warning(" Chromium install timed out (>10min)")
_print_info(" Run manually: npx agent-browser install --with-deps")
except Exception as exc:
_print_warning(f" Chromium install failed: {exc}")
_print_info(" Run manually: npx agent-browser install --with-deps")
def _post_setup_agent_browser(post_setup_key: str) -> None:
"""``agent_browser`` (local Chromium) and ``browserbase`` (cloud rows) hooks.
agent-browser is not a root package.json dependency — it resolves lazily via npx (or a
global/Hermes-managed install), so there is no ``npm install`` step here.
"""
# Every non-Camofox backend drives through the Browser Use CLI — install it here too.
_ensure_browser_use_cli()
try:
# Lazy import so the tools_config UI doesn't pull in browser_tool at import time.
from tools.browser_tool import (
_chromium_installed,
_running_in_docker,
_find_agent_browser,
_resolve_npx_bin,
_is_npx_agent_browser_sentinel,
AGENT_BROWSER_NPX_SPEC,
)
except Exception as exc: # pragma: no cover — defensive
_print_warning(f" Could not check Chromium status: {exc}")
return
# Reuse the runtime resolution cascade (PATH -> Homebrew/Hermes-managed node -> npx) rather than
# a bare shutil.which — Hermes-managed-Node-only setups resolve agent-browser/npx only that way.
try:
browser_cmd = _find_agent_browser(validate=False)
except FileNotFoundError:
_print_warning(" npx not found - browser tools require Node.js: https://nodejs.org")
return
# Only the local provider needs Chromium on disk; cloud providers host their own.
if post_setup_key != "agent_browser":
return
# Without Chromium the CLI hangs on first use until the command timeout fires. Skip inside
# Docker — the image bakes Chromium in, and runtime users usually can't write PLAYWRIGHT_BROWSERS_PATH.
if _chromium_installed():
_print_success(" Chromium browser already installed, nothing to do")
return
if _running_in_docker():
_print_warning(" Chromium is missing but you're running in Docker.")
_print_info(" Pull the latest image to get the bundled Chromium:")
_print_info(" docker pull ghcr.io/nousresearch/hermes-agent:latest")
return
if _is_npx_agent_browser_sentinel(browser_cmd):
# Re-resolve npx via the same cascade _find_agent_browser used — a bare shutil.which("npx")
# would silently diverge and hand subprocess.run a None argument.
npx_bin = _resolve_npx_bin()
if not npx_bin:
_print_warning(" npx not found - install Chromium manually: npx agent-browser install --with-deps")
return
install_cmd = [npx_bin, "--ignore-scripts", "-y", AGENT_BROWSER_NPX_SPEC, "install", "--with-deps"]
else:
install_cmd = [browser_cmd, "install", "--with-deps"]
_install_chromium(install_cmd)
def _post_setup_camofox() -> None:
from hermes_constants import find_node_executable
camofox_dir = PROJECT_ROOT / "node_modules" / "@askjo" / "camofox-browser"
_npm_bin = find_node_executable("npm")
if camofox_dir.exists():
_print_success(" Camofox already installed, nothing to do")
elif _npm_bin:
_print_info(" Installing Camofox browser server...")
# Absolute npm path so the .cmd shim executes on Windows; --workspaces=false avoids resolving apps/desktop.
result = subprocess.run(
[_npm_bin, "install", "--silent", "--workspaces=false"],
capture_output=True, text=True, encoding="utf-8", errors="replace", cwd=str(PROJECT_ROOT),
creationflags=_post_setup_no_window_flags(),
)
if result.returncode == 0:
_print_success(" Camofox installed")
else:
_print_warning(" npm install failed - run manually: npm install --workspaces=false")
if camofox_dir.exists():
_print_info(" Start the Camofox server:")
_print_info(" npx @askjo/camofox-browser")
_print_info(" First run downloads the Camoufox engine (~300MB)")
_print_info(" Or use Docker: docker run -p 9377:9377 -e CAMOFOX_PORT=9377 jo-inc/camofox-browser")
elif not _npm_bin:
_print_warning(" Node.js not found. Install Camofox via Docker:")
_print_info(" docker run -p 9377:9377 -e CAMOFOX_PORT=9377 jo-inc/camofox-browser")
_KITTENTTS_WHEEL_URL = "https://github.com/KittenML/KittenTTS/releases/download/0.8.1/kittentts-0.8.1-py3-none-any.whl"
# Post-setup hooks that only pip-install a Python package. Fields: module (import probe; present → skip
# install), label (status lines), installing (progress line), args (_pip_install args — keep in sync with
# _RESTORABLE_PYTHON_TOOL_DEPENDENCIES), manual ("Run manually:" command on failure/timeout),
# on_install (info lines only after a fresh install), always (info lines whenever the package is present).
_PIP_POST_SETUP_HOOKS: dict = {
"faster_whisper": {
"module": "faster_whisper", "label": "faster-whisper",
"installing": "Installing faster-whisper (model ~150MB downloads on first use)...",
"args": ["-U", "faster-whisper", "--quiet"], "manual": "uv pip install -U faster-whisper",
"on_install": ("Model sizes: tiny, base (default), small, medium, large-v3",
"Change via stt.local.model in ~/.hermes/config.yaml"),
"always": (),
},
"kittentts": {
"module": "kittentts", "label": "kittentts",
"installing": "Installing kittentts (~25-80MB model, CPU-only)...",
"args": ["-U", _KITTENTTS_WHEEL_URL, "soundfile", "--quiet"],
"manual": f"uv pip install -U '{_KITTENTTS_WHEEL_URL}' soundfile",
"on_install": ("Voices: Jasper, Bella, Luna, Bruno, Rosie, Hugo, Kiki, Leo",
"Models: KittenML/kitten-tts-nano-0.8-int8 (25MB), micro (41MB), mini (80MB)"),
"always": (),
},
"piper": {
"module": "piper", "label": "piper-tts",
"installing": "Installing piper-tts (~14MB wheel, voices downloaded on first use)...",
"args": ["-U", "piper-tts", "--quiet"], "manual": "uv pip install -U piper-tts",
"on_install": (),
"always": ("Default voice: en_US-lessac-medium (downloaded on first TTS call)",
"Full voice list: https://github.com/OHF-Voice/piper1-gpl/blob/main/docs/VOICES.md",
"Switch voices by setting tts.piper.voice in ~/.hermes/config.yaml"),
},
"ddgs": {
"module": "ddgs", "label": "ddgs",
"installing": "Installing ddgs (DuckDuckGo search package)...",
"args": ["-U", "ddgs", "--quiet"], "manual": "uv pip install -U ddgs",
"on_install": (),
"always": ("No API key required. DuckDuckGo enforces server-side rate limits.",
"Pair with an extract provider if you also need web_extract."),
},
}
def _post_setup_pip(spec: dict) -> None:
"""Run one ``_PIP_POST_SETUP_HOOKS`` entry."""
label = spec["label"]
freshly_installed = False
try:
__import__(spec["module"])
_print_success(f" {label} is already installed")
except ImportError:
_print_info(f" {spec['installing']}")
try:
result = _pip_install(spec["args"], timeout=300)
if result.returncode == 0:
_print_success(f" {label} installed")
freshly_installed = True
else:
_print_warning(f" {label} install failed:")
_print_info(f" {(result.stderr or '').strip()[:300]}")
_print_info(f" Run manually: {spec['manual']}")
return
except subprocess.TimeoutExpired:
_print_warning(f" {label} install timed out (>5min)")
_print_info(f" Run manually: {spec['manual']}")
return
if freshly_installed:
for line in spec["on_install"]:
_print_info(f" {line}")
for line in spec["always"]:
_print_info(f" {line}")
def _post_setup_spotify() -> None:
# Full `hermes auth spotify` flow: no client_id yet → interactive wizard (persists to ~/.hermes/.env)
# then PKCE; existing app → OAuth only.
from types import SimpleNamespace
try:
from hermes_cli.auth import login_spotify_command
except Exception as exc:
_print_warning(f" Could not load Spotify auth: {exc}")
_print_info(" Run manually: hermes auth spotify")
return
_print_info(" Starting Spotify login...")
try:
login_spotify_command(SimpleNamespace(
client_id=None, redirect_uri=None, scope=None, no_browser=False, timeout=None,
))
_print_success(" Spotify authenticated")
except SystemExit as exc:
# User aborted the wizard or OAuth failed — don't fail the toolset enable.
_print_warning(f" Spotify login did not complete: {exc}")
_print_info(" Run later: hermes auth spotify")
except Exception as exc:
_print_warning(f" Spotify login failed: {exc}")
_print_info(" Run manually: hermes auth spotify")
def _post_setup_langfuse() -> None:
try:
__import__("langfuse")
_print_success(" langfuse SDK already installed")
except ImportError:
_print_info(" Installing langfuse SDK...")
result = _pip_install(["langfuse", "--quiet"], timeout=120)
if result.returncode == 0:
_print_success(" langfuse SDK installed")
else:
_print_warning(" langfuse SDK install failed — run manually: uv pip install langfuse")
# The bundled observability/langfuse plugin is opt-in (standalone plugins don't load until enabled).
try:
from hermes_cli.plugins_cmd import _get_enabled_set, _save_enabled_set
enabled = _get_enabled_set()
if "observability/langfuse" in enabled or "langfuse" in enabled:
_print_success(" Plugin observability/langfuse already enabled")
else:
enabled.add("observability/langfuse")
_save_enabled_set(enabled)
_print_success(" Plugin observability/langfuse enabled")
except Exception as exc:
_print_warning(f" Could not enable plugin automatically: {exc}")
_print_info(" Run manually: hermes plugins enable observability/langfuse")
_print_info(" Restart Hermes for tracing to take effect.")
_print_info(" Verify: hermes plugins list")
def _post_setup_xai_grok() -> None:
"""Shared xAI credential bootstrap for any picker row that talks to xAI (TTS, STT, Video Gen,
x_search …). Accepts a SuperGrok-tier OAuth token (preferred — billed to the existing
subscription) or a raw XAI_API_KEY; the rows declare empty env_vars so the auth UX lives here.
"""
try:
from hermes_cli.auth import get_xai_oauth_auth_status
oauth_logged_in = bool(get_xai_oauth_auth_status().get("logged_in"))
except Exception:
oauth_logged_in = False
existing_api_key = get_env_value("XAI_API_KEY")
if oauth_logged_in:
_print_success(" xAI will use your xAI Grok OAuth (SuperGrok / Premium+) credentials")
return
if existing_api_key:
_print_success(" xAI will use your existing XAI_API_KEY")
return
_print_info(" xAI needs credentials. Choose one:")
try:
from hermes_cli.setup import _run_xai_oauth_login_from_setup, prompt_choice, prompt as _setup_prompt
from hermes_cli.config import save_env_value
except Exception as exc:
_print_warning(f" Could not load setup helpers: {exc}")
_print_info(" Run later: hermes auth add xai-oauth (or set XAI_API_KEY)")
return
idx = prompt_choice(
" How do you want xAI to authenticate?",
choices=[
"Sign in with xAI Grok OAuth (SuperGrok / Premium+) — browser login",
"Paste an xAI API key (console.x.ai)",
"Skip — configure later via `hermes auth add xai-oauth`",
],
default=0,
)
if idx == 0:
if _run_xai_oauth_login_from_setup():
_print_success(" Logged in — xAI will use these OAuth credentials")
else:
_print_warning(" xAI Grok OAuth login did not complete. Run later: hermes auth add xai-oauth")
elif idx == 1:
api_key = _setup_prompt(" xAI API key", password=True)
if api_key:
save_env_value("XAI_API_KEY", api_key)
_print_success(" XAI_API_KEY saved")
else:
_print_warning(" No API key provided. Run later: hermes auth add xai-oauth")
else:
_print_info(" xAI will remain inactive until credentials are configured.")
# post_setup key -> hook. Unknown keys are a silent no-op (callers validate against valid_post_setup_keys()).
_POST_SETUP_HOOKS: dict = {
"lightpanda": _post_setup_lightpanda,
"agent_browser": lambda: _post_setup_agent_browser("agent_browser"),
"browserbase": lambda: _post_setup_agent_browser("browserbase"),
"browser_use_cli": lambda: _ensure_browser_use_cli(verbose_hints=True),
"camofox": _post_setup_camofox,
"cua_driver": lambda: install_cua_driver(upgrade=False),
"spotify": _post_setup_spotify,
"langfuse": _post_setup_langfuse,
"xai_grok": _post_setup_xai_grok,
**{key: (lambda spec=spec: _post_setup_pip(spec)) for key, spec in _PIP_POST_SETUP_HOOKS.items()},
}
def _run_post_setup(post_setup_key: str):
"""Run post-setup hooks for tools that need extra installation steps."""
hook = _POST_SETUP_HOOKS.get(post_setup_key)
if hook is not None:
hook()
def valid_post_setup_keys() -> Set[str]:
"""Return the set of post-setup keys declared by any visible provider (``TOOL_CATEGORIES`` plus
plugin-registered providers). This is the allowlist ``post-setup`` and the dashboard endpoint
validate against, so a caller cannot drive ``_run_post_setup`` with an arbitrary key.
"""
from hermes_cli.tools_config import (
TOOL_CATEGORIES,
_plugin_browser_providers,
_plugin_image_gen_providers,
_plugin_video_gen_providers,
_plugin_web_search_providers,
)
keys: Set[str] = set()
for cat in TOOL_CATEGORIES.values():
for prov in cat.get("providers", []):
ps = prov.get("post_setup")
if ps:
keys.add(ps)
for builder in (
_plugin_web_search_providers, _plugin_image_gen_providers,
_plugin_video_gen_providers, _plugin_browser_providers,
):
try:
for prov in builder():
ps = prov.get("post_setup")
if ps:
keys.add(ps)
except Exception: # pragma: no cover — defensive; plugins optional
continue
return keys
def run_post_setup_command(args) -> int:
"""``hermes tools post-setup <key>`` — non-interactive runner the dashboard spawns so the GUI can
drive backend setup without re-implementing install logic. Exit code: 0 ok, 2 unknown key.
"""
key = getattr(args, "post_setup_key", None)
if not key:
_print_error("Usage: hermes tools post-setup <key>")
return 2
valid = valid_post_setup_keys()
if key not in valid:
_print_error(f"Unknown post-setup key: {key!r}. Valid keys: {', '.join(sorted(valid)) or '(none)'}")
return 2
_print_info(f"Running post-setup hook: {key}")
try:
_run_post_setup(key)
except Exception as exc: # pragma: no cover — defensive
_print_error(f"Post-setup failed: {exc}")
return 1
_print_success(f"Post-setup '{key}' complete")
return 0
# post_setup_key -> predicate(): True when the install side-effect is already satisfied. Used by
# `_toolset_needs_configuration_prompt` to force provider setup when a no-key provider still needs a
# binary/dependency install (otherwise toggling the toolset on silently skips the hook). Only add an
# entry when the post_setup is the ONLY install side-effect for a no-key provider and the check is
# local, bounded, and import-light.
_POST_SETUP_INSTALLED: dict = {
"cua_driver": lambda: _cua_driver_install_ready(),
}
def _post_setup_already_installed(post_setup_key: str) -> bool:
"""Return True when the post_setup install side-effect is satisfied (or no check is registered)."""
predicate = _POST_SETUP_INSTALLED.get(post_setup_key)
if predicate is None:
return True
try:
return bool(predicate())
except Exception:
return True
def _module_installed(module_name: str) -> bool:
"""Cheap importable-without-importing check (no heavy side effects)."""
import importlib.util
try:
return importlib.util.find_spec(module_name) is not None
except Exception:
return False
# Python deps installed via ``hermes tools`` aren't in the managed runtime's locked ``all`` sync, so a
# runtime replacement snapshots this static allowlist before the old site-packages disappears and
# restores it afterward. Keep install args in sync with the ``_run_post_setup`` hooks.
_RESTORABLE_PYTHON_TOOL_DEPENDENCIES: dict[str, tuple[str, tuple[str, ...]]] = {
"faster_whisper": ("faster_whisper", ("-U", "faster-whisper")),
"kittentts": ("kittentts", ("-U", _KITTENTTS_WHEEL_URL, "soundfile")),
"piper": ("piper", ("-U", "piper-tts")),
"ddgs": ("ddgs", ("-U", "ddgs")),
"langfuse": ("langfuse", ("langfuse",)),
}
def active_restorable_python_tool_dependencies() -> list[str]:
"""Return ``hermes tools`` Python dependencies present in this runtime."""
return [
name for name, (module_name, _install_args) in _RESTORABLE_PYTHON_TOOL_DEPENDENCIES.items()
if _module_installed(module_name)
]
def restorable_python_tool_dependency(name: str) -> tuple[str, tuple[str, ...]] | None:
"""Return the import probe and pip arguments for an allowlisted tool."""
return _RESTORABLE_PYTHON_TOOL_DEPENDENCIES.get(name)
def _agent_browser_installed() -> bool:
"""True when everything ``_run_post_setup("agent_browser")`` installs is present: the agent-browser
CLI *and* the Chromium build it drives (or the Lightpanda engine, which needs no Chromium), so
"Run setup" flips to installed only when re-running it would be a no-op.
"""
from hermes_cli.nous_subscription import _local_browser_runnable
# The hook runs in a spawned process; this probe runs in the long-lived web-server/CLI process whose
# browser_tool may have cached a stale "Chromium missing" result. Drop the cache so the pill flips to Ready.
bt = sys.modules.get("tools.browser_tool")
if bt is not None:
bt._cached_chromium_installed = None
return _local_browser_runnable()
def _camofox_installed() -> bool:
"""True when the Camofox npm package ``_run_post_setup("camofox")`` installs is in node_modules."""
return (PROJECT_ROOT / "node_modules" / "@askjo" / "camofox-browser").exists()
def _lightpanda_installed() -> bool:
"""True when a lightpanda binary is on PATH or in a known install dir."""
try:
from tools.browser_lightpanda import find_lightpanda_binary
return find_lightpanda_binary() is not None
except Exception:
return False
def _cloud_agent_browser_installed() -> bool:
"""Installed-check for the ``browserbase`` hook: cloud providers host their own Chromium, so
presence of the agent-browser CLI is the whole contract."""
from hermes_cli.nous_subscription import _has_agent_browser
return _has_agent_browser()
# post_setup_key -> predicate(): True when the install side-effect is satisfied. Used by
# ``provider_readiness_status`` to mark a keyless post_setup row "ready" vs "needs_setup"; mirrors the
# installed-checks the hooks perform. ``xai_grok`` is absent — a credential bootstrap handled as an
# auth check. Late-bound lambdas so tests can monkeypatch the underlying predicates.
_POST_SETUP_READY: dict = {
**{key: (lambda m=module: _module_installed(m)) for key, (module, _args) in _RESTORABLE_PYTHON_TOOL_DEPENDENCIES.items()},
"agent_browser": lambda: _agent_browser_installed(),
"browserbase": lambda: _cloud_agent_browser_installed(),
"camofox": lambda: _camofox_installed(),
"lightpanda": lambda: _lightpanda_installed(),
"cua_driver": lambda: _cua_driver_install_ready(),
}
File diff suppressed because it is too large Load Diff
@@ -23,7 +23,7 @@ from hermes_cli.tools_config import (
def _quiet(monkeypatch):
import hermes_cli.tools_config as tc
import hermes_cli.tools_config_providers as tc
monkeypatch.setattr(tc, "_print_success", lambda *a, **k: None)
monkeypatch.setattr(tc, "_print_info", lambda *a, **k: None, raising=False)
@@ -80,11 +80,13 @@ def test_image_and_video_selectors_share_the_selection_contract(monkeypatch):
def _quiet_reconfigure(monkeypatch):
"""Silence prints + model pickers for _reconfigure_provider paths."""
import hermes_cli.tools_config as tc
import hermes_cli.tools_config_providers as tcp
monkeypatch.setattr(tc, "_print_success", lambda *a, **k: None)
monkeypatch.setattr(tc, "_print_info", lambda *a, **k: None, raising=False)
monkeypatch.setattr(tc, "_print_warning", lambda *a, **k: None, raising=False)
monkeypatch.setattr(tc, "_configure_imagegen_model", lambda *a, **k: None)
monkeypatch.setattr(tcp, "_print_success", lambda *a, **k: None)
monkeypatch.setattr(tcp, "_print_info", lambda *a, **k: None, raising=False)
monkeypatch.setattr(tcp, "_print_warning", lambda *a, **k: None, raising=False)
monkeypatch.setattr(tcp, "_configure_imagegen_model", lambda *a, **k: None)
# _configure_provider resolves the post-setup hook lazily from tools_config.
monkeypatch.setattr(tc, "_run_post_setup", lambda *a, **k: None, raising=False)
# Managed rows gate on live Portal auth — stub it green.
import hermes_cli.nous_subscription as ns
+52 -52
View File
@@ -13,7 +13,7 @@ must:
incomplete installations, and install when missing.
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
deleted (see the release-probe comment in tools_config_cua.py) — the upstream
installer has CUA_DRIVER_RS_BAKED_VERSION baked in by CD and errors
cleanly on missing-arch assets, and the upgrade path uses
``cua_driver_update_check()`` (which shells `cua-driver check-update
@@ -61,7 +61,7 @@ def _runtime_manifest(version="0.20.0", *, omit=None):
class TestCuaDriverRuntimeContract:
def test_current_manifest_is_ready(self):
from hermes_cli import tools_config
from hermes_cli import tools_config_cua as tools_config
result = SimpleNamespace(
returncode=0,
@@ -80,7 +80,7 @@ class TestCuaDriverRuntimeContract:
@pytest.mark.parametrize("version", ["0.19.4", "bad-version"])
def test_old_or_unversioned_driver_needs_repair(self, version):
from hermes_cli import tools_config
from hermes_cli import tools_config_cua as tools_config
result = SimpleNamespace(
returncode=0,
@@ -94,7 +94,7 @@ class TestCuaDriverRuntimeContract:
assert state["reason"]
def test_incomplete_manifest_needs_repair(self):
from hermes_cli import tools_config
from hermes_cli import tools_config_cua as tools_config
result = SimpleNamespace(
returncode=0,
@@ -128,7 +128,7 @@ class TestInstallCuaDriverUpgrade:
no subprocess, no path handling, no import — so there is nothing
underneath the branch for a real host to falsify.
"""
from hermes_cli import tools_config
from hermes_cli import tools_config_cua as tools_config
with patch.object(tools_config, "_print_warning") as warn, \
patch("platform.system", return_value="FreeBSD"):
@@ -137,7 +137,7 @@ class TestInstallCuaDriverUpgrade:
def test_non_upgrade_on_unsupported_platform_warns(self):
"""Same narrow exception as above — see that test's docstring."""
from hermes_cli import tools_config
from hermes_cli import tools_config_cua as tools_config
with patch.object(tools_config, "_print_warning") as warn, \
patch("platform.system", return_value="FreeBSD"):
@@ -145,7 +145,7 @@ class TestInstallCuaDriverUpgrade:
warn.assert_called()
def test_upgrade_with_binary_present_runs_installer(self):
from hermes_cli import tools_config
from hermes_cli import tools_config_cua as tools_config
with patch.object(tools_config.shutil, "which",
side_effect=lambda n: "/usr/local/bin/" + n
@@ -164,7 +164,7 @@ class TestInstallCuaDriverUpgrade:
assert kwargs.get("verbose") is False
def test_upgrade_without_binary_runs_installer(self):
from hermes_cli import tools_config
from hermes_cli import tools_config_cua as tools_config
with patch.object(tools_config.shutil, "which",
side_effect=lambda n: "/usr/bin/curl" if n == "curl" else None), \
@@ -179,7 +179,7 @@ class TestInstallCuaDriverUpgrade:
branch, which this lane takes for real."""
from unittest.mock import MagicMock
from hermes_cli import tools_config
from hermes_cli import tools_config_cua as tools_config
fake_proc = MagicMock()
fake_proc.pid = 1
@@ -212,7 +212,7 @@ class TestInstallCuaDriverUpgrade:
"""``linux_only``: same POSIX Popen path as the test above."""
from unittest.mock import MagicMock
from hermes_cli import tools_config
from hermes_cli import tools_config_cua as tools_config
fake_proc = MagicMock()
fake_proc.pid = 1
@@ -245,7 +245,7 @@ class TestInstallCuaDriverUpgrade:
import subprocess
from unittest.mock import MagicMock
from hermes_cli import tools_config
from hermes_cli import tools_config_cua as tools_config
fake_proc = MagicMock()
fake_proc.pid = 1
@@ -280,7 +280,7 @@ class TestInstallCuaDriverUpgrade:
fake_proc.communicate.assert_called_once_with(timeout=120)
def test_upgrade_can_suppress_installer_progress(self):
from hermes_cli import tools_config
from hermes_cli import tools_config_cua as tools_config
with patch.object(
tools_config.shutil,
@@ -310,7 +310,7 @@ class TestInstallCuaDriverUpgrade:
assert runner.call_args.kwargs["show_progress"] is False
def test_upgrade_non_writable_install_target_skips_refresh(self):
from hermes_cli import tools_config
from hermes_cli import tools_config_cua as tools_config
with patch.object(tools_config.shutil, "which",
side_effect=lambda n: "/usr/local/bin/" + n
@@ -327,7 +327,7 @@ class TestInstallCuaDriverUpgrade:
)
def test_fresh_install_non_writable_install_target_skips_install(self):
from hermes_cli import tools_config
from hermes_cli import tools_config_cua as tools_config
with patch.object(tools_config.shutil, "which",
side_effect=lambda n: "/usr/bin/curl" if n == "curl" else None), \
@@ -353,7 +353,7 @@ class TestInstallCuaDriverUpgrade:
"""
import os
from hermes_cli import tools_config
from hermes_cli import tools_config_cua as tools_config
writable = tools_config._cua_install_target_writable()
if os.path.isdir("/Applications"):
@@ -362,7 +362,7 @@ class TestInstallCuaDriverUpgrade:
assert writable is True
def test_non_upgrade_with_binary_skips_install(self):
from hermes_cli import tools_config
from hermes_cli import tools_config_cua as tools_config
with patch.object(tools_config.shutil, "which",
side_effect=lambda n: "/usr/local/bin/" + n
@@ -383,7 +383,7 @@ class TestInstallCuaDriverUpgrade:
runner.assert_not_called()
def test_non_upgrade_repairs_incompatible_existing_driver(self):
from hermes_cli import tools_config
from hermes_cli import tools_config_cua as tools_config
incompatible = {
"ready": False,
@@ -416,7 +416,7 @@ class TestInstallCuaDriverUpgrade:
assert runner.call_args.kwargs["label"] == "Repairing"
def test_incompatible_explicit_override_is_not_replaced(self, monkeypatch):
from hermes_cli import tools_config
from hermes_cli import tools_config_cua as tools_config
monkeypatch.setenv("HERMES_CUA_DRIVER_CMD", "/opt/custom/cua-driver")
incompatible = {
@@ -443,7 +443,7 @@ class TestInstallCuaDriverUpgrade:
def test_missing_explicit_override_does_not_install_standard_driver(
self, monkeypatch, upgrade
):
from hermes_cli import tools_config
from hermes_cli import tools_config_cua as tools_config
monkeypatch.setenv("HERMES_CUA_DRIVER_CMD", "/missing/custom/cua-driver")
with patch.object(
@@ -457,7 +457,7 @@ class TestInstallCuaDriverUpgrade:
runner.assert_not_called()
def test_non_upgrade_without_binary_runs_installer(self):
from hermes_cli import tools_config
from hermes_cli import tools_config_cua as tools_config
with patch.object(tools_config.shutil, "which",
side_effect=lambda n: "/usr/bin/curl" if n == "curl" else None), \
@@ -493,7 +493,7 @@ class TestRequireConfirmedUpdate:
"""
from unittest.mock import MagicMock
from hermes_cli import tools_config
from hermes_cli import tools_config_cua as tools_config
_which_names = {"curl", "powershell"}
if not binary_missing:
@@ -637,7 +637,7 @@ class TestRequireConfirmedUpdate:
wedge)."""
from unittest.mock import MagicMock
from hermes_cli import tools_config
from hermes_cli import tools_config_cua as tools_config
incompatible = {
"ready": False,
@@ -761,7 +761,7 @@ class TestArchProbeRemoval:
"""
def test_probe_function_is_gone(self):
from hermes_cli import tools_config
from hermes_cli import tools_config_cua as tools_config
assert not hasattr(tools_config, "_check_cua_driver_asset_for_arch")
assert not hasattr(tools_config, "_latest_cua_driver_rs_release")
@@ -771,7 +771,7 @@ class TestArchProbeRemoval:
line. install.sh errors cleanly when the arch has no asset, so the
probe was duplicate gatekeeping.
"""
from hermes_cli import tools_config
from hermes_cli import tools_config_cua as tools_config
# No platform fake: "does Python hit the GitHub API?" is host-agnostic,
# and ``which`` is stubbed so the host's own fetch tool resolves.
@@ -793,7 +793,7 @@ class TestArchProbeRemoval:
short-circuits the network re-install via the binary's native
``check-update --json`` verb.
"""
from hermes_cli import tools_config
from hermes_cli import tools_config_cua as tools_config
with patch.object(tools_config.shutil, "which",
side_effect=lambda n: "/usr/local/bin/" + n
@@ -837,7 +837,7 @@ class TestPosixStaleInstallLockClear:
os.environ.pop("CUA_DRIVER_RS_HOME", None)
def test_dead_holder_lock_is_cleared(self, tmp_path):
from hermes_cli import tools_config
from hermes_cli import tools_config_cua as tools_config
dead_pid = 4194000 # above default pid_max on most systems
lock = self._make_lock(tmp_path, pid=dead_pid)
@@ -847,14 +847,14 @@ class TestPosixStaleInstallLockClear:
def test_live_holder_lock_is_kept(self, tmp_path):
import os
from hermes_cli import tools_config
from hermes_cli import tools_config_cua as tools_config
lock = self._make_lock(tmp_path, pid=os.getpid())
tools_config._clear_stale_cua_install_lock()
assert lock.exists()
def test_pidless_fresh_lock_is_kept(self, tmp_path):
from hermes_cli import tools_config
from hermes_cli import tools_config_cua as tools_config
lock = self._make_lock(tmp_path, pid=None)
tools_config._clear_stale_cua_install_lock()
@@ -863,7 +863,7 @@ class TestPosixStaleInstallLockClear:
def test_pidless_old_lock_is_cleared(self, tmp_path):
import os
import time
from hermes_cli import tools_config
from hermes_cli import tools_config_cua as tools_config
lock = self._make_lock(tmp_path, pid=None)
old = time.time() - (tools_config._CUA_LOCK_STALE_AFTER + 60)
@@ -875,7 +875,7 @@ class TestPosixStaleInstallLockClear:
def test_no_lock_is_noop(self, tmp_path):
import os
os.environ["CUA_DRIVER_RS_HOME"] = str(tmp_path / ".cua-driver")
from hermes_cli import tools_config
from hermes_cli import tools_config_cua as tools_config
tools_config._clear_stale_cua_install_lock() # must not raise
@@ -886,7 +886,7 @@ class TestWindowsStaleInstallLockClearDispatch:
test — on Linux the faked platform asserted the dispatch and skipped
the ``.install.lock.d`` directory that really exists here.
"""
from hermes_cli import tools_config
from hermes_cli import tools_config_cua as tools_config
with patch.object(
tools_config, "_clear_stale_windows_cua_install_lock"
@@ -917,7 +917,7 @@ class TestWindowsStaleInstallLockClear:
os.environ.pop("CUA_DRIVER_RS_HOME", None)
def test_unlocked_lock_file_is_cleared(self, tmp_path):
from hermes_cli import tools_config
from hermes_cli import tools_config_cua as tools_config
lock = self._make_lock(tmp_path)
with patch.object(tools_config, "_print_info"):
@@ -928,7 +928,7 @@ class TestWindowsStaleInstallLockClear:
def test_lock_held_with_file_share_none_is_kept(self, tmp_path):
import ctypes
from ctypes import wintypes
from hermes_cli import tools_config
from hermes_cli import tools_config_cua as tools_config
lock = self._make_lock(tmp_path)
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
@@ -979,7 +979,7 @@ class TestInstallerTimeoutKillsProcessGroup:
import signal
import subprocess
from unittest.mock import MagicMock
from hermes_cli import tools_config
from hermes_cli import tools_config_cua as tools_config
killed = {}
sigkill = getattr(signal, "SIGKILL", signal.SIGTERM)
@@ -1016,7 +1016,7 @@ class TestInstallerTimeoutKillsProcessGroup:
assert fake_proc.communicate.call_count == 2
def test_timeout_ceiling_exceeds_upstream_lock_window(self):
from hermes_cli import tools_config
from hermes_cli import tools_config_cua as tools_config
# The upstream installer waits up to 600s before reclaiming a stale
# lock; our ceiling must give that window room to complete.
assert tools_config._CUA_INSTALLER_TIMEOUT > tools_config._CUA_LOCK_STALE_AFTER
@@ -1024,7 +1024,7 @@ class TestInstallerTimeoutKillsProcessGroup:
@pytest.mark.linux_only
def test_installer_runs_in_new_session_on_posix(self):
from unittest.mock import MagicMock
from hermes_cli import tools_config
from hermes_cli import tools_config_cua as tools_config
captured = {}
fake_proc = MagicMock()
@@ -1049,7 +1049,7 @@ class TestInstallerTimeoutKillsProcessGroup:
def test_windows_timeout_kills_descendants_and_parent(self):
import subprocess
from unittest.mock import MagicMock
from hermes_cli import tools_config
from hermes_cli import tools_config_cua as tools_config
child = MagicMock()
parent = MagicMock()
@@ -1087,7 +1087,7 @@ class TestInstallerTimeoutKillsProcessGroup:
import psutil
import subprocess
from unittest.mock import MagicMock
from hermes_cli import tools_config
from hermes_cli import tools_config_cua as tools_config
parent = MagicMock()
parent.children.side_effect = psutil.AccessDenied(pid=12345)
@@ -1130,7 +1130,7 @@ class TestInstallerTimeoutDrainIsBounded:
"""
def test_drain_grace_is_short_relative_to_the_run_ceiling(self):
from hermes_cli import tools_config
from hermes_cli import tools_config_cua as tools_config
# This is a grace period for a pipe that a live process is holding
# open, not a second budget for the install itself — the install is
@@ -1148,7 +1148,7 @@ class TestInstallerTimeoutDrainIsBounded:
"""
import subprocess
from unittest.mock import MagicMock
from hermes_cli import tools_config
from hermes_cli import tools_config_cua as tools_config
fake_proc = MagicMock()
fake_proc.pid = 12345
@@ -1185,7 +1185,7 @@ class TestInstallerTimeoutDrainIsBounded:
"""
import subprocess
from unittest.mock import MagicMock
from hermes_cli import tools_config
from hermes_cli import tools_config_cua as tools_config
fake_proc = MagicMock()
fake_proc.pid = 12345
@@ -1225,7 +1225,7 @@ class TestInstallerTimeoutDrainIsBounded:
import psutil
import subprocess
from unittest.mock import MagicMock
from hermes_cli import tools_config
from hermes_cli import tools_config_cua as tools_config
child = MagicMock()
child.kill.side_effect = psutil.AccessDenied(pid=999)
@@ -1270,7 +1270,7 @@ class TestInstallerTimeoutDrainIsBounded:
"""
import subprocess
from unittest.mock import MagicMock
from hermes_cli import tools_config
from hermes_cli import tools_config_cua as tools_config
parent = MagicMock()
parent.children.return_value = []
@@ -1310,7 +1310,7 @@ class TestInstallerNoShell:
def _run(self, download_rc=0):
from unittest.mock import MagicMock
from hermes_cli import tools_config
from hermes_cli import tools_config_cua as tools_config
calls = []
fake_proc = MagicMock()
@@ -1363,7 +1363,7 @@ class TestInstallerNoShell:
import os
captured = {}
from unittest.mock import MagicMock
from hermes_cli import tools_config
from hermes_cli import tools_config_cua as tools_config
fake_proc = MagicMock()
fake_proc.pid = 1
@@ -1407,7 +1407,7 @@ class TestConfirmedVersionPinning:
"""Version pinning also applies to explicit installer runs."""
from unittest.mock import MagicMock
from hermes_cli import tools_config
from hermes_cli import tools_config_cua as tools_config
with patch.object(tools_config.shutil, "which",
side_effect=lambda n: "/x/" + n
@@ -1477,7 +1477,7 @@ class TestRunInstallerPinEnv:
def _run(self, pin_version):
from unittest.mock import MagicMock
from hermes_cli import tools_config
from hermes_cli import tools_config_cua as tools_config
captured = {}
fake_proc = MagicMock()
@@ -1521,7 +1521,7 @@ class TestWindowsAutostartRepair:
True unconditionally off Windows, so only the fake made the schtasks
probe run at all.
"""
from hermes_cli import tools_config
from hermes_cli import tools_config_cua as tools_config
calls = []
@@ -1547,7 +1547,7 @@ class TestWindowsAutostartRepair:
repair hook are both inside the ``is_windows`` branch, so on Linux the
fake selected a branch whose `powershell` doesn't exist on PATH."""
from unittest.mock import MagicMock
from hermes_cli import tools_config
from hermes_cli import tools_config_cua as tools_config
captured = {}
fake_proc = MagicMock()
@@ -1590,7 +1590,7 @@ class TestWindowsAutostartRepair:
"""``windows_only``: same early return off Windows — the elevated
PowerShell command string is only built on a real Windows host.
"""
from hermes_cli import tools_config
from hermes_cli import tools_config_cua as tools_config
calls = []
driver = (
@@ -1639,7 +1639,7 @@ class TestCuaVersionSummary:
@staticmethod
def _summary(raw, **kw):
from hermes_cli import tools_config
from hermes_cli import tools_config_cua as tools_config
return tools_config._cua_version_summary(raw, **kw)
@@ -1679,7 +1679,7 @@ class TestUnattendedRefreshPreflights:
system="Linux"):
from unittest.mock import MagicMock
from hermes_cli import tools_config
from hermes_cli import tools_config_cua as tools_config
proc = MagicMock()
proc.communicate.return_value = ("ok", None)
@@ -135,10 +135,10 @@ def test_refresh_uses_pre_rebuild_snapshot_when_provided(monkeypatch):
def test_capture_active_tool_dependencies_uses_tools_status_probes(monkeypatch):
from hermes_cli import tools_config
from hermes_cli import tools_config_post_setup
monkeypatch.setattr(
tools_config,
tools_config_post_setup,
"_module_installed",
lambda module: module in {"langfuse", "ddgs"},
)
@@ -64,8 +64,8 @@ class TestInstallDependenciesRunner:
# tests/tools/test_lazy_deps.py uses.
with _patch.dict(os.environ, {"HERMES_DISABLE_LAZY_INSTALLS": "0"}), \
patch("plugins.memory.find_provider_dir", return_value=tmp_path), \
patch("hermes_cli.tools_config.shutil.which", side_effect=which_side_effect), \
patch("hermes_cli.tools_config.subprocess.run", fake_run):
patch("hermes_cli.tools_config_cua.shutil.which", side_effect=which_side_effect), \
patch("hermes_cli.tools_config_cua.subprocess.run", fake_run):
memory_setup._install_dependencies("x")
return calls, sys.executable
+5 -5
View File
@@ -21,25 +21,25 @@ class TestPostSetupGate:
from hermes_cli import tools_config
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setattr(tools_config.shutil, "which", lambda name, path=None: None)
monkeypatch.setattr("shutil.which", lambda name, path=None: None)
assert tools_config._toolset_needs_configuration_prompt(
"computer_use", {}
) is True
def test_incompatible_cua_driver_forces_setup(self, monkeypatch):
from hermes_cli import tools_config
from hermes_cli import tools_config, tools_config_post_setup
monkeypatch.setattr(tools_config, "_cua_driver_install_ready", lambda: False)
monkeypatch.setattr(tools_config_post_setup, "_cua_driver_install_ready", lambda: False)
assert tools_config._toolset_needs_configuration_prompt(
"computer_use", {}
) is True
def test_compatible_cua_driver_skips_setup(self, monkeypatch):
from hermes_cli import tools_config
from hermes_cli import tools_config, tools_config_post_setup
monkeypatch.setattr(tools_config, "_cua_driver_install_ready", lambda: True)
monkeypatch.setattr(tools_config_post_setup, "_cua_driver_install_ready", lambda: True)
assert tools_config._toolset_needs_configuration_prompt(
"computer_use", {}
+26 -25
View File
@@ -391,13 +391,13 @@ class TestAgentBrowserPostSetup:
Chromium-branch tests never bootstrap uv / hit the network, and so
their print/subprocess assertions stay scoped to the agent-browser
logic under test."""
with patch("hermes_cli.tools_config._ensure_browser_use_cli") as stub:
with patch("hermes_cli.tools_config_post_setup._ensure_browser_use_cli") as stub:
yield stub
def test_warns_when_neither_npx_nor_agent_browser_on_path(self):
with patch("shutil.which", return_value=None), patch(
"subprocess.run"
) as run, patch("hermes_cli.tools_config._print_warning") as warn:
) as run, patch("hermes_cli.tools_config_post_setup._print_warning") as warn:
_run_post_setup("agent_browser")
run.assert_not_called()
@@ -425,7 +425,7 @@ class TestAgentBrowserPostSetup:
) as run, patch(
"tools.browser_tool._chromium_installed", return_value=True
), patch(
"hermes_cli.tools_config._print_success"
"hermes_cli.tools_config_post_setup._print_success"
) as success:
_run_post_setup("agent_browser")
@@ -443,7 +443,7 @@ class TestAgentBrowserPostSetup:
), patch(
"tools.browser_tool._running_in_docker", return_value=True
), patch(
"hermes_cli.tools_config._print_warning"
"hermes_cli.tools_config_post_setup._print_warning"
) as warn:
_run_post_setup("agent_browser")
@@ -464,7 +464,7 @@ class TestAgentBrowserPostSetup:
"tools.browser_tool._find_agent_browser",
side_effect=FileNotFoundError("agent-browser CLI not found"),
), patch(
"hermes_cli.tools_config._print_warning"
"hermes_cli.tools_config_post_setup._print_warning"
) as warn:
_run_post_setup("agent_browser")
@@ -491,7 +491,7 @@ class TestAgentBrowserPostSetup:
), patch(
"tools.browser_tool._find_agent_browser", return_value="npx agent-browser"
), patch(
"hermes_cli.tools_config._print_success"
"hermes_cli.tools_config_post_setup._print_success"
):
run.return_value = SimpleNamespace(returncode=0, stdout="", stderr="")
_run_post_setup("agent_browser")
@@ -519,7 +519,7 @@ class TestAgentBrowserPostSetup:
), patch(
"tools.browser_tool._resolve_npx_bin", return_value=hermes_npx
), patch(
"hermes_cli.tools_config._print_success"
"hermes_cli.tools_config_post_setup._print_success"
):
run.return_value = SimpleNamespace(returncode=0, stdout="", stderr="")
_run_post_setup("agent_browser")
@@ -545,7 +545,7 @@ class TestAgentBrowserPostSetup:
), patch(
"tools.browser_tool._resolve_npx_bin", return_value=None
), patch(
"hermes_cli.tools_config._print_warning"
"hermes_cli.tools_config_post_setup._print_warning"
) as warn:
_run_post_setup("agent_browser") # must not raise
@@ -566,7 +566,7 @@ class TestAgentBrowserPostSetup:
"tools.browser_tool._find_agent_browser",
return_value="/usr/local/bin/agent-browser",
), patch(
"hermes_cli.tools_config._print_success"
"hermes_cli.tools_config_post_setup._print_success"
):
run.return_value = SimpleNamespace(returncode=0, stdout="", stderr="")
_run_post_setup("agent_browser")
@@ -591,7 +591,7 @@ class TestAgentBrowserPostSetup:
), patch(
"tools.browser_tool._find_agent_browser", return_value="npx agent-browser"
), patch(
"hermes_cli.tools_config._print_success"
"hermes_cli.tools_config_post_setup._print_success"
):
_bt._cached_chromium_installed = True
_run_post_setup("agent_browser")
@@ -618,9 +618,9 @@ class TestAgentBrowserPostSetup:
), patch(
"tools.browser_tool._find_agent_browser", return_value="npx agent-browser"
), patch(
"hermes_cli.tools_config._print_warning"
"hermes_cli.tools_config_post_setup._print_warning"
) as warn, patch(
"hermes_cli.tools_config._print_info"
"hermes_cli.tools_config_post_setup._print_info"
) as info:
_bt._cached_chromium_installed = "sentinel"
_run_post_setup("agent_browser")
@@ -644,7 +644,7 @@ class TestAgentBrowserPostSetup:
), patch(
"tools.browser_tool._find_agent_browser", return_value="npx agent-browser"
), patch(
"hermes_cli.tools_config._print_warning"
"hermes_cli.tools_config_post_setup._print_warning"
) as warn:
_run_post_setup("agent_browser") # must not raise
@@ -659,7 +659,7 @@ class TestBrowserUseCliInstalledForAllNonCamofoxBackends:
@pytest.mark.parametrize("key", ["agent_browser", "browserbase", "browser_use_cli"])
def test_browser_post_setup_attempts_cli_install(self, key):
with patch("hermes_cli.tools_config._ensure_browser_use_cli") as ensure, patch(
with patch("hermes_cli.tools_config_post_setup._ensure_browser_use_cli") as ensure, patch(
"shutil.which", return_value=None
), patch("subprocess.run"):
_run_post_setup(key)
@@ -669,7 +669,7 @@ class TestBrowserUseCliInstalledForAllNonCamofoxBackends:
"""Camofox is Firefox-based with no CDP surface; the CDP-only
browser-use harness cannot drive it, so its setup must not pull
the CLI in."""
with patch("hermes_cli.tools_config._ensure_browser_use_cli") as ensure, patch(
with patch("hermes_cli.tools_config_post_setup._ensure_browser_use_cli") as ensure, patch(
"hermes_constants.find_node_executable", return_value=None
), patch("subprocess.run"):
_run_post_setup("camofox")
@@ -680,7 +680,7 @@ class TestBrowserUseCliInstalledForAllNonCamofoxBackends:
helper — install_cli() owns the managed-copy check and provisions
$HERMES_HOME/bin when only side installs exist."""
with patch(
"hermes_cli.tools_config.shutil.which", return_value="/usr/bin/browser-use"
"hermes_cli.tools_config_post_setup.shutil.which", return_value="/usr/bin/browser-use"
), patch(
"tools.browser_use_cli.install_cli",
return_value=(True, "browser-use CLI already installed (/managed/bin/browser-use)"),
@@ -696,11 +696,11 @@ class TestBrowserUseCliInstalledForAllNonCamofoxBackends:
from hermes_cli.tools_config import _ensure_browser_use_cli
with patch(
"hermes_cli.tools_config.shutil.which", return_value=None
"hermes_cli.tools_config_post_setup.shutil.which", return_value=None
), patch(
"tools.browser_use_cli.install_cli",
return_value=(False, "`uv tool install browser-use` failed:\nboom"),
), patch("hermes_cli.tools_config._print_warning") as warn:
), patch("hermes_cli.tools_config_post_setup._print_warning") as warn:
_ensure_browser_use_cli() # must not raise
assert any("failed" in c.args[0] for c in warn.call_args_list)
@@ -886,13 +886,14 @@ def test_vision_picker_custom_endpoint(tmp_path, monkeypatch):
"""Custom endpoint writes base_url+model to config and the key to env."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
import hermes_cli.tools_config as tc
import hermes_cli.tools_config_providers as tcp
from hermes_cli.config import load_config
seq = iter([2]) # Custom OpenAI-compatible endpoint
prompts = iter(["https://my.endpoint/v1", "sk-secret", "my-vision-model"])
with patch.object(tc, "_prompt_choice", side_effect=lambda *a, **k: next(seq)), \
patch.object(tc, "_prompt", side_effect=lambda *a, **k: next(prompts)), \
patch.object(tc, "save_env_value") as save_env, \
patch.object(tcp, "_prompt", side_effect=lambda *a, **k: next(prompts)), \
patch.object(tcp, "save_env_value") as save_env, \
patch.object(tc, "_toolset_has_keys", return_value=False):
tc._configure_vision_backend()
@@ -1251,15 +1252,15 @@ class TestLightpandaPostSetup:
@pytest.fixture(autouse=True)
def _stub_browser_use_install(self):
with patch("hermes_cli.tools_config._ensure_browser_use_cli") as stub:
with patch("hermes_cli.tools_config_post_setup._ensure_browser_use_cli") as stub:
yield stub
def test_reports_binary_when_found(self, _stub_browser_use_install):
from hermes_cli.tools_config import _run_post_setup
with patch("tools.browser_lightpanda.find_lightpanda_binary", return_value="/opt/lightpanda"), \
patch("hermes_cli.tools_config._print_success") as ok, \
patch("hermes_cli.tools_config._print_warning") as warn:
patch("hermes_cli.tools_config_post_setup._print_success") as ok, \
patch("hermes_cli.tools_config_post_setup._print_warning") as warn:
_run_post_setup("lightpanda")
_stub_browser_use_install.assert_called_once()
assert "/opt/lightpanda" in ok.call_args.args[0]
@@ -1270,8 +1271,8 @@ class TestLightpandaPostSetup:
from tools.browser_lightpanda import LIGHTPANDA_INSTALL_URL
with patch("tools.browser_lightpanda.find_lightpanda_binary", return_value=None), \
patch("hermes_cli.tools_config._print_warning") as warn, \
patch("hermes_cli.tools_config._print_info") as info:
patch("hermes_cli.tools_config_post_setup._print_warning") as warn, \
patch("hermes_cli.tools_config_post_setup._print_info") as info:
_run_post_setup("lightpanda")
assert "not found" in warn.call_args.args[0]
assert any(LIGHTPANDA_INSTALL_URL in c.args[0] for c in info.call_args_list)
+3 -3
View File
@@ -85,7 +85,7 @@ class TestReconfigureWritesProvider:
):
"""Env vars present and user accepts current value → still writes
video_gen.provider via the post-env-vars branch."""
from hermes_cli import tools_config
from hermes_cli import tools_config, tools_config_providers
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
video_gen_registry.register_provider(_FakeVideoProvider("xai_fake"))
@@ -93,11 +93,11 @@ class TestReconfigureWritesProvider:
# Picker prompts replaced — no TTY in tests.
monkeypatch.setattr(tools_config, "_prompt_choice", lambda *a, **kw: 0)
# User presses Enter to keep the existing key.
monkeypatch.setattr(tools_config, "_prompt", lambda *a, **kw: "")
monkeypatch.setattr(tools_config_providers, "_prompt", lambda *a, **kw: "")
# Pretend the env var is already set so the reconfigure path
# hits the "Kept current" branch.
monkeypatch.setattr(
tools_config,
tools_config_providers,
"get_env_value",
lambda key: "sk-fake" if key == "XAI_FAKE_API_KEY" else "",
)
@@ -49,6 +49,7 @@ class TestToggleToolsetInstallOnEnable:
self, monkeypatch
):
import hermes_cli.tools_config as tools_config
import hermes_cli.tools_config_post_setup as tools_config_post_setup
calls = self._spawn_recorder(monkeypatch)
# Binary missing → the cua_driver predicate reports unsatisfied.
@@ -56,7 +57,7 @@ class TestToggleToolsetInstallOnEnable:
tools_config, "_resolved_cua_driver_cmd", lambda: None
)
monkeypatch.setattr(
tools_config, "_cua_driver_install_ready", lambda: False
tools_config_post_setup, "_cua_driver_install_ready", lambda: False
)
resp = self.client.put(
@@ -75,13 +76,14 @@ class TestToggleToolsetInstallOnEnable:
self, monkeypatch
):
import hermes_cli.tools_config as tools_config
import hermes_cli.tools_config_post_setup as tools_config_post_setup
calls = self._spawn_recorder(monkeypatch)
monkeypatch.setattr(
tools_config, "_resolved_cua_driver_cmd", lambda: "/usr/bin/cua-driver"
)
monkeypatch.setattr(
tools_config, "_cua_driver_install_ready", lambda: True
tools_config_post_setup, "_cua_driver_install_ready", lambda: True
)
resp = self.client.put(
@@ -93,13 +95,14 @@ class TestToggleToolsetInstallOnEnable:
def test_disable_never_spawns_install(self, monkeypatch):
import hermes_cli.tools_config as tools_config
import hermes_cli.tools_config_post_setup as tools_config_post_setup
calls = self._spawn_recorder(monkeypatch)
monkeypatch.setattr(
tools_config, "_resolved_cua_driver_cmd", lambda: None
)
monkeypatch.setattr(
tools_config, "_cua_driver_install_ready", lambda: False
tools_config_post_setup, "_cua_driver_install_ready", lambda: False
)
resp = self.client.put(
@@ -111,13 +114,14 @@ class TestToggleToolsetInstallOnEnable:
def test_spawn_failure_does_not_fail_the_toggle(self, monkeypatch):
import hermes_cli.tools_config as tools_config
import hermes_cli.tools_config_post_setup as tools_config_post_setup
import hermes_cli.web_server as web_server
monkeypatch.setattr(
tools_config, "_resolved_cua_driver_cmd", lambda: None
)
monkeypatch.setattr(
tools_config, "_cua_driver_install_ready", lambda: False
tools_config_post_setup, "_cua_driver_install_ready", lambda: False
)
def _boom(subcommand, name, **kwargs):
+3 -1
View File
@@ -2839,9 +2839,11 @@ class TestNewEndpoints:
),
)
# No xAI credentials → the Grok OAuth-backed row needs sign-in.
import hermes_cli.tools_config_post_setup as tools_config_post_setup
monkeypatch.setattr(tools_config, "_xai_credentials_present", lambda: False)
# Local TTS engines not installed → their rows need setup.
monkeypatch.setattr(tools_config, "_module_installed", lambda name: False)
monkeypatch.setattr(tools_config_post_setup, "_module_installed", lambda name: False)
monkeypatch.delenv("ELEVENLABS_API_KEY", raising=False)
resp = self.client.get("/api/tools/toolsets/tts/config")