Files
hermes-agent/tools/browser_tool_real_profile.py

388 lines
16 KiB
Python

"""Real-profile local browsing: snapshot the user's default Chromium profile into a
hermes-owned copy, launch the real browser binary on it, and attach agent-browser.
State (``_REAL_PROFILE_SESSION``, ``_real_profile_cdp_lock``, ``_real_profile_cdp_cache``,
``_real_profile_chrome_procs``) lives in ``tools.browser_tool``. Origin-module symbols are
resolved lazily through ``tools.browser_tool`` (``_bt``) so ``patch("tools.browser_tool.X")``
keeps working; never import ``tools.browser_tool`` at import time (cycle).
"""
import os
import re
import subprocess
import sys
import time
from typing import Optional, Tuple
from tools.browser_tool_origin import origin_module as _origin
def _terminate_real_profile_chrome() -> None:
"""Terminate real-browser processes launched for real-profile sessions (idempotent, atexit-safe).
agent-browser only ATTACHED to them, so its own session cleanup never kills them.
"""
_bt = _origin()
while _bt._real_profile_chrome_procs:
proc = _bt._real_profile_chrome_procs.pop()
try:
if proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=5)
except Exception:
proc.kill()
except Exception as e:
_bt.logger.debug("real-profile chrome terminate failed: %s", e)
def _cdp_http_ready(http_cdp: str) -> bool:
"""True when an ``http://host:port`` CDP discovery root answers."""
try:
from hermes_cli.browser_connect import is_browser_debug_ready
return is_browser_debug_ready(http_cdp, timeout=1.0)
except Exception:
return False
def _agent_browser_get_cdp(session_name: str) -> Optional[str]:
"""HTTP CDP discovery root of an agent-browser session (converted from its
``ws://`` cdp-url), or None when it isn't running / unparsable."""
_bt = _origin()
try:
browser_cmd = _bt._find_agent_browser()
except FileNotFoundError:
return None
try:
proc = subprocess.run(
[*_bt._agent_browser_argv(browser_cmd), "--session", session_name, "get", "cdp-url"],
capture_output=True, text=True, timeout=15, env=_bt._build_browser_env(),
)
except (subprocess.SubprocessError, OSError) as e:
_bt.logger.debug("real-profile get cdp-url failed: %s", e)
return None
out = (proc.stdout or "").strip()
m = re.search(r"ws://127\.0\.0\.1:(\d+)/", out)
if not m:
return None
return f"http://127.0.0.1:{m.group(1)}"
def _cdp_on_data_dir(http_cdp: str, data_dir: str) -> bool:
"""True when the CDP endpoint's browser runs on ``data_dir``.
Chrome writes its live debug port to ``DevToolsActivePort`` in the
user-data-dir; a port match proves the browser is our profile copy, not a
throwaway temp dir a raced/stale launch fell back to.
"""
m = re.search(r":(\d+)", http_cdp or "")
if not m:
return False
try:
with open(os.path.join(data_dir, "DevToolsActivePort"), encoding="utf-8") as fh:
port_line = fh.readline().strip()
return port_line == m.group(1)
except OSError:
return False
def _agent_browser_close_session(session_name: str) -> None:
"""Best-effort close of an agent-browser session (stale/wrong-dir cleanup)."""
_bt = _origin()
try:
browser_cmd = _bt._find_agent_browser()
except FileNotFoundError:
return
try:
subprocess.run(
[*_bt._agent_browser_argv(browser_cmd), "--session", session_name, "close"],
capture_output=True, text=True, timeout=15, env=_bt._build_browser_env(),
)
except (subprocess.SubprocessError, OSError) as e:
_bt.logger.debug("real-profile session close failed: %s", e)
_REAL_PROFILE_CHROME_FLAGS = (
"--remote-debugging-port=0",
"--no-first-run",
"--no-default-browser-check",
"--disable-background-networking",
"--disable-component-update",
"--disable-default-apps",
"--disable-hang-monitor",
"--disable-popup-blocking",
"--disable-prompt-on-repost",
"--disable-sync",
"--disable-features=Translate",
"--no-startup-window",
)
def _real_profile_unsupported_reason(browser) -> Optional[str]:
"""Fail-closed message when the detected default browser can't be used, else None.
A recognized pre-release channel (Beta/Dev/Canary) lives in a
channel-specific profile dir we don't resolve; normalizing it to the stable
family would drive a DIFFERENT profile/account (wrong-principal bug), so
refuse rather than guess.
"""
from hermes_cli.browser_connect import UNSUPPORTED_CHANNEL
if browser is None:
return (
"browser.use_real_profile is on, but your default browser is not a "
"supported Chromium browser (Chrome, Edge, Brave, Brave Origin, "
"Chromium). "
"Real-profile browsing requires a Chromium default; set one or turn "
"the toggle off."
)
if browser == UNSUPPORTED_CHANNEL:
return (
"browser.use_real_profile is on, but your default browser is a "
"pre-release Chromium channel (Beta / Dev / Canary), which "
"real-profile browsing does not support. Set your default to a "
"stable Chrome / Edge / Brave / Brave Origin / Chromium, or turn "
"the toggle off."
)
return None
def _real_profile_snapshot_error(err: str) -> str:
"""User-facing message for a failed profile snapshot.
A locked profile surfaces the guidance verbatim (it already says whether
closing is armed) plus the exact approved-close command; the agent must ASK
the user before running it — it quits their browser.
"""
from hermes_cli.browser_connect import _PROFILE_LOCKED_PREFIX
if err and err.startswith(_PROFILE_LOCKED_PREFIX):
body = err[len(_PROFILE_LOCKED_PREFIX):]
return (
body + " To close it (only after the user approves — it "
"quits their browser and loses unsaved tabs), run: "
"`hermes browser close-profile`, then retry."
)
return f"browser.use_real_profile is on, but {err}"
def _launch_real_profile_chrome(real_binary: str, copy_dir: str) -> Tuple[Optional[int], Optional[str]]:
"""Launch the user's REAL browser binary on the profile COPY; return (debug_port, error).
agent-browser's own launch path force-adds --use-mock-keychain /
--password-store=basic, which makes macOS Chrome drop every
keychain-encrypted cookie — the copy would launch signed out. Launching the
real binary ourselves with NO mock-keychain switches keeps the OS keychain
path intact; agent-browser attaches afterwards via ``--cdp <port>``.
Headless by default: real-profile browsing is a background capability and a
focus-stealing window defeats it. Chrome's NEW headless mode shares the
profile's normal cookie store (legacy --headless does not), and cookie
decryption is unaffected by headless (the drop comes from mock-keychain, not
headless). Users opt into a window via browser.headed / AGENT_BROWSER_HEADED;
on a display-less Linux host we force headless regardless so the launch
doesn't die at startup. Waits for Chrome to write DevToolsActivePort.
"""
_bt = _origin()
port_file = os.path.join(copy_dir, "DevToolsActivePort")
try:
os.unlink(port_file) # stale port from a previous launch confuses reuse probes
except OSError:
pass
chrome_argv = [real_binary, f"--user-data-dir={copy_dir}", *_REAL_PROFILE_CHROME_FLAGS]
_has_display = bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
_want_headed = _bt._is_headed_mode() and (_has_display or not sys.platform.startswith("linux"))
if not _want_headed:
chrome_argv.append("--headless=new")
try:
chrome_proc = subprocess.Popen(
chrome_argv,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
start_new_session=True,
env=_bt._build_browser_env(),
)
except (subprocess.SubprocessError, OSError) as e:
return None, f"browser.use_real_profile is on, but the launch failed: {e}"
_bt._real_profile_chrome_procs.append(chrome_proc)
deadline = time.monotonic() + 30.0
while time.monotonic() < deadline:
try:
with open(port_file, encoding="utf-8") as fh:
line = fh.readline().strip()
if line.isdigit():
return int(line), None
except OSError:
pass
if chrome_proc.poll() is not None:
_bt._terminate_real_profile_chrome()
return None, (
"browser.use_real_profile is on, but Chrome exited during "
"startup (another instance may hold the profile copy)."
)
time.sleep(0.25)
_bt._terminate_real_profile_chrome()
return None, (
"browser.use_real_profile is on, but the real-profile browser "
"did not expose a debug port in time. Retry, or turn the toggle off."
)
def _attach_agent_browser_to_real_profile(port: int, copy_dir: str) -> Tuple[Optional[str], Optional[str]]:
"""Make agent-browser ATTACH to the running Chrome (never launch its own).
Returns ``(http_cdp, error)``. The daemon may answer with the endpoint of a
browser IT spawned (throwaway temp profile) instead of the Chrome we
launched; the DevToolsActivePort file OUR Chrome wrote is authoritative, so
on disagreement we trust ours.
"""
_bt = _origin()
try:
browser_cmd = _bt._find_agent_browser()
except FileNotFoundError as e:
return None, (
"browser.use_real_profile is on, but the local browser engine "
f"(agent-browser) is not installed: {e}"
)
argv = [
*_bt._agent_browser_argv(browser_cmd),
"--session", _bt._REAL_PROFILE_SESSION,
"--cdp", str(port),
"open", "about:blank",
]
try:
proc = subprocess.run(
argv, capture_output=True, text=True,
timeout=_bt._get_open_command_timeout(first_open=True),
env=_bt._build_browser_env(),
)
except subprocess.TimeoutExpired:
return None, (
"browser.use_real_profile is on, but the real-profile browser "
"took too long to start. Retry, or turn the toggle off."
)
except (subprocess.SubprocessError, OSError) as e:
return None, f"browser.use_real_profile is on, but the launch failed: {e}"
if proc.returncode != 0:
tail = (proc.stderr or proc.stdout or "").strip().splitlines()
reason = tail[-1] if tail else f"exit {proc.returncode}"
return None, (
f"browser.use_real_profile is on, but the real-profile browser "
f"failed to start: {reason}"
)
cdp = _bt._agent_browser_get_cdp(_bt._REAL_PROFILE_SESSION)
try:
with open(os.path.join(copy_dir, "DevToolsActivePort"), encoding="utf-8") as fh:
our_port = fh.readline().strip()
m = re.search(r":(\d+)", cdp or "")
if m and m.group(1) != our_port:
cdp = f"http://127.0.0.1:{our_port}"
except (OSError, ValueError):
pass
if not cdp:
return None, (
"browser.use_real_profile is on, but the real-profile browser "
"started without exposing a devtools endpoint. Retry, or turn "
"the toggle off."
)
return cdp, None
def _real_profile_cdp() -> tuple:
"""Resolve ``(cdp_url, error)`` for consented real-profile browsing.
Snapshots the user's default-Chromium profile into a hermes-owned copy
(auth/login state only), launches the real browser binary on that copy and
returns the HTTP CDP endpoint for agent-browser / the browser-use harness to
attach to. The copy is a non-default dir, so it sidesteps the Chrome ≥136
default-profile remote-debugging block and never contends with the user's
running browser.
A single shared agent-browser session is reused across calls (its CDP URL
is cached and re-validated). Returns ``(None, message)`` fail-closed when
the default browser is non-Chromium or the snapshot/launch fails;
``(None, None)`` when consent is off.
"""
_bt = _origin()
if not _bt._use_real_profile():
# Consent is off. A snapshot store from a previous consented run holds
# copies of the user's cookies/logins — delete it so revoking consent
# actually removes the credential copies. Cheap and idempotent.
try:
from hermes_cli.browser_connect import cleanup_real_profile_snapshots
cleanup_real_profile_snapshots()
except Exception as e:
_bt.logger.debug("real-profile cleanup-on-consent-off failed: %s", e)
_bt._real_profile_cdp_cache.pop("cdp", None)
return None, None
# Lightpanda rejects ``--profile`` outright. Detect it BEFORE default-browser
# detection so even a host with no Chromium default reports the actionable
# conflict (the engine setting) rather than a generic launch failure.
if _bt._using_lightpanda_engine():
return None, (
"browser.use_real_profile is on, but browser.engine is set to "
"'lightpanda', which cannot load a real Chromium profile. Set "
"browser.engine to 'auto' or 'chrome' to use real-profile browsing, "
"or turn the toggle off."
)
from hermes_cli.browser_connect import (
chromium_executable, detect_default_chromium, real_profile_copy_dir, snapshot_real_profile
)
with _bt._real_profile_cdp_lock:
# Reuse a live copy-browser from an earlier call this process made.
cached = _bt._real_profile_cdp_cache.get("cdp")
if cached and _bt._cdp_http_ready(cached):
return cached, None
_bt._real_profile_cdp_cache.pop("cdp", None)
browser = detect_default_chromium()
unsupported = _real_profile_unsupported_reason(browser)
if unsupported:
return None, unsupported
# Reuse BEFORE writing anything. A shared copy-browser may already be up
# from a previous hermes process; if it is driving OUR copy dir, hand it
# back untouched. CRITICAL: the snapshot overlay (which truncates and
# rewrites Cookies / Login Data) must NOT run while that browser holds
# the user-data-dir open — doing so corrupts the live databases. So
# resolve the copy dir as a PATH only (no copy), probe reuse, and return
# early on a hit; the overlay happens solely on the relaunch path below.
copy_dir = real_profile_copy_dir(browser)
existing = _bt._agent_browser_get_cdp(_bt._REAL_PROFILE_SESSION)
if existing and _bt._cdp_http_ready(existing) and _bt._cdp_on_data_dir(existing, copy_dir):
_bt._real_profile_cdp_cache["cdp"] = existing
return existing, None
if existing:
# Stale/wrong-dir session (throwaway-temp fallback, or an old copy):
# close it so nothing holds the dir open before we overlay + relaunch.
_bt._agent_browser_close_session(_bt._REAL_PROFILE_SESSION)
# No live browser owns the dir now — safe to (re)snapshot + overlay.
snap_dir, err = snapshot_real_profile(browser)
if err or not snap_dir:
return None, _real_profile_snapshot_error(err)
copy_dir = snap_dir
real_binary = chromium_executable(browser)
if real_binary is None:
return None, (
"browser.use_real_profile is on, but the real browser binary for "
f"'{browser}' could not be found. Reinstall it or turn the toggle off."
)
port, err = _launch_real_profile_chrome(real_binary, copy_dir)
if port is None:
return None, err
cdp, err = _attach_agent_browser_to_real_profile(port, copy_dir)
if not cdp:
return None, err
_bt._real_profile_cdp_cache["cdp"] = cdp
_bt.logger.info("real-profile browser ready for %s at %s (%s)", browser, cdp, copy_dir)
return cdp, None