Files
hermes-agent/tests/tools/test_bot_desktop_browser.py
ethernet 66d54ebf51 Merge remote-tracking branch 'origin/main' into ethie/pm-clean
# Conflicts:
#	.github/workflows/docker.yml
#	Dockerfile
#	apps/desktop/src/app/settings/about-settings.tsx
#	docker/stage2-hook.sh
2026-09-24 02:23:44 -04:00

379 lines
20 KiB
Python

"""The dock's Browser and agent-browser resolve to one identity: same executable, same user-data-dir —
and when a human opened that browser first, the agent attaches to it instead of launching a second one
(Chromium's profile singleton would forward the launch and kill it without a DevTools endpoint)."""
from __future__ import annotations
import os
import socket
from pathlib import Path
from types import SimpleNamespace
import pytest
from tools.bot_desktop import browser, runtime
def test_dock_and_agent_share_browser_identity(tmp_path, monkeypatch):
exe = tmp_path / "chrome"
exe.write_text("#!/bin/sh\n", encoding="utf-8")
exe.chmod(0o755)
monkeypatch.setenv("AGENT_BROWSER_EXECUTABLE_PATH", str(exe))
monkeypatch.delenv("AGENT_BROWSER_PROFILE", raising=False)
monkeypatch.setattr(runtime, "state_dir", lambda: tmp_path / "bot-desktop")
dock_exe, dock_profile = browser.dock_launch()
agent_env = browser.env_for_agent({})
assert dock_exe == agent_env["AGENT_BROWSER_EXECUTABLE_PATH"] == str(exe)
assert dock_profile == agent_env["AGENT_BROWSER_PROFILE"] == str(tmp_path / "bot-desktop" / "browser-profile")
def test_user_pinned_profile_wins(tmp_path, monkeypatch):
monkeypatch.setenv("AGENT_BROWSER_PROFILE", str(tmp_path / "mine"))
monkeypatch.setattr(runtime, "state_dir", lambda: tmp_path / "bot-desktop")
assert browser.profile_dir() == tmp_path / "mine"
def test_pinned_profile_honours_tilde_and_resolves_relative_paths_against_hermes_home(tmp_path, monkeypatch):
"""Regression for #110029: the docs say setting AGENT_BROWSER_PROFILE pins your own user-data-dir, but only
an absolute value was honoured — `~/pin` and `pin` silently fell back to the default and the human's dock
browser and the agent's browser could end up on different jars. A relative path is anchored where the rest
of this profile's screen state lives (its HERMES_HOME), so two profiles never share one 'pin'."""
home = tmp_path / "hermes-home"
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(runtime, "get_hermes_home", lambda: home)
monkeypatch.setattr(Path, "home", lambda: tmp_path / "user")
monkeypatch.setenv("HOME", str(tmp_path / "user"))
monkeypatch.setenv("AGENT_BROWSER_PROFILE", "~/pin")
assert browser.profile_dir() == tmp_path / "user" / "pin"
monkeypatch.setenv("AGENT_BROWSER_PROFILE", "pin")
assert browser.profile_dir() == home / "pin"
assert browser.env_for_agent({})["AGENT_BROWSER_PROFILE"] == str(home / "pin"), "agent-browser gets the resolved path"
def test_dock_browser_advertises_a_devtools_port():
"""A human-started instance must be attachable, or the agent can never drive it afterwards."""
assert "--remote-debugging-port=" in browser.dock_argv("/opt/chrome", "/p/dir")[2]
def test_dock_browser_caps_its_disk_cache():
"""The persistent profile lives on the gateway's disk (6 GB on a hosted instance); the HTTP cache
must not be allowed to grow without bound there."""
argv = browser.dock_argv("/opt/chrome", "/p/dir")
cap = next(a for a in argv if a.startswith("--disk-cache-size="))
assert 0 < int(cap.split("=", 1)[1]) <= 512 * 1024 * 1024
def _fake_running_instance(user_data_dir, pid: int, port: int) -> None:
(user_data_dir / "DevToolsActivePort").write_text(f"{port}\n/devtools/browser/abc\n", encoding="utf-8")
os.symlink(f"host-{pid}", user_data_dir / "SingletonLock")
def test_running_instance_port_requires_live_pid_and_open_port(tmp_path):
listener = socket.socket()
listener.bind(("127.0.0.1", 0))
listener.listen(1)
port = listener.getsockname()[1]
try:
_fake_running_instance(tmp_path, os.getpid(), port)
assert browser.running_instance_cdp_port(str(tmp_path)) == port
# Both files outlive a closed Chromium: a dead pid must not be trusted.
os.unlink(tmp_path / "SingletonLock")
os.symlink("host-2147483000", tmp_path / "SingletonLock")
assert browser.running_instance_cdp_port(str(tmp_path)) is None
finally:
listener.close()
# Live pid, port no longer accepting: still not attachable.
os.unlink(tmp_path / "SingletonLock")
os.symlink(f"host-{os.getpid()}", tmp_path / "SingletonLock")
assert browser.running_instance_cdp_port(str(tmp_path)) is None
assert browser.running_instance_cdp_port(str(tmp_path / "missing")) is None
def test_agent_attaches_to_human_started_browser(monkeypatch):
"""With a live dock instance on the shared profile the local argv carries ``--cdp <port>``; without one
it stays a plain ``--session`` launch."""
from tools import browser_tool_session as session
monkeypatch.setattr(runtime, "published_env", lambda: {"DISPLAY": ":37"})
monkeypatch.setattr(session._cloud, "_get_browser_engine", lambda: "auto")
monkeypatch.setattr(session._cloud, "_is_headed_mode", lambda: False)
monkeypatch.setattr(session, "_agent_browser_argv", lambda cmd: [cmd])
argvs: list = []
def spawn(task_id, session_info, cmd_parts, *rest):
argvs.append(cmd_parts)
return {"success": True}
monkeypatch.setattr(session, "_spawn_and_collect", spawn)
monkeypatch.setattr(session._lp, "_lightpanda_fallback_reason", lambda *a: None)
info = {"session_name": "h_abc", "cdp_url": None, "features": {"local": True}}
monkeypatch.setattr(browser, "running_instance_cdp_port", lambda d, **kw: 41234)
session._dispatch_browser_command("t", info, "agent-browser", "open", ["https://x"], 10, None)
assert argvs[-1][:5] == ["agent-browser", "--session", "h_abc", "--cdp", "41234"]
monkeypatch.setattr(browser, "running_instance_cdp_port", lambda d, **kw: None)
session._dispatch_browser_command("t", info, "agent-browser", "open", ["https://x"], 10, None)
assert "--cdp" not in argvs[-1] and argvs[-1][:3] == ["agent-browser", "--session", "h_abc"]
def _install_browsers(tmp_path, monkeypatch, *, playwright: bool, system: bool):
"""A Playwright build under a private PLAYWRIGHT_BROWSERS_PATH and/or a system chromium on PATH."""
monkeypatch.delenv("AGENT_BROWSER_EXECUTABLE_PATH", raising=False)
roots = tmp_path / "pw"
roots.mkdir(parents=True)
monkeypatch.setenv("PLAYWRIGHT_BROWSERS_PATH", str(roots))
pw_exe = roots / "chromium-1200" / "chrome-linux" / "chrome"
if playwright:
pw_exe.parent.mkdir(parents=True)
pw_exe.write_text("#!/bin/sh\n", encoding="utf-8")
pw_exe.chmod(0o755)
monkeypatch.setattr(
"hermes_cli.browser_runtime.chromium_executable",
lambda *, allow_override=True: str(pw_exe) if playwright else None,
)
sys_exe = tmp_path / "bin" / "chromium"
if system:
sys_exe.parent.mkdir(parents=True)
sys_exe.write_text("#!/bin/sh\n", encoding="utf-8")
sys_exe.chmod(0o755)
monkeypatch.setattr("shutil.which", lambda name, *a, **k: str(sys_exe) if system and name == "chromium" else None)
return str(pw_exe), str(sys_exe)
def test_unprivileged_user_under_apparmor_userns_restriction_gets_the_system_browser(tmp_path, monkeypatch):
"""Playwright's bundled Chromium has no setuid chrome_sandbox; with
kernel.apparmor_restrict_unprivileged_userns=1 it dies 'FATAL: No usable sandbox!' for a non-root user,
so the dock icon is dead. A distro chromium (which ships the sandbox helper) must win there — and
the Playwright build stays the answer when it is the only one, started with exactly the flags
agent-browser starts it with on that host (one sandbox policy for the human's and the bot's browser)."""
pw_exe, sys_exe = _install_browsers(tmp_path, monkeypatch, playwright=True, system=True)
monkeypatch.setattr(browser.os, "geteuid", lambda: 1000)
monkeypatch.setattr(browser, "_userns_restricted", lambda: True)
assert browser.executable() == sys_exe
monkeypatch.setattr(browser, "_userns_restricted", lambda: False)
assert browser.executable() == pw_exe # unrestricted host: Playwright's build as before
_install_browsers(tmp_path / "only-pw", monkeypatch, playwright=True, system=False)
monkeypatch.setattr(browser, "_userns_restricted", lambda: True)
exe = browser.executable()
assert exe and exe.endswith("chrome-linux/chrome")
from tools import browser_tool_session as session
agent_env: dict = {}
session._apply_chromium_sandbox_args(agent_env)
agent_flags = set(agent_env.get("AGENT_BROWSER_ARGS", "").split(",")) - {""}
assert agent_flags <= set(browser.dock_argv(exe, "/p/dir"))
assert ("--no-sandbox" in browser.dock_argv(exe, "/p/dir")) == ("--no-sandbox" in agent_flags)
def test_root_dock_browser_starts_with_the_same_sandbox_args_as_the_agents_browser(monkeypatch):
"""Chromium refuses to start as root without --no-sandbox; agent-browser gets that flag from one
policy, and the dock icon (same binary, same profile) must get the very same flags or the human's
click dies while the agent's launch works."""
from tools import browser_tool_session as session
monkeypatch.setattr(session.os, "geteuid", lambda: 0)
monkeypatch.setattr(browser.os, "geteuid", lambda: 0)
agent_env: dict = {}
session._apply_chromium_sandbox_args(agent_env)
agent_flags = set(agent_env["AGENT_BROWSER_ARGS"].split(","))
assert agent_flags, "root must inject sandbox flags for agent-browser"
assert agent_flags <= set(browser.dock_argv("/opt/chrome", "/p/dir"))
# Same policy for the NON-root container case (the official image runs the gateway as uid 10000 in
# Docker): agent-browser bypasses the sandbox there, and the dock icon must too or it dies on click.
monkeypatch.setattr(session.os, "geteuid", lambda: 10000)
monkeypatch.setattr(browser.os, "geteuid", lambda: 10000)
monkeypatch.setattr(session._install, "_running_in_docker", lambda: True)
docker_env: dict = {}
session._apply_chromium_sandbox_args(docker_env)
assert set(docker_env["AGENT_BROWSER_ARGS"].split(",")) <= set(browser.dock_argv("/opt/chrome", "/p/dir"))
monkeypatch.setattr(session._install, "_running_in_docker", lambda: False)
monkeypatch.setattr(session, "apparmor_restricts_unprivileged_userns", lambda: False)
assert "--no-sandbox" not in browser.dock_argv("/opt/chrome", "/p/dir") # seated non-root host: sandboxed
def test_status_reports_the_headed_browser_or_its_absence(monkeypatch):
"""The official image ships only chromium_headless_shell: executable() is None and the dock silently
has no Browser icon. Status must say so instead of leaving the pane to guess. The subject is the
browser field, not host policy: status() gates it on is_supported_host(), which is pinned True so the
test means the same thing on every CI lane."""
monkeypatch.setattr(runtime, "is_supported_host", lambda: True)
monkeypatch.setattr(runtime, "_launcher_pid", lambda: None)
monkeypatch.setattr(runtime, "published_env", lambda: {})
monkeypatch.setattr(runtime, "geometry", lambda: "1440x900")
monkeypatch.setattr(browser, "executable", lambda: None)
assert runtime.status().as_dict()["browser"] is None
monkeypatch.setattr(browser, "executable", lambda: "/usr/bin/chromium")
assert runtime.status().browser == "/usr/bin/chromium"
def test_dock_exec_line_survives_spaces_in_the_executable_and_profile_paths():
"""The launcher used to split the shell line on the first space to find the executable, so a
Chromium under '/opt/Google Chrome/' or a profile under a spaced HERMES_HOME broke the dock icon.
Exec= follows the Desktop Entry spec: each argument double-quoted, with the reserved characters
backslash-escaped inside the quotes."""
exe = "/opt/Google Chrome/chrome"
profile = '/home/a b/.hermes/browser "x"/profile'
line = browser.dock_exec_line(exe, profile)
assert line.startswith('Exec="/opt/Google Chrome/chrome" ')
assert r'"--user-data-dir=/home/a b/.hermes/browser \\"x\\"/profile"' in line # spec: \" quoted, then \ string-escaped
assert "--remote-debugging-port=0" in line
def test_headless_shell_override_is_not_a_headed_browser(tmp_path, monkeypatch):
"""The official Docker image ships only Playwright's chrome-headless-shell and its boot hook exports it
as AGENT_BROWSER_EXECUTABLE_PATH. That binary cannot open a window: taken at face value the dock's
Browser icon would point at it and status would claim a headed browser exists. Live in the image:
status.browser named the headless shell while the dock had no working Browser."""
shell = tmp_path / "shell" / "chromium_headless_shell-1243" / "chrome-headless-shell-linux64" / "chrome-headless-shell"
shell.parent.mkdir(parents=True)
shell.write_text("#!/bin/sh\n", encoding="utf-8")
shell.chmod(0o755)
_install_browsers(tmp_path, monkeypatch, playwright=False, system=False)
monkeypatch.setenv("AGENT_BROWSER_EXECUTABLE_PATH", str(shell))
assert browser.executable() is None
_, sys_exe = _install_browsers(tmp_path / "with-sys", monkeypatch, playwright=False, system=True)
monkeypatch.setenv("AGENT_BROWSER_EXECUTABLE_PATH", str(shell))
assert browser.executable() == sys_exe # a real headed browser elsewhere still wins over the override
def test_headless_override_does_not_hide_pm_headed_browser(tmp_path, monkeypatch):
import pm
from hermes_cli.browser_runtime import chromium_executable
headless = tmp_path / "chrome-headless-shell"
headed = tmp_path / "chrome"
for exe in (headless, headed):
exe.write_text("#!/bin/sh\n", encoding="utf-8")
exe.chmod(0o755)
monkeypatch.setenv("AGENT_BROWSER_EXECUTABLE_PATH", str(headless))
monkeypatch.setattr(pm, "installed_package", lambda name: SimpleNamespace(binary=headed))
monkeypatch.setattr(browser, "_system_executable", lambda: None)
monkeypatch.setattr(browser, "_userns_restricted", lambda: False)
assert chromium_executable() == str(headless) # ordinary headless browsing keeps its override
dock = browser.dock_launch()
assert dock is not None and dock[0] == str(headed)
assert browser.env_for_agent({})["AGENT_BROWSER_EXECUTABLE_PATH"] == str(headed)
@pytest.mark.parametrize("engine, headed, starts", [("chrome", True, 1), ("chrome", False, 0), ("lightpanda", True, 0)])
def test_headed_chromium_spawn_asks_the_screen_to_start_but_the_env_builder_never_does(tmp_path, monkeypatch, engine, headed, starts):
"""Regression for #110050 at the right boundary: a real browser command that forks a headed Chromium daemon
starts the profile's screen (bot_desktop.auto_start) like computer_use dispatch does; `_build_browser_env()`
itself stays pure — it also serves the npx cache warmer, the Chromium auto-installer and the Lightpanda
engine, none of which may block on Xvnc+Xfce coming up."""
from tools import browser_tool as bt
from tools import browser_tool_cloud as cloud
from tools import browser_tool_session as session
calls: list = []
monkeypatch.setattr(runtime, "ensure_started_for_tool", lambda: calls.append(1))
monkeypatch.setattr(runtime, "published_env", lambda: {})
monkeypatch.setattr(cloud, "_is_headed_mode", lambda: headed)
bt._build_browser_env()
assert calls == []
class _Done:
returncode = 0
def wait(self, timeout=None): return 0
def _fake_popen(argv, env, socket_dir, tag, stdin_payload=None):
for slot in ("stdout", "stderr"):
Path(socket_dir, f"_{slot}_{tag}").write_text("{}" if slot == "stdout" else "", encoding="utf-8")
return _Done()
monkeypatch.setattr(session, "_popen_agent_browser", _fake_popen)
monkeypatch.setattr(session, "_prepare_session_socket_dir", lambda name: str(tmp_path))
session._spawn_and_collect("t", {"session_name": "h_x"}, ["agent-browser"], "open", engine, 5)
assert len(calls) == starts
def test_janitor_keeps_the_shared_browser_alive_while_a_human_holds_the_lease(monkeypatch):
"""#110064: the agent goes idle BECAUSE the human took over to log in; the janitor must count the human's
lease as activity for the shared local browser, and reap it again once the lease is handed back."""
from tools import browser_tool_lifecycle as lifecycle, browser_tool_session as session
from tools.bot_desktop import lease
monkeypatch.setattr(runtime, "published_env", lambda: {"DISPLAY": ":37"})
holder = {"human": True}
monkeypatch.setattr(lease, "human_holds", lambda *a, **k: holder["human"])
reaped: list = []
monkeypatch.setattr(lifecycle, "cleanup_browser", lambda task_id: reaped.append(task_id))
monkeypatch.setattr(lifecycle._bt, "BROWSER_SESSION_INACTIVITY_TIMEOUT", 1)
monkeypatch.setattr(lifecycle._bt, "_active_sessions", {
"bot": {"session_name": "h_bot", "features": {"local": True}},
"cloud": {"session_name": "c_1", "bb_session_id": "bb", "features": {}},
})
monkeypatch.setattr(lifecycle._bt, "_session_last_activity", {"bot": 0.0, "cloud": 0.0})
monkeypatch.setattr(lifecycle._bt, "_session_owner_homes", {})
lifecycle._cleanup_inactive_browser_sessions()
assert reaped == ["cloud"], "only the browser the human is not typing into may be reaped"
assert lifecycle._bt._session_last_activity["bot"] > 0, "the human's lease refreshed the bot browser's activity"
holder["human"] = False
lifecycle._bt._session_last_activity["bot"] = 0.0
lifecycle._cleanup_inactive_browser_sessions()
assert reaped == ["cloud", "bot"]
assert session.human_holds_shared_browser({"features": {"local": True}}) is False
def test_daemon_idle_timer_defers_to_the_janitor_only_for_the_shared_headed_browser(monkeypatch):
"""The agent-browser daemon cannot see the lease, so on the Bot Desktop its self-termination timer steps
back and the lease-aware janitor owns the browser's lifetime; headless browsing keeps the mirror."""
from tools import browser_tool_session as session
monkeypatch.setattr(session._bt, "BROWSER_SESSION_INACTIVITY_TIMEOUT", 120)
monkeypatch.setattr(runtime, "published_env", lambda: {"DISPLAY": ":37"})
monkeypatch.setattr(session._cloud, "_is_headed_mode", lambda: True)
assert session._daemon_idle_timeout_seconds() > 120
monkeypatch.setattr(session._cloud, "_is_headed_mode", lambda: False)
assert session._daemon_idle_timeout_seconds() == 120
monkeypatch.setattr(session._cloud, "_is_headed_mode", lambda: True)
monkeypatch.setattr(runtime, "published_env", lambda: {})
assert session._daemon_idle_timeout_seconds() == 120
def test_a_headless_shell_pin_is_replaced_while_a_screen_is_up(tmp_path, monkeypatch):
"""The boot hook exports a chrome-headless-shell path; leaving it would put the agent and the dock on
two binaries over one --user-data-dir, where the singleton swallows the dock's launch."""
shell = tmp_path / "chrome-headless-shell"
shell.write_text("#!/bin/sh\n", encoding="utf-8")
shell.chmod(0o755)
headed = tmp_path / "chrome"
headed.write_text("#!/bin/sh\n", encoding="utf-8")
headed.chmod(0o755)
monkeypatch.setattr(runtime, "state_dir", lambda: tmp_path / "bot-desktop")
monkeypatch.setattr(browser, "_managed_executable", lambda: str(headed))
monkeypatch.delenv("AGENT_BROWSER_PROFILE", raising=False)
# Unpinned, the ubuntu runner (non-root, userns-restricted) flips executable() to
# its own /usr/bin/google-chrome; host policy is not the subject here.
monkeypatch.setattr(browser, "_userns_restricted", lambda: False)
agent_env = browser.env_for_agent({"AGENT_BROWSER_EXECUTABLE_PATH": str(shell)})
dock_exe, _ = browser.dock_launch()
assert agent_env["AGENT_BROWSER_EXECUTABLE_PATH"] == dock_exe == str(headed), \
"the agent and the dock must share one binary once a screen is up"
def test_a_real_user_pin_is_still_honoured(tmp_path, monkeypatch):
"""Only a headless-shell pin is overridden; a human's own headed browser stays put."""
mine = tmp_path / "my-chrome"
mine.write_text("#!/bin/sh\n", encoding="utf-8")
mine.chmod(0o755)
other = tmp_path / "chrome"
other.write_text("#!/bin/sh\n", encoding="utf-8")
other.chmod(0o755)
monkeypatch.setattr(runtime, "state_dir", lambda: tmp_path / "bot-desktop")
monkeypatch.setattr(browser, "_managed_executable", lambda: str(other))
env = browser.env_for_agent({"AGENT_BROWSER_EXECUTABLE_PATH": str(mine)})
assert env["AGENT_BROWSER_EXECUTABLE_PATH"] == str(mine)