feat(browser): add Camofox local anti-detection browser backend (#4008)
Camofox-browser is a self-hosted Node.js server wrapping Camoufox (Firefox fork with C++ fingerprint spoofing). When CAMOFOX_URL is set, all 11 browser tools route through the Camofox REST API instead of the agent-browser CLI. Maps 1:1 to the existing browser tool interface: - Navigate, snapshot, click, type, scroll, back, press, close - Get images, vision (screenshot + LLM analysis) - Console (returns empty with note — camofox limitation) Setup: npm start in camofox-browser dir, or docker run -p 9377:9377 Then: CAMOFOX_URL=http://localhost:9377 in ~/.hermes/.env Advantages over Browserbase (cloud): - Free (no per-session API costs) - Local (zero network latency for browser ops) - Anti-detection at C++ level (bypasses Cloudflare/Google bot detection) - Works offline, Docker-ready Files: - tools/browser_camofox.py: Full REST backend (~400 lines) - tools/browser_tool.py: Routing at each tool function - hermes_cli/config.py: CAMOFOX_URL env var entry - tests/tools/test_browser_camofox.py: 20 tests
This commit is contained in:
290
tests/tools/test_browser_camofox.py
Normal file
290
tests/tools/test_browser_camofox.py
Normal file
@@ -0,0 +1,290 @@
|
||||
"""Tests for the Camofox browser backend."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from tools.browser_camofox import (
|
||||
camofox_back,
|
||||
camofox_click,
|
||||
camofox_close,
|
||||
camofox_console,
|
||||
camofox_get_images,
|
||||
camofox_navigate,
|
||||
camofox_press,
|
||||
camofox_scroll,
|
||||
camofox_snapshot,
|
||||
camofox_type,
|
||||
camofox_vision,
|
||||
check_camofox_available,
|
||||
cleanup_all_camofox_sessions,
|
||||
is_camofox_mode,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCamofoxMode:
|
||||
def test_disabled_by_default(self, monkeypatch):
|
||||
monkeypatch.delenv("CAMOFOX_URL", raising=False)
|
||||
assert is_camofox_mode() is False
|
||||
|
||||
def test_enabled_when_url_set(self, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
assert is_camofox_mode() is True
|
||||
|
||||
def test_health_check_unreachable(self, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:19999")
|
||||
assert check_camofox_available() is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _mock_response(status=200, json_data=None):
|
||||
resp = MagicMock()
|
||||
resp.status_code = status
|
||||
resp.json.return_value = json_data or {}
|
||||
resp.content = b"\x89PNG\r\n\x1a\nfake"
|
||||
resp.raise_for_status = MagicMock()
|
||||
return resp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Navigate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCamofoxNavigate:
|
||||
@patch("tools.browser_camofox.requests.post")
|
||||
def test_creates_tab_on_first_navigate(self, mock_post, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
mock_post.return_value = _mock_response(json_data={"tabId": "tab1", "url": "https://example.com"})
|
||||
|
||||
result = json.loads(camofox_navigate("https://example.com", task_id="t1"))
|
||||
assert result["success"] is True
|
||||
assert result["url"] == "https://example.com"
|
||||
|
||||
@patch("tools.browser_camofox.requests.post")
|
||||
def test_navigates_existing_tab(self, mock_post, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
# First call creates tab
|
||||
mock_post.return_value = _mock_response(json_data={"tabId": "tab2", "url": "https://a.com"})
|
||||
camofox_navigate("https://a.com", task_id="t2")
|
||||
|
||||
# Second call navigates
|
||||
mock_post.return_value = _mock_response(json_data={"ok": True, "url": "https://b.com"})
|
||||
result = json.loads(camofox_navigate("https://b.com", task_id="t2"))
|
||||
assert result["success"] is True
|
||||
assert result["url"] == "https://b.com"
|
||||
|
||||
def test_connection_error_returns_helpful_message(self, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:19999")
|
||||
result = json.loads(camofox_navigate("https://example.com", task_id="t_err"))
|
||||
assert result["success"] is False
|
||||
assert "Cannot connect" in result["error"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Snapshot
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCamofoxSnapshot:
|
||||
def test_no_session_returns_error(self, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
result = json.loads(camofox_snapshot(task_id="no_such_task"))
|
||||
assert result["success"] is False
|
||||
assert "browser_navigate" in result["error"]
|
||||
|
||||
@patch("tools.browser_camofox.requests.post")
|
||||
@patch("tools.browser_camofox.requests.get")
|
||||
def test_returns_snapshot(self, mock_get, mock_post, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
# Create session
|
||||
mock_post.return_value = _mock_response(json_data={"tabId": "tab3", "url": "https://x.com"})
|
||||
camofox_navigate("https://x.com", task_id="t3")
|
||||
|
||||
# Return snapshot
|
||||
mock_get.return_value = _mock_response(json_data={
|
||||
"snapshot": "- heading \"Test\" [e1]\n- button \"Submit\" [e2]",
|
||||
"refsCount": 2,
|
||||
})
|
||||
result = json.loads(camofox_snapshot(task_id="t3"))
|
||||
assert result["success"] is True
|
||||
assert "[e1]" in result["snapshot"]
|
||||
assert result["element_count"] == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Click / Type / Scroll / Back / Press
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCamofoxInteractions:
|
||||
@patch("tools.browser_camofox.requests.post")
|
||||
def test_click(self, mock_post, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
mock_post.return_value = _mock_response(json_data={"tabId": "tab4", "url": "https://x.com"})
|
||||
camofox_navigate("https://x.com", task_id="t4")
|
||||
|
||||
mock_post.return_value = _mock_response(json_data={"ok": True, "url": "https://x.com"})
|
||||
result = json.loads(camofox_click("@e5", task_id="t4"))
|
||||
assert result["success"] is True
|
||||
assert result["clicked"] == "e5"
|
||||
|
||||
@patch("tools.browser_camofox.requests.post")
|
||||
def test_type(self, mock_post, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
mock_post.return_value = _mock_response(json_data={"tabId": "tab5", "url": "https://x.com"})
|
||||
camofox_navigate("https://x.com", task_id="t5")
|
||||
|
||||
mock_post.return_value = _mock_response(json_data={"ok": True})
|
||||
result = json.loads(camofox_type("@e3", "hello world", task_id="t5"))
|
||||
assert result["success"] is True
|
||||
assert result["typed"] == "hello world"
|
||||
|
||||
@patch("tools.browser_camofox.requests.post")
|
||||
def test_scroll(self, mock_post, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
mock_post.return_value = _mock_response(json_data={"tabId": "tab6", "url": "https://x.com"})
|
||||
camofox_navigate("https://x.com", task_id="t6")
|
||||
|
||||
mock_post.return_value = _mock_response(json_data={"ok": True})
|
||||
result = json.loads(camofox_scroll("down", task_id="t6"))
|
||||
assert result["success"] is True
|
||||
assert result["scrolled"] == "down"
|
||||
|
||||
@patch("tools.browser_camofox.requests.post")
|
||||
def test_back(self, mock_post, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
mock_post.return_value = _mock_response(json_data={"tabId": "tab7", "url": "https://x.com"})
|
||||
camofox_navigate("https://x.com", task_id="t7")
|
||||
|
||||
mock_post.return_value = _mock_response(json_data={"ok": True, "url": "https://prev.com"})
|
||||
result = json.loads(camofox_back(task_id="t7"))
|
||||
assert result["success"] is True
|
||||
|
||||
@patch("tools.browser_camofox.requests.post")
|
||||
def test_press(self, mock_post, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
mock_post.return_value = _mock_response(json_data={"tabId": "tab8", "url": "https://x.com"})
|
||||
camofox_navigate("https://x.com", task_id="t8")
|
||||
|
||||
mock_post.return_value = _mock_response(json_data={"ok": True})
|
||||
result = json.loads(camofox_press("Enter", task_id="t8"))
|
||||
assert result["success"] is True
|
||||
assert result["pressed"] == "Enter"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Close
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCamofoxClose:
|
||||
@patch("tools.browser_camofox.requests.delete")
|
||||
@patch("tools.browser_camofox.requests.post")
|
||||
def test_close_session(self, mock_post, mock_delete, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
mock_post.return_value = _mock_response(json_data={"tabId": "tab9", "url": "https://x.com"})
|
||||
camofox_navigate("https://x.com", task_id="t9")
|
||||
|
||||
mock_delete.return_value = _mock_response(json_data={"ok": True})
|
||||
result = json.loads(camofox_close(task_id="t9"))
|
||||
assert result["success"] is True
|
||||
assert result["closed"] is True
|
||||
|
||||
def test_close_nonexistent_session(self, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
result = json.loads(camofox_close(task_id="nonexistent"))
|
||||
assert result["success"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Console (limited support)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCamofoxConsole:
|
||||
def test_console_returns_empty_with_note(self, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
result = json.loads(camofox_console(task_id="t_console"))
|
||||
assert result["success"] is True
|
||||
assert result["total_messages"] == 0
|
||||
assert "not available" in result["note"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Images
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCamofoxGetImages:
|
||||
@patch("tools.browser_camofox.requests.post")
|
||||
@patch("tools.browser_camofox.requests.get")
|
||||
def test_get_images(self, mock_get, mock_post, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
mock_post.return_value = _mock_response(json_data={"tabId": "tab10", "url": "https://x.com"})
|
||||
camofox_navigate("https://x.com", task_id="t10")
|
||||
|
||||
mock_get.return_value = _mock_response(json_data={
|
||||
"images": [{"src": "https://x.com/img.png", "alt": "Logo"}],
|
||||
})
|
||||
result = json.loads(camofox_get_images(task_id="t10"))
|
||||
assert result["success"] is True
|
||||
assert result["count"] == 1
|
||||
assert result["images"][0]["src"] == "https://x.com/img.png"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routing integration — verify browser_tool routes to camofox
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBrowserToolRouting:
|
||||
"""Verify that browser_tool.py delegates to camofox when CAMOFOX_URL is set."""
|
||||
|
||||
@patch("tools.browser_camofox.requests.post")
|
||||
def test_browser_navigate_routes_to_camofox(self, mock_post, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
mock_post.return_value = _mock_response(json_data={"tabId": "tab_rt", "url": "https://example.com"})
|
||||
|
||||
from tools.browser_tool import browser_navigate
|
||||
# Bypass SSRF check for test URL
|
||||
with patch("tools.browser_tool._is_safe_url", return_value=True):
|
||||
result = json.loads(browser_navigate("https://example.com", task_id="t_route"))
|
||||
assert result["success"] is True
|
||||
|
||||
def test_check_requirements_passes_with_camofox(self, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
from tools.browser_tool import check_browser_requirements
|
||||
assert check_browser_requirements() is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cleanup helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCamofoxCleanup:
|
||||
@patch("tools.browser_camofox.requests.post")
|
||||
@patch("tools.browser_camofox.requests.delete")
|
||||
def test_cleanup_all(self, mock_delete, mock_post, monkeypatch):
|
||||
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
|
||||
mock_post.return_value = _mock_response(json_data={"tabId": "tab_c", "url": "https://x.com"})
|
||||
camofox_navigate("https://x.com", task_id="t_cleanup")
|
||||
|
||||
mock_delete.return_value = _mock_response(json_data={"ok": True})
|
||||
cleanup_all_camofox_sessions()
|
||||
|
||||
# Session should be gone
|
||||
result = json.loads(camofox_snapshot(task_id="t_cleanup"))
|
||||
assert result["success"] is False
|
||||
Reference in New Issue
Block a user