feat(browser): support externally managed Camofox sessions

Allow integrations to share a visible Camofox identity with Hermes and recover existing tabs without carrying local patches.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Dan Benyamin 2026-05-10 17:54:13 -07:00 committed by Teknium
parent 3955aefced
commit 62fd905340
7 changed files with 255 additions and 10 deletions

View file

@ -628,6 +628,12 @@ DEFAULT_CONFIG = {
# so the server maps it to a persistent Firefox profile automatically.
# When false (default), each session gets a random userId (ephemeral).
"managed_persistence": False,
# Optional externally managed Camofox identity. Useful when another
# app owns the visible browser and Hermes should operate in it.
"user_id": "",
"session_key": "",
# Rehydrate tab_id from Camofox before creating a new tab.
"adopt_existing_tab": False,
},
},

View file

@ -193,6 +193,118 @@ class TestManagedPersistenceMode:
assert tab_requests[0]["userId"] == tab_requests[1]["userId"]
class TestConfiguredCamofoxIdentity:
"""Externally managed Camofox sessions can provide their own identity."""
def test_env_identity_overrides_default_identity(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
monkeypatch.setenv("CAMOFOX_USER_ID", "shared-camofox")
monkeypatch.setenv("CAMOFOX_SESSION_KEY", "visible-tab")
monkeypatch.setenv("CAMOFOX_ADOPT_EXISTING_TAB", "true")
with patch("tools.browser_camofox._get", return_value={"tabs": []}) as mock_get:
session = _get_session("task-1")
assert session["user_id"] == "shared-camofox"
assert session["session_key"] == "visible-tab"
assert session["managed"] is True
assert session["adopt_existing_tab"] is True
mock_get.assert_called_once_with(
"/tabs",
params={"userId": "shared-camofox"},
timeout=5,
)
def test_config_identity_is_used_when_env_is_absent(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
config = {
"browser": {
"camofox": {
"user_id": "config-user",
"session_key": "config-session",
"adopt_existing_tab": False,
}
}
}
with patch("tools.browser_camofox.load_config", return_value=config):
session = _get_session("task-1")
assert session["user_id"] == "config-user"
assert session["session_key"] == "config-session"
assert session["adopt_existing_tab"] is False
def test_env_identity_takes_precedence_over_config(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
monkeypatch.setenv("CAMOFOX_USER_ID", "env-user")
monkeypatch.setenv("CAMOFOX_SESSION_KEY", "env-session")
monkeypatch.setenv("CAMOFOX_ADOPT_EXISTING_TAB", "false")
config = {
"browser": {
"camofox": {
"user_id": "config-user",
"session_key": "config-session",
"adopt_existing_tab": True,
}
}
}
with patch("tools.browser_camofox.load_config", return_value=config):
session = _get_session("task-1")
assert session["user_id"] == "env-user"
assert session["session_key"] == "env-session"
assert session["adopt_existing_tab"] is False
def test_adopts_existing_tab_matching_session_key(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
monkeypatch.setenv("CAMOFOX_USER_ID", "shared-camofox")
monkeypatch.setenv("CAMOFOX_SESSION_KEY", "visible-tab")
monkeypatch.setenv("CAMOFOX_ADOPT_EXISTING_TAB", "true")
tabs = {
"tabs": [
{"tabId": "tab-other", "listItemId": "other"},
{"tabId": "tab-visible", "listItemId": "visible-tab"},
]
}
with patch("tools.browser_camofox._get", return_value=tabs):
session = _get_session("task-1")
assert session["tab_id"] == "tab-visible"
def test_managed_persistence_can_opt_into_tab_adoption(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
config = {"browser": {"camofox": {"managed_persistence": True, "adopt_existing_tab": True}}}
with (
patch("tools.browser_camofox.load_config", return_value=config),
patch("tools.browser_camofox._get", return_value={"tabs": [{"tabId": "tab-1"}]}),
):
session = _get_session("task-1")
assert session["tab_id"] == "tab-1"
def test_soft_cleanup_preserves_externally_managed_session(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
monkeypatch.setenv("CAMOFOX_USER_ID", "shared-camofox")
with patch("tools.browser_camofox._get", return_value={"tabs": []}):
_get_session("task-1")
result = camofox_soft_cleanup("task-1")
assert result is True
import tools.browser_camofox as mod
with mod._sessions_lock:
assert "task-1" not in mod._sessions
class TestVncUrlDiscovery:
"""VNC URL is derived from the Camofox health endpoint."""

View file

@ -53,8 +53,11 @@ class TestCamofoxIdentity:
class TestCamofoxConfigDefaults:
def test_default_config_includes_managed_persistence_toggle(self):
def test_default_config_includes_camofox_controls(self):
from hermes_cli.config import DEFAULT_CONFIG
browser_cfg = DEFAULT_CONFIG["browser"]
assert browser_cfg["camofox"]["managed_persistence"] is False
assert browser_cfg["camofox"]["user_id"] == ""
assert browser_cfg["camofox"]["session_key"] == ""
assert browser_cfg["camofox"]["adopt_existing_tab"] is False

View file

@ -98,6 +98,16 @@ def get_vnc_url() -> Optional[str]:
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() -> bool:
"""Return whether Hermes-managed persistence is enabled for Camofox.
@ -107,12 +117,46 @@ def _managed_persistence_enabled() -> bool:
Controlled by ``browser.camofox.managed_persistence`` in config.yaml.
"""
try:
camofox_cfg = load_config().get("browser", {}).get("camofox", {})
except Exception as exc:
logger.warning("managed_persistence check failed, defaulting to disabled: %s", exc)
return bool(_get_camofox_config().get("managed_persistence"))
def _camofox_identity_override(task_id: Optional[str], camofox_cfg: Dict[str, Any]) -> Optional[Dict[str, str]]:
"""Return an externally configured Camofox identity, if one is set.
Integrations that own the visible Camofox browser can set a shared user ID
so Hermes operates in the same browser profile instead of creating a
separate private session.
"""
user_id = os.getenv("CAMOFOX_USER_ID", "").strip() or str(camofox_cfg.get("user_id") or "").strip()
if not user_id:
return None
session_key = (
os.getenv("CAMOFOX_SESSION_KEY", "").strip()
or str(camofox_cfg.get("session_key") or "").strip()
or f"task_{(task_id or 'default')[:16]}"
)
return {"user_id": user_id, "session_key": session_key}
def _env_flag(name: str) -> Optional[bool]:
raw = os.getenv(name, "").strip().lower()
if not raw:
return None
if raw in {"1", "true", "yes", "on"}:
return True
if raw in {"0", "false", "no", "off"}:
return False
return bool(camofox_cfg.get("managed_persistence"))
logger.debug("Ignoring invalid boolean env %s=%r", name, raw)
return None
def _adopt_existing_tab_enabled(camofox_cfg: Dict[str, Any]) -> bool:
"""Return whether Hermes should recover an existing Camofox tab ID."""
env_value = _env_flag("CAMOFOX_ADOPT_EXISTING_TAB")
if env_value is not None:
return env_value
return bool(camofox_cfg.get("adopt_existing_tab"))
# ---------------------------------------------------------------------------
@ -123,6 +167,44 @@ _sessions: Dict[str, Dict[str, Any]] = {}
_sessions_lock = threading.Lock()
def _adopt_existing_tab(session: Dict[str, Any]) -> Dict[str, Any]:
"""Attach process-local state to an already-open managed Camofox tab.
Some integrations own the visible Camofox tab outside Hermes. Gateway
restarts can leave this module's in-memory session cache empty even though
Camofox still has that tab, so rehydrate tab_id before creating a new tab.
"""
if session.get("tab_id") or not session.get("adopt_existing_tab"):
return session
if not get_camofox_url():
return session
try:
tabs = _get("/tabs", params={"userId": session["user_id"]}, timeout=5).get("tabs", [])
except Exception as exc:
logger.debug("Camofox tab adoption failed for %s: %s", session.get("user_id"), exc)
return session
if not isinstance(tabs, list) or not tabs:
return session
session_key = session.get("session_key")
matching_tabs = [
tab
for tab in tabs
if isinstance(tab, dict) and tab.get("listItemId") == session_key
]
candidates = matching_tabs or [tab for tab in tabs if isinstance(tab, dict)]
latest = candidates[-1] if candidates else None
tab_id = latest.get("tabId") if isinstance(latest, dict) 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 a camofox session for the given task.
@ -133,14 +215,26 @@ def _get_session(task_id: Optional[str]) -> Dict[str, Any]:
task_id = task_id or "default"
with _sessions_lock:
if task_id in _sessions:
return _sessions[task_id]
if _managed_persistence_enabled():
return _adopt_existing_tab(_sessions[task_id])
camofox_cfg = _get_camofox_config()
identity_override = _camofox_identity_override(task_id, camofox_cfg)
if identity_override:
session = {
"user_id": identity_override["user_id"],
"tab_id": None,
"session_key": identity_override["session_key"],
"managed": True,
"adopt_existing_tab": _adopt_existing_tab_enabled(camofox_cfg),
}
elif bool(camofox_cfg.get("managed_persistence")):
identity = get_camofox_identity(task_id)
session = {
"user_id": identity["user_id"],
"tab_id": None,
"session_key": identity["session_key"],
"managed": True,
"adopt_existing_tab": _adopt_existing_tab_enabled(camofox_cfg),
}
else:
session = {
@ -148,9 +242,10 @@ def _get_session(task_id: Optional[str]) -> Dict[str, Any]:
"tab_id": None,
"session_key": f"task_{task_id[:16]}",
"managed": False,
"adopt_existing_tab": False,
}
_sessions[task_id] = session
return session
return _adopt_existing_tab(session)
def _ensure_tab(task_id: Optional[str], url: str = "about:blank") -> Dict[str, Any]:
@ -190,7 +285,8 @@ def camofox_soft_cleanup(task_id: Optional[str] = None) -> bool:
does nothing and returns ``False`` so the caller can fall back to
:func:`camofox_close`.
"""
if _managed_persistence_enabled():
camofox_cfg = _get_camofox_config()
if bool(camofox_cfg.get("managed_persistence")) 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

View file

@ -129,6 +129,9 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe
| `FIRECRAWL_BROWSER_TTL` | Firecrawl browser session TTL in seconds (default: 300) |
| `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`) |
| `CAMOFOX_USER_ID` | Optional externally managed Camofox user ID for shared visible sessions |
| `CAMOFOX_SESSION_KEY` | Optional Camofox session key used when creating tabs for `CAMOFOX_USER_ID` |
| `CAMOFOX_ADOPT_EXISTING_TAB` | Set to `true` to reuse an existing Camofox tab before creating a new one |
| `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/)) |

View file

@ -1530,6 +1530,9 @@ browser:
dialog_timeout_s: 300 # Safety auto-dismiss under must_respond (seconds)
camofox:
managed_persistence: false # When true, Camofox sessions persist cookies/logins across restarts
user_id: "" # Optional externally managed Camofox userId
session_key: "" # Optional session key sent when Hermes creates a tab
adopt_existing_tab: false # Reuse an existing tab for this identity before creating one
```
**Dialog policies:**

View file

@ -235,6 +235,28 @@ If step 5 logs you out, the Camofox server isn't honoring the stable `userId`. D
Hermes derives the stable `userId` from the profile-scoped directory `~/.hermes/browser_auth/camofox/` (or the equivalent under `$HERMES_HOME` for non-default profiles). The actual browser profile data lives on the Camofox server side, keyed by that `userId`. To fully reset a persistent profile, clear it on the Camofox server and remove the corresponding Hermes profile's state directory.
#### Externally managed Camofox sessions
If another app owns the visible Camofox browser, configure Hermes to use that same Camofox identity:
```yaml
browser:
camofox:
user_id: shared-camofox
session_key: visible-tab
adopt_existing_tab: true
```
You can also set the equivalent environment variables:
```bash
CAMOFOX_USER_ID=shared-camofox
CAMOFOX_SESSION_KEY=visible-tab
CAMOFOX_ADOPT_EXISTING_TAB=true
```
When `user_id` is set, Hermes treats the Camofox session as externally managed and skips destructive cleanup. Set `adopt_existing_tab` when gateway restarts should recover the already-open tab before creating a new one.
#### 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.