From 70744add158f4067a62f57e99bc16a87ced94037 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 1 Apr 2026 04:18:50 -0700 Subject: [PATCH] feat(browser): add persistent Camofox sessions and VNC URL discovery (salvage #4400) (#4419) Adds two Camofox features: 1. Persistent browser sessions: new `browser.camofox.managed_persistence` config option. When enabled, Hermes sends a deterministic profile-scoped userId to Camofox so the server maps it to a persistent browser profile directory. Cookies, logins, and browser state survive across restarts. Default remains ephemeral (random userId per session). 2. VNC URL discovery: Camofox /health endpoint returns vncPort when running in headed mode. Hermes constructs the VNC URL and includes it in navigate responses so the agent can share it with users. Also fixes camofox_vision bug where call_llm response object was passed directly to json.dumps instead of extracting .choices[0].message.content. Changes from original PR: - Removed browser_evaluate tool (separate feature, needs own PR) - Removed snapshot truncation limit change (unrelated) - Config.yaml only for managed_persistence (no env var, no version bump) - Rewrote tests to use config mock instead of env var - Reverted package-lock.json churn Co-authored-by: analista --- hermes_cli/config.py | 9 +- .../tools/test_browser_camofox_persistence.py | 242 ++++++++++++++++++ tests/tools/test_browser_camofox_state.py | 66 +++++ tools/browser_camofox.py | 83 +++++- tools/browser_camofox_state.py | 47 ++++ .../docs/reference/environment-variables.md | 1 + website/docs/user-guide/configuration.md | 2 + website/docs/user-guide/features/browser.md | 45 ++++ 8 files changed, 485 insertions(+), 10 deletions(-) create mode 100644 tests/tools/test_browser_camofox_persistence.py create mode 100644 tests/tools/test_browser_camofox_state.py create mode 100644 tools/browser_camofox_state.py diff --git a/hermes_cli/config.py b/hermes_cli/config.py index ee1ae2117..682e8fe1f 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -247,6 +247,13 @@ DEFAULT_CONFIG = { "command_timeout": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.) "record_sessions": False, # Auto-record browser sessions as WebM videos "allow_private_urls": False, # Allow navigating to private/internal IPs (localhost, 192.168.x.x, etc.) + "camofox": { + # When true, Hermes sends a stable profile-scoped userId to Camofox + # so the server can map it to a persistent browser profile directory. + # Requires Camofox server to be configured with CAMOFOX_PROFILE_DIR. + # When false (default), each session gets a random userId (ephemeral). + "managed_persistence": False, + }, }, # Filesystem checkpoints — automatic snapshots before destructive file ops. @@ -510,7 +517,7 @@ DEFAULT_CONFIG = { }, # Config schema version - bump this when adding new required fields - "_config_version": 11, + "_config_version": 10, } # ============================================================================= diff --git a/tests/tools/test_browser_camofox_persistence.py b/tests/tools/test_browser_camofox_persistence.py new file mode 100644 index 000000000..0fa5723c6 --- /dev/null +++ b/tests/tools/test_browser_camofox_persistence.py @@ -0,0 +1,242 @@ +"""Persistence tests for the Camofox browser backend. + +Tests that managed persistence uses stable identity while default mode +uses random identity. The actual browser profile persistence is handled +by the Camofox server (when CAMOFOX_PROFILE_DIR is set). +""" + +import json +from unittest.mock import MagicMock, patch + +import pytest + +from tools.browser_camofox import ( + _drop_session, + _get_session, + _managed_persistence_enabled, + camofox_close, + camofox_navigate, + check_camofox_available, + cleanup_all_camofox_sessions, + get_vnc_url, +) +from tools.browser_camofox_state import get_camofox_identity + + +def _mock_response(status=200, json_data=None): + resp = MagicMock() + resp.status_code = status + resp.json.return_value = json_data or {} + resp.raise_for_status = MagicMock() + return resp + + +def _enable_persistence(): + """Return a patch context that enables managed persistence via config.""" + config = {"browser": {"camofox": {"managed_persistence": True}}} + return patch("tools.browser_camofox.load_config", return_value=config) + + +@pytest.fixture(autouse=True) +def _clear_session_state(): + import tools.browser_camofox as mod + yield + with mod._sessions_lock: + mod._sessions.clear() + mod._vnc_url = None + mod._vnc_url_checked = False + + +class TestManagedPersistenceToggle: + def test_disabled_by_default(self): + config = {"browser": {"camofox": {"managed_persistence": False}}} + with patch("tools.browser_camofox.load_config", return_value=config): + assert _managed_persistence_enabled() is False + + def test_enabled_via_config_yaml(self): + config = {"browser": {"camofox": {"managed_persistence": True}}} + with patch("tools.browser_camofox.load_config", return_value=config): + assert _managed_persistence_enabled() is True + + def test_disabled_when_key_missing(self): + config = {"browser": {}} + with patch("tools.browser_camofox.load_config", return_value=config): + assert _managed_persistence_enabled() is False + + def test_disabled_on_config_load_error(self): + with patch("tools.browser_camofox.load_config", side_effect=Exception("fail")): + assert _managed_persistence_enabled() is False + + +class TestEphemeralMode: + """Default behavior: random userId, no persistence.""" + + def test_session_gets_random_user_id(self, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") + + session = _get_session("task-1") + assert session["user_id"].startswith("hermes_") + assert session["managed"] is False + + def test_different_tasks_get_different_user_ids(self, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") + + s1 = _get_session("task-1") + s2 = _get_session("task-2") + assert s1["user_id"] != s2["user_id"] + + def test_session_reuse_within_same_task(self, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") + + s1 = _get_session("task-1") + s2 = _get_session("task-1") + assert s1 is s2 + + +class TestManagedPersistenceMode: + """With managed_persistence: stable userId derived from Hermes profile.""" + + def test_session_gets_stable_user_id(self, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") + + with _enable_persistence(): + session = _get_session("task-1") + expected = get_camofox_identity("task-1") + assert session["user_id"] == expected["user_id"] + assert session["session_key"] == expected["session_key"] + assert session["managed"] is True + + def test_same_user_id_after_session_drop(self, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") + + with _enable_persistence(): + s1 = _get_session("task-1") + uid1 = s1["user_id"] + _drop_session("task-1") + s2 = _get_session("task-1") + assert s2["user_id"] == uid1 + + def test_same_user_id_across_tasks(self, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") + + with _enable_persistence(): + s1 = _get_session("task-a") + s2 = _get_session("task-b") + # Same profile = same userId, different session keys + assert s1["user_id"] == s2["user_id"] + assert s1["session_key"] != s2["session_key"] + + def test_different_profiles_get_different_user_ids(self, tmp_path, monkeypatch): + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") + + with _enable_persistence(): + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "profile-a")) + s1 = _get_session("task-1") + uid_a = s1["user_id"] + _drop_session("task-1") + + monkeypatch.setenv("HERMES_HOME", str(tmp_path / "profile-b")) + s2 = _get_session("task-1") + assert s2["user_id"] != uid_a + + def test_navigate_uses_stable_identity(self, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") + + requests_seen = [] + + def _capture_post(url, json=None, timeout=None): + requests_seen.append(json) + return _mock_response( + json_data={"tabId": "tab-1", "url": "https://example.com"} + ) + + with _enable_persistence(), \ + patch("tools.browser_camofox.requests.post", side_effect=_capture_post): + result = json.loads(camofox_navigate("https://example.com", task_id="task-1")) + + assert result["success"] is True + expected = get_camofox_identity("task-1") + assert requests_seen[0]["userId"] == expected["user_id"] + + def test_navigate_reuses_identity_after_close(self, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") + + requests_seen = [] + + def _capture_post(url, json=None, timeout=None): + requests_seen.append(json) + return _mock_response( + json_data={"tabId": f"tab-{len(requests_seen)}", "url": "https://example.com"} + ) + + with ( + _enable_persistence(), + patch("tools.browser_camofox.requests.post", side_effect=_capture_post), + patch("tools.browser_camofox.requests.delete", return_value=_mock_response()), + ): + first = json.loads(camofox_navigate("https://example.com", task_id="task-1")) + camofox_close("task-1") + second = json.loads(camofox_navigate("https://example.com", task_id="task-1")) + + assert first["success"] is True + assert second["success"] is True + tab_requests = [req for req in requests_seen if "userId" in req] + assert len(tab_requests) == 2 + assert tab_requests[0]["userId"] == tab_requests[1]["userId"] + + +class TestVncUrlDiscovery: + """VNC URL is derived from the Camofox health endpoint.""" + + def test_vnc_url_from_health_port(self, monkeypatch): + monkeypatch.setenv("CAMOFOX_URL", "http://myhost:9377") + health_resp = _mock_response(json_data={"ok": True, "vncPort": 6080}) + with patch("tools.browser_camofox.requests.get", return_value=health_resp): + assert check_camofox_available() is True + assert get_vnc_url() == "http://myhost:6080" + + def test_vnc_url_none_when_headless(self, monkeypatch): + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") + health_resp = _mock_response(json_data={"ok": True}) + with patch("tools.browser_camofox.requests.get", return_value=health_resp): + check_camofox_available() + assert get_vnc_url() is None + + def test_vnc_url_rejects_invalid_port(self, monkeypatch): + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") + health_resp = _mock_response(json_data={"ok": True, "vncPort": "bad"}) + with patch("tools.browser_camofox.requests.get", return_value=health_resp): + check_camofox_available() + assert get_vnc_url() is None + + def test_vnc_url_only_probed_once(self, monkeypatch): + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") + health_resp = _mock_response(json_data={"ok": True, "vncPort": 6080}) + with patch("tools.browser_camofox.requests.get", return_value=health_resp) as mock_get: + check_camofox_available() + check_camofox_available() + # Second call still hits /health for availability but doesn't re-parse vncPort + assert get_vnc_url() == "http://localhost:6080" + + def test_navigate_includes_vnc_hint(self, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377") + import tools.browser_camofox as mod + mod._vnc_url = "http://localhost:6080" + mod._vnc_url_checked = True + + with patch("tools.browser_camofox.requests.post", return_value=_mock_response( + json_data={"tabId": "t1", "url": "https://example.com"} + )): + result = json.loads(camofox_navigate("https://example.com", task_id="vnc-test")) + + assert result["vnc_url"] == "http://localhost:6080" + assert "vnc_hint" in result diff --git a/tests/tools/test_browser_camofox_state.py b/tests/tools/test_browser_camofox_state.py new file mode 100644 index 000000000..cbc90d821 --- /dev/null +++ b/tests/tools/test_browser_camofox_state.py @@ -0,0 +1,66 @@ +"""Tests for Hermes-managed Camofox state helpers.""" + +from unittest.mock import patch + +import pytest + + +def _load_module(): + from tools import browser_camofox_state as state + return state + + +class TestCamofoxStatePaths: + def test_paths_are_profile_scoped(self, tmp_path): + state = _load_module() + with patch.object(state, "get_hermes_home", return_value=tmp_path): + assert state.get_camofox_state_dir() == tmp_path / "browser_auth" / "camofox" + + +class TestCamofoxIdentity: + def test_identity_is_deterministic(self, tmp_path): + state = _load_module() + with patch.object(state, "get_hermes_home", return_value=tmp_path): + first = state.get_camofox_identity("task-1") + second = state.get_camofox_identity("task-1") + assert first == second + + def test_identity_differs_by_task(self, tmp_path): + state = _load_module() + with patch.object(state, "get_hermes_home", return_value=tmp_path): + a = state.get_camofox_identity("task-a") + b = state.get_camofox_identity("task-b") + # Same user (same profile), different session keys + assert a["user_id"] == b["user_id"] + assert a["session_key"] != b["session_key"] + + def test_identity_differs_by_profile(self, tmp_path): + state = _load_module() + with patch.object(state, "get_hermes_home", return_value=tmp_path / "profile-a"): + a = state.get_camofox_identity("task-1") + with patch.object(state, "get_hermes_home", return_value=tmp_path / "profile-b"): + b = state.get_camofox_identity("task-1") + assert a["user_id"] != b["user_id"] + + def test_default_task_id(self, tmp_path): + state = _load_module() + with patch.object(state, "get_hermes_home", return_value=tmp_path): + identity = state.get_camofox_identity() + assert "user_id" in identity + assert "session_key" in identity + assert identity["user_id"].startswith("hermes_") + assert identity["session_key"].startswith("task_") + + +class TestCamofoxConfigDefaults: + def test_default_config_includes_managed_persistence_toggle(self): + from hermes_cli.config import DEFAULT_CONFIG + + browser_cfg = DEFAULT_CONFIG["browser"] + assert browser_cfg["camofox"]["managed_persistence"] is False + + def test_config_version_unchanged(self): + from hermes_cli.config import DEFAULT_CONFIG + + # managed_persistence is auto-merged by _deep_merge, no version bump needed + assert DEFAULT_CONFIG["_config_version"] == 10 diff --git a/tools/browser_camofox.py b/tools/browser_camofox.py index 9b11ef0d0..b3b01c20d 100644 --- a/tools/browser_camofox.py +++ b/tools/browser_camofox.py @@ -34,6 +34,9 @@ from typing import Any, Dict, Optional import requests +from hermes_cli.config import load_config +from tools.browser_camofox_state import get_camofox_identity + logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- @@ -42,6 +45,8 @@ logger = logging.getLogger(__name__) _DEFAULT_TIMEOUT = 30 # seconds per HTTP request _SNAPSHOT_MAX_CHARS = 80_000 # camofox paginates at this limit +_vnc_url: Optional[str] = None # cached from /health response +_vnc_url_checked = False # only probe once per process def get_camofox_url() -> str: @@ -56,16 +61,52 @@ def is_camofox_mode() -> bool: def check_camofox_available() -> bool: """Verify the Camofox server is reachable.""" + global _vnc_url, _vnc_url_checked url = get_camofox_url() if not url: return False try: resp = requests.get(f"{url}/health", timeout=5) + if resp.status_code == 200 and not _vnc_url_checked: + try: + data = resp.json() + vnc_port = data.get("vncPort") + if isinstance(vnc_port, int) and 1 <= vnc_port <= 65535: + from urllib.parse import urlparse + parsed = urlparse(url) + host = parsed.hostname or "localhost" + _vnc_url = f"http://{host}:{vnc_port}" + except (ValueError, KeyError): + pass + _vnc_url_checked = True return resp.status_code == 200 except Exception: return False +def get_vnc_url() -> Optional[str]: + """Return the VNC URL if the Camofox server exposes one, or None.""" + if not _vnc_url_checked: + check_camofox_available() + return _vnc_url + + +def _managed_persistence_enabled() -> bool: + """Return whether Hermes-managed persistence is enabled for Camofox. + + When enabled, sessions use a stable profile-scoped userId so the + Camofox server can map it to a persistent browser profile directory. + When disabled (default), each session gets a random userId (ephemeral). + + Controlled by ``browser.camofox.managed_persistence`` in config.yaml. + """ + try: + camofox_cfg = load_config().get("browser", {}).get("camofox", {}) + except Exception: + return False + return bool(camofox_cfg.get("managed_persistence")) + + # --------------------------------------------------------------------------- # Session management # --------------------------------------------------------------------------- @@ -75,16 +116,31 @@ _sessions_lock = threading.Lock() def _get_session(task_id: Optional[str]) -> Dict[str, Any]: - """Get or create a camofox session for the given task.""" + """Get or create a camofox session for the given task. + + When managed persistence is enabled, uses a deterministic userId + derived from the Hermes profile so the Camofox server can map it + to the same persistent browser profile across restarts. + """ task_id = task_id or "default" with _sessions_lock: if task_id in _sessions: return _sessions[task_id] - session = { - "user_id": f"hermes_{uuid.uuid4().hex[:10]}", - "tab_id": None, - "session_key": f"task_{task_id[:16]}", - } + if _managed_persistence_enabled(): + identity = get_camofox_identity(task_id) + session = { + "user_id": identity["user_id"], + "tab_id": None, + "session_key": identity["session_key"], + "managed": True, + } + else: + session = { + "user_id": f"hermes_{uuid.uuid4().hex[:10]}", + "tab_id": None, + "session_key": f"task_{task_id[:16]}", + "managed": False, + } _sessions[task_id] = session return session @@ -172,11 +228,19 @@ def camofox_navigate(url: str, task_id: Optional[str] = None) -> str: {"userId": session["user_id"], "url": url}, timeout=60, ) - return json.dumps({ + result = { "success": True, "url": data.get("url", url), "title": data.get("title", ""), - }) + } + 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." + ) + return json.dumps(result) except requests.HTTPError as e: return json.dumps({"success": False, "error": f"Navigation failed: {e}"}) except requests.ConnectionError: @@ -436,7 +500,7 @@ def camofox_vision(question: str, annotate: bool = False, except Exception: _vision_timeout = 120 - analysis = call_llm( + response = call_llm( messages=[{ "role": "user", "content": [ @@ -452,6 +516,7 @@ def camofox_vision(question: str, annotate: bool = False, task="vision", timeout=_vision_timeout, ) + analysis = response.choices[0].message.content if response.choices else "" return json.dumps({ "success": True, diff --git a/tools/browser_camofox_state.py b/tools/browser_camofox_state.py new file mode 100644 index 000000000..3a2bde03f --- /dev/null +++ b/tools/browser_camofox_state.py @@ -0,0 +1,47 @@ +"""Hermes-managed Camofox state helpers. + +Provides profile-scoped identity and state directory paths for Camofox +persistent browser profiles. When managed persistence is enabled, Hermes +sends a deterministic userId derived from the active profile so that +Camofox can map it to the same persistent browser profile directory +across restarts. +""" + +from __future__ import annotations + +import uuid +from pathlib import Path +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 + + +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 newly created + tabs within the same profile reuse the same identity contract. + """ + scope_root = str(get_camofox_state_dir()) + logical_scope = task_id or "default" + 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}:{logical_scope}", + ).hex[:16] + return { + "user_id": f"hermes_{user_digest}", + "session_key": f"task_{session_digest}", + } diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 10b6367be..c86092a0b 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -85,6 +85,7 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe | `BROWSERBASE_PROJECT_ID` | Browserbase project ID | | `BROWSER_USE_API_KEY` | Browser Use cloud browser API key ([browser-use.com](https://browser-use.com/)) | | `BROWSER_CDP_URL` | Chrome DevTools Protocol URL for local browser (set via `/browser connect`, e.g. `ws://localhost:9222`) | +| `CAMOFOX_URL` | Camofox local anti-detection browser URL (default: `http://localhost:9377`) | | `BROWSER_INACTIVITY_TIMEOUT` | Browser session inactivity timeout in seconds | | `FAL_KEY` | Image generation ([fal.ai](https://fal.ai/)) | | `GROQ_API_KEY` | Groq Whisper STT API key ([groq.com](https://groq.com/)) | diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index d6ef5b05b..071f8c77f 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -1016,6 +1016,8 @@ browser: inactivity_timeout: 120 # Seconds before auto-closing idle sessions command_timeout: 30 # Timeout in seconds for browser commands (screenshot, navigate, etc.) record_sessions: false # Auto-record browser sessions as WebM videos to ~/.hermes/browser_recordings/ + camofox: + managed_persistence: false # When true, Camofox sessions persist cookies/logins across restarts ``` The browser toolset supports multiple providers. See the [Browser feature page](/docs/user-guide/features/browser) for details on Browserbase, Browser Use, and local Chrome CDP setup. diff --git a/website/docs/user-guide/features/browser.md b/website/docs/user-guide/features/browser.md index 0f7b2570c..10a6ccee8 100644 --- a/website/docs/user-guide/features/browser.md +++ b/website/docs/user-guide/features/browser.md @@ -11,6 +11,7 @@ Hermes Agent includes a full browser automation toolset with multiple backend op - **Browserbase cloud mode** via [Browserbase](https://browserbase.com) for managed cloud browsers and anti-bot tooling - **Browser Use cloud mode** via [Browser Use](https://browser-use.com) as an alternative cloud browser provider +- **Camofox local mode** via [Camofox](https://github.com/jo-inc/camofox-browser) for local anti-detection browsing (Firefox-based fingerprint spoofing) - **Local Chrome via CDP** — connect browser tools to your own Chrome instance using `/browser connect` - **Local browser mode** via the `agent-browser` CLI and a local Chromium installation @@ -54,6 +55,50 @@ BROWSER_USE_API_KEY=*** Get your API key at [browser-use.com](https://browser-use.com). Browser Use provides a cloud browser via its REST API. If both Browserbase and Browser Use credentials are set, Browserbase takes priority. +### Camofox local mode + +[Camofox](https://github.com/jo-inc/camofox-browser) is a self-hosted Node.js server wrapping Camoufox (a Firefox fork with C++ fingerprint spoofing). It provides local anti-detection browsing without cloud dependencies. + +```bash +# Install and run +git clone https://github.com/jo-inc/camofox-browser && cd camofox-browser +npm install && npm start # downloads Camoufox (~300MB) on first run + +# Or via Docker +docker run -d --network host -e CAMOFOX_PORT=9377 jo-inc/camofox-browser +``` + +Then set in `~/.hermes/.env`: + +```bash +CAMOFOX_URL=http://localhost:9377 +``` + +Or configure via `hermes tools` → Browser Automation → Camofox. + +When `CAMOFOX_URL` is set, all browser tools automatically route through Camofox instead of Browserbase or agent-browser. + +#### Persistent browser sessions + +By default, each Camofox session gets a random identity — cookies and logins don't survive across agent restarts. To enable persistent browser sessions: + +```yaml +# In ~/.hermes/config.yaml +browser: + camofox: + managed_persistence: true +``` + +When enabled, Hermes sends a stable profile-scoped identity to Camofox. The Camofox server maps this identity to a persistent browser profile directory, so cookies, logins, and localStorage survive across restarts. Different Hermes profiles get different browser profiles (profile isolation). + +:::note +The Camofox server must also be configured with `CAMOFOX_PROFILE_DIR` on the server side for persistence to work. +::: + +#### VNC live view + +When Camofox runs in headed mode (with a visible browser window), it exposes a VNC port in its health check response. Hermes automatically discovers this and includes the VNC URL in navigation responses, so the agent can share a link for you to watch the browser live. + ### Local Chrome via CDP (`/browser connect`) Instead of a cloud provider, you can attach Hermes browser tools to your own running Chrome instance via the Chrome DevTools Protocol (CDP). This is useful when you want to see what the agent is doing in real-time, interact with pages that require your own cookies/sessions, or avoid cloud browser costs.