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 <psikonetik@gmail.com.com>
This commit is contained in:
Teknium 2026-04-01 04:18:50 -07:00 committed by GitHub
parent 85e96a4638
commit 70744add15
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 485 additions and 10 deletions

View file

@ -247,6 +247,13 @@ DEFAULT_CONFIG = {
"command_timeout": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.) "command_timeout": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.)
"record_sessions": False, # Auto-record browser sessions as WebM videos "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.) "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. # 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 schema version - bump this when adding new required fields
"_config_version": 11, "_config_version": 10,
} }
# ============================================================================= # =============================================================================

View file

@ -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

View file

@ -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

View file

@ -34,6 +34,9 @@ from typing import Any, Dict, Optional
import requests import requests
from hermes_cli.config import load_config
from tools.browser_camofox_state import get_camofox_identity
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -42,6 +45,8 @@ logger = logging.getLogger(__name__)
_DEFAULT_TIMEOUT = 30 # seconds per HTTP request _DEFAULT_TIMEOUT = 30 # seconds per HTTP request
_SNAPSHOT_MAX_CHARS = 80_000 # camofox paginates at this limit _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: def get_camofox_url() -> str:
@ -56,16 +61,52 @@ def is_camofox_mode() -> bool:
def check_camofox_available() -> bool: def check_camofox_available() -> bool:
"""Verify the Camofox server is reachable.""" """Verify the Camofox server is reachable."""
global _vnc_url, _vnc_url_checked
url = get_camofox_url() url = get_camofox_url()
if not url: if not url:
return False return False
try: try:
resp = requests.get(f"{url}/health", timeout=5) 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 return resp.status_code == 200
except Exception: except Exception:
return False 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 # Session management
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -75,16 +116,31 @@ _sessions_lock = threading.Lock()
def _get_session(task_id: Optional[str]) -> Dict[str, Any]: 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" task_id = task_id or "default"
with _sessions_lock: with _sessions_lock:
if task_id in _sessions: if task_id in _sessions:
return _sessions[task_id] return _sessions[task_id]
session = { if _managed_persistence_enabled():
"user_id": f"hermes_{uuid.uuid4().hex[:10]}", identity = get_camofox_identity(task_id)
"tab_id": None, session = {
"session_key": f"task_{task_id[:16]}", "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 _sessions[task_id] = session
return session return session
@ -172,11 +228,19 @@ def camofox_navigate(url: str, task_id: Optional[str] = None) -> str:
{"userId": session["user_id"], "url": url}, {"userId": session["user_id"], "url": url},
timeout=60, timeout=60,
) )
return json.dumps({ result = {
"success": True, "success": True,
"url": data.get("url", url), "url": data.get("url", url),
"title": data.get("title", ""), "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: except requests.HTTPError as e:
return json.dumps({"success": False, "error": f"Navigation failed: {e}"}) return json.dumps({"success": False, "error": f"Navigation failed: {e}"})
except requests.ConnectionError: except requests.ConnectionError:
@ -436,7 +500,7 @@ def camofox_vision(question: str, annotate: bool = False,
except Exception: except Exception:
_vision_timeout = 120 _vision_timeout = 120
analysis = call_llm( response = call_llm(
messages=[{ messages=[{
"role": "user", "role": "user",
"content": [ "content": [
@ -452,6 +516,7 @@ def camofox_vision(question: str, annotate: bool = False,
task="vision", task="vision",
timeout=_vision_timeout, timeout=_vision_timeout,
) )
analysis = response.choices[0].message.content if response.choices else ""
return json.dumps({ return json.dumps({
"success": True, "success": True,

View file

@ -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}",
}

View file

@ -85,6 +85,7 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe
| `BROWSERBASE_PROJECT_ID` | Browserbase project ID | | `BROWSERBASE_PROJECT_ID` | Browserbase project ID |
| `BROWSER_USE_API_KEY` | Browser Use cloud browser API key ([browser-use.com](https://browser-use.com/)) | | `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`) | | `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 | | `BROWSER_INACTIVITY_TIMEOUT` | Browser session inactivity timeout in seconds |
| `FAL_KEY` | Image generation ([fal.ai](https://fal.ai/)) | | `FAL_KEY` | Image generation ([fal.ai](https://fal.ai/)) |
| `GROQ_API_KEY` | Groq Whisper STT API key ([groq.com](https://groq.com/)) | | `GROQ_API_KEY` | Groq Whisper STT API key ([groq.com](https://groq.com/)) |

View file

@ -1016,6 +1016,8 @@ browser:
inactivity_timeout: 120 # Seconds before auto-closing idle sessions inactivity_timeout: 120 # Seconds before auto-closing idle sessions
command_timeout: 30 # Timeout in seconds for browser commands (screenshot, navigate, etc.) 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/ 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. 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.

View file

@ -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 - **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 - **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 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 - **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. 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`) ### 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. 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.