Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,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
Expand Down
5 changes: 5 additions & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -640,6 +640,11 @@ def _ensure_hermes_home_managed(home: Path):
"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",
},
},

Expand Down
77 changes: 77 additions & 0 deletions tests/tools/test_browser_camofox.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
camofox_vision,
check_camofox_available,
is_camofox_mode,
_rewrite_loopback_url_for_camofox,
)


Expand Down Expand Up @@ -59,6 +60,10 @@ def test_health_check_unreachable(self, monkeypatch):
# ---------------------------------------------------------------------------


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
Expand All @@ -73,6 +78,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):
Expand All @@ -83,6 +142,24 @@ def test_creates_tab_on_first_navigate(self, mock_post, monkeypatch):
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")
Expand Down
103 changes: 99 additions & 4 deletions tools/browser_camofox.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
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
Expand All @@ -29,6 +32,7 @@
import threading
import uuid
from typing import Any, Dict, Optional
from urllib.parse import SplitResult, urlsplit, urlunsplit

import requests

Expand Down Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions website/docs/user-guide/features/browser.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading