simplify(compat): tools/browser_tool + browser_supervisor — drop 114 re-exports + 6 legacy aliases + PEP 562 requests/call_llm hook, repoint 21 non-test callers; siblings read sibling names directly
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
"""Browser provider registry: cloud browser backends registered by plugins via
|
||||
:meth:`PluginContext.register_browser_provider`, consumed by ``tools.browser_tool._get_cloud_provider``.
|
||||
:meth:`PluginContext.register_browser_provider`, consumed by ``tools.browser_tool_cloud._get_cloud_provider``.
|
||||
|
||||
Active-provider precedence (see :func:`_resolve`): ``browser.cloud_provider`` in config.yaml wins
|
||||
regardless of ``is_available()`` (so the dispatcher surfaces a typed "X_API_KEY is not set" error
|
||||
|
||||
@@ -1401,7 +1401,7 @@ class GatewayShutdownMixin:
|
||||
cleanup_all_environments()
|
||||
|
||||
def _cleanup_browsers() -> None:
|
||||
from tools.browser_tool import cleanup_all_browsers
|
||||
from tools.browser_tool_lifecycle import cleanup_all_browsers
|
||||
cleanup_all_browsers()
|
||||
|
||||
_step("cleanup_all_environments", _cleanup_environments)
|
||||
|
||||
@@ -501,7 +501,7 @@ def _browser_connect(cli, cdp_url: str) -> None:
|
||||
return
|
||||
cdp_url, port = normalized
|
||||
# Clear any existing browser sessions so the next tool call uses the new backend
|
||||
_probe("tools.browser_tool", "cleanup_all_browsers", None)
|
||||
_probe("tools.browser_tool_lifecycle", "cleanup_all_browsers", None)
|
||||
print()
|
||||
# Already serving CDP? For the default-local URL probe both loopbacks: a squatter
|
||||
# on 127.0.0.1:<port> (e.g. an IDE debugger) can push the browser to bind [::1] only.
|
||||
@@ -543,7 +543,7 @@ def _browser_disconnect(cli) -> None:
|
||||
"(already using default mode)")
|
||||
os.environ.pop("BROWSER_CDP_URL", None)
|
||||
with suppress(Exception):
|
||||
from tools.browser_tool import cleanup_all_browsers
|
||||
from tools.browser_tool_lifecycle import cleanup_all_browsers
|
||||
from tools.browser_tool_cdp import _stop_cdp_supervisor
|
||||
_stop_cdp_supervisor("default")
|
||||
cleanup_all_browsers()
|
||||
@@ -586,7 +586,7 @@ def _browser_status() -> None:
|
||||
except Exception:
|
||||
print(" Status: ⚠ not reachable (browser may not be running)")
|
||||
else:
|
||||
provider = _probe("tools.browser_tool", "_get_cloud_provider", None)
|
||||
provider = _probe("tools.browser_tool_cloud", "_get_cloud_provider", None)
|
||||
if provider is not None:
|
||||
print(f"🌐 Browser: {provider.display_name} (cloud)")
|
||||
_print_lightpanda_engine_status()
|
||||
|
||||
@@ -36,13 +36,13 @@ def _has_system_browser() -> bool:
|
||||
|
||||
def _has_npx_agent_browser() -> bool:
|
||||
"""agent-browser resolves lazily via npx on the default install, invisible to the PATH/managed-dir
|
||||
probes above. Mirror tools.browser_tool.check_browser_requirements's Termux carve-out so this
|
||||
probes above. Mirror tools.browser_tool_install.check_browser_requirements's Termux carve-out so this
|
||||
check can't diverge from what browser tools actually find.
|
||||
|
||||
See #43564.
|
||||
"""
|
||||
try:
|
||||
from tools.browser_tool import _find_agent_browser, _is_npx_agent_browser_sentinel, _requires_real_termux_browser_install
|
||||
from tools.browser_tool_install import _find_agent_browser, _is_npx_agent_browser_sentinel, _requires_real_termux_browser_install
|
||||
browser_cmd = _find_agent_browser(validate=False)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@@ -247,7 +247,7 @@ def _check_terminal_backend(should_fix: bool, f: Finding) -> None:
|
||||
def _check_agent_browser(should_fix: bool) -> bool:
|
||||
"""agent-browser resolution; returns True when browser tools will find a usable install.
|
||||
|
||||
Mirrors ``tools.browser_tool._find_agent_browser``'s own cascade (lazy npx or a global/Hermes-managed
|
||||
Mirrors ``tools.browser_tool_install._find_agent_browser``'s own cascade (lazy npx or a global/Hermes-managed
|
||||
install) so doctor can't diverge from the tools; validate=False keeps it a cheap, side-effect-free check.
|
||||
"""
|
||||
try:
|
||||
@@ -295,7 +295,9 @@ def _check_chromium() -> None:
|
||||
"""
|
||||
from hermes_cli.doctor import PROJECT_ROOT
|
||||
try:
|
||||
from tools.browser_tool import _is_camofox_mode, _get_cloud_provider, _get_cdp_override_raw
|
||||
from tools.browser_tool import _is_camofox_mode
|
||||
from tools.browser_tool_cloud import _get_cloud_provider
|
||||
from tools.browser_tool_cdp import _get_cdp_override_raw
|
||||
from tools.browser_tool_install import _chromium_installed
|
||||
from tools.browser_tool_lightpanda_fallback import _using_lightpanda_engine
|
||||
except Exception:
|
||||
|
||||
@@ -187,11 +187,11 @@ def _has_agent_browser() -> bool:
|
||||
from hermes_constants import agent_browser_runnable
|
||||
|
||||
# agent-browser resolves lazily via npx for most installs, which a bare PATH + node_modules
|
||||
# probe can't see. Mirror the local-CLI tail of tools.browser_tool.check_browser_requirements
|
||||
# probe can't see. Mirror the local-CLI tail of tools.browser_tool_install.check_browser_requirements
|
||||
# (same cascade, same Termux carve-out) so setup/status can't diverge from runtime;
|
||||
# validate=False keeps this a cheap existence check with no subprocess spawn.
|
||||
try:
|
||||
from tools.browser_tool import _find_agent_browser, _requires_real_termux_browser_install
|
||||
from tools.browser_tool_install import _find_agent_browser, _requires_real_termux_browser_install
|
||||
except Exception:
|
||||
# Runtime probe unavailable: fall back to binary presence rather than crashing. Rungs: PATH;
|
||||
# Hermes-managed Node dirs ($HERMES_HOME/node, prepended to PATH at runtime but usually absent
|
||||
@@ -219,11 +219,12 @@ def _has_agent_browser() -> bool:
|
||||
def _local_browser_runnable() -> bool:
|
||||
"""True when the *local* browser backend would actually start: the CLI must be present AND a
|
||||
Chromium build on disk (else agent-browser hangs until the command timeout) unless the Lightpanda
|
||||
engine is selected. Mirrors the local-mode tail of tools.browser_tool.check_browser_requirements."""
|
||||
engine is selected. Mirrors the local-mode tail of tools.browser_tool_install.check_browser_requirements."""
|
||||
if not _has_agent_browser():
|
||||
return False
|
||||
try:
|
||||
from tools.browser_tool import _chromium_installed, _using_lightpanda_engine
|
||||
from tools.browser_tool_install import _chromium_installed
|
||||
from tools.browser_tool_lightpanda_fallback import _using_lightpanda_engine
|
||||
except Exception:
|
||||
return True # runtime probe unavailable: fall back to binary presence rather than crashing
|
||||
return _using_lightpanda_engine() or _chromium_installed()
|
||||
|
||||
@@ -102,15 +102,16 @@ def _post_setup_agent_browser(post_setup_key: str) -> None:
|
||||
# check_browser_requirements).
|
||||
# agent-browser is no longer a root package.json dependency (#43564) — it resolves lazily via npx
|
||||
# for most installs, which a bare PATH + node_modules probe can't see. Mirror the local-CLI tail of
|
||||
# :func:`tools.browser_tool.check_browser_requirements` (same cascade, same Termux carve-out) so the
|
||||
# :func:`tools.browser_tool_install.check_browser_requirements` (same cascade, same Termux carve-out) so the
|
||||
# setup/status surfaces can't diverge from what browser tools actually find at runtime;
|
||||
# validate=False keeps this a cheap existence check with no subprocess spawn.
|
||||
# agent-browser is no longer a root package.json dependency (#43564) — it resolves lazily via npx
|
||||
# (or a global/Hermes-managed install) instead of a local `npm install`, so there's no node_modules/
|
||||
# population step here anymore.
|
||||
from tools.browser_tool import (
|
||||
from tools.browser_tool import AGENT_BROWSER_NPX_SPEC
|
||||
from tools.browser_tool_install import (
|
||||
_chromium_installed, _running_in_docker, _find_agent_browser, _resolve_npx_bin,
|
||||
_is_npx_agent_browser_sentinel, AGENT_BROWSER_NPX_SPEC)
|
||||
_is_npx_agent_browser_sentinel)
|
||||
except Exception as exc: # pragma: no cover — defensive
|
||||
_print_warning(f" Could not check Chromium status: {exc}")
|
||||
return
|
||||
|
||||
@@ -94,7 +94,8 @@ def main():
|
||||
print("agent-browser CLI not found — skipping subprocess bench.")
|
||||
sub_times = []
|
||||
else:
|
||||
from tools.browser_tool import _run_browser_command, _last_session_key
|
||||
from tools.browser_tool_session import _run_browser_command
|
||||
from tools.browser_tool import _last_session_key
|
||||
task_id = _last_session_key("bench-eval")
|
||||
sub_times = []
|
||||
for _ in range(args.iterations):
|
||||
|
||||
@@ -69,7 +69,7 @@ def get_camofox_url() -> str:
|
||||
|
||||
def _config_cdp_url() -> str:
|
||||
"""Persistent ``browser.cdp_url`` from config.yaml, or "" (read here, not via
|
||||
``browser_tool._get_cdp_override`` — circular import)."""
|
||||
``browser_tool_cdp._get_cdp_override`` — circular import)."""
|
||||
try:
|
||||
from hermes_cli.config import read_raw_config # late-bound: tests patch the source module
|
||||
browser_cfg = read_raw_config().get("browser", {})
|
||||
@@ -86,7 +86,7 @@ def is_camofox_mode() -> bool:
|
||||
Selection is ``browser.cloud_provider: camofox``; ``CAMOFOX_URL`` is only the address
|
||||
and never overrides a different stored selection (legacy: with no selection ever
|
||||
written, a set ``CAMOFOX_URL`` still activates Camofox). A CDP override (``BROWSER_CDP_URL``
|
||||
env or ``browser.cdp_url``, same precedence as ``browser_tool._get_cdp_override()``) wins.
|
||||
env or ``browser.cdp_url``, same precedence as ``browser_tool_cdp._get_cdp_override()``) wins.
|
||||
"""
|
||||
if os.getenv("BROWSER_CDP_URL", "").strip() or _config_cdp_url():
|
||||
return False
|
||||
@@ -344,7 +344,8 @@ def _fetch_snapshot(session: Dict[str, Any]) -> tuple[str, int]:
|
||||
"""``(snapshot_text, refs_count)`` truncated like the main browser tool (line boundaries,
|
||||
full tree stored to cache/web, read_file pointer appended). Lazy import: ``browser_tool``
|
||||
imports this module."""
|
||||
from tools.browser_tool import _truncate_snapshot, get_browser_snapshot_threshold
|
||||
from tools.browser_tool_snapshot import _truncate_snapshot
|
||||
from tools.browser_tool import get_browser_snapshot_threshold
|
||||
data = _snapshot_data(session)
|
||||
snapshot, threshold = data.get("snapshot", ""), get_browser_snapshot_threshold()
|
||||
if len(snapshot) > threshold:
|
||||
@@ -409,7 +410,7 @@ def _camofox_private_page_block(session: Dict[str, Any], task_id: Optional[str],
|
||||
the SSRF guard applies (non-local backend, not a local sidecar, ``allow_private_urls``
|
||||
unset); fail-open on probe failure like sibling guards. Lazy import (cycle).
|
||||
"""
|
||||
from tools.browser_tool import _camofox_current_page_private_url, _eval_ssrf_guard_active
|
||||
from tools.browser_tool_eval_policy import _camofox_current_page_private_url, _eval_ssrf_guard_active
|
||||
if not _eval_ssrf_guard_active(task_id or "default"):
|
||||
return None
|
||||
blocked_url = _camofox_current_page_private_url(session["tab_id"], session["user_id"])
|
||||
|
||||
@@ -101,9 +101,9 @@ def _run_async(coro):
|
||||
|
||||
|
||||
def _resolve_cdp_endpoint() -> str:
|
||||
"""Normalized CDP WebSocket URL via ``browser_tool._get_cdp_override``, or ""."""
|
||||
"""Normalized CDP WebSocket URL via ``browser_tool_cdp._get_cdp_override``, or ""."""
|
||||
try:
|
||||
from tools.browser_tool import _get_cdp_override # type: ignore[import-not-found]
|
||||
from tools.browser_tool_cdp import _get_cdp_override # type: ignore[import-not-found]
|
||||
return (_get_cdp_override() or "").strip()
|
||||
except Exception as exc: # pragma: no cover — defensive
|
||||
logger.debug("browser_cdp: failed to resolve CDP endpoint: %s", exc)
|
||||
@@ -114,6 +114,11 @@ def _blocked(message: str, method: str) -> str:
|
||||
return tool_error(message, method=method, cdp_docs=CDP_DOCS_URL)
|
||||
|
||||
|
||||
def _expression_private_target(expression: str) -> Optional[str]:
|
||||
from tools.browser_tool_eval_policy import _expression_targets_private_url
|
||||
return _expression_targets_private_url(expression)
|
||||
|
||||
|
||||
def _navigate_private_target(bt: Any, params: Dict[str, Any]) -> Optional[str]:
|
||||
"""Blocked URL literal for ``Page.navigate`` params, else ``None``."""
|
||||
from tools.browser_tool_eval_policy import _url_blocked
|
||||
@@ -125,7 +130,7 @@ def _navigate_private_target(bt: Any, params: Dict[str, Any]) -> Optional[str]:
|
||||
_METHOD_PARAM_GUARDS = {
|
||||
"Page.navigate": (_navigate_private_target,
|
||||
"Blocked: CDP Page.navigate target is a private or internal address ({})."),
|
||||
"Runtime.evaluate": (lambda bt, params: bt._expression_targets_private_url(str(params.get("expression") or "")),
|
||||
"Runtime.evaluate": (lambda bt, params: _expression_private_target(str(params.get("expression") or "")),
|
||||
"Blocked: CDP Runtime.evaluate expression targets a private or internal address ({})."),
|
||||
}
|
||||
|
||||
@@ -139,7 +144,8 @@ def _browser_cdp_private_guard(*, task_id: str, method: str, params: Dict[str, A
|
||||
"""
|
||||
try:
|
||||
from tools import browser_tool as bt # type: ignore[import-not-found]
|
||||
if not bt._eval_ssrf_guard_active(task_id): # type: ignore[attr-defined]
|
||||
from tools import browser_tool_eval_policy as policy
|
||||
if not policy._eval_ssrf_guard_active(task_id):
|
||||
return None
|
||||
guard = _METHOD_PARAM_GUARDS.get(method)
|
||||
if guard is not None:
|
||||
@@ -148,7 +154,7 @@ def _browser_cdp_private_guard(*, task_id: str, method: str, params: Dict[str, A
|
||||
if literal:
|
||||
return _blocked(template.format(literal), method)
|
||||
if method not in _CDP_PRIVATE_PAGE_ALLOWED_METHODS:
|
||||
blocked_url = bt._current_page_private_url(task_id) # type: ignore[attr-defined]
|
||||
blocked_url = policy._current_page_private_url(task_id)
|
||||
if blocked_url:
|
||||
return _blocked(f"Blocked: page URL targets a private or internal address ({blocked_url}). "
|
||||
f"Raw CDP method {method!r} could expose private page content or state.", method)
|
||||
@@ -375,7 +381,8 @@ def _browser_cdp_check() -> bool:
|
||||
surfaced). Raw (no-I/O) gate: check_fns run at every startup, and resolving the endpoint
|
||||
over HTTP here would block launch on a stale endpoint."""
|
||||
try:
|
||||
from tools.browser_tool import _get_cdp_override_raw, check_browser_requirements # type: ignore[import-not-found]
|
||||
from tools.browser_tool_cdp import _get_cdp_override_raw
|
||||
from tools.browser_tool_install import check_browser_requirements # type: ignore[import-not-found]
|
||||
except ImportError as exc: # pragma: no cover — defensive
|
||||
logger.debug("browser_cdp check: browser_tool import failed: %s", exc)
|
||||
return False
|
||||
|
||||
@@ -67,7 +67,7 @@ def find_lightpanda_binary() -> Optional[str]:
|
||||
return None
|
||||
path_env = os.environ.get("PATH", "")
|
||||
try:
|
||||
from tools.browser_tool import _merge_browser_path
|
||||
from tools.browser_tool_install import _merge_browser_path
|
||||
path_env = _merge_browser_path(path_env)
|
||||
except Exception as e:
|
||||
logger.debug("browser PATH merge unavailable: %s", e)
|
||||
|
||||
@@ -5,7 +5,7 @@ persistent WebSocket, ``Page`` / ``Runtime`` / ``Target`` events on every attach
|
||||
session (top page + auto-attached OOPIF / worker targets), pending dialogs + frame
|
||||
tree exposed via a thread-safe snapshot. Not in the tool schema — output reaches the
|
||||
agent via ``browser_snapshot`` / ``browser_dialog``. Dialog capture and frame tracking
|
||||
are mixins (``browser_supervisor_dialogs`` / ``browser_supervisor_frames``) re-exported here.
|
||||
are mixins (``browser_supervisor_dialogs`` / ``browser_supervisor_frames``).
|
||||
Design spec: ``website/docs/developer-guide/browser-supervisor.md``.
|
||||
"""
|
||||
|
||||
@@ -20,15 +20,11 @@ import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple, TYPE_CHECKING
|
||||
|
||||
from tools.browser_supervisor_dialogs import ( # noqa: F401 — re-exported
|
||||
DEFAULT_DIALOG_POLICY, DEFAULT_DIALOG_TIMEOUT_S, DIALOG_BRIDGE_HOST, DIALOG_BRIDGE_URL_PATTERN,
|
||||
DIALOG_POLICY_AUTO_ACCEPT, DIALOG_POLICY_AUTO_DISMISS, DIALOG_POLICY_MUST_RESPOND, RECENT_DIALOGS_MAX,
|
||||
_DIALOG_BRIDGE_SCRIPT, _VALID_POLICIES, DialogRecord, DialogSupervisionMixin, PendingDialog,
|
||||
_redact_supervisor_text, _trim_ring,
|
||||
)
|
||||
from tools.browser_supervisor_frames import ( # noqa: F401 — re-exported
|
||||
FRAME_TREE_MAX_ENTRIES, FRAME_TREE_MAX_OOPIF_DEPTH, FrameInfo, FrameTrackingMixin,
|
||||
from tools.browser_supervisor_dialogs import (
|
||||
DEFAULT_DIALOG_POLICY, DEFAULT_DIALOG_TIMEOUT_S, RECENT_DIALOGS_MAX, _VALID_POLICIES, DialogRecord,
|
||||
DialogSupervisionMixin, PendingDialog,
|
||||
)
|
||||
from tools.browser_supervisor_frames import FrameInfo, FrameTrackingMixin
|
||||
|
||||
# ``websockets`` costs ~22 ms at import and is only needed once a supervisor connects.
|
||||
if TYPE_CHECKING:
|
||||
@@ -475,8 +471,4 @@ class _SupervisorRegistry:
|
||||
SUPERVISOR_REGISTRY = _SupervisorRegistry()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CDPSupervisor", "DEFAULT_DIALOG_POLICY", "DEFAULT_DIALOG_TIMEOUT_S", "DIALOG_POLICY_AUTO_ACCEPT",
|
||||
"DIALOG_POLICY_AUTO_DISMISS", "DIALOG_POLICY_MUST_RESPOND", "DialogRecord", "FrameInfo", "PendingDialog",
|
||||
"SUPERVISOR_REGISTRY", "SupervisorSnapshot", "_SupervisorRegistry",
|
||||
]
|
||||
__all__ = ["CDPSupervisor", "SUPERVISOR_REGISTRY", "SupervisorSnapshot", "_SupervisorRegistry"]
|
||||
|
||||
@@ -5,16 +5,14 @@ Backends — local headless Chromium, Browser Use / Browserbase / Firecrawl clou
|
||||
(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; their names are re-imported
|
||||
here so ``patch("tools.browser_tool.X")`` keeps working.
|
||||
Sibling ``browser_tool_*`` modules hold extracted clusters.
|
||||
"""
|
||||
|
||||
import atexit
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess # noqa: F401 (tests patch tools.browser_tool.subprocess.Popen)
|
||||
import shutil # noqa: F401 (tests patch tools.browser_tool.shutil.which)
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
@@ -22,28 +20,9 @@ import time
|
||||
from typing import Dict, Any, Optional, Union
|
||||
from pathlib import Path
|
||||
from agent.redact import redact_cdp_url
|
||||
from hermes_constants import ( # noqa: F401 (test-patchable surface, read via origin by sibling modules)
|
||||
agent_browser_runnable, get_hermes_home, get_hermes_home_override, hermes_home_key, node_tool_runnable,
|
||||
)
|
||||
from utils import env_int, is_truthy_value # noqa: F401 (read via origin by sibling modules)
|
||||
from hermes_constants import get_hermes_home
|
||||
from utils import env_int
|
||||
from hermes_cli.config import DEFAULT_CONFIG, cfg_get
|
||||
from hermes_cli._subprocess_compat import windows_hide_flags # noqa: F401 (test-patchable; read via origin)
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""PEP 562 lazy attributes: ``requests`` / ``call_llm`` load on first use and are
|
||||
bound into module globals so ``patch("tools.browser_tool.requests.get")`` works."""
|
||||
if name == "requests":
|
||||
import requests as _requests
|
||||
|
||||
globals()["requests"] = _requests
|
||||
return _requests
|
||||
if name == "call_llm":
|
||||
from agent.auxiliary_client import call_llm as _call_llm
|
||||
|
||||
globals()["call_llm"] = _call_llm
|
||||
return _call_llm
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
# Env keys re-added to the agent-browser subprocess AFTER credential stripping.
|
||||
@@ -84,11 +63,8 @@ except Exception:
|
||||
_normalize_url_for_request = lambda url: url # noqa: E731 — best-effort fallback
|
||||
_sensitive_query_param_name = lambda url: None # noqa: E731 — best-effort fallback
|
||||
# Browser-provider ABC + registry; per-vendor providers live under
|
||||
# ``plugins/browser/<vendor>/``. Legacy class names are re-exported as shims.
|
||||
# The dispatcher consults the registry; the legacy class names are re-exported below as backward-compat
|
||||
# shims for callers that import them from this module. See #25214.
|
||||
from agent.browser_provider import BrowserProvider as CloudBrowserProvider # noqa: F401 (legacy alias)
|
||||
from agent.browser_registry import get_provider as _registry_get_browser_provider # noqa: F401 (test-patchable)
|
||||
# ``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:
|
||||
@@ -96,10 +72,9 @@ except ImportError:
|
||||
# with only ``get_provider``; no mutable registry → constant generation.
|
||||
def _browser_registry_generation(*, scope=None):
|
||||
return (0, 0)
|
||||
from plugins.browser.browserbase.provider import BrowserbaseBrowserProvider as BrowserbaseProvider # noqa: F401 (legacy import surface)
|
||||
from plugins.browser.browser_use.provider import BrowserUseBrowserProvider as BrowserUseProvider # noqa: F401
|
||||
from plugins.browser.firecrawl.provider import FirecrawlBrowserProvider as FirecrawlProvider # noqa: F401
|
||||
from tools.tool_backend_helpers import normalize_browser_cloud_provider # noqa: F401 (read via origin)
|
||||
from plugins.browser.browserbase.provider import BrowserbaseBrowserProvider
|
||||
from plugins.browser.browser_use.provider import BrowserUseBrowserProvider
|
||||
from plugins.browser.firecrawl.provider import FirecrawlBrowserProvider
|
||||
# Optional backends: Camofox (CAMOFOX_URL routes everything through its REST API)
|
||||
# and the Browser Use CLI.
|
||||
try:
|
||||
@@ -122,14 +97,7 @@ _SANE_PATH_DIRS = (
|
||||
)
|
||||
_SANE_PATH = os.pathsep.join(_SANE_PATH_DIRS)
|
||||
|
||||
from tools.browser_tool_install import ( # noqa: F401 (re-exported; tests patch tools.browser_tool.<name>)
|
||||
_discover_homebrew_node_dirs, _browser_candidate_path_dirs, _merge_browser_path,
|
||||
_browser_install_hint, _is_npx_agent_browser_sentinel, _requires_real_termux_browser_install,
|
||||
_termux_browser_install_error, _agent_browser_candidate_present, _resolve_npx_bin,
|
||||
_agent_browser_candidates, _find_agent_browser, warm_agent_browser_npx_cache,
|
||||
_chromium_search_roots, _chromium_installed, _maybe_autoinstall_chromium, _running_in_docker,
|
||||
check_browser_requirements, check_browser_vision_requirements,
|
||||
)
|
||||
from tools import browser_tool_install as _install
|
||||
|
||||
_last_screenshot_cleanup_by_dir: dict[str, float] = {} # throttles full directory scans
|
||||
|
||||
@@ -148,7 +116,6 @@ MIN_FIRST_OPEN_TIMEOUT = 120
|
||||
# ``browser.snapshot_threshold``.
|
||||
DEFAULT_SNAPSHOT_THRESHOLD = 15000
|
||||
MIN_SNAPSHOT_THRESHOLD = 1000
|
||||
SNAPSHOT_SUMMARIZE_THRESHOLD = DEFAULT_SNAPSHOT_THRESHOLD # legacy import surface
|
||||
# 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
|
||||
@@ -162,17 +129,17 @@ NPX_AGENT_BROWSER_SENTINEL = "npx agent-browser"
|
||||
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 the origin.
|
||||
# reset by ``cleanup_all_browsers``. Written/read by the sibling modules via ``browser_tool_origin``.
|
||||
_cached_command_timeout: Optional[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[int] = None
|
||||
_snapshot_threshold_resolved = False
|
||||
_cached_cloud_provider: Optional[CloudBrowserProvider] = None
|
||||
_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[CloudBrowserProvider]] = {}
|
||||
_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
|
||||
@@ -249,16 +216,7 @@ def _get_open_command_timeout(*, first_open: bool = False) -> int:
|
||||
return max(_safe_command_timeout(), MIN_FIRST_OPEN_TIMEOUT if first_open else MIN_OPEN_TIMEOUT)
|
||||
|
||||
|
||||
from tools.browser_tool_session import ( # noqa: F401 (re-exported; tests patch tools.browser_tool.<name>)
|
||||
_needs_chromium_sandbox_bypass, _apply_chromium_sandbox_args, _read_command_output_files,
|
||||
_unlink_command_output_files, _format_browser_timeout_error, _agent_browser_argv,
|
||||
_prepare_session_socket_dir, _agent_browser_command_env, _popen_agent_browser,
|
||||
_create_local_session, _create_lightpanda_session, _local_backend_process_dead,
|
||||
_create_cdp_session, _create_cloud_session_or_fallback, _create_session_for_key,
|
||||
_get_session_info, _discard_timed_out_browser_session, _read_browser_daemon_pid,
|
||||
_browser_daemon_responsive, _handle_browser_command_timeout, _interpret_browser_command_output,
|
||||
_run_browser_command,
|
||||
)
|
||||
from tools import browser_tool_session as _session
|
||||
|
||||
|
||||
def _get_vision_model() -> Optional[str]:
|
||||
@@ -266,10 +224,7 @@ def _get_vision_model() -> Optional[str]:
|
||||
return os.getenv("AUXILIARY_VISION_MODEL", "").strip() or None
|
||||
|
||||
|
||||
from tools.browser_tool_cdp import ( # noqa: F401 (re-exported; tests patch tools.browser_tool.<name>)
|
||||
_resolve_cdp_override, _get_cdp_override_raw, _get_cdp_override, _get_dialog_policy_config,
|
||||
_ensure_cdp_supervisor, _stop_cdp_supervisor,
|
||||
)
|
||||
from tools import browser_tool_cdp as _cdp
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Cloud provider registry — legacy class-name dict is a backward-compat shim:
|
||||
@@ -277,25 +232,14 @@ from tools.browser_tool_cdp import ( # noqa: F401 (re-exported; tests patch to
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
_PROVIDER_REGISTRY: Dict[str, type] = {
|
||||
"browserbase": BrowserbaseProvider, "browser-use": BrowserUseProvider, "firecrawl": FirecrawlProvider,
|
||||
"browserbase": BrowserbaseBrowserProvider, "browser-use": BrowserUseBrowserProvider, "firecrawl": FirecrawlBrowserProvider,
|
||||
}
|
||||
# Frozen import-time copy used to detect test-time monkeypatching. NEVER mutate.
|
||||
_DEFAULT_PROVIDER_REGISTRY: Dict[str, type] = dict(_PROVIDER_REGISTRY)
|
||||
|
||||
from tools.browser_tool_cloud import ( # noqa: F401 (re-exported; tests patch tools.browser_tool.<name>)
|
||||
_is_legacy_provider_registry_overridden, _ensure_browser_plugins_loaded, _get_cloud_provider,
|
||||
_instantiate_explicit_cloud_provider, _autodetect_cloud_provider,
|
||||
_resolve_cloud_provider_uncached, _is_local_mode, _is_local_backend, _get_browser_engine,
|
||||
_is_headed_mode, _should_inject_engine, _auto_local_for_private_urls, _use_real_profile,
|
||||
_allow_private_urls, _resolve_allow_private_urls,
|
||||
)
|
||||
from tools import browser_tool_cloud as _cloud
|
||||
|
||||
from hermes_constants import is_termux as _is_termux_environment # noqa: F401 (read via origin)
|
||||
from tools.browser_tool_lightpanda_fallback import ( # noqa: F401
|
||||
_using_lightpanda_engine, lightpanda_engine_status, _lightpanda_fallback_reason,
|
||||
_needs_lightpanda_fallback, _annotate_lightpanda_fallback, _copy_fallback_warning,
|
||||
_run_chrome_fallback_command, _chrome_fallback_screenshot,
|
||||
)
|
||||
from tools import browser_tool_lightpanda_fallback as _lp
|
||||
|
||||
|
||||
# Single shared real-profile copy-browser session: concurrent tasks reuse it
|
||||
@@ -305,12 +249,6 @@ _real_profile_cdp_lock = threading.Lock()
|
||||
_real_profile_cdp_cache: dict = {}
|
||||
_real_profile_chrome_procs: list = [] # Popen handles of directly-launched real browsers
|
||||
|
||||
from tools.browser_tool_real_profile import ( # noqa: F401
|
||||
_terminate_real_profile_chrome, _cdp_http_ready, _agent_browser_get_cdp, _cdp_on_data_dir,
|
||||
_agent_browser_close_session, _REAL_PROFILE_CHROME_FLAGS, _real_profile_unsupported_reason,
|
||||
_real_profile_snapshot_error, _launch_real_profile_chrome,
|
||||
_attach_agent_browser_to_real_profile, _real_profile_cdp,
|
||||
)
|
||||
|
||||
|
||||
_PRIVATE_HOST_SUFFIXES = (".localhost", ".local", ".lan", ".internal")
|
||||
@@ -357,10 +295,10 @@ def _navigation_session_key(task_id: str, url: str) -> str:
|
||||
if task_id is None:
|
||||
task_id = "default"
|
||||
hybrid = (
|
||||
not _get_cdp_override_raw()
|
||||
not _cdp._get_cdp_override_raw()
|
||||
and not _is_camofox_mode()
|
||||
and _get_cloud_provider() is not None
|
||||
and _auto_local_for_private_urls()
|
||||
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
|
||||
@@ -481,7 +419,7 @@ class _BrowserSessionBackend:
|
||||
return True
|
||||
logger.info("Recycling suspect browser session %s before reuse (%s)", self._session_key, reason)
|
||||
try:
|
||||
_cleanup_single_browser_session(self._session_key)
|
||||
_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)
|
||||
@@ -494,22 +432,13 @@ _cleanup_thread = None
|
||||
_cleanup_running = False
|
||||
_cleanup_lock = threading.Lock() # protects _session_last_activity AND _active_sessions
|
||||
|
||||
from tools.browser_tool_lifecycle import ( # noqa: F401 (re-exported; tests patch tools.browser_tool.<name>)
|
||||
_session_expiry_timestamp, _session_has_expired, _emergency_cleanup_all_sessions,
|
||||
_session_owner_scope, _cleanup_inactive_browser_sessions, _write_owner_pid,
|
||||
_verify_reapable_browser_daemon, _socket_dir_idle_seconds, _owner_pid_alive, _reap_socket_dir,
|
||||
_reap_orphaned_browser_sessions, _browser_cleanup_thread_worker, _start_browser_cleanup_thread,
|
||||
_stop_browser_cleanup_thread, _update_session_activity, _kill_process_tree,
|
||||
_legacy_kill_process_tree, _pid_exists, _cleanup_old_screenshots, _cleanup_old_recordings,
|
||||
_drop_last_active_binding, cleanup_browser, _kill_verified_daemon, _release_session_resources,
|
||||
_force_reap_browser_session, _cleanup_single_browser_session, cleanup_all_browsers,
|
||||
)
|
||||
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(_emergency_cleanup_all_sessions)
|
||||
atexit.register(_stop_browser_cleanup_thread)
|
||||
atexit.register(_lifecycle._emergency_cleanup_all_sessions)
|
||||
atexit.register(_lifecycle._stop_browser_cleanup_thread)
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Tool Schemas
|
||||
@@ -656,9 +585,7 @@ BROWSER_TOOL_SCHEMAS = [
|
||||
},
|
||||
]
|
||||
|
||||
from tools.browser_tool_snapshot import ( # noqa: F401
|
||||
_store_full_snapshot, _truncate_snapshot, _redact_browser_output, _extract_screenshot_path_from_text,
|
||||
)
|
||||
from tools import browser_tool_snapshot as _snapshot
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Browser Tool Functions
|
||||
@@ -689,7 +616,7 @@ def _url_policy_error(url: str, *, auto_local: bool = False) -> Optional[dict]:
|
||||
(2) cloud metadata / IMDS refused UNCONDITIONALLY (a local Chromium on a cloud VM still
|
||||
reaches the host IMDS); (3) private addresses refused unless local, sidecar-routed, or
|
||||
``browser.allow_private_urls``; (4) website policy allow/deny lists."""
|
||||
local = _is_local_backend()
|
||||
local = _cloud._is_local_backend()
|
||||
sensitive_query_key = _sensitive_query_param_name(url)
|
||||
if sensitive_query_key and not local and not auto_local:
|
||||
return _err(
|
||||
@@ -705,7 +632,7 @@ def _url_policy_error(url: str, *, auto_local: bool = False) -> Optional[dict]:
|
||||
# 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 _allow_private_urls() and not _is_safe_url(url):
|
||||
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:
|
||||
@@ -746,15 +673,15 @@ def _post_redirect_block(nav_session_key: str, url: str, final_url: str, auto_lo
|
||||
if _is_always_blocked_url(final_url):
|
||||
what = "a cloud metadata endpoint"
|
||||
elif (
|
||||
not _is_local_backend()
|
||||
not _cloud._is_local_backend()
|
||||
and not auto_local_this_nav
|
||||
and not _allow_private_urls()
|
||||
and not _cloud._allow_private_urls()
|
||||
and not _is_safe_url(final_url)
|
||||
):
|
||||
what = "a private/internal address"
|
||||
else:
|
||||
return None
|
||||
_run_browser_command(nav_session_key, "open", ["about:blank"], timeout=10)
|
||||
_session._run_browser_command(nav_session_key, "open", ["about:blank"], timeout=10)
|
||||
return json.dumps(_err(f"Blocked: redirect landed on {what}"))
|
||||
|
||||
|
||||
@@ -766,20 +693,20 @@ def _snapshot_fields(snap_result: Dict[str, Any]) -> Dict[str, Any]:
|
||||
refs = data.get("refs", {})
|
||||
threshold = get_browser_snapshot_threshold()
|
||||
if len(snapshot_text) > threshold:
|
||||
snapshot_text = _truncate_snapshot(snapshot_text, max_chars=threshold)
|
||||
return {"snapshot": _redact_browser_output(snapshot_text), "element_count": len(refs) if refs else 0}
|
||||
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"):
|
||||
_copy_fallback_warning(response, result)
|
||||
_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 = _run_browser_command(nav_session_key, "snapshot", ["-c"])
|
||||
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)
|
||||
@@ -809,15 +736,15 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str:
|
||||
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(_get_cloud_provider()).__name__ if _get_cloud_provider() else "none")
|
||||
url, type(_cloud._get_cloud_provider()).__name__ if _cloud._get_cloud_provider() else "none")
|
||||
|
||||
session_info = _get_session_info(nav_session_key)
|
||||
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 = _run_browser_command(nav_session_key, "open", [url],
|
||||
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")))
|
||||
@@ -836,7 +763,7 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str:
|
||||
# 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
|
||||
_copy_fallback_warning(response, result)
|
||||
_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)
|
||||
@@ -870,7 +797,7 @@ def browser_snapshot(
|
||||
if _is_camofox_mode():
|
||||
return _camofox("camofox_snapshot", full, task_id)
|
||||
effective_task_id = _last_session_key(task_id or "default")
|
||||
result = _run_browser_command(effective_task_id, "snapshot", [] if full else ["-c"])
|
||||
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")
|
||||
|
||||
@@ -879,7 +806,7 @@ def browser_snapshot(
|
||||
return blocked
|
||||
|
||||
response = {"success": True, **_snapshot_fields(result)}
|
||||
_copy_fallback_warning(response, 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.
|
||||
@@ -889,7 +816,7 @@ def browser_snapshot(
|
||||
if _supervisor is not None:
|
||||
_sv_snap = _supervisor.snapshot()
|
||||
if _sv_snap.active:
|
||||
response.update(_redact_browser_output(_sv_snap.to_dict()))
|
||||
response.update(_snapshot._redact_browser_output(_sv_snap.to_dict()))
|
||||
except Exception as _sv_exc:
|
||||
logger.debug("supervisor snapshot merge failed: %s", _sv_exc)
|
||||
|
||||
@@ -898,7 +825,7 @@ def browser_snapshot(
|
||||
|
||||
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(_copy_fallback_warning(response, result))
|
||||
return _dumps(_lp._copy_fallback_warning(response, result))
|
||||
|
||||
|
||||
def _failed_response(result: Dict[str, Any], default_error: str) -> str:
|
||||
@@ -924,7 +851,7 @@ def _guarded_action(task_id: Optional[str], action: str, command: str, args: lis
|
||||
blocked = _blocked_private_page_action(effective_task_id, action)
|
||||
if blocked is not None:
|
||||
return blocked
|
||||
return _tool_response(_run_browser_command(effective_task_id, command, args), ok, err)
|
||||
return _tool_response(_session._run_browser_command(effective_task_id, command, args), ok, err)
|
||||
|
||||
|
||||
def _at_ref(ref: str) -> str:
|
||||
@@ -948,7 +875,7 @@ def browser_type(ref: str, text: str, task_id: Optional[str] = None) -> str:
|
||||
if blocked is not None:
|
||||
return blocked
|
||||
ref = _at_ref(ref)
|
||||
result = _run_browser_command(effective_task_id, "fill", [ref, text])
|
||||
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).
|
||||
@@ -957,7 +884,7 @@ def browser_type(ref: str, text: str, task_id: Optional[str] = None) -> str:
|
||||
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(_copy_fallback_warning(response, result), text))
|
||||
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:
|
||||
@@ -968,7 +895,7 @@ def browser_scroll(direction: str, task_id: Optional[str] = None) -> str:
|
||||
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 = _run_browser_command(effective_task_id, "scroll", [direction, str(_SCROLL_PIXELS)])
|
||||
result = _session._run_browser_command(effective_task_id, "scroll", [direction, str(_SCROLL_PIXELS)])
|
||||
return _tool_response(result, {"scrolled": direction}, f"Failed to scroll {direction}")
|
||||
|
||||
|
||||
@@ -977,7 +904,7 @@ def browser_back(task_id: Optional[str] = None) -> str:
|
||||
if _is_camofox_mode():
|
||||
return _camofox("camofox_back", task_id)
|
||||
effective_task_id = _last_session_key(task_id or "default")
|
||||
result = _run_browser_command(effective_task_id, "back", [])
|
||||
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).
|
||||
@@ -1002,9 +929,9 @@ def _blocked_private_page_json(blocked_url: str, why: str) -> str:
|
||||
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_ssrf_guard_active(effective_task_id):
|
||||
if not _eval_policy._eval_ssrf_guard_active(effective_task_id):
|
||||
return None
|
||||
blocked_url = _current_page_private_url(effective_task_id)
|
||||
blocked_url = _eval_policy._current_page_private_url(effective_task_id)
|
||||
return _blocked_private_page_json(blocked_url, why) if blocked_url else None
|
||||
|
||||
|
||||
@@ -1026,7 +953,7 @@ def browser_console(clear: bool = False, expression: Optional[str] = None, task_
|
||||
"""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 = _enforce_browser_eval_policy(expression)
|
||||
policy_error = _eval_policy._enforce_browser_eval_policy(expression)
|
||||
if policy_error:
|
||||
return _dumps(_err(policy_error))
|
||||
return _browser_eval(expression, task_id)
|
||||
@@ -1040,33 +967,27 @@ def browser_console(clear: bool = False, expression: Optional[str] = None, task_
|
||||
return blocked
|
||||
|
||||
clear_args = ["--clear"] if clear else []
|
||||
console_result = _run_browser_command(effective_task_id, "console", clear_args)
|
||||
errors_result = _run_browser_command(effective_task_id, "errors", clear_args)
|
||||
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": _redact_browser_output(msg.get("text", "")), "source": "console"}
|
||||
{"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": _redact_browser_output(err.get("message", "")), "source": "exception"}
|
||||
{"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),
|
||||
}
|
||||
_copy_fallback_warning(response, console_result)
|
||||
_lp._copy_fallback_warning(response, console_result)
|
||||
_merge_fallback_warning(response, errors_result)
|
||||
return _dumps(response)
|
||||
|
||||
|
||||
from tools.browser_tool_eval_policy import ( # noqa: F401
|
||||
_eval_ssrf_guard_active, _JS_URL_LITERAL_RE, _expression_targets_private_url,
|
||||
_current_page_private_url, _RISKY_BROWSER_EVAL_PATTERNS, _JS_STRING_LITERAL_RE,
|
||||
_SENSITIVE_BROWSER_EVAL_TOKENS, _allow_unsafe_browser_evaluate, _restrict_browser_evaluate,
|
||||
_decode_js_string_literal, _decoded_js_string_literals, _sensitive_browser_eval_token_reason,
|
||||
_risky_browser_eval_reason, _enforce_browser_eval_policy, _camofox_current_page_private_url,
|
||||
)
|
||||
from tools import browser_tool_eval_policy as _eval_policy
|
||||
|
||||
|
||||
def _parse_eval_value(raw_result: Any) -> Any:
|
||||
@@ -1080,7 +1001,7 @@ def _parse_eval_value(raw_result: Any) -> Any:
|
||||
|
||||
|
||||
def _eval_ok_response(parsed: Any, **extra) -> Dict[str, Any]:
|
||||
return {"success": True, "result": _redact_browser_output(parsed), "result_type": type(parsed).__name__, **extra}
|
||||
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:
|
||||
@@ -1089,7 +1010,7 @@ def _eval_result_or_blocked(effective_task_id: str, parsed: Any, result: Dict[st
|
||||
blocked = _blocked_private_page_content(effective_task_id)
|
||||
if blocked is not None:
|
||||
return blocked
|
||||
return _dumps(_copy_fallback_warning(_eval_ok_response(parsed, **extra), result), default=str)
|
||||
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]:
|
||||
@@ -1131,7 +1052,7 @@ def _eval_failure_response(result: Dict[str, Any]) -> str:
|
||||
"(e.g. .innerText, .href, .src, .value) or use "
|
||||
"JSON.stringify() / a snapshot tool instead."
|
||||
)
|
||||
return json.dumps(_copy_fallback_warning(_err(err), result))
|
||||
return json.dumps(_lp._copy_fallback_warning(_err(err), result))
|
||||
|
||||
|
||||
def _browser_eval(expression: str, task_id: Optional[str] = None) -> str:
|
||||
@@ -1140,8 +1061,8 @@ def _browser_eval(expression: str, task_id: Optional[str] = None) -> str:
|
||||
page-URL recheck closes navigate-then-read."""
|
||||
effective_task_id = _last_session_key(task_id or "default")
|
||||
|
||||
if _eval_ssrf_guard_active(effective_task_id):
|
||||
blocked_literal = _expression_targets_private_url(expression)
|
||||
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 "
|
||||
@@ -1158,7 +1079,7 @@ def _browser_eval(expression: str, task_id: Optional[str] = None) -> str:
|
||||
if fast is not None:
|
||||
return fast
|
||||
|
||||
result = _run_browser_command(effective_task_id, "eval", [expression])
|
||||
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)
|
||||
@@ -1174,8 +1095,8 @@ def _camofox_eval(expression: str, task_id: Optional[str] = None) -> str:
|
||||
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_ssrf_guard_active(task_id or "default"):
|
||||
_blocked_url = _camofox_current_page_private_url(tab_id, user_id)
|
||||
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)
|
||||
|
||||
@@ -1199,9 +1120,9 @@ def _maybe_start_recording(task_id: str):
|
||||
return
|
||||
recordings_dir = hermes_home / "browser_recordings"
|
||||
recordings_dir.mkdir(parents=True, exist_ok=True)
|
||||
_cleanup_old_recordings(max_age_hours=72)
|
||||
_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 = _run_browser_command(task_id, "record", ["start", str(recording_path)])
|
||||
result = _session._run_browser_command(task_id, "record", ["start", str(recording_path)])
|
||||
if result.get("success"):
|
||||
with _cleanup_lock:
|
||||
_recording_sessions.add(task_id)
|
||||
@@ -1218,7 +1139,7 @@ def _maybe_stop_recording(task_id: str):
|
||||
if task_id not in _recording_sessions:
|
||||
return
|
||||
try:
|
||||
result = _run_browser_command(task_id, "record", ["stop"])
|
||||
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:
|
||||
@@ -1241,7 +1162,7 @@ def browser_get_images(task_id: Optional[str] = None) -> str:
|
||||
return _camofox("camofox_get_images", task_id)
|
||||
|
||||
effective_task_id = _last_session_key(task_id or "default")
|
||||
result = _run_browser_command(effective_task_id, "eval", [_GET_IMAGES_JS])
|
||||
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")
|
||||
|
||||
@@ -1252,7 +1173,7 @@ def browser_get_images(task_id: Optional[str] = None) -> str:
|
||||
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": _redact_browser_output(images), "count": len(images)}, 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)
|
||||
|
||||
@@ -1260,32 +1181,29 @@ def browser_get_images(task_id: Optional[str] = None) -> str:
|
||||
_LP_VISION_FALLBACK_REASON = "Lightpanda has no graphical renderer for screenshots; used Chrome for vision capture."
|
||||
|
||||
|
||||
from tools.browser_tool_vision import ( # noqa: F401 (re-exported; tests patch tools.browser_tool.<name>)
|
||||
_vision_mode_label, _lightpanda_vision_preroute, _native_vision_result,
|
||||
_analyze_screenshot_with_aux_llm,
|
||||
)
|
||||
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 = _annotate_lightpanda_fallback(
|
||||
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 = _run_browser_command(effective_task_id, "screenshot", screenshot_args,
|
||||
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_mode_label()} mode): {result.get('error', 'Unknown error')}"
|
||||
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_mode_label()} mode). "
|
||||
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."
|
||||
@@ -1309,12 +1227,12 @@ def browser_vision(question: str, annotate: bool = False, task_id: Optional[str]
|
||||
if blocked is not None:
|
||||
return blocked
|
||||
|
||||
_lp_prerouted, _lp_fallback_warning, screenshot_path = _lightpanda_vision_preroute(
|
||||
_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)
|
||||
_cleanup_old_screenshots(screenshots_dir, max_age_hours=24)
|
||||
_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:
|
||||
@@ -1323,12 +1241,12 @@ def browser_vision(question: str, annotate: bool = False, task_id: Optional[str]
|
||||
# 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 _native_vision_result(screenshot_path, question, annotate, result, _lp_fallback_warning)
|
||||
return _vision._native_vision_result(screenshot_path, question, annotate, result, _lp_fallback_warning)
|
||||
|
||||
analysis = _analyze_screenshot_with_aux_llm(screenshot_path, question)
|
||||
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)}
|
||||
_copy_fallback_warning(response_data, result)
|
||||
_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)
|
||||
@@ -1340,7 +1258,7 @@ def browser_vision(question: str, annotate: bool = False, task_id: Optional[str]
|
||||
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>."
|
||||
_copy_fallback_warning(error_info, result)
|
||||
_lp._copy_fallback_warning(error_info, result)
|
||||
return _dumps(error_info)
|
||||
|
||||
|
||||
@@ -1355,7 +1273,7 @@ _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 check_browser_requirements() or extension_controller_available(action)
|
||||
return _install.check_browser_requirements() or extension_controller_available(action)
|
||||
|
||||
|
||||
def _fallback_call(fn_name: str, arg_defaults: Dict[str, Any], extra_kw: tuple = ()):
|
||||
@@ -1381,9 +1299,9 @@ _BROWSER_TOOL_TABLE = (
|
||||
("browser_scroll", "📜", None, {"direction": "down"}),
|
||||
("browser_back", "◀️", None, {}),
|
||||
("browser_press", "⌨️", None, {"key": ""}),
|
||||
("browser_get_images", "🖼️", check_browser_requirements, {}),
|
||||
("browser_vision", "👁️", check_browser_vision_requirements, {"question": "", "annotate": False}),
|
||||
("browser_console", "🖥️", check_browser_requirements, {"clear": False, "expression": None}),
|
||||
("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}),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""User-supplied CDP endpoint resolution (browser.cdp_url / real-profile), dialog-policy config and the per-task CDP supervisor lifecycle.
|
||||
|
||||
Split out of ``tools/browser_tool.py``; every name is re-imported there (tests monkeypatch ``tools.browser_tool.<name>``).
|
||||
Origin symbols/state go through ``_bt`` (origin module, resolved per call) — no import cycle."""
|
||||
Split out of ``tools/browser_tool.py``. Facade-owned state is read through ``_bt`` (``tools.browser_tool``, resolved per call) — no import cycle."""
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
@@ -67,14 +66,14 @@ def _get_cdp_override() -> str:
|
||||
:func:`_get_cdp_override_raw`.
|
||||
"""
|
||||
_bt = _origin()
|
||||
return _bt._resolve_cdp_override(raw) if (raw := _bt._get_cdp_override_raw()) else ""
|
||||
return _resolve_cdp_override(raw) if (raw := _get_cdp_override_raw()) else ""
|
||||
|
||||
|
||||
def _get_dialog_policy_config() -> Tuple[str, float]:
|
||||
"""Read ``browser.dialog_policy`` + ``browser.dialog_timeout_s``; supervisor defaults when absent/invalid."""
|
||||
_bt = _origin()
|
||||
# Deferred so browser_tool imports in minimal environments.
|
||||
from tools.browser_supervisor import DEFAULT_DIALOG_POLICY, DEFAULT_DIALOG_TIMEOUT_S, _VALID_POLICIES
|
||||
from tools.browser_supervisor_dialogs import DEFAULT_DIALOG_POLICY, DEFAULT_DIALOG_TIMEOUT_S, _VALID_POLICIES
|
||||
policy, timeout_s = DEFAULT_DIALOG_POLICY, DEFAULT_DIALOG_TIMEOUT_S
|
||||
try:
|
||||
from hermes_cli.config import read_raw_config
|
||||
@@ -108,18 +107,18 @@ def _ensure_cdp_supervisor(task_id: str) -> None:
|
||||
snapshots just lack ``pending_dialogs`` / ``frame_tree``.
|
||||
"""
|
||||
_bt = _origin()
|
||||
cdp_url = _bt._get_cdp_override()
|
||||
cdp_url = _get_cdp_override()
|
||||
if not cdp_url:
|
||||
with _bt._cleanup_lock:
|
||||
session_info = _bt._active_sessions.get(task_id, {})
|
||||
maybe = str(session_info.get("cdp_url") or "")
|
||||
if maybe:
|
||||
cdp_url = _bt._resolve_cdp_override(maybe)
|
||||
cdp_url = _resolve_cdp_override(maybe)
|
||||
if not cdp_url:
|
||||
return
|
||||
try:
|
||||
from tools.browser_supervisor import SUPERVISOR_REGISTRY # type: ignore[import-not-found]
|
||||
policy, timeout_s = _bt._get_dialog_policy_config()
|
||||
policy, timeout_s = _get_dialog_policy_config()
|
||||
SUPERVISOR_REGISTRY.get_or_start(task_id=task_id, cdp_url=cdp_url, dialog_policy=policy, dialog_timeout_s=timeout_s)
|
||||
except Exception as exc:
|
||||
_bt.logger.debug("CDP supervisor attach for task=%s failed (non-fatal): %s", task_id, exc)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Cloud browser provider resolution (explicit browser.cloud_provider, auto-detect, per-profile cache), backend/engine selection and headed-mode flags.
|
||||
|
||||
Split out of ``tools/browser_tool.py``; every name is re-imported there (tests monkeypatch ``tools.browser_tool.<name>``).
|
||||
Origin symbols/state go through ``_bt`` (origin module, resolved per call) — no import cycle."""
|
||||
Split out of ``tools/browser_tool.py``. Facade-owned state is read through ``_bt`` (``tools.browser_tool``, resolved per call) — no import cycle."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -9,7 +8,14 @@ import os
|
||||
from typing import Callable, Optional
|
||||
|
||||
from agent.browser_provider import BrowserProvider as CloudBrowserProvider
|
||||
from agent.browser_registry import get_provider as _registry_get_browser_provider
|
||||
from hermes_constants import get_hermes_home_override, hermes_home_key
|
||||
from plugins.browser.browser_use.provider import BrowserUseBrowserProvider
|
||||
from plugins.browser.browserbase.provider import BrowserbaseBrowserProvider
|
||||
from tools.tool_backend_helpers import normalize_browser_cloud_provider
|
||||
from utils import is_truthy_value
|
||||
from tools.browser_tool_origin import origin_module as _origin
|
||||
from tools import browser_tool_cdp as _cdp
|
||||
|
||||
|
||||
def _memo(_bt, resolved_attr: str, cache_attr: str, compute: Callable[[], object]):
|
||||
@@ -43,7 +49,7 @@ def _ensure_browser_plugins_loaded() -> None:
|
||||
def _get_cloud_provider() -> Optional[CloudBrowserProvider]:
|
||||
"""Return the provider cached for the active Hermes profile."""
|
||||
_bt = _origin()
|
||||
scope = _bt.hermes_home_key()
|
||||
scope = hermes_home_key()
|
||||
with _bt._cloud_provider_cache_lock:
|
||||
# A cleared boolean (tests / legacy reset) is a full reset even if a scoped resolution is still mirrored here.
|
||||
if not _bt._cloud_provider_resolved:
|
||||
@@ -59,7 +65,7 @@ def _get_cloud_provider() -> Optional[CloudBrowserProvider]:
|
||||
return _bt._cached_cloud_provider
|
||||
_bt._cached_cloud_provider = None
|
||||
_bt._cloud_provider_resolved = False
|
||||
resolved = _bt._resolve_cloud_provider_uncached()
|
||||
resolved = _resolve_cloud_provider_uncached()
|
||||
after_generation = _bt._browser_registry_generation(scope=scope)
|
||||
if before_generation != after_generation: # force reload mid-resolution: discard, resolve again
|
||||
continue
|
||||
@@ -80,12 +86,12 @@ def _instantiate_explicit_cloud_provider(provider_key: str) -> Optional[CloudBro
|
||||
"""
|
||||
_bt = _origin()
|
||||
try:
|
||||
if _bt._is_legacy_provider_registry_overridden():
|
||||
if _is_legacy_provider_registry_overridden():
|
||||
factory = _bt._PROVIDER_REGISTRY.get(provider_key)
|
||||
resolved = factory() if factory is not None else None
|
||||
else:
|
||||
_bt._ensure_browser_plugins_loaded()
|
||||
resolved = _bt._registry_get_browser_provider(provider_key)
|
||||
_ensure_browser_plugins_loaded()
|
||||
resolved = _registry_get_browser_provider(provider_key)
|
||||
if resolved is None:
|
||||
from tools.tool_backend_helpers import selection_error
|
||||
raise ValueError(selection_error(
|
||||
@@ -103,12 +109,11 @@ def _instantiate_explicit_cloud_provider(provider_key: str) -> Optional[CloudBro
|
||||
def _autodetect_cloud_provider() -> Optional[CloudBrowserProvider]:
|
||||
"""Auto-detect: Browser Use, then Browserbase; never raises.
|
||||
|
||||
Uses the class names bound on the origin module so tests that monkeypatch ``BrowserUseProvider`` keep driving
|
||||
this branch. Third-party plugins are only reachable via explicit ``browser.cloud_provider: <name>``.
|
||||
Third-party plugins are only reachable via explicit ``browser.cloud_provider: <name>``.
|
||||
"""
|
||||
_bt = _origin()
|
||||
try:
|
||||
for cls in (_bt.BrowserUseProvider, _bt.BrowserbaseProvider):
|
||||
for cls in (BrowserUseBrowserProvider, BrowserbaseBrowserProvider):
|
||||
fallback_provider = cls()
|
||||
if fallback_provider.is_available():
|
||||
return fallback_provider
|
||||
@@ -131,7 +136,7 @@ def _resolve_cloud_provider_uncached() -> Optional[CloudBrowserProvider]:
|
||||
from hermes_cli.config import read_raw_config
|
||||
browser_cfg = read_raw_config().get("browser", {})
|
||||
if isinstance(browser_cfg, dict) and "cloud_provider" in browser_cfg:
|
||||
provider_key = _bt.normalize_browser_cloud_provider(browser_cfg.get("cloud_provider"))
|
||||
provider_key = normalize_browser_cloud_provider(browser_cfg.get("cloud_provider"))
|
||||
if provider_key in ("local", "camofox"):
|
||||
# Camofox runs through the built-in browser tools, not a cloud provider.
|
||||
_bt._cached_cloud_provider = None
|
||||
@@ -141,7 +146,7 @@ def _resolve_cloud_provider_uncached() -> Optional[CloudBrowserProvider]:
|
||||
# Managed "Nous Subscription" is serviced by the Browser Use provider.
|
||||
provider_key = "browser-use"
|
||||
if provider_key:
|
||||
resolved = _bt._instantiate_explicit_cloud_provider(provider_key)
|
||||
resolved = _instantiate_explicit_cloud_provider(provider_key)
|
||||
if resolved is None:
|
||||
return None
|
||||
except ValueError:
|
||||
@@ -151,7 +156,7 @@ def _resolve_cloud_provider_uncached() -> Optional[CloudBrowserProvider]:
|
||||
_bt.logger.debug("Could not read cloud_provider from config: %s", e)
|
||||
|
||||
if resolved is None and provider_key is None:
|
||||
resolved = _bt._autodetect_cloud_provider()
|
||||
resolved = _autodetect_cloud_provider()
|
||||
if resolved is None:
|
||||
return None
|
||||
_bt._cached_cloud_provider = resolved
|
||||
@@ -162,7 +167,7 @@ def _resolve_cloud_provider_uncached() -> Optional[CloudBrowserProvider]:
|
||||
def _is_local_mode() -> bool:
|
||||
"""Return True when the browser tool will use a local browser backend."""
|
||||
_bt = _origin()
|
||||
return not _bt._get_cdp_override_raw() and _bt._get_cloud_provider() is None
|
||||
return not _cdp._get_cdp_override_raw() and _get_cloud_provider() is None
|
||||
|
||||
|
||||
def _is_local_backend() -> bool:
|
||||
@@ -174,11 +179,11 @@ def _is_local_backend() -> bool:
|
||||
in agreement.
|
||||
"""
|
||||
_bt = _origin()
|
||||
if _bt._get_cdp_override_raw():
|
||||
if _cdp._get_cdp_override_raw():
|
||||
return False
|
||||
if _bt._is_camofox_mode():
|
||||
return True
|
||||
if _bt._get_cloud_provider() is not None:
|
||||
if _get_cloud_provider() is not None:
|
||||
return False
|
||||
# Scope-aware: under gateway multiplexing the routed profile's terminal backend lives in the per-turn scope.
|
||||
# When terminal runs in a container, browser on host can access internal networks the terminal can't →
|
||||
@@ -219,7 +224,7 @@ def _is_headed_mode() -> bool:
|
||||
def _should_inject_engine(engine: str) -> bool:
|
||||
"""True when ``--engine`` should be added: explicit (non-``auto``) engine on a non-cloud, non-camofox local session."""
|
||||
_bt = _origin()
|
||||
return engine != "auto" and not _bt._is_camofox_mode() and _bt._is_local_mode()
|
||||
return engine != "auto" and not _bt._is_camofox_mode() and _is_local_mode()
|
||||
|
||||
|
||||
def _auto_local_for_private_urls() -> bool:
|
||||
@@ -247,12 +252,12 @@ def _allow_private_urls() -> bool:
|
||||
resolve on every call so one profile's opt-out is never reused by another.
|
||||
"""
|
||||
_bt = _origin()
|
||||
if _bt.get_hermes_home_override() is not None:
|
||||
return _bt._resolve_allow_private_urls()
|
||||
return _memo(_bt, "_allow_private_urls_resolved", "_cached_allow_private_urls", _bt._resolve_allow_private_urls)
|
||||
if get_hermes_home_override() is not None:
|
||||
return _resolve_allow_private_urls()
|
||||
return _memo(_bt, "_allow_private_urls_resolved", "_cached_allow_private_urls", _resolve_allow_private_urls)
|
||||
|
||||
|
||||
def _resolve_allow_private_urls() -> bool:
|
||||
"""Read the browser private-URL toggle from the active config scope."""
|
||||
_bt = _origin()
|
||||
return _bt._browser_cfg("allow_private_urls", False, lambda v: _bt.is_truthy_value(v, default=False), "allow_private_urls from config")
|
||||
return _bt._browser_cfg("allow_private_urls", False, lambda v: is_truthy_value(v, default=False), "allow_private_urls from config")
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
"""browser_console(expression=...) policy: SSRF guard gating, private-URL probes,
|
||||
and the opt-in sensitive-primitive denylist.
|
||||
|
||||
Origin-module symbols are resolved lazily through ``tools.browser_tool`` (``_bt``)
|
||||
so ``patch("tools.browser_tool.X")`` keeps working; never import it at import time (cycle).
|
||||
Facade-owned state is read through ``_bt`` (``tools.browser_tool``, resolved per call) — no import cycle.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Optional
|
||||
from utils import is_truthy_value
|
||||
from tools.browser_tool_origin import origin_module as _origin
|
||||
from tools import browser_tool_cloud as _cloud
|
||||
from tools import browser_tool_session as _session
|
||||
|
||||
|
||||
def _eval_ssrf_guard_active(effective_task_id: str) -> bool:
|
||||
@@ -18,7 +20,7 @@ def _eval_ssrf_guard_active(effective_task_id: str) -> bool:
|
||||
browser-on-host reaches networks the terminal can't); skipped for local sidecars / ``allow_private_urls``.
|
||||
"""
|
||||
_bt = _origin()
|
||||
return not _bt._is_local_backend() and not _bt._is_local_sidecar_key(effective_task_id) and not _bt._allow_private_urls()
|
||||
return not _cloud._is_local_backend() and not _bt._is_local_sidecar_key(effective_task_id) and not _cloud._allow_private_urls()
|
||||
|
||||
|
||||
def _url_blocked(_bt, url: str) -> bool:
|
||||
@@ -44,7 +46,7 @@ def _current_page_private_url(effective_task_id: str) -> Optional[str]:
|
||||
``location.href = '...'`` eval). Fail-open on probe failure, matching the snapshot/vision guards."""
|
||||
_bt = _origin()
|
||||
try:
|
||||
url_result = _bt._run_browser_command(effective_task_id, "eval", ["window.location.href"], timeout=5, _engine_override="auto")
|
||||
url_result = _session._run_browser_command(effective_task_id, "eval", ["window.location.href"], timeout=5, _engine_override="auto")
|
||||
if url_result.get("success"):
|
||||
current_url = url_result.get("data", {}).get("result", "").strip().strip('"').strip("'")
|
||||
if current_url and _url_blocked(_bt, current_url):
|
||||
@@ -84,7 +86,7 @@ _SENSITIVE_BROWSER_EVAL_TOKENS: tuple[tuple[str, str], ...] = (
|
||||
def _browser_eval_flag(key: str) -> bool:
|
||||
"""Read boolean ``browser.<key>`` (default False) through the origin's config reader."""
|
||||
_bt = _origin()
|
||||
return _bt._browser_cfg(key, False, lambda v: _bt.is_truthy_value(v, default=False), f"browser.{key} from config")
|
||||
return _bt._browser_cfg(key, False, lambda v: is_truthy_value(v, default=False), f"browser.{key} from config")
|
||||
|
||||
|
||||
def _allow_unsafe_browser_evaluate() -> bool:
|
||||
@@ -137,8 +139,7 @@ def _risky_browser_eval_reason(expression: str) -> Optional[str]:
|
||||
def _enforce_browser_eval_policy(expression: str) -> Optional[str]:
|
||||
"""Block sensitive browser JS evaluation when the opt-in denylist is on (opt-in because it gates on
|
||||
primitive *names*; private-address egress is enforced separately in ``_browser_eval``)."""
|
||||
_bt = _origin()
|
||||
if not _bt._restrict_browser_evaluate() or _bt._allow_unsafe_browser_evaluate():
|
||||
if not _restrict_browser_evaluate() or _allow_unsafe_browser_evaluate():
|
||||
return None
|
||||
reason = _risky_browser_eval_reason(expression)
|
||||
if not reason:
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""agent-browser / Chromium discovery and install: PATH merging, npx resolution, candidate binaries, Chromium detection + auto-install, requirement checks.
|
||||
|
||||
Split out of ``tools/browser_tool.py``; every name is re-imported there (tests monkeypatch ``tools.browser_tool.<name>``).
|
||||
Origin symbols/state go through ``_bt`` (origin module, resolved per call) — no import cycle."""
|
||||
Split out of ``tools/browser_tool.py``. Facade-owned state is read through ``_bt`` (``tools.browser_tool``, resolved per call) — no import cycle."""
|
||||
|
||||
import contextlib
|
||||
import functools
|
||||
@@ -12,7 +11,13 @@ import sys
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from hermes_cli._subprocess_compat import windows_hide_flags
|
||||
from hermes_constants import agent_browser_runnable, get_hermes_home, is_termux as _is_termux_environment, node_tool_runnable
|
||||
from tools.browser_tool_origin import origin_module as _origin
|
||||
from tools import browser_tool_cdp as _cdp
|
||||
from tools import browser_tool_cloud as _cloud
|
||||
from tools import browser_tool_lifecycle as _lifecycle
|
||||
from tools import browser_tool_lightpanda_fallback as _lp
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
@@ -34,23 +39,23 @@ def _discover_homebrew_node_dirs() -> tuple[str, ...]:
|
||||
def _browser_candidate_path_dirs() -> list[str]:
|
||||
"""Return ordered browser CLI PATH candidates shared by discovery and execution."""
|
||||
_bt = _origin()
|
||||
home = _bt.get_hermes_home()
|
||||
home = get_hermes_home()
|
||||
managed = (home / "node" / "bin", home / "node", home / "node_modules" / ".bin")
|
||||
return [*map(str, managed), *_bt._discover_homebrew_node_dirs(), *_bt._SANE_PATH_DIRS]
|
||||
return [*map(str, managed), *_discover_homebrew_node_dirs(), *_bt._SANE_PATH_DIRS]
|
||||
|
||||
|
||||
def _merge_browser_path(existing_path: str = "") -> str:
|
||||
"""Prepend browser-specific PATH fallbacks without reordering existing entries."""
|
||||
path_parts = [p for p in (existing_path or "").split(os.pathsep) if p]
|
||||
prefix_parts: list[str] = []
|
||||
for part in _origin()._browser_candidate_path_dirs():
|
||||
for part in _browser_candidate_path_dirs():
|
||||
if part and part not in path_parts and part not in prefix_parts and os.path.isdir(part):
|
||||
prefix_parts.append(part)
|
||||
return os.pathsep.join(prefix_parts + path_parts)
|
||||
|
||||
|
||||
def _browser_install_hint() -> str:
|
||||
return "npm install -g agent-browser && agent-browser install" + ("" if _origin()._is_termux_environment() else " --with-deps")
|
||||
return "npm install -g agent-browser && agent-browser install" + ("" if _is_termux_environment() else " --with-deps")
|
||||
|
||||
|
||||
def _is_npx_agent_browser_sentinel(browser_cmd: str) -> bool:
|
||||
@@ -58,12 +63,11 @@ def _is_npx_agent_browser_sentinel(browser_cmd: str) -> bool:
|
||||
|
||||
|
||||
def _requires_real_termux_browser_install(browser_cmd: str) -> bool:
|
||||
_bt = _origin()
|
||||
return _bt._is_termux_environment() and _bt._is_local_mode() and _bt._is_npx_agent_browser_sentinel(browser_cmd)
|
||||
return _is_termux_environment() and _cloud._is_local_mode() and _is_npx_agent_browser_sentinel(browser_cmd)
|
||||
|
||||
|
||||
def _termux_browser_install_error() -> str:
|
||||
return f"Local browser automation on Termux cannot rely on the bare npx fallback. Install agent-browser explicitly first: {_origin()._browser_install_hint()}"
|
||||
return f"Local browser automation on Termux cannot rely on the bare npx fallback. Install agent-browser explicitly first: {_browser_install_hint()}"
|
||||
|
||||
|
||||
def _agent_browser_candidate_present(path: str | None) -> bool:
|
||||
@@ -80,11 +84,10 @@ def _resolve_npx_bin() -> Optional[str]:
|
||||
Bare PATH first would let a broken system npx shadow a healthy managed one,
|
||||
so every candidate is validated with ``node_tool_runnable`` before use.
|
||||
"""
|
||||
_bt = _origin()
|
||||
extended_path = _bt._merge_browser_path("")
|
||||
extended_path = _merge_browser_path("")
|
||||
for path in ([extended_path] if extended_path else []) + [None]:
|
||||
npx = shutil.which("npx", path=path)
|
||||
if npx and _bt.node_tool_runnable(npx):
|
||||
if npx and node_tool_runnable(npx):
|
||||
return npx
|
||||
return None
|
||||
|
||||
@@ -116,7 +119,7 @@ def _find_agent_browser(*, validate: bool = True) -> str:
|
||||
|
||||
def _not_found(cached: bool) -> FileNotFoundError:
|
||||
return FileNotFoundError(f"agent-browser CLI not found{' (cached)' if cached else ''}. Install it with: "
|
||||
f"{_bt._browser_install_hint()}\nOr ensure npx is available in your PATH.")
|
||||
f"{_browser_install_hint()}\nOr ensure npx is available in your PATH.")
|
||||
|
||||
def _accept(candidate: str) -> str:
|
||||
# Set resolved at each accept site (not before the search) so a concurrent reader never sees
|
||||
@@ -130,24 +133,24 @@ def _find_agent_browser(*, validate: bool = True) -> str:
|
||||
if _bt._cached_agent_browser is None:
|
||||
raise _not_found(cached=True)
|
||||
return _bt._cached_agent_browser
|
||||
ok = _bt.agent_browser_runnable if validate else _bt._agent_browser_candidate_present
|
||||
extended_path = _bt._merge_browser_path("")
|
||||
for candidate in _bt._agent_browser_candidates(extended_path):
|
||||
ok = agent_browser_runnable if validate else _agent_browser_candidate_present
|
||||
extended_path = _merge_browser_path("")
|
||||
for candidate in _agent_browser_candidates(extended_path):
|
||||
if candidate and ok(candidate):
|
||||
return _accept(candidate)
|
||||
# npx fallback (also searches the extended PATH)
|
||||
if _bt._resolve_npx_bin():
|
||||
if _resolve_npx_bin():
|
||||
return _accept(_bt.NPX_AGENT_BROWSER_SENTINEL)
|
||||
if not validate:
|
||||
raise FileNotFoundError("agent-browser CLI not found")
|
||||
try: # Nothing found — try lazy installation before giving up.
|
||||
from hermes_cli.dep_ensure import ensure_dependency
|
||||
if ensure_dependency("browser"):
|
||||
home = _bt.get_hermes_home()
|
||||
home = get_hermes_home()
|
||||
managed = (home / "node_modules" / ".bin", home / "node" / "bin", home / "node")
|
||||
for path in (None, *([extended_path] if extended_path else []), *map(str, managed)):
|
||||
recheck = shutil.which("agent-browser", path=path)
|
||||
if recheck and _bt.agent_browser_runnable(recheck):
|
||||
if recheck and agent_browser_runnable(recheck):
|
||||
return _accept(recheck)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -171,16 +174,16 @@ def warm_agent_browser_npx_cache(timeout: float = 60.0) -> bool:
|
||||
graph.
|
||||
"""
|
||||
_bt = _origin()
|
||||
npx_bin = _bt._resolve_npx_bin()
|
||||
npx_bin = _resolve_npx_bin()
|
||||
if not npx_bin:
|
||||
return False
|
||||
env = _bt._build_browser_env()
|
||||
env["PATH"] = _bt._merge_browser_path(env.get("PATH", ""))
|
||||
env["PATH"] = _merge_browser_path(env.get("PATH", ""))
|
||||
popen_kwargs: dict = {"stdout": subprocess.PIPE, "stderr": subprocess.PIPE, "text": True, "env": env}
|
||||
if os.name == "posix":
|
||||
popen_kwargs.update(creationflags=_bt.windows_hide_flags(), start_new_session=True)
|
||||
popen_kwargs.update(creationflags=windows_hide_flags(), start_new_session=True)
|
||||
else:
|
||||
popen_kwargs["creationflags"] = _bt.windows_hide_flags() | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
||||
popen_kwargs["creationflags"] = windows_hide_flags() | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
||||
# --ignore-scripts: AGENT_BROWSER_NPX_SPEC is a floating range; a compromised future patch must not run
|
||||
# install-time lifecycle scripts here. --prefer-offline: once cached, repeat runs must not re-hit the registry.
|
||||
cmd = [npx_bin, "--ignore-scripts", "--prefer-offline", "-y", _bt.AGENT_BROWSER_NPX_SPEC, "--version"]
|
||||
@@ -192,7 +195,7 @@ def warm_agent_browser_npx_cache(timeout: float = 60.0) -> bool:
|
||||
proc.communicate(timeout=timeout)
|
||||
return proc.returncode == 0
|
||||
except Exception as exc:
|
||||
_bt._kill_process_tree(proc)
|
||||
_lifecycle._kill_process_tree(proc)
|
||||
if isinstance(exc, subprocess.TimeoutExpired):
|
||||
with contextlib.suppress(Exception):
|
||||
proc.communicate(timeout=5)
|
||||
@@ -234,7 +237,7 @@ def _chromium_installed() -> bool:
|
||||
_bt._cached_chromium_installed = bool(
|
||||
(ab_path and (os.path.isfile(ab_path) or shutil.which(ab_path)))
|
||||
or any(shutil.which(name) for name in ("google-chrome", "chromium", "chromium-browser", "chrome"))
|
||||
or any(root and os.path.isdir(root) and _has_chromium_build(root) for root in _bt._chromium_search_roots())
|
||||
or any(root and os.path.isdir(root) and _has_chromium_build(root) for root in _chromium_search_roots())
|
||||
)
|
||||
return _bt._cached_chromium_installed
|
||||
|
||||
@@ -247,20 +250,20 @@ def _maybe_autoinstall_chromium() -> bool:
|
||||
"""
|
||||
_bt = _origin()
|
||||
if _bt._chromium_autoinstall_attempted:
|
||||
return _bt._chromium_installed()
|
||||
return _chromium_installed()
|
||||
_bt._chromium_autoinstall_attempted = True
|
||||
if _bt._running_in_docker():
|
||||
if _running_in_docker():
|
||||
return False
|
||||
from tools.lazy_deps import _allow_lazy_installs
|
||||
if not _allow_lazy_installs():
|
||||
return False
|
||||
try:
|
||||
browser_cmd = _bt._find_agent_browser()
|
||||
browser_cmd = _find_agent_browser()
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
install_cmd = [browser_cmd, "install"]
|
||||
if _bt._is_npx_agent_browser_sentinel(browser_cmd):
|
||||
install_cmd = [_bt._resolve_npx_bin() or "npx", "--ignore-scripts", "-y", _bt.AGENT_BROWSER_NPX_SPEC, "install"]
|
||||
if _is_npx_agent_browser_sentinel(browser_cmd):
|
||||
install_cmd = [_resolve_npx_bin() or "npx", "--ignore-scripts", "-y", _bt.AGENT_BROWSER_NPX_SPEC, "install"]
|
||||
|
||||
_bt.logger.info("browser: Chromium missing — auto-installing the browser binary (one-time ~170MB; disable via security.allow_lazy_installs)")
|
||||
try:
|
||||
@@ -274,7 +277,7 @@ def _maybe_autoinstall_chromium() -> bool:
|
||||
_bt.logger.warning("browser: Chromium auto-install exited %s: %s", proc.returncode, tail)
|
||||
return False
|
||||
_bt._cached_chromium_installed = None
|
||||
return _bt._chromium_installed()
|
||||
return _chromium_installed()
|
||||
|
||||
|
||||
def _running_in_docker() -> bool:
|
||||
@@ -302,25 +305,25 @@ def check_browser_requirements() -> bool:
|
||||
if _bt._is_camofox_mode():
|
||||
return True
|
||||
# CDP override needs no local binary. Raw (no-I/O) check: this runs during schema build, where a stale endpoint must not cost a blocking probe.
|
||||
if _bt._get_cdp_override_raw():
|
||||
if _cdp._get_cdp_override_raw():
|
||||
return True
|
||||
# Do not exec ``agent-browser --version`` here: Windows .cmd shims flash a console during Desktop startup. Execution paths still validate.
|
||||
try:
|
||||
browser_cmd = _bt._find_agent_browser(validate=False)
|
||||
browser_cmd = _find_agent_browser(validate=False)
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
# Termux: the bare npx fallback is too fragile to advertise as a satisfied local dependency.
|
||||
if _bt._requires_real_termux_browser_install(browser_cmd):
|
||||
if _requires_real_termux_browser_install(browser_cmd):
|
||||
return False
|
||||
# Cloud mode also requires provider credentials; no local Chromium needed.
|
||||
provider = _bt._get_cloud_provider()
|
||||
provider = _cloud._get_cloud_provider()
|
||||
if provider is not None:
|
||||
return provider.is_available()
|
||||
# Lightpanda provides text/navigation tools without Chromium; screenshots/vision still return install errors.
|
||||
if _bt._using_lightpanda_engine():
|
||||
if _lp._using_lightpanda_engine():
|
||||
return True
|
||||
# Local Chrome mode needs Chromium on disk or the CLI hangs until the command timeout.
|
||||
return _bt._chromium_installed()
|
||||
return _chromium_installed()
|
||||
|
||||
|
||||
def check_browser_vision_requirements() -> bool:
|
||||
@@ -330,7 +333,7 @@ def check_browser_vision_requirements() -> bool:
|
||||
configured, then fails at call time with a cryptic provider-side error like ``unknown variant
|
||||
`image_url`, expected `text``` (issue #31179).
|
||||
"""
|
||||
if not _origin().check_browser_requirements():
|
||||
if not check_browser_requirements():
|
||||
return False
|
||||
try:
|
||||
from tools.vision_tools import check_vision_requirements
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Browser session lifecycle: inactivity janitor, orphan reaper, per-session teardown, atexit cleanup.
|
||||
|
||||
Split out of ``tools/browser_tool.py`` (every name re-imported there); origin symbols
|
||||
are read through the ``_bt`` proxy so ``patch("tools.browser_tool.X")`` is honoured.
|
||||
Split out of ``tools/browser_tool.py``. Facade-owned state is read through ``_bt`` (``tools.browser_tool``, resolved per call) — no import cycle.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
@@ -17,6 +16,11 @@ from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from hermes_constants import get_hermes_home, reset_hermes_home_override, set_hermes_home_override
|
||||
from tools.browser_tool_origin import origin as _bt
|
||||
from tools import browser_tool_cdp as _cdp
|
||||
from tools import browser_tool_cloud as _cloud
|
||||
from tools import browser_tool_session as _session
|
||||
from tools import browser_tool_install as _install
|
||||
from tools import browser_tool_real_profile as _real_profile
|
||||
|
||||
|
||||
def _session_expiry_timestamp(session_info: Dict[str, Any]) -> Optional[float]:
|
||||
@@ -45,7 +49,7 @@ def _session_has_expired(
|
||||
session_info: Dict[str, Any], *, now: Optional[float] = None
|
||||
) -> bool:
|
||||
"""Whether a cached browser session crossed its provider deadline."""
|
||||
expires_at = _bt._session_expiry_timestamp(session_info)
|
||||
expires_at = _session_expiry_timestamp(session_info)
|
||||
if expires_at is None:
|
||||
return False
|
||||
return (time.time() if now is None else now) >= expires_at
|
||||
@@ -75,11 +79,11 @@ def _emergency_cleanup_all_sessions():
|
||||
# Own sessions first so their owner_pid files are gone before the reaper scans.
|
||||
# Real-profile Chrome is launched directly (not by agent-browser), so the
|
||||
# session cleanup never reaps it.
|
||||
_best_effort("Real-profile chrome cleanup on exit", _bt._terminate_real_profile_chrome)
|
||||
_best_effort("Real-profile chrome cleanup on exit", _real_profile._terminate_real_profile_chrome)
|
||||
if _bt._active_sessions:
|
||||
_bt.logger.info("Emergency cleanup: closing %s active session(s)...", len(_bt._active_sessions))
|
||||
try:
|
||||
_bt.cleanup_all_browsers()
|
||||
cleanup_all_browsers()
|
||||
except Exception as e:
|
||||
_bt.logger.error("Emergency cleanup error: %s", e)
|
||||
finally:
|
||||
@@ -93,7 +97,7 @@ def _emergency_cleanup_all_sessions():
|
||||
_best_effort("Lightpanda cleanup on exit", _stop_all_lightpanda)
|
||||
# Safe even if we never used the browser — owner_pid liveness protects daemons
|
||||
# owned by other live hermes processes.
|
||||
_best_effort("Orphan reap on exit", _bt._reap_orphaned_browser_sessions)
|
||||
_best_effort("Orphan reap on exit", _reap_orphaned_browser_sessions)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
@@ -154,8 +158,8 @@ def _cleanup_inactive_browser_sessions():
|
||||
elapsed = int(current_time - _bt._session_last_activity.get(task_id, current_time))
|
||||
_bt.logger.info("Cleaning up inactive session for task: %s (inactive for %ss)", task_id, elapsed)
|
||||
try:
|
||||
with _bt._session_owner_scope(task_id):
|
||||
_bt.cleanup_browser(task_id)
|
||||
with _session_owner_scope(task_id):
|
||||
cleanup_browser(task_id)
|
||||
_forget_session_tracking(task_id)
|
||||
except Exception as e:
|
||||
with _bt._cleanup_lock:
|
||||
@@ -167,8 +171,8 @@ def _cleanup_inactive_browser_sessions():
|
||||
_bt.logger.error("Browser cleanup failed %d times for inactive session %s; "
|
||||
"force-reaping: %s", failures, task_id, e)
|
||||
try:
|
||||
with _bt._session_owner_scope(task_id):
|
||||
_bt._force_reap_browser_session(task_id)
|
||||
with _session_owner_scope(task_id):
|
||||
_force_reap_browser_session(task_id)
|
||||
except Exception as reap_exc:
|
||||
_bt.logger.error("Force-reap of browser session %s failed: %s", task_id, reap_exc)
|
||||
finally:
|
||||
@@ -296,11 +300,11 @@ def _reap_socket_dir(socket_dir: str, session_name: str, tracked_names: set) ->
|
||||
pidless dir is only stale after the grace period (deleting it immediately races the
|
||||
creator's first stdout open). The PID is identity-verified before any tree-kill.
|
||||
"""
|
||||
owner_pid, owner_alive = _bt._owner_pid_alive(socket_dir, session_name)
|
||||
owner_pid, owner_alive = _owner_pid_alive(socket_dir, session_name)
|
||||
if owner_alive is True:
|
||||
if session_name in tracked_names:
|
||||
return False
|
||||
idle_s = _bt._socket_dir_idle_seconds(socket_dir)
|
||||
idle_s = _socket_dir_idle_seconds(socket_dir)
|
||||
if idle_s is None or idle_s < _bt.BROWSER_ORPHAN_GRACE_SECONDS:
|
||||
return False # unknown age or within grace — fail safe
|
||||
_bt.logger.warning(
|
||||
@@ -313,7 +317,7 @@ def _reap_socket_dir(socket_dir: str, session_name: str, tracked_names: set) ->
|
||||
|
||||
pid_file = os.path.join(socket_dir, f"{session_name}.pid")
|
||||
if not os.path.isfile(pid_file):
|
||||
idle_s = _bt._socket_dir_idle_seconds(socket_dir)
|
||||
idle_s = _socket_dir_idle_seconds(socket_dir)
|
||||
if idle_s is None or idle_s < _bt.BROWSER_ORPHAN_GRACE_SECONDS:
|
||||
return False
|
||||
shutil.rmtree(socket_dir, ignore_errors=True)
|
||||
@@ -325,7 +329,7 @@ def _reap_socket_dir(socket_dir: str, session_name: str, tracked_names: set) ->
|
||||
shutil.rmtree(socket_dir, ignore_errors=True)
|
||||
return False
|
||||
|
||||
if not _bt._verify_reapable_browser_daemon(daemon_pid, socket_dir, session_name):
|
||||
if not _verify_reapable_browser_daemon(daemon_pid, socket_dir, session_name):
|
||||
return False # leave process and dir for a later sweep once the imposter PID is gone
|
||||
|
||||
# Tree-kill so Chromium children (renderer, GPU, ...) go too.
|
||||
@@ -367,7 +371,7 @@ def _reap_orphaned_browser_sessions():
|
||||
reaped = 0
|
||||
for socket_dir in socket_dirs:
|
||||
session_name = os.path.basename(socket_dir).removeprefix("agent-browser-")
|
||||
if session_name and _bt._reap_socket_dir(socket_dir, session_name, tracked_names):
|
||||
if session_name and _reap_socket_dir(socket_dir, session_name, tracked_names):
|
||||
reaped += 1
|
||||
|
||||
if reaped:
|
||||
@@ -383,13 +387,13 @@ def _browser_cleanup_thread_worker():
|
||||
while _bt._cleanup_running:
|
||||
if cycle % reap_every_cycles == 0: # cycle 0 is the startup reap
|
||||
try:
|
||||
_bt._reap_orphaned_browser_sessions()
|
||||
_reap_orphaned_browser_sessions()
|
||||
except Exception as e:
|
||||
_bt.logger.warning("Orphan reap error: %s", e)
|
||||
cycle += 1
|
||||
|
||||
try:
|
||||
_bt._cleanup_inactive_browser_sessions()
|
||||
_cleanup_inactive_browser_sessions()
|
||||
except Exception as e:
|
||||
_bt.logger.warning("Cleanup thread error: %s", e)
|
||||
|
||||
@@ -404,7 +408,7 @@ def _start_browser_cleanup_thread():
|
||||
with _bt._cleanup_lock:
|
||||
if _bt._cleanup_thread is None or not _bt._cleanup_thread.is_alive():
|
||||
_bt._cleanup_running = True
|
||||
_bt._cleanup_thread = threading.Thread(target=_bt._browser_cleanup_thread_worker, daemon=True,
|
||||
_bt._cleanup_thread = threading.Thread(target=_browser_cleanup_thread_worker, daemon=True,
|
||||
name="browser-cleanup")
|
||||
_bt._cleanup_thread.start()
|
||||
_bt.logger.info("Started inactivity cleanup thread (timeout: %ss)", _bt.BROWSER_SESSION_INACTIVITY_TIMEOUT)
|
||||
@@ -452,7 +456,7 @@ def _kill_process_tree(proc: "subprocess.Popen") -> None:
|
||||
|
||||
_deadline_kill_tree(proc.pid)
|
||||
except Exception:
|
||||
_bt._legacy_kill_process_tree(proc)
|
||||
_legacy_kill_process_tree(proc)
|
||||
|
||||
|
||||
def _legacy_kill_process_tree(proc: "subprocess.Popen") -> None:
|
||||
@@ -549,8 +553,8 @@ def cleanup_browser(task_id: Optional[str] = None) -> None:
|
||||
if not _bt._is_local_sidecar_key(task_id) and sidecar_key in _bt._active_sessions:
|
||||
session_keys.append(sidecar_key)
|
||||
for session_key in session_keys:
|
||||
_bt._cleanup_single_browser_session(session_key)
|
||||
_bt._drop_last_active_binding(task_id)
|
||||
_cleanup_single_browser_session(session_key)
|
||||
_drop_last_active_binding(task_id)
|
||||
|
||||
|
||||
def _kill_verified_daemon(socket_dir: str, session_name: str) -> bool:
|
||||
@@ -561,7 +565,7 @@ def _kill_verified_daemon(socket_dir: str, session_name: str) -> bool:
|
||||
return False
|
||||
try:
|
||||
daemon_pid = int(Path(pid_file).read_text(encoding="utf-8").strip())
|
||||
if not _bt._verify_reapable_browser_daemon(daemon_pid, socket_dir, session_name):
|
||||
if not _verify_reapable_browser_daemon(daemon_pid, socket_dir, session_name):
|
||||
_bt.logger.debug("Skipped daemon kill for %s: pid %s failed identity verification", session_name, daemon_pid)
|
||||
return False
|
||||
if not _terminate_verified_daemon(daemon_pid, session_name, lambda *_a: _bt.logger.debug(
|
||||
@@ -586,7 +590,7 @@ def _release_session_resources(task_id: str, session_info: Dict[str, Any]) -> No
|
||||
_forget_session_tracking(task_id, session=True)
|
||||
|
||||
if bb_session_id: # cloud only — local sidecars have bb_session_id=None
|
||||
provider = _bt._get_cloud_provider()
|
||||
provider = _cloud._get_cloud_provider()
|
||||
if provider is not None:
|
||||
try:
|
||||
provider.close_session(bb_session_id)
|
||||
@@ -597,7 +601,7 @@ def _release_session_resources(task_id: str, session_info: Dict[str, Any]) -> No
|
||||
if session_name:
|
||||
socket_dir = os.path.join(_bt._socket_safe_tmpdir(), f"agent-browser-{session_name}")
|
||||
if os.path.exists(socket_dir):
|
||||
_bt._kill_verified_daemon(socket_dir, session_name)
|
||||
_kill_verified_daemon(socket_dir, session_name)
|
||||
shutil.rmtree(socket_dir, ignore_errors=True)
|
||||
|
||||
|
||||
@@ -606,19 +610,19 @@ def _force_reap_browser_session(task_id: str) -> None:
|
||||
|
||||
Janitor last resort after repeated cleanup failures (#100738).
|
||||
"""
|
||||
_bt._stop_cdp_supervisor(task_id)
|
||||
_cdp._stop_cdp_supervisor(task_id)
|
||||
with _bt._cleanup_lock:
|
||||
session_info = _bt._active_sessions.get(task_id)
|
||||
_bt._session_last_activity.pop(task_id, None)
|
||||
_bt._recording_sessions.discard(task_id)
|
||||
if session_info:
|
||||
_bt._release_session_resources(task_id, session_info)
|
||||
_bt._drop_last_active_binding(task_id)
|
||||
_release_session_resources(task_id, session_info)
|
||||
_drop_last_active_binding(task_id)
|
||||
|
||||
|
||||
def _cleanup_single_browser_session(task_id: str) -> None:
|
||||
"""Reap a single browser session by its exact session key."""
|
||||
_bt._stop_cdp_supervisor(task_id) # close our WebSocket BEFORE the backend tears down the endpoint
|
||||
_cdp._stop_cdp_supervisor(task_id) # close our WebSocket BEFORE the backend tears down the endpoint
|
||||
|
||||
# Camofox: managed persistence keeps the profile (cookies) across tasks; skip the full
|
||||
# close then — the inactivity reaper still frees idle resources.
|
||||
@@ -651,16 +655,16 @@ def _cleanup_single_browser_session(task_id: str) -> None:
|
||||
stop_lightpanda(session_info.get("session_name", ""))
|
||||
except Exception as e:
|
||||
_bt.logger.warning("lightpanda stop failed for task %s: %s", task_id, e)
|
||||
elif _bt._session_has_expired(session_info):
|
||||
elif _session_has_expired(session_info):
|
||||
_bt.logger.debug("Skipping agent-browser close for expired session %s", task_id)
|
||||
else:
|
||||
try:
|
||||
_bt._run_browser_command(task_id, "close", [], timeout=10)
|
||||
_session._run_browser_command(task_id, "close", [], timeout=10)
|
||||
_bt.logger.debug("agent-browser close command completed for task %s", task_id)
|
||||
except Exception as e:
|
||||
_bt.logger.warning("agent-browser close failed for task %s: %s", task_id, e)
|
||||
|
||||
_bt._release_session_resources(task_id, session_info)
|
||||
_release_session_resources(task_id, session_info)
|
||||
_bt.logger.debug("Removed task %s from active sessions", task_id)
|
||||
|
||||
|
||||
@@ -669,7 +673,7 @@ def cleanup_all_browsers() -> None:
|
||||
with _bt._cleanup_lock:
|
||||
task_ids = list(_bt._active_sessions.keys())
|
||||
for task_id in task_ids:
|
||||
_bt.cleanup_browser(task_id)
|
||||
cleanup_browser(task_id)
|
||||
|
||||
try: # tear down CDP supervisors so background threads exit
|
||||
from tools.browser_supervisor import SUPERVISOR_REGISTRY # type: ignore[import-not-found]
|
||||
@@ -677,7 +681,7 @@ def cleanup_all_browsers() -> None:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_bt._discover_homebrew_node_dirs.cache_clear()
|
||||
_install._discover_homebrew_node_dirs.cache_clear()
|
||||
# Each resolved flag flips BEFORE its cache is nulled so a concurrent reader never
|
||||
# sees ``resolved=True`` with ``cache=None``.
|
||||
for flag, cache in (
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"""Lightpanda engine status and the automatic Chrome fallback for browser commands
|
||||
that Lightpanda cannot serve (screenshots, empty snapshots, failed commands).
|
||||
|
||||
Origin-module symbols are resolved lazily through ``tools.browser_tool`` (``_bt``)
|
||||
so ``patch("tools.browser_tool.X")`` keeps working; never import it at import time (cycle).
|
||||
Facade-owned state is read through ``_bt`` (``tools.browser_tool``, resolved per call) — no import cycle.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -11,6 +10,10 @@ import shutil
|
||||
import subprocess
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from tools.browser_tool_origin import origin_module as _origin
|
||||
from tools import browser_tool_cdp as _cdp
|
||||
from tools import browser_tool_cloud as _cloud
|
||||
from tools import browser_tool_install as _install
|
||||
from tools import browser_tool_session as _session
|
||||
|
||||
# Commands where Chrome can meaningfully produce a different result. Session-management
|
||||
# commands (close, record) are tied to the engine's daemon and can't be retried elsewhere.
|
||||
@@ -20,7 +23,7 @@ _FALLBACK_ELIGIBLE = frozenset({"open", "snapshot", "screenshot", "eval", "click
|
||||
|
||||
def _using_lightpanda_engine() -> bool:
|
||||
"""Return True when local browser commands are configured for Lightpanda."""
|
||||
return _origin()._get_browser_engine() == "lightpanda"
|
||||
return _cloud._get_browser_engine() == "lightpanda"
|
||||
|
||||
|
||||
def lightpanda_engine_status() -> Tuple[bool, str]:
|
||||
@@ -31,18 +34,18 @@ def lightpanda_engine_status() -> Tuple[bool, str]:
|
||||
precedence with config-only gates (no network I/O) for ``/browser status`` / ``hermes doctor``.
|
||||
"""
|
||||
_bt = _origin()
|
||||
if not _bt._using_lightpanda_engine():
|
||||
if not _using_lightpanda_engine():
|
||||
return False, ""
|
||||
if _bt._get_cdp_override_raw():
|
||||
if _cdp._get_cdp_override_raw():
|
||||
return False, "a CDP override is active (/browser connect or browser.cdp_url)"
|
||||
if _bt._is_camofox_mode():
|
||||
return False, "Camofox is the selected browser (CAMOFOX_URL)"
|
||||
# Real-profile before cloud provider: browser_exec resolves real-profile before
|
||||
# the backend, so with both set the real-profile toggle claims the session.
|
||||
if _bt._use_real_profile():
|
||||
if _cloud._use_real_profile():
|
||||
return False, "browser.use_real_profile is on (Lightpanda cannot load a Chromium profile)"
|
||||
try:
|
||||
provider = _bt._get_cloud_provider()
|
||||
provider = _cloud._get_cloud_provider()
|
||||
except Exception:
|
||||
provider = None
|
||||
if provider is not None:
|
||||
@@ -122,7 +125,7 @@ def _run_chrome_fallback_command(task_id: str, command: str, args: List[str], ti
|
||||
import uuid
|
||||
# 1. Current URL from the Lightpanda session. ``get url`` is not fallback-eligible,
|
||||
# so this can't recurse; the explicit override strips Chromium-only env flags.
|
||||
url_result = _bt._run_browser_command(task_id, "get", ["url"], timeout=10, _engine_override="lightpanda")
|
||||
url_result = _session._run_browser_command(task_id, "get", ["url"], timeout=10, _engine_override="lightpanda")
|
||||
current_url = str(url_result.get("data", {}).get("url", "")).strip() if url_result.get("success") else None
|
||||
if not current_url:
|
||||
_bt.logger.warning("Chrome fallback: could not determine current URL from LP session")
|
||||
@@ -131,12 +134,12 @@ def _run_chrome_fallback_command(task_id: str, command: str, args: List[str], ti
|
||||
# 2. Temporary Chrome session (bypasses _get_session_info's cache).
|
||||
tmp_session = f"h_cfb_{uuid.uuid4().hex[:8]}"
|
||||
try:
|
||||
browser_cmd = _bt._find_agent_browser()
|
||||
browser_cmd = _install._find_agent_browser()
|
||||
except FileNotFoundError as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
if not _bt._chromium_installed():
|
||||
if _bt._running_in_docker():
|
||||
if not _install._chromium_installed():
|
||||
if _install._running_in_docker():
|
||||
hint = ("Chrome fallback requires Chromium, but it is missing. You're running in Docker — "
|
||||
"pull the latest image: docker pull ghcr.io/nousresearch/hermes-agent:latest")
|
||||
else:
|
||||
@@ -144,14 +147,14 @@ def _run_chrome_fallback_command(task_id: str, command: str, args: List[str], ti
|
||||
"npx agent-browser install --with-deps (or: npx playwright install --with-deps chromium)")
|
||||
return {"success": False, "error": hint}
|
||||
|
||||
base_args = _bt._agent_browser_argv(browser_cmd) + ["--engine", "chrome", "--session", tmp_session, "--json"]
|
||||
task_socket_dir = _bt._prepare_session_socket_dir(tmp_session)
|
||||
base_args = _session._agent_browser_argv(browser_cmd) + ["--engine", "chrome", "--session", tmp_session, "--json"]
|
||||
task_socket_dir = _session._prepare_session_socket_dir(tmp_session)
|
||||
# Bypasses _run_browser_command, so apply the same Chromium sandbox policy explicitly.
|
||||
browser_env = _bt._agent_browser_command_env(task_socket_dir)
|
||||
_bt._apply_chromium_sandbox_args(browser_env)
|
||||
browser_env = _session._agent_browser_command_env(task_socket_dir)
|
||||
_session._apply_chromium_sandbox_args(browser_env)
|
||||
|
||||
def _run_tmp(cmd: str, cmd_args: List[str]) -> Dict[str, Any]:
|
||||
proc = _bt._popen_agent_browser(base_args + [cmd] + cmd_args, browser_env, task_socket_dir, cmd)
|
||||
proc = _session._popen_agent_browser(base_args + [cmd] + cmd_args, browser_env, task_socket_dir, cmd)
|
||||
stdout_path = os.path.join(task_socket_dir, f"_stdout_{cmd}")
|
||||
stderr_path = os.path.join(task_socket_dir, f"_stderr_{cmd}")
|
||||
try:
|
||||
@@ -168,7 +171,7 @@ def _run_chrome_fallback_command(task_id: str, command: str, args: List[str], ti
|
||||
except Exception as exc:
|
||||
_bt.logger.debug("Chrome fallback tmp cmd '%s' error: %s", cmd, exc)
|
||||
finally:
|
||||
_bt._unlink_command_output_files(stdout_path, stderr_path)
|
||||
_session._unlink_command_output_files(stdout_path, stderr_path)
|
||||
return {"success": False, "error": f"Chrome fallback '{cmd}' failed"}
|
||||
|
||||
try:
|
||||
@@ -189,4 +192,4 @@ def _run_chrome_fallback_command(task_id: str, command: str, args: List[str], ti
|
||||
|
||||
def _chrome_fallback_screenshot(task_id: str, args: List[str], timeout: int) -> Dict[str, Any]:
|
||||
"""Take a screenshot using a temporary Chrome session."""
|
||||
return _origin()._run_chrome_fallback_command(task_id, "screenshot", args, timeout)
|
||||
return _run_chrome_fallback_command(task_id, "screenshot", args, timeout)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Origin-module lookup shared by the ``tools.browser_tool_*`` extraction modules.
|
||||
|
||||
Extracted code must read its origin's symbols *through* ``tools.browser_tool`` so
|
||||
``patch("tools.browser_tool.X")`` is honoured, and must not import it at import time
|
||||
(cycle) — hence the lazy :func:`origin_module` and the :data:`origin` proxy.
|
||||
Extracted code reads facade-owned state (caches, locks, session tables, facade-defined
|
||||
helpers) *through* ``tools.browser_tool`` and must not import it at import time (cycle) —
|
||||
hence the lazy :func:`origin_module` and the :data:`origin` proxy.
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
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 symbols are read
|
||||
through ``_bt`` so ``patch("tools.browser_tool.X")`` keeps working (never import it at import time).
|
||||
``_real_profile_chrome_procs``) lives in ``tools.browser_tool``; it is read
|
||||
through ``_bt`` (resolved per call — never import ``tools.browser_tool`` at import time).
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -13,6 +13,10 @@ import sys
|
||||
import time
|
||||
from typing import Optional, Tuple
|
||||
from tools.browser_tool_origin import origin_module as _origin
|
||||
from tools import browser_tool_cloud as _cloud
|
||||
from tools import browser_tool_install as _install
|
||||
from tools import browser_tool_lightpanda_fallback as _lp
|
||||
from tools import browser_tool_session as _session
|
||||
|
||||
_RP = "browser.use_real_profile is on, but "
|
||||
|
||||
@@ -36,11 +40,11 @@ def _agent_browser_session_cmd(session_name: str, *cmd: str, log_label: str) ->
|
||||
"""Run ``agent-browser --session <name> <cmd...>``; None when agent-browser is missing or the run fails."""
|
||||
_bt = _origin()
|
||||
try:
|
||||
browser_cmd = _bt._find_agent_browser()
|
||||
browser_cmd = _install._find_agent_browser()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
try:
|
||||
return subprocess.run([*_bt._agent_browser_argv(browser_cmd), "--session", session_name, *cmd],
|
||||
return subprocess.run([*_session._agent_browser_argv(browser_cmd), "--session", session_name, *cmd],
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=15,
|
||||
env=_bt._build_browser_env(), stdin=subprocess.DEVNULL)
|
||||
except (subprocess.SubprocessError, OSError) as e:
|
||||
@@ -128,7 +132,7 @@ def _launch_real_profile_chrome(real_binary: str, copy_dir: str) -> Tuple[Option
|
||||
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"))
|
||||
if not (_bt._is_headed_mode() and (_has_display or not sys.platform.startswith("linux"))):
|
||||
if not (_cloud._is_headed_mode() and (_has_display or not sys.platform.startswith("linux"))):
|
||||
chrome_argv.append("--headless=new")
|
||||
try:
|
||||
chrome_proc = subprocess.Popen(chrome_argv, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
@@ -143,10 +147,10 @@ def _launch_real_profile_chrome(real_binary: str, copy_dir: str) -> Tuple[Option
|
||||
if line.isdigit():
|
||||
return int(line), None
|
||||
if chrome_proc.poll() is not None:
|
||||
_bt._terminate_real_profile_chrome()
|
||||
_terminate_real_profile_chrome()
|
||||
return None, _RP + "Chrome exited during startup (another instance may hold the profile copy)."
|
||||
time.sleep(0.25)
|
||||
_bt._terminate_real_profile_chrome()
|
||||
_terminate_real_profile_chrome()
|
||||
return None, _RP + "the real-profile browser did not expose a debug port in time. Retry, or turn the toggle off."
|
||||
|
||||
|
||||
@@ -158,10 +162,10 @@ def _attach_agent_browser_to_real_profile(port: int, copy_dir: str) -> Tuple[Opt
|
||||
"""
|
||||
_bt = _origin()
|
||||
try:
|
||||
browser_cmd = _bt._find_agent_browser()
|
||||
browser_cmd = _install._find_agent_browser()
|
||||
except FileNotFoundError as e:
|
||||
return None, f"{_RP}the local browser engine (agent-browser) is not installed: {e}"
|
||||
argv = [*_bt._agent_browser_argv(browser_cmd), "--session", _bt._REAL_PROFILE_SESSION,
|
||||
argv = [*_session._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, encoding="utf-8", errors="replace",
|
||||
@@ -174,7 +178,7 @@ def _attach_agent_browser_to_real_profile(port: int, copy_dir: str) -> Tuple[Opt
|
||||
if proc.returncode != 0:
|
||||
tail = (proc.stderr or proc.stdout or "").strip().splitlines()
|
||||
return None, f"{_RP}the real-profile browser failed to start: {tail[-1] if tail else f'exit {proc.returncode}'}"
|
||||
cdp = _bt._agent_browser_get_cdp(_bt._REAL_PROFILE_SESSION)
|
||||
cdp = _agent_browser_get_cdp(_bt._REAL_PROFILE_SESSION)
|
||||
our_port = _read_devtools_port(copy_dir)
|
||||
if our_port is not None and (m := re.search(r":(\d+)", cdp or "")) and m.group(1) != our_port:
|
||||
cdp = f"http://127.0.0.1:{our_port}"
|
||||
@@ -192,7 +196,7 @@ def _real_profile_cdp() -> tuple:
|
||||
across calls (cached, re-validated). ``(None, message)`` fail-closed; ``(None, None)`` when consent is off.
|
||||
"""
|
||||
_bt = _origin()
|
||||
if not _bt._use_real_profile():
|
||||
if not _cloud._use_real_profile():
|
||||
# Consent is off: delete any snapshot store (copies of cookies/logins) so
|
||||
# revoking consent actually removes the credential copies.
|
||||
try:
|
||||
@@ -205,7 +209,7 @@ def _real_profile_cdp() -> tuple:
|
||||
|
||||
# Lightpanda rejects ``--profile``; check BEFORE default-browser detection so a
|
||||
# host with no Chromium default still reports the actionable engine conflict.
|
||||
if _bt._using_lightpanda_engine():
|
||||
if _lp._using_lightpanda_engine():
|
||||
return None, (_RP + "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.")
|
||||
|
||||
@@ -214,7 +218,7 @@ def _real_profile_cdp() -> tuple:
|
||||
|
||||
with _bt._real_profile_cdp_lock:
|
||||
cached = _bt._real_profile_cdp_cache.get("cdp")
|
||||
if cached and _bt._cdp_http_ready(cached):
|
||||
if cached and _cdp_http_ready(cached):
|
||||
return cached, None
|
||||
_bt._real_profile_cdp_cache.pop("cdp", None)
|
||||
|
||||
@@ -227,12 +231,12 @@ def _real_profile_cdp() -> tuple:
|
||||
# Cookies / Login Data) must NOT run while a live copy-browser (maybe from a previous
|
||||
# hermes process) holds the user-data-dir open — that corrupts the databases.
|
||||
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):
|
||||
existing = _agent_browser_get_cdp(_bt._REAL_PROFILE_SESSION)
|
||||
if existing and _cdp_http_ready(existing) and _cdp_on_data_dir(existing, copy_dir):
|
||||
_bt._real_profile_cdp_cache["cdp"] = existing
|
||||
return existing, None
|
||||
if existing: # stale/wrong-dir session: close it so nothing holds the dir open
|
||||
_bt._agent_browser_close_session(_bt._REAL_PROFILE_SESSION)
|
||||
_agent_browser_close_session(_bt._REAL_PROFILE_SESSION)
|
||||
|
||||
copy_dir, err = snapshot_real_profile(browser)
|
||||
if err or not copy_dir:
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"""agent-browser session management: daemon spawn, per-backend session creation
|
||||
(local/lightpanda/cdp/cloud), cached lookup, command execution + output interpretation.
|
||||
|
||||
Split out of ``tools/browser_tool.py`` (every name re-imported there); origin symbols
|
||||
are read through the ``_bt`` proxy so ``patch("tools.browser_tool.X")`` is honoured.
|
||||
Split out of ``tools/browser_tool.py``. Facade-owned state is read through ``_bt`` (``tools.browser_tool``, resolved per call) — no import cycle.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -14,7 +13,15 @@ import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from hermes_cli._subprocess_compat import windows_hide_flags
|
||||
from tools.browser_tool_origin import origin as _bt
|
||||
from tools import browser_tool_cdp as _cdp
|
||||
from tools import browser_tool_cloud as _cloud
|
||||
from tools import browser_tool_install as _install
|
||||
from tools import browser_tool_lifecycle as _lifecycle
|
||||
from tools import browser_tool_lightpanda_fallback as _lp
|
||||
from tools import browser_tool_real_profile as _real_profile
|
||||
from tools import browser_tool_snapshot as _snapshot
|
||||
|
||||
_DOCKER_PULL = "docker pull ghcr.io/nousresearch/hermes-agent:latest"
|
||||
_CHROMIUM_INSTALL = "npx agent-browser install --with-deps (or: npx playwright install --with-deps chromium)"
|
||||
@@ -27,7 +34,7 @@ def _needs_chromium_sandbox_bypass() -> bool:
|
||||
"""True when Chromium needs --no-sandbox to start reliably (root, Docker, AppArmor userns)."""
|
||||
if hasattr(os, "geteuid") and os.geteuid() == 0:
|
||||
return True
|
||||
if _bt._running_in_docker():
|
||||
if _install._running_in_docker():
|
||||
return True
|
||||
try:
|
||||
with open("/proc/sys/kernel/apparmor_restrict_unprivileged_userns", encoding="utf-8") as f:
|
||||
@@ -39,7 +46,7 @@ def _needs_chromium_sandbox_bypass() -> bool:
|
||||
def _apply_chromium_sandbox_args(browser_env: Dict[str, str]) -> None:
|
||||
"""Add required Chromium sandbox flags without overriding user settings."""
|
||||
if ("AGENT_BROWSER_ARGS" not in browser_env and "AGENT_BROWSER_CHROME_FLAGS" not in browser_env
|
||||
and _bt._needs_chromium_sandbox_bypass()):
|
||||
and _needs_chromium_sandbox_bypass()):
|
||||
_bt.logger.debug("browser: sandbox bypass needed (root/docker/AppArmor userns) — injecting --no-sandbox")
|
||||
browser_env["AGENT_BROWSER_ARGS"] = "--no-sandbox,--disable-dev-shm-usage"
|
||||
|
||||
@@ -77,8 +84,8 @@ def _format_browser_timeout_error(
|
||||
parts.append("Chromium sandbox launch failed. Set AGENT_BROWSER_ARGS="
|
||||
"'--no-sandbox,--disable-dev-shm-usage' in your environment, "
|
||||
"or run: npx agent-browser install --with-deps")
|
||||
elif command == "open" and _bt._is_local_mode():
|
||||
if _bt._running_in_docker():
|
||||
elif command == "open" and _cloud._is_local_mode():
|
||||
if _install._running_in_docker():
|
||||
parts.append("The browser daemon may still be starting or Chromium may be "
|
||||
f"missing. Pull the latest image: {_DOCKER_PULL}")
|
||||
else:
|
||||
@@ -95,8 +102,8 @@ def _agent_browser_argv(browser_cmd: str) -> list:
|
||||
absent the bare name gives a readable ``FileNotFoundError``. ``--ignore-scripts``:
|
||||
the spec is a floating range — a compromised future patch must not run install scripts.
|
||||
"""
|
||||
if _bt._is_npx_agent_browser_sentinel(browser_cmd):
|
||||
_npx_bin = _bt._resolve_npx_bin() or "npx"
|
||||
if _install._is_npx_agent_browser_sentinel(browser_cmd):
|
||||
_npx_bin = _install._resolve_npx_bin() or "npx"
|
||||
return [_npx_bin, "--ignore-scripts", "--prefer-offline", "-y", _bt.AGENT_BROWSER_NPX_SPEC]
|
||||
return [browser_cmd]
|
||||
|
||||
@@ -107,7 +114,7 @@ def _prepare_session_socket_dir(session_name: str) -> str:
|
||||
ownerless agent-browser-* dir in the shared tmpdir."""
|
||||
socket_dir = os.path.join(_bt._socket_safe_tmpdir(), f"agent-browser-{session_name}")
|
||||
os.makedirs(socket_dir, mode=0o700, exist_ok=True)
|
||||
_bt._write_owner_pid(socket_dir, session_name)
|
||||
_lifecycle._write_owner_pid(socket_dir, session_name)
|
||||
return socket_dir
|
||||
|
||||
|
||||
@@ -116,7 +123,7 @@ def _agent_browser_command_env(socket_dir: str) -> Dict[str, str]:
|
||||
daemon-side idle self-termination (agent-browser 0.24+) mirroring the Python janitor
|
||||
unless the user set ``AGENT_BROWSER_IDLE_TIMEOUT_MS`` explicitly."""
|
||||
env = _bt._build_browser_env()
|
||||
env["PATH"] = _bt._merge_browser_path(env.get("PATH", ""))
|
||||
env["PATH"] = _install._merge_browser_path(env.get("PATH", ""))
|
||||
env["AGENT_BROWSER_SOCKET_DIR"] = socket_dir
|
||||
if "AGENT_BROWSER_IDLE_TIMEOUT_MS" not in env:
|
||||
env["AGENT_BROWSER_IDLE_TIMEOUT_MS"] = str(_bt.BROWSER_SESSION_INACTIVITY_TIMEOUT * 1000)
|
||||
@@ -138,7 +145,7 @@ def _popen_agent_browser(argv: List[str], env: Dict[str, str], socket_dir: str,
|
||||
if os.name == "nt":
|
||||
_si = subprocess.STARTUPINFO()
|
||||
_si.dwFlags |= subprocess.STARTF_USESTDHANDLES
|
||||
_popen_extra = {"creationflags": _bt.windows_hide_flags(), "close_fds": True, "startupinfo": _si}
|
||||
_popen_extra = {"creationflags": windows_hide_flags(), "close_fds": True, "startupinfo": _si}
|
||||
return subprocess.Popen(argv, stdout=fds[0], stderr=fds[1], stdin=subprocess.DEVNULL, env=env, **_popen_extra)
|
||||
finally:
|
||||
for fd in fds:
|
||||
@@ -160,18 +167,18 @@ def _create_local_session(task_id: str, allow_real_profile: bool = True) -> Dict
|
||||
internal host the model chose.
|
||||
"""
|
||||
if allow_real_profile:
|
||||
cdp_url, err = _bt._real_profile_cdp()
|
||||
cdp_url, err = _real_profile._real_profile_cdp()
|
||||
if err:
|
||||
raise RuntimeError(err)
|
||||
if cdp_url:
|
||||
info = _session_record("rp", _bt._resolve_cdp_override(cdp_url), {"local": True, "real_profile": True})
|
||||
info = _session_record("rp", _cdp._resolve_cdp_override(cdp_url), {"local": True, "real_profile": True})
|
||||
_bt.logger.info("Created real-profile local session %s for task %s", info["session_name"], task_id)
|
||||
return info
|
||||
|
||||
# Browser Use mode + ``browser.engine: lightpanda`` drives a Hermes-spawned
|
||||
# ``lightpanda serve`` (the built-in tools are hidden in that mode).
|
||||
if _bt._is_browser_use_cli_mode() and _bt._using_lightpanda_engine():
|
||||
return _bt._create_lightpanda_session(task_id)
|
||||
if _bt._is_browser_use_cli_mode() and _lp._using_lightpanda_engine():
|
||||
return _create_lightpanda_session(task_id)
|
||||
|
||||
info = _session_record("h", None, {"local": True})
|
||||
_bt.logger.info("Created local browser session %s for task %s", info["session_name"], task_id)
|
||||
@@ -183,7 +190,7 @@ def _create_lightpanda_session(task_id: str) -> Dict[str, Any]:
|
||||
from tools.browser_lightpanda import launch_lightpanda
|
||||
|
||||
info = _session_record("lp", None, {"local": True, "lightpanda": True})
|
||||
server, err = launch_lightpanda(info["session_name"], block_private_networks=not _bt._is_local_backend())
|
||||
server, err = launch_lightpanda(info["session_name"], block_private_networks=not _cloud._is_local_backend())
|
||||
if err:
|
||||
raise RuntimeError(err)
|
||||
info["cdp_url"] = server.cdp_url
|
||||
@@ -218,14 +225,14 @@ def _create_cloud_session_or_fallback(task_id: str, provider) -> Dict[str, Any]:
|
||||
raise ValueError(f"Cloud provider returned invalid session: {session_info!r}")
|
||||
if session_info.get("cdp_url"):
|
||||
session_info = dict(session_info)
|
||||
session_info["cdp_url"] = _bt._resolve_cdp_override(str(session_info["cdp_url"]))
|
||||
session_info["cdp_url"] = _cdp._resolve_cdp_override(str(session_info["cdp_url"]))
|
||||
return session_info
|
||||
except Exception as e:
|
||||
provider_name = type(provider).__name__
|
||||
_bt.logger.warning("Cloud provider %s failed (%s); attempting fallback to local Chromium for task %s",
|
||||
provider_name, e, task_id, exc_info=True)
|
||||
try:
|
||||
session_info = _bt._create_local_session(task_id)
|
||||
session_info = _create_local_session(task_id)
|
||||
except Exception as local_error:
|
||||
raise RuntimeError(f"Cloud provider {provider_name} failed ({e}) and local "
|
||||
f"fallback also failed ({local_error})") from e
|
||||
@@ -238,15 +245,15 @@ def _create_cloud_session_or_fallback(task_id: str, provider) -> Dict[str, Any]:
|
||||
def _create_session_for_key(task_id: str, force_local: bool) -> Dict[str, Any]:
|
||||
"""Fresh session for ``task_id`` (runs OUTSIDE the lock: cloud mode makes a network call).
|
||||
Precedence: CDP override > hybrid local sidecar (never real-profile) > cloud > local."""
|
||||
cdp_override = _bt._get_cdp_override()
|
||||
cdp_override = _cdp._get_cdp_override()
|
||||
if cdp_override and not force_local:
|
||||
return _bt._create_cdp_session(task_id, cdp_override)
|
||||
return _create_cdp_session(task_id, cdp_override)
|
||||
if force_local:
|
||||
return _bt._create_local_session(task_id, allow_real_profile=False)
|
||||
provider = _bt._get_cloud_provider()
|
||||
return _create_local_session(task_id, allow_real_profile=False)
|
||||
provider = _cloud._get_cloud_provider()
|
||||
if provider is None:
|
||||
return _bt._create_local_session(task_id)
|
||||
return _bt._create_cloud_session_or_fallback(task_id, provider)
|
||||
return _create_local_session(task_id)
|
||||
return _create_cloud_session_or_fallback(task_id, provider)
|
||||
|
||||
|
||||
def _get_session_info(task_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
@@ -256,8 +263,8 @@ def _get_session_info(task_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
if task_id is None:
|
||||
task_id = "default"
|
||||
|
||||
_bt._start_browser_cleanup_thread()
|
||||
_bt._update_session_activity(task_id)
|
||||
_lifecycle._start_browser_cleanup_thread()
|
||||
_lifecycle._update_session_activity(task_id)
|
||||
|
||||
with _bt._cleanup_lock:
|
||||
existing_session = _bt._active_sessions.get(task_id)
|
||||
@@ -265,7 +272,7 @@ def _get_session_info(task_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
def _replacement_after_teardown() -> Optional[Dict[str, Any]]:
|
||||
# Teardown removes the activity entry; re-touch so the reaper tracks the
|
||||
# replacement. Another thread may already have re-created it — reuse that.
|
||||
_bt._update_session_activity(task_id)
|
||||
_lifecycle._update_session_activity(task_id)
|
||||
with _bt._cleanup_lock:
|
||||
replacement = _bt._active_sessions.get(task_id)
|
||||
return replacement if replacement is not None and replacement is not existing_session else None
|
||||
@@ -278,17 +285,17 @@ def _get_session_info(task_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
if replacement is not None:
|
||||
return replacement
|
||||
existing_session = None
|
||||
elif not _bt._session_has_expired(existing_session) and not _bt._local_backend_process_dead(existing_session):
|
||||
elif not _lifecycle._session_has_expired(existing_session) and not _local_backend_process_dead(existing_session):
|
||||
return existing_session
|
||||
else:
|
||||
_bt.logger.info("Replacing expired or dead browser session for task %s", task_id)
|
||||
_bt._cleanup_single_browser_session(task_id)
|
||||
_lifecycle._cleanup_single_browser_session(task_id)
|
||||
replacement = _replacement_after_teardown()
|
||||
if replacement is not None:
|
||||
return replacement
|
||||
|
||||
force_local = _bt._is_local_sidecar_key(task_id)
|
||||
session_info = _bt._create_session_for_key(task_id, force_local)
|
||||
session_info = _create_session_for_key(task_id, force_local)
|
||||
|
||||
with _bt._cleanup_lock:
|
||||
if task_id in _bt._active_sessions: # created concurrently during the network call — don't leak ours
|
||||
@@ -302,7 +309,7 @@ def _get_session_info(task_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
# Lazy-start the CDP supervisor (idempotent). Skip local sidecars (no CDP URL) and
|
||||
# Lightpanda sessions (Browser Use mode hides the tools that consume supervisor state).
|
||||
if not force_local and not (session_info.get("features") or {}).get("lightpanda"):
|
||||
_bt._ensure_cdp_supervisor(task_id)
|
||||
_cdp._ensure_cdp_supervisor(task_id)
|
||||
|
||||
return session_info
|
||||
|
||||
@@ -312,7 +319,7 @@ def _discard_timed_out_browser_session(task_id: str, session_info: Dict[str, Any
|
||||
with _bt._cleanup_lock:
|
||||
if _bt._active_sessions.get(task_id) is not session_info:
|
||||
return
|
||||
_bt._stop_cdp_supervisor(task_id)
|
||||
_cdp._stop_cdp_supervisor(task_id)
|
||||
if session_info.get("bb_session_id") or session_info.get("cdp_url"):
|
||||
replacement = dict(session_info)
|
||||
replacement["session_name"] = f"h_{uuid.uuid4().hex[:10]}"
|
||||
@@ -328,11 +335,11 @@ def _discard_timed_out_browser_session(task_id: str, session_info: Dict[str, Any
|
||||
|
||||
session_name = str(session_info.get("session_name") or "")
|
||||
if session_name and os.path.isfile(os.path.join(task_socket_dir, f"{session_name}.pid")):
|
||||
daemon_pid = _bt._read_browser_daemon_pid(task_socket_dir, session_name)
|
||||
daemon_pid = _read_browser_daemon_pid(task_socket_dir, session_name)
|
||||
if daemon_pid is None: # corrupt pid file
|
||||
_bt.logger.debug("Could not kill timed-out browser daemon for %s", session_name)
|
||||
return
|
||||
if not _bt._verify_reapable_browser_daemon(daemon_pid, task_socket_dir, session_name):
|
||||
if not _lifecycle._verify_reapable_browser_daemon(daemon_pid, task_socket_dir, session_name):
|
||||
return
|
||||
try:
|
||||
# Tree-kill: terminating only the daemon PID leaks the Chromium tree.
|
||||
@@ -399,18 +406,18 @@ def _handle_browser_command_timeout(task_id: str, session_info: Dict[str, Any],
|
||||
from scratch. See #72206.
|
||||
"""
|
||||
if session_info.get("bb_session_id") or session_info.get("cdp_url"):
|
||||
_bt._discard_timed_out_browser_session(task_id, session_info, task_socket_dir)
|
||||
_discard_timed_out_browser_session(task_id, session_info, task_socket_dir)
|
||||
return
|
||||
|
||||
_bt._browser_session_backend(task_id).mark_suspect("browser command timed out; session may be poisoned")
|
||||
|
||||
session_name = str(session_info.get("session_name") or "")
|
||||
daemon_pid = _bt._read_browser_daemon_pid(task_socket_dir, session_name) if session_name else None
|
||||
daemon_pid = _read_browser_daemon_pid(task_socket_dir, session_name) if session_name else None
|
||||
daemon_alive = (
|
||||
daemon_pid is not None
|
||||
and _bt._pid_exists(daemon_pid)
|
||||
and _bt._verify_reapable_browser_daemon(daemon_pid, task_socket_dir, session_name)
|
||||
and _bt._browser_daemon_responsive(task_socket_dir)
|
||||
and _lifecycle._pid_exists(daemon_pid)
|
||||
and _lifecycle._verify_reapable_browser_daemon(daemon_pid, task_socket_dir, session_name)
|
||||
and _browser_daemon_responsive(task_socket_dir)
|
||||
)
|
||||
if daemon_alive:
|
||||
_bt.logger.warning("browser daemon for %s is alive after command timeout; session "
|
||||
@@ -419,7 +426,7 @@ def _handle_browser_command_timeout(task_id: str, session_info: Dict[str, Any],
|
||||
|
||||
_bt.logger.warning("browser daemon for %s is wedged or dead after command timeout; "
|
||||
"tree-killing and evicting the session", task_id)
|
||||
_bt._discard_timed_out_browser_session(task_id, session_info, task_socket_dir)
|
||||
_discard_timed_out_browser_session(task_id, session_info, task_socket_dir)
|
||||
# The poisoned entry is gone either way; the flag must not poison a session
|
||||
# created later under the same key.
|
||||
_bt._suspect_browser_sessions.pop(task_id, None)
|
||||
@@ -451,7 +458,7 @@ def _interpret_browser_command_output(command: str, stdout: str, stderr: str, re
|
||||
_bt.logger.warning("browser '%s' returned non-JSON output (rc=%s): %s", command, returncode, raw[:500])
|
||||
if command == "screenshot":
|
||||
combined_text = "\n".join(part for part in [stdout_text, (stderr or "").strip()] if part)
|
||||
recovered_path = _bt._extract_screenshot_path_from_text(combined_text)
|
||||
recovered_path = _snapshot._extract_screenshot_path_from_text(combined_text)
|
||||
if recovered_path and Path(recovered_path).exists():
|
||||
_bt.logger.info("browser 'screenshot' recovered file from non-JSON output: %s", recovered_path)
|
||||
return {"success": True, "data": {"path": recovered_path, "raw": raw}}
|
||||
@@ -470,24 +477,24 @@ def _browser_command_preflight() -> Dict[str, Any]:
|
||||
"""Fail fast before spawning (missing CLI, Termux gap, interrupt, no Chromium in local
|
||||
mode — else every call hangs for command_timeout). Error result, or ``{"browser_cmd": path}``."""
|
||||
try:
|
||||
browser_cmd = _bt._find_agent_browser()
|
||||
browser_cmd = _install._find_agent_browser()
|
||||
except FileNotFoundError as e:
|
||||
_bt.logger.warning("agent-browser CLI not found: %s", e)
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
if _bt._requires_real_termux_browser_install(browser_cmd):
|
||||
error = _bt._termux_browser_install_error()
|
||||
if _install._requires_real_termux_browser_install(browser_cmd):
|
||||
error = _install._termux_browser_install_error()
|
||||
_bt.logger.warning("browser command blocked on Termux: %s", error)
|
||||
return {"success": False, "error": error}
|
||||
|
||||
# Skip when engine=lightpanda — LP doesn't need Chromium for navigation.
|
||||
if (
|
||||
_bt._is_local_mode()
|
||||
and not _bt._chromium_installed()
|
||||
and _bt._get_browser_engine() != "lightpanda"
|
||||
and not _bt._maybe_autoinstall_chromium()
|
||||
_cloud._is_local_mode()
|
||||
and not _install._chromium_installed()
|
||||
and _cloud._get_browser_engine() != "lightpanda"
|
||||
and not _install._maybe_autoinstall_chromium()
|
||||
):
|
||||
hint = _CHROMIUM_MISSING_DOCKER_HINT if _bt._running_in_docker() else _CHROMIUM_MISSING_HINT
|
||||
hint = _CHROMIUM_MISSING_DOCKER_HINT if _install._running_in_docker() else _CHROMIUM_MISSING_HINT
|
||||
_bt.logger.warning("browser command blocked: %s", hint)
|
||||
return {"success": False, "error": hint}
|
||||
|
||||
@@ -502,10 +509,10 @@ def _spawn_and_collect(
|
||||
command: str, engine: str, timeout: int,
|
||||
) -> Dict[str, Any]:
|
||||
"""Run the prepared agent-browser argv once and interpret its output (handles timeout)."""
|
||||
task_socket_dir = _bt._prepare_session_socket_dir(session_info["session_name"])
|
||||
task_socket_dir = _prepare_session_socket_dir(session_info["session_name"])
|
||||
_bt.logger.debug("browser cmd=%s task=%s socket_dir=%s (%d chars)",
|
||||
command, task_id, task_socket_dir, len(task_socket_dir))
|
||||
browser_env = _bt._agent_browser_command_env(task_socket_dir)
|
||||
browser_env = _agent_browser_command_env(task_socket_dir)
|
||||
|
||||
# Lightpanda rejects Chromium-only launch flags: strip current and legacy vars;
|
||||
# Chrome commands and fallback use the shared Chromium policy.
|
||||
@@ -516,31 +523,31 @@ def _spawn_and_collect(
|
||||
"for Lightpanda command %s (agent-browser rejects them with --engine lightpanda)",
|
||||
command)
|
||||
else:
|
||||
_bt._apply_chromium_sandbox_args(browser_env)
|
||||
_apply_chromium_sandbox_args(browser_env)
|
||||
|
||||
stdout_path = os.path.join(task_socket_dir, f"_stdout_{command}")
|
||||
stderr_path = os.path.join(task_socket_dir, f"_stderr_{command}")
|
||||
proc = _bt._popen_agent_browser(cmd_parts, browser_env, task_socket_dir, command)
|
||||
proc = _popen_agent_browser(cmd_parts, browser_env, task_socket_dir, command)
|
||||
|
||||
try:
|
||||
proc.wait(timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
stdout, stderr = _bt._read_command_output_files(stdout_path, stderr_path)
|
||||
_bt._unlink_command_output_files(stdout_path, stderr_path)
|
||||
_bt._handle_browser_command_timeout(task_id, session_info, task_socket_dir)
|
||||
stdout, stderr = _read_command_output_files(stdout_path, stderr_path)
|
||||
_unlink_command_output_files(stdout_path, stderr_path)
|
||||
_handle_browser_command_timeout(task_id, session_info, task_socket_dir)
|
||||
if stderr and stderr.strip():
|
||||
_bt.logger.warning("browser '%s' stderr after timeout: %s", command, stderr.strip()[:500])
|
||||
_bt.logger.warning("browser '%s' timed out after %ds (task=%s, socket_dir=%s)",
|
||||
command, timeout, task_id, task_socket_dir)
|
||||
return {"success": False, "error": _bt._format_browser_timeout_error(command, timeout, stdout, stderr)}
|
||||
return {"success": False, "error": _format_browser_timeout_error(command, timeout, stdout, stderr)}
|
||||
with open(stdout_path, "r", encoding="utf-8") as f:
|
||||
stdout = f.read()
|
||||
with open(stderr_path, "r", encoding="utf-8") as f:
|
||||
stderr = f.read()
|
||||
_bt._unlink_command_output_files(stdout_path, stderr_path)
|
||||
return _bt._interpret_browser_command_output(command, stdout, stderr, proc.returncode)
|
||||
_unlink_command_output_files(stdout_path, stderr_path)
|
||||
return _interpret_browser_command_output(command, stdout, stderr, proc.returncode)
|
||||
|
||||
|
||||
def _run_browser_command(
|
||||
@@ -563,29 +570,29 @@ def _run_browser_command(
|
||||
browser_cmd = preflight["browser_cmd"]
|
||||
|
||||
try:
|
||||
session_info = _bt._get_session_info(task_id)
|
||||
session_info = _get_session_info(task_id)
|
||||
except Exception as e:
|
||||
_bt.logger.warning("Failed to create browser session for task=%s: %s", task_id, e)
|
||||
return {"success": False, "error": f"Failed to create browser session: {str(e)}"}
|
||||
# Cleanup stops the supervisor before closing the backend; keep it stopped.
|
||||
if command != "close" and session_info.get("cdp_url"):
|
||||
_bt._ensure_cdp_supervisor(task_id)
|
||||
_cdp._ensure_cdp_supervisor(task_id)
|
||||
|
||||
# Cloud/CDP: ``--cdp <ws_url>`` (NEVER with --session: agent-browser >=0.13
|
||||
# would create a local browser and silently ignore --cdp). Local: ``--session <name>``.
|
||||
# Engine injection keys off the resolved session backend, not global provider
|
||||
# state: hybrid routing can create a local sidecar while a cloud provider stays configured.
|
||||
engine = _engine_override or _bt._get_browser_engine()
|
||||
engine = _engine_override or _cloud._get_browser_engine()
|
||||
if session_info.get("cdp_url"):
|
||||
backend_args = ["--cdp", session_info["cdp_url"]]
|
||||
else:
|
||||
backend_args = ["--session", session_info["session_name"]]
|
||||
if _bt._is_headed_mode():
|
||||
if _cloud._is_headed_mode():
|
||||
backend_args.append("--headed")
|
||||
if engine != "auto" and not _bt._is_camofox_mode():
|
||||
backend_args += ["--engine", engine]
|
||||
|
||||
cmd_parts = _bt._agent_browser_argv(browser_cmd) + backend_args + ["--json", command] + args
|
||||
cmd_parts = _agent_browser_argv(browser_cmd) + backend_args + ["--json", command] + args
|
||||
|
||||
try:
|
||||
result = _spawn_and_collect(task_id, session_info, cmd_parts, command, engine, timeout)
|
||||
@@ -595,13 +602,13 @@ def _run_browser_command(
|
||||
|
||||
# Lightpanda automatic Chrome fallback — runs for ALL exit paths (timeout,
|
||||
# empty, non-JSON, nonzero rc, parsed).
|
||||
fallback_reason = _bt._lightpanda_fallback_reason(engine, command, result)
|
||||
fallback_reason = _lp._lightpanda_fallback_reason(engine, command, result)
|
||||
if fallback_reason:
|
||||
_bt.logger.info("Lightpanda fallback: retrying '%s' with Chrome (task=%s): %s", command, task_id, fallback_reason)
|
||||
if command == "screenshot": # separate Chrome session to the same URL
|
||||
fallback_result = _bt._chrome_fallback_screenshot(task_id, args or [], timeout)
|
||||
fallback_result = _lp._chrome_fallback_screenshot(task_id, args or [], timeout)
|
||||
else:
|
||||
fallback_result = _bt._run_chrome_fallback_command(task_id, command, args, timeout)
|
||||
return _bt._annotate_lightpanda_fallback(fallback_result, fallback_reason)
|
||||
fallback_result = _lp._run_chrome_fallback_command(task_id, command, args, timeout)
|
||||
return _lp._annotate_lightpanda_fallback(fallback_result, fallback_reason)
|
||||
|
||||
return result
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
"""Snapshot post-processing for the browser tools: truncate-and-store of oversized
|
||||
accessibility trees, model-boundary secret redaction, screenshot-path recovery.
|
||||
|
||||
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).
|
||||
Facade-owned state is read through ``_bt`` (``tools.browser_tool``, resolved per call) — no import cycle.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
"""browser_vision helpers: Lightpanda pre-route, native provider vision, auxiliary-LLM screenshot analysis.
|
||||
|
||||
Split out of ``tools/browser_tool.py``; every name is re-imported there. Origin
|
||||
symbols are read through ``_bt`` (the
|
||||
:data:`tools.browser_tool_origin.origin` proxy) so ``patch("tools.browser_tool.X")``
|
||||
is honoured and no import cycle exists.
|
||||
Split out of ``tools/browser_tool.py``. Facade-owned state is read through ``_bt`` (``tools.browser_tool``, resolved per call) — no import cycle.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -11,11 +8,14 @@ import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from hermes_cli.config import cfg_get
|
||||
from tools.browser_tool_origin import origin as _bt
|
||||
from tools import browser_tool_cloud as _cloud
|
||||
from tools import browser_tool_lightpanda_fallback as _lp
|
||||
|
||||
|
||||
def _vision_mode_label() -> str:
|
||||
_cp = _bt._get_cloud_provider()
|
||||
_cp = _cloud._get_cloud_provider()
|
||||
return "local" if _cp is None else f"cloud ({_cp.display_name})"
|
||||
|
||||
|
||||
@@ -26,13 +26,13 @@ def _lightpanda_vision_preroute(
|
||||
(it has no graphical renderer). Returns ``(prerouted, fallback_warning, path)``;
|
||||
on fallback failure ``prerouted`` is False and the caller takes the normal
|
||||
screenshot path (forcing Chrome) so the standard fallback metadata still applies."""
|
||||
engine = _bt._get_browser_engine()
|
||||
if engine != "lightpanda" or not _bt._should_inject_engine(engine):
|
||||
engine = _cloud._get_browser_engine()
|
||||
if engine != "lightpanda" or not _cloud._should_inject_engine(engine):
|
||||
return False, None, screenshot_path
|
||||
_bt.logger.debug("browser_vision: pre-routing screenshot to Chrome (engine=lightpanda)")
|
||||
screenshot_args = ["--annotate"] if annotate else []
|
||||
fb_result = _bt._chrome_fallback_screenshot(effective_task_id, screenshot_args, _bt._get_command_timeout())
|
||||
fb_result = _bt._annotate_lightpanda_fallback(fb_result, _bt._LP_VISION_FALLBACK_REASON)
|
||||
fb_result = _lp._chrome_fallback_screenshot(effective_task_id, screenshot_args, _bt._get_command_timeout())
|
||||
fb_result = _lp._annotate_lightpanda_fallback(fb_result, _bt._LP_VISION_FALLBACK_REASON)
|
||||
if not fb_result.get("success"):
|
||||
_bt.logger.warning("Lightpanda Chrome fallback vision screenshot failed: %s", fb_result.get("error"))
|
||||
return False, None, screenshot_path
|
||||
@@ -108,7 +108,7 @@ def _analyze_screenshot_with_aux_llm(screenshot_path: Path, question: str) -> st
|
||||
vision_temperature = 0.1
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
_vision_cfg = _bt.cfg_get(load_config(), "auxiliary", "vision", default={})
|
||||
_vision_cfg = cfg_get(load_config(), "auxiliary", "vision", default={})
|
||||
if _vision_cfg.get("timeout") is not None:
|
||||
vision_timeout = float(_vision_cfg["timeout"])
|
||||
if _vision_cfg.get("temperature") is not None:
|
||||
@@ -116,6 +116,8 @@ def _analyze_screenshot_with_aux_llm(screenshot_path: Path, question: str) -> st
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
from agent.auxiliary_client import call_llm # lazy: heavy client, only needed on the vision path
|
||||
|
||||
call_kwargs = {
|
||||
"task": "vision", "temperature": vision_temperature, "timeout": vision_timeout,
|
||||
"messages": [{"role": "user", "content": [
|
||||
@@ -126,7 +128,7 @@ def _analyze_screenshot_with_aux_llm(screenshot_path: Path, question: str) -> st
|
||||
if vision_model:
|
||||
call_kwargs["model"] = vision_model
|
||||
try:
|
||||
response = _bt.call_llm(**call_kwargs)
|
||||
response = call_llm(**call_kwargs)
|
||||
except Exception as _api_err:
|
||||
from tools.vision_tools import _is_image_size_error, _resize_image_for_vision, _RESIZE_TARGET_BYTES
|
||||
if not (_is_image_size_error(_api_err) and len(data_url) > _RESIZE_TARGET_BYTES):
|
||||
@@ -135,7 +137,7 @@ def _analyze_screenshot_with_aux_llm(screenshot_path: Path, question: str) -> st
|
||||
len(data_url) / (1024 * 1024), _RESIZE_TARGET_BYTES / (1024 * 1024))
|
||||
data_url = _resize_image_for_vision(screenshot_path, mime_type="image/png")
|
||||
call_kwargs["messages"][0]["content"][1]["image_url"]["url"] = data_url
|
||||
response = _bt.call_llm(**call_kwargs)
|
||||
response = call_llm(**call_kwargs)
|
||||
|
||||
from agent.redact import redact_sensitive_text # the LLM may have read secrets off the screenshot
|
||||
return redact_sensitive_text((response.choices[0].message.content or "").strip())
|
||||
|
||||
@@ -104,7 +104,7 @@ def _camofox_active(context: str = "") -> bool:
|
||||
|
||||
|
||||
def _real_profile_consented() -> bool:
|
||||
return _lazy_call("tools.browser_tool", "_use_real_profile", False, "real-profile consent lookup failed")
|
||||
return _lazy_call("tools.browser_tool_cloud", "_use_real_profile", False, "real-profile consent lookup failed")
|
||||
|
||||
|
||||
def _set_cdp_env(env: dict, cdp: str) -> None:
|
||||
@@ -160,7 +160,7 @@ def _floor_subprocess_path(path: str) -> str:
|
||||
if os.name == "nt":
|
||||
return path
|
||||
with contextlib.suppress(Exception):
|
||||
from tools.browser_tool import _merge_browser_path
|
||||
from tools.browser_tool_install import _merge_browser_path
|
||||
return _merge_browser_path(path or "")
|
||||
parts = [p for p in (path or "").split(os.pathsep) if p]
|
||||
return os.pathsep.join(parts + [d for d in _FHS_BIN_DIRS if d not in set(parts) and os.path.isdir(d)])
|
||||
@@ -353,7 +353,8 @@ def _resolve_lightpanda_cdp(env: dict, task_id: Optional[str], session_name: str
|
||||
nothing of higher precedence claimed the session). Each cache key gets its own process via the
|
||||
legacy ``_get_session_info()`` (cache, reaper, atexit): private browser, own-tab preamble skipped."""
|
||||
try:
|
||||
from tools.browser_tool import _get_session_info, _using_lightpanda_engine
|
||||
from tools.browser_tool_session import _get_session_info
|
||||
from tools.browser_tool_lightpanda_fallback import _using_lightpanda_engine
|
||||
if not _using_lightpanda_engine():
|
||||
return None
|
||||
except Exception as e: # stubbed browser_tool in tests / engine lookup failure
|
||||
@@ -383,7 +384,9 @@ def _resolve_backend_cdp(env: dict, task_id: Optional[str], session_name: str =
|
||||
if _has_cdp_env(env):
|
||||
return None
|
||||
try:
|
||||
from tools.browser_tool import _get_cdp_override, _get_cloud_provider, _get_session_info
|
||||
from tools.browser_tool_cloud import _get_cloud_provider
|
||||
from tools.browser_tool_session import _get_session_info
|
||||
from tools.browser_tool_cdp import _get_cdp_override
|
||||
except Exception as e: # pragma: no cover — stubbed browser_tool in tests
|
||||
logger.debug("browser_tool backend resolution unavailable: %s", e)
|
||||
return None
|
||||
@@ -428,7 +431,9 @@ def _resolve_real_profile_cdp(env: dict, force_local: bool) -> Optional[str]:
|
||||
if not _real_profile_consented() or _has_cdp_env(env):
|
||||
return None
|
||||
try:
|
||||
from tools.browser_tool import _get_cdp_override_raw, _get_cloud_provider, _real_profile_cdp
|
||||
from tools.browser_tool_cdp import _get_cdp_override_raw
|
||||
from tools.browser_tool_cloud import _get_cloud_provider
|
||||
from tools.browser_tool_real_profile import _real_profile_cdp
|
||||
except Exception as e: # pragma: no cover — stubbed browser_tool in tests
|
||||
logger.debug("real-profile backend resolution unavailable: %s", e)
|
||||
return None
|
||||
@@ -608,7 +613,7 @@ _HELPERS_DIGEST = (
|
||||
|
||||
def _description_header() -> str:
|
||||
"""Header tailored to whether the active model can see images natively"""
|
||||
if _lazy_call("tools.browser_tool", "lightpanda_engine_status", (False, ""),
|
||||
if _lazy_call("tools.browser_tool_lightpanda_fallback", "lightpanda_engine_status", (False, ""),
|
||||
"lightpanda engine status unavailable")[0]: # no screenshots, whatever the model sees
|
||||
return _HEADER_BASE + _HEADER_TEXT_ONLY + _HEADER_LIGHTPANDA
|
||||
vision = _lazy_call("tools.vision_tools", "_should_use_native_vision_fast_path", False, "")
|
||||
|
||||
@@ -12,7 +12,7 @@ _CDP_SCHEMES = {"http", "https", "ws", "wss"}
|
||||
|
||||
def _resolve_browser_cdp_url() -> str:
|
||||
"""Configured browser CDP override without network I/O (``/browser status`` must be fast;
|
||||
``tools.browser_tool._get_cdp_override`` HTTP-probes discovery URLs). Same precedence (env,
|
||||
``tools.browser_tool_cdp._get_cdp_override`` HTTP-probes discovery URLs). Same precedence (env,
|
||||
then ``browser.cdp_url``) minus WS resolution; ``browser_navigate`` normalizes on the next call."""
|
||||
if env_url := os.environ.get("BROWSER_CDP_URL", "").strip():
|
||||
return env_url
|
||||
@@ -96,7 +96,7 @@ def _connect_local_default(port: int, system: str, announce) -> str | None:
|
||||
def _browser_connect(rid, params: dict) -> dict:
|
||||
import platform
|
||||
from hermes_cli.browser_connect import DEFAULT_BROWSER_CDP_URL
|
||||
from tools.browser_tool import cleanup_all_browsers
|
||||
from tools.browser_tool_lifecycle import cleanup_all_browsers
|
||||
from urllib.parse import urlparse
|
||||
raw_url = params.get("url")
|
||||
if raw_url is not None and not isinstance(raw_url, str):
|
||||
@@ -161,7 +161,7 @@ def _browser_disconnect(rid) -> dict:
|
||||
# Reap, drop the override, reap again — same swap window as ``_browser_connect``.
|
||||
def reap() -> None:
|
||||
with contextlib.suppress(Exception):
|
||||
from tools.browser_tool import cleanup_all_browsers
|
||||
from tools.browser_tool_lifecycle import cleanup_all_browsers
|
||||
cleanup_all_browsers()
|
||||
|
||||
reap()
|
||||
|
||||
Reference in New Issue
Block a user