mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-18 04:41:56 +00:00
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:
parent
3955aefced
commit
62fd905340
7 changed files with 255 additions and 10 deletions
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/)) |
|
||||
|
|
|
|||
|
|
@ -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:**
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue