diff --git a/hermes_cli/tools_config_cua.py b/hermes_cli/tools_config_cua.py index 1d3246fdf4..219bb71527 100644 --- a/hermes_cli/tools_config_cua.py +++ b/hermes_cli/tools_config_cua.py @@ -303,7 +303,13 @@ def install_cua_driver(upgrade: bool = False, require_confirmed_update: bool = F suffix = "" if version is None else f": {version or 'unknown version'}" _print_success(f" {driver_cmd} already installed{suffix}.") if is_windows and not _repair_cua_driver_autostart_windows(binary, verbose=False): - return _fail(" cua-driver is compatible, but Windows autostart repair failed.") + # Degrade, do not fail (#115017): the driver is installed and compatible — only its + # logon task is missing (declined or unavailable UAC consent, no interactive desktop). + # Returning False here made the whole install report failure, so a working Computer Use + # toolset read as broken and the install was re-attempted on the next run. The reachable + # next step is the manual elevated command. + _print_warning(" cua-driver is compatible; its logon auto-start was not registered.") + _print_info(" From an elevated shell, run: cua-driver autostart enable") _print_cua_platform_notes(is_windows, is_linux, fresh_install=False) return True if repair_existing: @@ -512,7 +518,16 @@ def _repair_cua_driver_autostart_windows(driver_cmd: str, *, verbose: bool) -> b """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.""" + structured ``-FilePath`` / ``-ArgumentList`` parameters instead. + + The wrapper itself must stay invisible and unattended (#115017): this runs from the shared + install/refresh path, which a windowless parent drives (Desktop backend, detached gateway, a + logon task). A ``powershell.exe`` spawned without ``CREATE_NO_WINDOW`` allocates its OWN console + there — a blank PowerShell window parked on the user's desktop for as long as the elevated + ``-Verb RunAs -Wait`` child lives — and without ``-NonInteractive`` that shell can sit on an + interactive prompt instead of unwinding. ``CREATE_NO_WINDOW`` (stdlib ``windows_hide_flags``, + the same seam every other spawn in this module uses) keeps stdio as pipes so the failure text + is still reported; the OS-owned UAC consent UI is unaffected.""" if sys.platform != "win32" or _cua_driver_autostart_registered_windows(): return True binary = shutil.which(driver_cmd) @@ -525,8 +540,10 @@ def _repair_cua_driver_autostart_windows(driver_cmd: str, *, verbose: bool) -> b _print_info(" Registering cua-driver auto-start..." if verbose else " Repairing cua-driver auto-start registration...") try: - result = _run_text([ps, "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps_cmd], - timeout=300, env=_cua_driver_env()) + result = _run_text([ps, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", + "-Command", ps_cmd], + timeout=300, env=_cua_driver_env(), + creationflags=_post_setup_no_window_flags()) except subprocess.TimeoutExpired: return _fail(" cua-driver autostart registration timed out.") except Exception as exc: diff --git a/tests/hermes_cli/test_install_cua_driver.py b/tests/hermes_cli/test_install_cua_driver.py index 3683f7e0be..f53e8c14a5 100644 --- a/tests/hermes_cli/test_install_cua_driver.py +++ b/tests/hermes_cli/test_install_cua_driver.py @@ -1628,6 +1628,77 @@ class TestWindowsAutostartRepair: assert f"$exe = '{driver}'" in ps_command assert f"& {driver}" not in ps_command + @pytest.mark.windows_only + def test_repair_spawns_no_console_window_and_failure_degrades(self): + """``windows_only``: issue #115017 — the auto-start repair must not park a blank PowerShell + window on the desktop, and a failed repair must not fail the install. + + This repair is reached from the shared install/refresh path, which a windowless parent + drives (Desktop backend, detached gateway, a logon task). A ``powershell.exe`` spawned + there WITHOUT ``CREATE_NO_WINDOW`` allocates its own console: a blank PowerShell window that + stays on screen for as long as the elevated ``-Verb RunAs -Wait`` child lives (verified live + on Windows 11: the window appears, and with ``CREATE_NO_WINDOW`` it never does). Without + ``-NonInteractive`` that shell can also sit on an interactive prompt instead of unwinding. + """ + from hermes_cli import tools_config_cua as tools_config + + create_no_window = 0x08000000 + calls = [] + driver = ( + r"C:\Users\Ha Trung\AppData\Local\Programs\Cua" + r"\cua-driver\bin\cua-driver.exe" + ) + + def fake_which(name: str): + if name == "cua-driver": + return driver + if name == "powershell": + return r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe" + return None + + def fake_run(cmd, **kwargs): + calls.append((cmd, kwargs)) + if cmd[0] == "schtasks.exe": # task absent -> the repair branch runs + return SimpleNamespace(returncode=1, stdout="", stderr="") + return SimpleNamespace(returncode=1, stdout="", stderr="denied") # repair itself fails + + with patch.object(tools_config.shutil, "which", side_effect=fake_which), \ + patch("subprocess.run", side_effect=fake_run), \ + patch.object(tools_config, "_post_setup_no_window_flags", + side_effect=lambda **kw: create_no_window), \ + patch.object(tools_config, "_print_warning") as warn, \ + patch.object(tools_config, "_print_info"): + assert tools_config._repair_cua_driver_autostart_windows( + "cua-driver", verbose=False + ) is False + + ps_cmd, ps_kwargs = next( + (cmd, kwargs) for cmd, kwargs in calls + if str(cmd[0]).lower().endswith("powershell.exe") + ) + assert ps_kwargs.get("creationflags") == create_no_window, ( + "windowless spawn is the fix: without CREATE_NO_WINDOW the child allocates its own " + "visible console window on the user's desktop" + ) + assert "-NonInteractive" in ps_cmd, "no interactive prompt may be presented" + assert ps_cmd[-2:] == ["-Command", ps_cmd[-1]], "script stays the -Command argument" + assert ps_kwargs.get("timeout"), "the repair must stay bounded, never block the caller" + + # Degrade, do not fail: the driver is installed and compatible, only its logon task is + # missing — install_cua_driver must not report the whole toolset as broken. + with patch.object(tools_config, "_resolved_cua_driver_cmd", return_value=driver), \ + patch.object(tools_config, "_cua_driver_contract_status", + return_value={"ready": True, "version": "0.20.0", "reason": ""}), \ + patch.object(tools_config, "_cua_driver_version", return_value="0.20.0"), \ + patch.object(tools_config, "_repair_cua_driver_autostart_windows", + return_value=False), \ + patch.object(tools_config, "_print_success"): + assert tools_config.install_cua_driver( + upgrade=False, show_installer_progress=False + ) is True + + assert any("auto-start" in str(call) for call in warn.call_args_list) + class TestCuaVersionSummary: """`hermes computer-use status` prints one line, whatever the binary says.