Camofox VNC one-shot, computer-use aux-vision verdict, tirith binary path, MCP discovery lock path, remote-backend probe text, learned image token costs, auxiliary per-task semaphores and the custom-endpoint /models memo all held one profile's config-derived value for the whole process. The skill-sync debounce Timer ran with empty ContextVars, so a secondary's write pushed as the launch profile (and cancelled its pending push). Each memo is now keyed by hermes_home_key() (or credential fingerprint for the per-key catalog) under an override; the timer is per home and runs its callback inside the scheduling turn's copied context. Unscoped slots are unchanged.
602 lines
28 KiB
Python
602 lines
28 KiB
Python
"""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 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
|
|
|
|
import base64
|
|
import ipaddress
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import threading
|
|
import uuid
|
|
from typing import Any, Callable, Dict, Optional
|
|
from urllib.parse import SplitResult, urlsplit, urlunsplit
|
|
|
|
import requests
|
|
|
|
from agent.secret_scope import get_secret
|
|
from hermes_cli.config import cfg_get, load_config, read_raw_config
|
|
from hermes_constants import get_hermes_home_override, hermes_home_key
|
|
from tools.browser_camofox_state import get_camofox_identity
|
|
from tools.registry import tool_error
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ---- Configuration ----
|
|
|
|
_DEFAULT_TIMEOUT = 30 # fallback when config is unreadable
|
|
_NO_SESSION_ERROR = "No browser session. Call browser_navigate first."
|
|
_vnc_url: Optional[str] = None # cached from /health response
|
|
_vnc_url_checked = False # only probe once per process
|
|
# Routed profiles (multiplexed gateway) each point CAMOFOX_URL at their own server, so the one-shot
|
|
# slot above would hand the launch profile's VNC address to every other profile: memo per server URL.
|
|
_vnc_url_by_camofox_url: Dict[str, Optional[str]] = {}
|
|
# browser.command_timeout, resolved lazily like browser_tool; keyed by profile home because the
|
|
# multiplexed gateway serves every profile from one process.
|
|
_cached_cmd_timeout: Optional[Dict[str, int]] = None
|
|
_cmd_timeout_resolved = False
|
|
|
|
|
|
def _get_command_timeout() -> int:
|
|
"""``browser.command_timeout`` (floor 5s, default 30s), cached per profile home after first read."""
|
|
global _cached_cmd_timeout, _cmd_timeout_resolved
|
|
home = hermes_home_key()
|
|
if _cached_cmd_timeout is None:
|
|
_cached_cmd_timeout = {}
|
|
if _cmd_timeout_resolved and home in _cached_cmd_timeout:
|
|
return _cached_cmd_timeout[home]
|
|
timeout = _DEFAULT_TIMEOUT
|
|
try:
|
|
val = cfg_get(read_raw_config(), "browser", "command_timeout")
|
|
if val is not None:
|
|
timeout = max(int(val), 5)
|
|
except Exception as exc:
|
|
logger.debug("Could not read browser.command_timeout: %s", exc)
|
|
_cached_cmd_timeout[home] = timeout
|
|
_cmd_timeout_resolved = True
|
|
return timeout
|
|
|
|
|
|
def _auth_headers() -> Dict[str, str]:
|
|
"""Return Authorization header when CAMOFOX_API_KEY is set."""
|
|
key = (get_secret("CAMOFOX_API_KEY", "") or "").strip()
|
|
return {"Authorization": f"Bearer {key}"} if key else {}
|
|
|
|
|
|
def get_camofox_url() -> str:
|
|
"""Return the configured Camofox server URL, or empty string."""
|
|
return (get_secret("CAMOFOX_URL", "") or "").rstrip("/")
|
|
|
|
|
|
def _config_cdp_url() -> str:
|
|
"""Persistent ``browser.cdp_url`` from config.yaml, or "" (read here, not via
|
|
``browser_tool_cdp._get_cdp_override`` — circular import)."""
|
|
try:
|
|
from hermes_cli.config import read_raw_config # late-bound: tests patch the source module
|
|
browser_cfg = read_raw_config().get("browser", {})
|
|
if isinstance(browser_cfg, dict):
|
|
return str(browser_cfg.get("cdp_url", "") or "").strip()
|
|
except Exception:
|
|
pass
|
|
return ""
|
|
|
|
|
|
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``, same precedence as ``browser_tool_cdp._get_cdp_override()``) wins.
|
|
"""
|
|
if os.getenv("BROWSER_CDP_URL", "").strip() or _config_cdp_url():
|
|
return False
|
|
try:
|
|
from tools.tool_backend_helpers import read_selection
|
|
selected = read_selection("browser")
|
|
except Exception: # pragma: no cover — helpers are in-repo
|
|
selected = None
|
|
if selected is not None:
|
|
return selected == "camofox"
|
|
return bool(get_camofox_url())
|
|
|
|
|
|
def _vnc_url_from_health(url: str, resp: Any) -> Optional[str]:
|
|
try:
|
|
vnc_port = resp.json().get("vncPort")
|
|
if isinstance(vnc_port, int) and 1 <= vnc_port <= 65535:
|
|
return f"http://{urlsplit(url).hostname or 'localhost'}:{vnc_port}"
|
|
except (ValueError, KeyError):
|
|
pass
|
|
return None
|
|
|
|
|
|
def check_camofox_available() -> bool:
|
|
"""Verify the Camofox server is reachable (and cache its VNC URL once)."""
|
|
global _vnc_url, _vnc_url_checked
|
|
url = get_camofox_url()
|
|
if not url:
|
|
return False
|
|
try:
|
|
resp = requests.get(f"{url}/health", timeout=5)
|
|
except Exception:
|
|
return False
|
|
if resp.status_code == 200:
|
|
if get_hermes_home_override() is not None:
|
|
if url not in _vnc_url_by_camofox_url:
|
|
_vnc_url_by_camofox_url[url] = _vnc_url_from_health(url, resp)
|
|
elif not _vnc_url_checked:
|
|
_vnc_url = _vnc_url_from_health(url, resp) or _vnc_url
|
|
_vnc_url_checked = True
|
|
return resp.status_code == 200
|
|
|
|
|
|
def get_vnc_url() -> Optional[str]:
|
|
"""Return the VNC URL if the Camofox server exposes one, or None."""
|
|
if get_hermes_home_override() is not None:
|
|
url = get_camofox_url()
|
|
if url not in _vnc_url_by_camofox_url:
|
|
check_camofox_available()
|
|
return _vnc_url_by_camofox_url.get(url)
|
|
if not _vnc_url_checked:
|
|
check_camofox_available()
|
|
return _vnc_url
|
|
|
|
|
|
def _get_camofox_config() -> Dict[str, Any]:
|
|
"""Return the ``browser.camofox`` config block, or an empty dict."""
|
|
try:
|
|
camofox_cfg = load_config().get("browser", {}).get("camofox", {})
|
|
except Exception as exc:
|
|
logger.warning("camofox config check failed, defaulting to disabled: %s", exc)
|
|
return {}
|
|
return camofox_cfg if isinstance(camofox_cfg, dict) else {}
|
|
|
|
|
|
def _managed_persistence_enabled(camofox_cfg: Optional[Dict[str, Any]] = None) -> bool:
|
|
"""``browser.camofox.managed_persistence``: stable profile-scoped userId vs random per session."""
|
|
return bool((_get_camofox_config() if camofox_cfg is None else camofox_cfg).get("managed_persistence"))
|
|
|
|
|
|
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 = _env_or_cfg("CAMOFOX_USER_ID", camofox_cfg, "user_id", secret=True)
|
|
if not user_id:
|
|
return None
|
|
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]}"}
|
|
|
|
|
|
def _flag(env_name: str, camofox_cfg: Dict[str, Any], cfg_key: str) -> bool:
|
|
"""Boolean toggle: env var wins when set to a valid value, else config key."""
|
|
raw = os.getenv(env_name, "").strip().lower()
|
|
if raw in {"1", "true", "yes", "on"}:
|
|
return True
|
|
if raw in {"0", "false", "no", "off"}:
|
|
return False
|
|
if raw:
|
|
logger.debug("Ignoring invalid boolean env %s=%r", env_name, raw)
|
|
return bool(camofox_cfg.get(cfg_key))
|
|
|
|
|
|
def _is_loopback_hostname(hostname: Optional[str]) -> bool:
|
|
if not hostname:
|
|
return False
|
|
host = hostname.strip().strip("[]").lower()
|
|
if host in {"localhost", "localhost.localdomain"}:
|
|
return True
|
|
try:
|
|
return ipaddress.ip_address(host).is_loopback
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
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_cfg = _get_camofox_config()
|
|
if not _flag("CAMOFOX_REWRITE_LOOPBACK_URLS", camofox_cfg, "rewrite_loopback_urls"):
|
|
return url, None
|
|
try:
|
|
parsed = urlsplit(url)
|
|
except ValueError:
|
|
return url, None
|
|
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
|
|
userinfo = (parsed.username + (f":{parsed.password}" if parsed.password else "") + "@") if parsed.username else ""
|
|
host_part = f"[{alias}]" if ":" in alias and not alias.startswith("[") else alias
|
|
port_part = f":{parsed.port}" if parsed.port else ""
|
|
rewritten = urlunsplit(
|
|
SplitResult(parsed.scheme, f"{userinfo}{host_part}{port_part}", parsed.path, parsed.query, parsed.fragment))
|
|
return rewritten, {"from": parsed.hostname or "", "to": alias, "original_url": url, "rewritten_url": rewritten}
|
|
|
|
|
|
# ---- Session management ----
|
|
_sessions: Dict[str, Dict[str, Any]] = {} # task_id -> {"user_id": str, "tab_id": str|None, ...}
|
|
_sessions_lock = threading.Lock()
|
|
|
|
|
|
def _adopt_existing_tab(session: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Rehydrate tab_id from an already-open managed tab: gateway restarts empty the
|
|
in-memory cache while Camofox still holds the integration-owned tab."""
|
|
if session.get("tab_id") or not session.get("adopt_existing_tab") or not get_camofox_url():
|
|
return session
|
|
try:
|
|
tabs = _get("/tabs", params=_user_params(session), timeout=5).get("tabs", [])
|
|
except Exception as exc:
|
|
logger.debug("Camofox tab adoption failed for %s: %s", session.get("user_id"), exc)
|
|
return session
|
|
dict_tabs = [tab for tab in tabs if isinstance(tab, dict)] if isinstance(tabs, list) else []
|
|
candidates = [tab for tab in dict_tabs if tab.get("listItemId") == session.get("session_key")] or dict_tabs
|
|
tab_id = candidates[-1].get("tabId") if candidates else None
|
|
if isinstance(tab_id, str) and tab_id:
|
|
session["tab_id"] = tab_id
|
|
logger.debug("Adopted existing Camofox tab %s for %s", tab_id, session.get("user_id"))
|
|
return session
|
|
|
|
|
|
def _get_session(task_id: Optional[str]) -> Dict[str, Any]:
|
|
"""Get or create the task's session. Identity precedence: external override
|
|
(CAMOFOX_USER_ID / config) → profile-scoped identity when managed persistence
|
|
is on → random ephemeral userId."""
|
|
task_id = task_id or "default"
|
|
with _sessions_lock:
|
|
if task_id in _sessions:
|
|
return _adopt_existing_tab(_sessions[task_id])
|
|
camofox_cfg = _get_camofox_config()
|
|
identity = _camofox_identity_override(task_id, camofox_cfg)
|
|
if identity is None and _managed_persistence_enabled(camofox_cfg):
|
|
identity = get_camofox_identity(task_id)
|
|
if identity is None:
|
|
identity = {"user_id": f"hermes_{uuid.uuid4().hex[:10]}", "session_key": f"task_{task_id[:16]}"}
|
|
managed, adopt = False, False
|
|
else:
|
|
managed, adopt = True, _flag("CAMOFOX_ADOPT_EXISTING_TAB", camofox_cfg, "adopt_existing_tab")
|
|
session = {"user_id": identity["user_id"], "tab_id": None, "session_key": identity["session_key"],
|
|
"managed": managed, "adopt_existing_tab": adopt}
|
|
_sessions[task_id] = session
|
|
return _adopt_existing_tab(session)
|
|
|
|
|
|
def _ensure_tab(task_id: Optional[str], url: str = "about:blank") -> Dict[str, Any]:
|
|
"""Ensure a tab exists for the session, creating one if needed."""
|
|
session = _get_session(task_id)
|
|
if not session["tab_id"]:
|
|
data = _post("/tabs", {"userId": session["user_id"], "listItemId": session["session_key"], "url": url})
|
|
session["tab_id"] = data.get("tabId")
|
|
return session
|
|
|
|
|
|
def _drop_session(task_id: Optional[str]) -> Optional[Dict[str, Any]]:
|
|
"""Remove and return session info."""
|
|
with _sessions_lock:
|
|
return _sessions.pop(task_id or "default", None)
|
|
|
|
|
|
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`."""
|
|
camofox_cfg = _get_camofox_config()
|
|
if _managed_persistence_enabled(camofox_cfg) or _camofox_identity_override(task_id, camofox_cfg):
|
|
_drop_session(task_id)
|
|
logger.debug("Camofox soft cleanup for task %s (managed persistence)", task_id)
|
|
return True
|
|
return False
|
|
|
|
|
|
# ---- HTTP helpers ----
|
|
def _request(method: str, path: str, timeout: Optional[int] = None, **kwargs: Any) -> requests.Response:
|
|
"""Issue an authenticated request to camofox and return the raised-for-status response."""
|
|
resp = getattr(requests, method)(f"{get_camofox_url()}{path}", headers=_auth_headers(),
|
|
timeout=_get_command_timeout() if timeout is None else timeout, **kwargs)
|
|
resp.raise_for_status()
|
|
return resp
|
|
|
|
|
|
def _post(path: str, body: dict, timeout: Optional[int] = None) -> dict:
|
|
return _request("post", path, timeout, json=body).json()
|
|
|
|
|
|
def _get(path: str, params: dict = None, timeout: Optional[int] = None) -> dict:
|
|
return _request("get", path, timeout, params=params).json()
|
|
|
|
|
|
def _get_raw(path: str, params: dict = None, timeout: Optional[int] = None) -> requests.Response:
|
|
"""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:
|
|
return _request("delete", path, timeout, json=body).json()
|
|
|
|
|
|
# ---- Tool implementations ----
|
|
def _tab_path(session: Dict[str, Any], suffix: str) -> str:
|
|
return f"/tabs/{session['tab_id']}/{suffix}"
|
|
|
|
|
|
def _user_params(session: Dict[str, Any]) -> Dict[str, str]:
|
|
return {"userId": session["user_id"]}
|
|
|
|
|
|
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 = []
|
|
lines = snapshot.split("\n")
|
|
for i, line in enumerate(lines):
|
|
stripped = line.strip()
|
|
if not stripped.startswith(("- img ", "img ")):
|
|
continue
|
|
alt_match = re.search(r'img\s+"([^"]*)"', stripped)
|
|
url_match = re.search(r'/url:\s*(\S+)', lines[i + 1].strip()) if i + 1 < len(lines) else None
|
|
alt, src = (alt_match.group(1) if alt_match else ""), (url_match.group(1) if url_match else "")
|
|
if alt or src:
|
|
images.append({"src": src, "alt": alt})
|
|
return images
|
|
|
|
|
|
def _fetch_snapshot(session: Dict[str, Any]) -> tuple[str, int]:
|
|
"""``(snapshot_text, refs_count)`` truncated like the main browser tool (line boundaries,
|
|
full tree stored to cache/web, read_file pointer appended). Lazy import: ``browser_tool``
|
|
imports this module."""
|
|
from tools.browser_tool_snapshot import _truncate_snapshot
|
|
from tools.browser_tool import get_browser_snapshot_threshold
|
|
data = _snapshot_data(session)
|
|
snapshot, threshold = data.get("snapshot", ""), get_browser_snapshot_threshold()
|
|
if len(snapshot) > threshold:
|
|
snapshot = _truncate_snapshot(snapshot, max_chars=threshold)
|
|
return snapshot, data.get("refsCount", 0)
|
|
|
|
|
|
def _navigate_tab(task_id: Optional[str], browser_url: str) -> tuple[Dict[str, Any], dict]:
|
|
"""Open ``browser_url`` in the task's tab (creating it if missing) and return
|
|
``(session, navigate_response)``. A 404 on the existing tab means the server
|
|
garbage-collected it — recreate instead of failing."""
|
|
session = _get_session(task_id)
|
|
if session["tab_id"]:
|
|
try:
|
|
data = _post(_tab_path(session, "navigate"), {"userId": session["user_id"], "url": browser_url}, timeout=60)
|
|
return session, data
|
|
except requests.HTTPError as e:
|
|
if e.response is None or e.response.status_code != 404:
|
|
raise
|
|
logger.warning("Camofox tab %s returned 404 — tab was garbage collected. Creating a fresh tab.",
|
|
session["tab_id"])
|
|
session["tab_id"] = None
|
|
return _ensure_tab(task_id, browser_url), {"ok": True, "url": browser_url}
|
|
|
|
|
|
def camofox_navigate(url: str, task_id: Optional[str] = None) -> str:
|
|
"""Navigate to a URL via Camofox."""
|
|
try:
|
|
browser_url, rewrite_info = _rewrite_loopback_url_for_camofox(url)
|
|
session, data = _navigate_tab(task_id, browser_url)
|
|
result = {"success": True, "url": data.get("url", browser_url), "title": data.get("title", "")}
|
|
if rewrite_info:
|
|
result["requested_url"], result["url_rewrite"] = url, rewrite_info
|
|
result["warning"] = ("Rewrote loopback URL for Docker-hosted Camofox: "
|
|
f"{rewrite_info['from']} -> {rewrite_info['to']}")
|
|
vnc = get_vnc_url()
|
|
if vnc:
|
|
result["vnc_url"] = vnc
|
|
result["vnc_hint"] = ("Browser is visible via VNC. "
|
|
"Share this link with the user so they can watch the browser live.")
|
|
try: # Auto-take a compact snapshot so the model can act immediately.
|
|
result["snapshot"], result["element_count"] = _fetch_snapshot(session)
|
|
except Exception:
|
|
pass # Navigation succeeded; snapshot is a bonus
|
|
return json.dumps(result)
|
|
except requests.HTTPError as e:
|
|
return tool_error(f"Navigation failed: {e}", success=False)
|
|
except requests.ConnectionError:
|
|
return json.dumps({"success": False, "error": (
|
|
f"Cannot connect to Camofox at {get_camofox_url()}. "
|
|
"Is the server running? Start with: npm start (in camofox-browser dir) "
|
|
"or: docker run -p 9377:9377 -e CAMOFOX_PORT=9377 jo-inc/camofox-browser")})
|
|
except Exception as e:
|
|
return tool_error(str(e), success=False)
|
|
|
|
|
|
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. Lazy import (cycle).
|
|
"""
|
|
from tools.browser_tool_eval_policy import _camofox_current_page_private_url, _eval_ssrf_guard_active
|
|
if not _eval_ssrf_guard_active(task_id or "default"):
|
|
return None
|
|
blocked_url = _camofox_current_page_private_url(session["tab_id"], session["user_id"])
|
|
if not blocked_url:
|
|
return None
|
|
return json.dumps({"success": False, "error": (
|
|
"Blocked: page URL targets a private or internal address "
|
|
f"({blocked_url}). Refusing to {action} on this page in this browser mode.")}, ensure_ascii=False)
|
|
|
|
|
|
def _require_tab(task_id: Optional[str], action: Optional[str] = None) -> tuple[Dict[str, Any], Optional[str]]:
|
|
"""Return ``(session, error_payload)``: error when no tab exists or, if ``action`` given, the page is private."""
|
|
session = _get_session(task_id)
|
|
if not session["tab_id"]:
|
|
return session, tool_error(_NO_SESSION_ERROR, success=False)
|
|
return session, (_camofox_private_page_block(session, task_id, action) if action is not None else None)
|
|
|
|
|
|
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, guard_action)
|
|
if blocked:
|
|
return blocked
|
|
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: 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:
|
|
"""Click an element by ref via Camofox."""
|
|
clean_ref = ref.lstrip("@") # our tool convention prefixes refs with @
|
|
return _tab_action(task_id, "click", "click", {"ref": clean_ref},
|
|
lambda data: {"success": True, "clicked": clean_ref, "url": data.get("url", "")})
|
|
|
|
|
|
def camofox_type(ref: str, text: str, task_id: Optional[str] = None) -> str:
|
|
"""Type text into an element by ref via Camofox."""
|
|
try:
|
|
session, blocked = _require_tab(task_id, "type")
|
|
if blocked:
|
|
return blocked
|
|
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.
|
|
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))
|
|
except Exception as e:
|
|
from agent.display import redact_browser_typed_text_for_display
|
|
return tool_error(redact_browser_typed_text_for_display(str(e), text), success=False)
|
|
|
|
|
|
def camofox_scroll(direction: str, task_id: Optional[str] = None) -> str:
|
|
"""Scroll the page via Camofox."""
|
|
return _tab_action(task_id, None, "scroll", {"direction": direction},
|
|
lambda data: {"success": True, "scrolled": direction})
|
|
|
|
|
|
def camofox_back(task_id: Optional[str] = None) -> str:
|
|
"""Navigate back via Camofox."""
|
|
return _tab_action(task_id, None, "back", {}, lambda data: {"success": True, "url": data.get("url", "")})
|
|
|
|
|
|
def camofox_press(key: str, task_id: Optional[str] = None) -> str:
|
|
"""Press a keyboard key via Camofox."""
|
|
return _tab_action(task_id, "press", "press", {"key": key}, lambda data: {"success": True, "pressed": key})
|
|
|
|
|
|
def camofox_close(task_id: Optional[str] = None) -> str:
|
|
"""Close the browser session via Camofox."""
|
|
try:
|
|
session = _drop_session(task_id)
|
|
if session:
|
|
_delete(f"/sessions/{session['user_id']}")
|
|
return json.dumps({"success": True, "closed": True})
|
|
except Exception as e:
|
|
return json.dumps({"success": True, "closed": True, "warning": str(e)})
|
|
|
|
|
|
def camofox_get_images(task_id: Optional[str] = None) -> str:
|
|
"""Get images on the current page via Camofox (parsed from the snapshot)."""
|
|
def body(session):
|
|
images = _parse_snapshot_images(_snapshot_data(session).get("snapshot", ""))
|
|
return json.dumps({"success": True, "images": images, "count": len(images)})
|
|
return _with_tab(task_id, "extract page images", body)
|
|
|
|
|
|
def _vision_llm_settings() -> tuple[float, float]:
|
|
"""``auxiliary.vision`` ``(timeout, temperature)``; defaults 120s / 0.1 on any config error."""
|
|
try:
|
|
cfg = cfg_get(load_config(), "auxiliary", "vision", default={})
|
|
return float(cfg.get("timeout", 120)), float(cfg.get("temperature", 0.1))
|
|
except Exception:
|
|
return 120.0, 0.1
|
|
|
|
|
|
def _save_screenshot(content: bytes) -> str:
|
|
"""Write PNG bytes under ``$HERMES_HOME/browser_screenshots`` and return the path."""
|
|
from hermes_constants import get_hermes_home
|
|
screenshots_dir = get_hermes_home() / "browser_screenshots"
|
|
screenshots_dir.mkdir(parents=True, exist_ok=True)
|
|
screenshot_path = str(screenshots_dir / f"browser_screenshot_{uuid.uuid4().hex[:8]}.png")
|
|
with open(screenshot_path, "wb") as f:
|
|
f.write(content)
|
|
return screenshot_path
|
|
|
|
|
|
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."""
|
|
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")
|
|
annotation_context = ""
|
|
if annotate:
|
|
try:
|
|
snapshot = _snapshot_data(session).get("snapshot", "")
|
|
annotation_context = f"\n\nAccessibility tree (element refs for interaction):\n{snapshot[:3000]}"
|
|
except Exception:
|
|
pass
|
|
# The screenshot itself cannot be redacted, but the text-based accessibility snippet
|
|
# sent alongside it must not leak secret values.
|
|
from agent.redact import redact_sensitive_text
|
|
from agent.auxiliary_client import call_llm
|
|
vision_prompt = f"Analyze this browser screenshot and answer: {question}{redact_sensitive_text(annotation_context)}"
|
|
timeout, temperature = _vision_llm_settings()
|
|
response = call_llm(
|
|
messages=[{"role": "user", "content": [
|
|
{"type": "text", "text": vision_prompt},
|
|
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{img_b64}"}}]}],
|
|
task="vision", temperature=temperature, timeout=timeout)
|
|
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})
|
|
return _with_tab(task_id, "capture a screenshot", body)
|
|
|
|
|
|
def camofox_console(clear: bool = False, task_id: Optional[str] = None) -> str:
|
|
"""Console output is not exposed by the Camofox REST API; return an empty result with a note."""
|
|
return json.dumps({
|
|
"success": True, "console_messages": [], "js_errors": [], "total_messages": 0, "total_errors": 0,
|
|
"note": "Console log capture is not available with the Camofox backend. "
|
|
"Use browser_snapshot or browser_vision to inspect page state."})
|