From a0fc3df878e5d99125d3bbcbaeda6a4966e192c1 Mon Sep 17 00:00:00 2001 From: wysie Date: Fri, 29 May 2026 13:43:55 +0800 Subject: [PATCH] fix(browser): rewrite Camofox Docker loopback URLs (#25541) Co-authored-by: Wysie --- cli.py | 4 + hermes_cli/config.py | 5 + tests/tools/test_browser_camofox.py | 77 +++++++++++++++ tools/browser_camofox.py | 103 +++++++++++++++++++- website/docs/user-guide/features/browser.md | 19 ++++ 5 files changed, 204 insertions(+), 4 deletions(-) diff --git a/cli.py b/cli.py index a815175d9fe..bf0c3610fc8 100644 --- a/cli.py +++ b/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 diff --git a/hermes_cli/config.py b/hermes_cli/config.py index f0df9cd0345..52b7021d8b5 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -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", }, }, diff --git a/tests/tools/test_browser_camofox.py b/tests/tools/test_browser_camofox.py index 3f8ed7fb668..b8fc1a4d702 100644 --- a/tests/tools/test_browser_camofox.py +++ b/tests/tools/test_browser_camofox.py @@ -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") diff --git a/tools/browser_camofox.py b/tools/browser_camofox.py index 45bf885def6..b920160bd67 100644 --- a/tools/browser_camofox.py +++ b/tools/browser_camofox.py @@ -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 diff --git a/website/docs/user-guide/features/browser.md b/website/docs/user-guide/features/browser.md index e98ad522b1a..2dd307cea5a 100644 --- a/website/docs/user-guide/features/browser.md +++ b/website/docs/user-guide/features/browser.md @@ -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.