diff --git a/hermes_cli/config.py b/hermes_cli/config.py index c7946872bf2..6b981824279 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -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, }, }, diff --git a/tests/tools/test_browser_camofox_persistence.py b/tests/tools/test_browser_camofox_persistence.py index eddd36f0047..ff5624ca031 100644 --- a/tests/tools/test_browser_camofox_persistence.py +++ b/tests/tools/test_browser_camofox_persistence.py @@ -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.""" diff --git a/tests/tools/test_browser_camofox_state.py b/tests/tools/test_browser_camofox_state.py index 9ce3d132028..f0e632ad5f6 100644 --- a/tests/tools/test_browser_camofox_state.py +++ b/tests/tools/test_browser_camofox_state.py @@ -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 diff --git a/tools/browser_camofox.py b/tools/browser_camofox.py index 5f59dd913ff..071f1a2164b 100644 --- a/tools/browser_camofox.py +++ b/tools/browser_camofox.py @@ -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 diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index eda0c2863a7..b17036ade44 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -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/)) | diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 14f80d4d97a..5ea0c0b1779 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -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:** diff --git a/website/docs/user-guide/features/browser.md b/website/docs/user-guide/features/browser.md index 2ae5e2b5aa4..d917363df8a 100644 --- a/website/docs/user-guide/features/browser.md +++ b/website/docs/user-guide/features/browser.md @@ -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.