mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-30 06:41:51 +00:00
fix(browser): rewrite Camofox Docker loopback URLs (#25541)
Co-authored-by: Wysie <wysie@users.noreply.github.com>
This commit is contained in:
parent
f61fd59b62
commit
a0fc3df878
5 changed files with 204 additions and 4 deletions
4
cli.py
4
cli.py
|
|
@ -382,6 +382,10 @@ def load_cli_config() -> Dict[str, Any]:
|
|||
"inactivity_timeout": 120, # Auto-cleanup inactive browser sessions after 2 min
|
||||
"record_sessions": False, # Auto-record browser sessions as WebM videos
|
||||
"engine": "auto", # Browser engine: auto (Chrome), lightpanda, chrome
|
||||
"camofox": {
|
||||
"rewrite_loopback_urls": False,
|
||||
"loopback_host_alias": "host.docker.internal",
|
||||
},
|
||||
},
|
||||
"compression": {
|
||||
"enabled": True, # Auto-compress when approaching context limit
|
||||
|
|
|
|||
|
|
@ -850,6 +850,11 @@ DEFAULT_CONFIG = {
|
|||
"session_key": "",
|
||||
# Rehydrate tab_id from Camofox before creating a new tab.
|
||||
"adopt_existing_tab": False,
|
||||
# Docker Camofox opens page URLs from inside the container. Enable
|
||||
# this to rewrite loopback page URLs (localhost/127.0.0.1/::1) to a
|
||||
# host alias while leaving CAMOFOX_URL itself unchanged.
|
||||
"rewrite_loopback_urls": False,
|
||||
"loopback_host_alias": "host.docker.internal",
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ from tools.browser_camofox import (
|
|||
camofox_vision,
|
||||
check_camofox_available,
|
||||
is_camofox_mode,
|
||||
_rewrite_loopback_url_for_camofox,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -57,6 +58,10 @@ class TestCamofoxMode:
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _config_with_camofox(**camofox_config):
|
||||
return {"browser": {"camofox": camofox_config}}
|
||||
|
||||
|
||||
def _mock_response(status=200, json_data=None):
|
||||
resp = MagicMock()
|
||||
resp.status_code = status
|
||||
|
|
@ -71,6 +76,60 @@ def _mock_response(status=200, json_data=None):
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCamofoxLoopbackRewrite:
|
||||
@patch("tools.browser_camofox.load_config")
|
||||
def test_rewrites_localhost_when_enabled(self, mock_config, monkeypatch):
|
||||
monkeypatch.delenv("CAMOFOX_REWRITE_LOOPBACK_URLS", raising=False)
|
||||
monkeypatch.delenv("CAMOFOX_LOOPBACK_HOST_ALIAS", raising=False)
|
||||
mock_config.return_value = _config_with_camofox(rewrite_loopback_urls=True)
|
||||
|
||||
rewritten, metadata = _rewrite_loopback_url_for_camofox("http://127.0.0.1:8766/#settings")
|
||||
|
||||
assert rewritten == "http://host.docker.internal:8766/#settings"
|
||||
assert metadata == {
|
||||
"from": "127.0.0.1",
|
||||
"to": "host.docker.internal",
|
||||
"original_url": "http://127.0.0.1:8766/#settings",
|
||||
"rewritten_url": "http://host.docker.internal:8766/#settings",
|
||||
}
|
||||
|
||||
@patch("tools.browser_camofox.load_config")
|
||||
def test_rewrite_is_opt_in(self, mock_config, monkeypatch):
|
||||
monkeypatch.delenv("CAMOFOX_REWRITE_LOOPBACK_URLS", raising=False)
|
||||
mock_config.return_value = _config_with_camofox(rewrite_loopback_urls=False)
|
||||
|
||||
rewritten, metadata = _rewrite_loopback_url_for_camofox("http://localhost:3000/app?x=1")
|
||||
|
||||
assert rewritten == "http://localhost:3000/app?x=1"
|
||||
assert metadata is None
|
||||
|
||||
@patch("tools.browser_camofox.load_config")
|
||||
def test_preserves_public_urls_when_enabled(self, mock_config, monkeypatch):
|
||||
monkeypatch.delenv("CAMOFOX_REWRITE_LOOPBACK_URLS", raising=False)
|
||||
mock_config.return_value = _config_with_camofox(rewrite_loopback_urls=True)
|
||||
|
||||
rewritten, metadata = _rewrite_loopback_url_for_camofox("https://example.com:8443/path?q=1#top")
|
||||
|
||||
assert rewritten == "https://example.com:8443/path?q=1#top"
|
||||
assert metadata is None
|
||||
|
||||
@patch("tools.browser_camofox.load_config")
|
||||
def test_env_alias_takes_precedence(self, mock_config, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_REWRITE_LOOPBACK_URLS", "true")
|
||||
monkeypatch.setenv("CAMOFOX_LOOPBACK_HOST_ALIAS", "192.168.1.10")
|
||||
mock_config.return_value = _config_with_camofox(
|
||||
rewrite_loopback_urls=False,
|
||||
loopback_host_alias="host.docker.internal",
|
||||
)
|
||||
|
||||
rewritten, metadata = _rewrite_loopback_url_for_camofox("http://[::1]:8080/path")
|
||||
|
||||
assert rewritten == "http://192.168.1.10:8080/path"
|
||||
assert metadata is not None
|
||||
assert metadata["from"] == "::1"
|
||||
assert metadata["to"] == "192.168.1.10"
|
||||
|
||||
|
||||
class TestCamofoxNavigate:
|
||||
@patch("tools.browser_camofox.requests.post")
|
||||
def test_creates_tab_on_first_navigate(self, mock_post, monkeypatch):
|
||||
|
|
@ -81,6 +140,24 @@ class TestCamofoxNavigate:
|
|||
assert result["success"] is True
|
||||
assert result["url"] == "https://example.com"
|
||||
|
||||
@patch("tools.browser_camofox.load_config")
|
||||
@patch("tools.browser_camofox.requests.post")
|
||||
def test_navigate_uses_rewritten_loopback_url(self, mock_post, mock_config, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
monkeypatch.delenv("CAMOFOX_REWRITE_LOOPBACK_URLS", raising=False)
|
||||
monkeypatch.delenv("CAMOFOX_LOOPBACK_HOST_ALIAS", raising=False)
|
||||
mock_config.return_value = _config_with_camofox(rewrite_loopback_urls=True)
|
||||
mock_post.return_value = _mock_response(json_data={"tabId": "tab_rewrite"})
|
||||
|
||||
result = json.loads(camofox_navigate("http://127.0.0.1:8766/#settings", task_id="t_rewrite"))
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["url"] == "http://host.docker.internal:8766/#settings"
|
||||
assert result["requested_url"] == "http://127.0.0.1:8766/#settings"
|
||||
assert result["url_rewrite"]["to"] == "host.docker.internal"
|
||||
assert "Rewrote loopback URL" in result["warning"]
|
||||
assert mock_post.call_args.kwargs["json"]["url"] == "http://host.docker.internal:8766/#settings"
|
||||
|
||||
@patch("tools.browser_camofox.requests.post")
|
||||
def test_navigates_existing_tab(self, mock_post, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@ Setup::
|
|||
docker run -p 9377:9377 -e CAMOFOX_PORT=9377 jo-inc/camofox-browser
|
||||
|
||||
Then set ``CAMOFOX_URL=http://localhost:9377`` in ``~/.hermes/.env``.
|
||||
For Docker Camofox, optionally set ``CAMOFOX_REWRITE_LOOPBACK_URLS=true``
|
||||
so page URLs like ``http://127.0.0.1:3000`` are opened inside the
|
||||
container as ``http://host.docker.internal:3000``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -29,6 +32,7 @@ import os
|
|||
import threading
|
||||
import uuid
|
||||
from typing import Any, Dict, Optional
|
||||
from urllib.parse import SplitResult, urlsplit, urlunsplit
|
||||
|
||||
import requests
|
||||
|
||||
|
|
@ -159,6 +163,89 @@ def _adopt_existing_tab_enabled(camofox_cfg: Dict[str, Any]) -> bool:
|
|||
return bool(camofox_cfg.get("adopt_existing_tab"))
|
||||
|
||||
|
||||
def _loopback_rewrite_enabled(camofox_cfg: Dict[str, Any]) -> bool:
|
||||
"""Return whether loopback navigation URLs should be rewritten for Docker.
|
||||
|
||||
``CAMOFOX_URL`` itself often points at a host-published Docker port such as
|
||||
``http://127.0.0.1:9377``. That is correct for Hermes talking to the
|
||||
Camofox control API, but a page URL like ``http://127.0.0.1:3000`` is opened
|
||||
by the browser *inside* the Docker container. In that context loopback
|
||||
points at the container, not the host running the web app.
|
||||
|
||||
The rewrite is opt-in because non-Docker Camofox installs run the browser on
|
||||
the host, where loopback URLs are already correct.
|
||||
"""
|
||||
env_value = _env_flag("CAMOFOX_REWRITE_LOOPBACK_URLS")
|
||||
if env_value is not None:
|
||||
return env_value
|
||||
return bool(camofox_cfg.get("rewrite_loopback_urls"))
|
||||
|
||||
|
||||
def _loopback_rewrite_host(camofox_cfg: Dict[str, Any]) -> str:
|
||||
"""Return the host alias used when rewriting loopback page URLs."""
|
||||
return (
|
||||
os.getenv("CAMOFOX_LOOPBACK_HOST_ALIAS", "").strip()
|
||||
or str(camofox_cfg.get("loopback_host_alias") or "").strip()
|
||||
or "host.docker.internal"
|
||||
)
|
||||
|
||||
|
||||
def _is_loopback_hostname(hostname: Optional[str]) -> bool:
|
||||
"""Return True for localhost/127.0.0.0/8/::1-style hostnames."""
|
||||
if not hostname:
|
||||
return False
|
||||
host = hostname.strip().strip("[]").lower()
|
||||
if host in {"localhost", "localhost.localdomain"}:
|
||||
return True
|
||||
try:
|
||||
import ipaddress
|
||||
|
||||
return ipaddress.ip_address(host).is_loopback
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _rewrite_loopback_url_for_camofox(url: str) -> tuple[str, Optional[Dict[str, str]]]:
|
||||
"""Rewrite loopback page URLs for Docker-hosted Camofox, if configured.
|
||||
|
||||
Returns ``(rewritten_url, metadata)``. ``metadata`` is present only when a
|
||||
rewrite happened so the tool result can disclose the change to the model.
|
||||
"""
|
||||
camofox_cfg = _get_camofox_config()
|
||||
if not _loopback_rewrite_enabled(camofox_cfg):
|
||||
return url, None
|
||||
|
||||
try:
|
||||
parsed = urlsplit(url)
|
||||
except ValueError:
|
||||
return url, None
|
||||
|
||||
if parsed.scheme not in {"http", "https"} or not _is_loopback_hostname(parsed.hostname):
|
||||
return url, None
|
||||
|
||||
alias = _loopback_rewrite_host(camofox_cfg)
|
||||
if not alias:
|
||||
return url, None
|
||||
|
||||
userinfo = ""
|
||||
if parsed.username:
|
||||
userinfo = parsed.username
|
||||
if parsed.password:
|
||||
userinfo += f":{parsed.password}"
|
||||
userinfo += "@"
|
||||
host_part = f"[{alias}]" if ":" in alias and not alias.startswith("[") else alias
|
||||
port_part = f":{parsed.port}" if parsed.port else ""
|
||||
rewritten = urlunsplit(
|
||||
SplitResult(parsed.scheme, f"{userinfo}{host_part}{port_part}", parsed.path, parsed.query, parsed.fragment)
|
||||
)
|
||||
return rewritten, {
|
||||
"from": parsed.hostname or "",
|
||||
"to": alias,
|
||||
"original_url": url,
|
||||
"rewritten_url": rewritten,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -336,23 +423,31 @@ def _delete(path: str, body: dict = None, timeout: int = _DEFAULT_TIMEOUT) -> di
|
|||
def camofox_navigate(url: str, task_id: Optional[str] = None) -> str:
|
||||
"""Navigate to a URL via Camofox."""
|
||||
try:
|
||||
browser_url, rewrite_info = _rewrite_loopback_url_for_camofox(url)
|
||||
session = _get_session(task_id)
|
||||
if not session["tab_id"]:
|
||||
# Create tab with the target URL directly
|
||||
session = _ensure_tab(task_id, url)
|
||||
data = {"ok": True, "url": url}
|
||||
session = _ensure_tab(task_id, browser_url)
|
||||
data = {"ok": True, "url": browser_url}
|
||||
else:
|
||||
# Navigate existing tab
|
||||
data = _post(
|
||||
f"/tabs/{session['tab_id']}/navigate",
|
||||
{"userId": session["user_id"], "url": url},
|
||||
{"userId": session["user_id"], "url": browser_url},
|
||||
timeout=60,
|
||||
)
|
||||
result = {
|
||||
"success": True,
|
||||
"url": data.get("url", url),
|
||||
"url": data.get("url", browser_url),
|
||||
"title": data.get("title", ""),
|
||||
}
|
||||
if rewrite_info:
|
||||
result["requested_url"] = url
|
||||
result["url_rewrite"] = rewrite_info
|
||||
result["warning"] = (
|
||||
"Rewrote loopback URL for Docker-hosted Camofox: "
|
||||
f"{rewrite_info['from']} -> {rewrite_info['to']}"
|
||||
)
|
||||
vnc = get_vnc_url()
|
||||
if vnc:
|
||||
result["vnc_url"] = vnc
|
||||
|
|
|
|||
|
|
@ -185,6 +185,25 @@ Then set in `~/.hermes/.env`:
|
|||
CAMOFOX_URL=http://localhost:9377
|
||||
```
|
||||
|
||||
If Camofox is running in Docker and you want it to open web apps served from the host machine, enable loopback rewriting. `CAMOFOX_URL` should still point at the host-published control API, but page URLs such as `http://127.0.0.1:3000` must be opened from inside the container as `http://host.docker.internal:3000`:
|
||||
|
||||
```yaml
|
||||
# ~/.hermes/config.yaml
|
||||
browser:
|
||||
camofox:
|
||||
rewrite_loopback_urls: true
|
||||
loopback_host_alias: host.docker.internal # default; use a LAN IP if needed
|
||||
```
|
||||
|
||||
Equivalent env vars:
|
||||
|
||||
```bash
|
||||
CAMOFOX_REWRITE_LOOPBACK_URLS=true
|
||||
CAMOFOX_LOOPBACK_HOST_ALIAS=host.docker.internal
|
||||
```
|
||||
|
||||
The rewrite only applies to page navigation URLs with loopback hosts (`localhost`, `127.0.0.1`, `::1`). It does not change `CAMOFOX_URL`. Leave it disabled for non-Docker Camofox installs, where the browser already runs on the host and loopback URLs are correct.
|
||||
|
||||
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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue