mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
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).
113 lines
5.8 KiB
Python
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))
|