fix(browser): rewrite Camofox Docker loopback URLs (#25541)

Co-authored-by: Wysie <wysie@users.noreply.github.com>
This commit is contained in:
wysie 2026-05-29 13:43:55 +08:00 committed by GitHub
parent f61fd59b62
commit a0fc3df878
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 204 additions and 4 deletions

4
cli.py
View file

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

View file

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

View file

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

View file

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

View file

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