The default browser_exec tool ran the `browser-use` CLI from a PM side environment (browser-use==0.13.10 in <home>/environments/browser-use), provisioned by the installers and `hermes update`. Sealed Desktop payloads skip that step, so the Desktop app never had it and silently fell back to the built-in tools; the side env was also per-profile and 225 MB. The CLI's execution path is only `browser_harness.run.main()`; the browser-use agent framework (anthropic/openai/google-api pins, 93 MB of googleapiclient) is never imported. browser-harness itself is 2.6 MB of pure Python whose pins (Pillow 12.3.0, websockets 15.0.1) already match Hermes's own, so it becomes a core dependency and runs on sys.executable: - pyproject/uv.lock: browser-harness==0.1.13 (+ cdp-use, fetch-use). - _find_cli() returns [sys.executable, -m, browser_harness.run]; the child env points PYTHONPATH at the harness site dir (the Desktop store interpreter boots without a venv and the harness daemon re-runs sys.executable), replacing whatever the agent inherited. - The side-env provisioning (install_cli, the update/installer step) goes.
168 lines
6.7 KiB
Python
168 lines
6.7 KiB
Python
"""Provider setup delegates package policy to PM and external server owners."""
|
|
|
|
import os
|
|
import sys
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from threading import Thread
|
|
from types import ModuleType
|
|
from unittest.mock import patch
|
|
|
|
import pm
|
|
import pytest
|
|
|
|
from hermes_cli.tools_config_post_setup import _run_post_setup
|
|
|
|
|
|
@pytest.mark.platforms('linux')
|
|
@pytest.mark.parametrize('provider,docker,playwright', [
|
|
('browserbase', False, False), ('agent_browser', False, True),
|
|
('agent_browser', False, False), ('agent_browser', True, False),
|
|
('browser_use_cli', False, False),
|
|
])
|
|
def test_browser_setup_respects_provider_and_native_owner(monkeypatch, capsys, provider, docker, playwright):
|
|
with (
|
|
patch('tools.browser_use_cli._find_cli', return_value=['harness']) as install,
|
|
patch('tools.browser_tool_install._running_in_docker', return_value=docker),
|
|
patch('tools.browser_tool_install._find_agent_browser', return_value='/image/agent-browser'),
|
|
patch('tools.browser_tool_install._chromium_installed', return_value=False) as chromium,
|
|
patch('importlib.util.find_spec', return_value=object() if playwright else None),
|
|
patch('shutil.which', return_value='/usr/bin/apt-get'),
|
|
patch('pm.ensure') as ensure, patch('subprocess.run') as run,
|
|
):
|
|
before = dict(os.environ)
|
|
_run_post_setup(provider)
|
|
assert dict(os.environ) == before
|
|
install.assert_called_once()
|
|
run.assert_not_called()
|
|
if provider == 'browserbase':
|
|
chromium.assert_not_called()
|
|
if provider == 'browser_use_cli' or docker:
|
|
ensure.assert_not_called()
|
|
else:
|
|
ensure.assert_called_once_with('agent-browser', explicit=True)
|
|
output = capsys.readouterr().out
|
|
if docker:
|
|
assert 'Docker' in output
|
|
elif provider == 'agent_browser':
|
|
assert 'system' in output and 'npx' not in output
|
|
if playwright:
|
|
assert sys.executable in output and '-m playwright install-deps chromium' in output
|
|
else:
|
|
assert 'https://playwright.dev/python/docs/browsers#install-system-dependencies' in output
|
|
|
|
|
|
@pytest.mark.parametrize("status", [200, 503])
|
|
def test_camofox_setup_leaves_external_server_and_config_owned_by_user(monkeypatch, capsys, status):
|
|
from hermes_cli.config import get_hermes_home
|
|
from hermes_cli.tools_config_post_setup import _POST_SETUP_READY
|
|
|
|
class Health(BaseHTTPRequestHandler):
|
|
def do_GET(self):
|
|
assert self.path == "/health"
|
|
self.send_response(status)
|
|
self.end_headers()
|
|
self.wfile.write(b"{}")
|
|
|
|
def log_message(self, format, *args):
|
|
pass
|
|
|
|
config_path = get_hermes_home() / "config.yaml"
|
|
config_path.write_text("browser:\n cloud_provider: camofox\n", encoding="utf-8")
|
|
before = config_path.read_bytes()
|
|
with ThreadingHTTPServer(("127.0.0.1", 0), Health) as server:
|
|
thread = Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
monkeypatch.setenv("CAMOFOX_URL", f"http://127.0.0.1:{server.server_port}")
|
|
try:
|
|
with (
|
|
patch("hermes_constants.find_node_executable", return_value="/external/npm"),
|
|
patch("subprocess.run") as run,
|
|
patch("pm.ensure") as ensure,
|
|
):
|
|
_run_post_setup("camofox")
|
|
run.assert_not_called()
|
|
ensure.assert_not_called()
|
|
assert _POST_SETUP_READY["camofox"]() is (status == 200)
|
|
finally:
|
|
server.shutdown()
|
|
thread.join(timeout=5)
|
|
|
|
assert config_path.read_bytes() == before
|
|
output = capsys.readouterr().out
|
|
assert "externally managed" in output
|
|
assert ("reachable" if status == 200 else "not reachable") in output
|
|
|
|
|
|
@pytest.mark.parametrize("key,extra", [
|
|
("ddgs", "ddgs"), ("faster_whisper", "stt-whisper"),
|
|
("kittentts", "kittentts"), ("piper", "piper"),
|
|
])
|
|
@pytest.mark.parametrize("refused", [False, True])
|
|
def test_importable_sdk_does_not_bypass_pm_constraints(monkeypatch, capsys, key, extra, refused):
|
|
sdk = ModuleType(key)
|
|
sdk.__version__ = "0.0.0"
|
|
monkeypatch.setitem(sys.modules, key, sdk)
|
|
error = pm.InstallError("venv", "outside frozen feature set") if refused else None
|
|
with patch("pm.sync_venv", side_effect=error) as sync:
|
|
_run_post_setup(key)
|
|
|
|
sync.assert_called_once_with([extra], explicit=True)
|
|
output = capsys.readouterr().out
|
|
if refused:
|
|
assert "outside frozen feature set" in output
|
|
assert "Retry with: hermes tools" in output
|
|
assert "Restart Hermes" not in output
|
|
else:
|
|
assert "Restart Hermes" in output
|
|
|
|
|
|
@pytest.mark.parametrize("failure", [None, "sdk", "admission"])
|
|
def test_langfuse_setup_uses_plugin_admission_and_preserves_config_on_refusal(
|
|
monkeypatch, tmp_path, capsys, failure,
|
|
):
|
|
from hermes_cli.config import get_hermes_home, read_raw_config
|
|
|
|
monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "runtime"))
|
|
monkeypatch.setitem(sys.modules, "langfuse", ModuleType("langfuse"))
|
|
config_path = get_hermes_home() / "config.yaml"
|
|
config_path.write_text(
|
|
"plugins:\n enabled: [other]\n disabled: [langfuse, observability/langfuse]\n",
|
|
encoding="utf-8",
|
|
)
|
|
before = config_path.read_bytes()
|
|
|
|
def resolve_candidate(**kwargs):
|
|
assert kwargs["explicit"] is True
|
|
assert kwargs["plugins"].data["enabled"]
|
|
if failure == "admission":
|
|
raise pm.InstallError("venv", "candidate refused")
|
|
# The real admission publisher must commit both lists, not a second UI writer.
|
|
from pm.publication import PluginSelection
|
|
from pm.paths import repo_root
|
|
from hermes_cli.runtime_state import runtime_lock, finish_publication
|
|
with runtime_lock(repo_root()):
|
|
PluginSelection(dict(kwargs["plugins"].data)).publish(repo_root())
|
|
finish_publication(repo_root())
|
|
|
|
with (
|
|
patch("pm.sync_venv", side_effect=pm.InstallError("venv", "SDK refused") if failure == "sdk" else None) as sdk,
|
|
patch("pm.client.sync_venv", side_effect=resolve_candidate) as admission,
|
|
):
|
|
_run_post_setup("langfuse")
|
|
|
|
sdk.assert_called_once_with(["langfuse"], explicit=True)
|
|
if failure == "sdk":
|
|
admission.assert_not_called()
|
|
else:
|
|
admission.assert_called_once()
|
|
if failure:
|
|
assert config_path.read_bytes() == before
|
|
output = capsys.readouterr().out
|
|
assert "refused" in output
|
|
if failure == "sdk":
|
|
assert "Retry with: hermes tools" in output
|
|
else:
|
|
plugin_config = read_raw_config()["plugins"]
|
|
assert set(plugin_config["enabled"]) == {"other", "observability/langfuse"}
|
|
assert plugin_config["disabled"] == []
|