refactor(tools): simplify browser backend modules (browser_use_cli, camofox, cdp, lightpanda, real-profile, install, cloud)

This commit is contained in:
Teknium
2026-09-02 22:39:53 -07:00
parent 113f04616b
commit 5ea8a63aba
11 changed files with 849 additions and 1811 deletions
+70 -94
View File
@@ -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:
+6 -16
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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")
+64 -156
View File
@@ -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
View File
@@ -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
+59 -125
View File
@@ -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
View File
@@ -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
View File
@@ -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,