diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 8fd7cd4ea5e..f185c8788c2 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -482,6 +482,11 @@ TOOLSET_ENV_REQUIREMENTS = { # ─── Post-Setup Hooks ───────────────────────────────────────────────────────── +def _cua_driver_cmd() -> str: + """Return the cua-driver executable name/path, honoring non-empty overrides.""" + return os.environ.get("HERMES_CUA_DRIVER_CMD", "").strip() or "cua-driver" + + def _pip_install( args: List[str], *, @@ -628,7 +633,8 @@ def install_cua_driver(upgrade: bool = False) -> bool: _print_warning(" Computer Use (cua-driver) is macOS-only; skipping.") return False - binary = shutil.which("cua-driver") + driver_cmd = _cua_driver_cmd() + binary = shutil.which(driver_cmd) # Not installed → fresh install path (only when caller asked for it). if not binary and not upgrade: @@ -644,12 +650,12 @@ def install_cua_driver(upgrade: bool = False) -> bool: if binary and not upgrade: try: version = subprocess.run( - ["cua-driver", "--version"], + [driver_cmd, "--version"], capture_output=True, text=True, timeout=5, ).stdout.strip() - _print_success(f" cua-driver already installed: {version or 'unknown version'}") + _print_success(f" {driver_cmd} already installed: {version or 'unknown version'}") except Exception: - _print_success(" cua-driver already installed.") + _print_success(f" {driver_cmd} already installed.") _print_info(" Grant macOS permissions if not done yet:") _print_info(" System Settings > Privacy & Security > Accessibility") _print_info(" System Settings > Privacy & Security > Screen Recording") @@ -667,7 +673,7 @@ def install_cua_driver(upgrade: bool = False) -> bool: # Show before/after version when we have a baseline. Best-effort. try: before = subprocess.run( - ["cua-driver", "--version"], + [driver_cmd, "--version"], capture_output=True, text=True, timeout=5, ).stdout.strip() except Exception: @@ -679,13 +685,13 @@ def install_cua_driver(upgrade: bool = False) -> bool: if ok and before: try: after = subprocess.run( - ["cua-driver", "--version"], + [driver_cmd, "--version"], capture_output=True, text=True, timeout=5, ).stdout.strip() if after and after != before: - _print_success(f" cua-driver upgraded: {before} → {after}") + _print_success(f" {driver_cmd} upgraded: {before} → {after}") elif after: - _print_info(f" cua-driver up to date: {after}") + _print_info(f" {driver_cmd} up to date: {after}") except Exception: pass return ok @@ -709,11 +715,12 @@ def _run_cua_driver_installer(label: str = "Installing", verbose: bool = True) - _print_info(f" {label} cua-driver (macOS background computer-use)...") else: _print_info(f" {label} cua-driver...") + driver_cmd = _cua_driver_cmd() try: result = subprocess.run(install_cmd, shell=True, timeout=300) - if result.returncode == 0 and shutil.which("cua-driver"): + if result.returncode == 0 and shutil.which(driver_cmd): if verbose: - _print_success(" cua-driver installed.") + _print_success(f" {driver_cmd} installed.") _print_info(" IMPORTANT — grant macOS permissions now:") _print_info(" System Settings > Privacy & Security > Accessibility") _print_info(" System Settings > Privacy & Security > Screen Recording") @@ -1805,7 +1812,7 @@ _POST_SETUP_INSTALLED: dict = { # entry when (a) the post_setup is the ONLY install side-effect for # a no-key provider, and (b) an installed-state check is cheap and # doesn't trigger a heavy import. - "cua_driver": lambda: bool(shutil.which("cua-driver")), + "cua_driver": lambda: bool(shutil.which(_cua_driver_cmd())), } diff --git a/tests/hermes_cli/test_tools_config.py b/tests/hermes_cli/test_tools_config.py index 787292d83a4..0cb42ba299a 100644 --- a/tests/hermes_cli/test_tools_config.py +++ b/tests/hermes_cli/test_tools_config.py @@ -12,8 +12,10 @@ from hermes_cli.tools_config import ( _get_platform_tools, _platform_toolset_summary, _reconfigure_tool, + _run_post_setup, _save_platform_tools, _toolset_has_keys, + _toolset_needs_configuration_prompt, CONFIGURABLE_TOOLSETS, TOOL_CATEGORIES, _visible_providers, @@ -752,6 +754,91 @@ def test_numeric_mcp_server_name_does_not_crash_sorted(): # ─── Imagegen Backend Picker Wiring ──────────────────────────────────────── +def test_toolset_has_keys_treats_no_key_providers_as_configured(): + config = {} + + assert _toolset_has_keys("computer_use", config) is True + + +def test_computer_use_needs_configuration_when_cua_driver_post_setup_pending(): + """No-key providers can still need setup when their post_setup is unsatisfied. + + Returning users enabling Computer Use through `hermes tools` must reach the + cua-driver post-setup installer even though the provider has no API keys. + """ + with patch("shutil.which", return_value=None): + assert _toolset_needs_configuration_prompt("computer_use", {}) is True + + +def test_computer_use_skips_configuration_when_cua_driver_already_installed(): + """Installed post_setup dependencies should keep returning-user toggles no-op.""" + def fake_which(name: str): + return "/usr/local/bin/cua-driver" if name == "cua-driver" else None + + with patch("shutil.which", side_effect=fake_which): + assert _toolset_needs_configuration_prompt("computer_use", {}) is False + + +def test_computer_use_respects_custom_cua_driver_command(): + """The setup gate should match runtime's HERMES_CUA_DRIVER_CMD override.""" + def fake_which(name: str): + return "/opt/bin/custom-cua" if name == "custom-cua" else None + + with patch.dict("os.environ", {"HERMES_CUA_DRIVER_CMD": "custom-cua"}), \ + patch("shutil.which", side_effect=fake_which): + assert _toolset_needs_configuration_prompt("computer_use", {}) is False + + +def test_computer_use_blank_custom_driver_command_falls_back_to_default(): + """Blank overrides should not make the setup gate look for an empty command.""" + def fake_which(name: str): + return "/usr/local/bin/cua-driver" if name == "cua-driver" else None + + with patch.dict("os.environ", {"HERMES_CUA_DRIVER_CMD": " "}), \ + patch("shutil.which", side_effect=fake_which): + assert _toolset_needs_configuration_prompt("computer_use", {}) is False + + +def test_computer_use_post_setup_respects_custom_driver_command_when_installed(): + """post_setup already-installed checks should version-probe the override.""" + def fake_which(name: str): + return "/opt/bin/custom-cua" if name == "custom-cua" else None + + with patch.dict("os.environ", {"HERMES_CUA_DRIVER_CMD": "custom-cua"}), \ + patch("platform.system", return_value="Darwin"), \ + patch("shutil.which", side_effect=fake_which), \ + patch("subprocess.run") as run: + run.return_value.stdout = "custom 1.2.3\n" + + _run_post_setup("cua_driver") + + run.assert_called_once() + assert run.call_args.args[0] == ["custom-cua", "--version"] + + +def test_computer_use_post_setup_missing_override_does_not_accept_default_binary(): + """A default cua-driver binary must not satisfy a missing runtime override.""" + seen = [] + + def fake_which(name: str): + seen.append(name) + if name == "cua-driver": + return "/usr/local/bin/cua-driver" + if name == "curl": + return None + return None + + with patch.dict("os.environ", {"HERMES_CUA_DRIVER_CMD": "custom-cua"}), \ + patch("platform.system", return_value="Darwin"), \ + patch("shutil.which", side_effect=fake_which), \ + patch("subprocess.run") as run: + _run_post_setup("cua_driver") + + run.assert_not_called() + assert "custom-cua" in seen + assert "curl" in seen + + class TestImagegenBackendRegistry: """IMAGEGEN_BACKENDS tags drive the model picker flow in tools_config."""