Files
hermes-agent/tests/tools/test_browser_real_profile.py
2026-10-10 15:20:14 +02:00

1139 lines
56 KiB
Python

"""Tests for real-profile browsing: resolvers, snapshot, launch routing, consent.
The consent path never drives the live default profile: it snapshots into
``~/.hermes/browser-profile/<browser>/`` and launches the user's real binary
on the copy with a devtools port (see hermes_cli.browser_connect). These tests
exercise the real functions with real file I/O wherever possible — the mocks
are limited to OS detection and process launch.
"""
import json
import os
from unittest.mock import Mock, patch
import pytest
from tools import browser_tool_cdp as bt_cdp
from tools import browser_tool_cloud as bt_cloud
from tools import browser_tool_lightpanda_fallback as bt_lightpanda_fallback
from tools import browser_tool_real_profile as bt_real_profile
from tools import browser_tool_session as bt_session
from tools import browser_tool_install as bt_install
def _auth_db(path, value=None):
"""Store/read a marker in a real auth DB so snapshot fixtures exercise SQLite."""
import sqlite3
from contextlib import closing
with closing(sqlite3.connect(path)) as conn, conn:
if value is not None:
conn.execute("create table if not exists marker(value)")
conn.execute("delete from marker")
conn.execute("insert into marker values(?)", (value,))
return conn.execute("select value from marker").fetchone()[0]
class TestRealProfileResolvers:
def test_data_dir_unknown_browser_is_none(self):
import hermes_cli.browser_connect as bc
assert bc.real_profile_data_dir("firefox", "Windows") is None
class TestSnapshotRealProfile:
"""Real file I/O: the snapshot copier against a synthetic profile tree."""
def _make_profile(self, root):
"""Build a minimal real-looking Chromium user-data-dir."""
(root / "Default" / "Network").mkdir(parents=True)
(root / "Default" / "Cache" / "Cache_Data").mkdir(parents=True)
(root / "Code Cache" / "js").mkdir(parents=True)
(root / "Crashpad").mkdir()
(root / "Local State").write_text('{"os_crypt": {}}')
_auth_db((root / "Default" / "Cookies"), "sqlite-cookies")
_auth_db((root / "Default" / "Network" / "Cookies"), "sqlite-net-cookies")
_auth_db((root / "Default" / "Login Data"), "sqlite-logins")
(root / "Default" / "Preferences").write_text("{}")
(root / "Default" / "Cache" / "Cache_Data" / "big").write_text("x" * 1000)
(root / "Code Cache" / "js" / "blob").write_text("y" * 1000)
(root / "Crashpad" / "dump").write_text("z")
# Live-instance leftovers that must never reach the copy
os.symlink("dead-target-1", root / "SingletonLock")
return root
def test_fresh_snapshot_copies_auth_and_skips_caches(self, tmp_path, monkeypatch):
import hermes_cli.browser_connect as bc
src = self._make_profile(tmp_path / "real")
home = tmp_path / "hermes-home"
monkeypatch.setattr(bc, "get_hermes_home", lambda: home)
dst, err = bc.snapshot_real_profile("chrome", src=str(src))
assert err is None
assert dst == str(home / "browser-profile" / "chrome")
# Auth files present
assert _auth_db(home / "browser-profile" / "chrome" / "Default" / "Cookies") == "sqlite-cookies"
assert (home / "browser-profile" / "chrome" / "Default" / "Network" / "Cookies").exists()
assert (home / "browser-profile" / "chrome" / "Default" / "Login Data").exists()
assert (home / "browser-profile" / "chrome" / "Local State").exists()
# Caches, crash dirs, singleton leftovers excluded
assert not (home / "browser-profile" / "chrome" / "Default" / "Cache").exists()
assert not (home / "browser-profile" / "chrome" / "Code Cache").exists()
assert not (home / "browser-profile" / "chrome" / "Crashpad").exists()
assert not (home / "browser-profile" / "chrome" / "SingletonLock").exists()
def test_existing_snapshot_refreshes_auth_files_only(self, tmp_path, monkeypatch):
import hermes_cli.browser_connect as bc
src = self._make_profile(tmp_path / "real")
home = tmp_path / "hermes-home"
monkeypatch.setattr(bc, "get_hermes_home", lambda: home)
dst, err = bc.snapshot_real_profile("chrome", src=str(src))
assert err is None
# Simulate: user logs into a new site in their own browser, and the
# copy has drifted state that must survive (History not in refresh set).
_auth_db((src / "Default" / "Cookies"), "sqlite-cookies-v2")
copy_history = home / "browser-profile" / "chrome" / "Default" / "History"
copy_history.write_text("agent-session-history")
dst2, err2 = bc.snapshot_real_profile("chrome", src=str(src))
assert err2 is None and dst2 == dst
assert _auth_db(home / "browser-profile" / "chrome" / "Default" / "Cookies") == "sqlite-cookies-v2"
assert copy_history.read_text() == "agent-session-history"
def test_missing_source_fails_closed(self, tmp_path, monkeypatch):
import hermes_cli.browser_connect as bc
monkeypatch.setattr(bc, "get_hermes_home", lambda: tmp_path / "hh")
dst, err = bc.snapshot_real_profile("chrome", src=str(tmp_path / "nope"))
assert dst is None
assert err
def test_snapshot_files_are_owner_only(self, tmp_path, monkeypatch):
"""Every copied file must be 0600 and every dir 0700 (#96729).
copy2 preserves Chrome's 0644 source modes and sqlite-backup files
land umask-wide, so without explicit reconciliation the user's
session-cookie copies are group/world-readable.
"""
import stat
import hermes_cli.browser_connect as bc
src = self._make_profile(tmp_path / "real")
home = tmp_path / "hermes-home"
monkeypatch.setattr(bc, "get_hermes_home", lambda: home)
old_umask = os.umask(0o022) # the common default that produced 0644
try:
dst, err = bc.snapshot_real_profile("chrome", src=str(src))
finally:
os.umask(old_umask)
assert err is None and dst
offenders = []
for root, dirs, files in os.walk(dst):
for d in dirs:
mode = stat.S_IMODE(os.stat(os.path.join(root, d)).st_mode)
if mode & 0o077:
offenders.append((os.path.join(root, d), oct(mode)))
for f in files:
mode = stat.S_IMODE(os.stat(os.path.join(root, f)).st_mode)
if mode & 0o077:
offenders.append((os.path.join(root, f), oct(mode)))
assert not offenders, f"group/world-accessible snapshot entries: {offenders}"
def test_existing_lax_snapshot_heals_on_refresh(self, tmp_path, monkeypatch):
"""A snapshot left 0644 by an older build tightens on the next pass."""
import stat
import hermes_cli.browser_connect as bc
src = self._make_profile(tmp_path / "real")
home = tmp_path / "hermes-home"
monkeypatch.setattr(bc, "get_hermes_home", lambda: home)
dst, err = bc.snapshot_real_profile("chrome", src=str(src))
assert err is None and dst
cookies = os.path.join(dst, "Default", "Cookies")
os.chmod(cookies, 0o644) # simulate the pre-fix on-disk state
dst2, err2 = bc.snapshot_real_profile("chrome", src=str(src))
assert err2 is None and dst2 == dst
assert stat.S_IMODE(os.stat(cookies).st_mode) == 0o600
class TestRealProfileCdpLaunch:
"""The agent-browser-based launcher in browser_tool_real_profile._real_profile_cdp."""
def _reset(self):
import tools.browser_tool as bt
bt._real_profile_cdp_cache.clear()
def test_consent_off_is_noop(self):
self._reset()
with patch.object(bt_cloud, "_use_real_profile", return_value=False):
cdp, err = bt_real_profile._real_profile_cdp()
assert cdp is None and err is None
def test_non_chromium_default_fails_closed(self):
self._reset()
with patch.object(bt_cloud, "_use_real_profile", return_value=True), \
patch("hermes_cli.browser_connect.detect_default_chromium", return_value=None):
cdp, err = bt_real_profile._real_profile_cdp()
assert cdp is None
assert err
def test_snapshot_failure_fails_closed(self):
self._reset()
with patch.object(bt_cloud, "_use_real_profile", return_value=True), \
patch("hermes_cli.browser_connect.detect_default_chromium", return_value="chrome"), \
patch("hermes_cli.browser_connect.snapshot_real_profile", return_value=(None, "boom")):
cdp, err = bt_real_profile._real_profile_cdp()
assert cdp is None
assert err and "boom" in err
def test_stale_resolver_holder_fails_fast(self, monkeypatch):
"""A timed-out worker holding the launch lock must not wedge later calls."""
import threading
import tools.browser_tool as bt
self._reset()
monkeypatch.setattr(bt, "_REAL_PROFILE_CDP_LOCK_TIMEOUT_S", 0.05, raising=False)
result = {}
started = threading.Event()
def resolve():
started.set()
result.setdefault("value", bt_real_profile._real_profile_cdp())
with patch.object(bt_cloud, "_use_real_profile", return_value=True), \
patch.object(bt_lightpanda_fallback, "_using_lightpanda_engine", return_value=False), \
patch("hermes_cli.browser_connect.detect_default_chromium", return_value=None):
bt._real_profile_cdp_lock.acquire()
worker = threading.Thread(target=resolve, daemon=True)
try:
worker.start()
assert started.wait(timeout=2.0), "resolver worker was not scheduled"
worker.join(timeout=2.0)
stalled = worker.is_alive()
finally:
bt._real_profile_cdp_lock.release()
worker.join(timeout=2.0)
assert not stalled, "real-profile resolver waited indefinitely on a stale holder"
cdp, err = result["value"]
assert cdp is None
assert err and "already being prepared" in err
def test_launch_is_headless_and_agent_browser_attaches(self, tmp_path):
"""Real-profile browsing runs headless (no focus-stealing window).
Two argv paths are checked:
1. The REAL Chrome binary we launch ourselves (via Popen) MUST pass
``--headless=new``. Real-profile browsing is a background
capability — a visible window that grabs focus every turn defeats
the point. NEW headless shares the profile's normal cookie store
(unlike legacy ``--headless``), and cookie decryption is unaffected
by headless — the drop we avoid comes from ``--use-mock-keychain``,
not from headless. We launch without mock-keychain switches, so the
copied auth/login state still loads.
2. agent-browser ATTACHES to that running Chrome via ``--cdp``, so its
argv must contain ``--cdp`` and must NOT contain launch-mode
switches (``--headless`` / ``--profile``).
"""
import tools.browser_tool as bt
self._reset()
captured = {}
def fake_agent_browser_spawn(argv, env, socket_dir, tag):
captured["argv"] = argv
captured["env"] = env
return Mock(wait=Mock(return_value=0), returncode=0)
class FakeChrome:
def poll(self):
return None
def fake_popen(argv, **kw):
captured["chrome_argv"] = argv
(tmp_path / "DevToolsActivePort").write_text("41000\n/devtools/browser/x\n")
return FakeChrome()
with patch.object(bt_cloud, "_use_real_profile", return_value=True), \
patch("hermes_cli.browser_connect.detect_default_chromium", return_value="chrome"), \
patch("hermes_cli.browser_connect.snapshot_real_profile", return_value=(str(tmp_path), None)), \
patch("hermes_cli.browser_connect.chromium_executable", return_value="/usr/bin/chrome"), \
patch.object(bt.subprocess, "Popen", side_effect=fake_popen), \
patch.object(bt_real_profile, "_agent_browser_get_cdp",
side_effect=[None, "http://127.0.0.1:41000"]), \
patch.object(bt_install, "_find_agent_browser", return_value="/usr/bin/agent-browser"), \
patch.object(bt_session, "_popen_agent_browser", side_effect=fake_agent_browser_spawn), \
patch.object(bt, "_socket_safe_tmpdir", return_value=str(tmp_path)), \
patch.object(bt_cloud, "_is_headed_mode", return_value=False):
bt_real_profile._real_profile_cdp()
# The chrome launch itself is headless (no window, no focus steal).
assert "--headless=new" in captured["chrome_argv"]
# agent-browser attaches, it does not launch.
assert "--headless" not in captured["argv"]
assert "--profile" not in captured["argv"]
assert "--cdp" in captured["argv"]
# #100855: the attach daemon lives in a reaper-visible socket dir claimed by this
# process, and never self-terminates (Chrome is ours, not the daemon's).
socket_dir = captured["env"]["AGENT_BROWSER_SOCKET_DIR"]
assert socket_dir == str(tmp_path / f"agent-browser-{bt._REAL_PROFILE_SESSION}")
assert (tmp_path / f"agent-browser-{bt._REAL_PROFILE_SESSION}" / f"{bt._REAL_PROFILE_SESSION}.owner_pid").read_text() == str(os.getpid())
assert "AGENT_BROWSER_IDLE_TIMEOUT_MS" not in captured["env"]
self._reset()
def test_reuses_only_session_on_our_copy_dir(self, tmp_path):
"""A live session on a DIFFERENT dir (stale/throwaway) is closed, not reused."""
import tools.browser_tool as bt
self._reset()
proc = Mock(return_value=None, returncode=0, stdout="", stderr="")
closed = {"n": 0}
class FakeChrome:
def poll(self):
return None
def fake_popen(argv, **kw):
(tmp_path / "DevToolsActivePort").write_text("41000\n/devtools/browser/x\n")
return FakeChrome()
with patch.object(bt_cloud, "_use_real_profile", return_value=True), \
patch("hermes_cli.browser_connect.detect_default_chromium", return_value="chrome"), \
patch("hermes_cli.browser_connect.snapshot_real_profile", return_value=(str(tmp_path), None)), \
patch("hermes_cli.browser_connect.chromium_executable", return_value="/usr/bin/chrome"), \
patch.object(bt.subprocess, "Popen", side_effect=fake_popen), \
patch.object(bt_real_profile, "_agent_browser_get_cdp",
side_effect=["http://127.0.0.1:5000", "http://127.0.0.1:41000"]), \
patch.object(bt_real_profile, "_cdp_http_ready", return_value=True), \
patch.object(bt_real_profile, "_cdp_on_data_dir", return_value=False), \
patch.object(bt_real_profile, "_agent_browser_close_session",
side_effect=lambda s: closed.__setitem__("n", closed["n"] + 1)), \
patch.object(bt_install, "_find_agent_browser", return_value="/usr/bin/agent-browser"), \
patch.object(bt_real_profile, "_capture_agent_browser_cli", return_value=proc), \
patch.object(bt_cloud, "_is_headed_mode", return_value=False):
cdp, _err = bt_real_profile._real_profile_cdp()
assert closed["n"] == 1 # stale wrong-dir session was closed
assert cdp == "http://127.0.0.1:41000"
self._reset()
@pytest.mark.parametrize("live_browser_id", ["/devtools/browser/x", "/devtools/browser/other"])
def test_reattaches_to_surviving_chrome_instead_of_overlaying_its_profile(self, tmp_path, live_browser_id):
"""The attach daemon of a crashed owner gets reaped, but its Chrome (Hermes-launched,
own session) survives holding the copy dir: re-attach, never re-run the snapshot.
A DevToolsActivePort left by a crash whose port was recycled by ANOTHER CDP server
(browser id mismatch) must not be attached to; the normal launch path runs."""
self._reset()
(tmp_path / "DevToolsActivePort").write_text("41000\n/devtools/browser/x\n")
version = Mock()
version.json.return_value = {"webSocketDebuggerUrl": f"ws://127.0.0.1:41000{live_browser_id}"}
with patch.object(bt_cloud, "_use_real_profile", return_value=True), \
patch("hermes_cli.browser_connect.detect_default_chromium", return_value="chrome"), \
patch("hermes_cli.browser_connect.real_profile_copy_dir", return_value=str(tmp_path)), \
patch("hermes_cli.browser_connect.snapshot_real_profile", return_value=(None, "boom")) as snapshot, \
patch("requests.get", return_value=version), \
patch.object(bt_real_profile, "_agent_browser_get_cdp", return_value=None), \
patch.object(bt_real_profile, "_attach_agent_browser_to_real_profile",
return_value=("http://127.0.0.1:41000", None)) as attach:
cdp, err = bt_real_profile._real_profile_cdp()
if live_browser_id == "/devtools/browser/x":
assert (cdp, err) == ("http://127.0.0.1:41000", None)
attach.assert_called_once_with(41000, str(tmp_path))
snapshot.assert_not_called()
else:
attach.assert_not_called()
snapshot.assert_called_once()
self._reset()
def test_cdp_on_data_dir_matches_devtoolsactiveport(self, tmp_path):
(tmp_path / "DevToolsActivePort").write_text("41000\n/devtools/browser/x\n")
assert bt_real_profile._cdp_on_data_dir("http://127.0.0.1:41000", str(tmp_path))
assert not bt_real_profile._cdp_on_data_dir("http://127.0.0.1:9999", str(tmp_path))
class TestAgentBrowserCliCapture:
"""#96731: agent-browser's resident daemon inherits the caller's stdio and
outlives the CLI, so pipe-based capture never sees EOF after the CLI
exits. Capture must go through temp files and wait only for the CLI."""
STUB_GRANDCHILD = (
"import subprocess, sys\n"
# A grandchild that inherits stdout and outlives the CLI — exactly
# what agent-browser's first-use daemon spawn does.
"subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(30)'])\n"
"sys.stdout.write('ws://127.0.0.1:41022/devtools/browser/stub\\n')\n"
"sys.stderr.write('daemon warm\\n')\n"
)
def test_get_cdp_returns_despite_grandchild_holding_stdio(self, tmp_path):
"""The blocking frame from the #96731 py-spy capture: get cdp-url."""
import sys
import time
stub = tmp_path / "agent_browser_daemon_stub.py"
stub.write_text(self.STUB_GRANDCHILD)
with patch.object(bt_install, "_find_agent_browser", return_value=str(stub)), \
patch.object(bt_session, "_agent_browser_argv", return_value=[sys.executable, str(stub)]), \
patch.object(bt_real_profile, "_real_profile_daemon_env", return_value=({}, str(tmp_path))):
start = time.monotonic()
cdp = bt_real_profile._agent_browser_get_cdp("hermes-real-profile")
elapsed = time.monotonic() - start
assert cdp == "http://127.0.0.1:41022"
# Pipe capture would stall here for the full 15s timeout on POSIX and
# hang past the outer tool deadline on Windows.
assert elapsed < 10, f"get cdp-url stalled {elapsed:.1f}s behind a grandchild"
@pytest.mark.platforms("linux")
def test_get_cdp_cleans_capture_files_despite_grandchild(self, tmp_path):
"""POSIX: the capture files are unlinked even while the daemon
grandchild still holds them open. On Windows the inherited handles
keep the files until the daemon exits and the unlink is best-effort
by design (_unlink_command_output_files swallows OSError), so this
assertion is a linux_only test rather than a bare sys.platform check
— the contract stays visible in both CI lanes."""
import sys
stub = tmp_path / "agent_browser_daemon_stub.py"
stub.write_text(self.STUB_GRANDCHILD)
with patch.object(bt_install, "_find_agent_browser", return_value=str(stub)), \
patch.object(bt_session, "_agent_browser_argv", return_value=[sys.executable, str(stub)]), \
patch.object(bt_real_profile, "_real_profile_daemon_env", return_value=({}, str(tmp_path))):
cdp = bt_real_profile._agent_browser_get_cdp("hermes-real-profile")
assert cdp == "http://127.0.0.1:41022"
assert not list(tmp_path.glob("_std*_rp-*"))
def test_capture_cli_surfaces_stdout_stderr_and_exit_code(self, tmp_path):
import sys
stub = tmp_path / "agent_browser_echo_stub.py"
stub.write_text(
"import sys\n"
"sys.stdout.write('out-42\\n')\n"
"sys.stderr.write('err-7\\n')\n"
"sys.exit(3)\n"
)
with patch.object(bt_real_profile, "_real_profile_daemon_env", return_value=({}, str(tmp_path))):
proc = bt_real_profile._capture_agent_browser_cli(
[sys.executable, str(stub)], timeout=15, tag="rp-test",
)
assert proc.returncode == 3
assert proc.stdout == "out-42"
assert proc.stderr == "err-7"
def test_capture_cli_timeout_kills_cli_without_stall(self, tmp_path):
import subprocess
import sys
import time
stub = tmp_path / "agent_browser_sleep_stub.py"
stub.write_text("import time; time.sleep(30)\n")
with patch.object(bt_real_profile, "_real_profile_daemon_env", return_value=({}, str(tmp_path))):
start = time.monotonic()
with pytest.raises(subprocess.TimeoutExpired):
bt_real_profile._capture_agent_browser_cli(
[sys.executable, str(stub)], timeout=2, tag="rp-timeout",
)
elapsed = time.monotonic() - start
# The CLI itself is killed at the deadline instead of draining pipes
# behind a daemon grandchild forever.
assert elapsed < 10, f"timeout path stalled {elapsed:.1f}s"
@pytest.mark.platforms("linux")
def test_capture_cli_timeout_cleans_capture_files(self, tmp_path):
"""POSIX: the capture files are unlinked after the timeout kill. On
Windows the killed CLI's handles can outlive the kill() return, so
the best-effort unlink may hit a still-open file and leave the
fixed-name pair behind (bounded: reused and truncated by the next
call with the same tag) — hence linux_only, not an inline
sys.platform guard."""
import subprocess
import sys
stub = tmp_path / "agent_browser_sleep_stub.py"
stub.write_text("import time; time.sleep(30)\n")
with patch.object(bt_real_profile, "_real_profile_daemon_env", return_value=({}, str(tmp_path))):
with pytest.raises(subprocess.TimeoutExpired):
bt_real_profile._capture_agent_browser_cli(
[sys.executable, str(stub)], timeout=2, tag="rp-timeout",
)
assert not (tmp_path / "_stdout_rp-timeout").exists()
assert not (tmp_path / "_stderr_rp-timeout").exists()
class TestConsentConfigRead:
"""Unmocked config read: _use_real_profile against a real config.yaml."""
def test_consent_default_off(self):
with patch("hermes_cli.config.read_raw_config", return_value={}):
assert bt_cloud._use_real_profile() is False
def test_consent_revocation_takes_effect_immediately(self):
"""No process-lifetime caching: consent is a per-use read."""
with patch("hermes_cli.config.read_raw_config",
return_value={"browser": {"use_real_profile": True}}):
assert bt_cloud._use_real_profile() is True
with patch("hermes_cli.config.read_raw_config",
return_value={"browser": {"use_real_profile": False}}):
assert bt_cloud._use_real_profile() is False
class TestLocalSessionRealProfile:
def test_local_session_attaches_to_real_profile_cdp(self):
with patch.object(bt_real_profile, "_real_profile_cdp",
return_value=("http://127.0.0.1:9251", None)), \
patch.object(bt_cdp, "_resolve_cdp_override", side_effect=lambda u: u):
info = bt_session._create_local_session("t1")
assert info["cdp_url"] == "http://127.0.0.1:9251"
assert info["features"]["real_profile"] is True
assert info["session_name"].startswith("rp_")
def test_local_session_fails_closed_on_error(self):
with patch.object(bt_real_profile, "_real_profile_cdp", return_value=(None, "no chromium")):
with pytest.raises(RuntimeError, match="no chromium"):
bt_session._create_local_session("t1")
def test_local_session_without_consent_is_throwaway(self):
with patch.object(bt_real_profile, "_real_profile_cdp", return_value=(None, None)):
info = bt_session._create_local_session("t1")
assert info["cdp_url"] is None
assert "real_profile" not in info["features"]
assert info["session_name"].startswith("h_")
class TestBrowserExecLocalArg:
def _env(self):
return {}
def test_local_forces_real_profile_under_cloud_backend(self):
import tools.browser_use_cli as bu
env = self._env()
with patch.object(bu, "_real_profile_consented", return_value=True), \
patch("tools.browser_tool_cdp._get_cdp_override_raw", return_value=""), \
patch("tools.browser_tool_cloud._get_cloud_provider", return_value=Mock()), \
patch("tools.browser_tool_real_profile._real_profile_cdp",
return_value=("http://127.0.0.1:9251", None)):
err = bu._resolve_real_profile_cdp(env, force_local=True)
assert err is None
assert env.get("BU_CDP_URL") == "http://127.0.0.1:9251"
def test_no_force_keeps_cloud_backend(self):
import tools.browser_use_cli as bu
env = self._env()
with patch.object(bu, "_real_profile_consented", return_value=True), \
patch("tools.browser_tool_cdp._get_cdp_override_raw", return_value=""), \
patch("tools.browser_tool_cloud._get_cloud_provider", return_value=Mock()):
err = bu._resolve_real_profile_cdp(env, force_local=False)
assert err is None
assert "BU_CDP_URL" not in env and "BU_CDP_WS" not in env
def test_local_backend_upgrades_without_force(self):
import tools.browser_use_cli as bu
env = self._env()
with patch.object(bu, "_real_profile_consented", return_value=True), \
patch.object(bu, "_read_browser_cfg", return_value={}), \
patch("tools.browser_tool_cdp._get_cdp_override_raw", return_value=""), \
patch("tools.browser_tool_cloud._get_cloud_provider", return_value=None), \
patch("tools.browser_tool_real_profile._real_profile_cdp",
return_value=("http://127.0.0.1:9251", None)):
err = bu._resolve_real_profile_cdp(env, force_local=False)
assert err is None
assert env.get("BU_CDP_URL") == "http://127.0.0.1:9251"
def test_consent_off_is_inert(self):
import tools.browser_use_cli as bu
env = self._env()
with patch.object(bu, "_real_profile_consented", return_value=False):
err = bu._resolve_real_profile_cdp(env, force_local=True)
assert err is None and env == {}
def test_launch_failure_fails_closed(self):
import tools.browser_use_cli as bu
env = self._env()
with patch.object(bu, "_real_profile_consented", return_value=True), \
patch("tools.browser_tool_cdp._get_cdp_override_raw", return_value=""), \
patch("tools.browser_tool_real_profile._real_profile_cdp",
return_value=(None, "chrome exited")):
err = bu._resolve_real_profile_cdp(env, force_local=True)
assert err == "chrome exited"
assert "BU_CDP_URL" not in env
def test_explicit_bu_env_override_wins(self):
import tools.browser_use_cli as bu
env = {"BU_CDP_WS": "ws://operator-override"}
with patch.object(bu, "_real_profile_consented", return_value=True):
err = bu._resolve_real_profile_cdp(env, force_local=True)
assert err is None
assert env["BU_CDP_WS"] == "ws://operator-override"
assert "BU_CDP_URL" not in env
def test_operator_cdp_override_wins(self):
import tools.browser_use_cli as bu
env = self._env()
with patch.object(bu, "_real_profile_consented", return_value=True), \
patch("tools.browser_tool_cdp._get_cdp_override_raw", return_value="ws://connect"):
err = bu._resolve_real_profile_cdp(env, force_local=True)
assert err is None and env == {}
class TestBrowserExecSchemaGating:
def test_local_arg_absent_without_consent(self):
import tools.browser_use_cli as bu
with patch.object(bu, "_real_profile_consented", return_value=False):
overrides = bu._dynamic_schema_overrides()
assert "parameters" not in overrides
assert "local" not in bu.BROWSER_EXEC_SCHEMA["parameters"]["properties"]
def test_local_arg_present_with_consent(self):
import tools.browser_use_cli as bu
with patch.object(bu, "_real_profile_consented", return_value=True):
overrides = bu._dynamic_schema_overrides()
props = overrides["parameters"]["properties"]
assert "local" in props
assert props["local"]["type"] == "boolean"
# Static schema must stay untouched (override is a copy).
assert "local" not in bu.BROWSER_EXEC_SCHEMA["parameters"]["properties"]
# 'local' must not be required — pure opt-in.
assert "local" not in overrides["parameters"].get("required", [])
class TestChannelIdentity:
"""#95549 invariant: pre-release channels must NOT normalize to stable.
Swallowing Beta/Dev/Canary into the stable family drives a different
profile/account — a wrong-principal bug. Detection must flag the channel
(UNSUPPORTED_CHANNEL) so the caller fails closed, never returning 'chrome'
for a Beta default.
"""
def test_linux_beta_not_normalized_to_stable(self):
import hermes_cli.browser_connect as bc
with patch.object(bc.subprocess, "run",
return_value=Mock(stdout="google-chrome-beta.desktop\n")):
assert bc._detect_default_linux() == bc.UNSUPPORTED_CHANNEL
def test_linux_stable_still_resolves(self):
import hermes_cli.browser_connect as bc
with patch.object(bc.subprocess, "run",
return_value=Mock(stdout="google-chrome.desktop\n")):
assert bc._detect_default_linux() == "chrome"
def test_linux_flatpak_beta_not_stable(self):
import hermes_cli.browser_connect as bc
with patch.object(bc.subprocess, "run",
return_value=Mock(stdout="com.google.chrome.beta.desktop\n")):
assert bc._detect_default_linux() == bc.UNSUPPORTED_CHANNEL
def test_darwin_canary_not_normalized(self):
import hermes_cli.browser_connect as bc
with patch.object(bc, "_launchservices_https_handler",
return_value="com.google.chrome.canary"):
with patch.object(bc.subprocess, "run", return_value=Mock(stdout="")):
assert bc._detect_default_darwin() == bc.UNSUPPORTED_CHANNEL
def test_windows_progid_maps(self):
import hermes_cli.browser_connect as bc
# Stable ProgIds → family; channel ProgIds are in the channel set.
assert dict(bc._WINDOWS_PROGID_MAP)["chromehtml"] == "chrome"
assert "chromebhtml" in bc._WINDOWS_CHANNEL_PROGIDS # Beta
assert "msedgebhtml" in bc._WINDOWS_CHANNEL_PROGIDS # Edge Beta
# A channel ProgId must not be a prefix hit for any stable entry.
for chan in bc._WINDOWS_CHANNEL_PROGIDS:
assert not any(chan.startswith(p) for p, _ in bc._WINDOWS_PROGID_MAP)
def test_channel_sentinel_fails_closed_in_cdp(self):
"""A channel default → _real_profile_cdp fails closed, never launches."""
import tools.browser_tool as bt
import hermes_cli.browser_connect as bc
bt._real_profile_cdp_cache.clear()
with patch.object(bt_cloud, "_use_real_profile", return_value=True), \
patch("hermes_cli.browser_connect.detect_default_chromium",
return_value=bc.UNSUPPORTED_CHANNEL), \
patch("hermes_cli.browser_connect.snapshot_real_profile") as snap:
cdp, err = bt_real_profile._real_profile_cdp()
assert cdp is None
assert err
snap.assert_not_called() # never even snapshotted a stable profile
bt._real_profile_cdp_cache.clear()
def test_data_dir_rejects_sentinel(self):
import hermes_cli.browser_connect as bc
assert bc.real_profile_data_dir(bc.UNSUPPORTED_CHANNEL, "Linux") is None
assert bc.chromium_executable(bc.UNSUPPORTED_CHANNEL, "Linux") is None
class TestSnapshotIsCredentialStore:
"""The copied Cookies/Login Data must live inside Hermes' secret lifecycle."""
def test_excluded_from_backup(self):
import hermes_cli.backup as bk
# Hyphen snapshot dirs are any-depth; Browser Use CLI underscore dir is root-scoped.
assert bk._should_exclude(
__import__("pathlib").Path("browser-profile/chrome/Default/Cookies")
)
assert bk._should_exclude(
__import__("pathlib").Path(
"browser_profiles/browser-use-default/Default/Login Data"
)
)
def test_read_guard_blocks_snapshot(self, tmp_path, monkeypatch):
import agent.file_safety as fs
home = tmp_path / ".hermes"
(home / "browser-profile" / "chrome" / "Default").mkdir(parents=True)
cookies = home / "browser-profile" / "chrome" / "Default" / "Cookies"
cookies.write_text("secret-cookie-db")
monkeypatch.setenv("HERMES_HOME", str(home))
err = fs.get_read_block_error(str(cookies))
assert err
def test_read_guard_allows_normal_file(self, tmp_path, monkeypatch):
import agent.file_safety as fs
home = tmp_path / ".hermes"
home.mkdir(parents=True)
monkeypatch.setenv("HERMES_HOME", str(home))
normal = tmp_path / "notes.txt"
normal.write_text("hello")
assert fs.get_read_block_error(str(normal)) is None
class TestReviewBugFixes:
"""Regressions for the five PR #95620 review findings."""
# ── Bug 2: launch the profile the user actually browses (last_used) ──
def _multi_profile(self, root):
"""Build a data-dir where the SIGNED-IN session lives in 'Profile 6'."""
for prof in ("Default", "Profile 6"):
(root / prof / "Network").mkdir(parents=True)
(root / "Local State").write_text(
'{"profile": {"last_used": "Profile 6"}}'
)
# Default is signed OUT (tracking cookies only); Profile 6 has the session.
_auth_db((root / "Default" / "Cookies"), "default-tracking-only")
_auth_db((root / "Profile 6" / "Cookies"), "PROFILE6-SESSION-AUTH")
_auth_db((root / "Profile 6" / "Login Data"), "profile6-logins")
(root / "Profile 6" / "Preferences").write_text("{}")
return root
def test_last_used_profile_lands_in_copy_default(self, tmp_path, monkeypatch):
import hermes_cli.browser_connect as bc
src = self._multi_profile(tmp_path / "real")
home = tmp_path / "hh"
monkeypatch.setattr(bc, "get_hermes_home", lambda: home)
_dst, err = bc.snapshot_real_profile("chrome", src=str(src))
assert err is None
# The copy's Default must carry PROFILE 6's session, not Default's.
got = _auth_db(home / "browser-profile" / "chrome" / "Default" / "Cookies")
assert got == "PROFILE6-SESSION-AUTH"
assert _auth_db(home / "browser-profile" / "chrome" / "Default" / "Login Data") == "profile6-logins"
def test_last_used_falls_back_to_default(self, tmp_path):
import hermes_cli.browser_connect as bc
root = tmp_path / "d"
(root / "Default").mkdir(parents=True)
(root / "Local State").write_text('{"profile": {"last_used": "Profile 9"}}') # not present
assert bc._last_used_profile(str(root)) == "Default"
def test_refresh_remirrors_last_used(self, tmp_path, monkeypatch):
import hermes_cli.browser_connect as bc
src = self._multi_profile(tmp_path / "real")
home = tmp_path / "hh"
monkeypatch.setattr(bc, "get_hermes_home", lambda: home)
bc.snapshot_real_profile("chrome", src=str(src)) # fresh
_auth_db((src / "Profile 6" / "Cookies"), "PROFILE6-REFRESHED")
_dst, err = bc.snapshot_real_profile("chrome", src=str(src)) # refresh
assert err is None
assert _auth_db(home / "browser-profile" / "chrome" / "Default" / "Cookies") == "PROFILE6-REFRESHED"
# ── Bug 3: private-URL sidecar must NOT carry the real profile ──
def test_sidecar_never_uses_real_profile(self):
# Even with consent resolving a real-profile CDP, the sidecar path
# (allow_real_profile=False) must return a throwaway session.
with patch.object(bt_real_profile, "_real_profile_cdp",
return_value=("http://127.0.0.1:9251", None)):
info = bt_session._create_local_session("t::local", allow_real_profile=False)
assert info["cdp_url"] is None
assert "real_profile" not in info["features"]
assert info["session_name"].startswith("h_")
# ── Bug 1: macOS 26 LSHandlers parser ──
def test_macos26_detect_returns_chrome(self):
import hermes_cli.browser_connect as bc
dump = (
"( { LSHandlerPreferredVersions = { LSHandlerRoleAll = \"7559.97\"; }; "
"LSHandlerRoleAll = \"com.google.chrome\"; LSHandlerURLScheme = https; } )"
)
with patch.object(bc.subprocess, "run", return_value=Mock(stdout=dump)):
assert bc._detect_default_darwin() == "chrome"
# ── Bug 4: permissions applied on refresh, not only fresh ──
# ── Bug 5: lightpanda engine + consent fails with an actionable message ──
def test_lightpanda_engine_fails_actionably(self):
import tools.browser_tool as bt
bt._real_profile_cdp_cache.clear()
with patch.object(bt_cloud, "_use_real_profile", return_value=True), \
patch.object(bt_lightpanda_fallback, "_using_lightpanda_engine", return_value=True), \
patch("hermes_cli.browser_connect.detect_default_chromium") as det:
cdp, err = bt_real_profile._real_profile_cdp()
assert cdp is None
assert err and "lightpanda" in err.lower() and "browser.engine" in err.lower()
det.assert_not_called() # guard fires before detection
bt._real_profile_cdp_cache.clear()
class TestReviewRound3:
"""Regressions for the round-3 review findings (Adolanium + kshitij)."""
def _multi(self, root):
for prof in ("Default", "Profile 6"):
(root / prof / "Network").mkdir(parents=True)
(root / "Local State").write_text('{"profile": {"last_used": "Profile 6"}}')
_auth_db((root / "Default" / "Cookies"), "default-signed-out")
_auth_db((root / "Profile 6" / "Cookies"), "PROFILE6-SESSION")
(root / "Profile 6" / "Preferences").write_text("{}")
return root
# ── ② torn first copy must not poison freshness ──
def test_torn_copy_is_redone_not_overlaid(self, tmp_path, monkeypatch):
import hermes_cli.browser_connect as bc
src = self._multi(tmp_path / "real")
home = tmp_path / "hh"
monkeypatch.setattr(bc, "get_hermes_home", lambda: home)
dst = bc.real_profile_copy_dir("chrome")
# Simulate a torn first copy: Default exists but NO done marker.
os.makedirs(os.path.join(dst, "Default"))
open(os.path.join(dst, "Default", "Cookies"), "w").write("HALF-COPY-GARBAGE")
_d, err = bc.snapshot_real_profile("chrome", src=str(src))
assert err is None
# Rebuilt from the active profile, not treated as populated.
assert _auth_db(home / "browser-profile" / "chrome" / "Default" / "Cookies") == "PROFILE6-SESSION"
assert os.path.isfile(os.path.join(dst, bc._SNAPSHOT_DONE_MARKER))
# ── ④ only the active profile is copied, never the others ──
def test_only_active_profile_copied(self, tmp_path, monkeypatch):
import hermes_cli.browser_connect as bc
src = self._multi(tmp_path / "real")
# Add a non-active profile with its own cookies — must NOT be copied.
(src / "Profile 3").mkdir()
_auth_db((src / "Profile 3" / "Cookies"), "PROFILE3-SHOULD-NOT-COPY")
home = tmp_path / "hh"
monkeypatch.setattr(bc, "get_hermes_home", lambda: home)
_dst, err = bc.snapshot_real_profile("chrome", src=str(src))
assert err is None
copy = home / "browser-profile" / "chrome"
# Active profile (Profile 6) landed in Default; other profiles absent.
assert _auth_db(copy / "Default" / "Cookies") == "PROFILE6-SESSION"
assert not (copy / "Profile 3").exists()
assert not (copy / "Profile 6").exists()
# ── ③ consent-off deletes the snapshot store ──
def test_cleanup_removes_store(self, tmp_path, monkeypatch):
import hermes_cli.browser_connect as bc
home = tmp_path / "hh"
monkeypatch.setattr(bc, "get_hermes_home", lambda: home)
store = home / "browser-profile" / "chrome" / "Default"
store.mkdir(parents=True)
(store / "Cookies").write_text("secret")
bc.cleanup_real_profile_snapshots()
assert not (home / "browser-profile").exists()
def test_cleanup_idempotent_when_absent(self, tmp_path, monkeypatch):
import hermes_cli.browser_connect as bc
monkeypatch.setattr(bc, "get_hermes_home", lambda: tmp_path / "hh")
bc.cleanup_real_profile_snapshots() # no raise
# ── Windows lock probe (unit; the live share-lock is proven in the
# windows-latest E2E — here we cover the probe's contract portably) ──
def test_lock_probe_false_when_readable(self, tmp_path):
import hermes_cli.browser_connect as bc
(tmp_path / "Default" / "Network").mkdir(parents=True)
(tmp_path / "Default" / "Network" / "Cookies").write_bytes(b"db")
assert bc._profile_is_locked(str(tmp_path), "Default") is False
def test_lock_probe_false_when_no_cookie_db(self, tmp_path):
import hermes_cli.browser_connect as bc
(tmp_path / "Default").mkdir(parents=True)
assert bc._profile_is_locked(str(tmp_path), "Default") is False
def test_lock_probe_true_on_permissionerror(self, tmp_path, monkeypatch):
import hermes_cli.browser_connect as bc
(tmp_path / "Default").mkdir(parents=True)
(tmp_path / "Default" / "Cookies").write_bytes(b"db")
import builtins
real_open = builtins.open
def deny(path, *a, **k):
if str(path).endswith("Cookies"):
raise PermissionError("locked")
return real_open(path, *a, **k)
monkeypatch.setattr(builtins, "open", deny)
assert bc._profile_is_locked(str(tmp_path), "Default") is True
def test_snapshot_fails_fast_when_locked(self, tmp_path, monkeypatch):
"""snapshot_real_profile always BLOCKS when locked — never kills, never
proceeds to a heavy copy. autoclose off → plain quit guidance."""
import hermes_cli.browser_connect as bc
src = self._multi(tmp_path / "real")
home = tmp_path / "hh"
monkeypatch.setattr(bc, "get_hermes_home", lambda: home)
monkeypatch.setattr(bc, "_profile_is_locked", lambda s, p: True)
monkeypatch.setattr(bc, "_real_profile_autoclose", lambda: False)
called = {"copytree": 0}
import shutil as _sh
orig_ct = _sh.copytree
monkeypatch.setattr(_sh, "copytree",
lambda *a, **k: (called.__setitem__("copytree", called["copytree"] + 1), orig_ct(*a, **k))[1])
dst, err = bc.snapshot_real_profile("chrome", src=str(src))
assert dst is None
assert err and err.startswith(bc._PROFILE_LOCKED_PREFIX)
assert called["copytree"] == 0 # bailed before any copy
def test_snapshot_blocks_when_locked_even_with_autoclose(self, tmp_path, monkeypatch):
"""Even with autoclose armed, snapshot_real_profile does NOT kill — it
blocks and defers the close to the explicit, user-approved step. The
message offers the close (mentions Hermes can close it)."""
import hermes_cli.browser_connect as bc
src = self._multi(tmp_path / "real")
home = tmp_path / "hh"
monkeypatch.setattr(bc, "get_hermes_home", lambda: home)
monkeypatch.setattr(bc, "_profile_is_locked", lambda s, p: True)
monkeypatch.setattr(bc, "_real_profile_autoclose", lambda: True)
killed = {"n": 0}
monkeypatch.setattr(bc, "close_browser_holding_profile",
lambda *a, **k: (killed.__setitem__("n", killed["n"] + 1), (True, "x"))[1])
dst, err = bc.snapshot_real_profile("chrome", src=str(src))
assert dst is None
assert err and err.startswith(bc._PROFILE_LOCKED_PREFIX)
assert killed["n"] == 0 # snapshot must NOT invoke the killer itself
def test_processes_holding_profile_identity_binding(self, tmp_path, monkeypatch):
"""The process matcher requires BOTH a browser binary AND this exact
user-data-dir in the cmdline — never a same-name process on another dir."""
import hermes_cli.browser_connect as bc
class FakeProc:
def __init__(self, name, cmdline):
self.info = {"name": name, "cmdline": cmdline}
ud = str(tmp_path / "ud")
procs = [
FakeProc("chrome.exe", ["chrome.exe", f"--user-data-dir={ud}"]), # match
FakeProc("chrome.exe", ["chrome.exe", "--user-data-dir=C:\\Other"]), # wrong dir
FakeProc("python.exe", ["python.exe", f"--user-data-dir={ud}"]), # not a browser
]
class FakePsutil:
NoSuchProcess = psutil_exc = type("E", (Exception,), {})
AccessDenied = type("E2", (Exception,), {})
def process_iter(self, attrs=None):
return iter(procs)
import sys as _sys
monkeypatch.setitem(_sys.modules, "psutil", FakePsutil())
matched = list(bc._processes_holding_profile(ud))
assert len(matched) == 1
assert matched[0].info["name"] == "chrome.exe"
assert f"--user-data-dir={ud}" in " ".join(matched[0].info["cmdline"])
def test_consent_off_triggers_cleanup(self, tmp_path, monkeypatch):
called = {"n": 0}
with patch.object(bt_cloud, "_use_real_profile", return_value=False), \
patch("hermes_cli.browser_connect.cleanup_real_profile_snapshots",
side_effect=lambda: called.__setitem__("n", called["n"] + 1)):
cdp, err = bt_real_profile._real_profile_cdp()
assert cdp is None and err is None
assert called["n"] == 1
# ── ① overlay must not run before the reuse check (live-browser safety) ──
def test_reuse_skips_snapshot_overlay(self, tmp_path):
"""When a live session on our copy dir is reused, snapshot_real_profile
must NOT be called — otherwise it rewrites cookie DBs under a live
browser."""
import tools.browser_tool as bt
bt._real_profile_cdp_cache.clear()
with patch.object(bt_cloud, "_use_real_profile", return_value=True), \
patch.object(bt_lightpanda_fallback, "_using_lightpanda_engine", return_value=False), \
patch("hermes_cli.browser_connect.detect_default_chromium", return_value="chrome"), \
patch("hermes_cli.browser_connect.real_profile_copy_dir", return_value=str(tmp_path)), \
patch.object(bt_real_profile, "_agent_browser_get_cdp", return_value="http://127.0.0.1:9251"), \
patch.object(bt_real_profile, "_cdp_http_ready", return_value=True), \
patch.object(bt_real_profile, "_cdp_on_data_dir", return_value=True), \
patch("hermes_cli.browser_connect.snapshot_real_profile") as snap:
cdp, err = bt_real_profile._real_profile_cdp()
assert cdp == "http://127.0.0.1:9251" and err is None
snap.assert_not_called() # ← the fix: no overlay while a live browser owns the dir
bt._real_profile_cdp_cache.clear()
class TestWindowsLockedProfileCopy:
"""Windows: a running Chrome holds Cookies/Login Data with an exclusive
lock. The auth DBs must be copied via SQLite online-backup (works under the
lock), not a raw copy that fails and leaves a signed-out snapshot."""
def _locked_src(self, root):
import sqlite3
(root / "Default" / "Network").mkdir(parents=True)
(root / "Local State").write_text(json.dumps({"profile": {"last_used": "Default"}}))
(root / "Default" / "Preferences").write_text("{}")
ck = str(root / "Default" / "Cookies")
con = sqlite3.connect(ck)
con.execute("create table cookies(host_key, name)")
con.executemany("insert into cookies values(?,?)",
[("nous.ai", f"c{i}") for i in range(42)])
con.commit()
return root, con # caller keeps con open to simulate the live lock
def test_locked_cookie_db_copied_via_backup(self, tmp_path, monkeypatch):
import hermes_cli.browser_connect as bc
import sqlite3, shutil
src, con = self._locked_src(tmp_path / "real")
con.execute("BEGIN"); con.execute("insert into cookies values('u','uncommitted')")
home = tmp_path / "hh"
monkeypatch.setattr(bc, "get_hermes_home", lambda: home)
try:
_dst, err = bc.snapshot_real_profile("chrome", src=str(src))
finally:
con.rollback(); con.close()
assert err is None
copy_ck = str(home / "browser-profile" / "chrome" / "Default" / "Cookies")
t = str(tmp_path / "probe"); shutil.copy2(copy_ck, t)
n = sqlite3.connect(t).execute("select count(*) from cookies").fetchone()[0]
assert n == 42 # committed rows copied under the lock; uncommitted excluded
# No stale journal/wal sidecar left next to the backed-up DB.
assert not (home / "browser-profile" / "chrome" / "Default" / "Cookies-journal").exists()
def test_copy_auth_file_backs_up_db(self, tmp_path):
import hermes_cli.browser_connect as bc
import sqlite3
src = str(tmp_path / "Cookies")
con = sqlite3.connect(src); con.execute("create table cookies(x)"); con.execute("insert into cookies values(1)"); con.commit(); con.close()
dst = str(tmp_path / "out" / "Cookies")
assert bc._copy_auth_file(src, dst) is None
assert sqlite3.connect(dst).execute("select count(*) from cookies").fetchone()[0] == 1
@pytest.mark.parametrize("locked", ["source", "destination"])
def test_copy_auth_file_bounds_locks_without_overwriting(self, tmp_path, locked):
import sqlite3
import subprocess
import sys
import hermes_cli.browser_connect as bc
src, dst = tmp_path / "Cookies", tmp_path / "out" / "Cookies"
dst.parent.mkdir()
for path, value in ((src, 7), (dst, 99)):
with sqlite3.connect(path) as conn:
conn.execute("create table cookies(x)")
conn.execute("insert into cookies values(?)", (value,))
conn.close()
holder = sqlite3.connect(src if locked == "source" else dst)
holder.execute("begin exclusive")
try:
result = subprocess.run(
[sys.executable, "-c",
"from hermes_cli.browser_connect import _copy_auth_file; "
"import sys; print(_copy_auth_file(sys.argv[1], sys.argv[2]))",
str(src), str(dst)],
capture_output=True, text=True, timeout=15, stdin=subprocess.DEVNULL, check=False)
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == bc._AUTH_DB_LOCKED
finally:
holder.rollback()
holder.close()
with sqlite3.connect(dst) as conn:
assert conn.execute("select x from cookies").fetchall() == [(99,)]
conn.close()
assert bc._copy_auth_file(str(src), str(dst)) is None
with sqlite3.connect(dst) as conn:
assert conn.execute("select x from cookies").fetchall() == [(7,)]
conn.close()
def test_copy_auth_file_preserves_source_wal_not_abandoned_destination_wal(self, tmp_path):
import sqlite3
import subprocess
import sys
import hermes_cli.browser_connect as bc
src, dst = tmp_path / "Cookies", tmp_path / "out" / "Cookies"
dst.parent.mkdir()
source = sqlite3.connect(src)
source.execute("create table cookies(x)")
source.execute("insert into cookies values(7)")
source.commit()
source.execute("pragma journal_mode=wal")
source.execute("update cookies set x=8")
source.commit()
subprocess.run(
[sys.executable, "-c",
"import sqlite3, os, sys; c=sqlite3.connect(sys.argv[1]); "
"c.execute('create table cookies(x)'); c.commit(); "
"c.execute('pragma journal_mode=wal'); "
"c.execute('insert into cookies values(99)'); c.commit(); os._exit(0)",
str(dst)], check=True, timeout=15, stdin=subprocess.DEVNULL)
assert os.path.exists(str(dst) + "-wal")
try:
assert bc._copy_auth_file(str(src), str(dst)) is None
with sqlite3.connect(dst) as conn:
assert conn.execute("select x from cookies").fetchall() == [(8,)]
conn.close()
finally:
source.close()
def test_copy_auth_file_slow_but_progressing_backup_is_not_called_locked(self, tmp_path, monkeypatch):
"""A large DB on a slow disk that is still copying pages past the deadline must not
get the lock wording (whose all-locked message tells the user to quit the browser)."""
import sqlite3
import hermes_cli.browser_connect as bc
src = str(tmp_path / "Web Data")
con = sqlite3.connect(src)
con.execute("create table t(x)")
con.executemany("insert into t values(?)", ((b"x" * 4000,) for _ in range(600))) # > 256 pages
con.commit(); con.close()
monkeypatch.setattr(bc, "_AUTH_BACKUP_DEADLINE_S", -1.0) # first callback is already past due
reason = bc._copy_auth_file(src, str(tmp_path / "out" / "Web Data"))
assert reason and reason != bc._AUTH_DB_LOCKED
assert "write lock" not in reason and "exceeded" in reason
def test_copy_auth_file_plain_for_non_db(self, tmp_path):
import hermes_cli.browser_connect as bc
src = str(tmp_path / "Preferences"); open(src, "w").write('{"k":1}')
dst = str(tmp_path / "out" / "Preferences")
assert bc._copy_auth_file(src, dst) is None
assert open(dst).read() == '{"k":1}'
def test_fail_closed_when_db_unreadable(self, tmp_path, monkeypatch):
"""If even the online-backup can't read the DB, snapshot fails closed
rather than launching a silently signed-out session."""
import hermes_cli.browser_connect as bc
root = tmp_path / "real"
(root / "Default").mkdir(parents=True)
(root / "Local State").write_text(json.dumps({"profile": {"last_used": "Default"}}))
(root / "Default" / "Cookies").write_text("not-a-db")
(root / "Default" / "Preferences").write_text("{}")
home = tmp_path / "hh"
monkeypatch.setattr(bc, "get_hermes_home", lambda: home)
# Force both sqlite-backup and raw copy to fail for the DB.
monkeypatch.setattr(bc, "_copy_auth_file",
lambda s, d: "file is not a database" if os.path.basename(s) in bc._SQLITE_AUTH_DBS else None)
dst, err = bc.snapshot_real_profile("chrome", src=str(root))
assert dst is None
assert err