refactor(tools): simplify browser backend modules (browser_use_cli, camofox, cdp, lightpanda, real-profile, install, cloud)
This commit is contained in:
+70
-94
@@ -1,14 +1,11 @@
|
||||
"""Camofox browser backend — local anti-detection browser via REST API.
|
||||
|
||||
Camofox-browser (https://github.com/jo-inc/camofox-browser) is a self-hosted
|
||||
Node.js server wrapping Camoufox (Firefox fork with C++ fingerprint spoofing).
|
||||
Its REST API maps 1:1 to our browser tool interface: accessibility snapshots
|
||||
with element refs, click/type/scroll by ref, screenshots.
|
||||
|
||||
Setup: ``npm install && npm start`` in a camofox-browser checkout, or
|
||||
``docker run -p 9377:9377 -e CAMOFOX_PORT=9377 jo-inc/camofox-browser``; then set
|
||||
``CAMOFOX_URL=http://localhost:9377`` in ``~/.hermes/.env`` (Docker: see
|
||||
``CAMOFOX_REWRITE_LOOPBACK_URLS`` in :func:`_rewrite_loopback_url_for_camofox`).
|
||||
Camofox-browser (https://github.com/jo-inc/camofox-browser) is a self-hosted Node.js
|
||||
server wrapping Camoufox (Firefox fork with C++ fingerprint spoofing); its REST API maps
|
||||
1:1 to our browser tool interface (accessibility snapshots with element refs, click/type/
|
||||
scroll by ref, screenshots). Setup: ``npm start`` in a checkout or ``docker run -p 9377:9377
|
||||
-e CAMOFOX_PORT=9377 jo-inc/camofox-browser``, then ``CAMOFOX_URL=http://localhost:9377`` in
|
||||
``~/.hermes/.env`` (Docker: see ``CAMOFOX_REWRITE_LOOPBACK_URLS`` below).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -46,8 +43,7 @@ _cmd_timeout_resolved = False
|
||||
|
||||
|
||||
def _get_command_timeout() -> int:
|
||||
"""``browser.command_timeout`` (floor 5s, default 30s), cached after first read;
|
||||
mirrors :func:`tools.browser_tool._get_command_timeout`."""
|
||||
"""``browser.command_timeout`` (floor 5s, default 30s), cached after first read."""
|
||||
global _cached_cmd_timeout, _cmd_timeout_resolved
|
||||
if _cmd_timeout_resolved:
|
||||
return _cached_cmd_timeout # type: ignore[return-value]
|
||||
@@ -76,8 +72,8 @@ def get_camofox_url() -> str:
|
||||
|
||||
|
||||
def _config_cdp_url() -> str:
|
||||
"""Persistent ``browser.cdp_url`` from config.yaml, or "". Read here rather than
|
||||
via ``browser_tool._get_cdp_override`` (circular import)."""
|
||||
"""Persistent ``browser.cdp_url`` from config.yaml, or "" (read here, not via
|
||||
``browser_tool._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", {})
|
||||
@@ -91,11 +87,10 @@ def _config_cdp_url() -> str:
|
||||
def is_camofox_mode() -> bool:
|
||||
"""True when the Camofox backend is selected and no CDP override is active.
|
||||
|
||||
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`` config, same precedence
|
||||
as ``browser_tool._get_cdp_override()``) wins so tools drive the real CDP browser.
|
||||
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.
|
||||
"""
|
||||
if os.getenv("BROWSER_CDP_URL", "").strip() or _config_cdp_url():
|
||||
return False
|
||||
@@ -154,18 +149,19 @@ def _managed_persistence_enabled(camofox_cfg: Optional[Dict[str, Any]] = None) -
|
||||
return bool(camofox_cfg.get("managed_persistence"))
|
||||
|
||||
|
||||
def _secret_or_cfg(secret_name: str, camofox_cfg: Dict[str, Any], cfg_key: str) -> str:
|
||||
"""Secret-scope value first, then the ``browser.camofox`` config key, else ""."""
|
||||
return (get_secret(secret_name, "") or "").strip() or str(camofox_cfg.get(cfg_key) or "").strip()
|
||||
def _env_or_cfg(env_name: str, camofox_cfg: Dict[str, Any], cfg_key: str, *, secret: bool = False) -> str:
|
||||
"""Env/secret-scope value first, then the ``browser.camofox`` config key, else ""."""
|
||||
raw = get_secret(env_name, "") if secret else os.getenv(env_name, "")
|
||||
return (raw or "").strip() or str(camofox_cfg.get(cfg_key) or "").strip()
|
||||
|
||||
|
||||
def _camofox_identity_override(task_id: Optional[str], camofox_cfg: Dict[str, Any]) -> Optional[Dict[str, str]]:
|
||||
"""Externally configured identity (integrations owning the visible Camofox
|
||||
browser share a user ID so Hermes uses the same profile), or None."""
|
||||
user_id = _secret_or_cfg("CAMOFOX_USER_ID", camofox_cfg, "user_id")
|
||||
"""Externally configured identity (integrations owning the visible Camofox browser
|
||||
share a user ID so Hermes uses the same profile), or None."""
|
||||
user_id = _env_or_cfg("CAMOFOX_USER_ID", camofox_cfg, "user_id", secret=True)
|
||||
if not user_id:
|
||||
return None
|
||||
session_key = _secret_or_cfg("CAMOFOX_SESSION_KEY", camofox_cfg, "session_key")
|
||||
session_key = _env_or_cfg("CAMOFOX_SESSION_KEY", camofox_cfg, "session_key", secret=True)
|
||||
return {"user_id": user_id, "session_key": session_key or f"task_{(task_id or 'default')[:16]}"}
|
||||
|
||||
|
||||
@@ -181,14 +177,7 @@ def _flag(env_name: str, camofox_cfg: Dict[str, Any], cfg_key: str) -> bool:
|
||||
return bool(camofox_cfg.get(cfg_key))
|
||||
|
||||
|
||||
def _loopback_rewrite_host(camofox_cfg: Dict[str, Any]) -> str:
|
||||
"""Return the host alias used when rewriting loopback page URLs."""
|
||||
return (os.getenv("CAMOFOX_LOOPBACK_HOST_ALIAS", "").strip()
|
||||
or str(camofox_cfg.get("loopback_host_alias") or "").strip() or "host.docker.internal")
|
||||
|
||||
|
||||
def _is_loopback_hostname(hostname: Optional[str]) -> bool:
|
||||
"""Return True for localhost/127.0.0.0/8/::1-style hostnames."""
|
||||
if not hostname:
|
||||
return False
|
||||
host = hostname.strip().strip("[]").lower()
|
||||
@@ -203,12 +192,11 @@ def _is_loopback_hostname(hostname: Optional[str]) -> bool:
|
||||
def _rewrite_loopback_url_for_camofox(url: str) -> tuple[str, Optional[Dict[str, str]]]:
|
||||
"""Rewrite loopback page URLs for Docker-hosted Camofox, if configured.
|
||||
|
||||
``CAMOFOX_URL`` may point at a host-published Docker port, but page URLs are
|
||||
opened by the browser *inside* the container, where loopback is the container,
|
||||
not the host. Opt-in (``CAMOFOX_REWRITE_LOOPBACK_URLS`` / config) because
|
||||
non-Docker installs run the browser on the host. Returns ``(rewritten_url,
|
||||
metadata)``; ``metadata`` is present only when a rewrite happened so the tool
|
||||
result can disclose the change to the model.
|
||||
``CAMOFOX_URL`` may point at a host-published Docker port, but page URLs are opened by
|
||||
the browser *inside* the container, where loopback is the container, not the host.
|
||||
Opt-in (``CAMOFOX_REWRITE_LOOPBACK_URLS`` / config) because non-Docker installs run the
|
||||
browser on the host. Returns ``(rewritten_url, metadata)``; ``metadata`` is present only
|
||||
when a rewrite happened so the tool result can disclose the change to the model.
|
||||
"""
|
||||
camofox_cfg = _get_camofox_config()
|
||||
if not _flag("CAMOFOX_REWRITE_LOOPBACK_URLS", camofox_cfg, "rewrite_loopback_urls"):
|
||||
@@ -218,7 +206,7 @@ def _rewrite_loopback_url_for_camofox(url: str) -> tuple[str, Optional[Dict[str,
|
||||
parsed = urlsplit(url)
|
||||
except ValueError:
|
||||
return url, None
|
||||
alias = _loopback_rewrite_host(camofox_cfg)
|
||||
alias = _env_or_cfg("CAMOFOX_LOOPBACK_HOST_ALIAS", camofox_cfg, "loopback_host_alias") or "host.docker.internal"
|
||||
if parsed.scheme not in {"http", "https"} or not _is_loopback_hostname(parsed.hostname) or not alias:
|
||||
return url, None
|
||||
|
||||
@@ -302,9 +290,9 @@ def _drop_session(task_id: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||
|
||||
|
||||
def camofox_soft_cleanup(task_id: Optional[str] = None) -> bool:
|
||||
"""Drop only the local tracking entry (``True``) for managed profiles, which
|
||||
must survive across agent tasks; ``False`` for ephemeral sessions so the caller
|
||||
falls back to :func:`camofox_close`."""
|
||||
"""Drop only the local tracking entry (``True``) for managed profiles, which must
|
||||
survive across agent tasks; ``False`` for ephemeral sessions so the caller falls back
|
||||
to :func:`camofox_close`."""
|
||||
camofox_cfg = _get_camofox_config()
|
||||
if _managed_persistence_enabled(camofox_cfg) or _camofox_identity_override(task_id, camofox_cfg):
|
||||
_drop_session(task_id)
|
||||
@@ -325,22 +313,19 @@ def _request(method: str, path: str, timeout: Optional[int] = None, **kwargs: An
|
||||
|
||||
|
||||
def _post(path: str, body: dict, timeout: Optional[int] = None) -> dict:
|
||||
"""POST JSON to camofox and return parsed response."""
|
||||
return _request("post", path, timeout, json=body).json()
|
||||
|
||||
|
||||
def _get(path: str, params: dict = None, timeout: Optional[int] = None) -> dict:
|
||||
"""GET from camofox and return parsed response."""
|
||||
return _request("get", path, timeout, params=params).json()
|
||||
|
||||
|
||||
def _get_raw(path: str, params: dict = None, timeout: Optional[int] = None) -> requests.Response:
|
||||
"""GET from camofox and return raw response (for binary data)."""
|
||||
"""GET and return the raw response (for binary data)."""
|
||||
return _request("get", path, timeout, params=params)
|
||||
|
||||
|
||||
def _delete(path: str, body: dict = None, timeout: Optional[int] = None) -> dict:
|
||||
"""DELETE to camofox and return parsed response."""
|
||||
return _request("delete", path, timeout, json=body).json()
|
||||
|
||||
|
||||
@@ -354,13 +339,13 @@ def _user_params(session: Dict[str, Any]) -> Dict[str, str]:
|
||||
return {"userId": session["user_id"]}
|
||||
|
||||
|
||||
def _raw_snapshot(session: Dict[str, Any]) -> str:
|
||||
return _get(_tab_path(session, "snapshot"), params=_user_params(session)).get("snapshot", "")
|
||||
def _snapshot_data(session: Dict[str, Any]) -> dict:
|
||||
return _get(_tab_path(session, "snapshot"), params=_user_params(session))
|
||||
|
||||
|
||||
def _parse_snapshot_images(snapshot: str) -> list[Dict[str, str]]:
|
||||
"""Images from an accessibility snapshot: ``img "alt" [eN]`` entries with the
|
||||
URL on the following ``/url:`` line (Camofox has no /images endpoint)."""
|
||||
"""Images from an accessibility snapshot: ``img "alt" [eN]`` entries with the URL on
|
||||
the following ``/url:`` line (Camofox has no /images endpoint)."""
|
||||
images = []
|
||||
lines = snapshot.split("\n")
|
||||
for i, line in enumerate(lines):
|
||||
@@ -377,10 +362,10 @@ def _parse_snapshot_images(snapshot: str) -> list[Dict[str, str]]:
|
||||
|
||||
|
||||
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).
|
||||
``browser_tool`` imports this module, so import lazily."""
|
||||
data = _get(_tab_path(session, "snapshot"), params=_user_params(session))
|
||||
"""``(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."""
|
||||
data = _snapshot_data(session)
|
||||
snapshot = data.get("snapshot", "")
|
||||
from tools.browser_tool import _truncate_snapshot, get_browser_snapshot_threshold
|
||||
threshold = get_browser_snapshot_threshold()
|
||||
@@ -448,11 +433,10 @@ def camofox_navigate(url: str, task_id: Optional[str] = None) -> str:
|
||||
def _camofox_private_page_block(session: Dict[str, Any], task_id: Optional[str], action: str) -> Optional[str]:
|
||||
"""Blocked payload when the current page is private/internal, else None.
|
||||
|
||||
Mirrors the ``_camofox_eval`` guard in browser_tool.py: page-state reads on a
|
||||
non-local backend can leak an intranet/metadata page the terminal can't reach.
|
||||
Only active when the SSRF guard applies (non-local backend, not a local sidecar,
|
||||
``allow_private_urls`` unset); fail-open on probe failure like sibling guards.
|
||||
``browser_tool`` imports this module, so import lazily.
|
||||
Mirrors the ``_camofox_eval`` guard in browser_tool.py: page-state reads on a non-local
|
||||
backend can leak an intranet/metadata page the terminal can't reach. Only active when
|
||||
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
|
||||
if not _eval_ssrf_guard_active(task_id or "default"):
|
||||
@@ -475,31 +459,32 @@ def _require_tab(task_id: Optional[str], action: Optional[str] = None) -> tuple[
|
||||
return session, None
|
||||
|
||||
|
||||
def camofox_snapshot(full: bool = False, task_id: Optional[str] = None, user_task: Optional[str] = None) -> str:
|
||||
"""Accessibility tree snapshot. ``user_task`` is deprecated and ignored —
|
||||
oversized snapshots always truncate-and-store (no LLM summarization)."""
|
||||
def _with_tab(task_id: Optional[str], guard_action: Optional[str], body: Callable[[Dict[str, Any]], str]) -> str:
|
||||
"""Require a tab (+ private-page guard when ``guard_action`` is set), then run ``body(session)``;
|
||||
any exception becomes a ``tool_error``."""
|
||||
try:
|
||||
session, blocked = _require_tab(task_id, "read a page snapshot")
|
||||
session, blocked = _require_tab(task_id, guard_action)
|
||||
if blocked:
|
||||
return blocked
|
||||
snapshot, refs_count = _fetch_snapshot(session)
|
||||
return json.dumps({"success": True, "snapshot": snapshot, "element_count": refs_count})
|
||||
return body(session)
|
||||
except Exception as e:
|
||||
return tool_error(str(e), success=False)
|
||||
|
||||
|
||||
def _tab_action(task_id: Optional[str], guard_action: Optional[str], suffix: str,
|
||||
body: Dict[str, Any], result: Callable[[dict], dict]) -> str:
|
||||
"""Simple tab action: require a tab (+ private-page guard when ``guard_action``
|
||||
is set), POST ``body`` to ``/tabs/<id>/<suffix>``, build the result."""
|
||||
try:
|
||||
session, blocked = _require_tab(task_id, guard_action)
|
||||
if blocked:
|
||||
return blocked
|
||||
data = _post(_tab_path(session, suffix), {"userId": session["user_id"], **body})
|
||||
return json.dumps(result(data))
|
||||
except Exception as e:
|
||||
return tool_error(str(e), success=False)
|
||||
"""Simple tab action: POST ``body`` to ``/tabs/<id>/<suffix>``, build the result."""
|
||||
return _with_tab(task_id, guard_action, lambda session: json.dumps(
|
||||
result(_post(_tab_path(session, suffix), {"userId": session["user_id"], **body}))))
|
||||
|
||||
|
||||
def camofox_snapshot(full: bool = False, task_id: Optional[str] = None, user_task: Optional[str] = None) -> str:
|
||||
"""Accessibility tree snapshot. ``user_task`` is deprecated and ignored —
|
||||
oversized snapshots always truncate-and-store (no LLM summarization)."""
|
||||
def body(session):
|
||||
snapshot, refs_count = _fetch_snapshot(session)
|
||||
return json.dumps({"success": True, "snapshot": snapshot, "element_count": refs_count})
|
||||
return _with_tab(task_id, "read a page snapshot", body)
|
||||
|
||||
|
||||
def camofox_click(ref: str, task_id: Optional[str] = None) -> str:
|
||||
@@ -518,9 +503,9 @@ def camofox_type(ref: str, text: str, task_id: Optional[str] = None) -> str:
|
||||
clean_ref = ref.lstrip("@")
|
||||
_post(_tab_path(session, "type"), {"userId": session["user_id"], "ref": clean_ref, "text": text})
|
||||
from agent.display import redact_browser_typed_text_for_display, redact_tool_args_for_display
|
||||
# Match browser_tool.browser_type: the raw text is typed into the page, but
|
||||
# the returned display value is run through the secret-pattern redactor so
|
||||
# API keys / tokens don't leak into tool progress or chat history.
|
||||
# Match browser_tool.browser_type: the raw text is typed into the page, but the
|
||||
# returned display value is run through the secret-pattern redactor so API keys /
|
||||
# tokens don't leak into tool progress or chat history.
|
||||
display_text = (redact_tool_args_for_display("browser_type", {"text": text}) or {})["text"]
|
||||
response = {"success": True, "typed": display_text, "element": clean_ref}
|
||||
return json.dumps(redact_browser_typed_text_for_display(response, text))
|
||||
@@ -558,15 +543,10 @@ def camofox_close(task_id: Optional[str] = None) -> str:
|
||||
|
||||
def camofox_get_images(task_id: Optional[str] = None) -> str:
|
||||
"""Get images on the current page via Camofox (parsed from the snapshot)."""
|
||||
try:
|
||||
session, blocked = _require_tab(task_id, "extract page images")
|
||||
if blocked:
|
||||
return blocked
|
||||
|
||||
images = _parse_snapshot_images(_raw_snapshot(session))
|
||||
def body(session):
|
||||
images = _parse_snapshot_images(_snapshot_data(session).get("snapshot", ""))
|
||||
return json.dumps({"success": True, "images": images, "count": len(images)})
|
||||
except Exception as e:
|
||||
return tool_error(str(e), success=False)
|
||||
return _with_tab(task_id, "extract page images", body)
|
||||
|
||||
|
||||
def _vision_llm_settings() -> tuple[float, float]:
|
||||
@@ -591,11 +571,7 @@ def _save_screenshot(content: bytes) -> str:
|
||||
|
||||
def camofox_vision(question: str, annotate: bool = False, task_id: Optional[str] = None) -> str:
|
||||
"""Take a screenshot and analyze it with vision AI via Camofox."""
|
||||
try:
|
||||
session, blocked = _require_tab(task_id, "capture a screenshot")
|
||||
if blocked:
|
||||
return blocked
|
||||
|
||||
def body(session):
|
||||
resp = _get_raw(_tab_path(session, "screenshot"), params=_user_params(session))
|
||||
screenshot_path = _save_screenshot(resp.content)
|
||||
img_b64 = base64.b64encode(resp.content).decode("utf-8")
|
||||
@@ -603,7 +579,8 @@ def camofox_vision(question: str, annotate: bool = False, task_id: Optional[str]
|
||||
annotation_context = ""
|
||||
if annotate:
|
||||
try:
|
||||
annotation_context = f"\n\nAccessibility tree (element refs for interaction):\n{_raw_snapshot(session)[:3000]}"
|
||||
snapshot = _snapshot_data(session).get("snapshot", "")
|
||||
annotation_context = f"\n\nAccessibility tree (element refs for interaction):\n{snapshot[:3000]}"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -625,8 +602,7 @@ def camofox_vision(question: str, annotate: bool = False, task_id: Optional[str]
|
||||
analysis = (response.choices[0].message.content or "").strip() if response.choices else ""
|
||||
# Redact secrets the vision LLM may have read from the screenshot.
|
||||
return json.dumps({"success": True, "analysis": redact_sensitive_text(analysis), "screenshot_path": screenshot_path})
|
||||
except Exception as e:
|
||||
return tool_error(str(e), success=False)
|
||||
return _with_tab(task_id, "capture a screenshot", body)
|
||||
|
||||
|
||||
def camofox_console(clear: bool = False, task_id: Optional[str] = None) -> str:
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"""Hermes-managed Camofox state helpers.
|
||||
|
||||
With managed persistence enabled, Hermes sends a deterministic userId derived
|
||||
from the active profile so Camofox maps it to the same persistent browser
|
||||
profile directory across restarts.
|
||||
With managed persistence enabled, Hermes sends a deterministic userId derived from the
|
||||
active profile so Camofox maps it to the same persistent browser profile across restarts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -13,25 +12,16 @@ from typing import Dict, Optional
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
CAMOFOX_STATE_DIR_NAME = "browser_auth"
|
||||
CAMOFOX_STATE_SUBDIR = "camofox"
|
||||
|
||||
|
||||
def get_camofox_state_dir() -> Path:
|
||||
"""Return the profile-scoped root directory for Camofox persistence."""
|
||||
return get_hermes_home() / CAMOFOX_STATE_DIR_NAME / CAMOFOX_STATE_SUBDIR
|
||||
return get_hermes_home() / "browser_auth" / "camofox"
|
||||
|
||||
|
||||
def get_camofox_identity(task_id: Optional[str] = None) -> Dict[str, str]:
|
||||
"""Return the stable Hermes-managed Camofox identity for this profile.
|
||||
|
||||
The user identity is profile-scoped (same Hermes profile = same userId); the
|
||||
session key is scoped to the logical browser task so new tabs within the
|
||||
same profile reuse the same identity contract.
|
||||
"""
|
||||
"""Stable Hermes-managed Camofox identity: userId is profile-scoped, session key is
|
||||
scoped to the logical browser task so new tabs in the same profile reuse it."""
|
||||
scope_root = str(get_camofox_state_dir())
|
||||
user_digest = uuid.uuid5(uuid.NAMESPACE_URL, f"camofox-user:{scope_root}").hex[:10]
|
||||
session_digest = uuid.uuid5(
|
||||
uuid.NAMESPACE_URL, f"camofox-session:{scope_root}:{task_id or 'default'}"
|
||||
).hex[:16]
|
||||
session_digest = uuid.uuid5(uuid.NAMESPACE_URL, f"camofox-session:{scope_root}:{task_id or 'default'}").hex[:16]
|
||||
return {"user_id": f"hermes_{user_digest}", "session_key": f"task_{session_digest}"}
|
||||
|
||||
+113
-226
@@ -1,10 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Raw Chrome DevTools Protocol (CDP) passthrough tool ``browser_cdp``.
|
||||
|
||||
Sends arbitrary CDP commands to the browser's DevTools WebSocket when a CDP URL
|
||||
is configured (``/browser connect`` → ``BROWSER_CDP_URL``, ``browser.cdp_url``,
|
||||
or a CDP-backed cloud session). Escape hatch for operations the main browser
|
||||
tools don't cover. Method reference: https://chromedevtools.github.io/devtools-protocol/
|
||||
Sends arbitrary CDP commands to the browser's DevTools WebSocket when a CDP URL is
|
||||
configured (``/browser connect`` → ``BROWSER_CDP_URL``, ``browser.cdp_url``, or a
|
||||
CDP-backed cloud session). Escape hatch for operations the main browser tools don't
|
||||
cover. Method reference: https://chromedevtools.github.io/devtools-protocol/
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -20,22 +20,17 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
CDP_DOCS_URL = "https://chromedevtools.github.io/devtools-protocol/"
|
||||
|
||||
# Browser/target inspection that never reads page body/cookies/DOM/storage —
|
||||
# stays usable so the model can list tabs or navigate away from a blocked page.
|
||||
# Browser/target inspection that never reads page body/cookies/DOM/storage — stays
|
||||
# usable so the model can list tabs or navigate away from a blocked page.
|
||||
_CDP_PRIVATE_PAGE_ALLOWED_METHODS = {
|
||||
"Browser.getVersion",
|
||||
"Target.getTargets",
|
||||
"Target.attachToTarget",
|
||||
"Target.detachFromTarget",
|
||||
"Page.navigate",
|
||||
"Page.reload",
|
||||
"Page.stopLoading",
|
||||
"Browser.getVersion", "Target.getTargets", "Target.attachToTarget", "Target.detachFromTarget",
|
||||
"Page.navigate", "Page.reload", "Page.stopLoading",
|
||||
}
|
||||
|
||||
# method → result paths that are ALWAYS opaque base64 (protocol-declared binary).
|
||||
# redact_sensitive_text's Fernet pattern ("gAAAA" + base64 alphabet) can match
|
||||
# arbitrary spans inside such payloads and corrupt the decoded bytes; the payload
|
||||
# is not free text the model reads, so redaction protects no secret there.
|
||||
# redact_sensitive_text's Fernet pattern ("gAAAA" + base64 alphabet) can match arbitrary
|
||||
# spans inside such payloads and corrupt the decoded bytes; the payload is not free text
|
||||
# the model reads, so redaction protects no secret there.
|
||||
_CDP_ALWAYS_BINARY_PATHS: Dict[str, tuple] = {
|
||||
"Page.captureScreenshot": (("data",),),
|
||||
"Page.printToPDF": (("data",),),
|
||||
@@ -57,10 +52,10 @@ _CDP_FLAGGED_BINARY_PATHS: Dict[str, tuple] = {
|
||||
def _redact_cdp_output(value: Any, *, always_paths: tuple = (), flagged_paths: tuple = ()) -> Any:
|
||||
"""Redact browser-originated CDP result text; opaque bytes stay byte-identical.
|
||||
|
||||
Exemptions come ONLY from the calling method's spec as exact result paths.
|
||||
Path suffixes propagate only into the matching subtree, so ``base64Encoded``
|
||||
is honored solely as a sibling on the trusted carrier object — never as
|
||||
ambient trust a ``Runtime.evaluate`` by-value object could spoof.
|
||||
Exemptions come ONLY from the calling method's spec as exact result paths. Path
|
||||
suffixes propagate only into the matching subtree, so ``base64Encoded`` is honored
|
||||
solely as a sibling on the trusted carrier object — never as ambient trust a
|
||||
``Runtime.evaluate`` by-value object could spoof.
|
||||
"""
|
||||
from agent.redact import redact_sensitive_text
|
||||
|
||||
@@ -133,22 +128,18 @@ def _blocked(message: str, method: str) -> str:
|
||||
|
||||
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
|
||||
|
||||
target_url = str(params.get("url") or "").strip()
|
||||
if target_url and (bt._is_always_blocked_url(target_url) or not bt._is_safe_url(target_url)):
|
||||
return target_url
|
||||
return None
|
||||
return target_url if target_url and _url_blocked(bt, target_url) else None
|
||||
|
||||
|
||||
# method → (probe(bt, params) -> blocked literal | None, error template)
|
||||
_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 "")),
|
||||
"Blocked: CDP Runtime.evaluate expression targets a private or internal address ({}).",
|
||||
),
|
||||
"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 "")),
|
||||
"Blocked: CDP Runtime.evaluate expression targets a private or internal address ({})."),
|
||||
}
|
||||
|
||||
|
||||
@@ -156,9 +147,8 @@ def _browser_cdp_private_guard(*, task_id: str, method: str, params: Dict[str, A
|
||||
"""Apply the browser SSRF/private-page guard to raw CDP calls.
|
||||
|
||||
Raw CDP shares the cloud/private-network boundary of ``browser_snapshot`` /
|
||||
``browser_console`` / ``browser_eval`` and must not become their bypass.
|
||||
Guard probes are best-effort; a probe failure never breaks local/custom CDP
|
||||
workflows.
|
||||
``browser_console`` / ``browser_eval`` and must not become their bypass. Probes are
|
||||
best-effort; a probe failure never breaks local/custom CDP workflows.
|
||||
"""
|
||||
try:
|
||||
from tools import browser_tool as bt # type: ignore[import-not-found]
|
||||
@@ -190,19 +180,15 @@ async def _cdp_call(
|
||||
) -> Dict[str, Any]:
|
||||
"""Make a single CDP call, optionally attaching to a target first.
|
||||
|
||||
With ``target_id``, ``Target.attachToTarget(flatten=True)`` multiplexes a
|
||||
page-level session over the browser-level WebSocket; without it ``method``
|
||||
runs at browser level (``Target.*``, ``Browser.*``, ``Storage.*`` …).
|
||||
With ``target_id``, ``Target.attachToTarget(flatten=True)`` multiplexes a page-level
|
||||
session over the browser-level WebSocket; without it ``method`` runs at browser level.
|
||||
"""
|
||||
assert websockets is not None # guarded by _WS_AVAILABLE at call-site
|
||||
|
||||
async with websockets.connect(
|
||||
ws_url,
|
||||
max_size=None, # CDP responses (e.g. DOM.getDocument) can be large
|
||||
open_timeout=timeout,
|
||||
close_timeout=5,
|
||||
ping_interval=None, # CDP server doesn't expect pings
|
||||
) as ws:
|
||||
# max_size=None: CDP responses (e.g. DOM.getDocument) can be large; ping_interval=None: CDP
|
||||
# servers don't expect pings.
|
||||
async with websockets.connect(ws_url, max_size=None, open_timeout=timeout, close_timeout=5,
|
||||
ping_interval=None) as ws:
|
||||
next_id = 1
|
||||
|
||||
async def _send(req: Dict[str, Any], what: str) -> Dict[str, Any]:
|
||||
@@ -219,21 +205,17 @@ async def _cdp_call(
|
||||
if msg.get("id") == call_id:
|
||||
return msg
|
||||
|
||||
session_id: Optional[str] = None
|
||||
req: Dict[str, Any] = {"method": method, "params": params or {}}
|
||||
if target_id:
|
||||
msg = await _send(
|
||||
{"method": "Target.attachToTarget", "params": {"targetId": target_id, "flatten": True}},
|
||||
f"attaching to target {target_id}",
|
||||
)
|
||||
msg = await _send({"method": "Target.attachToTarget", "params": {"targetId": target_id, "flatten": True}},
|
||||
f"attaching to target {target_id}")
|
||||
if "error" in msg:
|
||||
raise RuntimeError(f"Target.attachToTarget failed: {msg['error']}")
|
||||
session_id = msg.get("result", {}).get("sessionId")
|
||||
if not session_id:
|
||||
raise RuntimeError("Target.attachToTarget did not return a sessionId")
|
||||
|
||||
req: Dict[str, Any] = {"method": method, "params": params or {}}
|
||||
if session_id:
|
||||
req["sessionId"] = session_id
|
||||
|
||||
msg = await _send(req, f"waiting for response to {method}")
|
||||
if "error" in msg:
|
||||
raise RuntimeError(f"CDP error: {msg['error']}")
|
||||
@@ -247,26 +229,18 @@ def _browser_cdp_via_supervisor(
|
||||
try:
|
||||
from tools.browser_supervisor import SUPERVISOR_REGISTRY # type: ignore[import-not-found]
|
||||
except Exception as exc: # pragma: no cover — defensive
|
||||
return tool_error(
|
||||
f"CDP supervisor is not available: {exc}. frame_id routing requires "
|
||||
f"a running supervisor attached via /browser connect or an active "
|
||||
f"Browserbase session."
|
||||
)
|
||||
return tool_error(f"CDP supervisor is not available: {exc}. frame_id routing requires a running "
|
||||
"supervisor attached via /browser connect or an active Browserbase session.")
|
||||
|
||||
supervisor = SUPERVISOR_REGISTRY.get(task_id)
|
||||
if supervisor is None:
|
||||
return tool_error(
|
||||
f"No CDP supervisor is attached for task={task_id!r}. Call "
|
||||
f"browser_navigate or /browser connect first so the supervisor "
|
||||
f"can attach. Once attached, browser_snapshot will populate "
|
||||
f"frame_tree with frame_ids you can pass here."
|
||||
)
|
||||
return tool_error(f"No CDP supervisor is attached for task={task_id!r}. Call browser_navigate or "
|
||||
"/browser connect first so the supervisor can attach. Once attached, browser_snapshot "
|
||||
"will populate frame_tree with frame_ids you can pass here.")
|
||||
|
||||
tree = supervisor.snapshot().frame_tree
|
||||
frame_info: Optional[Dict[str, Any]] = next(
|
||||
(f for f in [tree.get("top"), *(tree.get("children") or [])] if f and f.get("frame_id") == frame_id),
|
||||
None,
|
||||
)
|
||||
(f for f in [tree.get("top"), *(tree.get("children") or [])] if f and f.get("frame_id") == frame_id), None)
|
||||
if frame_info is None:
|
||||
# frame_tree is capped at 30 entries — check the raw frames dict too.
|
||||
with supervisor._state_lock: # type: ignore[attr-defined]
|
||||
@@ -274,22 +248,15 @@ def _browser_cdp_via_supervisor(
|
||||
if raw is not None:
|
||||
frame_info = raw.to_dict()
|
||||
if frame_info is None:
|
||||
return tool_error(
|
||||
f"frame_id {frame_id!r} not found in supervisor state. "
|
||||
f"Call browser_snapshot to see current frame_tree."
|
||||
)
|
||||
return tool_error(f"frame_id {frame_id!r} not found in supervisor state. "
|
||||
"Call browser_snapshot to see current frame_tree.")
|
||||
|
||||
child_sid = frame_info.get("session_id")
|
||||
if not child_sid:
|
||||
# Same-origin iframes have no dedicated session; the agent reaches them
|
||||
# via contentWindow/contentDocument from the parent instead.
|
||||
return tool_error(
|
||||
f"frame_id {frame_id!r} is not an out-of-process iframe (no "
|
||||
f"dedicated CDP session). For same-origin iframes, use "
|
||||
f"`browser_cdp(method='Runtime.evaluate', params={{'expression': "
|
||||
f"\"document.querySelector('iframe').contentDocument.title\"}})` "
|
||||
f"at the top-level page instead."
|
||||
)
|
||||
# Same-origin iframes have no dedicated session; reach them via contentWindow/contentDocument.
|
||||
return tool_error(f"frame_id {frame_id!r} is not an out-of-process iframe (no dedicated CDP session). "
|
||||
"For same-origin iframes, use `browser_cdp(method='Runtime.evaluate', params={'expression': "
|
||||
"\"document.querySelector('iframe').contentDocument.title\"})` at the top-level page instead.")
|
||||
|
||||
loop = supervisor._loop # type: ignore[attr-defined]
|
||||
if loop is None or not loop.is_running():
|
||||
@@ -298,24 +265,15 @@ def _browser_cdp_via_supervisor(
|
||||
try:
|
||||
from agent.async_utils import safe_schedule_threadsafe
|
||||
fut = safe_schedule_threadsafe(
|
||||
supervisor._cdp(method, params or {}, session_id=child_sid, timeout=timeout), # type: ignore[attr-defined]
|
||||
loop,
|
||||
)
|
||||
supervisor._cdp(method, params or {}, session_id=child_sid, timeout=timeout), loop) # type: ignore[attr-defined]
|
||||
if fut is None:
|
||||
return tool_error("CDP call via supervisor failed: loop unavailable", cdp_docs=CDP_DOCS_URL)
|
||||
result_msg = fut.result(timeout=timeout + 2)
|
||||
except Exception as exc:
|
||||
return tool_error(
|
||||
f"CDP call via supervisor failed: {type(exc).__name__}: {exc}", cdp_docs=CDP_DOCS_URL,
|
||||
)
|
||||
return tool_error(f"CDP call via supervisor failed: {type(exc).__name__}: {exc}", cdp_docs=CDP_DOCS_URL)
|
||||
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"method": method,
|
||||
"frame_id": frame_id,
|
||||
"session_id": child_sid,
|
||||
"result": result_msg.get("result", {}),
|
||||
}, ensure_ascii=False)
|
||||
return json.dumps({"success": True, "method": method, "frame_id": frame_id, "session_id": child_sid,
|
||||
"result": result_msg.get("result", {})}, ensure_ascii=False)
|
||||
|
||||
|
||||
def browser_cdp(
|
||||
@@ -328,12 +286,11 @@ def browser_cdp(
|
||||
) -> str:
|
||||
"""Send a raw CDP command (see ``CDP_DOCS_URL``).
|
||||
|
||||
``target_id`` attaches a fresh stateless connection to a tab; ``frame_id``
|
||||
(OOPIF from ``browser_snapshot.frame_tree``) routes through the supervisor's
|
||||
live WebSocket instead — the only reliable way to evaluate inside an iframe
|
||||
on backends where fresh per-call connections hit signed-URL expiry
|
||||
(Browserbase). Both paths share the same private-page/SSRF guard. Returns
|
||||
JSON ``{"success": True, "method", "result"}`` or ``{"error": ...}``.
|
||||
``target_id`` attaches a fresh stateless connection to a tab; ``frame_id`` (OOPIF from
|
||||
``browser_snapshot.frame_tree``) routes through the supervisor's live WebSocket instead
|
||||
— the only reliable way to evaluate inside an iframe where fresh per-call connections
|
||||
hit signed-URL expiry (Browserbase). Both paths share the same private-page/SSRF guard.
|
||||
Returns JSON ``{"success": True, "method", "result"}`` or ``{"error": ...}``.
|
||||
"""
|
||||
effective_task_id = task_id or "default"
|
||||
|
||||
@@ -341,33 +298,23 @@ def browser_cdp(
|
||||
blocked = _browser_cdp_private_guard(task_id=effective_task_id, method=method, params=params or {})
|
||||
if blocked:
|
||||
return blocked
|
||||
return _browser_cdp_via_supervisor(
|
||||
task_id=effective_task_id, frame_id=frame_id, method=method, params=params, timeout=timeout,
|
||||
)
|
||||
return _browser_cdp_via_supervisor(task_id=effective_task_id, frame_id=frame_id, method=method,
|
||||
params=params, timeout=timeout)
|
||||
|
||||
if not method or not isinstance(method, str):
|
||||
return tool_error("'method' is required (e.g. 'Target.getTargets')", cdp_docs=CDP_DOCS_URL)
|
||||
if not _WS_AVAILABLE:
|
||||
return tool_error(
|
||||
"The 'websockets' Python package is required but not installed. "
|
||||
"Install it with: pip install websockets"
|
||||
)
|
||||
return tool_error("The 'websockets' Python package is required but not installed. "
|
||||
"Install it with: pip install websockets")
|
||||
endpoint = _resolve_cdp_endpoint()
|
||||
if not endpoint:
|
||||
return tool_error(
|
||||
"No CDP endpoint is available. Run '/browser connect' to attach "
|
||||
"to a running Chrome, Brave, Chromium, or Edge browser, or set "
|
||||
"'browser.cdp_url' in config.yaml. The Camofox backend is REST-only "
|
||||
"and does not expose CDP.",
|
||||
cdp_docs=CDP_DOCS_URL,
|
||||
)
|
||||
return tool_error("No CDP endpoint is available. Run '/browser connect' to attach to a running Chrome, "
|
||||
"Brave, Chromium, or Edge browser, or set 'browser.cdp_url' in config.yaml. The Camofox "
|
||||
"backend is REST-only and does not expose CDP.", cdp_docs=CDP_DOCS_URL)
|
||||
if not endpoint.startswith(("ws://", "wss://")):
|
||||
return tool_error(
|
||||
f"CDP endpoint is not a WebSocket URL: {endpoint!r}. "
|
||||
"Expected ws://... or wss://... — the /browser connect "
|
||||
"resolver should have rewritten this. Check that a Chromium-family "
|
||||
"browser is actually listening on the debug port."
|
||||
)
|
||||
return tool_error(f"CDP endpoint is not a WebSocket URL: {endpoint!r}. Expected ws://... or wss://... — "
|
||||
"the /browser connect resolver should have rewritten this. Check that a Chromium-family "
|
||||
"browser is actually listening on the debug port.")
|
||||
call_params: Dict[str, Any] = params or {}
|
||||
if not isinstance(call_params, dict):
|
||||
return tool_error(f"'params' must be an object/dict, got {type(call_params).__name__}")
|
||||
@@ -389,24 +336,15 @@ def browser_cdp(
|
||||
except (TimeoutError, RuntimeError) as exc:
|
||||
return tool_error(str(exc), method=method)
|
||||
except WebSocketException as exc:
|
||||
return tool_error(
|
||||
f"WebSocket error talking to CDP at {endpoint}: {exc}. The "
|
||||
"browser may have disconnected — try '/browser connect' again.",
|
||||
method=method,
|
||||
)
|
||||
return tool_error(f"WebSocket error talking to CDP at {endpoint}: {exc}. The browser may have "
|
||||
"disconnected — try '/browser connect' again.", method=method)
|
||||
except Exception as exc: # pragma: no cover — unexpected
|
||||
logger.exception("browser_cdp unexpected error")
|
||||
return tool_error(f"Unexpected error: {type(exc).__name__}: {exc}", method=method)
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"success": True,
|
||||
"method": method,
|
||||
"result": _redact_cdp_output(
|
||||
result,
|
||||
always_paths=_CDP_ALWAYS_BINARY_PATHS.get(method, ()),
|
||||
flagged_paths=_CDP_FLAGGED_BINARY_PATHS.get(method, ()),
|
||||
),
|
||||
}
|
||||
payload: Dict[str, Any] = {"success": True, "method": method, "result": _redact_cdp_output(
|
||||
result, always_paths=_CDP_ALWAYS_BINARY_PATHS.get(method, ()),
|
||||
flagged_paths=_CDP_FLAGGED_BINARY_PATHS.get(method, ()))}
|
||||
if target_id:
|
||||
payload["target_id"] = target_id
|
||||
return json.dumps(payload, ensure_ascii=False)
|
||||
@@ -415,95 +353,54 @@ def browser_cdp(
|
||||
BROWSER_CDP_SCHEMA: Dict[str, Any] = {
|
||||
"name": "browser_cdp",
|
||||
"description": (
|
||||
"Send a raw Chrome DevTools Protocol (CDP) command. Escape hatch for "
|
||||
"browser operations not covered by browser_navigate, browser_click, "
|
||||
"browser_console, etc.\n\n"
|
||||
"**Requires a reachable CDP endpoint.** Available when the user has "
|
||||
"run '/browser connect' to attach to a running Chrome, Brave, Chromium, "
|
||||
"or Edge browser, or when 'browser.cdp_url' is set in config.yaml. "
|
||||
"Not currently wired up for cloud backends (Browserbase, Browser Use, "
|
||||
"Firecrawl) — those expose CDP per session but live-session routing is "
|
||||
"a follow-up. Camofox is REST-only and will never support CDP. If the "
|
||||
"tool is in your toolset at all, a CDP endpoint is already reachable.\n\n"
|
||||
f"**CDP method reference:** {CDP_DOCS_URL} — use web_extract on a "
|
||||
"method's URL (e.g. '/tot/Page/#method-handleJavaScriptDialog') "
|
||||
"to look up parameters and return shape.\n\n"
|
||||
"Send a raw Chrome DevTools Protocol (CDP) command. Escape hatch for browser operations not covered "
|
||||
"by browser_navigate, browser_click, browser_console, etc.\n\n"
|
||||
"**Requires a reachable CDP endpoint.** Available when the user has run '/browser connect' to attach "
|
||||
"to a running Chrome, Brave, Chromium, or Edge browser, or when 'browser.cdp_url' is set in "
|
||||
"config.yaml. Not currently wired up for cloud backends (Browserbase, Browser Use, Firecrawl) — "
|
||||
"those expose CDP per session but live-session routing is a follow-up. Camofox is REST-only and "
|
||||
"will never support CDP. If the tool is in your toolset at all, a CDP endpoint is already reachable.\n\n"
|
||||
f"**CDP method reference:** {CDP_DOCS_URL} — use web_extract on a method's URL "
|
||||
"(e.g. '/tot/Page/#method-handleJavaScriptDialog') to look up parameters and return shape.\n\n"
|
||||
"**Common patterns:**\n"
|
||||
"- List tabs: method='Target.getTargets', params={}\n"
|
||||
"- Handle a native JS dialog: method='Page.handleJavaScriptDialog', "
|
||||
"params={'accept': true, 'promptText': ''}, target_id=<tabId>\n"
|
||||
"- Get all cookies: method='Network.getAllCookies', params={}\n"
|
||||
"- Eval in a specific tab: method='Runtime.evaluate', "
|
||||
"params={'expression': '...', 'returnByValue': true}, "
|
||||
"- Eval in a specific tab: method='Runtime.evaluate', params={'expression': '...', 'returnByValue': true}, "
|
||||
"target_id=<tabId>\n"
|
||||
"- Set viewport for a tab: method='Emulation.setDeviceMetricsOverride', "
|
||||
"params={'width': 1280, 'height': 720, 'deviceScaleFactor': 1, "
|
||||
"'mobile': false}, target_id=<tabId>\n\n"
|
||||
"params={'width': 1280, 'height': 720, 'deviceScaleFactor': 1, 'mobile': false}, target_id=<tabId>\n\n"
|
||||
"**Usage rules:**\n"
|
||||
"- Browser-level methods (Target.*, Browser.*, Storage.*): omit "
|
||||
"target_id and frame_id.\n"
|
||||
"- Page-level methods (Page.*, Runtime.*, DOM.*, Emulation.*, "
|
||||
"Network.* scoped to a tab): pass target_id from Target.getTargets.\n"
|
||||
"- **Cross-origin iframe scope** (Runtime.evaluate inside an OOPIF, "
|
||||
"Page.* targeting a frame target, etc.): pass frame_id from the "
|
||||
"browser_snapshot frame_tree output. This routes through the CDP "
|
||||
"supervisor's live connection — the only reliable way on "
|
||||
"Browserbase where stateless CDP calls hit signed-URL expiry.\n"
|
||||
"- Each stateless call (without frame_id) is independent — sessions "
|
||||
"and event subscriptions do not persist between calls. For stateful "
|
||||
"workflows, prefer the dedicated browser tools or use frame_id "
|
||||
"- Browser-level methods (Target.*, Browser.*, Storage.*): omit target_id and frame_id.\n"
|
||||
"- Page-level methods (Page.*, Runtime.*, DOM.*, Emulation.*, Network.* scoped to a tab): pass "
|
||||
"target_id from Target.getTargets.\n"
|
||||
"- **Cross-origin iframe scope** (Runtime.evaluate inside an OOPIF, Page.* targeting a frame target, "
|
||||
"etc.): pass frame_id from the browser_snapshot frame_tree output. This routes through the CDP "
|
||||
"supervisor's live connection — the only reliable way on Browserbase where stateless CDP calls hit "
|
||||
"signed-URL expiry.\n"
|
||||
"- Each stateless call (without frame_id) is independent — sessions and event subscriptions do not "
|
||||
"persist between calls. For stateful workflows, prefer the dedicated browser tools or use frame_id "
|
||||
"routing."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"method": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"CDP method name, e.g. 'Target.getTargets', "
|
||||
"'Runtime.evaluate', 'Page.handleJavaScriptDialog'."
|
||||
),
|
||||
},
|
||||
"params": {
|
||||
"type": "object",
|
||||
"description": (
|
||||
"Method-specific parameters as a JSON object. Omit or "
|
||||
"pass {} for methods that take no parameters."
|
||||
),
|
||||
"properties": {},
|
||||
"additionalProperties": True,
|
||||
},
|
||||
"target_id": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Optional. Target/tab ID from Target.getTargets result "
|
||||
"(each entry's 'targetId'). Use for page-level methods "
|
||||
"at the top-level tab scope. Mutually exclusive with "
|
||||
"frame_id."
|
||||
),
|
||||
},
|
||||
"frame_id": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Optional. Out-of-process iframe (OOPIF) frame_id from "
|
||||
"browser_snapshot.frame_tree.children[] where "
|
||||
"is_oopif=true. When set, routes the call through the "
|
||||
"CDP supervisor's live session for that iframe. "
|
||||
"Essential for Runtime.evaluate inside cross-origin "
|
||||
"iframes, especially on Browserbase where fresh "
|
||||
"per-call CDP connections can't keep up with signed "
|
||||
"URL rotation. For same-origin iframes, use parent "
|
||||
"contentWindow/contentDocument from Runtime.evaluate "
|
||||
"at the top-level page instead."
|
||||
),
|
||||
},
|
||||
"timeout": {
|
||||
"type": "number",
|
||||
"description": (
|
||||
"Timeout in seconds (default 30, max 300)."
|
||||
),
|
||||
"default": 30,
|
||||
},
|
||||
"method": {"type": "string", "description": (
|
||||
"CDP method name, e.g. 'Target.getTargets', 'Runtime.evaluate', 'Page.handleJavaScriptDialog'.")},
|
||||
"params": {"type": "object", "properties": {}, "additionalProperties": True, "description": (
|
||||
"Method-specific parameters as a JSON object. Omit or pass {} for methods that take no parameters.")},
|
||||
"target_id": {"type": "string", "description": (
|
||||
"Optional. Target/tab ID from Target.getTargets result (each entry's 'targetId'). Use for "
|
||||
"page-level methods at the top-level tab scope. Mutually exclusive with frame_id.")},
|
||||
"frame_id": {"type": "string", "description": (
|
||||
"Optional. Out-of-process iframe (OOPIF) frame_id from browser_snapshot.frame_tree.children[] "
|
||||
"where is_oopif=true. When set, routes the call through the CDP supervisor's live session for "
|
||||
"that iframe. Essential for Runtime.evaluate inside cross-origin iframes, especially on "
|
||||
"Browserbase where fresh per-call CDP connections can't keep up with signed URL rotation. For "
|
||||
"same-origin iframes, use parent contentWindow/contentDocument from Runtime.evaluate at the "
|
||||
"top-level page instead.")},
|
||||
"timeout": {"type": "number", "default": 30, "description": "Timeout in seconds (default 30, max 300)."},
|
||||
},
|
||||
"required": ["method"],
|
||||
},
|
||||
@@ -511,14 +408,10 @@ BROWSER_CDP_SCHEMA: Dict[str, Any] = {
|
||||
|
||||
|
||||
def _browser_cdp_check() -> bool:
|
||||
"""Availability check: offered only when a static CDP URL is set.
|
||||
|
||||
Camofox (REST-only), default local agent-browser (hidden CDP port) and cloud
|
||||
providers whose per-session ``cdp_url`` isn't surfaced are gated out. Thin
|
||||
wrapper so ``registry.register`` stays a top-level statement (AST scan).
|
||||
Raw (no-I/O) gate: check_fns run at every startup; resolving the endpoint
|
||||
over HTTP here would block launch on a stale endpoint.
|
||||
"""
|
||||
"""Availability check: offered only when a static CDP URL is set (Camofox is REST-only;
|
||||
the default local agent-browser hides its CDP port; cloud per-session ``cdp_url`` isn't
|
||||
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 ( # type: ignore[import-not-found]
|
||||
_get_cdp_override_raw,
|
||||
@@ -535,18 +428,12 @@ registry.register(
|
||||
toolset="browser-cdp",
|
||||
schema=BROWSER_CDP_SCHEMA,
|
||||
handler=lambda args, **kw: routed_browser_handler(
|
||||
"browser_cdp",
|
||||
args,
|
||||
"browser_cdp", args,
|
||||
fallback=lambda: browser_cdp(
|
||||
method=args.get("method", ""),
|
||||
params=args.get("params"),
|
||||
target_id=args.get("target_id"),
|
||||
frame_id=args.get("frame_id"),
|
||||
timeout=args.get("timeout", 30.0),
|
||||
task_id=kw.get("task_id"),
|
||||
method=args.get("method", ""), params=args.get("params"), target_id=args.get("target_id"),
|
||||
frame_id=args.get("frame_id"), timeout=args.get("timeout", 30.0), task_id=kw.get("task_id"),
|
||||
),
|
||||
task_id=kw.get("task_id"),
|
||||
session_id=kw.get("session_id"),
|
||||
task_id=kw.get("task_id"), session_id=kw.get("session_id"),
|
||||
),
|
||||
check_fn=_browser_cdp_check,
|
||||
emoji="🧪",
|
||||
|
||||
+61
-153
@@ -1,12 +1,9 @@
|
||||
"""Lightpanda local engine for Browser Use mode.
|
||||
|
||||
With ``browser.engine: lightpanda``, Browser Use mode spawns one ``lightpanda
|
||||
serve`` per browser session and points ``browser_exec`` at its CDP endpoint
|
||||
(``BU_CDP_URL``); the built-in ``browser_*`` tools keep going through
|
||||
``agent-browser --engine lightpanda``. ``tools.browser_tool`` owns the session
|
||||
cache, inactivity reaper and atexit sweep, calling :func:`launch_lightpanda`,
|
||||
:func:`stop_lightpanda` and :func:`reap_orphaned_lightpanda` (for processes a
|
||||
crashed Hermes left behind).
|
||||
With ``browser.engine: lightpanda``, Browser Use mode spawns one ``lightpanda serve`` per
|
||||
browser session and points ``browser_exec`` at its CDP endpoint (``BU_CDP_URL``); the
|
||||
built-in ``browser_*`` tools keep going through ``agent-browser --engine lightpanda``.
|
||||
``tools.browser_tool`` owns the session cache, inactivity reaper and atexit sweep.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -24,9 +21,7 @@ from typing import Dict, Optional, Tuple
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
LIGHTPANDA_INSTALL_URL = "https://lightpanda.io/docs/run-locally/installation/one-liner"
|
||||
LIGHTPANDA_INSTALL_HINT = (
|
||||
f"Install Lightpanda from {LIGHTPANDA_INSTALL_URL} and make sure " "`lightpanda` is on PATH"
|
||||
)
|
||||
LIGHTPANDA_INSTALL_HINT = f"Install Lightpanda from {LIGHTPANDA_INSTALL_URL} and make sure " "`lightpanda` is on PATH"
|
||||
|
||||
_READY_TIMEOUT_S = 10.0
|
||||
_POLL_INTERVAL_S = 0.1
|
||||
@@ -55,12 +50,9 @@ class LightpandaServer:
|
||||
|
||||
def _home_candidates() -> list:
|
||||
home = Path.home()
|
||||
candidates = [
|
||||
home / ".lightpanda" / "lightpanda", home / ".local" / "bin" / "lightpanda"
|
||||
]
|
||||
candidates = [home / ".lightpanda" / "lightpanda", home / ".local" / "bin" / "lightpanda"]
|
||||
try:
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
candidates.append(Path(get_hermes_home()) / "bin" / "lightpanda")
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
logger.debug("hermes home unavailable for lightpanda lookup: %s", e)
|
||||
@@ -68,30 +60,19 @@ def _home_candidates() -> list:
|
||||
|
||||
|
||||
def find_lightpanda_binary() -> Optional[str]:
|
||||
"""Return the lightpanda executable, or None.
|
||||
|
||||
Order: PATH (with the same Homebrew/managed-node fallbacks agent-browser
|
||||
gets), then the locations the Lightpanda installer and agent-browser use
|
||||
(``~/.lightpanda/lightpanda``, ``~/.local/bin/lightpanda``), then
|
||||
``$HERMES_HOME/bin/lightpanda``. Lightpanda has no Windows build.
|
||||
"""
|
||||
"""Return the lightpanda executable, or None. Order: PATH (with agent-browser's Homebrew/managed-node
|
||||
fallbacks), then installer/agent-browser locations, then ``$HERMES_HOME/bin``. No Windows build."""
|
||||
if os.name == "nt":
|
||||
logger.debug("Lightpanda has no Windows build")
|
||||
return None
|
||||
path_env = os.environ.get("PATH", "")
|
||||
try:
|
||||
from tools.browser_tool import _merge_browser_path
|
||||
|
||||
path_env = _merge_browser_path(path_env)
|
||||
except Exception as e:
|
||||
logger.debug("browser PATH merge unavailable: %s", e)
|
||||
found = shutil.which("lightpanda", path=path_env)
|
||||
if found:
|
||||
return found
|
||||
for candidate in _home_candidates():
|
||||
if candidate.is_file() and os.access(candidate, os.X_OK):
|
||||
return str(candidate)
|
||||
return None
|
||||
return shutil.which("lightpanda", path=path_env) or next(
|
||||
(str(c) for c in _home_candidates() if c.is_file() and os.access(c, os.X_OK)), None)
|
||||
|
||||
|
||||
def _pick_free_loopback_port() -> int:
|
||||
@@ -103,48 +84,40 @@ def _pick_free_loopback_port() -> int:
|
||||
|
||||
def _state_dir() -> Path:
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
path = Path(get_hermes_home()) / "cache" / "browser-use" / "lightpanda"
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def _record_path(session_name: str) -> Path:
|
||||
return _state_dir() / f"{session_name}.json"
|
||||
|
||||
|
||||
def _browser_env() -> dict:
|
||||
try:
|
||||
from tools.browser_tool import _build_browser_env
|
||||
|
||||
return _build_browser_env()
|
||||
except Exception as e:
|
||||
logger.debug("credential-scrubbed browser env unavailable: %s", e)
|
||||
return os.environ.copy()
|
||||
|
||||
|
||||
def _cdp_ready(url: str) -> bool:
|
||||
def _cdp_ready(url: str, timeout: float = 0.2) -> bool:
|
||||
try:
|
||||
from hermes_cli.browser_connect import is_browser_debug_ready
|
||||
|
||||
return is_browser_debug_ready(url, timeout=0.2)
|
||||
return is_browser_debug_ready(url, timeout=timeout)
|
||||
except Exception as e:
|
||||
logger.debug("CDP readiness probe failed for %s: %s", url, e)
|
||||
return False
|
||||
|
||||
|
||||
def _read_log_tail(path: str) -> str:
|
||||
"""Last non-empty line of the child's stderr log ('' when unreadable)."""
|
||||
try:
|
||||
with open(path, "rb") as fh:
|
||||
data = fh.read()
|
||||
text = Path(path).read_bytes()[-_STDERR_TAIL_LIMIT:].decode("utf-8", errors="replace")
|
||||
except OSError:
|
||||
return ""
|
||||
text = data[-_STDERR_TAIL_LIMIT:].decode("utf-8", errors="replace").strip()
|
||||
lines = [line for line in text.splitlines() if line.strip()]
|
||||
return lines[-1] if lines else ""
|
||||
return next((ln for ln in reversed(text.strip().splitlines()) if ln.strip()), "")
|
||||
|
||||
|
||||
def _terminate(proc: subprocess.Popen) -> None:
|
||||
def _terminate(proc: subprocess.Popen, what: str = "lightpanda") -> None:
|
||||
"""poll -> terminate -> wait(5) -> kill; shared with the real-profile Chrome cleanup."""
|
||||
try:
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
@@ -153,13 +126,12 @@ def _terminate(proc: subprocess.Popen) -> None:
|
||||
except Exception:
|
||||
proc.kill()
|
||||
except Exception as e:
|
||||
logger.debug("lightpanda terminate failed: %s", e)
|
||||
logger.debug("%s terminate failed: %s", what, e)
|
||||
|
||||
|
||||
def _safe_start_time(pid: int) -> Optional[int]:
|
||||
try:
|
||||
from tools.process_registry import ProcessRegistry
|
||||
|
||||
return ProcessRegistry._safe_host_start_time(pid)
|
||||
except Exception:
|
||||
return None
|
||||
@@ -168,84 +140,36 @@ def _safe_start_time(pid: int) -> Optional[int]:
|
||||
def _tree_kill(pid: int, expected_start) -> None:
|
||||
"""Tree-kill ``pid`` via ProcessRegistry, verifying its start time first."""
|
||||
from tools.process_registry import ProcessRegistry
|
||||
|
||||
ProcessRegistry._terminate_host_pid(pid, expected_start=expected_start)
|
||||
|
||||
|
||||
def _kill_server(server: LightpandaServer) -> None:
|
||||
"""Tree-kill a live server, falling back to plain terminate on any failure."""
|
||||
try:
|
||||
_tree_kill(server.proc.pid, server.start_time)
|
||||
except Exception as e:
|
||||
logger.debug("lightpanda tree-kill failed for %s: %s", server.session_name, e)
|
||||
_terminate(server.proc)
|
||||
try:
|
||||
server.proc.wait(timeout=5)
|
||||
except Exception:
|
||||
_terminate(server.proc)
|
||||
|
||||
|
||||
def _write_record(server: LightpandaServer) -> None:
|
||||
record = {
|
||||
"pid": server.proc.pid,
|
||||
"port": server.port,
|
||||
"owner_pid": os.getpid(),
|
||||
"start_time": server.start_time,
|
||||
"started_at": time.time(),
|
||||
}
|
||||
record = {"pid": server.proc.pid, "port": server.port, "owner_pid": os.getpid(),
|
||||
"start_time": server.start_time, "started_at": time.time()}
|
||||
try:
|
||||
_record_path(server.session_name).write_text(json.dumps(record), encoding="utf-8")
|
||||
(_state_dir() / f"{server.session_name}.json").write_text(json.dumps(record), encoding="utf-8")
|
||||
except OSError as e:
|
||||
logger.debug("could not write lightpanda record for %s: %s", server.session_name, e)
|
||||
|
||||
|
||||
def _unlink_record(session_name: str) -> None:
|
||||
try:
|
||||
_record_path(session_name).unlink(missing_ok=True)
|
||||
except OSError as e:
|
||||
logger.debug("could not remove lightpanda record for %s: %s", session_name, e)
|
||||
|
||||
|
||||
def launch_lightpanda(
|
||||
session_name: str, *, block_private_networks: bool = False
|
||||
) -> Tuple[Optional[LightpandaServer], Optional[str]]:
|
||||
"""Start ``lightpanda serve`` on a free loopback port for ``session_name``.
|
||||
|
||||
Returns ``(server, None)`` once ``/json/version`` answers, or
|
||||
``(None, error)`` with an actionable message. The child's stderr goes to
|
||||
``$HERMES_HOME/cache/browser-use/lightpanda/<session>.log`` so a chatty
|
||||
process can never block on a pipe; only the tail is read on failure.
|
||||
"""
|
||||
def launch_lightpanda(session_name: str, *, block_private_networks: bool = False) -> Tuple[Optional[LightpandaServer], Optional[str]]:
|
||||
"""Start ``lightpanda serve`` on a free loopback port; ``(server, None)`` once ``/json/version`` answers,
|
||||
else ``(None, error)``. stderr goes to ``<state_dir>/<session>.log`` so a chatty child never blocks on a pipe."""
|
||||
binary = find_lightpanda_binary()
|
||||
if not binary:
|
||||
if os.name == "nt":
|
||||
return None, (
|
||||
"browser.engine is 'lightpanda' but Lightpanda has no Windows "
|
||||
"build. Set browser.engine to auto (or run Hermes under WSL2)."
|
||||
)
|
||||
return None, (
|
||||
"browser.engine is 'lightpanda' but no lightpanda binary was found "
|
||||
"on PATH, ~/.lightpanda or ~/.local/bin. "
|
||||
f"{LIGHTPANDA_INSTALL_HINT}, or set browser.engine to auto."
|
||||
)
|
||||
return None, ("browser.engine is 'lightpanda' but Lightpanda has no Windows "
|
||||
"build. Set browser.engine to auto (or run Hermes under WSL2).")
|
||||
return None, ("browser.engine is 'lightpanda' but no lightpanda binary was found on PATH, ~/.lightpanda "
|
||||
f"or ~/.local/bin. {LIGHTPANDA_INSTALL_HINT}, or set browser.engine to auto.")
|
||||
|
||||
port = _pick_free_loopback_port()
|
||||
argv = [binary, "serve", "--host", "127.0.0.1", "--port", str(port)]
|
||||
if block_private_networks:
|
||||
argv.append("--block-private-networks")
|
||||
argv = [binary, "serve", "--host", "127.0.0.1", "--port", str(port)] + (["--block-private-networks"] if block_private_networks else [])
|
||||
log_path = str(_state_dir() / f"{session_name}.log")
|
||||
|
||||
# find_lightpanda_binary() returns None on nt, so no Windows spawn branch is needed.
|
||||
try:
|
||||
with open(log_path, "wb") as log_file:
|
||||
proc = subprocess.Popen(
|
||||
argv,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=log_file,
|
||||
env=_browser_env(),
|
||||
start_new_session=True,
|
||||
)
|
||||
proc = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
||||
stderr=log_file, env=_browser_env(), start_new_session=True)
|
||||
except (OSError, subprocess.SubprocessError) as e:
|
||||
return None, f"Failed to launch lightpanda serve ({binary}): {e}"
|
||||
|
||||
@@ -255,36 +179,21 @@ def launch_lightpanda(
|
||||
rc = proc.poll()
|
||||
if rc is not None:
|
||||
tail = _read_log_tail(log_path)
|
||||
detail = f": {tail}" if tail else ""
|
||||
return None, (
|
||||
f"lightpanda serve exited with code {rc} before {url}/json/version "
|
||||
f"answered{detail}"
|
||||
)
|
||||
return None, f"lightpanda serve exited with code {rc} before {url}/json/version answered{f': {tail}' if tail else ''}"
|
||||
if _cdp_ready(url):
|
||||
break
|
||||
if time.monotonic() >= deadline:
|
||||
_terminate(proc)
|
||||
tail = _read_log_tail(log_path)
|
||||
detail = f" (last stderr line: {tail})" if tail else ""
|
||||
return None, (
|
||||
f"lightpanda serve did not expose {url}/json/version within "
|
||||
f"{int(_READY_TIMEOUT_S)}s{detail}"
|
||||
)
|
||||
return None, f"lightpanda serve did not expose {url}/json/version within {int(_READY_TIMEOUT_S)}s{detail}"
|
||||
time.sleep(_POLL_INTERVAL_S)
|
||||
|
||||
server = LightpandaServer(
|
||||
session_name=session_name,
|
||||
port=port,
|
||||
proc=proc,
|
||||
log_path=log_path,
|
||||
start_time=_safe_start_time(proc.pid),
|
||||
)
|
||||
server = LightpandaServer(session_name, port, proc, log_path, _safe_start_time(proc.pid))
|
||||
_write_record(server)
|
||||
with _servers_lock:
|
||||
_servers[session_name] = server
|
||||
logger.info(
|
||||
"Started lightpanda serve (pid %s, port %s) for session %s", proc.pid, port, session_name
|
||||
)
|
||||
logger.info("Started lightpanda serve (pid %s, port %s) for session %s", proc.pid, port, session_name)
|
||||
return server, None
|
||||
|
||||
|
||||
@@ -294,12 +203,23 @@ def get_server(session_name: str) -> Optional[LightpandaServer]:
|
||||
|
||||
|
||||
def stop_lightpanda(session_name: str) -> None:
|
||||
"""Stop the server for ``session_name`` (tree-kill) and drop its record."""
|
||||
"""Stop the server for ``session_name`` (tree-kill, plain terminate on failure) and drop its record."""
|
||||
with _servers_lock:
|
||||
server = _servers.pop(session_name, None)
|
||||
if server is not None and server.is_alive():
|
||||
_kill_server(server)
|
||||
_unlink_record(session_name)
|
||||
try:
|
||||
_tree_kill(server.proc.pid, server.start_time)
|
||||
except Exception as e:
|
||||
logger.debug("lightpanda tree-kill failed for %s: %s", server.session_name, e)
|
||||
_terminate(server.proc)
|
||||
try:
|
||||
server.proc.wait(timeout=5)
|
||||
except Exception:
|
||||
_terminate(server.proc)
|
||||
try:
|
||||
(_state_dir() / f"{session_name}.json").unlink(missing_ok=True)
|
||||
except OSError as e:
|
||||
logger.debug("could not remove lightpanda record for %s: %s", session_name, e)
|
||||
if server is not None:
|
||||
logger.debug("Stopped lightpanda serve for session %s", session_name)
|
||||
|
||||
@@ -319,33 +239,23 @@ def _is_lightpanda_process(pid: int, port, start_time) -> bool:
|
||||
"""True only when ``pid`` is verifiably the ``lightpanda serve`` we recorded."""
|
||||
try:
|
||||
import psutil
|
||||
|
||||
proc = psutil.Process(pid)
|
||||
if "lightpanda" not in proc.name().lower():
|
||||
return False
|
||||
cmdline = proc.cmdline()
|
||||
if "serve" not in cmdline or str(port) not in cmdline:
|
||||
return False
|
||||
if start_time:
|
||||
from gateway.status import get_process_start_time
|
||||
return get_process_start_time(pid) == start_time
|
||||
except Exception:
|
||||
return False
|
||||
if start_time:
|
||||
try:
|
||||
from gateway.status import get_process_start_time
|
||||
|
||||
return get_process_start_time(pid) == start_time
|
||||
except Exception:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def reap_orphaned_lightpanda() -> int:
|
||||
"""Kill ``lightpanda serve`` processes whose owning Hermes is gone.
|
||||
|
||||
Records are written by :func:`launch_lightpanda`; a live owner (another
|
||||
Hermes process, or this one still tracking the session) is never
|
||||
touched, and a PID is only signalled after psutil confirms it is still
|
||||
a ``lightpanda serve`` on the recorded port. Returns the reap count.
|
||||
"""
|
||||
"""Kill ``lightpanda serve`` processes whose owning Hermes is gone; return the count. A live owner is
|
||||
never touched; a PID is only signalled after psutil confirms it is still ``lightpanda serve`` on the recorded port."""
|
||||
try:
|
||||
state_dir = _state_dir()
|
||||
except Exception as e:
|
||||
@@ -372,14 +282,12 @@ def reap_orphaned_lightpanda() -> int:
|
||||
elif owner_pid and _pid_exists(int(owner_pid)):
|
||||
continue
|
||||
pid = record.get("pid")
|
||||
if not pid or not _is_lightpanda_process(int(pid), record.get("port"), record.get("start_time")):
|
||||
record_path.unlink(missing_ok=True)
|
||||
continue
|
||||
try:
|
||||
_tree_kill(int(pid), record.get("start_time"))
|
||||
reaped += 1
|
||||
logger.info("Reaped orphaned lightpanda serve pid %s (session %s)", pid, session_name)
|
||||
except Exception as e:
|
||||
logger.debug("orphan lightpanda kill failed for pid %s: %s", pid, e)
|
||||
if pid and _is_lightpanda_process(int(pid), record.get("port"), record.get("start_time")):
|
||||
try:
|
||||
_tree_kill(int(pid), record.get("start_time"))
|
||||
reaped += 1
|
||||
logger.info("Reaped orphaned lightpanda serve pid %s (session %s)", pid, session_name)
|
||||
except Exception as e:
|
||||
logger.debug("orphan lightpanda kill failed for pid %s: %s", pid, e)
|
||||
record_path.unlink(missing_ok=True)
|
||||
return reaped
|
||||
|
||||
+42
-93
@@ -1,12 +1,9 @@
|
||||
"""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 so
|
||||
``tools.browser_tool.<name>`` keeps resolving (and monkeypatching). Origin
|
||||
symbols and module state are read/written through ``_bt`` (the origin module,
|
||||
resolved per call by :func:`tools.browser_tool_origin.origin_module`) so
|
||||
``patch("tools.browser_tool.X")`` is honoured and no import cycle exists.
|
||||
"""
|
||||
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."""
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
from typing import Tuple
|
||||
|
||||
@@ -16,32 +13,25 @@ from tools.browser_tool_origin import origin_module as _origin
|
||||
def _resolve_cdp_override(cdp_url: str) -> str:
|
||||
"""Normalize a user-supplied CDP endpoint into a concrete websocket URL.
|
||||
|
||||
Full ``ws://.../devtools/browser/...`` endpoints pass through; HTTP
|
||||
discovery roots and bare ``ws://host:port`` are resolved via
|
||||
``/json/version`` → ``webSocketDebuggerUrl`` (falls back to the raw value
|
||||
with a warning if discovery fails).
|
||||
Full ``ws://.../devtools/browser/...`` endpoints pass through; HTTP discovery roots and bare ``ws://host:port``
|
||||
resolve via ``/json/version`` → ``webSocketDebuggerUrl`` (falls back to the raw value with a warning).
|
||||
"""
|
||||
_bt = _origin()
|
||||
raw = (cdp_url or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
|
||||
lowered = raw.lower()
|
||||
if "/devtools/browser/" in lowered:
|
||||
return raw
|
||||
|
||||
discovery_url = raw
|
||||
if lowered.startswith(("ws://", "wss://")):
|
||||
if raw.count(":") == 2 and raw.rstrip("/").rsplit(":", 1)[-1].isdigit() and "/" not in raw.split(":", 2)[-1]:
|
||||
discovery_url = ("http://" if lowered.startswith("ws://") else "https://") + raw.split("://", 1)[1]
|
||||
else:
|
||||
if not (raw.count(":") == 2 and raw.rstrip("/").rsplit(":", 1)[-1].isdigit() and "/" not in raw.split(":", 2)[-1]):
|
||||
return raw
|
||||
discovery_url = ("http://" if lowered.startswith("ws://") else "https://") + raw.split("://", 1)[1]
|
||||
version_url = discovery_url if discovery_url.lower().endswith("/json/version") else discovery_url.rstrip("/") + "/json/version"
|
||||
|
||||
if discovery_url.lower().endswith("/json/version"):
|
||||
version_url = discovery_url
|
||||
else:
|
||||
version_url = discovery_url.rstrip("/") + "/json/version"
|
||||
|
||||
san = _bt._sanitize_url_for_logs
|
||||
try:
|
||||
import requests # lazy — shared module object, test patches still apply
|
||||
|
||||
@@ -49,94 +39,62 @@ def _resolve_cdp_override(cdp_url: str) -> str:
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
except Exception as exc:
|
||||
_bt.logger.warning(
|
||||
"Failed to resolve CDP endpoint %s via %s: %s",
|
||||
_bt._sanitize_url_for_logs(raw),
|
||||
_bt._sanitize_url_for_logs(version_url),
|
||||
_bt._sanitize_url_for_logs(exc),
|
||||
)
|
||||
_bt.logger.warning("Failed to resolve CDP endpoint %s via %s: %s", san(raw), san(version_url), san(exc))
|
||||
return raw
|
||||
|
||||
ws_url = str(payload.get("webSocketDebuggerUrl") or "").strip()
|
||||
if ws_url:
|
||||
_bt.logger.info(
|
||||
"Resolved CDP endpoint %s -> %s",
|
||||
_bt._sanitize_url_for_logs(raw),
|
||||
_bt._sanitize_url_for_logs(ws_url),
|
||||
)
|
||||
_bt.logger.info("Resolved CDP endpoint %s -> %s", san(raw), san(ws_url))
|
||||
return ws_url
|
||||
|
||||
_bt.logger.warning(
|
||||
"CDP discovery at %s did not return webSocketDebuggerUrl; using raw endpoint",
|
||||
_bt._sanitize_url_for_logs(version_url),
|
||||
)
|
||||
_bt.logger.warning("CDP discovery at %s did not return webSocketDebuggerUrl; using raw endpoint", san(version_url))
|
||||
return raw
|
||||
|
||||
|
||||
def _get_cdp_override_raw() -> str:
|
||||
"""Return the *configured* CDP override without any network I/O.
|
||||
|
||||
Precedence: ``BROWSER_CDP_URL`` env (live ``/browser connect`` override),
|
||||
then ``browser.cdp_url`` in config.yaml. Callers that only need to know
|
||||
*whether* an override exists (check_fn gates, ``_is_local_mode`` /
|
||||
``_is_local_backend``, ``hermes doctor``) MUST use this, not
|
||||
:func:`_get_cdp_override`: that one does a 10s HTTP discovery, and a stale
|
||||
``cdp_url`` pointing at a dead Chrome would stall every startup's schema
|
||||
build with no error — no side effects during schema build.
|
||||
Precedence: ``BROWSER_CDP_URL`` env (live ``/browser connect``), then ``browser.cdp_url``. Is-it-configured
|
||||
gates (check_fns, ``_is_local_mode`` / ``_is_local_backend``, ``hermes doctor``) MUST use this, not
|
||||
:func:`_get_cdp_override`: its 10s HTTP discovery against a stale ``cdp_url`` would stall every startup's
|
||||
schema build with no error.
|
||||
"""
|
||||
_bt = _origin()
|
||||
env_override = os.environ.get("BROWSER_CDP_URL", "").strip()
|
||||
if env_override:
|
||||
return env_override
|
||||
return _bt._browser_cfg(
|
||||
"cdp_url", "", lambda v: str(v or "").strip(), "browser.cdp_url from config"
|
||||
)
|
||||
return env_override or _origin()._browser_cfg("cdp_url", "", lambda v: str(v or "").strip(), "browser.cdp_url from config")
|
||||
|
||||
|
||||
def _get_cdp_override() -> str:
|
||||
"""Return the resolved CDP URL override, or "" (skips cloud AND local launch).
|
||||
"""Resolved CDP URL override, or "" (skips cloud AND local launch).
|
||||
|
||||
May perform an HTTP ``/json/version`` discovery request — only call on
|
||||
paths about to *connect* (session creation, supervisor attach); pure
|
||||
is-it-configured gates must use :func:`_get_cdp_override_raw`.
|
||||
May perform HTTP ``/json/version`` discovery — only call on paths about to *connect*; pure gates must use
|
||||
:func:`_get_cdp_override_raw`.
|
||||
"""
|
||||
_bt = _origin()
|
||||
raw = _bt._get_cdp_override_raw()
|
||||
if not raw:
|
||||
return ""
|
||||
return _bt._resolve_cdp_override(raw)
|
||||
return _bt._resolve_cdp_override(raw) if (raw := _bt._get_cdp_override_raw()) else ""
|
||||
|
||||
|
||||
def _get_dialog_policy_config() -> Tuple[str, float]:
|
||||
"""Read ``browser.dialog_policy`` + ``browser.dialog_timeout_s`` from config.
|
||||
|
||||
Returns a ``(policy, timeout_s)`` tuple, falling back to the supervisor's
|
||||
defaults when keys are absent or invalid.
|
||||
"""
|
||||
# Defer imports so browser_tool can be imported in minimal environments.
|
||||
"""Read ``browser.dialog_policy`` + ``browser.dialog_timeout_s``; supervisor defaults when absent/invalid."""
|
||||
_bt = _origin()
|
||||
from tools.browser_supervisor import (
|
||||
DEFAULT_DIALOG_POLICY, DEFAULT_DIALOG_TIMEOUT_S, _VALID_POLICIES
|
||||
)
|
||||
# Deferred so browser_tool imports in minimal environments.
|
||||
from tools.browser_supervisor 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
|
||||
|
||||
cfg = read_raw_config()
|
||||
browser_cfg = cfg.get("browser", {}) if isinstance(cfg, dict) else {}
|
||||
if not isinstance(browser_cfg, dict):
|
||||
return DEFAULT_DIALOG_POLICY, DEFAULT_DIALOG_TIMEOUT_S
|
||||
policy = str(browser_cfg.get("dialog_policy") or DEFAULT_DIALOG_POLICY)
|
||||
if policy not in _VALID_POLICIES:
|
||||
_bt.logger.debug("Invalid browser.dialog_policy=%r; using default", policy)
|
||||
policy = DEFAULT_DIALOG_POLICY
|
||||
return policy, timeout_s
|
||||
candidate = str(browser_cfg.get("dialog_policy") or DEFAULT_DIALOG_POLICY)
|
||||
if candidate in _VALID_POLICIES:
|
||||
policy = candidate
|
||||
else:
|
||||
_bt.logger.debug("Invalid browser.dialog_policy=%r; using default", candidate)
|
||||
timeout_raw = browser_cfg.get("dialog_timeout_s")
|
||||
try:
|
||||
timeout_s = float(timeout_raw) if timeout_raw is not None else DEFAULT_DIALOG_TIMEOUT_S
|
||||
if timeout_s <= 0:
|
||||
timeout_s = DEFAULT_DIALOG_TIMEOUT_S
|
||||
except (TypeError, ValueError):
|
||||
timeout_s = DEFAULT_DIALOG_TIMEOUT_S
|
||||
with contextlib.suppress(TypeError, ValueError):
|
||||
parsed = float(timeout_raw) if timeout_raw is not None else DEFAULT_DIALOG_TIMEOUT_S
|
||||
if parsed > 0:
|
||||
timeout_s = parsed
|
||||
return policy, timeout_s
|
||||
except Exception:
|
||||
return DEFAULT_DIALOG_POLICY, DEFAULT_DIALOG_TIMEOUT_S
|
||||
@@ -145,18 +103,14 @@ def _get_dialog_policy_config() -> Tuple[str, float]:
|
||||
def _ensure_cdp_supervisor(task_id: str) -> None:
|
||||
"""Start a CDP supervisor for ``task_id`` if an endpoint is reachable.
|
||||
|
||||
Idempotent (``SupervisorRegistry.get_or_start`` skips an existing
|
||||
``(task_id, cdp_url)`` and restarts on URL change), so safe on every
|
||||
navigate / ``/browser connect``. URL precedence: the CDP override, then the
|
||||
session's own ``cdp_url`` (cloud providers). Swallows all errors — a failed
|
||||
attach must not break the session; snapshots just lack
|
||||
``pending_dialogs`` / ``frame_tree``.
|
||||
Idempotent (``get_or_start`` skips an existing ``(task_id, cdp_url)`` and restarts on URL change), so safe on
|
||||
every navigate / ``/browser connect``. URL precedence: the CDP override, then the session's own ``cdp_url``
|
||||
(cloud providers, e.g. Browserbase). Swallows all errors — a failed attach must not break the session;
|
||||
snapshots just lack ``pending_dialogs`` / ``frame_tree``.
|
||||
"""
|
||||
_bt = _origin()
|
||||
cdp_url = _bt._get_cdp_override()
|
||||
if not cdp_url:
|
||||
# Fallback: active session may carry a per-session CDP URL from a
|
||||
# cloud provider (Browserbase sets this).
|
||||
with _bt._cleanup_lock:
|
||||
session_info = _bt._active_sessions.get(task_id, {})
|
||||
maybe = str(session_info.get("cdp_url") or "")
|
||||
@@ -168,21 +122,16 @@ def _ensure_cdp_supervisor(task_id: str) -> None:
|
||||
from tools.browser_supervisor import SUPERVISOR_REGISTRY # type: ignore[import-not-found]
|
||||
|
||||
policy, timeout_s = _bt._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
|
||||
)
|
||||
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
|
||||
)
|
||||
_bt.logger.debug("CDP supervisor attach for task=%s failed (non-fatal): %s", task_id, exc)
|
||||
|
||||
|
||||
def _stop_cdp_supervisor(task_id: str) -> None:
|
||||
"""Stop the CDP supervisor for ``task_id`` if one exists. No-op otherwise."""
|
||||
_bt = _origin()
|
||||
try:
|
||||
from tools.browser_supervisor import SUPERVISOR_REGISTRY # type: ignore[import-not-found]
|
||||
|
||||
SUPERVISOR_REGISTRY.stop(task_id)
|
||||
except Exception as exc:
|
||||
_bt.logger.debug("CDP supervisor stop for task=%s failed (non-fatal): %s", task_id, exc)
|
||||
_origin().logger.debug("CDP supervisor stop for task=%s failed (non-fatal): %s", task_id, exc)
|
||||
|
||||
+74
-177
@@ -1,63 +1,52 @@
|
||||
"""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 so
|
||||
``tools.browser_tool.<name>`` keeps resolving (and monkeypatching). Origin
|
||||
symbols and module state are read/written through ``_bt`` (the origin module,
|
||||
resolved per call by :func:`tools.browser_tool_origin.origin_module`) so
|
||||
``patch("tools.browser_tool.X")`` is honoured and no import cycle exists.
|
||||
"""
|
||||
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."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
from typing import Callable, Optional
|
||||
|
||||
from agent.browser_provider import BrowserProvider as CloudBrowserProvider
|
||||
from tools.browser_tool_origin import origin_module as _origin
|
||||
|
||||
|
||||
def _is_legacy_provider_registry_overridden() -> bool:
|
||||
"""True when a test has patched ``_PROVIDER_REGISTRY`` to a custom value.
|
||||
def _memo(_bt, resolved_attr: str, cache_attr: str, compute: Callable[[], object]):
|
||||
"""Process-lifetime cache on ``_bt``: the resolved flag is set BEFORE computing, then the final value is stored."""
|
||||
if not getattr(_bt, resolved_attr):
|
||||
setattr(_bt, resolved_attr, True)
|
||||
setattr(_bt, cache_attr, compute())
|
||||
return getattr(_bt, cache_attr)
|
||||
|
||||
Each registered value is compared by identity against the canonical class
|
||||
in ``_DEFAULT_PROVIDER_REGISTRY`` (extra keys count too); adding a built-in
|
||||
provider only requires extending that default dict.
|
||||
"""
|
||||
|
||||
def _is_legacy_provider_registry_overridden() -> bool:
|
||||
"""True when a test has patched ``_PROVIDER_REGISTRY`` (identity check per key; extra keys count too)."""
|
||||
_bt = _origin()
|
||||
try:
|
||||
for key, default_cls in _bt._DEFAULT_PROVIDER_REGISTRY.items():
|
||||
if _bt._PROVIDER_REGISTRY.get(key) is not default_cls:
|
||||
return True
|
||||
# Extra keys not in the default registry → also an override.
|
||||
return len(_bt._PROVIDER_REGISTRY) != len(_bt._DEFAULT_PROVIDER_REGISTRY)
|
||||
return len(_bt._PROVIDER_REGISTRY) != len(_bt._DEFAULT_PROVIDER_REGISTRY) or any(
|
||||
_bt._PROVIDER_REGISTRY.get(key) is not default_cls for key, default_cls in _bt._DEFAULT_PROVIDER_REGISTRY.items()
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _ensure_browser_plugins_loaded() -> None:
|
||||
"""Idempotently trigger plugin discovery so the browser registry is populated.
|
||||
|
||||
``model_tools`` normally does this as an import side effect, but
|
||||
``_get_cloud_provider`` is also reached from standalone scripts and test
|
||||
harnesses that never import it; cheap on repeat calls.
|
||||
"""
|
||||
_bt = _origin()
|
||||
"""Idempotently trigger plugin discovery (standalone scripts/tests may never import ``model_tools``)."""
|
||||
try:
|
||||
from hermes_cli.plugins import _ensure_plugins_discovered
|
||||
|
||||
_ensure_plugins_discovered()
|
||||
except Exception as exc:
|
||||
_bt.logger.debug("Browser plugin discovery failed (non-fatal): %s", exc)
|
||||
_origin().logger.debug("Browser plugin discovery failed (non-fatal): %s", exc)
|
||||
|
||||
|
||||
def _get_cloud_provider() -> Optional[CloudBrowserProvider]:
|
||||
"""Return the provider cached for the active Hermes profile."""
|
||||
_bt = _origin()
|
||||
|
||||
scope = _bt.hermes_home_key()
|
||||
with _bt._cloud_provider_cache_lock:
|
||||
# Tests and legacy reset paths clear the boolean. Treat that as a full
|
||||
# reset even if a previous scoped resolution remains mirrored here.
|
||||
# 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:
|
||||
_bt._cached_cloud_provider_scope = None
|
||||
_bt._cached_cloud_providers.clear()
|
||||
@@ -75,15 +64,11 @@ def _get_cloud_provider() -> Optional[CloudBrowserProvider]:
|
||||
resolved = _bt._resolve_cloud_provider_uncached()
|
||||
after_generation = _bt._browser_registry_generation(scope=scope)
|
||||
if before_generation != after_generation:
|
||||
# A force reload replaced/unloaded this profile's provider
|
||||
# while resolution was in progress. Discard the stale result
|
||||
# and resolve against the new registry generation.
|
||||
# A force reload changed this profile's registry mid-resolution: discard and resolve again.
|
||||
continue
|
||||
if _bt._cloud_provider_resolved:
|
||||
_bt._cached_cloud_provider_scope = scope
|
||||
for stale_key in [
|
||||
key for key in _bt._cached_cloud_providers if key[0] == scope
|
||||
]:
|
||||
for stale_key in [key for key in _bt._cached_cloud_providers if key[0] == scope]:
|
||||
_bt._cached_cloud_providers.pop(stale_key, None)
|
||||
_bt._cached_cloud_providers[cache_key] = resolved
|
||||
return resolved
|
||||
@@ -92,11 +77,9 @@ def _get_cloud_provider() -> Optional[CloudBrowserProvider]:
|
||||
def _instantiate_explicit_cloud_provider(provider_key: str) -> Optional[CloudBrowserProvider]:
|
||||
"""Build the provider named by ``browser.cloud_provider``.
|
||||
|
||||
Test fixtures that patch ``_PROVIDER_REGISTRY`` drive the legacy dict;
|
||||
otherwise the plugin registry is consulted (after idempotent discovery).
|
||||
Strict selection: a stored-but-unregistered name raises ``ValueError``
|
||||
(never a silent reroute to auto-detect). Any other instantiation error is
|
||||
logged and yields None so the next call retries.
|
||||
A patched ``_PROVIDER_REGISTRY`` (test fixtures) drives the legacy dict; otherwise the plugin registry is
|
||||
consulted. Strict: an unregistered name raises ``ValueError`` (never a silent reroute to auto-detect); any
|
||||
other instantiation error is logged and yields None so the next call retries.
|
||||
"""
|
||||
_bt = _origin()
|
||||
try:
|
||||
@@ -110,32 +93,22 @@ def _instantiate_explicit_cloud_provider(provider_key: str) -> Optional[CloudBro
|
||||
from tools.tool_backend_helpers import selection_error
|
||||
|
||||
raise ValueError(selection_error(
|
||||
"browser",
|
||||
f"'{provider_key}'",
|
||||
"no registered browser plugin has that name (install "
|
||||
"the corresponding plugin or fix the config key "
|
||||
"spelling)",
|
||||
"browser", f"'{provider_key}'",
|
||||
"no registered browser plugin has that name (install the corresponding plugin or fix the config key spelling)",
|
||||
))
|
||||
return resolved
|
||||
except ValueError:
|
||||
raise
|
||||
except Exception:
|
||||
_bt.logger.warning(
|
||||
"Failed to instantiate explicit cloud_provider %r; will retry on next call",
|
||||
provider_key,
|
||||
exc_info=True,
|
||||
)
|
||||
_bt.logger.warning("Failed to instantiate explicit cloud_provider %r; will retry on next call", provider_key, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _autodetect_cloud_provider() -> Optional[CloudBrowserProvider]:
|
||||
"""Auto-detect: Browser Use (managed Nous gateway or API key), then Browserbase.
|
||||
"""Auto-detect: Browser Use, then Browserbase; never raises.
|
||||
|
||||
Uses the legacy class names bound on this module so tests that
|
||||
``monkeypatch.setattr(browser_tool, "BrowserUseProvider", ...)`` keep
|
||||
driving this branch. Third-party plugins are intentionally NOT reachable
|
||||
from auto-detect — only via explicit ``browser.cloud_provider: <name>``.
|
||||
Never raises (a failure must not poison the cache).
|
||||
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>``.
|
||||
"""
|
||||
_bt = _origin()
|
||||
try:
|
||||
@@ -151,15 +124,11 @@ def _autodetect_cloud_provider() -> Optional[CloudBrowserProvider]:
|
||||
def _resolve_cloud_provider_uncached() -> Optional[CloudBrowserProvider]:
|
||||
"""Return the configured cloud browser provider, or None for local mode.
|
||||
|
||||
Reads ``browser.cloud_provider`` and pins the result in the cache only when
|
||||
it is definitive (explicit ``local``/``camofox``, or a resolved provider).
|
||||
Explicit selection routes through :mod:`agent.browser_registry` so
|
||||
third-party plugins participate; auto-detect (only when no selection was
|
||||
ever written) walks Browser Use then Browserbase. A transient None
|
||||
(unreadable config, missing credentials) is NOT cached so it can self-heal.
|
||||
Pins the cache only when definitive (explicit ``local``/``camofox`` or a resolved provider); a transient None
|
||||
(unreadable config, missing credentials) is NOT cached so it can self-heal. Auto-detect runs only when no
|
||||
selection was ever written.
|
||||
"""
|
||||
_bt = _origin()
|
||||
|
||||
resolved: Optional[CloudBrowserProvider] = None
|
||||
provider_key = None
|
||||
try:
|
||||
@@ -182,15 +151,13 @@ def _resolve_cloud_provider_uncached() -> Optional[CloudBrowserProvider]:
|
||||
except ValueError:
|
||||
raise
|
||||
except Exception as e:
|
||||
# Config may be temporarily unreadable; still try auto-detect so
|
||||
# env-based / managed-gateway credentials can resolve. Don't pin cache.
|
||||
# Config may be temporarily unreadable; still try auto-detect (env/managed creds). Don't pin cache.
|
||||
_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()
|
||||
if resolved is None:
|
||||
return None
|
||||
|
||||
_bt._cached_cloud_provider = resolved
|
||||
_bt._cloud_provider_resolved = True
|
||||
return _bt._cached_cloud_provider
|
||||
@@ -199,21 +166,16 @@ 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()
|
||||
if _bt._get_cdp_override_raw():
|
||||
return False
|
||||
return _bt._get_cloud_provider() is None
|
||||
return not _bt._get_cdp_override_raw() and _bt._get_cloud_provider() is None
|
||||
|
||||
|
||||
def _is_local_backend() -> bool:
|
||||
"""Return True when the browser runs locally AND the terminal is also local.
|
||||
"""True when the browser runs locally AND the terminal is also local.
|
||||
|
||||
SSRF protection only matters when the browser can reach networks the user's
|
||||
terminal cannot: cloud backends, and a local browser paired with a
|
||||
containerized terminal (docker/modal/daytona/ssh/singularity). A CDP
|
||||
override is never trusted as local (that Chrome may live off-host) and MUST
|
||||
be checked before the Camofox short-circuit so Camofox + override still
|
||||
fails the local check; ``_is_local_mode`` treats overrides the same way —
|
||||
keep the two in agreement.
|
||||
SSRF protection only matters when the browser can reach networks the terminal cannot (cloud backends,
|
||||
containerized terminals). A CDP override is never trusted as local (that Chrome may live off-host) and MUST
|
||||
be checked before the Camofox short-circuit; ``_is_local_mode`` treats overrides the same way — keep the two
|
||||
in agreement.
|
||||
"""
|
||||
_bt = _origin()
|
||||
if _bt._get_cdp_override_raw():
|
||||
@@ -222,147 +184,82 @@ def _is_local_backend() -> bool:
|
||||
return True
|
||||
if _bt._get_cloud_provider() is not None:
|
||||
return False
|
||||
# Scope-aware: under gateway multiplexing the routed profile's terminal
|
||||
# backend lives in the per-turn terminal scope, not the process env.
|
||||
# Scope-aware: under gateway multiplexing the routed profile's terminal backend lives in the per-turn scope.
|
||||
from tools.terminal_scope import terminal_env
|
||||
|
||||
terminal_backend = terminal_env("TERMINAL_ENV", "local").strip().lower()
|
||||
return terminal_backend in ("local", "")
|
||||
return terminal_env("TERMINAL_ENV", "local").strip().lower() in ("local", "")
|
||||
|
||||
|
||||
def _get_browser_engine() -> str:
|
||||
"""Return the browser engine: ``auto`` (no ``--engine`` flag), ``lightpanda`` or ``chrome``.
|
||||
|
||||
``browser.engine`` first, then ``AGENT_BROWSER_ENGINE``, then ``auto``;
|
||||
cached. Lightpanda is much faster on navigation but has no graphical
|
||||
renderer (no screenshots).
|
||||
``browser.engine`` first, then ``AGENT_BROWSER_ENGINE``, then ``auto``; cached. Lightpanda is faster on
|
||||
navigation but has no graphical renderer (no screenshots).
|
||||
"""
|
||||
_bt = _origin()
|
||||
if _bt._browser_engine_resolved:
|
||||
return _bt._cached_browser_engine
|
||||
|
||||
_bt._browser_engine_resolved = True
|
||||
# Config file takes priority; env var only if config didn't set a value.
|
||||
_bt._cached_browser_engine = _bt._browser_cfg(
|
||||
"engine", "auto",
|
||||
lambda v: str(v).strip().lower() if v and str(v).strip() else "auto",
|
||||
"browser.engine from config",
|
||||
)
|
||||
if _bt._cached_browser_engine == "auto":
|
||||
env_val = os.environ.get("AGENT_BROWSER_ENGINE", "").strip().lower()
|
||||
if env_val:
|
||||
_bt._cached_browser_engine = env_val
|
||||
def compute() -> str:
|
||||
engine = _bt._browser_cfg("engine", "auto", lambda v: str(v).strip().lower() if v and str(v).strip() else "auto", "browser.engine from config")
|
||||
if engine == "auto":
|
||||
engine = os.environ.get("AGENT_BROWSER_ENGINE", "").strip().lower() or engine
|
||||
# agent-browser only accepts "chrome" and "lightpanda".
|
||||
_VALID_ENGINES = {"auto", "lightpanda", "chrome"}
|
||||
if engine not in _VALID_ENGINES:
|
||||
_bt.logger.warning("Unknown browser engine %r (valid: %s), falling back to 'auto'", engine, ", ".join(sorted(_VALID_ENGINES)))
|
||||
engine = "auto"
|
||||
return engine
|
||||
|
||||
# Validate: agent-browser only accepts "chrome" and "lightpanda".
|
||||
_VALID_ENGINES = {"auto", "lightpanda", "chrome"}
|
||||
if _bt._cached_browser_engine not in _VALID_ENGINES:
|
||||
_bt.logger.warning(
|
||||
"Unknown browser engine %r (valid: %s), falling back to 'auto'",
|
||||
_bt._cached_browser_engine, ", ".join(sorted(_VALID_ENGINES)),
|
||||
)
|
||||
_bt._cached_browser_engine = "auto"
|
||||
|
||||
return _bt._cached_browser_engine
|
||||
return _memo(_bt, "_browser_engine_resolved", "_cached_browser_engine", compute)
|
||||
|
||||
|
||||
def _is_headed_mode() -> bool:
|
||||
"""Return True when the browser should launch in headed (visible) mode.
|
||||
|
||||
Reads ``config["browser"]["headed"]`` with ``AGENT_BROWSER_HEADED`` env
|
||||
var as fallback. Result is cached after the first call.
|
||||
"""
|
||||
"""True when the browser should launch headed: ``browser.headed``, else ``AGENT_BROWSER_HEADED``; cached."""
|
||||
_bt = _origin()
|
||||
if _bt._headed_mode_resolved:
|
||||
return _bt._cached_headed_mode # type: ignore[return-value]
|
||||
|
||||
_bt._headed_mode_resolved = True
|
||||
_bt._cached_headed_mode = _bt._browser_cfg(
|
||||
"headed", False,
|
||||
lambda v: False if v is None else str(v).strip().lower() in ("true", "1", "yes"),
|
||||
"browser.headed from config",
|
||||
)
|
||||
if not _bt._cached_headed_mode:
|
||||
env_val = os.environ.get("AGENT_BROWSER_HEADED", "").strip()
|
||||
if env_val and env_val.lower() in ("true", "1", "yes"):
|
||||
_bt._cached_headed_mode = True
|
||||
def compute() -> bool:
|
||||
headed = _bt._browser_cfg("headed", False, lambda v: False if v is None else str(v).strip().lower() in ("true", "1", "yes"), "browser.headed from config")
|
||||
return headed or os.environ.get("AGENT_BROWSER_HEADED", "").strip().lower() in ("true", "1", "yes")
|
||||
|
||||
return _bt._cached_headed_mode
|
||||
return _memo(_bt, "_headed_mode_resolved", "_cached_headed_mode", compute)
|
||||
|
||||
|
||||
def _should_inject_engine(engine: str) -> bool:
|
||||
"""Return True when the engine flag should be added to agent-browser commands.
|
||||
|
||||
Only inject ``--engine`` for non-cloud, non-camofox local sessions where
|
||||
the engine is explicitly set (not ``auto``).
|
||||
"""
|
||||
"""True when ``--engine`` should be added: explicit (non-``auto``) engine on a non-cloud, non-camofox local session."""
|
||||
_bt = _origin()
|
||||
if engine == "auto":
|
||||
return False
|
||||
if _bt._is_camofox_mode():
|
||||
return False
|
||||
return _bt._is_local_mode()
|
||||
return engine != "auto" and not _bt._is_camofox_mode() and _bt._is_local_mode()
|
||||
|
||||
|
||||
def _auto_local_for_private_urls() -> bool:
|
||||
"""``browser.auto_local_for_private_urls`` (default True), cached for the process.
|
||||
|
||||
When on, ``browser_navigate`` routes private/loopback/LAN URLs to a local
|
||||
Chromium sidecar even with a cloud provider configured; public URLs keep
|
||||
using the cloud provider in the same conversation.
|
||||
"""
|
||||
"""``browser.auto_local_for_private_urls`` (default True), cached: route private/LAN URLs to a local sidecar even with a cloud provider."""
|
||||
_bt = _origin()
|
||||
if _bt._auto_local_for_private_urls_resolved:
|
||||
return _bt._cached_auto_local_for_private_urls
|
||||
|
||||
_bt._auto_local_for_private_urls_resolved = True
|
||||
_bt._cached_auto_local_for_private_urls = _bt._browser_cfg(
|
||||
"auto_local_for_private_urls", _bt._cached_auto_local_for_private_urls, bool,
|
||||
"auto_local_for_private_urls from config",
|
||||
return _memo(
|
||||
_bt, "_auto_local_for_private_urls_resolved", "_cached_auto_local_for_private_urls",
|
||||
lambda: _bt._browser_cfg("auto_local_for_private_urls", _bt._cached_auto_local_for_private_urls, bool, "auto_local_for_private_urls from config"),
|
||||
)
|
||||
return _bt._cached_auto_local_for_private_urls
|
||||
|
||||
|
||||
def _use_real_profile() -> bool:
|
||||
"""Return whether the user consented to real-profile local browsing.
|
||||
"""Whether the user consented to real-profile local browsing.
|
||||
|
||||
Reads ``browser.use_real_profile`` (default False) on EVERY call — it is a
|
||||
consent switch, so flipping it off must take effect without a restart, and
|
||||
in a multiplexed gateway each profile's config must decide for itself.
|
||||
The read is one YAML load per local session creation (not per command),
|
||||
so there is no hot-path cost to keeping it uncached.
|
||||
Read on EVERY call: it is a consent switch (flipping it off must not need a restart) and each multiplexed
|
||||
profile must decide for itself. One YAML load per local session creation, so no hot-path cost.
|
||||
"""
|
||||
_bt = _origin()
|
||||
return _bt._browser_cfg("use_real_profile", False, bool, "use_real_profile from config")
|
||||
return _origin()._browser_cfg("use_real_profile", False, bool, "use_real_profile from config")
|
||||
|
||||
|
||||
def _allow_private_urls() -> bool:
|
||||
"""Return whether the browser is allowed to navigate to private/internal addresses.
|
||||
"""Whether the browser may navigate to private/internal addresses (default False: SSRF protection on).
|
||||
|
||||
Reads ``config["browser"]["allow_private_urls"]``. Single-profile calls
|
||||
cache the result for the process lifetime; multiplexed profile turns resolve
|
||||
their context-local config on each call. Defaults to ``False`` (SSRF
|
||||
protection active).
|
||||
Single-profile calls cache for the process lifetime; multiplexed profile turns (ContextVar-scoped config)
|
||||
resolve on every call so one profile's opt-out is never reused by another.
|
||||
"""
|
||||
_bt = _origin()
|
||||
|
||||
# The profile multiplexer scopes config with a ContextVar while sharing
|
||||
# this module. Never reuse another profile's private-network opt-out.
|
||||
if _bt.get_hermes_home_override() is not None:
|
||||
return _bt._resolve_allow_private_urls()
|
||||
|
||||
if _bt._allow_private_urls_resolved:
|
||||
return _bt._cached_allow_private_urls
|
||||
|
||||
_bt._allow_private_urls_resolved = True
|
||||
_bt._cached_allow_private_urls = _bt._resolve_allow_private_urls()
|
||||
return _bt._cached_allow_private_urls
|
||||
return _memo(_bt, "_allow_private_urls_resolved", "_cached_allow_private_urls", _bt._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: _bt.is_truthy_value(v, default=False), "allow_private_urls from config")
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
and the opt-in sensitive-primitive denylist.
|
||||
|
||||
Origin-module symbols are resolved lazily through ``tools.browser_tool`` (``_bt``)
|
||||
so ``patch("tools.browser_tool.X")`` in tests keeps working; this module must not
|
||||
import ``tools.browser_tool`` at import time (cycle).
|
||||
so ``patch("tools.browser_tool.X")`` keeps working; never import it at import time (cycle).
|
||||
"""
|
||||
|
||||
import re
|
||||
@@ -14,66 +13,41 @@ from tools.browser_tool_origin import origin_module as _origin
|
||||
def _eval_ssrf_guard_active(effective_task_id: str) -> bool:
|
||||
"""Return True when eval-driven private-network access must be guarded.
|
||||
|
||||
Matches the gating used by ``browser_navigate`` / ``browser_snapshot`` /
|
||||
``browser_vision``: the SSRF guard is only meaningful for non-local
|
||||
backends (cloud browser, or a containerized terminal whose browser-on-host
|
||||
can reach internal networks the terminal can't), and is skipped for local
|
||||
sidecar sessions and when ``allow_private_urls`` is set.
|
||||
Same gating as ``browser_navigate`` / ``browser_snapshot`` / ``browser_vision``: the SSRF guard
|
||||
only matters for non-local backends (cloud browser, or a containerized terminal whose
|
||||
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 _bt._is_local_backend() and not _bt._is_local_sidecar_key(effective_task_id) and not _bt._allow_private_urls()
|
||||
|
||||
|
||||
# URL-shaped literals embedded in a JS expression (http/https only). Used to
|
||||
# pre-screen ``browser_console(expression=...)`` calls that fetch/XHR/navigate
|
||||
# to a private host directly — that path never updates ``location.href`` so the
|
||||
# post-eval page-URL recheck below can't see it.
|
||||
def _url_blocked(_bt, url: str) -> bool:
|
||||
"""True when ``url`` hits the always-blocked cloud-metadata floor or fails the SSRF guard."""
|
||||
return _bt._is_always_blocked_url(url) or not _bt._is_safe_url(url)
|
||||
|
||||
|
||||
# URL-shaped literals embedded in a JS expression (http/https only). fetch/XHR/navigate
|
||||
# to a private host never updates ``location.href``, so the post-eval page-URL recheck
|
||||
# can't see it; pre-screen the literals instead.
|
||||
_JS_URL_LITERAL_RE = re.compile(r"""https?://[^\s'"`)\]<>]+""", re.IGNORECASE)
|
||||
|
||||
|
||||
def _expression_targets_private_url(expression: str) -> Optional[str]:
|
||||
"""Return the first private/always-blocked URL literal in a JS expression.
|
||||
|
||||
Best-effort: scans for ``http(s)://...`` literals (fetch/XHR/navigation
|
||||
targets the agent may have embedded) and returns the first one that targets
|
||||
a private/internal address or the always-blocked cloud-metadata floor.
|
||||
Returns ``None`` when no such literal is found.
|
||||
"""
|
||||
"""Return the first private/always-blocked ``http(s)://`` literal in a JS expression (best-effort), else None."""
|
||||
_bt = _origin()
|
||||
if not isinstance(expression, str):
|
||||
return None
|
||||
for match in _JS_URL_LITERAL_RE.findall(expression):
|
||||
candidate = match.rstrip(".,;")
|
||||
if _bt._is_always_blocked_url(candidate) or not _bt._is_safe_url(candidate):
|
||||
return candidate
|
||||
return None
|
||||
literals = _JS_URL_LITERAL_RE.findall(expression) if isinstance(expression, str) else []
|
||||
return next((c for c in (m.rstrip(".,;") for m in literals) if _url_blocked(_bt, c)), None)
|
||||
|
||||
|
||||
def _current_page_private_url(effective_task_id: str) -> Optional[str]:
|
||||
"""Return the current page URL when it targets a private/internal address.
|
||||
|
||||
Reads ``window.location.href`` via a low-cost eval and returns it when the
|
||||
page has been navigated (e.g. via ``location.href = '...'`` in a prior
|
||||
eval) to an address the SSRF guard would reject. Returns ``None`` when the
|
||||
page is public, the URL can't be determined, or the check errors (fail-open
|
||||
on probe failure, matching the snapshot/vision guards).
|
||||
"""
|
||||
"""Return the current page URL when it targets a private/internal address (e.g. after a prior
|
||||
``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 = _bt._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 (
|
||||
_bt._is_always_blocked_url(current_url) or not _bt._is_safe_url(current_url)
|
||||
):
|
||||
current_url = url_result.get("data", {}).get("result", "").strip().strip('"').strip("'")
|
||||
if current_url and _url_blocked(_bt, current_url):
|
||||
return current_url
|
||||
except Exception as exc:
|
||||
_bt.logger.debug("_current_page_private_url: probe failed (%s)", exc)
|
||||
@@ -93,78 +67,51 @@ _RISKY_BROWSER_EVAL_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = (
|
||||
)
|
||||
|
||||
|
||||
_JS_STRING_LITERAL_RE = re.compile(
|
||||
r"""'(?:\\.|[^'\\])*'|\"(?:\\.|[^\"\\])*\"|`(?:\\.|[^`\\])*`""", re.S
|
||||
)
|
||||
_JS_STRING_LITERAL_RE = re.compile(r"""'(?:\\.|[^'\\])*'|\"(?:\\.|[^\"\\])*\"|`(?:\\.|[^`\\])*`""", re.S)
|
||||
|
||||
|
||||
_SENSITIVE_BROWSER_EVAL_TOKENS: tuple[tuple[str, str], ...] = (
|
||||
("cookie", "document.cookie"),
|
||||
("localStorage", "web storage"),
|
||||
("sessionStorage", "web storage"),
|
||||
("indexedDB", "IndexedDB"),
|
||||
("caches", "Cache Storage"),
|
||||
("clipboard", "navigator sensitive API"),
|
||||
("credentials", "navigator sensitive API"),
|
||||
("localStorage", "web storage"), ("sessionStorage", "web storage"),
|
||||
("indexedDB", "IndexedDB"), ("caches", "Cache Storage"),
|
||||
("clipboard", "navigator sensitive API"), ("credentials", "navigator sensitive API"),
|
||||
("serviceWorker", "navigator sensitive API"),
|
||||
("fetch", "network request"),
|
||||
("XMLHttpRequest", "network request"),
|
||||
("WebSocket", "network request"),
|
||||
("EventSource", "network request"),
|
||||
("fetch", "network request"), ("XMLHttpRequest", "network request"),
|
||||
("WebSocket", "network request"), ("EventSource", "network request"),
|
||||
("sendBeacon", "network beacon"),
|
||||
)
|
||||
|
||||
|
||||
def _allow_unsafe_browser_evaluate() -> bool:
|
||||
"""Return whether sensitive browser JS evaluation is explicitly allowed.
|
||||
|
||||
When true, ``browser_console(expression=...)`` runs without the
|
||||
sensitive-primitive denylist even if ``browser.restrict_evaluate`` is set.
|
||||
"""
|
||||
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(
|
||||
"allow_unsafe_evaluate", False,
|
||||
lambda v: _bt.is_truthy_value(v, default=False),
|
||||
"browser.allow_unsafe_evaluate from config",
|
||||
)
|
||||
return _bt._browser_cfg(key, False, lambda v: _bt.is_truthy_value(v, default=False), f"browser.{key} from config")
|
||||
|
||||
|
||||
def _allow_unsafe_browser_evaluate() -> bool:
|
||||
"""Whether sensitive browser JS evaluation is explicitly allowed (overrides ``restrict_evaluate``)."""
|
||||
return _browser_eval_flag("allow_unsafe_evaluate")
|
||||
|
||||
|
||||
def _restrict_browser_evaluate() -> bool:
|
||||
"""Return whether the sensitive-primitive eval denylist is enabled.
|
||||
"""Whether the sensitive-primitive eval denylist is enabled (off by default).
|
||||
|
||||
Off by default. ``browser_console(expression=...)`` is the agent's only
|
||||
programmatic page-inspection path, and the denylist blocks the *names* of
|
||||
common primitives (``fetch``, ``cookie``, ``querySelector(...input...)``)
|
||||
rather than any actual exfiltration — which also blocks a large class of
|
||||
legitimate DOM extraction (any selector or page script text containing
|
||||
those words). Egress itself is still gated by the SSRF/private-URL guards
|
||||
in ``_browser_eval`` regardless of this setting. Users who want the
|
||||
strict vocabulary denylist (e.g. when browsing hostile pages with a
|
||||
logged-in profile) opt in with ``browser.restrict_evaluate: true``;
|
||||
``browser.allow_unsafe_evaluate: true`` overrides it back off.
|
||||
It blocks the *names* of common primitives (``fetch``, ``cookie``, ``querySelector(...input...)``),
|
||||
not actual exfiltration, so it also blocks much legitimate DOM extraction; egress is still gated
|
||||
by the SSRF/private-URL guards in ``_browser_eval`` regardless. Opt in via
|
||||
``browser.restrict_evaluate: true`` (e.g. hostile pages with a logged-in profile).
|
||||
"""
|
||||
_bt = _origin()
|
||||
return _bt._browser_cfg(
|
||||
"restrict_evaluate", False,
|
||||
lambda v: _bt.is_truthy_value(v, default=False),
|
||||
"browser.restrict_evaluate from config",
|
||||
)
|
||||
return _browser_eval_flag("restrict_evaluate")
|
||||
|
||||
|
||||
def _decode_js_string_literal(literal: str) -> str:
|
||||
"""Best-effort decode of a JavaScript string literal for policy checks.
|
||||
|
||||
This is not a JS parser. It only normalizes common escaped property names
|
||||
such as ``document["co\\x6fkie"]`` before the fail-closed sensitive-token
|
||||
check below.
|
||||
"""
|
||||
"""Best-effort decode of a JS string literal (not a parser): normalizes escapes like ``"co\\x6fkie"``."""
|
||||
if len(literal) < 2:
|
||||
return literal
|
||||
body = literal[1:-1]
|
||||
try:
|
||||
return bytes(body, "utf-8").decode("unicode_escape")
|
||||
return bytes(literal[1:-1], "utf-8").decode("unicode_escape")
|
||||
except Exception:
|
||||
return body
|
||||
return literal[1:-1]
|
||||
|
||||
|
||||
def _decoded_js_string_literals(expression: str) -> list[str]:
|
||||
@@ -172,87 +119,48 @@ def _decoded_js_string_literals(expression: str) -> list[str]:
|
||||
|
||||
|
||||
def _sensitive_browser_eval_token_reason(expression: str) -> Optional[str]:
|
||||
"""Return a risk reason for direct or quoted sensitive browser primitives.
|
||||
|
||||
``browser_console(expression=...)`` executes in the page origin. A denylist
|
||||
that only searches direct spellings like ``document.cookie`` and ``fetch(``
|
||||
misses equivalent JavaScript property access such as ``document["cookie"]``
|
||||
or ``globalThis["fetch"](...)``. Treat sensitive primitive names as risky
|
||||
whether they appear as identifiers or decoded string-literal property names.
|
||||
Concatenating all string literals catches simple obfuscations like
|
||||
``document["coo" + "kie"]`` while the config opt-in preserves the escape
|
||||
hatch for trusted pages.
|
||||
"""
|
||||
string_literals = _decoded_js_string_literals(expression)
|
||||
concatenated_literals = "".join(string_literals).lower()
|
||||
for token, reason in _SENSITIVE_BROWSER_EVAL_TOKENS:
|
||||
if re.search(rf"\b{re.escape(token)}\b", expression, re.I):
|
||||
return reason
|
||||
token_lower = token.lower()
|
||||
if any(token_lower in literal.lower() for literal in string_literals):
|
||||
return reason
|
||||
if token_lower in concatenated_literals:
|
||||
return reason
|
||||
return None
|
||||
"""Risk reason for direct or quoted sensitive primitives: direct spellings alone miss
|
||||
``document["cookie"]`` / ``globalThis["fetch"]``, so tokens are also matched inside the decoded,
|
||||
concatenated string literals (catches ``document["coo" + "kie"]``)."""
|
||||
literals = "".join(_decoded_js_string_literals(expression)).lower()
|
||||
return next((reason for token, reason in _SENSITIVE_BROWSER_EVAL_TOKENS
|
||||
if re.search(rf"\b{re.escape(token)}\b", expression, re.I) or token.lower() in literals), None)
|
||||
|
||||
|
||||
def _risky_browser_eval_reason(expression: str) -> Optional[str]:
|
||||
"""Return a human-readable reason if a JS expression uses risky primitives."""
|
||||
if not expression:
|
||||
return None
|
||||
for pattern, reason in _RISKY_BROWSER_EVAL_PATTERNS:
|
||||
if pattern.search(expression):
|
||||
return reason
|
||||
return _sensitive_browser_eval_token_reason(expression)
|
||||
hit = next((reason for pattern, reason in _RISKY_BROWSER_EVAL_PATTERNS if pattern.search(expression)), None)
|
||||
return hit or _sensitive_browser_eval_token_reason(expression)
|
||||
|
||||
|
||||
def _enforce_browser_eval_policy(expression: str) -> Optional[str]:
|
||||
"""Block sensitive browser JS evaluation when the opt-in denylist is on.
|
||||
|
||||
The denylist is opt-in (``browser.restrict_evaluate: true``) because it
|
||||
gates on primitive *names*, which cripples legitimate DOM extraction —
|
||||
see ``_restrict_browser_evaluate``. Network egress to private/internal
|
||||
addresses is enforced separately in ``_browser_eval`` and does not depend
|
||||
on this policy.
|
||||
"""
|
||||
"""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():
|
||||
return None
|
||||
if _bt._allow_unsafe_browser_evaluate():
|
||||
if not _bt._restrict_browser_evaluate() or _bt._allow_unsafe_browser_evaluate():
|
||||
return None
|
||||
reason = _risky_browser_eval_reason(expression)
|
||||
if not reason:
|
||||
return None
|
||||
return (
|
||||
"Blocked: browser_console(expression=...) tried to use sensitive browser "
|
||||
f"JavaScript primitive ({reason}) while browser.restrict_evaluate is "
|
||||
"enabled. Use browser_snapshot/browser_get_images/browser_console "
|
||||
"without expression for normal inspection, or set "
|
||||
"browser.restrict_evaluate: false in config.yaml to allow "
|
||||
"programmatic evaluation."
|
||||
)
|
||||
return ("Blocked: browser_console(expression=...) tried to use sensitive browser "
|
||||
f"JavaScript primitive ({reason}) while browser.restrict_evaluate is "
|
||||
"enabled. Use browser_snapshot/browser_get_images/browser_console "
|
||||
"without expression for normal inspection, or set "
|
||||
"browser.restrict_evaluate: false in config.yaml to allow programmatic evaluation.")
|
||||
|
||||
|
||||
def _camofox_current_page_private_url(tab_id: str, user_id: str) -> Optional[str]:
|
||||
"""Return the Camofox page URL when it targets a private/internal address.
|
||||
|
||||
Camofox analogue of ``_current_page_private_url`` (evaluate endpoint instead
|
||||
of the agent-browser CLI). Returns ``None`` when the page is public, the URL
|
||||
can't be determined, or the probe errors (fail-open on probe failure,
|
||||
matching the snapshot/vision guards — do not change to fail-closed without
|
||||
also changing the sibling).
|
||||
"""
|
||||
"""Camofox analogue of ``_current_page_private_url`` (evaluate endpoint instead of the CLI). Fail-open
|
||||
on probe failure, matching the snapshot/vision guards — do not make fail-closed without the sibling."""
|
||||
_bt = _origin()
|
||||
try:
|
||||
from tools.browser_camofox import _post
|
||||
|
||||
data = _post(
|
||||
f"/tabs/{tab_id}/evaluate",
|
||||
body={"expression": "window.location.href", "userId": user_id},
|
||||
)
|
||||
data = _post(f"/tabs/{tab_id}/evaluate", body={"expression": "window.location.href", "userId": user_id})
|
||||
current_url = str(data.get("result") if isinstance(data, dict) else data or "")
|
||||
current_url = current_url.strip().strip('"').strip("'")
|
||||
if current_url and (_bt._is_always_blocked_url(current_url) or not _bt._is_safe_url(current_url)):
|
||||
if current_url and _url_blocked(_bt, current_url):
|
||||
return current_url
|
||||
except Exception as exc:
|
||||
_bt.logger.debug("_camofox_current_page_private_url: probe failed (%s)", exc)
|
||||
|
||||
+100
-263
@@ -1,12 +1,9 @@
|
||||
"""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 so
|
||||
``tools.browser_tool.<name>`` keeps resolving (and monkeypatching). Origin
|
||||
symbols and module state are read/written through ``_bt`` (the origin module,
|
||||
resolved per call by :func:`tools.browser_tool_origin.origin_module`) so
|
||||
``patch("tools.browser_tool.X")`` is honoured and no import cycle exists.
|
||||
"""
|
||||
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."""
|
||||
|
||||
import contextlib
|
||||
import functools
|
||||
import os
|
||||
import shutil
|
||||
@@ -20,63 +17,44 @@ from tools.browser_tool_origin import origin_module as _origin
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def _discover_homebrew_node_dirs() -> tuple[str, ...]:
|
||||
"""Find Homebrew versioned Node.js bin directories (e.g. node@20, node@24).
|
||||
|
||||
When Node is installed via ``brew install node@24`` and NOT linked into
|
||||
/opt/homebrew/bin, agent-browser isn't discoverable on the default PATH.
|
||||
This function finds those directories so they can be prepended.
|
||||
"""
|
||||
dirs: list[str] = []
|
||||
"""Homebrew versioned Node bin dirs (node@20, ...) that ``brew`` may not link into /opt/homebrew/bin."""
|
||||
homebrew_opt = "/opt/homebrew/opt"
|
||||
if not os.path.isdir(homebrew_opt):
|
||||
return tuple(dirs)
|
||||
try:
|
||||
for entry in os.listdir(homebrew_opt):
|
||||
if entry.startswith("node") and entry != "node":
|
||||
bin_dir = os.path.join(homebrew_opt, entry, "bin")
|
||||
if os.path.isdir(bin_dir):
|
||||
dirs.append(bin_dir)
|
||||
entries = os.listdir(homebrew_opt) if os.path.isdir(homebrew_opt) else []
|
||||
except OSError:
|
||||
pass
|
||||
return tuple(dirs)
|
||||
entries = []
|
||||
return tuple(
|
||||
bin_dir
|
||||
for entry in entries
|
||||
if entry.startswith("node") and entry != "node"
|
||||
if os.path.isdir(bin_dir := os.path.join(homebrew_opt, entry, "bin"))
|
||||
)
|
||||
|
||||
|
||||
def _browser_candidate_path_dirs() -> list[str]:
|
||||
"""Return ordered browser CLI PATH candidates shared by discovery and execution."""
|
||||
_bt = _origin()
|
||||
hermes_home = _bt.get_hermes_home()
|
||||
hermes_node_bin = str(hermes_home / "node" / "bin")
|
||||
hermes_node_root = str(hermes_home / "node")
|
||||
hermes_nm_bin = str(hermes_home / "node_modules" / ".bin")
|
||||
return [hermes_node_bin, hermes_node_root, hermes_nm_bin, *list(_bt._discover_homebrew_node_dirs()), *_bt._SANE_PATH_DIRS]
|
||||
home = _bt.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]
|
||||
|
||||
|
||||
def _merge_browser_path(existing_path: str = "") -> str:
|
||||
"""Prepend browser-specific PATH fallbacks without reordering existing entries."""
|
||||
_bt = _origin()
|
||||
path_parts = [p for p in (existing_path or "").split(os.pathsep) if p]
|
||||
existing_parts = set(path_parts)
|
||||
prefix_parts: list[str] = []
|
||||
|
||||
for part in _bt._browser_candidate_path_dirs():
|
||||
if not part or part in existing_parts or part in prefix_parts:
|
||||
continue
|
||||
if os.path.isdir(part):
|
||||
for part in _origin()._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:
|
||||
_bt = _origin()
|
||||
if _bt._is_termux_environment():
|
||||
return "npm install -g agent-browser && agent-browser install"
|
||||
return "npm install -g agent-browser && agent-browser install --with-deps"
|
||||
return "npm install -g agent-browser && agent-browser install" + ("" if _origin()._is_termux_environment() else " --with-deps")
|
||||
|
||||
|
||||
def _is_npx_agent_browser_sentinel(browser_cmd: str) -> bool:
|
||||
_bt = _origin()
|
||||
return browser_cmd.strip() == _bt.NPX_AGENT_BROWSER_SENTINEL
|
||||
return browser_cmd.strip() == _origin().NPX_AGENT_BROWSER_SENTINEL
|
||||
|
||||
|
||||
def _requires_real_termux_browser_install(browser_cmd: str) -> bool:
|
||||
@@ -85,11 +63,7 @@ def _requires_real_termux_browser_install(browser_cmd: str) -> bool:
|
||||
|
||||
|
||||
def _termux_browser_install_error() -> str:
|
||||
_bt = _origin()
|
||||
return (
|
||||
"Local browser automation on Termux cannot rely on the bare npx fallback. "
|
||||
f"Install agent-browser explicitly first: {_bt._browser_install_hint()}"
|
||||
)
|
||||
return f"Local browser automation on Termux cannot rely on the bare npx fallback. Install agent-browser explicitly first: {_origin()._browser_install_hint()}"
|
||||
|
||||
|
||||
def _agent_browser_candidate_present(path: str | None) -> bool:
|
||||
@@ -101,32 +75,25 @@ def _agent_browser_candidate_present(path: str | None) -> bool:
|
||||
|
||||
|
||||
def _resolve_npx_bin() -> Optional[str]:
|
||||
"""Resolve a runnable npx, preferring the Hermes-managed/Homebrew extended PATH.
|
||||
"""Resolve a runnable npx, extended (Hermes-managed/Homebrew) PATH first.
|
||||
|
||||
Bare PATH first would let a broken system npx shadow a healthy managed one
|
||||
with no recovery, so every candidate is validated with ``node_tool_runnable``
|
||||
before being trusted.
|
||||
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("")
|
||||
if extended_path:
|
||||
extended_npx = shutil.which("npx", path=extended_path)
|
||||
if extended_npx and _bt.node_tool_runnable(extended_npx):
|
||||
return extended_npx
|
||||
npx_path = shutil.which("npx")
|
||||
if npx_path and _bt.node_tool_runnable(npx_path):
|
||||
return npx_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):
|
||||
return npx
|
||||
return None
|
||||
|
||||
|
||||
def _agent_browser_candidates(extended_path: str):
|
||||
"""Yield agent-browser lookup candidates in resolution order (lazily — each is a filesystem probe).
|
||||
"""Yield agent-browser lookup candidates lazily: ambient PATH → extended PATH → repo-local node_modules/.bin.
|
||||
|
||||
Order: ambient PATH (global install) → extended PATH (Hermes-managed Node,
|
||||
macOS versioned Homebrew, Termux/system dirs) → repo-local node_modules/.bin.
|
||||
The local lookup goes through ``shutil.which`` with an explicit path so
|
||||
Windows resolves the ``.cmd`` shim (CreateProcess cannot run npm's
|
||||
extensionless POSIX shim — WinError 193) while POSIX keeps the plain one.
|
||||
The local lookup uses ``shutil.which`` with an explicit path so Windows resolves the ``.cmd`` shim
|
||||
(CreateProcess cannot run npm's extensionless POSIX shim — WinError 193).
|
||||
"""
|
||||
yield shutil.which("agent-browser")
|
||||
if extended_path:
|
||||
@@ -137,37 +104,26 @@ def _agent_browser_candidates(extended_path: str):
|
||||
|
||||
|
||||
def _find_agent_browser(*, validate: bool = True) -> str:
|
||||
"""
|
||||
Find the agent-browser CLI executable.
|
||||
"""Find the agent-browser CLI: PATH, Homebrew/managed dirs, local node_modules/.bin, npx fallback, lazy install.
|
||||
|
||||
Checks in order: current PATH, Homebrew/common bin dirs, Hermes-managed
|
||||
node, local node_modules/.bin/, npx fallback, then a lazy install.
|
||||
|
||||
Every candidate is validated with ``agent_browser_runnable`` before it is
|
||||
cached. A bare ``shutil.which`` hit is NOT trusted: agent-browser's npm
|
||||
postinstall re-points a global symlink at our local node_modules binary,
|
||||
which disappears on the next ``hermes update`` and leaves a dangling link
|
||||
that ``which`` still reports but exec fails on (exit 127). Validating lets a
|
||||
dead candidate fall through instead of being cached and killing every
|
||||
browser tool. ``validate=False`` (schema-time check_fn) only tests presence
|
||||
and never caches.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If agent-browser is not installed
|
||||
A bare ``shutil.which`` hit is NOT trusted: agent-browser's npm postinstall re-points a global symlink at our
|
||||
local node_modules binary, which vanishes on the next ``hermes update`` and leaves a dangling link ``which``
|
||||
still reports (exec fails with 127). Candidates are validated with ``agent_browser_runnable`` before caching
|
||||
so a dead one falls through. ``validate=False`` (schema-time check_fn) only tests presence and never caches.
|
||||
Raises FileNotFoundError when agent-browser is not installed.
|
||||
"""
|
||||
_bt = _origin()
|
||||
|
||||
def _not_found(cached: bool) -> FileNotFoundError:
|
||||
return FileNotFoundError(f"agent-browser CLI not found{' (cached)' if cached else ''}. Install it with: {_bt._browser_install_hint()}\nOr ensure npx is available in your PATH.")
|
||||
|
||||
if _bt._agent_browser_resolved:
|
||||
if _bt._cached_agent_browser is None:
|
||||
raise FileNotFoundError(
|
||||
"agent-browser CLI not found (cached). Install it with: "
|
||||
f"{_bt._browser_install_hint()}\n"
|
||||
"Or ensure npx is available in your PATH."
|
||||
)
|
||||
raise _not_found(cached=True)
|
||||
return _bt._cached_agent_browser
|
||||
|
||||
def _accept(candidate: str) -> str:
|
||||
# _agent_browser_resolved is set at each accept site (not before the
|
||||
# search) so a concurrent reader never sees resolved=True with a None cache.
|
||||
# Set resolved at each accept site (not before the search) so a concurrent reader never sees resolved=True with a None cache.
|
||||
if validate:
|
||||
_bt._cached_agent_browser = candidate
|
||||
_bt._agent_browser_resolved = True
|
||||
@@ -178,11 +134,9 @@ def _find_agent_browser(*, validate: bool = True) -> str:
|
||||
for candidate in _bt._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():
|
||||
return _accept(_bt.NPX_AGENT_BROWSER_SENTINEL)
|
||||
|
||||
if not validate:
|
||||
raise FileNotFoundError("agent-browser CLI not found")
|
||||
|
||||
@@ -190,74 +144,40 @@ def _find_agent_browser(*, validate: bool = True) -> str:
|
||||
try:
|
||||
from hermes_cli.dep_ensure import ensure_dependency
|
||||
if ensure_dependency("browser"):
|
||||
candidates = [
|
||||
shutil.which("agent-browser"),
|
||||
shutil.which("agent-browser", path=extended_path) if extended_path else None,
|
||||
shutil.which("agent-browser", path=str(_bt.get_hermes_home() / "node_modules" / ".bin")),
|
||||
shutil.which("agent-browser", path=str(_bt.get_hermes_home() / "node" / "bin")),
|
||||
shutil.which("agent-browser", path=str(_bt.get_hermes_home() / "node")),
|
||||
]
|
||||
for recheck in candidates:
|
||||
home = _bt.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):
|
||||
return _accept(recheck)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_bt._agent_browser_resolved = True
|
||||
raise FileNotFoundError(
|
||||
"agent-browser CLI not found. Install it with: "
|
||||
f"{_bt._browser_install_hint()}\n"
|
||||
"Or ensure npx is available in your PATH."
|
||||
)
|
||||
raise _not_found(cached=False)
|
||||
|
||||
|
||||
def warm_agent_browser_npx_cache(timeout: float = 60.0) -> bool:
|
||||
"""Best-effort pre-fetch of the agent-browser npm package via npx.
|
||||
"""Best-effort pre-fetch of the agent-browser npm package via npx (``hermes update`` / ``doctor --fix``).
|
||||
|
||||
agent-browser resolves lazily via ``npx agent-browser`` (not a root
|
||||
package.json dependency), so the first invocation in a session would pay
|
||||
npx's registry fetch; ``hermes update`` / ``hermes doctor --fix`` call this
|
||||
to warm the cache first. Runs with the credential-scrubbed env every other
|
||||
agent-browser spawn uses (registry-fetched npm code must never see the
|
||||
operator keyring), in its own process group, and tree-kills on timeout so
|
||||
a surviving descendant cannot hold the capture pipe open.
|
||||
Never raises; True only when npx actually exited 0.
|
||||
Runs with the credential-scrubbed env every other agent-browser spawn uses (registry-fetched npm code must
|
||||
never see the operator keyring), in its own process group, and tree-kills on timeout so a surviving
|
||||
descendant cannot hold the capture pipe open. Never raises; True only when npx exited 0.
|
||||
"""
|
||||
_bt = _origin()
|
||||
npx_bin = _bt._resolve_npx_bin()
|
||||
if not npx_bin:
|
||||
return False
|
||||
|
||||
env = _bt._build_browser_env()
|
||||
env["PATH"] = _bt._merge_browser_path(env.get("PATH", ""))
|
||||
|
||||
popen_kwargs: dict = {
|
||||
"stdout": subprocess.PIPE,
|
||||
"stderr": subprocess.PIPE,
|
||||
"text": True,
|
||||
"env": env,
|
||||
"creationflags": _bt.windows_hide_flags(),
|
||||
}
|
||||
popen_kwargs: dict = {"stdout": subprocess.PIPE, "stderr": subprocess.PIPE, "text": True, "env": env}
|
||||
if os.name == "posix":
|
||||
popen_kwargs["start_new_session"] = True
|
||||
popen_kwargs.update(creationflags=_bt.windows_hide_flags(), start_new_session=True)
|
||||
else:
|
||||
popen_kwargs["creationflags"] |= getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
||||
|
||||
cmd = [
|
||||
npx_bin,
|
||||
# --ignore-scripts: AGENT_BROWSER_NPX_SPEC is a floating ^0.26.0
|
||||
# range, not an exact pin — a compromised future 0.26.x patch must
|
||||
# not get to run its own install-time lifecycle scripts here.
|
||||
"--ignore-scripts",
|
||||
# --prefer-offline: once cached, repeat `hermes update`/`doctor
|
||||
# --fix` runs shouldn't hit the registry just to re-confirm
|
||||
# "latest" is still latest — that would defeat the point of
|
||||
# warming the cache in the first place.
|
||||
"--prefer-offline",
|
||||
"-y",
|
||||
_bt.AGENT_BROWSER_NPX_SPEC,
|
||||
"--version",
|
||||
]
|
||||
popen_kwargs["creationflags"] = _bt.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"]
|
||||
try:
|
||||
proc = subprocess.Popen(cmd, stdin=subprocess.DEVNULL, **popen_kwargs)
|
||||
except Exception:
|
||||
@@ -265,144 +185,87 @@ def warm_agent_browser_npx_cache(timeout: float = 60.0) -> bool:
|
||||
try:
|
||||
proc.communicate(timeout=timeout)
|
||||
return proc.returncode == 0
|
||||
except subprocess.TimeoutExpired:
|
||||
_bt._kill_process_tree(proc)
|
||||
try:
|
||||
proc.communicate(timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
_bt._kill_process_tree(proc)
|
||||
if isinstance(exc, subprocess.TimeoutExpired):
|
||||
with contextlib.suppress(Exception):
|
||||
proc.communicate(timeout=5)
|
||||
return False
|
||||
|
||||
|
||||
def _chromium_search_roots() -> List[str]:
|
||||
"""Directories to scan for a Chromium / headless-shell build, in the order
|
||||
agent-browser and Playwright probe them: ``PLAYWRIGHT_BROWSERS_PATH``, then
|
||||
Playwright's per-OS default cache."""
|
||||
roots: List[str] = []
|
||||
"""Chromium / headless-shell scan roots in agent-browser/Playwright probe order: ``PLAYWRIGHT_BROWSERS_PATH``, then the per-OS default cache."""
|
||||
env_path = os.environ.get("PLAYWRIGHT_BROWSERS_PATH", "").strip()
|
||||
if env_path and env_path != "0":
|
||||
roots.append(env_path)
|
||||
home = os.path.expanduser("~")
|
||||
roots: List[str] = [env_path] if env_path and env_path != "0" else []
|
||||
roots.append(os.path.join(home, ".cache", "ms-playwright"))
|
||||
if sys.platform == "darwin":
|
||||
roots.append(os.path.join(home, "Library", "Caches", "ms-playwright"))
|
||||
if sys.platform == "win32":
|
||||
local = os.environ.get("LOCALAPPDATA") or os.path.join(
|
||||
home, "AppData", "Local"
|
||||
)
|
||||
local = os.environ.get("LOCALAPPDATA") or os.path.join(home, "AppData", "Local")
|
||||
roots.append(os.path.join(local, "ms-playwright"))
|
||||
return roots
|
||||
|
||||
|
||||
def _chromium_installed() -> bool:
|
||||
"""Return True when a usable Chromium (or headless-shell) build is on disk.
|
||||
def _has_chromium_build(root: str) -> bool:
|
||||
"""True when ``root`` holds a Playwright ``chromium-*`` / ``chromium_headless_shell-*`` dir (agent-browser accepts either)."""
|
||||
try:
|
||||
return any(e.startswith(("chromium-", "chromium_headless_shell-")) for e in os.listdir(root))
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
Checks ``AGENT_BROWSER_EXECUTABLE_PATH``, then system Chrome/Chromium on
|
||||
PATH, then Playwright's cache (``chromium-*`` / ``chromium_headless_shell-*``
|
||||
dirs). Without a binary the CLI hangs on first use until the command
|
||||
timeout fires, so the tool must not be advertised.
|
||||
|
||||
def _chromium_installed() -> bool:
|
||||
"""True when a usable Chromium (or headless-shell) build is on disk; cached.
|
||||
|
||||
Checks ``AGENT_BROWSER_EXECUTABLE_PATH``, then system Chrome/Chromium on PATH, then Playwright's cache.
|
||||
Without a binary the CLI hangs on first use until the command timeout fires, so the tool must not be advertised.
|
||||
"""
|
||||
_bt = _origin()
|
||||
if _bt._cached_chromium_installed is not None:
|
||||
return _bt._cached_chromium_installed
|
||||
|
||||
# 1. AGENT_BROWSER_EXECUTABLE_PATH — explicit user-configured browser
|
||||
ab_path = os.environ.get("AGENT_BROWSER_EXECUTABLE_PATH", "").strip()
|
||||
if ab_path and (os.path.isfile(ab_path) or shutil.which(ab_path)):
|
||||
_bt._cached_chromium_installed = True
|
||||
return True
|
||||
|
||||
# 2. System Chrome/Chromium in PATH (common names)
|
||||
system_chrome = (
|
||||
shutil.which("google-chrome")
|
||||
or shutil.which("chromium")
|
||||
or shutil.which("chromium-browser")
|
||||
or shutil.which("chrome")
|
||||
_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())
|
||||
)
|
||||
if system_chrome:
|
||||
_bt._cached_chromium_installed = True
|
||||
return True
|
||||
|
||||
# 3. Playwright browser cache (legacy — chromium-* / chromium_headless_shell-* dirs)
|
||||
for root in _bt._chromium_search_roots():
|
||||
if not root or not os.path.isdir(root):
|
||||
continue
|
||||
try:
|
||||
entries = os.listdir(root)
|
||||
except OSError:
|
||||
continue
|
||||
# Playwright names them ``chromium-<build>`` and
|
||||
# ``chromium_headless_shell-<build>``; agent-browser accepts either.
|
||||
for entry in entries:
|
||||
if entry.startswith("chromium-") or entry.startswith(
|
||||
"chromium_headless_shell-"
|
||||
):
|
||||
_bt._cached_chromium_installed = True
|
||||
return True
|
||||
|
||||
_bt._cached_chromium_installed = False
|
||||
return False
|
||||
return _bt._cached_chromium_installed
|
||||
|
||||
|
||||
def _maybe_autoinstall_chromium() -> bool:
|
||||
"""Best-effort, gated download of the Chromium *binary* on local cold start.
|
||||
|
||||
Binary only (``agent-browser install``), never ``--with-deps`` — that shells
|
||||
``apt`` and needs root, so missing system libraries stay a user action.
|
||||
Gated by ``security.allow_lazy_installs``, skipped in Docker (Chromium ships
|
||||
in the image), attempted once per process. True only when Chromium is
|
||||
present afterwards.
|
||||
Binary only (``agent-browser install``), never ``--with-deps`` — that shells ``apt`` and needs root. Gated by
|
||||
``security.allow_lazy_installs``, skipped in Docker (Chromium ships in the image), attempted once per process.
|
||||
"""
|
||||
_bt = _origin()
|
||||
if _bt._chromium_autoinstall_attempted:
|
||||
return _bt._chromium_installed()
|
||||
_bt._chromium_autoinstall_attempted = True
|
||||
|
||||
if _bt._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()
|
||||
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",
|
||||
]
|
||||
else:
|
||||
install_cmd = [browser_cmd, "install"]
|
||||
install_cmd = [_bt._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)"
|
||||
)
|
||||
_bt.logger.info("browser: Chromium missing — auto-installing the browser binary (one-time ~170MB; disable via security.allow_lazy_installs)")
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
install_cmd,
|
||||
capture_output=True,
|
||||
text=True, encoding='utf-8', errors='replace',
|
||||
timeout=600,
|
||||
env=_bt._build_browser_env(),
|
||||
)
|
||||
proc = subprocess.run(install_cmd, capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=600, env=_bt._build_browser_env())
|
||||
except (OSError, subprocess.SubprocessError) as e:
|
||||
_bt.logger.warning("browser: Chromium auto-install failed to start: %s", e)
|
||||
return False
|
||||
|
||||
if proc.returncode != 0:
|
||||
tail = (proc.stderr or proc.stdout or "").strip()[-300:]
|
||||
_bt.logger.warning(
|
||||
"browser: Chromium auto-install exited %s: %s", proc.returncode, tail
|
||||
)
|
||||
_bt.logger.warning("browser: Chromium auto-install exited %s: %s", proc.returncode, tail)
|
||||
return False
|
||||
|
||||
_bt._cached_chromium_installed = None
|
||||
return _bt._chromium_installed()
|
||||
|
||||
@@ -421,67 +284,41 @@ def _running_in_docker() -> bool:
|
||||
def check_browser_requirements() -> bool:
|
||||
"""Whether the browser tools should be advertised.
|
||||
|
||||
Local mode needs the ``agent-browser`` CLI plus a Chromium build (except
|
||||
Lightpanda-only text workflows); cloud mode needs the CLI plus provider
|
||||
credentials (the provider hosts its own Chromium).
|
||||
Local mode needs the ``agent-browser`` CLI plus a Chromium build (except Lightpanda-only text workflows);
|
||||
cloud mode needs the CLI plus provider credentials (the provider hosts its own Chromium).
|
||||
"""
|
||||
# Browser Use CLI backend — browser_exec replaces the whole browser_*
|
||||
# surface (including browser_cdp/browser_dialog, whose check_fns funnel
|
||||
# through here), so hide these tools from the model.
|
||||
_bt = _origin()
|
||||
# Browser Use CLI backend: browser_exec replaces the whole browser_* surface (incl. browser_cdp/browser_dialog check_fns).
|
||||
if _bt._is_browser_use_cli_mode():
|
||||
return False
|
||||
|
||||
# Camofox backend — only needs the server URL, no agent-browser CLI
|
||||
# Camofox only needs the server URL, no agent-browser CLI.
|
||||
if _bt._is_camofox_mode():
|
||||
return True
|
||||
|
||||
# CDP override mode can connect to an existing remote/local browser endpoint
|
||||
# without requiring the local agent-browser binary on PATH.
|
||||
# Raw (no-I/O) check: this runs during tool-schema assembly at startup,
|
||||
# where a stale endpoint must not cost a blocking HTTP probe.
|
||||
# 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():
|
||||
return True
|
||||
|
||||
# The agent-browser CLI is required for local launch and cloud-provider flows.
|
||||
# Tool-schema assembly runs during Desktop startup; do not execute
|
||||
# ``agent-browser --version`` here, because Windows .cmd shims route through
|
||||
# cmd.exe and can flash a console before the user invokes any browser tool.
|
||||
# Actual browser execution paths still validate the candidate before use.
|
||||
# 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)
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
# On Termux, the bare npx fallback is too fragile to treat as a satisfied
|
||||
# local browser dependency. Require a real install (global or local) so the
|
||||
# browser tool is not advertised as available when it will likely fail on
|
||||
# first use.
|
||||
# Termux: the bare npx fallback is too fragile to advertise as a satisfied local dependency.
|
||||
if _bt._requires_real_termux_browser_install(browser_cmd):
|
||||
return False
|
||||
|
||||
# In cloud mode, also require provider credentials. Cloud browsers
|
||||
# don't need a local Chromium binary.
|
||||
# Cloud mode also requires provider credentials; no local Chromium needed.
|
||||
provider = _bt._get_cloud_provider()
|
||||
if provider is not None:
|
||||
return provider.is_configured()
|
||||
|
||||
# Local mode with Lightpanda can provide text/navigation tools without a
|
||||
# local Chromium install. Chrome fallback, screenshots, and browser_vision
|
||||
# will still return actionable Chromium install errors if invoked.
|
||||
# Lightpanda provides text/navigation tools without Chromium; screenshots/vision still return install errors.
|
||||
if _bt._using_lightpanda_engine():
|
||||
return True
|
||||
|
||||
# Local Chrome mode: agent-browser needs a Chromium build on disk. Without
|
||||
# it the CLI hangs on first use until the command timeout fires.
|
||||
# Local Chrome mode needs Chromium on disk or the CLI hangs until the command timeout.
|
||||
return _bt._chromium_installed()
|
||||
|
||||
|
||||
def check_browser_vision_requirements() -> bool:
|
||||
"""Advertise ``browser_vision`` only with BOTH a working browser AND a vision
|
||||
backend — otherwise it fails at call time with a cryptic provider error."""
|
||||
_bt = _origin()
|
||||
if not _bt.check_browser_requirements():
|
||||
"""Advertise ``browser_vision`` only with BOTH a working browser AND a vision backend."""
|
||||
if not _origin().check_browser_requirements():
|
||||
return False
|
||||
try:
|
||||
from tools.vision_tools import check_vision_requirements
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
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 ``tools.browser_tool``
|
||||
at import time (cycle).
|
||||
so ``patch("tools.browser_tool.X")`` keeps working; never import it at import time (cycle).
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -13,21 +12,23 @@ import subprocess
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from tools.browser_tool_origin import origin_module as _origin
|
||||
|
||||
# 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.
|
||||
_FALLBACK_ELIGIBLE = frozenset({"open", "snapshot", "screenshot", "eval", "click",
|
||||
"fill", "scroll", "back", "press", "console", "errors"})
|
||||
|
||||
|
||||
def _using_lightpanda_engine() -> bool:
|
||||
"""Return True when local browser commands are configured for Lightpanda."""
|
||||
_bt = _origin()
|
||||
return _bt._get_browser_engine() == "lightpanda"
|
||||
return _origin()._get_browser_engine() == "lightpanda"
|
||||
|
||||
|
||||
def lightpanda_engine_status() -> Tuple[bool, str]:
|
||||
"""Whether ``browser.engine: lightpanda`` is actually in effect, and why.
|
||||
|
||||
``(False, "")`` when the engine isn't lightpanda; otherwise the reason names
|
||||
the setting shadowing it or the driver running it. Mirrors the precedence
|
||||
of ``_should_inject_engine`` / ``browser_use_cli._resolve_backend_cdp``
|
||||
with config-only gates (no network I/O) so ``/browser status`` and
|
||||
``hermes doctor`` can call it.
|
||||
``(False, "")`` when the engine isn't lightpanda; else the reason names the setting shadowing
|
||||
it or the driver running it. Mirrors ``_should_inject_engine`` / ``_resolve_backend_cdp``
|
||||
precedence with config-only gates (no network I/O) for ``/browser status`` / ``hermes doctor``.
|
||||
"""
|
||||
_bt = _origin()
|
||||
if not _bt._using_lightpanda_engine():
|
||||
@@ -36,9 +37,8 @@ def lightpanda_engine_status() -> Tuple[bool, str]:
|
||||
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 is checked before the cloud provider: in browser_exec the
|
||||
# real-profile resolution runs before backend resolution, so with both
|
||||
# set it is the real-profile toggle that actually claims the session.
|
||||
# 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():
|
||||
return False, "browser.use_real_profile is on (Lightpanda cannot load a Chromium profile)"
|
||||
try:
|
||||
@@ -50,75 +50,37 @@ def lightpanda_engine_status() -> Tuple[bool, str]:
|
||||
name = provider.provider_name()
|
||||
except Exception:
|
||||
name = type(provider).__name__
|
||||
return False, (
|
||||
f"cloud provider {name} is selected (browser.cloud_provider, or "
|
||||
"auto-detected from credentials)"
|
||||
)
|
||||
bu_mode = _bt._is_browser_use_cli_mode()
|
||||
if bu_mode:
|
||||
try:
|
||||
from tools.browser_use_cli import (
|
||||
_read_browser_cfg, is_legacy_browser_use_cloud_config
|
||||
)
|
||||
|
||||
if is_legacy_browser_use_cloud_config(_read_browser_cfg()):
|
||||
return False, "Browser Use cloud (BROWSER_USE_API_KEY) is selected"
|
||||
except Exception as e:
|
||||
_bt.logger.debug("legacy Browser Use cloud check failed: %s", e)
|
||||
if bu_mode:
|
||||
return True, "Browser Use mode: Hermes spawns `lightpanda serve` per session"
|
||||
return True, "built-in browser tools: agent-browser --engine lightpanda"
|
||||
return False, f"cloud provider {name} is selected (browser.cloud_provider, or auto-detected from credentials)"
|
||||
if not _bt._is_browser_use_cli_mode():
|
||||
return True, "built-in browser tools: agent-browser --engine lightpanda"
|
||||
try:
|
||||
from tools.browser_use_cli import _read_browser_cfg, is_legacy_browser_use_cloud_config
|
||||
if is_legacy_browser_use_cloud_config(_read_browser_cfg()):
|
||||
return False, "Browser Use cloud (BROWSER_USE_API_KEY) is selected"
|
||||
except Exception as e:
|
||||
_bt.logger.debug("legacy Browser Use cloud check failed: %s", e)
|
||||
return True, "Browser Use mode: Hermes spawns `lightpanda serve` per session"
|
||||
|
||||
|
||||
def _lightpanda_fallback_reason(engine: str, command: str, result: Dict[str, Any]) -> Optional[str]:
|
||||
"""User-visible reason a Lightpanda result needs the Chrome fallback, or None.
|
||||
|
||||
The string is copied into the fallback result so users can see when Hermes
|
||||
silently switched engines.
|
||||
"""
|
||||
_bt = _origin()
|
||||
if engine != "lightpanda":
|
||||
"""User-visible reason a Lightpanda result needs the Chrome fallback (copied into the result), or None."""
|
||||
if engine != "lightpanda" or command not in _FALLBACK_ELIGIBLE:
|
||||
return None
|
||||
|
||||
# Only retry 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 on a different engine.
|
||||
_FALLBACK_ELIGIBLE = {"open", "snapshot", "screenshot", "eval", "click",
|
||||
"fill", "scroll", "back", "press", "console", "errors"}
|
||||
if command not in _FALLBACK_ELIGIBLE:
|
||||
return None
|
||||
|
||||
# Explicit failure
|
||||
if not result.get("success"):
|
||||
error = str(result.get("error") or "command failed").strip()
|
||||
return f"Lightpanda {command!r} failed ({error}); retried with Chrome."
|
||||
|
||||
return f"Lightpanda {command!r} failed ({str(result.get('error') or 'command failed').strip()}); retried with Chrome."
|
||||
data = result.get("data", {})
|
||||
|
||||
if command == "snapshot":
|
||||
snap = data.get("snapshot", "")
|
||||
# Empty or near-empty snapshots indicate Lightpanda couldn't render
|
||||
if not snap or len(snap.strip()) < 20:
|
||||
return "Lightpanda returned an empty/too-short snapshot; retried with Chrome."
|
||||
|
||||
if command == "screenshot":
|
||||
# Lightpanda returns a placeholder PNG with its panda logo.
|
||||
# Since Lightpanda resized it to 1920x1080, the placeholder is
|
||||
# ~17 KB. Real Chromium screenshots are typically 100 KB+.
|
||||
path = data.get("path", "")
|
||||
if path:
|
||||
try:
|
||||
size = os.path.getsize(path)
|
||||
if size < 20480:
|
||||
_bt.logger.debug("Lightpanda screenshot is suspiciously small (%d bytes), "
|
||||
"triggering Chrome fallback", size)
|
||||
return (
|
||||
f"Lightpanda screenshot was suspiciously small ({size} bytes); "
|
||||
"retried with Chrome."
|
||||
)
|
||||
except OSError:
|
||||
return "Lightpanda screenshot file was missing/unreadable; retried with Chrome."
|
||||
|
||||
if command == "snapshot" and len((data.get("snapshot", "") or "").strip()) < 20: # couldn't render
|
||||
return "Lightpanda returned an empty/too-short snapshot; retried with Chrome."
|
||||
if command == "screenshot" and data.get("path", ""):
|
||||
# Lightpanda returns a ~17 KB placeholder PNG (panda logo at 1920x1080); real Chromium is 100 KB+.
|
||||
try:
|
||||
size = os.path.getsize(data["path"])
|
||||
except OSError:
|
||||
return "Lightpanda screenshot file was missing/unreadable; retried with Chrome."
|
||||
if size < 20480:
|
||||
_origin().logger.debug("Lightpanda screenshot is suspiciously small (%d bytes), "
|
||||
"triggering Chrome fallback", size)
|
||||
return f"Lightpanda screenshot was suspiciously small ({size} bytes); " "retried with Chrome."
|
||||
return None
|
||||
|
||||
|
||||
@@ -129,24 +91,15 @@ def _needs_lightpanda_fallback(engine: str, command: str, result: Dict[str, Any]
|
||||
|
||||
def _annotate_lightpanda_fallback(result: Dict[str, Any], reason: str) -> Dict[str, Any]:
|
||||
"""Add a user-visible Chrome fallback warning to a browser command result."""
|
||||
warning = (
|
||||
"⚠ Lightpanda fallback: Chrome was used for this browser action. " f"{reason}"
|
||||
)
|
||||
annotated = dict(result)
|
||||
annotated["fallback_warning"] = warning
|
||||
annotated["browser_engine"] = "chrome"
|
||||
annotated["browser_engine_fallback"] = {
|
||||
"from": "lightpanda", "to": "chrome", "reason": reason
|
||||
}
|
||||
warning = "⚠ Lightpanda fallback: Chrome was used for this browser action. " f"{reason}"
|
||||
fields = {"fallback_warning": warning, "browser_engine": "chrome",
|
||||
"browser_engine_fallback": {"from": "lightpanda", "to": "chrome", "reason": reason}}
|
||||
annotated = {**result, **fields}
|
||||
data = annotated.get("data")
|
||||
if isinstance(data, dict):
|
||||
data = dict(data)
|
||||
data.setdefault("fallback_warning", warning)
|
||||
data.setdefault("browser_engine", "chrome")
|
||||
data.setdefault(
|
||||
"browser_engine_fallback", {"from": "lightpanda", "to": "chrome", "reason": reason}
|
||||
)
|
||||
annotated["data"] = data
|
||||
annotated["data"] = data = dict(data)
|
||||
for key, value in fields.items():
|
||||
data.setdefault(key, dict(value) if isinstance(value, dict) else value)
|
||||
return annotated
|
||||
|
||||
|
||||
@@ -159,33 +112,23 @@ def _copy_fallback_warning(target: Dict[str, Any], result: Dict[str, Any]) -> Di
|
||||
return target
|
||||
|
||||
|
||||
def _run_chrome_fallback_command(
|
||||
task_id: str, command: str, args: List[str], timeout: int
|
||||
) -> Dict[str, Any]:
|
||||
def _run_chrome_fallback_command(task_id: str, command: str, args: List[str], timeout: int) -> Dict[str, Any]:
|
||||
"""Run a browser command in a temporary Chrome session at the current URL.
|
||||
|
||||
agent-browser locks the engine when a named daemon starts, so ``--engine
|
||||
chrome`` on the Lightpanda session is ignored: use a fresh temp Chrome
|
||||
session, navigate it to the current URL, run ``command``, tear it down.
|
||||
agent-browser locks the engine when a named daemon starts, so ``--engine chrome`` on the
|
||||
Lightpanda session is ignored: fresh temp Chrome session -> same URL -> ``command`` -> tear down.
|
||||
"""
|
||||
_bt = _origin()
|
||||
import uuid
|
||||
|
||||
# 1. Grab the current URL from the Lightpanda session. ``get url`` is not
|
||||
# fallback-eligible, so an error cannot recursively trigger this helper.
|
||||
# Keep the explicit Lightpanda override so Chromium-only environment flags
|
||||
# are stripped while querying the already-running Lightpanda daemon.
|
||||
url_result = _bt._run_browser_command(
|
||||
task_id, "get", ["url"], timeout=10, _engine_override="lightpanda"
|
||||
)
|
||||
current_url = None
|
||||
if url_result.get("success"):
|
||||
current_url = str(url_result.get("data", {}).get("url", "")).strip()
|
||||
# 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")
|
||||
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")
|
||||
return {"success": False, "error": "Chrome fallback failed: could not determine current URL"}
|
||||
|
||||
# 2. Create a temporary Chrome session (bypasses _get_session_info's cache).
|
||||
# 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()
|
||||
@@ -194,17 +137,11 @@ def _run_chrome_fallback_command(
|
||||
|
||||
if not _bt._chromium_installed():
|
||||
if _bt._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"
|
||||
)
|
||||
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:
|
||||
hint = (
|
||||
"Chrome fallback requires Chromium, but it is missing. Install it with: "
|
||||
"npx agent-browser install --with-deps "
|
||||
"(or: npx playwright install --with-deps chromium)"
|
||||
)
|
||||
hint = ("Chrome fallback requires Chromium, but it is missing. Install it with: "
|
||||
"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"]
|
||||
@@ -224,7 +161,7 @@ def _run_chrome_fallback_command(
|
||||
proc.wait()
|
||||
return {"success": False, "error": f"Chrome fallback '{cmd}' timed out"}
|
||||
try:
|
||||
with open(stdout_path, "r", encoding="utf-8") as f:
|
||||
with open(stdout_path, encoding="utf-8") as f:
|
||||
stdout = f.read().strip()
|
||||
if stdout:
|
||||
return json.loads(stdout.split("\n")[-1])
|
||||
@@ -250,9 +187,6 @@ def _run_chrome_fallback_command(
|
||||
shutil.rmtree(task_socket_dir, ignore_errors=True)
|
||||
|
||||
|
||||
def _chrome_fallback_screenshot(
|
||||
task_id: str, args: List[str], timeout: int
|
||||
) -> Dict[str, Any]:
|
||||
def _chrome_fallback_screenshot(task_id: str, args: List[str], timeout: int) -> Dict[str, Any]:
|
||||
"""Take a screenshot using a temporary Chrome session."""
|
||||
_bt = _origin()
|
||||
return _bt._run_chrome_fallback_command(task_id, "screenshot", args, timeout)
|
||||
return _origin()._run_chrome_fallback_command(task_id, "screenshot", args, timeout)
|
||||
|
||||
+101
-238
@@ -2,9 +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-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).
|
||||
``_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).
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -15,328 +14,204 @@ import time
|
||||
from typing import Optional, Tuple
|
||||
from tools.browser_tool_origin import origin_module as _origin
|
||||
|
||||
_RP = "browser.use_real_profile is on, but "
|
||||
|
||||
|
||||
def _terminate_real_profile_chrome() -> None:
|
||||
"""Terminate real-browser processes launched for real-profile sessions (idempotent, atexit-safe).
|
||||
|
||||
agent-browser only ATTACHED to them, so its own session cleanup never kills them.
|
||||
"""
|
||||
"""Terminate real-browser processes launched for real-profile sessions (idempotent, atexit-safe);
|
||||
agent-browser only ATTACHED to them, so its own session cleanup never kills them."""
|
||||
from tools.browser_lightpanda import _terminate
|
||||
_bt = _origin()
|
||||
while _bt._real_profile_chrome_procs:
|
||||
proc = _bt._real_profile_chrome_procs.pop()
|
||||
try:
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except Exception:
|
||||
proc.kill()
|
||||
except Exception as e:
|
||||
_bt.logger.debug("real-profile chrome terminate failed: %s", e)
|
||||
_terminate(_bt._real_profile_chrome_procs.pop(), what="real-profile chrome")
|
||||
|
||||
|
||||
def _cdp_http_ready(http_cdp: str) -> bool:
|
||||
"""True when an ``http://host:port`` CDP discovery root answers."""
|
||||
try:
|
||||
from hermes_cli.browser_connect import is_browser_debug_ready
|
||||
|
||||
return is_browser_debug_ready(http_cdp, timeout=1.0)
|
||||
except Exception:
|
||||
return False
|
||||
from tools.browser_lightpanda import _cdp_ready
|
||||
return _cdp_ready(http_cdp, timeout=1.0)
|
||||
|
||||
|
||||
def _agent_browser_get_cdp(session_name: str) -> Optional[str]:
|
||||
"""HTTP CDP discovery root of an agent-browser session (converted from its
|
||||
``ws://`` cdp-url), or None when it isn't running / unparsable."""
|
||||
def _agent_browser_session_cmd(session_name: str, *cmd: str, log_label: str) -> Optional[subprocess.CompletedProcess]:
|
||||
"""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()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[*_bt._agent_browser_argv(browser_cmd), "--session", session_name, "get", "cdp-url"],
|
||||
capture_output=True, text=True, timeout=15, env=_bt._build_browser_env(),
|
||||
)
|
||||
return subprocess.run([*_bt._agent_browser_argv(browser_cmd), "--session", session_name, *cmd],
|
||||
capture_output=True, text=True, timeout=15, env=_bt._build_browser_env())
|
||||
except (subprocess.SubprocessError, OSError) as e:
|
||||
_bt.logger.debug("real-profile get cdp-url failed: %s", e)
|
||||
_bt.logger.debug("real-profile %s failed: %s", log_label, e)
|
||||
return None
|
||||
out = (proc.stdout or "").strip()
|
||||
m = re.search(r"ws://127\.0\.0\.1:(\d+)/", out)
|
||||
if not m:
|
||||
|
||||
|
||||
def _agent_browser_get_cdp(session_name: str) -> Optional[str]:
|
||||
"""HTTP CDP discovery root of an agent-browser session (from its ``ws://`` cdp-url), or None."""
|
||||
proc = _agent_browser_session_cmd(session_name, "get", "cdp-url", log_label="get cdp-url")
|
||||
m = re.search(r"ws://127\.0\.0\.1:(\d+)/", (proc.stdout or "").strip()) if proc is not None else None
|
||||
return f"http://127.0.0.1:{m.group(1)}" if m else None
|
||||
|
||||
|
||||
def _read_devtools_port(data_dir: str) -> Optional[str]:
|
||||
"""First line of Chrome's ``DevToolsActivePort`` in ``data_dir`` (None when unreadable)."""
|
||||
try:
|
||||
with open(os.path.join(data_dir, "DevToolsActivePort"), encoding="utf-8") as fh:
|
||||
return fh.readline().strip()
|
||||
except OSError:
|
||||
return None
|
||||
return f"http://127.0.0.1:{m.group(1)}"
|
||||
|
||||
|
||||
def _cdp_on_data_dir(http_cdp: str, data_dir: str) -> bool:
|
||||
"""True when the CDP endpoint's browser runs on ``data_dir``.
|
||||
|
||||
Chrome writes its live debug port to ``DevToolsActivePort`` in the
|
||||
user-data-dir; a port match proves the browser is our profile copy, not a
|
||||
throwaway temp dir a raced/stale launch fell back to.
|
||||
"""
|
||||
"""True when the CDP endpoint's browser runs on ``data_dir`` (DevToolsActivePort match proves it
|
||||
is our profile copy, not a throwaway temp dir a raced/stale launch fell back to)."""
|
||||
m = re.search(r":(\d+)", http_cdp or "")
|
||||
if not m:
|
||||
return False
|
||||
try:
|
||||
with open(os.path.join(data_dir, "DevToolsActivePort"), encoding="utf-8") as fh:
|
||||
port_line = fh.readline().strip()
|
||||
return port_line == m.group(1)
|
||||
except OSError:
|
||||
return False
|
||||
return bool(m) and _read_devtools_port(data_dir) == m.group(1)
|
||||
|
||||
|
||||
def _agent_browser_close_session(session_name: str) -> None:
|
||||
"""Best-effort close of an agent-browser session (stale/wrong-dir cleanup)."""
|
||||
_bt = _origin()
|
||||
try:
|
||||
browser_cmd = _bt._find_agent_browser()
|
||||
except FileNotFoundError:
|
||||
return
|
||||
try:
|
||||
subprocess.run(
|
||||
[*_bt._agent_browser_argv(browser_cmd), "--session", session_name, "close"],
|
||||
capture_output=True, text=True, timeout=15, env=_bt._build_browser_env(),
|
||||
)
|
||||
except (subprocess.SubprocessError, OSError) as e:
|
||||
_bt.logger.debug("real-profile session close failed: %s", e)
|
||||
_agent_browser_session_cmd(session_name, "close", log_label="session close")
|
||||
|
||||
|
||||
_REAL_PROFILE_CHROME_FLAGS = (
|
||||
"--remote-debugging-port=0",
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--disable-background-networking",
|
||||
"--disable-component-update",
|
||||
"--disable-default-apps",
|
||||
"--disable-hang-monitor",
|
||||
"--disable-popup-blocking",
|
||||
"--disable-prompt-on-repost",
|
||||
"--disable-sync",
|
||||
"--disable-features=Translate",
|
||||
"--no-startup-window",
|
||||
"--remote-debugging-port=0", "--no-first-run", "--no-default-browser-check",
|
||||
"--disable-background-networking", "--disable-component-update", "--disable-default-apps",
|
||||
"--disable-hang-monitor", "--disable-popup-blocking", "--disable-prompt-on-repost",
|
||||
"--disable-sync", "--disable-features=Translate", "--no-startup-window",
|
||||
)
|
||||
|
||||
|
||||
def _real_profile_unsupported_reason(browser) -> Optional[str]:
|
||||
"""Fail-closed message when the detected default browser can't be used, else None.
|
||||
"""Fail-closed message when the default browser can't be used, else None.
|
||||
|
||||
A recognized pre-release channel (Beta/Dev/Canary) lives in a
|
||||
channel-specific profile dir we don't resolve; normalizing it to the stable
|
||||
family would drive a DIFFERENT profile/account (wrong-principal bug), so
|
||||
refuse rather than guess.
|
||||
A pre-release channel lives in a profile dir we don't resolve; normalizing to the stable
|
||||
family would drive a DIFFERENT profile/account (wrong-principal bug), so refuse rather than guess.
|
||||
"""
|
||||
from hermes_cli.browser_connect import UNSUPPORTED_CHANNEL
|
||||
|
||||
if browser is None:
|
||||
return (
|
||||
"browser.use_real_profile is on, but your default browser is not a "
|
||||
"supported Chromium browser (Chrome, Edge, Brave, Brave Origin, "
|
||||
"Chromium). "
|
||||
"Real-profile browsing requires a Chromium default; set one or turn "
|
||||
"the toggle off."
|
||||
)
|
||||
return (_RP + "your default browser is not a supported Chromium browser (Chrome, Edge, Brave, "
|
||||
"Brave Origin, Chromium). Real-profile browsing requires a Chromium default; set one or turn the toggle off.")
|
||||
if browser == UNSUPPORTED_CHANNEL:
|
||||
return (
|
||||
"browser.use_real_profile is on, but your default browser is a "
|
||||
"pre-release Chromium channel (Beta / Dev / Canary), which "
|
||||
"real-profile browsing does not support. Set your default to a "
|
||||
"stable Chrome / Edge / Brave / Brave Origin / Chromium, or turn "
|
||||
"the toggle off."
|
||||
)
|
||||
return (_RP + "your default browser is a pre-release Chromium channel (Beta / Dev / Canary), which "
|
||||
"real-profile browsing does not support. Set your default to a "
|
||||
"stable Chrome / Edge / Brave / Brave Origin / Chromium, or turn the toggle off.")
|
||||
return None
|
||||
|
||||
|
||||
def _real_profile_snapshot_error(err: str) -> str:
|
||||
"""User-facing message for a failed profile snapshot.
|
||||
|
||||
A locked profile surfaces the guidance verbatim (it already says whether
|
||||
closing is armed) plus the exact approved-close command; the agent must ASK
|
||||
the user before running it — it quits their browser.
|
||||
"""
|
||||
"""User-facing message for a failed profile snapshot; a locked profile adds the approved-close
|
||||
command, which the agent must ASK the user about first (it quits their browser)."""
|
||||
from hermes_cli.browser_connect import _PROFILE_LOCKED_PREFIX
|
||||
|
||||
if err and err.startswith(_PROFILE_LOCKED_PREFIX):
|
||||
body = err[len(_PROFILE_LOCKED_PREFIX):]
|
||||
return (
|
||||
body + " To close it (only after the user approves — it "
|
||||
"quits their browser and loses unsaved tabs), run: "
|
||||
"`hermes browser close-profile`, then retry."
|
||||
)
|
||||
return f"browser.use_real_profile is on, but {err}"
|
||||
return (err[len(_PROFILE_LOCKED_PREFIX):] + " To close it (only after the user approves — it "
|
||||
"quits their browser and loses unsaved tabs), run: `hermes browser close-profile`, then retry.")
|
||||
return f"{_RP}{err}"
|
||||
|
||||
|
||||
def _launch_real_profile_chrome(real_binary: str, copy_dir: str) -> Tuple[Optional[int], Optional[str]]:
|
||||
"""Launch the user's REAL browser binary on the profile COPY; return (debug_port, error).
|
||||
|
||||
agent-browser's own launch path force-adds --use-mock-keychain /
|
||||
--password-store=basic, which makes macOS Chrome drop every
|
||||
keychain-encrypted cookie — the copy would launch signed out. Launching the
|
||||
real binary ourselves with NO mock-keychain switches keeps the OS keychain
|
||||
path intact; agent-browser attaches afterwards via ``--cdp <port>``.
|
||||
|
||||
Headless by default: real-profile browsing is a background capability and a
|
||||
focus-stealing window defeats it. Chrome's NEW headless mode shares the
|
||||
profile's normal cookie store (legacy --headless does not), and cookie
|
||||
decryption is unaffected by headless (the drop comes from mock-keychain, not
|
||||
headless). Users opt into a window via browser.headed / AGENT_BROWSER_HEADED;
|
||||
on a display-less Linux host we force headless regardless so the launch
|
||||
doesn't die at startup. Waits for Chrome to write DevToolsActivePort.
|
||||
agent-browser's own launch force-adds --use-mock-keychain / --password-store=basic, which makes
|
||||
macOS Chrome drop every keychain-encrypted cookie (signed-out copy); launching the real binary
|
||||
ourselves keeps the OS keychain path intact and agent-browser attaches via ``--cdp <port>``.
|
||||
Headless by default (a focus-stealing window defeats a background capability); Chrome's NEW
|
||||
headless shares the profile's cookie store (legacy --headless does not). browser.headed /
|
||||
AGENT_BROWSER_HEADED opts into a window, except on a display-less Linux host (launch would die).
|
||||
"""
|
||||
_bt = _origin()
|
||||
port_file = os.path.join(copy_dir, "DevToolsActivePort")
|
||||
try:
|
||||
os.unlink(port_file) # stale port from a previous launch confuses reuse probes
|
||||
os.unlink(os.path.join(copy_dir, "DevToolsActivePort")) # stale port confuses reuse probes
|
||||
except OSError:
|
||||
pass
|
||||
chrome_argv = [real_binary, f"--user-data-dir={copy_dir}", *_REAL_PROFILE_CHROME_FLAGS]
|
||||
_has_display = bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
|
||||
_want_headed = _bt._is_headed_mode() and (_has_display or not sys.platform.startswith("linux"))
|
||||
if not _want_headed:
|
||||
if not (_bt._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,
|
||||
stdin=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
env=_bt._build_browser_env(),
|
||||
)
|
||||
chrome_proc = subprocess.Popen(chrome_argv, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
stdin=subprocess.DEVNULL, start_new_session=True, env=_bt._build_browser_env())
|
||||
except (subprocess.SubprocessError, OSError) as e:
|
||||
return None, f"browser.use_real_profile is on, but the launch failed: {e}"
|
||||
return None, f"{_RP}the launch failed: {e}"
|
||||
_bt._real_profile_chrome_procs.append(chrome_proc)
|
||||
|
||||
deadline = time.monotonic() + 30.0
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
with open(port_file, encoding="utf-8") as fh:
|
||||
line = fh.readline().strip()
|
||||
if line.isdigit():
|
||||
return int(line), None
|
||||
except OSError:
|
||||
pass
|
||||
line = _read_devtools_port(copy_dir) or ""
|
||||
if line.isdigit():
|
||||
return int(line), None
|
||||
if chrome_proc.poll() is not None:
|
||||
_bt._terminate_real_profile_chrome()
|
||||
return None, (
|
||||
"browser.use_real_profile is on, but Chrome exited during "
|
||||
"startup (another instance may hold the profile copy)."
|
||||
)
|
||||
return None, _RP + "Chrome exited during startup (another instance may hold the profile copy)."
|
||||
time.sleep(0.25)
|
||||
_bt._terminate_real_profile_chrome()
|
||||
return None, (
|
||||
"browser.use_real_profile is on, but the real-profile browser "
|
||||
"did not expose a debug port in time. Retry, or turn the toggle off."
|
||||
)
|
||||
return None, _RP + "the real-profile browser did not expose a debug port in time. Retry, or turn the toggle off."
|
||||
|
||||
|
||||
def _attach_agent_browser_to_real_profile(port: int, copy_dir: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""Make agent-browser ATTACH to the running Chrome (never launch its own).
|
||||
"""Make agent-browser ATTACH to the running Chrome (never launch its own); returns ``(http_cdp, error)``.
|
||||
|
||||
Returns ``(http_cdp, error)``. The daemon may answer with the endpoint of a
|
||||
browser IT spawned (throwaway temp profile) instead of the Chrome we
|
||||
launched; the DevToolsActivePort file OUR Chrome wrote is authoritative, so
|
||||
on disagreement we trust ours.
|
||||
The daemon may answer with the endpoint of a browser IT spawned (throwaway temp profile);
|
||||
the DevToolsActivePort OUR Chrome wrote is authoritative on disagreement.
|
||||
"""
|
||||
_bt = _origin()
|
||||
try:
|
||||
browser_cmd = _bt._find_agent_browser()
|
||||
except FileNotFoundError as e:
|
||||
return None, (
|
||||
"browser.use_real_profile is on, but the local browser engine "
|
||||
f"(agent-browser) is not installed: {e}"
|
||||
)
|
||||
argv = [
|
||||
*_bt._agent_browser_argv(browser_cmd),
|
||||
"--session", _bt._REAL_PROFILE_SESSION,
|
||||
"--cdp", str(port),
|
||||
"open", "about:blank",
|
||||
]
|
||||
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,
|
||||
"--cdp", str(port), "open", "about:blank"]
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
argv, capture_output=True, text=True,
|
||||
timeout=_bt._get_open_command_timeout(first_open=True),
|
||||
env=_bt._build_browser_env(),
|
||||
)
|
||||
proc = subprocess.run(argv, capture_output=True, text=True,
|
||||
timeout=_bt._get_open_command_timeout(first_open=True), env=_bt._build_browser_env())
|
||||
except subprocess.TimeoutExpired:
|
||||
return None, (
|
||||
"browser.use_real_profile is on, but the real-profile browser "
|
||||
"took too long to start. Retry, or turn the toggle off."
|
||||
)
|
||||
return None, _RP + "the real-profile browser took too long to start. Retry, or turn the toggle off."
|
||||
except (subprocess.SubprocessError, OSError) as e:
|
||||
return None, f"browser.use_real_profile is on, but the launch failed: {e}"
|
||||
return None, f"{_RP}the launch failed: {e}"
|
||||
if proc.returncode != 0:
|
||||
tail = (proc.stderr or proc.stdout or "").strip().splitlines()
|
||||
reason = tail[-1] if tail else f"exit {proc.returncode}"
|
||||
return None, (
|
||||
f"browser.use_real_profile is on, but the real-profile browser "
|
||||
f"failed to start: {reason}"
|
||||
)
|
||||
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)
|
||||
try:
|
||||
with open(os.path.join(copy_dir, "DevToolsActivePort"), encoding="utf-8") as fh:
|
||||
our_port = fh.readline().strip()
|
||||
m = re.search(r":(\d+)", cdp or "")
|
||||
if m and m.group(1) != our_port:
|
||||
cdp = f"http://127.0.0.1:{our_port}"
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
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}"
|
||||
if not cdp:
|
||||
return None, (
|
||||
"browser.use_real_profile is on, but the real-profile browser "
|
||||
"started without exposing a devtools endpoint. Retry, or turn "
|
||||
"the toggle off."
|
||||
)
|
||||
return None, _RP + "the real-profile browser started without exposing a devtools endpoint. Retry, or turn the toggle off."
|
||||
return cdp, None
|
||||
|
||||
|
||||
def _real_profile_cdp() -> tuple:
|
||||
"""Resolve ``(cdp_url, error)`` for consented real-profile browsing.
|
||||
|
||||
Snapshots the user's default-Chromium profile into a hermes-owned copy
|
||||
(auth/login state only), launches the real browser binary on that copy and
|
||||
returns the HTTP CDP endpoint for agent-browser / the browser-use harness to
|
||||
attach to. The copy is a non-default dir, so it sidesteps the Chrome ≥136
|
||||
default-profile remote-debugging block and never contends with the user's
|
||||
running browser.
|
||||
|
||||
A single shared agent-browser session is reused across calls (its CDP URL
|
||||
is cached and re-validated). Returns ``(None, message)`` fail-closed when
|
||||
the default browser is non-Chromium or the snapshot/launch fails;
|
||||
``(None, None)`` when consent is off.
|
||||
Snapshot -> launch real binary on the copy -> return its HTTP CDP endpoint. The copy is a
|
||||
non-default dir, so it sidesteps the Chrome >=136 default-profile remote-debugging block and
|
||||
never contends with the user's running browser. One shared agent-browser session is reused
|
||||
across calls (cached, re-validated). ``(None, message)`` fail-closed; ``(None, None)`` when consent is off.
|
||||
"""
|
||||
_bt = _origin()
|
||||
if not _bt._use_real_profile():
|
||||
# Consent is off. A snapshot store from a previous consented run holds
|
||||
# copies of the user's cookies/logins — delete it so revoking consent
|
||||
# actually removes the credential copies. Cheap and idempotent.
|
||||
# Consent is off: delete any snapshot store (copies of cookies/logins) so
|
||||
# revoking consent actually removes the credential copies.
|
||||
try:
|
||||
from hermes_cli.browser_connect import cleanup_real_profile_snapshots
|
||||
|
||||
cleanup_real_profile_snapshots()
|
||||
except Exception as e:
|
||||
_bt.logger.debug("real-profile cleanup-on-consent-off failed: %s", e)
|
||||
_bt._real_profile_cdp_cache.pop("cdp", None)
|
||||
return None, None
|
||||
|
||||
# Lightpanda rejects ``--profile`` outright. Detect it BEFORE default-browser
|
||||
# detection so even a host with no Chromium default reports the actionable
|
||||
# conflict (the engine setting) rather than a generic launch failure.
|
||||
# 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():
|
||||
return None, (
|
||||
"browser.use_real_profile is on, but browser.engine is set to "
|
||||
"'lightpanda', which cannot load a real Chromium profile. Set "
|
||||
"browser.engine to 'auto' or 'chrome' to use real-profile browsing, "
|
||||
"or turn the toggle off."
|
||||
)
|
||||
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.")
|
||||
|
||||
from hermes_cli.browser_connect import (
|
||||
chromium_executable, detect_default_chromium, real_profile_copy_dir, snapshot_real_profile
|
||||
)
|
||||
from hermes_cli.browser_connect import (chromium_executable, detect_default_chromium,
|
||||
real_profile_copy_dir, snapshot_real_profile)
|
||||
|
||||
with _bt._real_profile_cdp_lock:
|
||||
# Reuse a live copy-browser from an earlier call this process made.
|
||||
cached = _bt._real_profile_cdp_cache.get("cdp")
|
||||
if cached and _bt._cdp_http_ready(cached):
|
||||
return cached, None
|
||||
@@ -347,35 +222,23 @@ def _real_profile_cdp() -> tuple:
|
||||
if unsupported:
|
||||
return None, unsupported
|
||||
|
||||
# Reuse BEFORE writing anything. A shared copy-browser may already be up
|
||||
# from a previous hermes process; if it is driving OUR copy dir, hand it
|
||||
# back untouched. CRITICAL: the snapshot overlay (which truncates and
|
||||
# rewrites Cookies / Login Data) must NOT run while that browser holds
|
||||
# the user-data-dir open — doing so corrupts the live databases. So
|
||||
# resolve the copy dir as a PATH only (no copy), probe reuse, and return
|
||||
# early on a hit; the overlay happens solely on the relaunch path below.
|
||||
# Reuse BEFORE writing anything. CRITICAL: the snapshot overlay (truncates/rewrites
|
||||
# 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):
|
||||
_bt._real_profile_cdp_cache["cdp"] = existing
|
||||
return existing, None
|
||||
if existing:
|
||||
# Stale/wrong-dir session (throwaway-temp fallback, or an old copy):
|
||||
# close it so nothing holds the dir open before we overlay + relaunch.
|
||||
if existing: # stale/wrong-dir session: close it so nothing holds the dir open
|
||||
_bt._agent_browser_close_session(_bt._REAL_PROFILE_SESSION)
|
||||
|
||||
# No live browser owns the dir now — safe to (re)snapshot + overlay.
|
||||
snap_dir, err = snapshot_real_profile(browser)
|
||||
if err or not snap_dir:
|
||||
copy_dir, err = snapshot_real_profile(browser)
|
||||
if err or not copy_dir:
|
||||
return None, _real_profile_snapshot_error(err)
|
||||
copy_dir = snap_dir
|
||||
|
||||
real_binary = chromium_executable(browser)
|
||||
if real_binary is None:
|
||||
return None, (
|
||||
"browser.use_real_profile is on, but the real browser binary for "
|
||||
f"'{browser}' could not be found. Reinstall it or turn the toggle off."
|
||||
)
|
||||
return None, f"{_RP}the real browser binary for '{browser}' could not be found. Reinstall it or turn the toggle off."
|
||||
port, err = _launch_real_profile_chrome(real_binary, copy_dir)
|
||||
if port is None:
|
||||
return None, err
|
||||
|
||||
+159
-270
@@ -27,16 +27,14 @@ BACKEND_DISABLED = "off"
|
||||
# Cloud daemon names become the BU_NAME env var
|
||||
_SESSION_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$")
|
||||
|
||||
# Set on the env dict by the CDP resolvers when the resolved browser is EXCLUSIVE
|
||||
# to this named session (per-name provider / named BU cloud / Lightpanda).
|
||||
# Popped before the subprocess launches — never exported to the CLI.
|
||||
# Set on the env dict by the CDP resolvers when the resolved browser is EXCLUSIVE to this named session
|
||||
# (per-name provider / named BU cloud / Lightpanda). Popped before the subprocess launches — never exported.
|
||||
_PRIVATE_BROWSER_SENTINEL = "_HERMES_BU_PRIVATE_BROWSER"
|
||||
|
||||
# Prepended to the model's code for named sessions on SHARED browsers (local
|
||||
# Chrome / CDP override): the harness daemon attaches to the first existing page
|
||||
# at startup, so two fresh named daemons can land on the SAME tab. Steering each
|
||||
# onto a tab it created prevents clobbering before their first new_tab(). Runs
|
||||
# once per daemon (marker keyed by BU_NAME + daemon pid).
|
||||
# Prepended to the model's code for named sessions on SHARED browsers (local Chrome / CDP override): the
|
||||
# harness daemon attaches to the first existing page at startup, so two fresh named daemons can land on the
|
||||
# SAME tab. Steering each onto a tab it created prevents clobbering. Runs once per daemon (marker keyed by
|
||||
# BU_NAME + daemon pid).
|
||||
_OWN_TAB_PREAMBLE = """\
|
||||
# hermes: pin this named session to its own tab (once per daemon process)
|
||||
def _hermes_ensure_own_tab():
|
||||
@@ -77,18 +75,11 @@ _MIN_TIMEOUT_S = 5
|
||||
_MAX_TIMEOUT_S = 1800
|
||||
_STDERR_CAP_CHARS = 4000
|
||||
|
||||
# Filesystem-safe task ids for per-task workspace dirs.
|
||||
_TASK_ID_SAFE_RE = re.compile(r"[^A-Za-z0-9._-]+")
|
||||
|
||||
# Screenshot paths printed by capture_screenshot(): POSIX absolute or Windows
|
||||
# drive-letter absolute (Browser Use on Windows prints native paths).
|
||||
_IMAGE_PATH_RE = re.compile(
|
||||
r"((?:[A-Za-z]:[\\/]|/)[^\s\"']+?\.(?:png|jpe?g|webp))", re.IGNORECASE
|
||||
)
|
||||
|
||||
_TASK_ID_SAFE_RE = re.compile(r"[^A-Za-z0-9._-]+") # filesystem-safe task ids
|
||||
# Screenshot paths printed by capture_screenshot(): POSIX or Windows drive-letter absolute.
|
||||
_IMAGE_PATH_RE = re.compile(r"((?:[A-Za-z]:[\\/]|/)[^\s\"']+?\.(?:png|jpe?g|webp))", re.IGNORECASE)
|
||||
# http(s) URL literals in exec code checked against browser_navigate's policy
|
||||
_URL_RE = re.compile(r"https?://[^\s'\"\\)]+", re.IGNORECASE)
|
||||
|
||||
_FHS_BIN_DIRS = ("/usr/local/sbin", "/usr/local/bin", "/usr/sbin", "/usr/bin", "/sbin", "/bin")
|
||||
|
||||
|
||||
@@ -99,30 +90,23 @@ def _quiet(fn: Callable[[], Any], default: Any, log_prefix: str = "") -> Any:
|
||||
except Exception as e:
|
||||
if log_prefix:
|
||||
logger.debug("%s: %s", log_prefix, e)
|
||||
return default
|
||||
return default
|
||||
|
||||
|
||||
def _lazy_call(module: str, name: str, default: Any, log_prefix: str) -> Any:
|
||||
"""Call ``module.name()`` resolved at call time (so tests can stub the module or
|
||||
patch the attribute); on any failure log ``log_prefix`` and return ``default``."""
|
||||
"""Call ``module.name()`` resolved at call time (tests stub the module / patch the
|
||||
attribute); on any failure log ``log_prefix`` and return ``default``."""
|
||||
return _quiet(lambda: getattr(importlib.import_module(module), name)(), default, log_prefix)
|
||||
|
||||
|
||||
def _camofox_active(context: str = "") -> bool:
|
||||
"""True when the Camofox backend is selected; False (logged) if the probe fails."""
|
||||
return _lazy_call("tools.browser_camofox", "is_camofox_mode", False, f"Camofox activity check failed{context}")
|
||||
|
||||
|
||||
def _real_profile_consented() -> bool:
|
||||
"""Whether the user opted in to real-profile local browsing (config read)."""
|
||||
return _lazy_call("tools.browser_tool", "_use_real_profile", False, "real-profile consent lookup failed")
|
||||
|
||||
|
||||
def _lightpanda_engine_in_use() -> bool:
|
||||
return _lazy_call("tools.browser_tool", "lightpanda_engine_status", (False, ""),
|
||||
"lightpanda engine status unavailable")[0]
|
||||
|
||||
|
||||
def _set_cdp_env(env: dict, cdp: str) -> None:
|
||||
"""Export a CDP endpoint under the BU_CDP_* contract (http(s) → URL, else WS)."""
|
||||
env["BU_CDP_URL" if cdp.startswith(("http://", "https://")) else "BU_CDP_WS"] = cdp
|
||||
@@ -159,9 +143,8 @@ def _blocked_url_in_code(code: str) -> Optional[str]:
|
||||
def _base_subprocess_env() -> dict:
|
||||
from tools.browser_tool import _build_browser_env
|
||||
env = _build_browser_env()
|
||||
# The CLI runs under its own Python (uv tool / uvx); inherited PYTHONPATH/PYTHONHOME
|
||||
# (Hermes's venv) win over its site-packages → wrong-ABI C-extensions (pydantic_core)
|
||||
# and a crash. Strip both.
|
||||
# The CLI runs under its own Python (uv tool / uvx); an inherited PYTHONPATH/PYTHONHOME
|
||||
# (Hermes's venv) wins over its site-packages → wrong-ABI C-extensions and a crash.
|
||||
env.pop("PYTHONPATH", None)
|
||||
env.pop("PYTHONHOME", None)
|
||||
env["PATH"] = _floor_subprocess_path(env.get("PATH", ""))
|
||||
@@ -170,11 +153,10 @@ def _base_subprocess_env() -> dict:
|
||||
|
||||
|
||||
def _floor_subprocess_path(path: str) -> str:
|
||||
"""Guarantee core system dirs survive onto the CLI subprocess PATH: profile workers
|
||||
(kanban bots, cron) can inherit a PATH of only version-manager dirs, and the uv
|
||||
browser-use binary's POSIX sh trampoline resolves ``dirname``/``realpath`` through
|
||||
PATH and dies (exit 127) without /usr/bin. Reuses browser_tool's ``_merge_browser_path``
|
||||
floor, else appends FHS bin dirs. Windows .cmd shims don't trampoline: no-op there."""
|
||||
"""Guarantee core system dirs on the CLI subprocess PATH: profile workers (kanban bots, cron) can inherit
|
||||
a PATH of only version-manager dirs, and the uv binary's POSIX sh trampoline resolves ``dirname``/``realpath``
|
||||
via PATH (exit 127 without /usr/bin). Reuses browser_tool's ``_merge_browser_path`` floor, else appends
|
||||
FHS bin dirs. Windows .cmd shims don't trampoline: no-op there."""
|
||||
if os.name == "nt":
|
||||
return path
|
||||
with contextlib.suppress(Exception):
|
||||
@@ -201,11 +183,8 @@ def _use_gateway(browser_cfg: dict) -> bool:
|
||||
|
||||
|
||||
def get_browser_backend() -> str:
|
||||
"""Return the configured browser backend key ("" = unset → default).
|
||||
|
||||
YAML 1.1 parses an unquoted ``off`` as False — a hand-edited ``backend: off``
|
||||
must mean BACKEND_DISABLED, not "unset" (True has no meaning → unset).
|
||||
"""
|
||||
"""Configured browser backend key ("" = unset → default). YAML 1.1 parses an
|
||||
unquoted ``off`` as False — that must mean BACKEND_DISABLED, not "unset"."""
|
||||
raw = _read_browser_cfg().get("backend")
|
||||
if isinstance(raw, bool):
|
||||
return BACKEND_DISABLED if raw is False else ""
|
||||
@@ -219,22 +198,16 @@ def is_legacy_browser_use_cloud_config(browser_cfg: dict) -> bool:
|
||||
if not isinstance(browser_cfg, dict) or browser_cfg.get("backend"):
|
||||
return False
|
||||
provider = str(browser_cfg.get("cloud_provider") or "").strip().lower()
|
||||
if provider not in {"browser-use", ""} or _use_gateway(browser_cfg):
|
||||
return False
|
||||
if _camofox_active(" during migration"):
|
||||
if provider not in {"browser-use", ""} or _use_gateway(browser_cfg) or _camofox_active(" during migration"):
|
||||
return False
|
||||
return bool(os.getenv("BROWSER_USE_API_KEY"))
|
||||
|
||||
|
||||
def is_browser_use_cli_mode() -> bool:
|
||||
"""True when the Browser Use CLI replaces the built-in browser stack.
|
||||
|
||||
Browser Use mode is the DEFAULT: unset ``browser.backend`` ("") enables it
|
||||
whenever the CLI is runnable (installed binary or uvx), so browsing never
|
||||
silently breaks; ``browser.backend: off`` (or ``/browser use off``) keeps the
|
||||
built-in browser_* tools. Camofox always falls back to the built-in tools —
|
||||
Firefox-based with a custom HTTP API and no CDP surface for the harness.
|
||||
"""
|
||||
"""True when the Browser Use CLI replaces the built-in browser stack. Browser Use mode is the DEFAULT:
|
||||
unset ``browser.backend`` ("") enables it whenever the CLI is runnable (installed binary or uvx);
|
||||
``browser.backend: off`` keeps the built-in browser_* tools. Camofox always falls back to the built-in
|
||||
tools (Firefox, custom HTTP API, no CDP surface for the harness)."""
|
||||
if _camofox_active():
|
||||
return False
|
||||
backend = get_browser_backend()
|
||||
@@ -243,27 +216,21 @@ def is_browser_use_cli_mode() -> bool:
|
||||
return is_legacy_browser_use_cloud_config(_read_browser_cfg()) or _find_cli() is not None
|
||||
|
||||
|
||||
_NOTICE_STAMP_NAME = ".browser_use_default_notice"
|
||||
_NOTICE_INTERVAL_S = 24 * 3600
|
||||
|
||||
|
||||
def default_downgrade_notice() -> Optional[str]:
|
||||
"""One-line notice when ``browser.backend`` is unset (Browser Use would be the
|
||||
default) but the CLI is not runnable, so the session fell back to the built-in
|
||||
tools. Rate-limited to once per 24h via a stamp file."""
|
||||
"""One-line notice when ``browser.backend`` is unset but the CLI is not runnable, so
|
||||
the session fell back to the built-in tools. Rate-limited to once per 24h via a stamp file."""
|
||||
try:
|
||||
if get_browser_backend() or _camofox_active() or _find_cli() is not None:
|
||||
return None # explicit choice / Camofox / CLI present — nothing downgraded
|
||||
stamp = Path(get_hermes_home()) / "cache" / _NOTICE_STAMP_NAME
|
||||
stamp = Path(get_hermes_home()) / "cache" / ".browser_use_default_notice"
|
||||
with contextlib.suppress(OSError):
|
||||
if 0 <= time.time() - stamp.stat().st_mtime < _NOTICE_INTERVAL_S:
|
||||
if 0 <= time.time() - stamp.stat().st_mtime < 24 * 3600:
|
||||
return None
|
||||
with contextlib.suppress(OSError):
|
||||
stamp.parent.mkdir(parents=True, exist_ok=True)
|
||||
stamp.touch()
|
||||
return ("Browser Use CLI not found — using the built-in browser tools. "
|
||||
"Run `hermes tools` (Browser Automation → Browser Use) to install it, "
|
||||
"or `browser.backend: off` in config.yaml to silence this.")
|
||||
return ("Browser Use CLI not found — using the built-in browser tools. Run `hermes tools` "
|
||||
"(Browser Automation → Browser Use) to install it, or `browser.backend: off` in config.yaml to silence this.")
|
||||
except Exception as e: # pragma: no cover — a notice must never break startup
|
||||
logger.debug("browser-use downgrade notice failed: %s", e)
|
||||
return None
|
||||
@@ -274,24 +241,17 @@ def _managed_bin_dir() -> str:
|
||||
return str(Path(get_hermes_home()) / "bin")
|
||||
|
||||
|
||||
def _user_local_bin_dir() -> Optional[str]:
|
||||
"""User-level tool dir (~/.local/bin; uv's tool bin dir on Windows) — Desktop/TUI
|
||||
workers may start with a minimal PATH that omits it."""
|
||||
if os.name == "nt":
|
||||
base = os.environ.get("APPDATA")
|
||||
return str(Path(base) / "uv" / "bin") if base else None
|
||||
return str(Path(os.path.expanduser("~")) / ".local" / "bin")
|
||||
|
||||
|
||||
def _find_cli() -> Optional[List[str]]:
|
||||
"""Locate the browser-use CLI, or None when it can't be run.
|
||||
|
||||
MANAGED-FIRST: Hermes' own ``$HERMES_HOME/bin`` copy (installed/updated via
|
||||
``install_cli()``) always wins so every session drives one Hermes-controlled
|
||||
binary; PATH and the user-level tool dir (manual ``uv tool install``, minimal
|
||||
Desktop/TUI PATHs) are fallbacks; uvx zero-install (same probe order) is last.
|
||||
"""
|
||||
probe_paths = [p for p in (_managed_bin_dir(), None, _user_local_bin_dir()) if p is None or p] # None = PATH
|
||||
"""Locate the browser-use CLI, or None when it can't be run. MANAGED-FIRST: Hermes' own ``$HERMES_HOME/bin``
|
||||
copy always wins so every session drives one Hermes-controlled binary; PATH and the user-level tool dir
|
||||
(~/.local/bin, or uv's %APPDATA%/uv/bin on Windows — Desktop/TUI workers may start with a minimal PATH
|
||||
that omits it) are fallbacks; uvx zero-install (same probe order) is last."""
|
||||
if os.name == "nt":
|
||||
appdata = os.environ.get("APPDATA")
|
||||
user_bin = str(Path(appdata) / "uv" / "bin") if appdata else None
|
||||
else:
|
||||
user_bin = str(Path(os.path.expanduser("~")) / ".local" / "bin")
|
||||
probe_paths = [p for p in (_managed_bin_dir(), None, user_bin) if p is None or p] # None = PATH
|
||||
for name, argv in (("browser-use", lambda b: [b]), ("uvx", lambda b: [b, "browser-use"])):
|
||||
for probe_path in probe_paths:
|
||||
found = shutil.which(name, path=probe_path)
|
||||
@@ -301,15 +261,10 @@ def _find_cli() -> Optional[List[str]]:
|
||||
|
||||
|
||||
def install_cli(timeout_s: int = 600) -> Tuple[bool, str]:
|
||||
"""Install the browser-use CLI persistently via ``uv tool install`` (managed uv
|
||||
via ``ensure_uv`` → uv on PATH), linking the binary into ``$HERMES_HOME/bin``
|
||||
(``UV_TOOL_BIN_DIR``) so ``_find_cli()`` resolves it for every profile without
|
||||
touching the user's PATH. Returns ``(ok, message)``; never raises.
|
||||
|
||||
MANAGED-FIRST: only the managed copy short-circuits. A browser-use on PATH is a
|
||||
user-level side install and must NOT prevent provisioning the canonical
|
||||
Hermes-managed copy (version drift, no updates via hermes tools).
|
||||
"""
|
||||
"""Install the browser-use CLI via ``uv tool install`` (managed uv via ``ensure_uv`` → uv on PATH), linking
|
||||
the binary into ``$HERMES_HOME/bin`` (``UV_TOOL_BIN_DIR``) so ``_find_cli()`` resolves it for every profile.
|
||||
Returns ``(ok, message)``; never raises. MANAGED-FIRST: only the managed copy short-circuits — a browser-use
|
||||
on PATH is a user-level side install and must not block provisioning the canonical copy (version drift)."""
|
||||
bin_dir = _managed_bin_dir()
|
||||
managed = shutil.which("browser-use", path=bin_dir)
|
||||
if managed:
|
||||
@@ -323,9 +278,7 @@ def install_cli(timeout_s: int = 600) -> Tuple[bool, str]:
|
||||
if not uv_bin:
|
||||
return False, ("uv is not available and could not be bootstrapped. Install uv "
|
||||
"(https://docs.astral.sh/uv/) and run `uv tool install browser-use`.")
|
||||
|
||||
env = dict(os.environ)
|
||||
env["UV_NO_CONFIG"] = "1"
|
||||
env = {**os.environ, "UV_NO_CONFIG": "1"}
|
||||
try:
|
||||
Path(bin_dir).mkdir(parents=True, exist_ok=True)
|
||||
env["UV_TOOL_BIN_DIR"] = bin_dir
|
||||
@@ -333,10 +286,8 @@ def install_cli(timeout_s: int = 600) -> Tuple[bool, str]:
|
||||
logger.debug("Could not prepare %s: %s", bin_dir, e)
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[uv_bin, "tool", "install", "browser-use"], capture_output=True, text=True,
|
||||
encoding="utf-8", errors="replace", env=env, timeout=timeout_s,
|
||||
)
|
||||
result = subprocess.run([uv_bin, "tool", "install", "browser-use"], capture_output=True, text=True,
|
||||
encoding="utf-8", errors="replace", env=env, timeout=timeout_s)
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, f"`uv tool install browser-use` timed out after {timeout_s}s"
|
||||
except Exception as e:
|
||||
@@ -348,8 +299,8 @@ def install_cli(timeout_s: int = 600) -> Tuple[bool, str]:
|
||||
|
||||
found = _find_cli()
|
||||
if not found or len(found) != 1:
|
||||
return False, ("install reported success but the browser-use binary is still "
|
||||
"not resolvable — run `uv tool install browser-use` manually")
|
||||
return False, ("install reported success but the browser-use binary is still not resolvable — "
|
||||
"run `uv tool install browser-use` manually")
|
||||
return True, f"browser-use CLI installed ({found[0]})"
|
||||
|
||||
|
||||
@@ -369,8 +320,8 @@ def _workspace_dir(task_id: Optional[str]) -> Optional[str]:
|
||||
|
||||
|
||||
def _find_screenshot(stdout: str, since: float) -> Optional[str]:
|
||||
"""Last screenshot path printed during this exec that exists and was
|
||||
written after the exec started, or None."""
|
||||
"""Last screenshot path printed during this exec that exists and was written after
|
||||
the exec started, or None."""
|
||||
for path in reversed(_IMAGE_PATH_RE.findall(stdout or "")):
|
||||
try:
|
||||
if os.path.isfile(path) and os.path.getmtime(path) >= since - 1:
|
||||
@@ -388,13 +339,10 @@ def _native_screenshot_result(result: Dict[str, Any], path: str) -> Optional[Dic
|
||||
|
||||
if not _should_use_native_vision_fast_path():
|
||||
return None
|
||||
# History-reuse cap: this data URL bakes into the tool result and is re-sent
|
||||
# every later turn — same policy as the vision_analyze / browser_vision native
|
||||
# embeds (256 KB / 1568 px, JPEG quality ladder).
|
||||
data_url = _resize_image_for_vision(
|
||||
Path(path), mime_type="image/png", max_base64_bytes=_EMBED_TARGET_BYTES,
|
||||
max_dimension=_EMBED_MAX_DIMENSION, force_jpeg=True,
|
||||
)
|
||||
# History-reuse cap: this data URL bakes into the tool result and is re-sent every later turn —
|
||||
# same policy as the vision_analyze / browser_vision native embeds.
|
||||
data_url = _resize_image_for_vision(Path(path), mime_type="image/png", max_base64_bytes=_EMBED_TARGET_BYTES,
|
||||
max_dimension=_EMBED_MAX_DIMENSION, force_jpeg=True)
|
||||
text = json.dumps(result, ensure_ascii=False)
|
||||
attached = text + "\n\nThe screenshot from this call is attached — inspect it with your native vision."
|
||||
return {
|
||||
@@ -414,11 +362,9 @@ def _backend_cache_key(task_id: Optional[str], session_name: str = "") -> str:
|
||||
|
||||
|
||||
def _resolve_lightpanda_cdp(env: dict, task_id: Optional[str], session_name: str = "") -> Optional[str]:
|
||||
"""Point the harness at a Hermes-spawned ``lightpanda serve`` (only when
|
||||
``browser.engine`` is ``lightpanda`` and nothing with higher precedence claimed
|
||||
the session). Each cache key gets its own process via the legacy
|
||||
``_get_session_info()`` (cache, inactivity reaper, atexit), so the browser is
|
||||
private to this session and the own-tab preamble is skipped."""
|
||||
"""Point the harness at a Hermes-spawned ``lightpanda serve`` (``browser.engine: lightpanda`` and
|
||||
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
|
||||
if not _using_lightpanda_engine():
|
||||
@@ -440,15 +386,12 @@ def _resolve_lightpanda_cdp(env: dict, task_id: Optional[str], session_name: str
|
||||
def _resolve_backend_cdp(env: dict, task_id: Optional[str], session_name: str = "") -> Optional[str]:
|
||||
"""Point the harness at the configured backend's CDP endpoint; error string on failure.
|
||||
|
||||
Precedence (first hit wins): (1) ``BU_CDP_WS``/``BU_CDP_URL`` already in env
|
||||
(operator override, untouched); (2) ``BROWSER_CDP_URL`` env / ``browser.cdp_url``
|
||||
config (``/browser connect``, same precedence as the built-in tools); (3) a
|
||||
cloud provider via the legacy ``_get_session_info()`` so browser_exec shares the
|
||||
SAME session machinery (per-task cache, expiry replacement, reaper, atexit);
|
||||
(4) ``browser.engine: lightpanda``; (5) nothing → None, the harness attaches to
|
||||
local Chrome (or BU cloud via BU_AUTOSPAWN for legacy configs). ``session_name``
|
||||
(BU_NAME) keys the provider cache so each name gets its OWN cloud browser and
|
||||
the same name reuses one — what makes named sessions concurrent-safe.
|
||||
Precedence: (1) ``BU_CDP_WS``/``BU_CDP_URL`` already in env (operator override); (2) ``BROWSER_CDP_URL``
|
||||
env / ``browser.cdp_url`` (``/browser connect``); (3) a cloud provider via the legacy ``_get_session_info()``
|
||||
so browser_exec shares the SAME session machinery (per-task cache, expiry, reaper, atexit);
|
||||
(4) ``browser.engine: lightpanda``; (5) nothing → None: the harness attaches to local Chrome (or BU
|
||||
cloud via BU_AUTOSPAWN for legacy configs). ``session_name`` (BU_NAME) keys the provider cache so
|
||||
each name gets its OWN cloud browser — what makes named sessions concurrent-safe.
|
||||
"""
|
||||
if _has_cdp_env(env):
|
||||
return None
|
||||
@@ -468,11 +411,9 @@ def _resolve_backend_cdp(env: dict, task_id: Optional[str], session_name: str =
|
||||
if provider is None:
|
||||
return _resolve_lightpanda_cdp(env, task_id, session_name)
|
||||
|
||||
# Browser Use direct-API configs: the CLI talks to BU cloud natively (BU_AUTOSPAWN /
|
||||
# auth login) — the legacy provider would create a second, redundant session. The
|
||||
# Nous-gateway variant (use_gateway: true) DOES resolve through the provider: the
|
||||
# gateway provisions the browser server-side and returns its CDP URL, giving
|
||||
# subscribers CLI mode with no raw key.
|
||||
# Browser Use direct-API configs: the CLI talks to BU cloud natively (BU_AUTOSPAWN / auth login) — the
|
||||
# legacy provider would create a second, redundant session. The Nous-gateway variant (use_gateway: true)
|
||||
# DOES resolve through the provider: the gateway provisions the browser server-side and returns its CDP URL.
|
||||
provider_key = str(getattr(provider, "name", "") or "").strip().lower()
|
||||
if provider_key == _BACKEND_KEY and not _use_gateway(_read_browser_cfg()):
|
||||
env[_PRIVATE_BROWSER_SENTINEL] = "1" # named BU cloud browsers are exclusive to their daemon
|
||||
@@ -486,25 +427,20 @@ def _resolve_backend_cdp(env: dict, task_id: Optional[str], session_name: str =
|
||||
f"Cloud browser provider {provider_name} returned no CDP endpoint, so Browser Use mode "
|
||||
"cannot drive it. Switch to the built-in browser tools for this provider.",
|
||||
)
|
||||
# A provider browser keyed bu-named-<name> is exclusive to this session —
|
||||
# the own-tab preamble would just leak a blank tab into it.
|
||||
# A provider browser keyed bu-named-<name> is exclusive to this session — the
|
||||
# own-tab preamble would just leak a blank tab into it.
|
||||
if err is None and session_name:
|
||||
env[_PRIVATE_BROWSER_SENTINEL] = "1"
|
||||
return err
|
||||
|
||||
|
||||
def _resolve_real_profile_cdp(env: dict, force_local: bool) -> Optional[str]:
|
||||
"""Point the harness at the user's real-profile copy-browser when consented.
|
||||
|
||||
With ``browser.use_real_profile`` on, local browsing means the user's default
|
||||
Chromium with their logins — Hermes launches on a SNAPSHOT of the real profile
|
||||
(hermes_cli.browser_connect). Two ways in: the effective backend is already
|
||||
local (no provider, CDP override, or legacy BU cloud config) → silent upgrade;
|
||||
or ``force_local`` (consent-gated ``local`` arg) → the user's browser even under
|
||||
a cloud backend. Operator overrides (BU_CDP_* env, /browser connect,
|
||||
``browser.cdp_url``) own the session either way. Fail closed: a real-profile
|
||||
launch error is returned so a consented user is never silently downgraded.
|
||||
"""
|
||||
"""Point the harness at the user's real-profile copy-browser (a SNAPSHOT of their default Chromium
|
||||
profile, hermes_cli.browser_connect) when consented. Two ways in: the effective backend is already local
|
||||
(no provider, CDP override, or legacy BU cloud config) → silent upgrade; or ``force_local`` (consent-gated
|
||||
``local`` arg) → the user's browser even under a cloud backend. Operator overrides (BU_CDP_* env,
|
||||
/browser connect, ``browser.cdp_url``) own the session either way. Fail closed: a launch error is
|
||||
returned so a consented user is never silently downgraded."""
|
||||
if not _real_profile_consented() or _has_cdp_env(env):
|
||||
return None
|
||||
|
||||
@@ -517,14 +453,11 @@ def _resolve_real_profile_cdp(env: dict, force_local: bool) -> Optional[str]:
|
||||
if _quiet(_get_cdp_override_raw, ""):
|
||||
return None
|
||||
|
||||
if not force_local:
|
||||
# Only auto-upgrade genuinely-local attaches; any cloud path (provider, provider
|
||||
# lookup failure, or legacy BU cloud config) stays on its backend unless the model
|
||||
# passes local=true.
|
||||
if _quiet(_get_cloud_provider, object()) is not None:
|
||||
return None
|
||||
if is_legacy_browser_use_cloud_config(_read_browser_cfg()):
|
||||
return None
|
||||
# Only auto-upgrade genuinely-local attaches; any cloud path (provider, provider lookup
|
||||
# failure, or legacy BU cloud config) stays on its backend unless the model passes local=true.
|
||||
if not force_local and (_quiet(_get_cloud_provider, object()) is not None
|
||||
or is_legacy_browser_use_cloud_config(_read_browser_cfg())):
|
||||
return None
|
||||
|
||||
cdp, err = _real_profile_cdp()
|
||||
if err:
|
||||
@@ -535,22 +468,17 @@ def _resolve_real_profile_cdp(env: dict, force_local: bool) -> Optional[str]:
|
||||
|
||||
|
||||
def _route_backend(env: dict, session: str, task_id: Optional[str], local: bool) -> Optional[str]:
|
||||
"""Resolve where the harness connects; returns an error string or None.
|
||||
|
||||
Real-profile consent runs BEFORE provider resolution so a real-profile hit
|
||||
short-circuits the cloud path via the BU_CDP_* env contract. Named sessions
|
||||
compose with the backend: BU_NAME namespaces the harness daemon (IPC socket,
|
||||
log, pid) and on provider backends additionally keys its own cloud browser.
|
||||
"""
|
||||
"""Resolve where the harness connects; returns an error string or None. Real-profile consent runs
|
||||
BEFORE provider resolution so a hit short-circuits the cloud path via the BU_CDP_* env contract. Named
|
||||
sessions compose with the backend: BU_NAME namespaces the harness daemon (IPC socket, log, pid) and on
|
||||
provider backends additionally keys its own cloud browser."""
|
||||
rp_err = _resolve_real_profile_cdp(env, force_local=local)
|
||||
if rp_err:
|
||||
return rp_err
|
||||
# local=True is only served by the real-profile route; consent off (schema
|
||||
# normally hidden, but be explicit) must not pretend.
|
||||
# local=True is only served by the real-profile route; consent off must not pretend.
|
||||
if local and not _has_cdp_env(env) and not _real_profile_consented():
|
||||
return ("local=true was requested but browser.use_real_profile is off. "
|
||||
"Enable it in config.yaml (browser.use_real_profile: true) or "
|
||||
"the desktop Settings → Browser section, then retry.")
|
||||
return ("local=true was requested but browser.use_real_profile is off. Enable it in config.yaml "
|
||||
"(browser.use_real_profile: true) or the desktop Settings → Browser section, then retry.")
|
||||
return _resolve_backend_cdp(env, task_id, session_name=session)
|
||||
|
||||
|
||||
@@ -558,12 +486,12 @@ def _windows_popen_kwargs() -> dict:
|
||||
"""Hide the console the .cmd shim would flash on Windows (as browser_tool does)."""
|
||||
if os.name != "nt":
|
||||
return {}
|
||||
|
||||
def _flags() -> dict:
|
||||
from hermes_cli._subprocess_compat import windows_hide_flags
|
||||
si = subprocess.STARTUPINFO()
|
||||
si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
||||
return {"creationflags": windows_hide_flags(), "startupinfo": si}
|
||||
|
||||
return _quiet(_flags, {}, "Windows hide-flags unavailable")
|
||||
|
||||
|
||||
@@ -587,10 +515,9 @@ def browser_exec(code: str, session: str = "", timeout_s: int = _DEFAULT_TIMEOUT
|
||||
|
||||
cmd = _find_cli()
|
||||
if not cmd:
|
||||
return tool_error("browser-use CLI not found on PATH, and uvx is unavailable for a "
|
||||
"zero-install run. Install it with `uv tool install browser-use` "
|
||||
"(or `pipx install browser-use`), then run `browser-use --doctor` "
|
||||
"to verify the setup.")
|
||||
return tool_error("browser-use CLI not found on PATH, and uvx is unavailable for a zero-install run. "
|
||||
"Install it with `uv tool install browser-use` (or `pipx install browser-use`), "
|
||||
"then run `browser-use --doctor` to verify the setup.")
|
||||
|
||||
env = _base_subprocess_env()
|
||||
if session:
|
||||
@@ -602,10 +529,9 @@ def browser_exec(code: str, session: str = "", timeout_s: int = _DEFAULT_TIMEOUT
|
||||
if route_err:
|
||||
return tool_error(route_err)
|
||||
|
||||
# SHARED browser (local Chrome / CDP override): pin each named session to its
|
||||
# own tab (see _OWN_TAB_PREAMBLE). Private per-name browsers skip this — no one
|
||||
# to collide with, and the extra tab would leak.
|
||||
private_browser = env.pop(_PRIVATE_BROWSER_SENTINEL, None)
|
||||
# SHARED browser (local Chrome / CDP override): pin each named session to its own tab (see
|
||||
# _OWN_TAB_PREAMBLE). Private per-name browsers skip this — nothing to collide with.
|
||||
private_browser = env.pop(_PRIVATE_BROWSER_SENTINEL, None) # always pop: never exported to the CLI
|
||||
if session and not private_browser:
|
||||
code = _OWN_TAB_PREAMBLE + code
|
||||
|
||||
@@ -613,8 +539,8 @@ def browser_exec(code: str, session: str = "", timeout_s: int = _DEFAULT_TIMEOUT
|
||||
if workspace:
|
||||
env["BH_AGENT_WORKSPACE"] = workspace
|
||||
|
||||
# BU_AUTOSPAWN makes the CLI start a Browser Use cloud browser when no
|
||||
# local Chrome/CDP endpoint is reachable (their API key authenticates it)
|
||||
# BU_AUTOSPAWN makes the CLI start a Browser Use cloud browser when no local
|
||||
# Chrome/CDP endpoint is reachable (their API key authenticates it)
|
||||
if "BU_AUTOSPAWN" not in env and is_legacy_browser_use_cloud_config(_read_browser_cfg()):
|
||||
env["BU_AUTOSPAWN"] = "1"
|
||||
|
||||
@@ -626,10 +552,9 @@ def browser_exec(code: str, session: str = "", timeout_s: int = _DEFAULT_TIMEOUT
|
||||
**_windows_popen_kwargs(),
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return tool_error(f"browser-use exec timed out after {timeout}s. The daemon may "
|
||||
f"still be working; retry with a larger timeout_s (max {_MAX_TIMEOUT_S}), "
|
||||
"or split the work into several calls that append to workspace files — "
|
||||
"anything already written to the workspace is preserved.")
|
||||
return tool_error(f"browser-use exec timed out after {timeout}s. The daemon may still be working; retry "
|
||||
f"with a larger timeout_s (max {_MAX_TIMEOUT_S}), or split the work into several calls that "
|
||||
"append to workspace files — anything already written to the workspace is preserved.")
|
||||
except OSError as e:
|
||||
return tool_error(f"Failed to launch browser-use CLI: {e}")
|
||||
|
||||
@@ -654,82 +579,63 @@ def browser_exec(code: str, session: str = "", timeout_s: int = _DEFAULT_TIMEOUT
|
||||
|
||||
|
||||
_HEADER_BASE = (
|
||||
"Drive a real web browser via the Browser Use CLI: `code` runs as full "
|
||||
"Python (stdlib available) with pre-imported browser helpers; stdout "
|
||||
"comes back in the result. Start `code` with a one-line comment "
|
||||
"describing the step for the user in plain language, max 60 chars "
|
||||
"(e.g. `# Searching Amazon for paper towels`) — the UI shows it as the "
|
||||
"step label.\n\n"
|
||||
"STATE: the browser session and workspace persist across calls; Python "
|
||||
"variables do NOT (fresh interpreter each call). The workspace dir is "
|
||||
"$BH_AGENT_WORKSPACE (also `workspace` in every result); functions "
|
||||
"defined in agent_helpers.py there are auto-imported into every call. "
|
||||
"For multi-item tasks ('all N products / every entry'), append each "
|
||||
"batch to a JSON/CSV file in the workspace, then read it back and "
|
||||
"aggregate in code — dedupe/count/sort with Python, not in your head — "
|
||||
"and verify the collected count against what was asked before "
|
||||
"answering.\n\n"
|
||||
"Batch each sub-procedure (navigate, wait, extract, act) into one call "
|
||||
"— do not spend a call per action — but for long extractions prefer "
|
||||
"several medium calls that append to workspace files over one giant "
|
||||
"call, so progress survives timeouts."
|
||||
"Drive a real web browser via the Browser Use CLI: `code` runs as full Python (stdlib available) "
|
||||
"with pre-imported browser helpers; stdout comes back in the result. Start `code` with a one-line "
|
||||
"comment describing the step for the user in plain language, max 60 chars "
|
||||
"(e.g. `# Searching Amazon for paper towels`) — the UI shows it as the step label.\n\n"
|
||||
"STATE: the browser session and workspace persist across calls; Python variables do NOT (fresh "
|
||||
"interpreter each call). The workspace dir is $BH_AGENT_WORKSPACE (also `workspace` in every result); "
|
||||
"functions defined in agent_helpers.py there are auto-imported into every call. For multi-item tasks "
|
||||
"('all N products / every entry'), append each batch to a JSON/CSV file in the workspace, then read it "
|
||||
"back and aggregate in code — dedupe/count/sort with Python, not in your head — and verify the "
|
||||
"collected count against what was asked before answering.\n\n"
|
||||
"Batch each sub-procedure (navigate, wait, extract, act) into one call — do not spend a call per "
|
||||
"action — but for long extractions prefer several medium calls that append to workspace files over "
|
||||
"one giant call, so progress survives timeouts."
|
||||
)
|
||||
|
||||
_HEADER_VISION = (
|
||||
" Screenshots are attached to your context automatically: when the exec "
|
||||
"output contains a capture_screenshot() path, the image arrives with "
|
||||
"this tool's result and you inspect it directly with your own vision — "
|
||||
"never send browser screenshots to a separate vision tool."
|
||||
" Screenshots are attached to your context automatically: when the exec output contains a "
|
||||
"capture_screenshot() path, the image arrives with this tool's result and you inspect it directly "
|
||||
"with your own vision — never send browser screenshots to a separate vision tool."
|
||||
)
|
||||
|
||||
_HEADER_TEXT_ONLY = (
|
||||
" Your model cannot view images, so work text-first: page_info() for "
|
||||
"state, js() for reading/extracting DOM text, fill_input(selector, "
|
||||
"text) for inputs, and js(\"document.querySelector('…').click()\") for "
|
||||
"clicks — skip the screenshot-driven workflow described below."
|
||||
" Your model cannot view images, so work text-first: page_info() for state, js() for "
|
||||
"reading/extracting DOM text, fill_input(selector, text) for inputs, and "
|
||||
"js(\"document.querySelector('…').click()\") for clicks — skip the screenshot-driven workflow described below."
|
||||
)
|
||||
|
||||
# Appended when the local engine is Lightpanda (browser.engine): no graphical
|
||||
# renderer, and one CDP connection holds one page — a second
|
||||
# Target.createTarget fails with TargetAlreadyLoaded (drop the new_tab()
|
||||
# sentence once lightpanda-io/browser#1962 lands).
|
||||
# Appended when the local engine is Lightpanda: no graphical renderer, and one CDP
|
||||
# connection holds one page — a second Target.createTarget fails with
|
||||
# TargetAlreadyLoaded (drop the new_tab() sentence once lightpanda-io/browser#1962 lands).
|
||||
_HEADER_LIGHTPANDA = (
|
||||
" The local engine is Lightpanda (no graphical renderer, one page per "
|
||||
"session): capture_screenshot() is unavailable, so work text-first; "
|
||||
"navigate with new_tab(url) exactly once, then goto_url(url) for every "
|
||||
"later navigation — a second new_tab() fails with TargetAlreadyLoaded."
|
||||
" The local engine is Lightpanda (no graphical renderer, one page per session): capture_screenshot() "
|
||||
"is unavailable, so work text-first; navigate with new_tab(url) exactly once, then goto_url(url) for "
|
||||
"every later navigation — a second new_tab() fails with TargetAlreadyLoaded."
|
||||
)
|
||||
|
||||
# Pinned quick-reference for the CLI's pre-imported helpers, replacing the live
|
||||
# ``browser-use skill`` fetch (which would ship uncontrolled third-party text into
|
||||
# every schema: version drift, supply-chain exposure, byte-unstable prompt). A/B
|
||||
# benchmarked: header-only matched the full skill dump at ~equal tokens.
|
||||
# ``browser-use skill`` fetch (uncontrolled third-party text in every schema: version
|
||||
# drift, supply-chain exposure, byte-unstable prompt). A/B benchmarked ~equal.
|
||||
_HELPERS_DIGEST = (
|
||||
"\n\nHELPERS (pre-imported): new_tab(url) opens/navigates (use for the "
|
||||
"FIRST navigation), goto_url(url) navigates the current tab, "
|
||||
"wait_for_load() after navigation, page_info() summarizes the current "
|
||||
"page state, js(expr) evaluates a JS expression and returns its value "
|
||||
"(js('document.title'); wrap function bodies as js('(() => {...})()') — "
|
||||
"a bare '() => {...}' returns the function itself, uncalled), "
|
||||
"fill_input(selector, text) types into inputs, click_at_xy(x, y) clicks "
|
||||
"viewport coordinates, capture_screenshot() saves and prints a "
|
||||
"screenshot path, cdp('Domain.method', **kwargs) is raw CDP — "
|
||||
"cdp('Accessibility.getFullAXTree')['nodes'] lists every element's "
|
||||
"role/name/backendDOMNodeId (filter in Python before printing; it is "
|
||||
"thousands of nodes), then cdp('DOM.getBoxModel', backendNodeId=n) gives "
|
||||
"click coordinates. ensure_real_tab() recovers from a stale/internal "
|
||||
"tab. Login walls: stop and ask the user; never guess credentials."
|
||||
"\n\nHELPERS (pre-imported): new_tab(url) opens/navigates (use for the FIRST navigation), goto_url(url) "
|
||||
"navigates the current tab, wait_for_load() after navigation, page_info() summarizes the current page "
|
||||
"state, js(expr) evaluates a JS expression and returns its value (js('document.title'); wrap function "
|
||||
"bodies as js('(() => {...})()') — a bare '() => {...}' returns the function itself, uncalled), "
|
||||
"fill_input(selector, text) types into inputs, click_at_xy(x, y) clicks viewport coordinates, "
|
||||
"capture_screenshot() saves and prints a screenshot path, cdp('Domain.method', **kwargs) is raw CDP — "
|
||||
"cdp('Accessibility.getFullAXTree')['nodes'] lists every element's role/name/backendDOMNodeId (filter "
|
||||
"in Python before printing; it is thousands of nodes), then cdp('DOM.getBoxModel', backendNodeId=n) "
|
||||
"gives click coordinates. ensure_real_tab() recovers from a stale/internal tab. Login walls: stop and "
|
||||
"ask the user; never guess credentials."
|
||||
)
|
||||
|
||||
# NOTE: browser_exec is additionally gated at tool-definition time — sessions whose
|
||||
# toolsets lack ``terminal`` never see it (model_tools._compute_tool_definitions).
|
||||
# The check_fn below only answers "is Browser Use mode configured"; surface policy
|
||||
# lives with the session, not in the process-wide TTL-cached check_fn.
|
||||
|
||||
|
||||
def _description_header() -> str:
|
||||
"""Header tailored to whether the active model can see images natively"""
|
||||
if _lightpanda_engine_in_use(): # no screenshots at all, whatever the model can see
|
||||
if _lazy_call("tools.browser_tool", "lightpanda_engine_status", (False, ""),
|
||||
"lightpanda engine status unavailable")[0]: # no screenshots, whatever the model sees
|
||||
return _HEADER_BASE + _HEADER_TEXT_ONLY + _HEADER_LIGHTPANDA
|
||||
if _lazy_call("tools.vision_tools", "_should_use_native_vision_fast_path", False, ""):
|
||||
return _HEADER_BASE + _HEADER_VISION
|
||||
@@ -738,19 +644,17 @@ def _description_header() -> str:
|
||||
|
||||
def _dynamic_schema_overrides() -> dict:
|
||||
overrides: dict = {"description": _description_header() + _HELPERS_DIGEST}
|
||||
# ``local`` exists ONLY when the user consented to real-profile browsing —
|
||||
# everyone else's schema carries zero extra surface. The caller memoizes on
|
||||
# config.yaml mtime, so toggling consent applies next session, not mid-chat.
|
||||
# ``local`` exists ONLY when the user consented to real-profile browsing — everyone
|
||||
# else's schema carries zero extra surface. The caller memoizes on config.yaml mtime,
|
||||
# so toggling consent applies next session, not mid-chat.
|
||||
if _real_profile_consented():
|
||||
props = dict(BROWSER_EXEC_SCHEMA["parameters"]["properties"])
|
||||
props["local"] = {
|
||||
"type": "boolean",
|
||||
"description": ("Drive the user's own local browser (a Hermes-managed copy of "
|
||||
"their real default-Chromium profile, logins/cookies included) "
|
||||
"instead of the configured cloud browser backend. Use when the "
|
||||
"user asks to act as themselves — their accounts, their "
|
||||
"sessions. No-op when the backend is already local. Default false."),
|
||||
"default": False,
|
||||
"type": "boolean", "default": False,
|
||||
"description": ("Drive the user's own local browser (a Hermes-managed copy of their real "
|
||||
"default-Chromium profile, logins/cookies included) instead of the configured "
|
||||
"cloud browser backend. Use when the user asks to act as themselves — their "
|
||||
"accounts, their sessions. No-op when the backend is already local. Default false."),
|
||||
}
|
||||
overrides["parameters"] = {**BROWSER_EXEC_SCHEMA["parameters"], "properties": props}
|
||||
return overrides
|
||||
@@ -758,38 +662,25 @@ def _dynamic_schema_overrides() -> dict:
|
||||
|
||||
BROWSER_EXEC_SCHEMA = {
|
||||
"name": "browser_exec",
|
||||
# Static fallback, used only when the CLI (and uvx) is unavailable
|
||||
"description": (
|
||||
_HEADER_BASE
|
||||
+ _HELPERS_DIGEST
|
||||
+ "\n\n(The browser-use CLI is not installed yet. Install it with "
|
||||
"`uv tool install browser-use`.)"
|
||||
),
|
||||
# Static fallback description, used only when the CLI (and uvx) is unavailable
|
||||
"description": (_HEADER_BASE + _HELPERS_DIGEST
|
||||
+ "\n\n(The browser-use CLI is not installed yet. Install it with `uv tool install browser-use`.)"),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string",
|
||||
"description": "Python code to execute using the pre-imported browser helpers. Use print(...) for any data you need back.",
|
||||
},
|
||||
"session": {
|
||||
"type": "string",
|
||||
"description": "Named isolated browser session — its own daemon and (on cloud backends) own browser, so concurrent tasks don't share tabs. Reuse the same name on every related call; omit for the shared default session.",
|
||||
},
|
||||
"timeout_s": {
|
||||
"type": "integer",
|
||||
"description": f"Max seconds to wait for the code to finish (default {_DEFAULT_TIMEOUT_S}, max {_MAX_TIMEOUT_S}).",
|
||||
"default": _DEFAULT_TIMEOUT_S,
|
||||
},
|
||||
"code": {"type": "string", "description": "Python code to execute using the pre-imported browser helpers. Use print(...) for any data you need back."},
|
||||
"session": {"type": "string", "description": "Named isolated browser session — its own daemon and (on cloud backends) own browser, so concurrent tasks don't share tabs. Reuse the same name on every related call; omit for the shared default session."},
|
||||
"timeout_s": {"type": "integer", "default": _DEFAULT_TIMEOUT_S,
|
||||
"description": f"Max seconds to wait for the code to finish (default {_DEFAULT_TIMEOUT_S}, max {_MAX_TIMEOUT_S})."},
|
||||
},
|
||||
"required": ["code"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry
|
||||
# ---------------------------------------------------------------------------
|
||||
# browser_exec is additionally gated at tool-definition time — sessions whose toolsets
|
||||
# lack ``terminal`` never see it (model_tools._compute_tool_definitions). check_fn only
|
||||
# answers "is Browser Use mode configured"; surface policy lives with the session.
|
||||
from tools.registry import registry
|
||||
|
||||
registry.register(
|
||||
@@ -797,10 +688,8 @@ registry.register(
|
||||
toolset="browser-use",
|
||||
schema=BROWSER_EXEC_SCHEMA,
|
||||
handler=lambda args, **kw: browser_exec(
|
||||
code=args.get("code", ""),
|
||||
session=args.get("session", "") or "",
|
||||
timeout_s=args.get("timeout_s", _DEFAULT_TIMEOUT_S),
|
||||
task_id=kw.get("task_id"),
|
||||
code=args.get("code", ""), session=args.get("session", "") or "",
|
||||
timeout_s=args.get("timeout_s", _DEFAULT_TIMEOUT_S), task_id=kw.get("task_id"),
|
||||
local=bool(args.get("local", False)),
|
||||
),
|
||||
check_fn=is_browser_use_cli_mode,
|
||||
|
||||
Reference in New Issue
Block a user