Files
hermes-agent/hermes_cli/browser_connect.py
ethernet b4a294fff9 Merge origin/main; keep PM as plugin dependency owner
Reconcile plugin declarations and validation through PM's atomic generation publication; preserve external runtimes, target markers, and conflict refusal. Keep one source-update completion owner and port upstream lifecycle changes to the PM desktop/runtime paths.
2026-09-17 13:52:05 -04:00

972 lines
48 KiB
Python

"""Shared helpers for attaching Hermes to a local Chromium-family CDP port.
Resolves the default Chromium browser + real profile dir, snapshots that profile for the
``browser.use_real_profile`` consent path, and discovers/launches a debug browser on a
loopback CDP port (dual-stack: IPv4 first, then IPv6).
"""
from __future__ import annotations
import contextlib
import json
import logging
import ntpath
import os
import platform
import posixpath
import re
import shlex
import shutil
import socket
import sqlite3
import subprocess
import time
import urllib.parse
import urllib.request
from dataclasses import dataclass, field
from pathlib import Path
from agent.proxy_bypass import is_loopback_host
from hermes_constants import get_hermes_home
logger = logging.getLogger(__name__)
DEFAULT_BROWSER_CDP_PORT = 9222
DEFAULT_BROWSER_CDP_URL = f"http://127.0.0.1:{DEFAULT_BROWSER_CDP_PORT}"
@dataclass(frozen=True)
class _Browser:
"""Per-browser install/profile locations for one Chromium-family product."""
key: str
mac_app: str
mac_support: tuple[str, ...] # under ~/Library/Application Support
win_bins: tuple[str, ...] # shutil.which names on Windows
win_install: tuple[tuple[str, ...], ...] # under Program Files / LOCALAPPDATA
win_profile: tuple[str, ...] # under LOCALAPPDATA
linux_bins: tuple[str, ...] # shutil.which names on Linux
linux_paths: tuple[str, ...] # known absolute install paths
linux_config: str # under $XDG_CONFIG_HOME
# PATH names tried by chromium_executable() when they differ from linux_bins
# (channel/alias binaries are launch candidates only).
linux_exec: tuple[str, ...] | None = None
# Launch-candidate order (chrome, chromium, brave, brave-origin, edge) is the tuple
# order. ``brave-origin`` is Brave's standalone paid build: same Chromium core but a
# fully distinct install identity (Brave-Origin product path, ``BraveOHTML`` ProgId,
# ``com.brave.Browser.origin`` bundle id) that installs side-by-side with Brave. Its
# profile is NOT under Brave-Browser and must never be conflated with the ``brave``
# key — a "brave" lookup resolving to the Origin binary drives the wrong profile.
# Positional layout per entry: key, mac_app / mac_support, win_bins / win_install /
# win_profile / linux_bins / linux_paths / linux_config [, linux_exec].
_BROWSERS = (
_Browser(
"chrome", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
("Google", "Chrome"), ("chrome.exe", "chrome"),
(("Google", "Chrome", "Application", "chrome.exe"),),
("Google", "Chrome", "User Data"),
("google-chrome", "google-chrome-stable"),
("/opt/google/chrome/chrome", "/usr/bin/google-chrome", "/usr/bin/google-chrome-stable"),
"google-chrome"),
_Browser(
"chromium", "/Applications/Chromium.app/Contents/MacOS/Chromium",
("Chromium",), ("chromium.exe", "chromium"),
(("Chromium", "Application", "chrome.exe"), ("Chromium", "Application", "chromium.exe")),
("Chromium", "User Data"),
("chromium-browser", "chromium"),
("/usr/bin/chromium-browser", "/usr/bin/chromium"),
"chromium"),
_Browser(
"brave", "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
("BraveSoftware", "Brave-Browser"), ("brave.exe", "brave"),
(("BraveSoftware", "Brave-Browser", "Application", "brave.exe"),),
("BraveSoftware", "Brave-Browser", "User Data"),
("brave-browser", "brave-browser-stable", "brave"),
("/usr/bin/brave-browser", "/usr/bin/brave-browser-stable", "/usr/bin/brave",
"/snap/bin/brave", "/opt/brave.com/brave/brave-browser", "/opt/brave.com/brave/brave",
"/opt/brave-bin/brave"),
"BraveSoftware/Brave-Browser"),
_Browser(
"brave-origin", "/Applications/Brave Origin.app/Contents/MacOS/Brave Origin",
("BraveSoftware", "Brave-Origin"), ("brave-origin.exe", "brave-origin"),
(("BraveSoftware", "Brave-Origin", "Application", "brave.exe"),
("BraveSoftware", "Brave-Origin", "Application", "brave-origin.exe")),
("BraveSoftware", "Brave-Origin", "User Data"),
("brave-origin", "brave-origin-nightly"),
("/usr/bin/brave-origin", "/opt/brave.com/brave-origin/brave-origin",
"/opt/brave.com/brave-origin-nightly/brave-origin"),
"BraveSoftware/Brave-Origin", linux_exec=("brave-origin",)),
_Browser(
"edge", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
("Microsoft Edge",), ("msedge.exe", "msedge"),
(("Microsoft", "Edge", "Application", "msedge.exe"),),
("Microsoft", "Edge", "User Data"),
("microsoft-edge", "microsoft-edge-stable", "msedge"),
("/usr/bin/microsoft-edge", "/usr/bin/microsoft-edge-stable",
"/opt/microsoft/msedge/microsoft-edge", "/opt/microsoft/msedge/msedge"),
"microsoft-edge", linux_exec=("microsoft-edge", "microsoft-edge-stable")),
)
_BROWSER_BY_KEY = {b.key: b for b in _BROWSERS}
# --- Default-Chromium resolution (``browser.use_real_profile``) ------------------------
# Only Chromium-family browsers are supported; a non-Chromium default (Firefox, Safari)
# resolves to None and the caller fails closed. Each platform table is a STABLE map plus a
# CHANNEL list: Beta/Dev/Canary are recognized but unsupported (their profiles live in dirs
# the tables don't carry) and are matched FIRST so they fail closed instead of being
# swallowed into the stable family — driving the wrong profile is a wrong-principal bug
# (#95549 invariant).
# Windows UserChoice ProgId prefixes → key. Case-insensitive PREFIX match so
# version suffixes (``ChromeHTML.X``) still resolve.
_WINDOWS_PROGID_MAP = (
("chromehtml", "chrome"), ("msedgehtm", "edge"),
("braveohtml", "brave-origin"), # Brave Origin stable (brave-core install_static)
("bravehtml", "brave"), ("chromiumhtm", "chromium"))
# ``ChromeBHTML`` = Beta, ``ChromeDHTML`` = Dev, ``ChromeSSHTML`` = Canary (SxS);
# ``MSEdge[BDC]HTML`` = Edge channels; Brave Origin Beta=BraveOBHTML, Dev=BraveODHTML,
# Nightly/SxS=BraveOSHTM (no trailing L — 10-char cap).
_WINDOWS_CHANNEL_PROGIDS = (
"chromebhtml", "chromedhtml", "chromesshtml", "chromecanaryhtml",
"msedgebhtml", "msedgedhtml", "msedgechtml",
"bravebetahtml", "bravenightlyhtml",
"braveobhtml", "braveodhtml", "braveoshtm")
# Linux xdg default-web-browser .desktop name fragments → key (SUBSTRING match),
# including Flatpak application ids (``com.google.Chrome.desktop``).
_LINUX_DESKTOP_MAP = (
("google-chrome", "chrome"), ("com.google.chrome", "chrome"), ("chromium", "chromium"),
# ORDER MATTERS: ``brave-origin.desktop`` contains the bare ``brave`` fragment,
# so the substring scan must hit the Origin entry first (#95549).
("brave-origin", "brave-origin"), ("brave", "brave"),
("microsoft-edge", "edge"), ("com.microsoft.edge", "edge"), ("msedge", "edge"))
_LINUX_CHANNEL_FRAGMENTS = (
"google-chrome-beta", "google-chrome-unstable", "google-chrome-canary",
"com.google.chrome.beta", "com.google.chrome.dev", "com.google.chrome.canary",
"microsoft-edge-beta", "microsoft-edge-dev", "microsoft-edge-canary",
"brave-browser-beta", "brave-browser-nightly", "brave-browser-dev",
"brave-origin-beta", "brave-origin-nightly", "brave-origin-dev")
# Where sandboxed Linux packages keep the profile instead of $XDG_CONFIG_HOME.
_LINUX_FLATPAK_IDS = {"chrome": "com.google.Chrome", "chromium": "org.chromium.Chromium",
"brave": "com.brave.Browser", "edge": "com.microsoft.Edge"}
_LINUX_SNAP_PROFILE_PARTS = {
"chromium": ("snap", "chromium", "common", "chromium"),
"brave": ("snap", "brave", "current", ".config", "BraveSoftware", "Brave-Browser")}
# macOS LaunchServices bundle-id → key. EXACT match (not prefix): ``com.google.chrome.beta``
# must not be read as ``com.google.chrome``, nor ``com.brave.browser.origin`` (Homebrew
# cask id for Brave Origin) as plain ``com.brave.browser``.
_DARWIN_BUNDLE_MAP = (
("com.google.chrome", "chrome"), ("com.microsoft.edgemac", "edge"),
("com.brave.browser", "brave"), ("com.brave.browser.origin", "brave-origin"),
("org.chromium.chromium", "chromium"))
_DARWIN_CHANNEL_BUNDLES = (
"com.google.chrome.beta", "com.google.chrome.dev", "com.google.chrome.canary",
"com.microsoft.edgemac.beta", "com.microsoft.edgemac.dev", "com.microsoft.edgemac.canary",
"com.brave.browser.beta", "com.brave.browser.nightly",
"com.brave.browser.origin.beta", "com.brave.browser.origin.dev",
"com.brave.browser.origin.nightly")
# Sentinel for a recognized-but-unsupported Chromium CHANNEL default. Distinct from
# None (non-Chromium) so the caller can give a channel-specific message.
UNSUPPORTED_CHANNEL = "__unsupported_channel__"
def real_profile_data_dir(browser: str, system: str | None = None) -> str | None:
"""Default user-data-dir for ``browser`` on ``system`` (None if unknown). Linux tries native
($XDG_CONFIG_HOME), snap and Flatpak — first existing wins, else native so the caller's
error names it. Darwin/Windows paths are not stat'ed."""
b = _BROWSER_BY_KEY.get(browser)
if b is None:
return None
system = system or platform.system()
home = os.path.expanduser("~")
if system == "Darwin":
return posixpath.join(home, "Library", "Application Support", *b.mac_support)
if system == "Windows":
local = os.environ.get("LOCALAPPDATA") or ntpath.join(home, "AppData", "Local")
return ntpath.join(local, *b.win_profile)
config = os.environ.get("XDG_CONFIG_HOME") or posixpath.join(home, ".config")
linux_parts = b.linux_config.split("/")
candidates = [posixpath.join(config, *linux_parts)]
if browser in _LINUX_SNAP_PROFILE_PARTS:
candidates.append(posixpath.join(home, *_LINUX_SNAP_PROFILE_PARTS[browser]))
if browser in _LINUX_FLATPAK_IDS:
candidates.append(posixpath.join(home, ".var", "app", _LINUX_FLATPAK_IDS[browser], "config",
*linux_parts))
return next((c for c in candidates if os.path.isdir(c)), candidates[0])
def _first_present(paths) -> str | None:
return next((p for p in paths if p and os.path.isfile(p)), None)
def chromium_executable(browser: str, system: str | None = None) -> str | None:
"""Return the first present executable for a Chromium ``browser``."""
b = _BROWSER_BY_KEY.get(browser)
if b is None:
return None
system = system or platform.system()
if system == "Darwin":
return _first_present((b.mac_app,))
if system == "Windows":
bases = [
os.environ.get("PROGRAMFILES", r"C:\Program Files"),
os.environ.get("PROGRAMFILES(X86)", r"C:\Program Files (x86)"),
os.environ.get("LOCALAPPDATA", str(Path.home() / "AppData" / "Local"))]
return _first_present(os.path.join(base, *parts) for base in bases for parts in b.win_install)
# Linux: PATH lookup first, then the known absolute install paths.
found = next(filter(None, map(shutil.which, b.linux_exec or b.linux_bins)), None)
return found or _first_present(b.linux_paths)
def _classify_default(value: str, channels, table, match) -> str | None:
"""Map an OS default-browser identifier to a canonical key. Channels are checked FIRST: a
Beta/Dev/Canary id must fail closed (UNSUPPORTED_CHANNEL), never match the stable profile."""
if any(match(value, chan) for chan in channels):
return UNSUPPORTED_CHANNEL
return next((browser for frag, browser in table if match(value, frag)), None)
def _detect_default_windows() -> str | None:
try:
import winreg # type: ignore
key = winreg.OpenKey(
winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\Shell\Associations\UrlAssociations\https\UserChoice")
prog_id, _ = winreg.QueryValueEx(key, "ProgId")
winreg.CloseKey(key)
except Exception: # non-Windows host (no winreg) or unreadable key
return None
return _classify_default(str(prog_id or "").lower(), _WINDOWS_CHANNEL_PROGIDS,
_WINDOWS_PROGID_MAP, str.startswith)
def _run_stdout(argv: list[str]) -> str | None:
try:
return subprocess.run(argv, capture_output=True, text=True, encoding="utf-8",
errors="replace", timeout=5).stdout
except Exception:
return None
def _launchservices_https_handler(dump: str) -> str | None:
"""Bundle id registered for ``https`` in ``defaults read … LSHandlers`` output (an array of
``{ … }`` dicts). Only the ``LSHandlerURLScheme = https`` entry counts — a browser
registered for another scheme or a file type must not be mistaken for the default."""
entries: list[str] = []
depth, buf = 0, []
for ch in dump:
depth += (ch == "{") - (ch == "}")
if ch == "{" and depth == 1:
buf = []
elif ch == "}" and depth == 0:
entries.append("".join(buf))
elif depth >= 1:
buf.append(ch)
for entry in entries:
low = entry.lower()
if not re.search(r'lshandlerurlscheme\s*=\s*"?https"?\s*;', low):
continue
# Strip the nested LSHandlerPreferredVersions block first: on macOS 26 it carries
# a VERSION NUMBER (LSHandlerRoleAll = "7559.97";), not the "-" placeholder older
# releases used, and the role regex below would return it instead of the bundle id.
# Left in, the role regex below would match that version before the real bundle id sitting at the
# entry's own level and return "7559.97" — which maps to no browser, so detection fails on a machine
# whose default IS Chrome (PR #95620 review).
low = re.sub(r"lshandlerpreferredversions\s*=\s*\{[^}]*\}\s*;", "", low)
# The real bundle id is the first non-"-" role value at this level.
roles = re.findall(r'lshandlerrole(?:all|viewer)\s*=\s*"?([a-z0-9.\-]+)"?\s*;', low)
return next((role for role in roles if role != "-"), None)
return None
def _detect_default_darwin() -> str | None:
out = _run_stdout(["defaults", "read",
"com.apple.LaunchServices/com.apple.launchservices.secure", "LSHandlers"])
bundle = _launchservices_https_handler(out) if out is not None else None
if not bundle:
return None
# Exact match. A non-Chromium https handler (Safari, Firefox, Arc, …) or an unknown
# channel bundle fails closed: no "first installed Chromium wins" fallback.
return _classify_default(bundle.lower(), _DARWIN_CHANNEL_BUNDLES, _DARWIN_BUNDLE_MAP, str.__eq__)
def _detect_default_linux() -> str | None:
out = (_run_stdout(["xdg-settings", "get", "default-web-browser"]) or "").strip().lower()
# Substring match; channels first because ``google-chrome-beta.desktop`` contains
# the stable ``google-chrome`` fragment.
return _classify_default(out, _LINUX_CHANNEL_FRAGMENTS, _LINUX_DESKTOP_MAP, str.__contains__)
def detect_default_chromium(system: str | None = None) -> str | None:
"""Return the canonical key of the default Chromium browser, or None."""
detect = {"Windows": _detect_default_windows, "Darwin": _detect_default_darwin}
return detect.get(system or platform.system(), _detect_default_linux)()
# --- Real-profile SNAPSHOT launch -------------------------------------------------------
# Never drive the live default user-data-dir: Chromium ≥136 (Google builds) refuses remote
# debugging on it, and the user's running browser holds it (SingletonLock). Instead snapshot
# the real profile into ``~/.hermes/browser-profile/`` and launch the user's real binary on
# the copy — with NO mock-keychain/basic-store switches, so OS-keyring-encrypted cookies
# decrypt exactly as in the user's own browser.
# Excluded from the snapshot: caches/telemetry AND replay-prone state that hangs a fresh
# Chromium's renderer (extensions + service workers spin up on launch and wedge JS eval;
# IndexedDB/GPUCache add hundreds of MB). Only auth/login state is kept.
_SNAPSHOT_IGNORES = (
"*Cache*", # Cache, Code Cache, GPUCache, GrShaderCache, ShaderCache, GraphiteDawnCache, component_crx_cache, ...
"Extensions", # wallets/etc.: 100s of MB, and hang the renderer headless
"Extension*", # Extension State, Extension Rules, Extension Scripts
"Local Extension Settings",
"Service Worker", # replays on launch → wedges the renderer
"IndexedDB", "Crash Reports", "Crashpad", "BrowserMetrics*", "Snapshots",
"OptimizationGuide*", "optimization_guide_model_store", "Safe Browsing", "SafetyTips",
"OnDeviceHeadSuggestModel", "segmentation_platform", "Sync Data", "Shared Dictionary",
"History*", # large; not needed for auth
"Favicons*",
"Singleton*", # live-instance symlinks; never valid in a copy
"RunningChromeVersion", "SingletonSocket", "*.tmp",
"*-journal", # SQLite rollback journals — sidecars of the auth DBs,
"*-wal", # which are copied via online-backup; a stale sidecar
"*-shm", # next to a backed-up DB corrupts it.
"BrowserMetrics-spare.pma",
)
# Auth-bearing files re-synced from the live profile on EVERY consented launch (the full
# tree is copied only when the snapshot doesn't exist yet). RELATIVE TO A PROFILE DIR; the
# caller mirrors them into the copy's ``Default``. No ``-journal``/``-wal`` sidecars: the
# DBs come via sqlite online-backup (committed state folded in); a stale journal corrupts.
_AUTH_REFRESH_PROFILE_FILES = (
"Cookies", "Network/Cookies", "Login Data", "Login Data For Account", "Web Data", "Preferences")
def real_profile_copy_dir(browser: str) -> str:
"""Return the hermes-owned snapshot dir for ``browser``'s real profile."""
return str(get_hermes_home() / "browser-profile" / browser)
def _last_used_profile(src: str) -> str:
"""Profile dir Chrome last used (``Local State`` → profile.last_used), else ``Default``. The
signed-in session usually lives in the profile actually browsed (``Profile 6``), not Default."""
try:
with open(os.path.join(src, "Local State"), encoding="utf-8-sig", errors="replace") as fh:
state = json.load(fh)
last = ((state.get("profile") or {}).get("last_used")) or "Default"
except (OSError, ValueError, AttributeError):
last = "Default"
return last if isinstance(last, str) and os.path.isdir(os.path.join(src, last)) else "Default"
def _secure_snapshot(path: str, *, contents: bool = False) -> None:
"""Lock down a snapshot dir (or, with ``contents``, everything INSIDE it) as a secret store.
It holds the user's Cookies / Login Data, so it gets the same owner-only perms (managed-mode /
NixOS group-share carve-out, HERMES_UID/GID) as every Hermes secret dir — via ``_secure_dir``/
``_secure_file``, not a bespoke chmod. Contents matter too (#96729): ``copy2`` keeps Chrome's
0644 and sqlite backups land umask-wide, so cookies were world-readable under the
``HERMES_HOME_MODE`` hatch. Best-effort; never blocks a launch."""
try:
from hermes_cli.config import _secure_dir, _secure_file
if not contents:
_secure_dir(path)
return
for root, dirs, files in os.walk(path):
for d in dirs:
_secure_dir(os.path.join(root, d))
for f in files:
_secure_file(os.path.join(root, f))
except Exception as e:
logger.debug("could not secure real-profile snapshot %s %s: %s",
"contents" if contents else "dir", path, e)
# Auth files that are SQLite databases: on Windows a running Chrome holds these with an
# exclusive lock, so a raw copy raises WinError 32 and a best-effort skip leaves the copy
# signed-out. They are copied via SQLite's online-backup API instead. Matched by basename.
_SQLITE_AUTH_DBS = frozenset({"Cookies", "Login Data", "Login Data For Account", "Web Data"})
# Budget for one auth DB's online backup. A running browser holds Login Data / Login Data For
# Account / Web Data with a hot write lock, so backup makes no progress and this deadline is
# what fails — the reason users see, so it is a named constant rather than a bare string.
_AUTH_BACKUP_DEADLINE_S = 5.0
_AUTH_DB_LOCKED = ("SQLite backup made no progress within five seconds — "
"a running browser holds a write lock on it")
def _copy_auth_file(src_file: str, dst_file: str) -> str | None:
"""Copy auth state; returns None on success, else WHY the file could not be snapshotted.
A DB that cannot be backed up consistently within the deadline is refused, never raw-copied."""
os.makedirs(os.path.dirname(dst_file), exist_ok=True)
try:
if os.path.basename(src_file) in _SQLITE_AUTH_DBS:
deadline = time.monotonic() + _AUTH_BACKUP_DEADLINE_S
last_remaining: list[int | None] = [None]
def check_deadline(_status: int, remaining: int, total: int) -> None:
# A held write lock makes every step fail with ``remaining`` unchanged; a
# large DB on a slow disk keeps shrinking it. Only the former is "locked" —
# the all-locked message tells the user to quit the browser.
before = total if last_remaining[0] is None else last_remaining[0]
last_remaining[0] = remaining
if _status != sqlite3.SQLITE_DONE and time.monotonic() >= deadline:
if remaining < before:
raise TimeoutError(f"SQLite backup exceeded {_AUTH_BACKUP_DEADLINE_S:g}s "
"while still making progress")
raise TimeoutError(_AUTH_DB_LOCKED)
# SQLite must coordinate both ends: immutable ignores committed source WAL,
# while replacing only the destination file can replay its abandoned WAL.
# Connection busy timeouts do not bound backup's retry loop; its callback does.
with contextlib.closing(sqlite3.connect(
Path(src_file).resolve().as_uri() + "?mode=ro", uri=True, timeout=0.0)) as source:
with contextlib.closing(sqlite3.connect(dst_file, timeout=0.0)) as out:
source.backup(out, pages=256, progress=check_deadline, sleep=0.1)
else:
shutil.copy2(src_file, dst_file)
return None
except (OSError, sqlite3.Error) as e:
# A raw DB copy can lose committed WAL or overwrite a locked destination.
logger.debug("real-profile: could not copy %s: %s", src_file, e)
return str(e) or type(e).__name__
def _mirror_profile_auth(src: str, dst: str, source_profile: str) -> dict[str, str]:
"""Mirror ``source_profile``'s auth files into the copy's ``Default`` (agent-browser opens it);
returns ``{relative name: reason}`` for the DB auth files that could NOT be copied ({} = clean)."""
failed: dict[str, str] = {}
for rel in _AUTH_REFRESH_PROFILE_FILES:
s = os.path.join(src, source_profile, rel)
if not os.path.isfile(s):
continue
reason = _copy_auth_file(s, os.path.join(dst, "Default", rel))
if reason and os.path.basename(rel) in _SQLITE_AUTH_DBS:
failed[rel] = reason
return failed
def _unavailable_auth_dbs_error(browser: str, failed: dict[str, str]) -> str:
"""Fail-closed message naming WHICH auth databases could not be snapshotted and WHY. On
macOS/Linux a running browser typically lets Cookies back up but holds Login Data / Login Data
For Account / Web Data with a write lock, so the message must not read as "close the browser"
when the real cause is an unreadable file (and vice versa)."""
names = ", ".join(failed)
if all(reason == _AUTH_DB_LOCKED for reason in failed.values()):
return (f"{browser} is running and holds the profile's {names} with a write lock, so their "
"SQLite backup made no progress within five seconds. Hermes does not fall back to a "
"raw file copy (it could lose committed logins). Fully quit "
f"{browser} (including any background instance) and retry, or turn "
"browser.use_real_profile off.")
details = "; ".join(f"{name}: {reason}" for name, reason in failed.items())
return (f"could not read the '{browser}' profile's login data ({details}). "
f"Close {browser} and retry, or turn browser.use_real_profile off.")
_SNAPSHOT_DONE_MARKER = ".hermes-snapshot-complete"
# Prefix stamped on the "profile is locked" error so the calling layer can recognize the
# needs-the-browser-closed condition and surface the close-with-approval flow.
_PROFILE_LOCKED_PREFIX = "[profile-locked] "
def _profile_is_locked(src: str, source_profile: str) -> bool:
"""True when the active profile's cookie DB can't be opened (browser running). On Windows a
running browser holds Cookies deny-all (PermissionError); this FAST probe fails closed BEFORE
the heavy snapshot so a locked profile never hangs the launch. Always False on POSIX."""
db = _first_present( # modern Network/ location first
os.path.join(src, source_profile, rel)
for rel in (os.path.join("Network", "Cookies"), "Cookies"))
if not db:
return False # nothing to lock; let the copy path handle "no cookies"
try:
with open(db, "rb"):
return False
except OSError as e: # other OSErrors are transient — don't declare locked; let the copy try
return isinstance(e, PermissionError)
def _browser_setting(key: str):
"""Read ``browser.<key>`` from raw config; None when unset/unreadable."""
try:
from hermes_cli.config import read_raw_config
browser_cfg = read_raw_config().get("browser", {})
return browser_cfg.get(key) if isinstance(browser_cfg, dict) else None
except Exception as e:
logger.debug("could not read %s: %s", key, e)
return None
def _real_profile_pin() -> str | None:
"""Pinned source profile dir name from ``browser.real_profile_pin`` (ALWAYS copied when set).
Unset, the snapshot follows ``profile.last_used``, which can hand the agent the wrong identity.
"""
pin = _browser_setting("real_profile_pin")
return pin.strip() if isinstance(pin, str) and pin.strip() else None
def _resolve_source_profile(src: str) -> tuple[str | None, str | None]:
"""``(profile_dir_name, error)``: the pin if set, else last_used. A pin missing under ``src``
FAILS CLOSED — falling back would silently browse as the wrong identity (wrong-principal)."""
pin = _real_profile_pin()
if pin:
if os.path.isdir(os.path.join(src, pin)):
return pin, None
return None, (
f"browser.real_profile_pin is set to '{pin}' but that profile directory does not "
f"exist under {src!r}. Profile directories are named like 'Default' or 'Profile 2' "
f"— list them with: ls {src!r}. Fix the pin, or remove it to fall back to the "
"last-used profile.")
return _last_used_profile(src), None
def _real_profile_autoclose() -> bool:
"""Whether browser.real_profile_autoclose consent is on (config read)."""
return bool(_browser_setting("real_profile_autoclose") or False)
def _processes_holding_profile(src: str):
"""Yield psutil.Process instances holding ``src`` open: Chromium-family binaries whose
cmdline references THIS user-data-dir — never an unrelated same-PID process. An unreadable
cmdline is skipped."""
try:
import psutil
except ImportError: # hard dep; defensive
return
norm = os.path.normcase(os.path.normpath(src))
browser_bins = (
"chrome", "chrome.exe", "chromium", "chromium.exe", "chrome_crashpad",
"brave", "brave.exe", "msedge", "msedge.exe", "google chrome")
for proc in psutil.process_iter(["name", "cmdline"]):
try:
name = (proc.info.get("name") or "").lower()
cmd = proc.info.get("cmdline") or []
joined = " ".join(cmd)
except (psutil.NoSuchProcess, psutil.AccessDenied, OSError):
continue
argv0 = cmd[0].lower() if cmd else "" # some platforms report a generic name
if not any(b in name or b in argv0 for b in browser_bins):
continue
# Binding: the exact user-data-dir must appear in the cmdline, normalized.
if (norm in os.path.normcase(os.path.normpath(joined))
or f"--user-data-dir={src}".lower() in joined.lower()):
yield proc
def close_browser_holding_profile(src: str, timeout: float = 15.0) -> tuple[bool, str]:
"""Terminate the browser process tree holding ``src`` and wait for release. CONSENTED,
DESTRUCTIVE (unsaved tab/form state is lost): only call after the user agreed."""
try:
import psutil
except ImportError:
return False, "psutil unavailable — cannot close the browser automatically."
procs = list(_processes_holding_profile(src))
if not procs:
# Already closed, or the holder is a different user / unreadable — caller re-probes.
return False, "no matching browser process found holding the profile."
# Include child processes (renderers, GPU, crashpad) for a full tree kill.
gone_errs = (psutil.NoSuchProcess, psutil.AccessDenied)
targets = list(procs)
for p in procs:
with contextlib.suppress(*gone_errs):
targets.extend(p.children(recursive=True))
for p in targets:
with contextlib.suppress(*gone_errs):
p.terminate()
alive = psutil.wait_procs(targets, timeout=min(timeout, 8.0))[1]
for p in alive:
with contextlib.suppress(*gone_errs):
p.kill()
psutil.wait_procs(alive, timeout=3.0)
# The lock releases slightly after the process exits on Windows; poll.
source_profile = _resolve_source_profile(src)[0] or _last_used_profile(src)
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if not _profile_is_locked(src, source_profile):
return True, "closed the browser and the profile lock released."
time.sleep(0.5)
return False, (
"closed the browser processes but the profile is still locked — "
"another instance may have relaunched (background/tray mode).")
def _sync_local_state(src: str, dst: str, source_profile: str) -> None:
"""Copy ``Local State`` into the snapshot and rewrite it for the single ``Default`` profile.
Verbatim it names the SOURCE profile (last_used="Profile 2", info_cache of 2/4/7) the copy
lacks, so Chrome would start SIGNED OUT. CRITICAL: Default's entry must be the SOURCE profile's
identity (Default DIR holds its cookies), else Chrome demands "Continue as <name>" each launch.
"""
ls_src, ls_dst = os.path.join(src, "Local State"), os.path.join(dst, "Local State")
if os.path.isfile(ls_src):
try:
shutil.copy2(ls_src, ls_dst)
except OSError as e:
logger.debug("real-profile snapshot: skipped Local State: %s", e)
try:
with open(ls_dst, encoding="utf-8-sig") as fh:
state = json.load(fh)
prof = state.get("profile")
if isinstance(prof, dict):
cache = prof.get("info_cache")
if isinstance(cache, dict):
src_entry = cache.get(source_profile) or cache.get("Default")
if src_entry:
prof["info_cache"] = {"Default": src_entry}
prof["last_used"] = "Default"
prof["last_active_profiles"] = ["Default"]
with open(ls_dst, "w", encoding="utf-8") as fh:
json.dump(state, fh)
except (OSError, ValueError) as e:
logger.debug("real-profile snapshot: could not normalize Local State: %s", e)
def _locked_profile_error(browser: str) -> str:
"""Fail-closed message for a locked profile; offers auto-close only when consent arms it.
NEVER kill from here: closing the browser is destructive and must be an explicit per-attempt,
user-approved step. A still-locked retry blocks again — no auto-retry, no loop."""
if _real_profile_autoclose():
msg = (
f"{browser} is running and has its profile locked, so its login data can't be copied "
"yet. Hermes can close it for you (this quits the browser — you'll lose unsaved "
"tabs). Ask the user to confirm, then close it and retry; if it's still locked after "
"that, they must fully quit it (including any background/tray instance).")
else:
msg = (
f"{browser} is running and has its profile locked, so its login data can't be copied. "
"Fully quit the browser (including any background/tray instance) and retry, or turn "
"browser.use_real_profile off. (Enable browser.real_profile_autoclose to let Hermes "
"offer to close it for you.)")
return _PROFILE_LOCKED_PREFIX + msg
def _copy_profile_tree(src: str, dst: str, source_profile: str) -> None:
"""Fresh (or torn-and-rebuilding) copy of the ACTIVE profile dir into the copy's Default,
minus caches AND the SQLite auth DBs (raw copytree of a Chrome-held file raises on Windows);
``_mirror_profile_auth`` copies the DBs lock-aware instead."""
dst_default = os.path.join(dst, "Default")
try:
shutil.rmtree(dst_default, ignore_errors=True)
shutil.copytree(
os.path.join(src, source_profile),
dst_default,
dirs_exist_ok=True,
symlinks=False,
ignore=shutil.ignore_patterns(*_SNAPSHOT_IGNORES, *_SQLITE_AUTH_DBS),
ignore_dangling_symlinks=True)
except shutil.Error as multi:
# Per-file failures (browser mid-write) are non-fatal.
logger.info(
"real-profile snapshot: %d file(s) skipped copying %s/%s",
len(multi.args[0]) if multi.args else 0, src, source_profile)
def snapshot_real_profile(browser: str, src: str | None = None) -> tuple[str | None, str | None]:
"""Snapshot ``browser``'s real ACTIVE profile into the hermes copy dir; returns ``(dst, err)``.
Copies ``Local State`` plus the active profile's auth files into the copy's ``Default``. The
completion marker is written only after full success, so a torn first copy (disk full, Ctrl+C)
never looks "already populated" — it is redone from scratch."""
src = src or real_profile_data_dir(browser)
if not src or not os.path.isdir(src):
return None, (
f"profile directory for '{browser}' was not found ({src!r}). "
"Launch that browser at least once, or turn browser.use_real_profile off.")
source_profile, resolve_err = _resolve_source_profile(src)
if resolve_err or not source_profile:
return None, resolve_err
dst = real_profile_copy_dir(browser)
# Fast lock probe BEFORE any copy: a blocking file op on a Windows-locked cookie DB can
# hang the launch for minutes. Never trips on POSIX; there a running browser surfaces later as
# auth DB backups that miss their deadline (``_unavailable_auth_dbs_error``).
if _profile_is_locked(src, source_profile):
return None, _locked_profile_error(browser)
marker = os.path.join(dst, _SNAPSHOT_DONE_MARKER)
# Only a copy that previously COMPLETED counts as populated; a half-written tree is
# rebuilt — otherwise a torn first copy poisons freshness forever.
populated = os.path.isfile(marker)
try:
os.makedirs(dst, exist_ok=True)
# Secure the snapshot dir AND its browser-profile parent on EVERY launch so a failed
# first attempt or an older-build dir still converges to owner-only perms.
for path in filter(None, (os.path.dirname(dst), dst)):
_secure_snapshot(path)
_sync_local_state(src, dst, source_profile)
if not populated:
_copy_profile_tree(src, dst, source_profile)
# Both paths: lock-aware auth DB copy into Default — also the per-launch re-sync.
failed_dbs = _mirror_profile_auth(src, dst, source_profile)
if failed_dbs: # even online-backup failed: never launch a silently signed-out session
return None, _unavailable_auth_dbs_error(browser, failed_dbs)
# Never carry live-instance leftovers into the copy.
for leftover in ("SingletonLock", "SingletonSocket", "SingletonCookie"):
with contextlib.suppress(OSError):
os.unlink(os.path.join(dst, leftover))
# Mark complete only after everything above succeeded.
try:
with open(marker, "w", encoding="utf-8") as fh:
fh.write(source_profile)
except OSError as e:
logger.debug("real-profile snapshot: could not write done marker: %s", e)
# AFTER the marker write so the marker itself is covered; every pass, so old snapshots heal.
_secure_snapshot(dst, contents=True)
except OSError as e:
return None, f"could not snapshot the '{browser}' profile into {dst}: {e}"
return dst, None
def cleanup_real_profile_snapshots() -> None:
"""Delete the whole real-profile snapshot store when consent is OFF (idempotent)."""
root = str(get_hermes_home() / "browser-profile")
if os.path.isdir(root):
shutil.rmtree(root, ignore_errors=True)
logger.info("real-profile: removed snapshot store %s (consent off)", root)
def _debug_candidate_paths(system: str):
"""Yield possible debug-browser binaries in launch order (may include None/missing)."""
install_bases = (os.environ.get("ProgramFiles"), os.environ.get("ProgramFiles(x86)"),
os.environ.get("LOCALAPPDATA"))
for b in _BROWSERS:
if system == "Darwin":
yield b.mac_app
elif system == "Windows":
yield from map(shutil.which, b.win_bins)
for base in filter(None, install_bases):
for parts in b.win_install:
yield os.path.join(base, *parts)
else:
yield from map(shutil.which, b.linux_bins)
yield from b.linux_paths
if system not in ("Darwin", "Windows"):
# WSL: Windows installs under ``/mnt/c/...`` are POSIX paths regardless of the host
# OS, so join with posixpath (os.path.join would emit backslashes on nt).
for b in _BROWSERS:
for base in ("/mnt/c/Program Files", "/mnt/c/Program Files (x86)"):
for parts in b.win_install:
yield posixpath.join(base, *parts)
def get_chrome_debug_candidates(system: str) -> list[str]:
candidates: dict[str, str] = {} # normalized -> first path seen (dedupe, keep order)
for path in filter(None, _debug_candidate_paths(system)):
normalized = os.path.normcase(os.path.normpath(path))
if normalized not in candidates and os.path.isfile(path):
candidates[normalized] = path
return list(candidates.values())
def chrome_debug_data_dir() -> str:
return str(get_hermes_home() / "chrome-debug")
def _chrome_debug_args(port: int) -> list[str]:
return [f"--remote-debugging-port={port}", f"--user-data-dir={chrome_debug_data_dir()}",
"--no-first-run", "--no-default-browser-check"]
def _tcp_open(host: str, port: int, timeout: float) -> bool:
try:
with socket.create_connection((host, port), timeout=timeout):
return True
except OSError:
return False
def is_browser_debug_ready(url: str, timeout: float = 1.0) -> bool:
"""Return True when ``url`` exposes a reachable Chrome DevTools endpoint."""
parsed = urllib.parse.urlparse(url if "://" in url else f"http://{url}")
try:
port = parsed.port or (443 if parsed.scheme in {"https", "wss"} else 80)
except ValueError:
return False
if parsed.scheme in {"ws", "wss"} and parsed.path.startswith("/devtools/browser/"):
return bool(parsed.hostname) and _tcp_open(parsed.hostname, port, timeout)
scheme = {"ws": "http", "wss": "https"}.get(parsed.scheme, parsed.scheme)
if scheme not in {"http", "https"} or not parsed.netloc:
return False
root = f"{scheme}://{parsed.netloc}".rstrip("/")
# Loopback readiness must not route through getproxies() (env or macOS system proxy, #110565).
handlers = [urllib.request.ProxyHandler({})] if is_loopback_host(parsed.hostname) else []
opener = urllib.request.build_opener(*handlers)
for probe in (f"{root}/json/version", f"{root}/json"):
try:
with opener.open(probe, timeout=timeout) as resp:
if 200 <= getattr(resp, "status", 200) < 300:
return True
except Exception:
continue
return False
# Both loopback literals, IPv4 FIRST: Windows (and some Linux setups) can hand each loopback
# to a different process — Chrome asked to bind :9222 while VS Code's js-debug holds
# 127.0.0.1:9222 comes up on [::1]:9222 only, invisible to an IPv4-only probe.
_LOOPBACK_PROBE_HOSTS = ("127.0.0.1", "[::1]")
_LOOPBACK_SOCKET_HOSTS = ("127.0.0.1", "::1")
def discover_local_cdp_url(port: int, timeout: float = 1.0) -> str | None:
"""Return the first loopback URL (IPv4 first, then IPv6) speaking CDP, else None."""
urls = (f"http://{host}:{port}" for host in _LOOPBACK_PROBE_HOSTS)
return next((url for url in urls if is_browser_debug_ready(url, timeout=timeout)), None)
def local_port_in_use(port: int, timeout: float = 0.5) -> bool:
"""True when either loopback accepts TCP on ``port``. Used AFTER a failed CDP probe to tell
"port is free, launch here" from "another application is squatting it; a launch would fight"."""
return any(_tcp_open(host, port, timeout) for host in _LOOPBACK_SOCKET_HOSTS)
def find_free_debug_port(preferred: int = DEFAULT_BROWSER_CDP_PORT, attempts: int = 10) -> int:
"""First port after ``preferred`` bindable on both loopbacks; ``preferred + 1`` if none binds
(the launch then fails with a clear browser-side error instead of silently doing nothing)."""
def bindable(family, host, port) -> bool:
try:
with socket.socket(family, socket.SOCK_STREAM) as sock:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind((host, port))
return True
except OSError:
return False
loopbacks = ((socket.AF_INET, "127.0.0.1"), (socket.AF_INET6, "::1"))
ports = range(preferred + 1, preferred + 1 + attempts)
return next((p for p in ports if all(bindable(f, h, p) for f, h in loopbacks)), preferred + 1)
def manual_chrome_debug_command(port: int = DEFAULT_BROWSER_CDP_PORT, system: str | None = None) -> str | None:
system = system or platform.system()
candidates = get_chrome_debug_candidates(system)
if candidates:
argv = [candidates[0], *_chrome_debug_args(port)]
return subprocess.list2cmdline(argv) if system == "Windows" else shlex.join(argv)
if system == "Darwin":
return (f'open -a "Google Chrome" --args --remote-debugging-port={port} '
f'--user-data-dir="{chrome_debug_data_dir()}" --no-first-run '
"--no-default-browser-check")
return None
def _detach_kwargs(system: str) -> dict:
if system != "Windows":
return {"start_new_session": True}
flags = (getattr(subprocess, "DETACHED_PROCESS", 0)
| getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0))
return {"creationflags": flags} if flags else {}
def _wait_for_browser_debug_ready_or_exit(
proc: subprocess.Popen, port: int, timeout: float = 2.0, interval: float = 0.1) -> str:
"""Classify a launched browser as "ready", "exited" or "starting". The grace window only needs
to catch a candidate that exits immediately; slower browsers may still finish starting later."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
# Dual-stack: a squatter on the IPv4 loopback can push the browser to bind [::1] only.
if discover_local_cdp_url(port, timeout=min(interval, 0.2)):
return "ready"
if proc.poll() is not None:
return "exited"
time.sleep(interval)
return "starting"
_LAUNCH_STDERR_LOG = "launch-stderr.log"
_STDERR_TAIL_LIMIT = 2000
@dataclass
class LaunchAttempt:
"""Outcome of one candidate-binary launch attempt."""
binary: str
state: str # "ready" | "starting" | "exited" | "spawn-failed"
returncode: int | None = None
stderr_tail: str = ""
@dataclass
class ChromeDebugLaunch:
"""Result of ``launch_chrome_debug``: ``launched`` = a browser was spawned and is ready or
still starting (NOT a guarantee the CDP port ever opens); ``attempts`` explains *why* not."""
launched: bool = False
attempts: list[LaunchAttempt] = field(default_factory=list)
@property
def hint(self) -> str | None:
"""Best user-facing explanation for a failed/soft launch, if any."""
for attempt in self.attempts:
if attempt.state == "exited" and attempt.returncode == 0:
name = os.path.basename(attempt.binary)
return (
f"{name} exited immediately without opening the debug port — an already-running "
f"{name} instance likely absorbed the launch (Chromium's single-instance "
"behavior). Close ALL of its processes (including background/tray instances) "
"and retry /browser connect.")
for attempt in self.attempts:
if attempt.state == "exited" and attempt.stderr_tail:
return (
f"{os.path.basename(attempt.binary)} exited before the debug port opened: "
f"{attempt.stderr_tail.splitlines()[-1].strip()}")
return None
def _read_stderr_tail(path: str) -> str:
try:
with open(path, "rb") as fh:
return fh.read()[-_STDERR_TAIL_LIMIT:].decode("utf-8", errors="replace").strip()
except OSError:
return ""
def launch_chrome_debug(
port: int = DEFAULT_BROWSER_CDP_PORT, system: str | None = None) -> ChromeDebugLaunch:
"""Launch a Chromium-family browser with remote debugging, trying each candidate in turn. One
that exits before the CDP port opens (crash, singleton forward, bad profile dir) is logged with
exit code + stderr tail and the next is tried."""
system = system or platform.system()
result = ChromeDebugLaunch()
candidates = get_chrome_debug_candidates(system)
if not candidates:
logger.info("browser debug launch: no Chromium-family binary found (system=%s)", system)
return result
data_dir = chrome_debug_data_dir()
os.makedirs(data_dir, exist_ok=True)
stderr_path = os.path.join(data_dir, _LAUNCH_STDERR_LOG)
for candidate in candidates:
try:
with open(stderr_path, "wb") as stderr_file:
proc = subprocess.Popen(
[candidate, *_chrome_debug_args(port)],
stdout=subprocess.DEVNULL, stderr=stderr_file, **_detach_kwargs(system))
except Exception as exc:
result.attempts.append(LaunchAttempt(binary=candidate, state="spawn-failed"))
logger.info("browser debug launch: failed to spawn %s: %s", candidate, exc)
continue
logger.info(
"browser debug launch: spawned %s (pid=%s) with --remote-debugging-port=%d",
candidate, getattr(proc, "pid", None), port)
state = _wait_for_browser_debug_ready_or_exit(proc, port)
attempt = LaunchAttempt(binary=candidate, state=state)
result.attempts.append(attempt)
if state != "exited":
result.launched = True
return result
attempt.returncode = getattr(proc, "returncode", None)
attempt.stderr_tail = _read_stderr_tail(stderr_path)
logger.warning(
"browser debug launch: %s exited (code=%s) before port %d opened%s",
candidate, attempt.returncode, port,
f"; stderr tail: {attempt.stderr_tail}" if attempt.stderr_tail else "")
return result
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
def try_launch_chrome_debug(port: int = DEFAULT_BROWSER_CDP_PORT, system: str | None = None) -> bool:
return launch_chrome_debug(port, system).launched
# ---- END PLUGIN-COMPAT ----