fix(computer-use): windowless, non-interactive Windows autostart repair
The shared cua-driver install/refresh path reached _repair_cua_driver_autostart_windows, which spawned powershell.exe without CREATE_NO_WINDOW and without -NonInteractive. Under a windowless parent (Desktop backend, detached gateway, logon task) that child allocated its OWN console: a blank PowerShell window parked on the desktop for as long as the elevated -Verb RunAs -Wait child lived, with no interactive prompt it could unwind from. Measured live on Windows 11 (pythonw parent): production kwargs open a visible console/Terminal window, the same spawn with CREATE_NO_WINDOW opens none. A failed repair also failed the whole install, so a compatible, working Computer Use toolset read as broken and the install was re-attempted on the next run. It now degrades instead: warn plus the elevated 'cua-driver autostart enable' hint. Fixes #115017
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user