"""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 ``; 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)