Files
hermes-agent/tests/pm/test_install_default_closure.py
teknium1 27062c3474 fix: install cua-driver and the Browser Use CLI by default again
Computer use and browser use are meant to work out of the box. The PM rewrite
(3d12e86ef1) and the MSIX installer rework (47f4ab3a17) dropped the
install-time cua-driver fetch (7060ac7bed) and the Browser Use CLI install
(baa6b2e34d). On a fresh install the computer_use check_fn therefore stayed
False, so the tool never reached the model and its lazy ensure could not fire,
and browser_exec quietly fell back to the built-in tools.

- cua-driver is a default PM package, so the installers, a bare
  `hermes pm install` and `hermes update` carry it on every target it builds
  for. Adds the missing Android gap (the lock has no bionic artifact).
- The shared default-tool step, which the installers (via source completion)
  and `hermes update` both run, provisions the Browser Use CLI for the default
  and explicit Browser Use backends. `--skip-browser` declines it along with
  agent-browser, and `off`/Camofox never use it.
- Installers regain --skip-computer-use / -SkipComputerUse (recorded as
  `--without cua-driver`).
- The update message stops calling every default "browser tools".
2026-09-27 19:18:58 -07:00

160 lines
6.8 KiB
Python

"""The default `pm install` closure must stage the interpreter boot requires.
A fresh source install emits boot launchers (the source completion's
publish_launchers, hermes_cli/_launchers.py) that exec the pm STORE
interpreter. The `python`
package is marked optional (dev installs use their own venv; sealed bundles
adopt a shipped one), so the old default closure — every non-optional
lockfile package — skipped it and left `hermes` unbootable (audit C05).
Behavioral: drive cmd_install with the real lockfile/registry and stub
installers, asserting on the requested package names, not on script text.
"""
from __future__ import annotations
import argparse
import importlib
import pytest
import pm.cli
@pytest.fixture()
def install_spy(monkeypatch):
calls = {"names": None, "verified": None, "sync_extras": None, "activated": [], "fail": set()}
def fake_install_names(names, target=None, *, verify=True):
# Accumulate: optional defaults install in their own call after the
# required closure.
calls["names"] = [*(calls["names"] or []), *names]
calls["verified"] = verify
return len(calls["fail"] & set(names))
def fake_sync_venv(extras=None, **kwargs):
calls["sync_extras"] = list(extras or [])
return None
def fake_activate(**kwargs):
calls["activated"].append(kwargs)
return []
monkeypatch.setattr(pm.cli, "_install_names", fake_install_names)
monkeypatch.setattr(importlib.import_module("pm.install"), "sync_venv", fake_sync_venv)
monkeypatch.setattr(importlib.import_module("pm.install"), "activate", fake_activate)
return calls
def test_default_closure_includes_the_boot_interpreter(install_spy):
assert pm.cli.cmd_install(argparse.Namespace(names=None, tools_only=False)) == 0
assert "python" in install_spy["names"]
assert "venv" not in install_spy["names"]
assert all(not pm.cli.get_package(name).internal for name in install_spy["names"])
assert install_spy["sync_extras"] == ["all"]
assert install_spy["activated"] == [{"allow_incomplete": True}]
@pytest.mark.parametrize("names", [["npm", "ripgrep"], ["dmgbuild"]])
def test_explicit_names_pass_through_untouched(install_spy, names) -> None:
assert pm.cli.cmd_install(argparse.Namespace(names=names, tools_only=False)) == 0
assert install_spy["names"] == names
assert install_spy["sync_extras"] is None
assert install_spy["activated"] == []
def test_tools_only_publishes_tools_and_stops_before_the_venv(install_spy):
assert pm.cli.cmd_install(argparse.Namespace(names=None, extra=[], target=None, tools_only=True)) == 0
assert "python" in install_spy["names"]
assert "venv" not in install_spy["names"]
assert install_spy["sync_extras"] is None
assert install_spy["activated"] == [{"allow_incomplete": True}]
def test_trust_recorded_skips_the_byte_check_and_still_syncs(install_spy):
assert pm.cli.cmd_install(argparse.Namespace(
names=None, extra=[], target=None, tools_only=False, trust_recorded=True)) == 0
assert "python" in install_spy["names"]
assert install_spy["verified"] is False
assert install_spy["sync_extras"] == ["all"]
@pytest.mark.parametrize("kwargs, message", [
({"names": ["ripgrep"], "extra": [], "target": None, "tools_only": False, "trust_recorded": True},
"--trust-recorded"),
({"names": None, "extra": ["anthropic"], "target": None, "tools_only": False, "trust_recorded": True},
"--trust-recorded"),
({"names": None, "extra": [], "target": "linux-x64", "tools_only": False, "trust_recorded": True},
"--target"),
])
def test_trust_recorded_refuses_a_narrowed_install(install_spy, capsys, kwargs, message):
assert pm.cli.cmd_install(argparse.Namespace(**kwargs)) == 1
assert message in capsys.readouterr().out
assert install_spy["names"] is None
assert install_spy["sync_extras"] is None
def test_a_missing_tool_blocks_the_venv_sync(install_spy, monkeypatch, capsys):
monkeypatch.setattr(
importlib.import_module("pm.install"), "activate",
lambda **kwargs: ["git: not installed or outdated"],
)
assert pm.cli.cmd_install(argparse.Namespace(names=None, tools_only=False)) == 1
assert install_spy["sync_extras"] is None
assert "git: not installed or outdated" in capsys.readouterr().out
def _bare_install(install_spy, **kwargs) -> list[str]:
install_spy["names"] = None
assert pm.cli.cmd_install(argparse.Namespace(names=None, tools_only=False, **kwargs)) == 0
return install_spy["names"]
def test_browser_tools_are_a_default_the_user_can_decline_and_restore(install_spy):
assert "agent-browser" in _bare_install(install_spy)
assert "agent-browser" not in _bare_install(install_spy, without=["agent-browser"])
# The opt-out is recorded: a later bare install (the updater's shape) keeps it.
assert "agent-browser" not in _bare_install(install_spy)
install_spy["names"] = None
assert pm.cli.cmd_install(argparse.Namespace(names=["agent-browser"], tools_only=False)) == 0
assert "agent-browser" in _bare_install(install_spy)
def test_a_failed_default_download_does_not_fail_the_install(install_spy, capsys):
install_spy["fail"] = {"agent-browser"}
assert "agent-browser" in _bare_install(install_spy)
assert install_spy["sync_extras"] == ["all"]
assert "hermes pm install agent-browser" in capsys.readouterr().out
def test_termux_has_no_pm_browser_default():
from pm.defaults import default_packages
names = pm.cli._lockfile().names()
assert "agent-browser" in default_packages(names, target="linux-x64", declined_names=frozenset())
assert default_packages(names, target="linux-arm64-bionic", declined_names=frozenset()) == []
def test_without_refuses_a_required_package(install_spy, capsys):
assert pm.cli.cmd_install(argparse.Namespace(names=None, tools_only=False, without=["git"])) == 1
assert "--without accepts only" in capsys.readouterr().out
assert install_spy["names"] is None
def test_default_install_carries_the_computer_use_driver_wherever_it_builds():
"""Computer use's only OS path ships in the box; a default claimed for a target
with no pinned artifact would fail every install there (the bionic KeyError)."""
from pm.defaults import default_packages
from pm.lock import Lockfile
from pm.paths import lockfile_path
from pm.store import ALL_TARGETS
lock = Lockfile(lockfile_path())
for target in ALL_TARGETS:
defaults = default_packages(lock.names(), target=target, declined_names=frozenset())
assert all(lock.artifacts(name, target) for name in defaults), target
driver = pm.cli.get_package("cua-driver")
assert ("cua-driver" in defaults) == (driver.missing_reason(target) is None), target
assert "cua-driver" not in default_packages(
lock.names(), target="linux-x64", declined_names=frozenset({"cua-driver"}))