1460 lines
60 KiB
Python
1460 lines
60 KiB
Python
"""Shared helpers for attaching Hermes to a local Chromium-family CDP port."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import ntpath
|
|
import os
|
|
import platform
|
|
import posixpath
|
|
import re
|
|
import shlex
|
|
import shutil
|
|
import subprocess
|
|
import time
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
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}"
|
|
|
|
_DARWIN_APPS = (
|
|
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
|
"/Applications/Brave Origin.app/Contents/MacOS/Brave Origin",
|
|
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
)
|
|
|
|
_WINDOWS_BROWSER_GROUPS = (
|
|
(("chrome.exe", "chrome"), (("Google", "Chrome", "Application", "chrome.exe"),)),
|
|
(
|
|
("chromium.exe", "chromium"),
|
|
(("Chromium", "Application", "chrome.exe"), ("Chromium", "Application", "chromium.exe")),
|
|
),
|
|
(("brave.exe", "brave"), (("BraveSoftware", "Brave-Browser", "Application", "brave.exe"),)),
|
|
(
|
|
("brave-origin.exe", "brave-origin"),
|
|
(
|
|
("BraveSoftware", "Brave-Origin", "Application", "brave.exe"),
|
|
("BraveSoftware", "Brave-Origin", "Application", "brave-origin.exe"),
|
|
),
|
|
),
|
|
(("msedge.exe", "msedge"), (("Microsoft", "Edge", "Application", "msedge.exe"),)),
|
|
)
|
|
|
|
_WINDOWS_BIN_NAMES = tuple(name for names, _ in _WINDOWS_BROWSER_GROUPS for name in names)
|
|
_WINDOWS_INSTALL_PARTS = tuple(parts for _, group in _WINDOWS_BROWSER_GROUPS for parts in group)
|
|
|
|
_LINUX_BROWSER_GROUPS = (
|
|
(
|
|
("google-chrome", "google-chrome-stable"),
|
|
("/opt/google/chrome/chrome", "/usr/bin/google-chrome", "/usr/bin/google-chrome-stable"),
|
|
),
|
|
(
|
|
("chromium-browser", "chromium"),
|
|
("/usr/bin/chromium-browser", "/usr/bin/chromium"),
|
|
),
|
|
(
|
|
("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",
|
|
),
|
|
),
|
|
# Brave Origin is a SEPARATE product identity (side-by-side installable
|
|
# with Brave), so it gets its own group: the executable fallback in
|
|
# chromium_executable() matches by group, and mixing Origin binaries into
|
|
# the brave group would let a "brave" lookup resolve to the Origin binary
|
|
# (or vice versa) — driving the wrong browser's profile.
|
|
(
|
|
("brave-origin", "brave-origin-nightly"),
|
|
(
|
|
"/usr/bin/brave-origin",
|
|
"/opt/brave.com/brave-origin/brave-origin",
|
|
"/opt/brave.com/brave-origin-nightly/brave-origin",
|
|
),
|
|
),
|
|
(
|
|
("microsoft-edge", "microsoft-edge-stable", "msedge"),
|
|
(
|
|
"/usr/bin/microsoft-edge",
|
|
"/usr/bin/microsoft-edge-stable",
|
|
"/opt/microsoft/msedge/microsoft-edge",
|
|
"/opt/microsoft/msedge/msedge",
|
|
),
|
|
),
|
|
)
|
|
|
|
_LINUX_BIN_NAMES = tuple(name for names, _ in _LINUX_BROWSER_GROUPS for name in names)
|
|
_LINUX_INSTALL_PATHS = tuple(path for _, paths in _LINUX_BROWSER_GROUPS for path in paths)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Real-profile (default Chromium) resolution
|
|
#
|
|
# Used by the browser tool's ``browser.use_real_profile`` consent path: when a
|
|
# local Chromium is launched, point agent-browser at the user's REAL default
|
|
# browser profile (``--profile <user-data-dir>`` + ``--executable-path``) so
|
|
# their live logins/cookies are available. Only Chromium-family browsers are
|
|
# supported; a non-Chromium default (Firefox, Safari) resolves to None and the
|
|
# caller fails closed with a clear message.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Canonical Chromium browser keys we support for real-profile driving.
|
|
# ``brave-origin`` is Brave's standalone paid build: same Chromium core, but a
|
|
# fully distinct install identity (BraveSoftware/Brave-Origin product path,
|
|
# ``BraveOHTML`` ProgId, ``com.brave.Browser.origin`` bundle id) so it
|
|
# side-by-side installs with regular Brave — its profile is NOT under
|
|
# Brave-Browser and must never be conflated with the ``brave`` key.
|
|
_CHROMIUM_BROWSERS = ("chrome", "edge", "brave", "chromium", "brave-origin")
|
|
|
|
# Windows UserChoice ProgId prefixes → canonical browser key. Matched
|
|
# case-insensitively by prefix so version suffixes (e.g. ``ChromeHTML.X``)
|
|
# still resolve to STABLE. Pre-release channels have their own ProgIds and
|
|
# MUST be matched first (see _WINDOWS_CHANNEL_PROGIDS) so they are never
|
|
# swallowed into the stable family — driving the wrong profile is a
|
|
# wrong-principal bug (#95549 invariant).
|
|
_WINDOWS_PROGID_MAP = (
|
|
("chromehtml", "chrome"),
|
|
("msedgehtm", "edge"),
|
|
# Brave Origin stable is ``BraveOHTML`` (brave-core install_static). Listed
|
|
# before ``bravehtml`` for clarity; the prefixes don't collide either way.
|
|
("braveohtml", "brave-origin"),
|
|
("bravehtml", "brave"),
|
|
("chromiumhtm", "chromium"),
|
|
)
|
|
|
|
# Pre-release ProgId prefixes we recognize but do NOT support (their profiles
|
|
# live in channel-specific dirs the resolver tables don't carry). Matched
|
|
# BEFORE the stable map; a hit fails closed rather than resolving to stable.
|
|
# ``ChromeBHTML`` = Beta, ``ChromeDHTML`` = Dev, ``ChromeSSHTML`` = Canary
|
|
# (SxS); ``MSEdgeBHTML`` / ``MSEdgeDHTML`` / ``MSEdgeCHTML`` = Edge channels.
|
|
_WINDOWS_CHANNEL_PROGIDS = (
|
|
"chromebhtml", "chromedhtml", "chromesshtml", "chromecanaryhtml",
|
|
"msedgebhtml", "msedgedhtml", "msedgechtml",
|
|
"bravebetahtml", "bravenightlyhtml",
|
|
# Brave Origin channels (brave-core install_static): Beta=BraveOBHTML,
|
|
# Dev=BraveODHTML, Nightly/SxS=BraveOSHTM (no trailing L — 10-char cap).
|
|
"braveobhtml", "braveodhtml", "braveoshtm",
|
|
)
|
|
|
|
# Linux xdg default-web-browser .desktop name fragments → canonical STABLE key.
|
|
# Includes the Flatpak application ids (``com.google.Chrome.desktop`` etc.),
|
|
# which share none of the native package name fragments. Anchored so a channel
|
|
# .desktop (``google-chrome-beta``, ``com.google.chrome.beta``) does NOT match
|
|
# the stable fragment — channels are caught by _LINUX_CHANNEL_FRAGMENTS first.
|
|
_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 —
|
|
# otherwise an Origin default resolves to stable Brave and real-profile
|
|
# mode drives a DIFFERENT browser's profile (wrong-principal, #95549).
|
|
("brave-origin", "brave-origin"),
|
|
("brave", "brave"),
|
|
("microsoft-edge", "edge"),
|
|
("com.microsoft.edge", "edge"),
|
|
("msedge", "edge"),
|
|
)
|
|
|
|
# Non-stable Linux channel .desktop fragments — recognized, unsupported.
|
|
# Checked before the stable map; a hit fails closed.
|
|
_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 → canonical STABLE key. EXACT match (not
|
|
# prefix): ``com.google.chrome.beta`` must not be read as ``com.google.chrome``.
|
|
_DARWIN_BUNDLE_MAP = (
|
|
("com.google.chrome", "chrome"),
|
|
("com.microsoft.edgemac", "edge"),
|
|
("com.brave.browser", "brave"),
|
|
# Brave Origin reuses the Brave bundle id with an ``.origin`` suffix
|
|
# (Homebrew cask: com.brave.Browser.origin). Exact matching keeps it from
|
|
# ever being read as plain ``com.brave.browser``.
|
|
("com.brave.browser.origin", "brave-origin"),
|
|
("org.chromium.chromium", "chromium"),
|
|
)
|
|
|
|
# Non-stable macOS channel bundle ids — recognized, unsupported. Checked first.
|
|
_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 returned when the OS default is a recognized-but-unsupported
|
|
# Chromium CHANNEL (Beta/Dev/Canary). Distinct from None (non-Chromium) so the
|
|
# caller fails closed with a channel-specific message instead of driving the
|
|
# stable profile of a different account.
|
|
UNSUPPORTED_CHANNEL = "__unsupported_channel__"
|
|
|
|
|
|
def _real_profile_relparts(browser: str) -> tuple:
|
|
"""(mac_support_subdir, windows_localappdata_parts, linux_config_name)."""
|
|
return {
|
|
"chrome": (
|
|
("Google", "Chrome"),
|
|
("Google", "Chrome", "User Data"),
|
|
"google-chrome",
|
|
),
|
|
"edge": (
|
|
("Microsoft Edge",),
|
|
("Microsoft", "Edge", "User Data"),
|
|
"microsoft-edge",
|
|
),
|
|
"brave": (
|
|
("BraveSoftware", "Brave-Browser"),
|
|
("BraveSoftware", "Brave-Browser", "User Data"),
|
|
"BraveSoftware/Brave-Browser",
|
|
),
|
|
"chromium": (
|
|
("Chromium",),
|
|
("Chromium", "User Data"),
|
|
"chromium",
|
|
),
|
|
"brave-origin": (
|
|
("BraveSoftware", "Brave-Origin"),
|
|
("BraveSoftware", "Brave-Origin", "User Data"),
|
|
"BraveSoftware/Brave-Origin",
|
|
),
|
|
}[browser]
|
|
|
|
|
|
def real_profile_data_dir(browser: str, system: str | None = None) -> str | None:
|
|
"""Return the default user-data-dir for a Chromium ``browser`` on ``system``.
|
|
|
|
Returns None for unknown browsers. On Linux the native ($XDG_CONFIG_HOME),
|
|
snap and Flatpak locations are tried and the first existing one wins; the
|
|
native path is returned when none exists so the caller's error names it.
|
|
Darwin/Windows paths are not stat'ed. Paths are built with the TARGET
|
|
system's separator (posix for Darwin/Linux, backslash for Windows) so an
|
|
explicit ``system`` argument resolves correctly regardless of the host OS.
|
|
"""
|
|
if browser not in _CHROMIUM_BROWSERS:
|
|
return None
|
|
system = system or platform.system()
|
|
mac_parts, win_parts, linux_name = _real_profile_relparts(browser)
|
|
home = os.path.expanduser("~")
|
|
if system == "Darwin":
|
|
return posixpath.join(home, "Library", "Application Support", *mac_parts)
|
|
if system == "Windows":
|
|
local = os.environ.get("LOCALAPPDATA") or ntpath.join(home, "AppData", "Local")
|
|
return ntpath.join(local, *win_parts)
|
|
# Linux / other POSIX
|
|
config = os.environ.get("XDG_CONFIG_HOME") or posixpath.join(home, ".config")
|
|
candidates = [posixpath.join(config, *linux_name.split("/"))]
|
|
snap_parts = _LINUX_SNAP_PROFILE_PARTS.get(browser)
|
|
if snap_parts:
|
|
candidates.append(posixpath.join(home, *snap_parts))
|
|
flatpak_id = _LINUX_FLATPAK_IDS.get(browser)
|
|
if flatpak_id:
|
|
candidates.append(
|
|
posixpath.join(home, ".var", "app", flatpak_id, "config", *linux_name.split("/"))
|
|
)
|
|
for candidate in candidates:
|
|
if os.path.isdir(candidate):
|
|
return candidate
|
|
return candidates[0]
|
|
|
|
|
|
def chromium_executable(browser: str, system: str | None = None) -> str | None:
|
|
"""Return the first present executable for a Chromium ``browser``."""
|
|
if browser not in _CHROMIUM_BROWSERS:
|
|
return None
|
|
system = system or platform.system()
|
|
|
|
def first_present(paths: tuple) -> str | None:
|
|
for p in paths:
|
|
if p and os.path.isfile(p):
|
|
return p
|
|
return None
|
|
|
|
if system == "Darwin":
|
|
app = {
|
|
"chrome": "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
"chromium": "/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
"brave": "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
|
"brave-origin": "/Applications/Brave Origin.app/Contents/MacOS/Brave Origin",
|
|
"edge": "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
}[browser]
|
|
return app if os.path.isfile(app) else None
|
|
if system == "Windows":
|
|
groups = {
|
|
"chrome": (("Google", "Chrome", "Application", "chrome.exe"),),
|
|
"chromium": (("Chromium", "Application", "chrome.exe"), ("Chromium", "Application", "chromium.exe")),
|
|
"brave": (("BraveSoftware", "Brave-Browser", "Application", "brave.exe"),),
|
|
"brave-origin": (
|
|
("BraveSoftware", "Brave-Origin", "Application", "brave.exe"),
|
|
("BraveSoftware", "Brave-Origin", "Application", "brave-origin.exe"),
|
|
),
|
|
"edge": (("Microsoft", "Edge", "Application", "msedge.exe"),),
|
|
}[browser]
|
|
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")),
|
|
]
|
|
cands = tuple(os.path.join(base, *parts) for base in bases for parts in groups)
|
|
return first_present(cands)
|
|
# Linux
|
|
linux = {
|
|
"chrome": ("google-chrome", "google-chrome-stable"),
|
|
"chromium": ("chromium-browser", "chromium"),
|
|
"brave": ("brave-browser", "brave-browser-stable", "brave"),
|
|
"brave-origin": ("brave-origin",),
|
|
"edge": ("microsoft-edge", "microsoft-edge-stable"),
|
|
}[browser]
|
|
for name in linux:
|
|
found = shutil.which(name)
|
|
if found:
|
|
return found
|
|
# fall back to the known absolute paths from the launch tables
|
|
for names, paths in _LINUX_BROWSER_GROUPS:
|
|
if any(n in linux for n in names):
|
|
hit = first_present(tuple(paths))
|
|
if hit:
|
|
return hit
|
|
return None
|
|
|
|
|
|
def _detect_default_windows() -> str | None:
|
|
try:
|
|
import winreg # type: ignore
|
|
except Exception:
|
|
return None
|
|
try:
|
|
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:
|
|
return None
|
|
low = str(prog_id or "").lower()
|
|
# Channels first: a recognized Beta/Dev/Canary ProgId must fail closed, not
|
|
# fall through to a stable prefix match and drive the stable profile.
|
|
for chan in _WINDOWS_CHANNEL_PROGIDS:
|
|
if low.startswith(chan):
|
|
return UNSUPPORTED_CHANNEL
|
|
for prefix, browser in _WINDOWS_PROGID_MAP:
|
|
if low.startswith(prefix):
|
|
return browser
|
|
return None
|
|
|
|
|
|
_LS_HANDLERS_READER = (
|
|
"defaults",
|
|
"read",
|
|
"com.apple.LaunchServices/com.apple.launchservices.secure",
|
|
"LSHandlers",
|
|
)
|
|
|
|
|
|
def _launchservices_https_handler(dump: str) -> str | None:
|
|
"""Return the bundle id registered for the ``https`` URL scheme.
|
|
|
|
``dump`` is the ``defaults read … LSHandlers`` output: an array of
|
|
``{ … }`` dictionaries, one per handler. Only the entry whose
|
|
``LSHandlerURLScheme`` is ``https`` counts — a browser registered for
|
|
another scheme or a file type must not be mistaken for the default.
|
|
Returns None when no https handler is recorded, which is what macOS
|
|
stores while Safari (the implicit default) has never been replaced.
|
|
"""
|
|
entries: list[str] = []
|
|
depth = 0
|
|
buf: list[str] = []
|
|
for ch in dump:
|
|
if ch == "{":
|
|
depth += 1
|
|
if depth == 1:
|
|
buf = []
|
|
continue
|
|
elif ch == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
entries.append("".join(buf))
|
|
continue
|
|
if 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 (e.g. LSHandlerRoleAll = "7559.97";), not
|
|
# the "-" placeholder older releases used. 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.
|
|
for role in re.findall(r'lshandlerrole(?:all|viewer)\s*=\s*"?([a-z0-9.\-]+)"?\s*;', low):
|
|
if role != "-":
|
|
return role
|
|
return None
|
|
return None
|
|
|
|
|
|
def _detect_default_darwin() -> str | None:
|
|
try:
|
|
out = subprocess.run(
|
|
list(_LS_HANDLERS_READER),
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
timeout=5,
|
|
).stdout
|
|
except Exception:
|
|
return None
|
|
bundle = _launchservices_https_handler(out)
|
|
if not bundle:
|
|
return None
|
|
b = bundle.lower()
|
|
# Channels first (exact): a Beta/Dev/Canary bundle must fail closed.
|
|
if b in _DARWIN_CHANNEL_BUNDLES:
|
|
return UNSUPPORTED_CHANNEL
|
|
for frag, browser in _DARWIN_BUNDLE_MAP:
|
|
if b == frag:
|
|
return browser
|
|
# A non-Chromium https handler (Safari, Firefox, Arc, …) or an unknown
|
|
# channel bundle: fail closed. No "first installed Chromium wins" fallback
|
|
# — that would drive a browser the user never made their default.
|
|
return None
|
|
|
|
|
|
def _detect_default_linux() -> str | None:
|
|
try:
|
|
out = subprocess.run(
|
|
["xdg-settings", "get", "default-web-browser"],
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
timeout=5,
|
|
).stdout.strip().lower()
|
|
except Exception:
|
|
out = ""
|
|
# Channels first: ``google-chrome-beta.desktop`` contains the stable
|
|
# ``google-chrome`` fragment, so a substring match would drive stable.
|
|
# Catch recognized channels and fail closed instead.
|
|
for frag in _LINUX_CHANNEL_FRAGMENTS:
|
|
if frag in out:
|
|
return UNSUPPORTED_CHANNEL
|
|
for frag, browser in _LINUX_DESKTOP_MAP:
|
|
if frag in out:
|
|
return browser
|
|
return None
|
|
|
|
|
|
def detect_default_chromium(system: str | None = None) -> str | None:
|
|
"""Return the canonical key of the default Chromium browser, or None.
|
|
|
|
None means the default browser is non-Chromium (Firefox, Safari) or could
|
|
not be determined — the caller fails closed rather than guessing.
|
|
"""
|
|
system = system or platform.system()
|
|
if system == "Windows":
|
|
return _detect_default_windows()
|
|
if system == "Darwin":
|
|
return _detect_default_darwin()
|
|
return _detect_default_linux()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Real-profile SNAPSHOT launch
|
|
#
|
|
# The consent path (``browser.use_real_profile``) never drives the live
|
|
# default user-data-dir. Chromium ≥136 (Google-branded builds) refuses
|
|
# remote debugging on the default dir no matter who launches it, and the
|
|
# live dir is usually held by the user's running browser (SingletonLock).
|
|
# Instead we snapshot the real profile into ``~/.hermes/browser-profile/``
|
|
# — a non-default dir Chrome will happily debug, that never contends with
|
|
# the user's browser — launch the user's real binary on the copy with a
|
|
# devtools port, and hand the CDP URL to whichever browser lane is active
|
|
# (Browser Use CLI or the built-in tools). We launch the browser ourselves
|
|
# precisely so NO mock-keychain/basic-store switches are added: cookies
|
|
# encrypted with the OS keyring (gnome-keyring / kwallet / macOS Keychain)
|
|
# decrypt exactly like they do in the user's own browser.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Directory names excluded from the profile snapshot: caches/telemetry AND the
|
|
# heavy, replay-prone state that hangs a fresh Chromium's renderer (extensions
|
|
# and their service workers spin up on launch and wedge JS eval; IndexedDB /
|
|
# GPUCache add hundreds of MB for nothing). We keep ONLY auth/login state
|
|
# (cookies, Login Data, Web Data, Preferences, Local State) — the point of the
|
|
# feature — which turns a multi-hundred-MB profile into a few MB.
|
|
_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",
|
|
)
|
|
|
|
# Small, auth-bearing files re-synced from the live profile on EVERY consented
|
|
# launch (the full tree is only copied when the snapshot doesn't exist yet).
|
|
# Paths here are RELATIVE TO A PROFILE DIR (Default, "Profile 6", …) — the
|
|
# caller resolves which source profile is active and mirrors these into the
|
|
# copy's ``Default`` so the launched Chromium (which opens ``Default``) lands
|
|
# on the user's real signed-in session. No ``-journal``/``-wal`` sidecars: the
|
|
# SQLite DBs are copied via the online-backup API (see _copy_auth_file), which
|
|
# produces a self-contained DB with committed state folded in — copying a
|
|
# stale raw journal on top of that would corrupt it.
|
|
_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:
|
|
"""Return the profile dir Chrome last used (``Local State`` → profile.last_used).
|
|
|
|
Chromium opens ``Default`` inside a user-data-dir unless told otherwise, but
|
|
the user's signed-in session usually lives in whichever profile they
|
|
actually browse (``Profile 6`` etc.). We read that here and mirror its auth
|
|
into the copy's ``Default`` so the launched browser is signed in. Falls back
|
|
to ``Default`` when Local State is missing/unreadable or names a profile
|
|
dir that doesn't exist.
|
|
"""
|
|
import json
|
|
|
|
try:
|
|
with open(os.path.join(src, "Local State"), encoding="utf-8", errors="replace") as fh:
|
|
state = json.load(fh)
|
|
last = ((state.get("profile") or {}).get("last_used")) or "Default"
|
|
except (OSError, ValueError, AttributeError):
|
|
last = "Default"
|
|
if not isinstance(last, str) or not os.path.isdir(os.path.join(src, last)):
|
|
return "Default"
|
|
return last
|
|
|
|
|
|
def _secure_snapshot_root(path: str) -> None:
|
|
"""Lock down a snapshot dir through Hermes' canonical secret-store policy.
|
|
|
|
The snapshot holds copies of the user's Cookies / Login Data, so it is a
|
|
credential store and must get the same owner-only permissions (and
|
|
managed-mode / NixOS group-share carve-out, HERMES_UID/GID ownership) as
|
|
every other Hermes secret dir — via ``hermes_cli.config._secure_dir``,
|
|
not a bespoke chmod. Deferred import avoids a config↔browser import cycle.
|
|
"""
|
|
try:
|
|
from hermes_cli.config import _secure_dir
|
|
|
|
_secure_dir(path)
|
|
except Exception as e: # never block a launch on a permissions best-effort
|
|
logger.debug("could not secure real-profile snapshot dir %s: %s", path, e)
|
|
|
|
|
|
def _secure_snapshot_contents(dst: str) -> None:
|
|
"""Owner-only modes for every file/dir INSIDE the snapshot (#96729).
|
|
|
|
``_secure_snapshot_root`` covers the top-level dirs, but the copied files
|
|
inherit the umask: ``shutil.copy2`` preserves the source's mode (Chrome
|
|
keeps its own profile 0644 inside a 0700 dir) and ``sqlite3.connect`` on
|
|
the backup destination creates plain umask files — so Cookies / Login
|
|
Data / Web Data landed 0644 and any nested profile subdir 0755. The 0700
|
|
parents contain the damage by default, but the documented
|
|
``HERMES_HOME_MODE`` hatch (nginx traversal) makes world-readable children
|
|
a real exposure — these are the user's live session cookies. Reconciled
|
|
through the house helpers (``_secure_dir`` / ``_secure_file``) on EVERY
|
|
snapshot pass, so older snapshots heal too; both helpers already carry the
|
|
managed-mode / container carve-outs. Best-effort: never blocks a launch.
|
|
"""
|
|
try:
|
|
from hermes_cli.config import _secure_dir, _secure_file
|
|
|
|
for root, dirs, files in os.walk(dst):
|
|
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: # best-effort, same policy as _secure_snapshot_root
|
|
logger.debug("could not secure real-profile snapshot contents %s: %s", dst, e)
|
|
|
|
|
|
# Auth files that are SQLite databases: on Windows a running Chrome holds these
|
|
# with an exclusive lock, so a raw file copy raises WinError 32 ("being used by
|
|
# another process") and a naive best-effort skip leaves the copy signed-out.
|
|
# These are copied via SQLite's online-backup API instead, which reads a
|
|
# consistent committed snapshot while the lock is held. Matched by basename.
|
|
_SQLITE_AUTH_DBS = frozenset({
|
|
"Cookies", "Login Data", "Login Data For Account", "Web Data",
|
|
})
|
|
|
|
|
|
def _copy_auth_file(src_file: str, dst_file: str) -> bool:
|
|
"""Copy one auth file, lock-aware. Returns True on success.
|
|
|
|
For SQLite DBs (Cookies/Login Data/…), use the online-backup API so the
|
|
copy works even while the browser holds the file's write lock (Windows).
|
|
Everything else is a plain copy. A DB whose backup fails falls through to a
|
|
raw copy attempt; only if BOTH fail do we report failure to the caller.
|
|
"""
|
|
os.makedirs(os.path.dirname(dst_file), exist_ok=True)
|
|
if os.path.basename(src_file) in _SQLITE_AUTH_DBS:
|
|
# On a live Chrome on macOS the profile holds
|
|
# its DBs in a state where mode=ro WITHOUT immutable=1 can hang the
|
|
# connect/backup indefinitely (the sqlite busy-timeout never fires
|
|
# because the block happens inside lock negotiation). immutable=1
|
|
# reads instantly and is correct here: we want a committed snapshot of
|
|
# a file another process owns, not coordinated writes. A torn read
|
|
# raises → falls through to the plain-copy fallback below.
|
|
for uri in (
|
|
f"file:{src_file}?mode=ro&immutable=1",
|
|
f"file:{src_file}?mode=ro",
|
|
):
|
|
try:
|
|
import sqlite3
|
|
|
|
# Short busy timeout so a truly wedged DB fails fast rather
|
|
# than hanging the launch.
|
|
source = sqlite3.connect(uri, uri=True, timeout=5)
|
|
try:
|
|
out = sqlite3.connect(dst_file)
|
|
try:
|
|
with out:
|
|
source.backup(out)
|
|
finally:
|
|
out.close()
|
|
finally:
|
|
source.close()
|
|
return True
|
|
except Exception as e:
|
|
logger.debug("real-profile: sqlite-backup of %s failed (%s); trying next mode",
|
|
src_file, e)
|
|
# Non-DB file, or DB whose backup failed: raw copy.
|
|
try:
|
|
shutil.copy2(src_file, dst_file)
|
|
return True
|
|
except OSError as e:
|
|
logger.debug("real-profile: could not copy %s: %s", src_file, e)
|
|
return False
|
|
|
|
|
|
def _mirror_profile_auth(src: str, dst: str, source_profile: str) -> int:
|
|
"""Copy ``source_profile``'s auth files into the copy's ``Default`` slot.
|
|
|
|
agent-browser launches ``Default`` in the copied user-data-dir; mirroring
|
|
the active source profile's cookies/logins/prefs there is what makes the
|
|
session actually signed in (the LinkedIn/Gmail "logged out" bug when the
|
|
real session lives in a non-Default profile). Lock-aware (Windows), so a
|
|
running Chrome doesn't block the cookie DBs.
|
|
|
|
Returns the number of DB auth files that could NOT be copied (0 = clean).
|
|
"""
|
|
dst_default = os.path.join(dst, "Default")
|
|
failed_dbs = 0
|
|
for rel in _AUTH_REFRESH_PROFILE_FILES:
|
|
s = os.path.join(src, source_profile, rel)
|
|
if not os.path.isfile(s):
|
|
continue
|
|
ok = _copy_auth_file(s, os.path.join(dst_default, rel))
|
|
if not ok and os.path.basename(rel) in _SQLITE_AUTH_DBS:
|
|
failed_dbs += 1
|
|
return failed_dbs
|
|
|
|
|
|
_SNAPSHOT_DONE_MARKER = ".hermes-snapshot-complete"
|
|
|
|
# Prefix stamped on the "profile is locked" error so the calling layer can
|
|
# recognize it as the specific needs-the-browser-closed condition (vs a generic
|
|
# snapshot failure) and surface the close-with-approval flow.
|
|
_PROFILE_LOCKED_PREFIX = "[profile-locked] "
|
|
|
|
|
|
def _profile_cookie_db(src: str, source_profile: str) -> str | None:
|
|
"""Path to the active profile's cookie DB (modern Network/ first)."""
|
|
for rel in (os.path.join("Network", "Cookies"), "Cookies"):
|
|
cand = os.path.join(src, source_profile, rel)
|
|
if os.path.isfile(cand):
|
|
return cand
|
|
return None
|
|
|
|
|
|
def _profile_is_locked(src: str, source_profile: str) -> bool:
|
|
"""True when the active profile's cookie DB can't be opened (browser running).
|
|
|
|
A running browser holds Cookies with a deny-all share mode on Windows
|
|
(proven live: even CreateFile with all share flags fails), so a plain open
|
|
raises PermissionError. This is a FAST probe — one open attempt, no copy —
|
|
used to fail closed BEFORE the heavy snapshot so a locked profile can never
|
|
hang the launch on a blocking file op. POSIX has no mandatory locking, so
|
|
the open succeeds and this returns False (copy proceeds normally).
|
|
"""
|
|
db = _profile_cookie_db(src, source_profile)
|
|
if not db:
|
|
return False # nothing to lock; let the copy path handle "no cookies"
|
|
try:
|
|
with open(db, "rb"):
|
|
return False
|
|
except PermissionError:
|
|
return True
|
|
except OSError:
|
|
# Other errors (transient) — don't declare locked; let the copy try.
|
|
return False
|
|
|
|
|
|
def _real_profile_pin() -> str | None:
|
|
"""Pinned source profile dir name from ``browser.real_profile_pin``.
|
|
|
|
Natively the snapshot follows Chrome's
|
|
``profile.last_used`` — whichever profile the user touched last. On a
|
|
machine with a work profile (HM) and a personal profile, that roulette
|
|
can silently give the agent the wrong identity. When set (e.g.
|
|
``"Profile 2"``), the snapshot ALWAYS copies that profile regardless of
|
|
last_used. Unset → native last_used behavior, unchanged.
|
|
"""
|
|
try:
|
|
from hermes_cli.config import read_raw_config
|
|
|
|
cfg = read_raw_config()
|
|
browser_cfg = cfg.get("browser", {})
|
|
if isinstance(browser_cfg, dict):
|
|
pin = browser_cfg.get("real_profile_pin")
|
|
if isinstance(pin, str) and pin.strip():
|
|
return pin.strip()
|
|
except Exception as e:
|
|
logger.debug("could not read real_profile_pin: %s", e)
|
|
return None
|
|
|
|
|
|
def _resolve_source_profile(src: str) -> tuple[str | None, str | None]:
|
|
"""Resolve which source profile to copy: pin first, else last_used.
|
|
|
|
Returns ``(profile_dir_name, error)``. A configured pin that does not
|
|
exist under ``src`` FAILS CLOSED with a fixable message — falling back
|
|
to last_used would silently browse as the wrong identity, which is the
|
|
exact wrong-principal bug this pin exists to prevent.
|
|
"""
|
|
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 "
|
|
f"directory does not exist under {src!r}. Profile directories are "
|
|
"named like 'Default' or 'Profile 2' — list them with: "
|
|
f"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).
|
|
|
|
When true, snapshot_real_profile may terminate a running browser that locks
|
|
the profile. Destructive → default False; the agent gates it on user OK.
|
|
"""
|
|
try:
|
|
from hermes_cli.config import read_raw_config
|
|
|
|
cfg = read_raw_config()
|
|
browser_cfg = cfg.get("browser", {})
|
|
if isinstance(browser_cfg, dict):
|
|
return bool(browser_cfg.get("real_profile_autoclose", False))
|
|
except Exception as e:
|
|
logger.debug("could not read real_profile_autoclose: %s", e)
|
|
return False
|
|
|
|
|
|
def _processes_holding_profile(src: str):
|
|
"""Yield (psutil.Process) instances holding the user-data-dir ``src`` open.
|
|
|
|
Identity discipline mirrors the daemon reaper: a process qualifies only when
|
|
it's a Chromium-family binary AND its command line references THIS
|
|
user-data-dir — so we never terminate an unrelated same-PID process. Any
|
|
ambiguity (unreadable cmdline) is skipped, fail-closed.
|
|
"""
|
|
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
|
|
if not any(b in name for b in browser_bins):
|
|
# Some platforms report a generic name; also accept when the binary
|
|
# in argv[0] looks like a browser.
|
|
argv0 = (cmd[0].lower() if cmd else "")
|
|
if not any(b in argv0 for b in browser_bins):
|
|
continue
|
|
# Binding: the exact user-data-dir must appear in the cmdline
|
|
# (--user-data-dir=<src>), normalized for case/separators.
|
|
if norm not in os.path.normcase(os.path.normpath(joined)) and \
|
|
f"--user-data-dir={src}".lower() not in joined.lower():
|
|
continue
|
|
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. Only call after the user has agreed to close their
|
|
browser — it terminates every Chromium-family process bound to this exact
|
|
user-data-dir (graceful terminate, then kill), so unsaved tab/form state in
|
|
that browser is lost. Returns ``(True, msg)`` once the profile lock actually
|
|
releases, ``(False, msg)`` if processes couldn't be found/killed or the lock
|
|
never released within ``timeout``.
|
|
"""
|
|
try:
|
|
import psutil
|
|
except ImportError:
|
|
return False, "psutil unavailable — cannot close the browser automatically."
|
|
|
|
procs = list(_processes_holding_profile(src))
|
|
if not procs:
|
|
# Nothing we can see holds it. Either already closed, or the holder is
|
|
# a different user / unreadable — caller re-probes the lock.
|
|
return False, "no matching browser process found holding the profile."
|
|
|
|
# Include child processes (renderers, GPU, crashpad) for a full tree kill.
|
|
targets = []
|
|
for p in procs:
|
|
targets.append(p)
|
|
try:
|
|
targets.extend(p.children(recursive=True))
|
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
pass
|
|
# Graceful terminate first.
|
|
for p in targets:
|
|
try:
|
|
p.terminate()
|
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
pass
|
|
gone, alive = psutil.wait_procs(targets, timeout=min(timeout, 8.0))
|
|
for p in alive:
|
|
try:
|
|
p.kill()
|
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
pass
|
|
psutil.wait_procs(alive, timeout=3.0)
|
|
|
|
# The lock releases slightly after the process exits on Windows; poll.
|
|
source_profile, _resolve_err = _resolve_source_profile(src)
|
|
if not source_profile:
|
|
source_profile = _last_used_profile(src)
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
if not _profile_is_locked(src, source_profile):
|
|
return True, f"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 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.
|
|
|
|
Copies only what the launched browser needs: the user-data-dir's
|
|
``Local State`` plus the auth-bearing files of the profile the user
|
|
actually browses (``Local State → profile.last_used``, e.g. ``Profile 6``),
|
|
mirrored into the copy's ``Default`` — which is what agent-browser opens.
|
|
We deliberately do NOT copy every profile dir: non-active profiles are
|
|
unused here and would just be stale credential copies sitting on disk.
|
|
|
|
A ``.hermes-snapshot-complete`` marker is written only after a copy fully
|
|
succeeds; a torn/interrupted first copy (disk full, Ctrl+C) therefore never
|
|
looks "already populated" on the next run — it is redone from scratch.
|
|
|
|
Auth files are re-synced on every call so fresh logins from the user's own
|
|
browsing show up. Locked-file copy errors are tolerated best-effort.
|
|
|
|
Returns ``(copy_dir, None)`` on success, ``(None, error)`` on failure.
|
|
"""
|
|
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 running browser holds the cookie DB
|
|
# deny-all (Windows), and a blocking file op on it can hang the launch for
|
|
# minutes. On POSIX this never trips (no mandatory locking) so
|
|
# copy-while-running still works.
|
|
if _profile_is_locked(src, source_profile):
|
|
# NEVER kill from here. Closing the user's browser is destructive and
|
|
# must be an explicit, per-attempt, user-approved step — not a silent
|
|
# side effect of a snapshot. So we always BLOCK when locked and let the
|
|
# agent decide whether to ask the user to close it (only offered when
|
|
# browser.real_profile_autoclose arms the capability). A subsequent
|
|
# attempt that is still locked 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 None, _PROFILE_LOCKED_PREFIX + msg
|
|
marker = os.path.join(dst, _SNAPSHOT_DONE_MARKER)
|
|
# Only a copy that previously COMPLETED counts as populated. A half-written
|
|
# tree (no marker) is treated as absent and rebuilt — otherwise a torn first
|
|
# copy poisons freshness forever and only ever gets auth overlays.
|
|
populated = os.path.isfile(marker)
|
|
try:
|
|
os.makedirs(dst, exist_ok=True)
|
|
# Secure the snapshot dir AND its browser-profile parent on EVERY
|
|
# launch: a failed first attempt or an older-build dir must still
|
|
# converge to owner-only perms; the parent enumerates every browser we
|
|
# hold cookies for.
|
|
parent = os.path.dirname(dst)
|
|
if parent:
|
|
_secure_snapshot_root(parent)
|
|
_secure_snapshot_root(dst)
|
|
|
|
# Base user-data-dir file the browser reads at startup. Cheap; always
|
|
# re-synced so last_used etc. stay current.
|
|
ls_src = os.path.join(src, "Local State")
|
|
ls_dst = 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)
|
|
|
|
# The copy contains ONLY the mirrored Default dir (that is where the
|
|
# pinned/active profile's auth was mirrored into), but a verbatim
|
|
# Local State still names the SOURCE profile (e.g. last_used="Profile
|
|
# 2", info_cache listing Profile 2/4/7). Chrome therefore opens a
|
|
# missing profile dir and starts SIGNED OUT. Rewrite Local State so
|
|
# the copy's only profile is Default and it is the last-used one.
|
|
# CRITICAL: Default's identity entry must be the SOURCE profile's
|
|
# entry (name + Google account), not the source's own "Default"
|
|
# entry — the Default DIR holds the source profile's cookies. A
|
|
# mismatch (cookies belong to profile B, info_cache names profile A) makes Chrome
|
|
# demand a "Continue as <name>" profile-sign-in reconciliation on
|
|
# every launch and treat the profile as mid-sign-in.
|
|
try:
|
|
import json as _json
|
|
|
|
with open(ls_dst, encoding="utf-8") 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)
|
|
|
|
if not populated:
|
|
# Fresh (or torn-and-rebuilding): drop any partial Default and copy
|
|
# the ACTIVE profile's full dir (minus caches AND the locked auth
|
|
# DBs) into the copy's Default. The SQLite auth DBs are excluded
|
|
# here because a raw copytree of a file a running Chrome holds open
|
|
# raises on Windows; they are copied lock-aware by
|
|
# _mirror_profile_auth below (sqlite online-backup).
|
|
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,
|
|
)
|
|
|
|
# Both paths: copy the active profile's auth DBs into Default,
|
|
# lock-aware (sqlite online-backup) so a running Chrome on Windows
|
|
# doesn't block them. This is also the per-launch fresh-login re-sync.
|
|
failed_dbs = _mirror_profile_auth(src, dst, source_profile)
|
|
if failed_dbs:
|
|
# We could not read the user's cookie/login DBs at all — even the
|
|
# online-backup fallback failed. Rather than launch a silently
|
|
# signed-out session, fail closed with an actionable message.
|
|
return None, (
|
|
f"could not read the '{browser}' profile's login data "
|
|
f"({failed_dbs} database(s) locked). Close {browser} and retry, "
|
|
"or turn browser.use_real_profile off."
|
|
)
|
|
|
|
# Never carry live-instance leftovers into the copy.
|
|
for leftover in ("SingletonLock", "SingletonSocket", "SingletonCookie"):
|
|
try:
|
|
os.unlink(os.path.join(dst, leftover))
|
|
except OSError:
|
|
pass
|
|
# 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)
|
|
# Owner-only modes for everything the copies above created — copy2
|
|
# preserves Chrome's 0644 and sqlite backup files land umask-wide;
|
|
# these are the user's session cookies (#96729). Runs AFTER the marker
|
|
# write so the marker itself is covered, and on every pass so
|
|
# snapshots from older builds heal on their next launch.
|
|
_secure_snapshot_contents(dst)
|
|
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 (all copied credentials).
|
|
|
|
Called when consent is OFF: the copied Cookies / Login Data must not
|
|
outlive the toggle. Best-effort and idempotent — missing dir is fine.
|
|
"""
|
|
root = str(get_hermes_home() / "browser-profile")
|
|
try:
|
|
if os.path.isdir(root):
|
|
shutil.rmtree(root, ignore_errors=True)
|
|
logger.info("real-profile: removed snapshot store %s (consent off)", root)
|
|
except OSError as e:
|
|
logger.debug("real-profile cleanup failed for %s: %s", root, e)
|
|
|
|
|
|
def get_chrome_debug_candidates(system: str) -> list[str]:
|
|
candidates: list[str] = []
|
|
seen: set[str] = set()
|
|
|
|
def add(path: str | None) -> None:
|
|
if not path:
|
|
return
|
|
normalized = os.path.normcase(os.path.normpath(path))
|
|
if normalized in seen or not os.path.isfile(path):
|
|
return
|
|
candidates.append(path)
|
|
seen.add(normalized)
|
|
|
|
def add_windows_install_paths(
|
|
bases: tuple[str | None, ...],
|
|
install_groups: tuple[tuple[tuple[str, ...], tuple[tuple[str, ...], ...]], ...],
|
|
) -> None:
|
|
for _, group in install_groups:
|
|
for base in filter(None, bases):
|
|
for parts in group:
|
|
# Only called with WSL ``/mnt/c/...`` bases — those are
|
|
# POSIX paths regardless of the host OS, so join with
|
|
# posixpath (os.path.join would emit backslashes on nt).
|
|
add(posixpath.join(base, *parts))
|
|
|
|
if system == "Darwin":
|
|
for app in _DARWIN_APPS:
|
|
add(app)
|
|
return candidates
|
|
|
|
if system == "Windows":
|
|
install_bases = (
|
|
os.environ.get("ProgramFiles"),
|
|
os.environ.get("ProgramFiles(x86)"),
|
|
os.environ.get("LOCALAPPDATA"),
|
|
)
|
|
for names, install_parts in _WINDOWS_BROWSER_GROUPS:
|
|
for name in names:
|
|
add(shutil.which(name))
|
|
for base in filter(None, install_bases):
|
|
for parts in install_parts:
|
|
add(os.path.join(base, *parts))
|
|
return candidates
|
|
|
|
for names, paths in _LINUX_BROWSER_GROUPS:
|
|
for name in names:
|
|
add(shutil.which(name))
|
|
for path in paths:
|
|
add(path)
|
|
add_windows_install_paths(("/mnt/c/Program Files", "/mnt/c/Program Files (x86)"), _WINDOWS_BROWSER_GROUPS)
|
|
return candidates
|
|
|
|
|
|
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 is_browser_debug_ready(url: str, timeout: float = 1.0) -> bool:
|
|
"""Return True when ``url`` exposes a reachable Chrome DevTools endpoint."""
|
|
import socket
|
|
import urllib.request
|
|
from urllib.parse import urlparse
|
|
|
|
parsed = 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/"):
|
|
if not parsed.hostname:
|
|
return False
|
|
try:
|
|
with socket.create_connection((parsed.hostname, port), timeout=timeout):
|
|
return True
|
|
except OSError:
|
|
return False
|
|
|
|
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("/")
|
|
for probe in (f"{root}/json/version", f"{root}/json"):
|
|
try:
|
|
with urllib.request.urlopen(probe, timeout=timeout) as resp:
|
|
if 200 <= getattr(resp, "status", 200) < 300:
|
|
return True
|
|
except Exception:
|
|
continue
|
|
return False
|
|
|
|
|
|
# Both loopback literals: Windows (and some Linux setups) can hand the IPv4
|
|
# loopback to one process and the IPv6 loopback to another. Chrome asked to
|
|
# bind :9222 while e.g. VS Code's js-debug holds 127.0.0.1:9222 will come up
|
|
# on [::1]:9222 only — reachable, but 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.
|
|
|
|
Dual-stack discovery: when another application squats the IPv4
|
|
loopback on ``port``, a debug browser launched with
|
|
``--remote-debugging-port`` may bind only ``[::1]``. Probing both
|
|
literals finds it either way. Returns ``None`` when neither
|
|
loopback exposes a CDP discovery endpoint.
|
|
"""
|
|
for host in _LOOPBACK_PROBE_HOSTS:
|
|
url = f"http://{host}:{port}"
|
|
if is_browser_debug_ready(url, timeout=timeout):
|
|
return url
|
|
return None
|
|
|
|
|
|
def local_port_in_use(port: int, timeout: float = 0.5) -> bool:
|
|
"""Return True when either loopback accepts TCP on ``port``.
|
|
|
|
Callers use this AFTER a failed CDP probe to distinguish "port is
|
|
free, we can launch a browser on it" from "another application
|
|
(IDE debugger, dev server) is squatting the port and a launch
|
|
would fight it".
|
|
"""
|
|
import socket
|
|
|
|
for host in _LOOPBACK_SOCKET_HOSTS:
|
|
try:
|
|
with socket.create_connection((host, port), timeout=timeout):
|
|
return True
|
|
except OSError:
|
|
continue
|
|
return False
|
|
|
|
|
|
def find_free_debug_port(preferred: int = DEFAULT_BROWSER_CDP_PORT, attempts: int = 10) -> int:
|
|
"""Return the first port after ``preferred`` bindable on both loopbacks.
|
|
|
|
Used when ``preferred`` is occupied by a non-CDP application: rather
|
|
than launching a browser into a bind conflict, pick a nearby free
|
|
port. Falls back to ``preferred + 1`` if nothing binds (the launch
|
|
will then fail with a clear browser-side error instead of silently
|
|
doing nothing).
|
|
"""
|
|
import socket
|
|
|
|
for port in range(preferred + 1, preferred + 1 + attempts):
|
|
bindable = True
|
|
for family, host in ((socket.AF_INET, "127.0.0.1"), (socket.AF_INET6, "::1")):
|
|
try:
|
|
with socket.socket(family, socket.SOCK_STREAM) as sock:
|
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
sock.bind((host, port))
|
|
except OSError:
|
|
bindable = False
|
|
break
|
|
if bindable:
|
|
return port
|
|
return 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":
|
|
data_dir = chrome_debug_data_dir()
|
|
return (
|
|
f'open -a "Google Chrome" --args --remote-debugging-port={port} '
|
|
f'--user-data-dir="{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 still starting.
|
|
|
|
We only need to wait long enough to catch the common failure mode where a
|
|
candidate binary exists but exits immediately before exposing the CDP port.
|
|
Slower browsers can still finish starting after this grace window.
|
|
"""
|
|
deadline = time.monotonic() + timeout
|
|
|
|
while time.monotonic() < deadline:
|
|
# Dual-stack: a squatter on the IPv4 loopback can push the browser
|
|
# to bind [::1] only — check both so a successful launch is seen.
|
|
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:
|
|
"""Structured result of ``launch_chrome_debug``.
|
|
|
|
``launched`` mirrors the legacy boolean contract: a launch command was
|
|
executed and the browser is ready or still starting (it does NOT
|
|
guarantee the CDP port ever opens). ``attempts`` carries per-candidate
|
|
diagnostics so callers can explain *why* nothing came up.
|
|
"""
|
|
|
|
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:
|
|
data = fh.read()
|
|
return data[-_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, with diagnostics.
|
|
|
|
Tries each detected candidate binary in turn. A candidate that exits
|
|
before the CDP port opens (crash, singleton forward to an existing
|
|
instance, bad profile dir) is logged — with exit code and a stderr tail —
|
|
and the next candidate 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
|
|
|
|
|
|
def try_launch_chrome_debug(port: int = DEFAULT_BROWSER_CDP_PORT, system: str | None = None) -> bool:
|
|
return launch_chrome_debug(port, system).launched
|