Files
hermes-agent/tools/browser_tool.py
teknium1 4586cde64d chore: mark the deliberate /tmp literals and shrink the lint baseline to one code block
The seventeen remaining literals are container-side paths, AF_UNIX socket-path-limit
candidates on darwin, detection needles, guard regexes and guidance text that tells the
model to avoid /tmp. Each carries an inline `no-tmp: ok — <why>` so the reason lives
next to the line; the baseline keeps only a fenced tree listing where a marker would render.
2026-09-19 10:44:26 -07:00

1385 lines
67 KiB
Python

#!/usr/bin/env python3
"""Browser automation tools driven by the agent-browser CLI.
Backends — local headless Chromium, Browser Use / Browserbase / Firecrawl cloud
(auto-detected from config + credentials), a user-supplied CDP endpoint, or Camofox —
share one agent-facing behaviour: per-task sessions, accessibility-tree snapshots with
``@eN`` refs, automatic cleanup. Settings live under ``browser.*`` in config.yaml.
Sibling ``browser_tool_*`` modules hold extracted clusters.
"""
import atexit
import json
import logging
import os
import subprocess
import sys
import tempfile
import threading
import time
from typing import Dict, Any, Optional, Union
from pathlib import Path
from agent.redact import redact_cdp_url
from hermes_constants import get_hermes_home, hermes_home_key
from utils import env_int
from hermes_cli.config import DEFAULT_CONFIG, cfg_get
# Env keys re-added to the agent-browser subprocess AFTER credential stripping.
# agent-browser is a Node process loading npm deps: a compromised transitive
# dependency could read every Hermes secret from process.env.
# Strip by default, then re-add only the browser-backend keys the worker legitimately needs. See #29157.
_BROWSER_PASSTHROUGH_KEYS: tuple[str, ...] = (
"BROWSERBASE_API_KEY", "BROWSERBASE_PROJECT_ID", "BROWSER_USE_API_KEY",
"FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", "FIRECRAWL_BROWSER_TTL",
)
def _build_browser_env() -> dict:
"""Credential-scrubbed env for an agent-browser subprocess (deferred import: test
harnesses stub the ``tools`` package). The passthrough keys are re-added from the active
profile's secret scope, never ``os.environ``: under multiplex that holds the LAUNCH profile's
Browserbase/Firecrawl keys, and a served profile's browser must run on its own (or none)."""
from agent.secret_scope import current_secret_scope, get_secret, serves_routed_profile
from tools.environments.local import served_profile_child_env
from agent.proxy_bypass import add_loopback_no_proxy
env = served_profile_child_env(inherit_credentials=False)
# A routed profile (multiplex, or a Desktop/dashboard backend serving ``?profile=B`` with the
# flag off) resolves from its bound scope only — a miss is "no key", never the launch profile's
# ``os.environ`` value that ``get_secret`` falls through to while multiplexing is inactive.
routed = serves_routed_profile()
scope = (current_secret_scope() or {}) if routed else None
for key in _BROWSER_PASSTHROUGH_KEYS:
value = scope.get(key) if routed else get_secret(key)
if value is not None:
env[key] = value
# The Browser Use harness dials the resolved local CDP URL over ``websockets``; without a
# loopback NO_PROXY a macOS system proxy captures that dial (#110565).
return add_loopback_no_proxy(env)
try:
from tools.website_policy import check_website_access
except Exception:
check_website_access = lambda url: None # noqa: E731 — fail-open if policy module unavailable
try:
from tools.url_safety import (
_is_declared_fake_ip,
is_safe_url as _is_safe_url,
is_always_blocked_url as _is_always_blocked_url,
normalize_url_for_request as _normalize_url_for_request,
)
except Exception:
_is_declared_fake_ip = lambda ip: False # noqa: E731 — no declaration known: keep the private verdict
_is_safe_url = lambda url: False # noqa: E731 — fail-closed: block all if safety module unavailable
_is_always_blocked_url = lambda url: True # noqa: E731 — fail-closed on the floor too
_normalize_url_for_request = lambda url: url # noqa: E731 — best-effort fallback
# Browser-provider ABC + registry; per-vendor providers live under
# ``plugins/browser/<vendor>/``. The dispatcher consults the registry. See #25214.
from agent.browser_provider import BrowserProvider
try:
from agent.browser_registry import registry_generation as _browser_registry_generation
except ImportError:
# Isolated compat tests install a minimal ``agent.browser_registry`` stub
# with only ``get_provider``; no mutable registry → constant generation.
def _browser_registry_generation(*, scope=None):
return (0, 0)
# Optional backends: Camofox (CAMOFOX_URL routes everything through its REST API)
# and the Browser Use CLI.
try:
from tools.browser_camofox import is_camofox_mode as _is_camofox_mode
except ImportError:
_is_camofox_mode = lambda: False # noqa: E731
try:
from tools.browser_use_cli import is_browser_use_cli_mode as _is_browser_use_cli_mode
except ImportError:
_is_browser_use_cli_mode = lambda: False # noqa: E731
logger = logging.getLogger(__name__)
# PATH fallbacks for minimal-PATH environments (systemd services): Termux,
# macOS Homebrew, and the usual system dirs — needed for agent-browser/npx/node.
_SANE_PATH_DIRS = (
"/data/data/com.termux/files/usr/bin", "/data/data/com.termux/files/usr/sbin",
"/opt/homebrew/bin", "/opt/homebrew/sbin", "/usr/local/sbin", "/usr/local/bin",
"/usr/sbin", "/usr/bin", "/sbin", "/bin",
)
_SANE_PATH = os.pathsep.join(_SANE_PATH_DIRS)
from tools import browser_tool_install as _install
_last_screenshot_cleanup_by_dir: dict[str, float] = {} # throttles full directory scans
# ----------------------------------------------------------------------------
# Configuration
# ----------------------------------------------------------------------------
DEFAULT_COMMAND_TIMEOUT = 30 # seconds
# Floors for ``open``: cold daemon + first Chromium launch can exceed the
# generic command_timeout on slow or library-starved Linux hosts.
MIN_OPEN_TIMEOUT = 60
MIN_FIRST_OPEN_TIMEOUT = 120
# Snapshot truncation budget — aligned with web_tools.DEFAULT_EXTRACT_CHAR_LIMIT
# so the model gets the same per-page budget from both paths. Configurable via
# ``browser.snapshot_threshold``.
DEFAULT_SNAPSHOT_THRESHOLD = 15000
MIN_SNAPSHOT_THRESHOLD = 1000
# Ceiling on the stored full-snapshot file (mirrors web_tools.MAX_STORED_TEXT_CHARS):
# the stored copy exists for read_file paging and must not be unbounded.
MAX_STORED_SNAPSHOT_CHARS = 2_000_000
_EMPTY_OK_COMMANDS: frozenset = frozenset({"close", "record"}) # legitimately empty stdout
# Sentinel _find_agent_browser returns/caches to mean "resolve via npx" rather
# than a concrete path (also compared in hermes_cli/tools_config.py and doctor.py).
NPX_AGENT_BROWSER_SENTINEL = "npx agent-browser"
# Pinned to match scripts/install.sh / install.ps1's managed install so a bare-npx
# resolution gets the same version instead of floating latest. Update together.
AGENT_BROWSER_NPX_SPEC = "agent-browser@^0.26.0"
# Process caches (``_cached_X`` + ``_X_resolved`` pairs) for config-derived lookups;
# reset by ``cleanup_all_browsers``. Written/read by the sibling modules via ``browser_tool_origin``.
# The config-derived ones are keyed by profile home (``hermes_home_key()``): the multiplexed
# gateway serves every profile from one process, so a single slot would hand the launch
# profile's browser settings to every other profile.
_cached_command_timeout: Optional[Dict[str, int]] = None
# Flip the resolved flag BEFORE nulling the cache so a concurrent reader never sees ``resolved=True`` with
# ``cache=None`` (#14331).
_command_timeout_resolved = False
_cached_snapshot_threshold: Optional[Dict[str, int]] = None
_snapshot_threshold_resolved = False
_cached_cloud_provider: Optional[BrowserProvider] = None
_cloud_provider_resolved = False
_cached_cloud_provider_scope: Optional[str] = None
_cached_cloud_providers: Dict[tuple[str, tuple[int, int]], Optional[BrowserProvider]] = {}
_cloud_provider_cache_lock = threading.RLock()
_allow_private_urls_resolved = False
_cached_allow_private_urls: Optional[bool] = None
_cached_agent_browser: Optional[str] = None
_agent_browser_resolved = False
_cached_browser_engine: Optional[str] = None # agent-browser v0.25.3+ ``--engine lightpanda``
_browser_engine_resolved = False
_auto_local_for_private_urls_resolved = False
_cached_auto_local_for_private_urls: bool = True
_cached_headed_mode: Optional[bool] = None
_headed_mode_resolved = False
_cached_chromium_installed: Optional[bool] = None
_chromium_autoinstall_attempted = False # one-shot: a failed 170MB download must not retry per call
# Mask secrets in logged CDP URLs; agent.redact.redact_cdp_url is the single policy.
_sanitize_url_for_logs = redact_cdp_url
def _browser_cfg(key: str, default, parse, log_label: str):
"""``parse(browser.<key>)`` from the RAW profile config (loader warnings must not
leak into tool JSON); ``default`` when absent, not a mapping, or on any error."""
try:
from hermes_cli.config import read_raw_config
browser_cfg = read_raw_config().get("browser", {})
if isinstance(browser_cfg, dict) and key in browser_cfg:
return parse(browser_cfg[key])
except Exception as e:
logger.debug("Could not read %s: %s", log_label, e)
return default
def _cached_browser_cfg(cache_name: str, flag_name: str, key: str, default, parse, log_label: str):
"""Process-cached ``_browser_cfg`` read, one slot per profile home (cleared by
``cleanup_all_browsers``). The value is stored BEFORE the resolved flag flips so a
concurrent reader never sees ``resolved=True`` with an empty cache."""
g = globals()
home = hermes_home_key()
cache = g[cache_name]
if cache is None:
cache = g[cache_name] = {}
if g[flag_name] and cache.get(home) is not None:
return cache[home]
result = _browser_cfg(key, default, parse, log_label)
cache[home] = result
g[flag_name] = True
return result
def _get_command_timeout() -> int:
"""``browser.command_timeout`` (floored at 5s; default 30s)."""
return _cached_browser_cfg(
"_cached_command_timeout", "_command_timeout_resolved",
"command_timeout", DEFAULT_COMMAND_TIMEOUT,
lambda v: DEFAULT_COMMAND_TIMEOUT if v is None else max(int(v), 5),
"command_timeout from config",
)
def _safe_command_timeout() -> int:
"""``_get_command_timeout`` guaranteed non-None (cache reset mid-flight); ``is not
None`` rather than ``or`` so a configured ``0`` is preserved."""
val = _get_command_timeout()
return val if val is not None else DEFAULT_COMMAND_TIMEOUT
def get_browser_snapshot_threshold() -> int:
"""``browser.snapshot_threshold`` (floored at MIN_SNAPSHOT_THRESHOLD)."""
return _cached_browser_cfg(
"_cached_snapshot_threshold", "_snapshot_threshold_resolved",
"snapshot_threshold", DEFAULT_SNAPSHOT_THRESHOLD,
lambda v: DEFAULT_SNAPSHOT_THRESHOLD if v is None else max(int(v), MIN_SNAPSHOT_THRESHOLD),
"browser.snapshot_threshold",
)
def _get_open_command_timeout(*, first_open: bool = False) -> int:
"""Timeout for agent-browser ``open`` (navigation / daemon cold start)."""
return max(_safe_command_timeout(), MIN_FIRST_OPEN_TIMEOUT if first_open else MIN_OPEN_TIMEOUT)
from tools import browser_tool_session as _session
def _get_vision_model() -> Optional[str]:
"""Model for browser_vision (screenshot analysis — multimodal)."""
return os.getenv("AUXILIARY_VISION_MODEL", "").strip() or None
from tools import browser_tool_cdp as _cdp
from tools import browser_tool_cloud as _cloud
from tools import browser_tool_lightpanda_fallback as _lp
# Single shared real-profile copy-browser session: concurrent tasks reuse it
# instead of each launching a rival Chromium on the same copied user-data-dir.
_REAL_PROFILE_SESSION = "hermes-real-profile"
_real_profile_cdp_lock = threading.Lock()
_real_profile_cdp_cache: dict = {}
_real_profile_chrome_procs: list = [] # Popen handles of directly-launched real browsers
_PRIVATE_HOST_SUFFIXES = (".localhost", ".local", ".lan", ".internal")
def _url_is_private(url: str) -> bool:
"""True when the URL's host is (or resolves to) a private/LAN/loopback/CGNAT address.
Routing oracle only: DNS failures are NOT private (the configured backend surfaces the
error); obvious names short-circuit the DNS hop. A local proxy's declared fake-ip sentinel
(``security.fake_ip_ranges``) is not private: the name is public, the cloud browser resolves
it itself, so routing it to the local sidecar would send every URL local on such a host."""
import ipaddress
import socket
from urllib.parse import urlparse
def private(host: str) -> Optional[bool]: # None when ``host`` is not an IP literal
try:
ip = ipaddress.ip_address(host)
except ValueError:
return None
if _is_declared_fake_ip(ip):
return False
return ip.is_private or ip.is_loopback or ip.is_link_local or ip in ipaddress.ip_network("100.64.0.0/10")
try:
hostname = (urlparse(url).hostname or "").strip().lower().rstrip(".")
if not hostname:
return False
if (literal := private(hostname)) is not None:
return literal
if hostname == "localhost" or hostname.endswith(_PRIVATE_HOST_SUFFIXES):
return True
try:
addr_info = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
except socket.gaierror:
return False
return any(private(sockaddr[0]) for *_, sockaddr in addr_info)
except Exception as exc:
logger.debug("URL-privacy check failed for %s: %s", url, exc)
return False
def _navigation_session_key(task_id: str, url: str) -> str:
"""Session key that should handle ``url`` for ``task_id``: ``f"{task_id}::local"`` (hybrid
local sidecar while the cloud session keeps serving public URLs) only when ALL hold —
cloud provider configured, ``browser.auto_local_for_private_urls`` on, private URL, no
CDP override (it owns the whole session), Camofox off (already local-only)."""
if task_id is None:
task_id = "default"
hybrid = (
not _cdp._get_cdp_override_raw()
and not _is_camofox_mode()
and _cloud._get_cloud_provider() is not None
and _cloud._auto_local_for_private_urls()
and _url_is_private(url)
)
return f"{task_id}{_LOCAL_SUFFIX}" if hybrid else task_id
def _is_local_sidecar_key(session_key: str) -> bool:
return session_key.endswith(_LOCAL_SUFFIX)
def _bare_task_id_for_session_key(session_key: str) -> str:
return session_key[: -len(_LOCAL_SUFFIX)] if _is_local_sidecar_key(session_key) else session_key
def _session_info_owned_by_task(session_info: Dict[str, Any], task_id: str, session_key: str) -> bool:
"""Ownership check; entries without metadata (older in-memory / hot-reload) pass,
any explicit mismatch fails before a non-nav tool can act on the wrong session."""
owner = session_info.get("owner_task_id")
key = session_info.get("session_key")
return (owner is None or owner == task_id) and (key is None or key == session_key)
def _last_session_key(task_id: str) -> str:
"""Session key a non-nav tool must use: the one that served the task's last navigation.
If it was cleaned up or ownership no longer matches, fail closed by dropping the stale
binding rather than recreating or mutating the wrong browser."""
if task_id is None:
task_id = "default"
recorded_key = _last_active_session_key.get(task_id)
if not recorded_key:
return task_id
with _cleanup_lock:
session_info = _active_sessions.get(recorded_key)
if session_info and _session_info_owned_by_task(session_info, task_id, recorded_key):
return recorded_key
_last_active_session_key.pop(task_id, None)
logger.debug("browser session ownership: dropping stale/mismatched last-active binding %s -> %s",
task_id, recorded_key)
return task_id
def _socket_safe_tmpdir() -> str:
"""Short temp dir for Unix sockets: macOS ``TMPDIR`` + ``agent-browser-hermes_…``
exceeds the 104-byte AF_UNIX limit (silent screenshot failures), so use /tmp there."""
return "/tmp" if sys.platform == "darwin" else tempfile.gettempdir() # no-tmp: ok — AF_UNIX 104-byte socket path limit on darwin
# Active sessions keyed by "session key": the bare task_id, or f"{task_id}::local"
# for a hybrid-routing local sidecar (opaque to _run_browser_command / cleanup_browser).
# Values: session_name (always), bb_session_id + cdp_url (cloud).
_active_sessions: Dict[str, Dict[str, Any]] = {}
_recording_sessions: set = set() # session_keys with active recordings
# Most recent session_key per task_id (set by browser_navigate, read by every non-nav
# tool) so click/snapshot land in the session that served the last navigation.
_last_active_session_key: Dict[str, str] = {}
_LOCAL_SUFFIX = "::local"
_cleanup_done = False
# Inactivity timeout: config.yaml is authoritative; BROWSER_INACTIVITY_TIMEOUT
# remains a legacy env fallback for unmigrated deployments.
DEFAULT_SESSION_INACTIVITY_TIMEOUT = int(DEFAULT_CONFIG.get("browser", {}).get("inactivity_timeout", 120))
def _get_session_inactivity_timeout() -> int:
env_default = env_int("BROWSER_INACTIVITY_TIMEOUT", DEFAULT_SESSION_INACTIVITY_TIMEOUT)
return _browser_cfg(
"inactivity_timeout", env_default,
lambda v: env_default if v is None else max(int(v), 30), # 30s floor: no instant reaping
"inactivity_timeout from config",
)
BROWSER_SESSION_INACTIVITY_TIMEOUT = _get_session_inactivity_timeout()
# Orphan reaper cadence: a startup-only reap can never recover from a leak that
# appears after boot in a long-lived process.
BROWSER_ORPHAN_REAP_INTERVAL = 300 # seconds
# Idle ceiling for a daemon whose owner is alive but which fell out of in-memory
# tracking (owner-alive alone would make it immortal); large multiple so a busy
# session is never touched.
BROWSER_ORPHAN_GRACE_SECONDS = max(3600, BROWSER_SESSION_INACTIVITY_TIMEOUT * 20)
_session_last_activity: Dict[str, float] = {}
# Owner Hermes home per session: the janitor is one process-global thread, so each
# teardown must re-enter the OWNING profile's scope (copy_context at spawn would
# pin the first profile's secrets onto every other profile's teardown).
# See #86402.
_session_owner_homes: Dict[str, str] = {}
# Consecutive janitor failures per session; force-reaped after MAX_INACTIVITY_CLEANUP_FAILURES.
# See #100738.
_cleanup_failures: Dict[str, int] = {}
MAX_INACTIVITY_CLEANUP_FAILURES = 3
# Session keys flagged suspect after a command timeout (written lock-free by
# mark_suspect; consumed by ensure_healthy() at next use, which recycles).
# See #72205.
_suspect_browser_sessions: Dict[str, str] = {}
class _BrowserSessionBackend:
"""``agent.deadline.SuspectableBackend`` adapter for one cached session key: the
timeout path calls ``mark_suspect`` inline; ``ensure_healthy`` runs at the top of
``_get_session_info`` — the choke point every command passes through."""
__slots__ = ("_session_key",)
def __init__(self, session_key: str) -> None:
self._session_key = session_key
def mark_suspect(self, reason: str) -> None:
"""MUST stay cheap and lock-free (runs inline on the timed-out caller's thread)."""
_suspect_browser_sessions[self._session_key] = reason
def ensure_healthy(self) -> bool:
"""Recycle the session when a prior timeout marked it suspect; False after teardown.
The flag is popped BEFORE teardown: ``close`` re-enters ``_get_session_info``
and must not recurse into another recycle."""
reason = _suspect_browser_sessions.pop(self._session_key, None)
if reason is None:
return True
logger.info("Recycling suspect browser session %s before reuse (%s)", self._session_key, reason)
try:
_lifecycle._cleanup_single_browser_session(self._session_key)
except Exception:
logger.warning("Teardown of suspect browser session %s failed; a fresh "
"session will be created anyway", self._session_key, exc_info=True)
return False
_browser_session_backend = _BrowserSessionBackend
_cleanup_thread = None
_cleanup_running = False
_cleanup_lock = threading.Lock() # protects _session_last_activity AND _active_sessions
from tools import browser_tool_lifecycle as _lifecycle
# atexit only — NO SIGINT/SIGTERM handlers calling sys.exit(): a SystemExit raised
# inside a prompt_toolkit key-binding callback corrupts the coroutine state and
# makes the process unkillable.
atexit.register(_lifecycle._emergency_cleanup_all_sessions)
atexit.register(_lifecycle._stop_browser_cleanup_thread)
# ----------------------------------------------------------------------------
# Tool Schemas
# ----------------------------------------------------------------------------
BROWSER_TOOL_SCHEMAS = [
{
"name": "browser_navigate",
"description": "Navigate to a URL in the browser. Initializes the session and loads the page. Must be called before other browser tools. For simple information retrieval, prefer a lightweight retrieval tool when one is available (faster, cheaper). For plain-text endpoints — URLs ending in .md, .txt, .json, .yaml, .yml, .csv, .xml, raw.githubusercontent.com, or any documented API endpoint — prefer an available text-extraction or terminal-fetch tool; the browser stack is overkill and much slower for these. Use browser tools when you need to interact with a page (click, fill forms, dynamic content). Returns a compact page snapshot with interactive elements and ref IDs — no need to call browser_snapshot separately after navigating.",
"parameters": {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "The URL to navigate to (e.g., 'https://example.com')"
}
},
"required": ["url"]
}
},
{
"name": "browser_snapshot",
"description": "Get a text-based snapshot of the current page's accessibility tree. Returns interactive elements with ref IDs (like @e1, @e2) for browser_click and browser_type. full=false (default): compact view with interactive elements. full=true: complete page content. Snapshots over 15000 chars are truncated or LLM-summarized; when that happens the complete snapshot is saved to a file and the output includes its path so you can page through the rest with read_file. Requires browser_navigate first. Note: browser_navigate already returns a compact snapshot — use this to refresh after interactions that change the page, or with full=true for complete content.",
"parameters": {
"type": "object",
"properties": {
"full": {
"type": "boolean",
"description": "If true, returns complete page content. If false (default), returns compact view with interactive elements only.",
"default": False
}
},
"required": []
}
},
{
"name": "browser_click",
"description": "Click on an element identified by its ref ID from the snapshot (e.g., '@e5'). The ref IDs are shown in square brackets in the snapshot output. Requires browser_navigate and browser_snapshot to be called first.",
"parameters": {
"type": "object",
"properties": {
"ref": {
"type": "string",
"description": "The element reference from the snapshot (e.g., '@e5', '@e12')"
}
},
"required": ["ref"]
}
},
{
"name": "browser_type",
"description": "Type text into an input field identified by its ref ID. Clears the field first, then types the new text. Requires browser_navigate and browser_snapshot to be called first.",
"parameters": {
"type": "object",
"properties": {
"ref": {
"type": "string",
"description": "The element reference from the snapshot (e.g., '@e3')"
},
"text": {
"type": "string", "description": "The text to type into the field"
}
},
"required": ["ref", "text"]
}
},
{
"name": "browser_scroll",
"description": "Scroll the page in a direction. Use this to reveal more content that may be below or above the current viewport. Requires browser_navigate to be called first.",
"parameters": {
"type": "object",
"properties": {
"direction": {
"type": "string", "enum": ["up", "down"], "description": "Direction to scroll"
}
},
"required": ["direction"]
}
},
{
"name": "browser_back",
"description": "Navigate back to the previous page in browser history. Requires browser_navigate to be called first.",
"parameters": {
"type": "object", "properties": {}, "required": []
}
},
{
"name": "browser_press",
"description": "Press a keyboard key. Useful for submitting forms (Enter), navigating (Tab), or keyboard shortcuts. Requires browser_navigate to be called first.",
"parameters": {
"type": "object",
"properties": {
"key": {
"type": "string",
"description": "Key to press (e.g., 'Enter', 'Tab', 'Escape', 'ArrowDown')"
}
},
"required": ["key"]
}
},
{
"name": "browser_get_images",
"description": "Get a list of all images on the current page with their URLs and alt text. Useful for finding images to analyze with the vision tool. Requires browser_navigate to be called first.",
"parameters": {
"type": "object", "properties": {}, "required": []
}
},
{
"name": "browser_vision",
"description": "Take a screenshot of the current page so you can inspect it visually. Use this when you need to understand what the page looks like - especially for CAPTCHAs, visual verification challenges, complex layouts, or cases where the text snapshot misses important visual information. When your active model has native vision, the screenshot is attached to your context directly and you inspect it on the next turn; otherwise Hermes falls back to an auxiliary vision model and returns a text analysis. Includes a screenshot_path that you can share with the user by including MEDIA:<screenshot_path> in your response. Requires browser_navigate to be called first.",
"parameters": {
"type": "object",
"properties": {
"question": {
"type": "string",
"description": "What you want to know about the page visually. Be specific about what you're looking for."
},
"annotate": {
"type": "boolean",
"default": False,
"description": "If true, overlay numbered [N] labels on interactive elements. Each [N] maps to ref @eN for subsequent browser commands. Useful for QA and spatial reasoning about page layout."
}
},
"required": ["question"]
}
},
{
"name": "browser_console",
"description": "Get browser console output and JavaScript errors from the current page. Returns console.log/warn/error/info messages and uncaught JS exceptions. Use this to detect silent JavaScript errors, failed API calls, and application warnings. Requires browser_navigate to be called first. When 'expression' is provided, evaluates JavaScript in the page context and returns the result — use this for DOM inspection, reading page state, or extracting data programmatically.",
"parameters": {
"type": "object",
"properties": {
"clear": {
"type": "boolean",
"default": False,
"description": "If true, clear the message buffers after reading"
},
"expression": {
"type": "string",
"description": "JavaScript expression to evaluate in the page context. Runs in the browser like DevTools console — full access to DOM, window, document. Return values are serialized to JSON. Example: 'document.title' or 'document.querySelectorAll(\"a\").length'"
}
},
"required": []
}
},
]
from tools import browser_tool_snapshot as _snapshot
# ----------------------------------------------------------------------------
# Browser Tool Functions
# ----------------------------------------------------------------------------
def _err(error: str, **extra) -> dict:
return {"success": False, "error": error, **extra}
def _dumps(payload: Dict[str, Any], **kw) -> str:
return json.dumps(payload, ensure_ascii=False, **kw)
def _secret_url_error(url: str) -> Optional[dict]:
"""Refuse URLs embedding an API key/token (raw and URL-decoded, catching ``%2D``
tricks) — a prompt injection could otherwise exfiltrate secrets via the URL."""
import urllib.parse
from agent.redact import _PREFIX_RE
if _PREFIX_RE.search(url) or _PREFIX_RE.search(urllib.parse.unquote(url)):
return _err("Blocked: URL contains what appears to be an API key or token. Secrets must not be sent in URLs.")
return None
def _url_policy_error(url: str, *, auto_local: bool = False) -> Optional[dict]:
"""Backend-aware URL checks on an already-normalized URL; None if allowed. Ordered floors:
(1) cloud metadata / IMDS refused UNCONDITIONALLY (a local Chromium on a cloud VM still
reaches the host IMDS); (2) private addresses refused unless local, sidecar-routed, or
``browser.allow_private_urls``; (3) website policy allow/deny lists.
Credential-NAMED query params (``?token=``, ``?signature=``) are deliberately NOT a floor:
magic links, OAuth callbacks and signed CDN assets are how the agent signs in and browses, and
a cloud browser already sees every cookie and typed password of the session — refusing the
URL protects nothing. Hermes' own secrets leaking into a URL are caught by ``_secret_url_error``."""
local = _cloud._is_local_backend()
# Always-blocked floor: cloud metadata / IMDS endpoints are denied regardless of backend, hybrid
# routing, or allow_private_urls. There's no legitimate agent use case for navigating to 169.254.169.254
# / metadata.google.internal / ECS task metadata via a browser, and routing those to a local Chromium
# sidecar on an EC2/GCP/Azure host exfiltrates IAM credentials (#16234). The floor is UNCONDITIONAL — it
# must fire for every backend, including the pure-local headless Chromium and off-host CDP cases (a
# local Chromium on a cloud VM still reaches the host IMDS).
if _is_always_blocked_url(url):
return _err("Blocked: URL targets a cloud metadata endpoint")
if not local and not auto_local and not _cloud._allow_private_urls() and not _is_safe_url(url):
return _err("Blocked: URL targets a private or internal address")
blocked = check_website_access(url)
if blocked:
return _err(blocked["message"],
blocked_by_policy={"host": blocked["host"], "rule": blocked["rule"], "source": blocked["source"]})
return None
def _secret_url_error_normalized(url: str) -> tuple[str, Optional[dict]]:
"""Secret check on the raw URL, then again on the normalized one; returns ``(url, error)``."""
err = _secret_url_error(url)
if err is None:
url = _normalize_url_for_request(url)
err = _secret_url_error(url)
return url, err
def evaluate_url_safety(url: str) -> Optional[dict]:
"""Run URL safety checks; None if safe, else an error dict"""
url, err = _secret_url_error_normalized(url)
return err or _url_policy_error(url)
_BOT_DETECTION_TITLE_PATTERNS = (
"access denied", "access to this page has been denied", "blocked", "bot detected", "verification required",
"please verify", "are you a robot", "captcha", "cloudflare", "ddos protection", "checking your browser",
"just a moment", "attention required",
)
def _post_redirect_block(nav_session_key: str, url: str, final_url: str, auto_local_this_nav: bool) -> Optional[str]:
"""Post-redirect SSRF check; blocked JSON payload or None. The page is moved to about:blank
first so later snapshots can't read the internal content. The metadata floor fires for
every backend; the private-address check is skipped for local, the sidecar, and
``browser.allow_private_urls``."""
if not final_url or final_url == url:
return None
if _is_always_blocked_url(final_url):
what = "a cloud metadata endpoint"
elif (
not _cloud._is_local_backend()
and not auto_local_this_nav
and not _cloud._allow_private_urls()
and not _is_safe_url(final_url)
):
what = "a private/internal address"
else:
return None
_session._run_browser_command(nav_session_key, "open", ["about:blank"], timeout=10)
return json.dumps(_err(f"Blocked: redirect landed on {what}"))
def _snapshot_fields(snap_result: Dict[str, Any]) -> Dict[str, Any]:
"""``snapshot`` + ``element_count`` fields from a successful snapshot result; oversized
snapshots truncate at line boundaries with the full tree stored for read_file paging."""
data = snap_result.get("data", {})
snapshot_text = data.get("snapshot", "")
refs = data.get("refs", {})
threshold = get_browser_snapshot_threshold()
if len(snapshot_text) > threshold:
snapshot_text = _snapshot._truncate_snapshot(snapshot_text, max_chars=threshold)
return {"snapshot": _snapshot._redact_browser_output(snapshot_text), "element_count": len(refs) if refs else 0}
def _merge_fallback_warning(response: Dict[str, Any], result: Dict[str, Any]) -> None:
"""Copy a secondary result's fallback warning only if the response has none yet."""
if result.get("fallback_warning") and not response.get("fallback_warning"):
_lp._copy_fallback_warning(response, result)
def _attach_auto_snapshot(response: Dict[str, Any], nav_session_key: str) -> None:
"""Add a compact snapshot to a navigate response so the model can act without browser_snapshot."""
try:
snap_result = _session._run_browser_command(nav_session_key, "snapshot", ["-c"])
if snap_result.get("success"):
response.update(_snapshot_fields(snap_result))
_merge_fallback_warning(response, snap_result)
except Exception as e:
logger.debug("Auto-snapshot after navigate failed: %s", e)
def browser_navigate(url: str, task_id: Optional[str] = None) -> str:
"""Navigate to ``url``; JSON with title, compact snapshot and, on first nav, stealth features.
Hybrid routing decides BEFORE the safety checks whether this URL goes to a local sidecar
(the cloud provider never sees it then, so the private-address checks are relaxed)."""
url, safety_error = _secret_url_error_normalized(url)
if safety_error is not None:
return json.dumps(safety_error)
effective_task_id = task_id or "default"
nav_session_key = _navigation_session_key(effective_task_id, url)
auto_local_this_nav = _is_local_sidecar_key(nav_session_key)
safety_error = _url_policy_error(url, auto_local=auto_local_this_nav)
if safety_error is not None:
return json.dumps(safety_error)
if _is_camofox_mode():
return _camofox("camofox_navigate", url, task_id)
if auto_local_this_nav:
logger.info("browser_navigate: auto-routing %s to local Chromium sidecar (cloud provider %s stays on "
"cloud for public URLs; set browser.auto_local_for_private_urls: false to disable)",
url, type(_cloud._get_cloud_provider()).__name__ if _cloud._get_cloud_provider() else "none")
session_info = _session._get_session_info(nav_session_key)
is_first_nav = session_info.get("_first_nav", True)
if is_first_nav:
session_info["_first_nav"] = False
_maybe_start_recording(nav_session_key)
result = _session._run_browser_command(nav_session_key, "open", [url],
timeout=_get_open_command_timeout(first_open=is_first_nav))
if not result.get("success"):
return _dumps(_err(result.get("error", "Navigation failed")))
data = result.get("data", {})
title = data.get("title", "")
final_url = data.get("url", url)
blocked = _post_redirect_block(nav_session_key, url, final_url, auto_local_this_nav)
if blocked is not None:
return blocked
response = {"success": True, "url": final_url, "title": title}
features = session_info.get("features") or {}
if features.get("real_profile"): # auditability: this ran on the user's real-profile copy-browser
response["used_real_profile"] = True
# Only a successful, non-blocked navigation becomes the task owner: failed opens
# and blocked redirects must not retarget follow-up clicks to an irrelevant session.
_last_active_session_key[effective_task_id] = nav_session_key
_lp._copy_fallback_warning(response, result)
_add_navigate_warnings(response, title, session_info if is_first_nav else None)
_attach_auto_snapshot(response, nav_session_key)
return _dumps(response)
def _add_navigate_warnings(response: Dict[str, Any], title: str, first_nav_session: Optional[Dict[str, Any]]) -> None:
"""Bot-detection hint from the page title; on first navigation, the session's stealth features."""
title_lower = title.lower()
if any(pattern in title_lower for pattern in _BOT_DETECTION_TITLE_PATTERNS):
response["bot_detection_warning"] = (
f"Page title '{title}' suggests bot detection. The site may have blocked this request. "
"Options: 1) Try adding delays between actions, 2) Access different pages first, "
"3) Enable advanced stealth (BROWSERBASE_ADVANCED_STEALTH=true, requires Scale plan), "
"4) Some sites have very aggressive bot detection that may be unavoidable."
)
if first_nav_session is not None and "features" in first_nav_session:
features = first_nav_session["features"]
if not features.get("proxies"):
response["stealth_warning"] = (
"Running WITHOUT residential proxies. Bot detection may be more aggressive. "
"Consider upgrading Browserbase plan for proxy support."
)
response["stealth_features"] = [k for k, v in features.items() if v]
def browser_snapshot(
full: bool = False, task_id: Optional[str] = None, user_task: Optional[str] = None
) -> str:
"""Text snapshot of the page's accessibility tree (compact unless ``full``).
``user_task`` is deprecated and unused (oversized snapshots always truncate-and-store)."""
if _is_camofox_mode():
return _camofox("camofox_snapshot", full, task_id)
effective_task_id = _last_session_key(task_id or "default")
result = _session._run_browser_command(effective_task_id, "snapshot", [] if full else ["-c"])
if not result.get("success"):
return _failed_response(result, "Failed to get snapshot")
blocked = _blocked_private_page_content(effective_task_id)
if blocked is not None:
return blocked
response = {"success": True, **_snapshot_fields(result)}
_lp._copy_fallback_warning(response, result)
# Merge supervisor state (pending dialogs + frame tree) when a CDP supervisor is
# attached. See website/docs/developer-guide/browser-supervisor.md.
try:
from tools.browser_supervisor import SUPERVISOR_REGISTRY # type: ignore[import-not-found]
_supervisor = SUPERVISOR_REGISTRY.get(effective_task_id)
if _supervisor is not None:
_sv_snap = _supervisor.snapshot()
if _sv_snap.active:
response.update(_snapshot._redact_browser_output(_sv_snap.to_dict()))
except Exception as _sv_exc:
logger.debug("supervisor snapshot merge failed: %s", _sv_exc)
return _dumps(response)
def _json_with_fallback(response: Dict[str, Any], result: Dict[str, Any]) -> str:
"""``json.dumps`` of ``response`` with the Lightpanda fallback metadata copied from ``result``."""
return _dumps(_lp._copy_fallback_warning(response, result))
def _failed_response(result: Dict[str, Any], default_error: str) -> str:
return _json_with_fallback(_err(result.get("error", default_error)), result)
def _tool_response(result: Dict[str, Any], ok: Dict[str, Any], default_error: str) -> str:
"""``{"success": True, **ok}`` or ``{"success": False, "error": result.error or default}``, plus fallback metadata."""
if not result.get("success"):
return _failed_response(result, default_error)
return _json_with_fallback({"success": True, **ok}, result)
def _camofox(func_name: str, *args):
"""Call ``tools.browser_camofox.<func_name>(*args)`` (Camofox mode delegation)."""
import importlib
return getattr(importlib.import_module("tools.browser_camofox"), func_name)(*args)
def _guarded_action(task_id: Optional[str], action: str, command: str, args: list, ok: Dict[str, Any], err: str) -> str:
"""Input action on the task's current page, refused when the SSRF guard flags the page."""
effective_task_id = _last_session_key(task_id or "default")
blocked = _blocked_private_page_action(effective_task_id, action)
if blocked is not None:
return blocked
return _tool_response(_session._run_browser_command(effective_task_id, command, args), ok, err)
def _at_ref(ref: str) -> str:
return ref if ref.startswith("@") else f"@{ref}"
def browser_click(ref: str, task_id: Optional[str] = None) -> str:
"""Click the element ``ref`` (e.g. "@e5")."""
if _is_camofox_mode():
return _camofox("camofox_click", ref, task_id)
ref = _at_ref(ref)
return _guarded_action(task_id, "click", "click", [ref], {"clicked": ref}, f"Failed to click {ref}")
def browser_type(ref: str, text: str, task_id: Optional[str] = None) -> str:
"""Type ``text`` into the element ``ref`` (fill: clears, then types)."""
if _is_camofox_mode():
return _camofox("camofox_type", ref, text, task_id)
effective_task_id = _last_session_key(task_id or "default")
blocked = _blocked_private_page_action(effective_task_id, "type")
if blocked is not None:
return blocked
ref = _at_ref(ref)
result = _session._run_browser_command(effective_task_id, "fill", [ref, text])
from agent.display import redact_browser_typed_text_for_display, redact_tool_args_for_display
# Typed text goes through the secret-pattern redactor so API keys / tokens don't
# leak into tool progress or chat history (the raw value already went to the browser).
display_text = (redact_tool_args_for_display("browser_type", {"text": text}) or {})["text"]
if result.get("success"):
response = {"success": True, "typed": display_text, "element": ref}
else:
response = _err(result.get("error", f"Failed to type into {ref}"))
return _dumps(redact_browser_typed_text_for_display(_lp._copy_fallback_warning(response, result), text))
def browser_scroll(direction: str, task_id: Optional[str] = None) -> str:
"""Scroll the page ``direction`` ("up"/"down") by about half a viewport."""
if direction not in {"up", "down"}:
return _dumps(_err(f"Invalid direction '{direction}'. Use 'up' or 'down'."))
_SCROLL_PIXELS = 500 # ~half a viewport in one call instead of 5x subprocess calls
if _is_camofox_mode(): # Camofox REST API has no pixel argument; use repeated calls
return [_camofox("camofox_scroll", direction, task_id) for _ in range(5)][-1]
effective_task_id = _last_session_key(task_id or "default")
result = _session._run_browser_command(effective_task_id, "scroll", [direction, str(_SCROLL_PIXELS)])
return _tool_response(result, {"scrolled": direction}, f"Failed to scroll {direction}")
def browser_back(task_id: Optional[str] = None) -> str:
"""Navigate back in browser history."""
if _is_camofox_mode():
return _camofox("camofox_back", task_id)
effective_task_id = _last_session_key(task_id or "default")
result = _session._run_browser_command(effective_task_id, "back", [])
if result.get("success"):
# History can land on a private/internal/metadata address the navigate
# preflight never saw (earlier redirect chain, manipulated client-side history).
blocked = _blocked_private_page(effective_task_id, "Browser history navigation (back) landed on this address.")
if blocked is not None:
return blocked
return _tool_response(result, {"url": result.get("data", {}).get("url", "")}, "Failed to go back")
def browser_press(key: str, task_id: Optional[str] = None) -> str:
"""Press a keyboard key (e.g. "Enter", "Tab")."""
if _is_camofox_mode():
return _camofox("camofox_press", key, task_id)
return _guarded_action(task_id, "press", "press", [key], {"pressed": key}, f"Failed to press {key}")
def _blocked_private_page_json(blocked_url: str, why: str) -> str:
"""Refusal payload for a page whose URL targets a private/internal address."""
return _dumps(_err(f"Blocked: page URL targets a private or internal address ({blocked_url}). {why}"))
def _blocked_private_page(effective_task_id: str, why: str) -> Optional[str]:
"""Blocked payload when the SSRF guard is active and the current page is private, else
None. Fail-open on probe failure (see ``_current_page_private_url``)."""
if not _eval_policy._eval_ssrf_guard_active(effective_task_id):
return None
blocked_url = _eval_policy._current_page_private_url(effective_task_id)
return _blocked_private_page_json(blocked_url, why) if blocked_url else None
def _blocked_private_page_action(effective_task_id: str, action: str) -> Optional[str]:
"""Blocked payload when an unsafe cloud page would receive input."""
return _blocked_private_page(effective_task_id, f"Refusing to {action} on this page in this browser mode.")
_EVAL_NAVIGATED_WHY = "This may have been caused by a JavaScript navigation via browser_console."
def _blocked_private_page_content(effective_task_id: str) -> Optional[str]:
"""Content-returning tools (snapshot/vision/eval/get_images): after an eval that may
have moved ``location.href`` to a private address, returning content would expose it."""
return _blocked_private_page(effective_task_id, _EVAL_NAVIGATED_WHY)
def browser_console(clear: bool = False, expression: Optional[str] = None, task_id: Optional[str] = None) -> str:
"""Console messages + uncaught JS errors (optionally ``clear``ing the buffers),
or — when ``expression`` is given — evaluate JS in the page like the DevTools console."""
if expression is not None:
policy_error = _eval_policy._enforce_browser_eval_policy(expression)
if policy_error:
return _dumps(_err(policy_error))
return _browser_eval(expression, task_id)
if _is_camofox_mode():
return _camofox("camofox_console", clear, task_id)
effective_task_id = _last_session_key(task_id or "default")
blocked = _blocked_private_page_content(effective_task_id)
if blocked is not None:
return blocked
clear_args = ["--clear"] if clear else []
console_result = _session._run_browser_command(effective_task_id, "console", clear_args)
errors_result = _session._run_browser_command(effective_task_id, "errors", clear_args)
messages = [
{"type": msg.get("type", "log"), "text": _snapshot._redact_browser_output(msg.get("text", "")), "source": "console"}
for msg in console_result.get("data", {}).get("messages", [])
] if console_result.get("success") else []
errors = [
{"message": _snapshot._redact_browser_output(err.get("message", "")), "source": "exception"}
for err in errors_result.get("data", {}).get("errors", [])
] if errors_result.get("success") else []
response = {
"success": True, "console_messages": messages, "js_errors": errors,
"total_messages": len(messages), "total_errors": len(errors),
}
_lp._copy_fallback_warning(response, console_result)
_merge_fallback_warning(response, errors_result)
return _dumps(response)
from tools import browser_tool_eval_policy as _eval_policy
def _parse_eval_value(raw_result: Any) -> Any:
"""Eval returns the JS value as a string; parse valid JSON so the model gets structured data."""
if isinstance(raw_result, str):
try:
return json.loads(raw_result)
except (json.JSONDecodeError, ValueError):
pass # keep as string
return raw_result
def _eval_ok_response(parsed: Any, **extra) -> Dict[str, Any]:
return {"success": True, "result": _snapshot._redact_browser_output(parsed), "result_type": type(parsed).__name__, **extra}
def _eval_result_or_blocked(effective_task_id: str, parsed: Any, result: Dict[str, Any], **extra) -> str:
"""Eval tool JSON, unless the post-eval page-URL recheck finds an eval navigated the
page to a private address — then the result is withheld."""
blocked = _blocked_private_page_content(effective_task_id)
if blocked is not None:
return blocked
return _dumps(_lp._copy_fallback_warning(_eval_ok_response(parsed, **extra), result), default=str)
def _eval_supervisor_fast_path(effective_task_id: str, expression: str) -> Optional[str]:
"""``Runtime.evaluate`` on the CDP supervisor's persistent WebSocket (no subprocess cost).
Tool JSON when the supervisor gave a definitive answer (value, blocked page, or a real
JS-side exception — NOT retried via subprocess, that would just reproduce it slower);
None to fall through to the subprocess path."""
try:
from tools.browser_supervisor import SUPERVISOR_REGISTRY # type: ignore[import-not-found]
supervisor = SUPERVISOR_REGISTRY.get(effective_task_id)
if supervisor is None:
return None
sup_result = supervisor.evaluate_runtime(expression)
if sup_result.get("ok"):
return _eval_result_or_blocked(
effective_task_id, _parse_eval_value(sup_result.get("result")), {}, method="cdp_supervisor")
err = sup_result.get("error") or "evaluate_runtime failed"
if "supervisor" not in err.lower():
return _dumps(_err(err))
logger.debug("browser_eval: supervisor path unavailable (%s), falling back to subprocess", err)
except ImportError:
pass
except Exception as exc: # pragma: no cover — defensive
logger.debug("browser_eval: supervisor path errored (%s), falling back", exc)
return None
def _eval_failure_response(result: Dict[str, Any]) -> str:
"""Tool JSON for a failed ``agent-browser eval``, with actionable rewrites of known errors."""
err = result.get("error", "eval failed")
if any(hint in err.lower() for hint in ("unknown command", "not supported", "not found", "no such command")):
err = f"JavaScript evaluation is not supported by this browser backend. {err}"
elif "reference chain is too long" in err.lower():
# A live DOM node / NodeList / Window can't be JSON-serialized by CDP. The
# supervisor path retries with returnByValue=false; the CLI can't.
err = (
"Expression returned a live DOM node / NodeList / Window, "
"which can't be serialized. Extract a primitive value "
"(e.g. .innerText, .href, .src, .value) or use "
"JSON.stringify() / a snapshot tool instead."
)
return json.dumps(_lp._copy_fallback_warning(_err(err), result))
def _browser_eval(expression: str, task_id: Optional[str] = None) -> str:
"""Evaluate JS in the page context. Private-network guard in two halves: the literal
pre-scan closes direct fetches (they never update ``location.href``); the post-eval
page-URL recheck closes navigate-then-read."""
effective_task_id = _last_session_key(task_id or "default")
if _eval_policy._eval_ssrf_guard_active(effective_task_id):
blocked_literal = _eval_policy._expression_targets_private_url(expression)
if blocked_literal:
return _dumps(_err(
"Blocked: JavaScript expression targets a private or "
f"internal address ({blocked_literal}). Reading internal "
"endpoints via browser_console is not permitted in this "
"browser mode."
))
# Camofox keeps its own raw-task_id-keyed session map, so pass the raw id.
if _is_camofox_mode():
return _camofox_eval(expression, task_id)
fast = _eval_supervisor_fast_path(effective_task_id, expression)
if fast is not None:
return fast
result = _session._run_browser_command(effective_task_id, "eval", [expression])
if not result.get("success"):
return _eval_failure_response(result)
return _eval_result_or_blocked(effective_task_id, _parse_eval_value(result.get("data", {}).get("result")), result)
def _camofox_eval(expression: str, task_id: Optional[str] = None) -> str:
"""Evaluate JS via Camofox's /tabs/{tab_id}/evaluate endpoint (if available)."""
from tools.browser_camofox import _ensure_tab, _post
try:
tab_info = _ensure_tab(task_id or "default")
tab_id = tab_info.get("tab_id") or tab_info.get("id")
user_id = tab_info["user_id"]
resp = _post(f"/tabs/{tab_id}/evaluate", body={"expression": expression, "userId": user_id})
parsed = _parse_eval_value(resp.get("result") if isinstance(resp, dict) else resp)
if _eval_policy._eval_ssrf_guard_active(task_id or "default"):
_blocked_url = _eval_policy._camofox_current_page_private_url(tab_id, user_id)
if _blocked_url:
return _blocked_private_page_json(_blocked_url, _EVAL_NAVIGATED_WHY)
return _dumps(_eval_ok_response(parsed), default=str)
except Exception as e:
if any(code in str(e) for code in ("404", "405", "501")): # server without eval support
return json.dumps(_err("JavaScript evaluation is not supported by this Camofox server. "
"Use browser_snapshot or browser_vision to inspect page state."))
return tool_error(str(e), success=False)
def _maybe_start_recording(task_id: str):
"""Start recording if browser.record_sessions is enabled in config."""
with _cleanup_lock:
if task_id in _recording_sessions:
return
try:
from hermes_cli.config import read_raw_config
hermes_home = get_hermes_home()
if not cfg_get(read_raw_config(), "browser", "record_sessions", default=False):
return
recordings_dir = hermes_home / "browser_recordings"
recordings_dir.mkdir(parents=True, exist_ok=True)
_lifecycle._cleanup_old_recordings(max_age_hours=72)
recording_path = recordings_dir / f"session_{time.strftime('%Y%m%d_%H%M%S')}_{task_id[:16]}.webm"
result = _session._run_browser_command(task_id, "record", ["start", str(recording_path)])
if result.get("success"):
with _cleanup_lock:
_recording_sessions.add(task_id)
logger.info("Auto-recording browser session %s to %s", task_id, recording_path)
else:
logger.debug("Could not start auto-recording: %s", result.get("error"))
except Exception as e:
logger.debug("Auto-recording setup failed: %s", e)
def _maybe_stop_recording(task_id: str):
"""Stop recording if one is active for this session."""
with _cleanup_lock:
if task_id not in _recording_sessions:
return
try:
result = _session._run_browser_command(task_id, "record", ["stop"])
if result.get("success"):
logger.info("Saved browser recording for session %s: %s", task_id, result.get("data", {}).get("path", ""))
except Exception as e:
logger.debug("Could not stop recording for %s: %s", task_id, e)
finally:
with _cleanup_lock:
_recording_sessions.discard(task_id)
_GET_IMAGES_JS = "JSON.stringify([...document.images].map(img => ({src: img.src, alt: img.alt || '', width: img.naturalWidth, height: img.naturalHeight})).filter(img => img.src && !img.src.startsWith('data:')))"
def browser_get_images(task_id: Optional[str] = None) -> str:
"""List the page's images (src, alt, natural size), excluding data: URIs."""
if _is_camofox_mode():
return _camofox("camofox_get_images", task_id)
effective_task_id = _last_session_key(task_id or "default")
result = _session._run_browser_command(effective_task_id, "eval", [_GET_IMAGES_JS])
if not result.get("success"):
return _failed_response(result, "Failed to get images")
blocked = _blocked_private_page_content(effective_task_id)
if blocked is not None:
return blocked
raw_result = result.get("data", {}).get("result", "[]")
try:
images = json.loads(raw_result) if isinstance(raw_result, str) else raw_result
return _json_with_fallback({"success": True, "images": _snapshot._redact_browser_output(images), "count": len(images)}, result)
except json.JSONDecodeError:
return _json_with_fallback({"success": True, "images": [], "count": 0, "warning": "Could not parse image data"}, result)
_LP_VISION_FALLBACK_REASON = "Lightpanda has no graphical renderer for screenshots; used Chrome for vision capture."
from tools import browser_tool_vision as _vision
def _capture_vision_screenshot(effective_task_id: str, annotate: bool, screenshot_path: Path, lp_prerouted: bool):
"""Take (or adopt the pre-routed) screenshot; returns ``(result, path, error_json_or_None)``."""
if lp_prerouted and screenshot_path.exists():
result = _lp._annotate_lightpanda_fallback(
{"success": True, "data": {"path": str(screenshot_path)}}, _LP_VISION_FALLBACK_REASON)
else:
screenshot_args = (["--annotate"] if annotate else []) + ["--full", str(screenshot_path)]
# A failed Lightpanda pre-route forces Chrome so _run_browser_command
# doesn't trigger a redundant LP fallback.
result = _session._run_browser_command(effective_task_id, "screenshot", screenshot_args,
_engine_override="auto" if lp_prerouted else None)
if not result.get("success"):
return result, screenshot_path, _json_with_fallback(_err(
f"Failed to take screenshot ({_vision._vision_mode_label()} mode): {result.get('error', 'Unknown error')}"
), result)
if result.get("data", {}).get("path"):
screenshot_path = Path(result["data"]["path"])
if not screenshot_path.exists():
return result, screenshot_path, _dumps(_err(
f"Screenshot file was not created at {screenshot_path} ({_vision._vision_mode_label()} mode). "
f"This may indicate a socket path issue (macOS /var/folders/), "
f"a missing Chromium install ('agent-browser install'), "
f"or a stale daemon process."
))
return result, screenshot_path, None
def browser_vision(question: str, annotate: bool = False, task_id: Optional[str] = None) -> Union[str, Dict[str, Any]]:
"""Screenshot the current page for visual inspection. Native-vision models get the image
attached to the conversation; otherwise the auxiliary vision model returns a text
analysis. The file is kept and its path returned (MEDIA:<path>)."""
if _is_camofox_mode():
return _camofox("camofox_vision", question, annotate, task_id)
import uuid as uuid_mod
from hermes_constants import get_hermes_dir
screenshots_dir = get_hermes_dir("cache/screenshots", "browser_screenshots")
screenshot_path = screenshots_dir / f"browser_screenshot_{uuid_mod.uuid4().hex}.png"
effective_task_id = _last_session_key(task_id or "default")
blocked = _blocked_private_page_content(effective_task_id)
if blocked is not None:
return blocked
_lp_prerouted, _lp_fallback_warning, screenshot_path = _vision._lightpanda_vision_preroute(
effective_task_id, annotate, screenshot_path)
result: Dict[str, Any] = {}
try:
screenshots_dir.mkdir(parents=True, exist_ok=True)
_lifecycle._cleanup_old_screenshots(screenshots_dir, max_age_hours=24)
result, screenshot_path, error = _capture_vision_screenshot(
effective_task_id, annotate, screenshot_path, _lp_prerouted)
if error is not None:
return error
# Native image routing: attach the screenshot directly instead of describing it
# through an aux vision LLM (no information loss).
from tools.vision_tools import _should_use_native_vision_fast_path
if _should_use_native_vision_fast_path():
return _vision._native_vision_result(screenshot_path, question, annotate, result, _lp_fallback_warning)
analysis = _vision._analyze_screenshot_with_aux_llm(screenshot_path, question)
response_data = {"success": True, "analysis": analysis or "Vision analysis returned no content.",
"screenshot_path": str(screenshot_path)}
_lp._copy_fallback_warning(response_data, result)
if annotate and result.get("data", {}).get("annotations"):
response_data["annotations"] = result["data"]["annotations"]
return _dumps(response_data)
except Exception as e:
# Keep a captured screenshot — the failure is in the analysis, not the capture,
# and deleting it loses evidence. The 24-hour cleanup bounds disk growth.
logger.warning("browser_vision failed: %s", e, exc_info=True)
error_info = _err(f"Error during vision analysis: {str(e)}")
if screenshot_path.exists():
error_info["screenshot_path"] = str(screenshot_path)
error_info["note"] = "Screenshot was captured but vision analysis failed. You can still share it via MEDIA:<path>."
_lp._copy_fallback_warning(error_info, result)
return _dumps(error_info)
# ---------------------------------------------------------------------------
# Registry
# ---------------------------------------------------------------------------
from tools.registry import registry, tool_error
from tools.browser_extension_router import extension_controller_available, routed_browser_handler
_BROWSER_SCHEMA_MAP = {s["name"]: s for s in BROWSER_TOOL_SCHEMAS}
def check_browser_routed_requirements(action: str = "browser_snapshot") -> bool:
"""Availability gate for tools that can use either browser backend."""
return _install.check_browser_requirements() or extension_controller_available(action)
def _fallback_call(fn_name: str, arg_defaults: Dict[str, Any], extra_kw: tuple = ()):
"""Adapter from the registry's ``(args, kw)`` to ``<fn_name>(**schema_args, task_id=...)``;
the function is looked up in module globals at call time so monkeypatching works."""
def call(args, kw):
params = {a: args.get(a, d) for a, d in arg_defaults.items()}
params["task_id"] = kw.get("task_id")
for k in extra_kw:
params[k] = kw.get(k)
return globals()[fn_name](**params)
return call
# (tool name, emoji, availability gate, schema-arg defaults[, extra kw names]); the tool
# function is the module global of the same name. Routed-through-extension tools (gate None)
# use the per-action gate; get_images/console/vision keep the plain requirement checks.
_BROWSER_TOOL_TABLE = (
("browser_navigate", "🌐", None, {"url": ""}),
("browser_snapshot", "📸", None, {"full": False}, ("user_task",)),
("browser_click", "👆", None, {"ref": ""}),
("browser_type", "⌨️", None, {"ref": "", "text": ""}),
("browser_scroll", "📜", None, {"direction": "down"}),
("browser_back", "◀️", None, {}),
("browser_press", "⌨️", None, {"key": ""}),
("browser_get_images", "🖼️", _install.check_browser_requirements, {}),
("browser_vision", "👁️", _install.check_browser_vision_requirements, {"question": "", "annotate": False}),
("browser_console", "🖥️", _install.check_browser_requirements, {"clear": False, "expression": None}),
)
def _routed_check_fn(name: str):
"""Per-action availability gate (a named function, as the registry expects)."""
def check() -> bool:
return check_browser_routed_requirements(name)
check.__name__ = check.__qualname__ = f"check_{name}_requirements"
return check
def _routed_handler(name: str, fallback):
def handler(args, **kw):
return routed_browser_handler(name, args, fallback=lambda: fallback(args, kw),
task_id=kw.get("task_id"), session_id=kw.get("session_id"))
return handler
for _name, _emoji, _check_fn, _defaults, *_extra in _BROWSER_TOOL_TABLE:
if _check_fn is None: # also binds the legacy check_browser_<x>_requirements globals (tests + callers)
_check_fn = globals()[f"check_{_name}_requirements"] = _routed_check_fn(_name)
registry.register(name=_name, toolset="browser", schema=_BROWSER_SCHEMA_MAP[_name],
handler=_routed_handler(_name, _fallback_call(_name, _defaults, *_extra)),
check_fn=_check_fn, emoji=_emoji)
# ---- 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.
from typing import List # noqa: F401,E402
from typing import Tuple # noqa: F401,E402
import contextlib # noqa: F401,E402
from datetime import datetime # noqa: F401,E402
import functools # noqa: F401,E402
import re # noqa: F401,E402
import shutil # noqa: F401,E402
import signal # noqa: F401,E402
from datetime import timezone # noqa: F401,E402
SNAPSHOT_SUMMARIZE_THRESHOLD = DEFAULT_SNAPSHOT_THRESHOLD
_PLUGIN_COMPAT_LAZY = {
'BrowserUseProvider': ('plugins.browser.browser_use.provider', 'BrowserUseBrowserProvider'),
'BrowserbaseProvider': ('plugins.browser.browserbase.provider', 'BrowserbaseBrowserProvider'),
'CloudBrowserProvider': ('agent.browser_provider', 'BrowserProvider'),
'FirecrawlProvider': ('plugins.browser.firecrawl.provider', 'FirecrawlBrowserProvider'),
'agent_browser_runnable': ('hermes_constants', 'agent_browser_runnable'),
'check_browser_requirements': ('tools.browser_tool_install', 'check_browser_requirements'),
'check_browser_vision_requirements': ('tools.browser_tool_install', 'check_browser_vision_requirements'),
'cleanup_all_browsers': ('tools.browser_tool_lifecycle', 'cleanup_all_browsers'),
'cleanup_browser': ('tools.browser_tool_lifecycle', 'cleanup_browser'),
'get_hermes_home_override': ('hermes_constants', 'get_hermes_home_override'),
'hermes_home_key': ('hermes_constants', 'hermes_home_key'),
'is_truthy_value': ('utils', 'is_truthy_value'),
'lightpanda_engine_status': ('tools.browser_tool_lightpanda_fallback', 'lightpanda_engine_status'),
'node_tool_runnable': ('hermes_constants', 'node_tool_runnable'),
'normalize_browser_cloud_provider': ('tools.tool_backend_helpers', 'normalize_browser_cloud_provider'),
'reset_hermes_home_override': ('hermes_constants', 'reset_hermes_home_override'),
'set_hermes_home_override': ('hermes_constants', 'set_hermes_home_override'),
'warm_agent_browser_npx_cache': ('tools.browser_tool_install', 'warm_agent_browser_npx_cache'),
'windows_hide_flags': ('hermes_cli._subprocess_compat', 'windows_hide_flags'),
}
def __getattr__(name): # PEP 562 — lazy so no import cycles
target = _PLUGIN_COMPAT_LAZY.get(name)
if target is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib
from hermes_cli.plugin_compat import warn_once
warn_once(__name__, name, *target)
return getattr(importlib.import_module(target[0]), target[1])
# ---- END PLUGIN-COMPAT ----