Files
Teknium 855f03e3f7 feat(browser): Python Chromium launcher + browser_session layer (API for browser_exec-only)
New tools/ runtime that replaces agent-browser's one job for browser_exec:
- tools/browser_launcher.py: launch/track/stop the packaged Chromium
  (--remote-debugging-port=0 + DevToolsActivePort, per-(home, key)
  ephemeral user-data-dir, headless default, sandbox-bypass detection),
  parent-first stop, host-wide launch records + identity-checked orphan reap,
  attach to a Chromium already running on a persistent profile.
- tools/browser_session.py (+ _lifecycle, _cloud, _cdp, _real_profile,
  _sandbox): session cache per cache key with the existing creation
  precedence; every session carries cdp_url.
- tools/browser_url_policy.py: URL safety + SSRF helpers.
- browser_lightpanda: using_lightpanda_engine / lightpanda_engine_status.
- bot_desktop.browser reads browser.executable_path / browser.user_data_dir;
  env_for_agent is gone (the launcher passes binary + profile explicitly).
2026-10-23 01:41:23 -07:00

113 lines
5.8 KiB
Python

"""The bot's browser on its Bot Desktop: one executable, one persistent user-data-dir per profile.
The agent's Chromium (``tools.browser_session`` → ``tools.browser_launcher``) and the dock's Browser icon a
human clicks must be THE SAME browser — same binary, same ``--user-data-dir`` — or the human logs in to a
jar the bot never sees. Chromium's singleton makes a second launch on the same user-data-dir open a window
in the running instance, which is the hand-over we want in ONE direction. When the human's dock instance is
already up, a launch would be forwarded to it and exit without a DevTools endpoint, so the dock exposes a
debugging port and the launcher ATTACHES to it instead of launching.
"""
from __future__ import annotations
import os
import shutil
from pathlib import Path
from typing import Optional
from tools.bot_desktop import runtime
DISK_CACHE_BYTES = 256 * 1024 * 1024
_SYSTEM_BROWSERS = ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser")
def profile_dir() -> Path:
"""User-data-dir the bot's browser uses on this profile's screen. ``browser.user_data_dir`` pins your own:
``~`` expands, and a relative path is anchored at this profile's HERMES_HOME (where the rest of the screen's
state lives), so ``pin`` means ``<HERMES_HOME>/pin`` and two profiles never share one jar by accident."""
from tools.browser_session_cloud import user_data_dir
override = user_data_dir()
return Path(override) if override else runtime.state_dir() / "browser-profile"
def executable() -> Optional[str]:
"""The Chromium the bot runs: an explicit ``browser.executable_path``, else the packaged Chromium, else a
system Chrome/Chromium. ``None`` when there is none.
Non-root under ``kernel.apparmor_restrict_unprivileged_userns=1`` (Ubuntu 23.10+) flips the order:
Playwright's bundle has no setuid ``chrome_sandbox`` and dies 'FATAL: No usable sandbox!' there, while
a distro chromium ships the helper. The bundle stays the answer when it is the only browser — a dock
icon that fails loudly beats a non-root ``--no-sandbox``.
"""
from tools.browser_session_cloud import executable_path
explicit = executable_path()
if explicit and os.access(explicit, os.X_OK) and not _is_headless_shell(explicit):
return explicit
finders = [_managed_executable, _system_executable]
if not _is_root() and _userns_restricted():
finders.reverse()
return next((exe for find in finders if (exe := find())), None)
def _managed_executable() -> Optional[str]:
from hermes_cli.browser_runtime import chromium_executable
exe = chromium_executable(allow_override=False)
return exe if exe and os.access(exe, os.X_OK) and not _is_headless_shell(exe) else None
def _is_headless_shell(exe: str) -> bool:
"""Playwright's ``chrome-headless-shell`` can drive pages but cannot open a window; trusting such an
override blindly would pin a windowless binary to the dock's Browser icon."""
return "headless" in os.path.basename(exe).lower() or "headless_shell" in exe
def _system_executable() -> Optional[str]:
return next((shutil.which(name) for name in _SYSTEM_BROWSERS if shutil.which(name)), None)
def _is_root() -> bool:
return hasattr(os, "geteuid") and os.geteuid() == 0
def _userns_restricted() -> bool:
from tools.browser_launcher import apparmor_restricts_unprivileged_userns
return apparmor_restricts_unprivileged_userns()
def dock_launch() -> Optional[tuple[str, str]]:
"""``(executable, user_data_dir)`` for the dock's Browser icon, or ``None`` when no Chromium exists."""
exe = executable()
return (exe, str(profile_dir())) if exe else None
# Flags the agent's launch and the dock's icon share on the persistent profile. --test-type hides the
# "Chrome for Testing is only for automated testing" and unsupported-flag (--no-sandbox as root) infobars
# that would sit over the human's takeover view; the persistent profile's HTTP cache is capped (uncapped it
# grows for months toward a hosted instance's 6 GB).
SHARED_PROFILE_ARGS = ("--test-type", f"--disk-cache-size={DISK_CACHE_BYTES}")
def dock_argv(exe: str, user_data_dir: str, *, sandbox_bypass: Optional[bool] = None) -> list[str]:
"""Command the dock's Browser icon runs. ``--remote-debugging-port=0`` makes a human-started
instance attachable (Chromium writes the chosen port to ``<user-data-dir>/DevToolsActivePort``);
first-run / default-browser dialogs would sit between the human and the bot's tabs. The sandbox policy
is the launcher's (root, Docker, AppArmor userns): the human's Browser is the bot's browser."""
from tools.browser_launcher import CHROMIUM_SANDBOX_BYPASS_ARGS, needs_chromium_sandbox_bypass
return [exe, f"--user-data-dir={user_data_dir}", "--remote-debugging-port=0", "--no-first-run",
"--no-default-browser-check", *SHARED_PROFILE_ARGS,
*(CHROMIUM_SANDBOX_BYPASS_ARGS if (needs_chromium_sandbox_bypass() if sandbox_bypass is None
else sandbox_bypass) else ())]
def dock_exec_line(exe: str, user_data_dir: str, *, sandbox_bypass: Optional[bool] = None) -> str:
"""The ``Exec=`` line of the dock's ``.desktop`` entry. Every argument is double-quoted per the
Desktop Entry spec (a browser under ``/opt/Google Chrome/`` or a profile under a spaced HERMES_HOME
otherwise splits into garbage): inside the quotes ``" ` $ \\`` are backslash-escaped, and because the
value is itself a string field, each of those backslashes is escaped once more."""
def quote(arg: str) -> str:
quoted = "".join("\\" + ch if ch in '"`$\\' else ch for ch in arg)
return '"' + quoted.replace("\\", "\\\\") + '"'
return "Exec=" + " ".join(quote(arg) for arg in dock_argv(exe, user_data_dir, sandbox_bypass=sandbox_bypass))