From 280247e1e581051934e3063cd66d893b0620b47d Mon Sep 17 00:00:00 2001 From: andreysk0304 Date: Mon, 27 Apr 2026 22:06:57 +0300 Subject: [PATCH] Integrate per-user browser runtimes into subagent API --- .env.example | 11 +- api/Dockerfile | 4 + api/clients/browser_rpc_client.py | 5 +- api/clients/browser_rpc_contracts.py | 2 +- api/services/browser_runtime_manager.py | 464 ++++++++++++++++++ api/services/task_service.py | 35 +- api/tests/test_browser_runtime_manager.py | 97 ++++ .../test_task_service_browser_runtime.py | 62 +++ browser_env/entrypoint.sh | 32 +- browser_env/nginx.browser-view.conf | 46 ++ docker-compose.yml | 40 +- 11 files changed, 777 insertions(+), 21 deletions(-) create mode 100644 api/services/browser_runtime_manager.py create mode 100644 api/tests/test_browser_runtime_manager.py create mode 100644 api/tests/test_task_service_browser_runtime.py create mode 100644 browser_env/nginx.browser-view.conf diff --git a/.env.example b/.env.example index 16fbeacf..ddd72975 100644 --- a/.env.example +++ b/.env.example @@ -13,10 +13,17 @@ TELEGRAM_ALLOWED_USERS= TELEGRAM_HOME_CHANNEL= BROWSER_URL=http://browser:9222 -BROWSER_VIEW_URL= +BROWSER_VIEW_URL=http://localhost:6080 +BROWSER_VIEW_BASE_URL=http://localhost:6081 BROWSER_API_HOST=0.0.0.0 BROWSER_API_PORT=8088 BROWSER_USE_RPC_URL=http://browser:8787/run BROWSER_USE_RPC_TIMEOUT=900 -BROWSER_API_MAX_CONCURRENCY=2 \ No newline at end of file +BROWSER_API_MAX_CONCURRENCY=2 +BROWSER_USE_ISOLATION_MODE=docker-per-principal +BROWSER_RUNTIME_IMAGE=browser-use-browser-runtime:latest +BROWSER_RUNTIME_NETWORK=browser-net +BROWSER_RUNTIME_TTL_SECONDS=900 +BROWSER_RUNTIME_START_TIMEOUT=45 +BROWSER_RUNTIME_ENABLE_UI=true diff --git a/api/Dockerfile b/api/Dockerfile index 91d7b96b..670bdb59 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -5,6 +5,10 @@ ENV PYTHONUNBUFFERED=1 WORKDIR /app +RUN apt-get update \ + && apt-get install -y --no-install-recommends docker.io \ + && rm -rf /var/lib/apt/lists/* + COPY requirements.txt /app/requirements.txt RUN pip install --no-cache-dir uv \ && uv pip install --system --no-cache-dir -r /app/requirements.txt diff --git a/api/clients/browser_rpc_client.py b/api/clients/browser_rpc_client.py index 1fcbe6f4..ce227d43 100644 --- a/api/clients/browser_rpc_client.py +++ b/api/clients/browser_rpc_client.py @@ -10,12 +10,13 @@ class BrowserRpcClient: self._rpc_url = rpc_url self._session = session - async def run(self, task: str, timeout_sec: float) -> dict[str, Any]: + async def run(self, task: str, timeout_sec: float, rpc_url: str | None = None) -> dict[str, Any]: payload = {"task": task} timeout = aiohttp.ClientTimeout(total=timeout_sec) + target_url = rpc_url or self._rpc_url try: - async with self._session.post(self._rpc_url, json=payload, timeout=timeout) as response: + async with self._session.post(target_url, json=payload, timeout=timeout) as response: if response.status >= 400: body = await response.text() raise BrowserRpcError(f"RPC HTTP: {response.status}: {body}") diff --git a/api/clients/browser_rpc_contracts.py b/api/clients/browser_rpc_contracts.py index 77ff31fa..bec7c968 100644 --- a/api/clients/browser_rpc_contracts.py +++ b/api/clients/browser_rpc_contracts.py @@ -5,4 +5,4 @@ class BrowserRpcError(RuntimeError): ... class BrowserRpcRunner(Protocol): - async def run(self, task: str, timeout_sec: float) -> dict[str, Any]: ... + async def run(self, task: str, timeout_sec: float, rpc_url: str | None = None) -> dict[str, Any]: ... diff --git a/api/services/browser_runtime_manager.py b/api/services/browser_runtime_manager.py new file mode 100644 index 00000000..23e33a34 --- /dev/null +++ b/api/services/browser_runtime_manager.py @@ -0,0 +1,464 @@ +"""Provision isolated browser-use Docker runtimes for API runs.""" + +from __future__ import annotations + +import hashlib +import json +import logging +import os +import re +import subprocess +import tempfile +import threading +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any +from urllib import request + +logger = logging.getLogger(__name__) + +_DEFAULT_SHARED_CDP_URL = "http://browser:9222" +_DEFAULT_SHARED_RPC_URL = "http://browser:8787/run" +_DEFAULT_RUNTIME_IMAGE = "browser-use-browser-runtime:latest" +_DEFAULT_RUNTIME_NETWORK = "browser-net" +_DEFAULT_TTL_SECONDS = 900 +_DEFAULT_START_TIMEOUT = 45 +_DEFAULT_ENABLE_UI = True +_REGISTRY_LOCK = threading.Lock() +_VIEW_URL_CACHE_LOCK = threading.Lock() +_VIEW_URL_CACHE: dict[str, Any] = {"value": "", "expires_at": 0.0} + + +@dataclass(frozen=True) +class BrowserRuntimeConfig: + mode: str + runtime_image: str + runtime_network: str + runtime_ttl_seconds: int + runtime_start_timeout: int + shared_cdp_url: str + enable_ui: bool + + +def _state_dir() -> Path: + return Path(os.getenv("BROWSER_RUNTIME_STATE_DIR", "/tmp/browser-use-api")) + + +def _registry_path() -> Path: + return _state_dir() / "docker_runtimes.json" + + +def _as_int(value: Any, default: int) -> int: + try: + return max(1, int(value)) + except (TypeError, ValueError): + return default + + +def _as_bool(value: Any, default: bool) -> bool: + if value is None: + return default + if isinstance(value, bool): + return value + return str(value).strip().lower() in {"1", "true", "yes", "on"} + + +def get_browser_runtime_config() -> BrowserRuntimeConfig: + mode = str(os.getenv("BROWSER_USE_ISOLATION_MODE", "shared")).strip().lower() + if mode not in {"shared", "docker-per-principal", "docker-per-task"}: + logger.warning("Unknown browser-use isolation mode %r; falling back to shared", mode) + mode = "shared" + + return BrowserRuntimeConfig( + mode=mode, + runtime_image=os.getenv("BROWSER_RUNTIME_IMAGE", _DEFAULT_RUNTIME_IMAGE).strip() + or _DEFAULT_RUNTIME_IMAGE, + runtime_network=os.getenv("BROWSER_RUNTIME_NETWORK", _DEFAULT_RUNTIME_NETWORK).strip() + or _DEFAULT_RUNTIME_NETWORK, + runtime_ttl_seconds=_as_int( + os.getenv("BROWSER_RUNTIME_TTL_SECONDS"), + _DEFAULT_TTL_SECONDS, + ), + runtime_start_timeout=_as_int( + os.getenv("BROWSER_RUNTIME_START_TIMEOUT"), + _DEFAULT_START_TIMEOUT, + ), + shared_cdp_url=os.getenv("BROWSER_URL", _DEFAULT_SHARED_CDP_URL).strip() + or _DEFAULT_SHARED_CDP_URL, + enable_ui=_as_bool( + os.getenv("BROWSER_RUNTIME_ENABLE_UI"), + _DEFAULT_ENABLE_UI, + ), + ) + + +def resolve_isolation_owner( + mode: str, + task_id: str | None, + metadata: dict[str, Any] | None = None, + thread_id: str | None = None, +) -> str: + if mode == "docker-per-task": + return (task_id or "default").strip() or "default" + + metadata = metadata or {} + for key in ("user_id", "session_id"): + value = metadata.get(key) + if value not in (None, ""): + return str(value).strip() or "default" + + return (thread_id or task_id or "default").strip() or "default" + + +def hash_runtime_owner(owner: str) -> str: + return hashlib.sha256(owner.encode("utf-8")).hexdigest()[:16] + + +def _normalize_browser_view_base_url(raw_url: str) -> str: + url = (raw_url or "").strip() + if not url: + return "" + for marker in ("/vnc.html", "/index.html"): + idx = url.find(marker) + if idx != -1: + url = url[:idx] + break + return url.rstrip("/") + + +def _discover_browser_view_base_url_from_tunnel() -> str: + now = time.time() + with _VIEW_URL_CACHE_LOCK: + cached_value = str(_VIEW_URL_CACHE.get("value", "") or "") + expires_at = float(_VIEW_URL_CACHE.get("expires_at", 0.0) or 0.0) + if cached_value and now < expires_at: + return cached_value + + try: + result = _run_docker(["logs", "--tail", "200", "browser-use-tunnel"], check=False) + combined = "\n".join(part for part in [result.stdout or "", result.stderr or ""] if part) + matches = re.findall(r"https://[^\s\"'<>]+", combined) + base_url = _normalize_browser_view_base_url(matches[-1]) if matches else "" + except Exception as exc: + logger.debug("Failed to discover browser view URL from tunnel logs: %s", exc) + base_url = "" + + with _VIEW_URL_CACHE_LOCK: + _VIEW_URL_CACHE["value"] = base_url + _VIEW_URL_CACHE["expires_at"] = now + (60 if base_url else 10) + + return base_url + + +def get_browser_view_url( + task_id: str | None = None, + metadata: dict[str, Any] | None = None, + thread_id: str | None = None, +) -> str: + base_url = _normalize_browser_view_base_url( + os.getenv("BROWSER_VIEW_BASE_URL", "") or os.getenv("BROWSER_VIEW_URL", "") + ) + if not base_url: + base_url = _discover_browser_view_base_url_from_tunnel() + if not base_url: + return "" + + config = get_browser_runtime_config() + if config.mode == "shared": + return f"{base_url}/vnc.html?path=websockify" + + owner = resolve_isolation_owner(config.mode, task_id, metadata, thread_id) + owner_hash = hash_runtime_owner(owner) + return f"{base_url}/view/{owner_hash}/vnc.html?path=view/{owner_hash}/websockify" + + +def _shared_rpc_url() -> str: + return os.getenv("BROWSER_USE_RPC_URL", _DEFAULT_SHARED_RPC_URL).strip() or _DEFAULT_SHARED_RPC_URL + + +def _runtime_rpc_url(container_name: str) -> str: + return f"http://{container_name}:8787/run" + + +def _container_name(owner_hash: str) -> str: + return f"browser-use-browser-{owner_hash}" + + +def _volume_name(owner_hash: str) -> str: + return f"browser-use-profile-{owner_hash}" + + +def _load_registry() -> dict[str, Any]: + path = _registry_path() + if not path.exists(): + return {"runtimes": {}} + try: + with open(path, "r", encoding="utf-8") as fh: + data = json.load(fh) or {} + if isinstance(data, dict) and isinstance(data.get("runtimes"), dict): + return data + except Exception as exc: + logger.warning("Failed to read browser-use runtime registry %s: %s", path, exc) + return {"runtimes": {}} + + +def _save_registry(payload: dict[str, Any]) -> None: + path = _registry_path() + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_path = tempfile.mkstemp(dir=str(path.parent), prefix=".browser_use_", suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as fh: + json.dump(payload, fh, indent=2, sort_keys=True) + fh.flush() + os.fsync(fh.fileno()) + os.replace(tmp_path, path) + except Exception: + try: + os.unlink(tmp_path) + except OSError: + pass + raise + + +def _run_docker(args: list[str], check: bool = True) -> subprocess.CompletedProcess[str]: + cmd = ["docker", *args] + logger.debug("browser-use docker cmd: %s", " ".join(cmd)) + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=120, + ) + if check and result.returncode != 0: + stderr = (result.stderr or result.stdout or "").strip() + raise RuntimeError(f"Docker command failed ({' '.join(cmd)}): {stderr}") + return result + + +def _ensure_docker_access() -> None: + _run_docker(["version"], check=True) + + +def _container_exists(container_name: str) -> bool: + result = _run_docker(["inspect", container_name], check=False) + return result.returncode == 0 + + +def _container_running(container_name: str) -> bool: + result = _run_docker(["inspect", "-f", "{{.State.Running}}", container_name], check=False) + return result.returncode == 0 and result.stdout.strip().lower() == "true" + + +def _remove_container(container_name: str) -> None: + if container_name: + _run_docker(["rm", "-f", container_name], check=False) + + +def _volume_exists(volume_name: str) -> bool: + result = _run_docker(["volume", "inspect", volume_name], check=False) + return result.returncode == 0 + + +def _ensure_volume(volume_name: str, owner_hash: str) -> None: + if _volume_exists(volume_name): + return + _run_docker( + [ + "volume", + "create", + "--label", + "browser_use.runtime=true", + "--label", + f"browser_use.owner_hash={owner_hash}", + volume_name, + ], + check=True, + ) + + +def _remove_volume(volume_name: str) -> None: + if volume_name: + _run_docker(["volume", "rm", "-f", volume_name], check=False) + + +def _runtime_env_args(browser_view_url: str, config: BrowserRuntimeConfig) -> list[str]: + env: dict[str, str] = { + "BROWSER_ENABLE_UI": "true" if config.enable_ui else "false", + "BROWSER_DATA_DIR": "/data", + "BROWSER_USE_RPC_HOST": "0.0.0.0", + "BROWSER_USE_RPC_PORT": "8787", + } + + if browser_view_url: + env["BROWSER_VIEW_URL"] = browser_view_url + + for key in ("MODEL_DEFAULT", "OPENAI_API_KEY", "OPENAI_BASE_URL"): + value = os.getenv(key) + if value is not None: + env[key] = value + + args: list[str] = [] + for key, value in env.items(): + args.extend(["-e", f"{key}={value}"]) + return args + + +def _start_runtime_container( + container_name: str, + volume_name: str, + owner_hash: str, + browser_view_url: str, + config: BrowserRuntimeConfig, +) -> None: + _ensure_volume(volume_name, owner_hash) + run_args = [ + "run", + "-d", + "--name", + container_name, + "--network", + config.runtime_network, + "--shm-size", + "2g", + "--label", + "browser_use.runtime=true", + "--label", + f"browser_use.owner_hash={owner_hash}", + "--label", + "browser_use.managed_by=browser_runtime_manager", + *_runtime_env_args(browser_view_url, config), + "-v", + f"{volume_name}:/data", + config.runtime_image, + ] + _run_docker(run_args, check=True) + + +def _wait_for_runtime(container_name: str, timeout_seconds: int) -> None: + deadline = time.time() + timeout_seconds + health_url = f"http://{container_name}:8787/health" + last_error = "" + while time.time() < deadline: + try: + with request.urlopen(health_url, timeout=2) as response: + if 200 <= response.status < 300: + return + last_error = f"HTTP {response.status}" + except Exception as exc: + last_error = str(exc) + time.sleep(1) + raise RuntimeError(f"Browser runtime {container_name} did not become ready: {last_error}") + + +def _cleanup_expired_runtimes_locked(registry: dict[str, Any], config: BrowserRuntimeConfig) -> None: + now = time.time() + runtimes = registry.setdefault("runtimes", {}) + expired_keys: list[str] = [] + for runtime_key, entry in list(runtimes.items()): + last_used = float(entry.get("last_used", 0) or 0) + if not last_used or now - last_used < config.runtime_ttl_seconds: + continue + + container_name = str(entry.get("container_name", "") or "") + volume_name = str(entry.get("volume_name", "") or "") + mode = str(entry.get("mode", "") or "") + logger.info("Cleaning expired browser-use runtime %s (%s)", runtime_key, container_name) + _remove_container(container_name) + if mode == "docker-per-task": + _remove_volume(volume_name) + expired_keys.append(runtime_key) + + for runtime_key in expired_keys: + runtimes.pop(runtime_key, None) + + +def ensure_browser_runtime( + task_id: str | None = None, + metadata: dict[str, Any] | None = None, + thread_id: str | None = None, +) -> dict[str, str]: + config = get_browser_runtime_config() + if config.mode == "shared": + return { + "cdp_url": config.shared_cdp_url, + "rpc_url": _shared_rpc_url(), + "browser_view": get_browser_view_url(task_id=task_id, metadata=metadata, thread_id=thread_id), + "isolation_mode": "shared", + "owner_hash": "", + } + + _ensure_docker_access() + owner = resolve_isolation_owner(config.mode, task_id, metadata, thread_id) + owner_hash = hash_runtime_owner(owner) + runtime_key = f"{config.mode}:{owner_hash}" + container_name = _container_name(owner_hash) + volume_name = _volume_name(owner_hash) + browser_view_url = get_browser_view_url(task_id=task_id, metadata=metadata, thread_id=thread_id) + + with _REGISTRY_LOCK: + registry = _load_registry() + _cleanup_expired_runtimes_locked(registry, config) + + if _container_running(container_name): + registry.setdefault("runtimes", {})[runtime_key] = { + "container_name": container_name, + "volume_name": volume_name, + "last_used": time.time(), + "mode": config.mode, + "owner_hash": owner_hash, + } + _save_registry(registry) + return { + "cdp_url": f"http://{container_name}:9222", + "rpc_url": _runtime_rpc_url(container_name), + "browser_view": browser_view_url, + "isolation_mode": config.mode, + "owner_hash": owner_hash, + } + + if _container_exists(container_name): + _remove_container(container_name) + + _start_runtime_container(container_name, volume_name, owner_hash, browser_view_url, config) + _wait_for_runtime(container_name, config.runtime_start_timeout) + + registry.setdefault("runtimes", {})[runtime_key] = { + "container_name": container_name, + "volume_name": volume_name, + "last_used": time.time(), + "mode": config.mode, + "owner_hash": owner_hash, + } + _save_registry(registry) + + return { + "cdp_url": f"http://{container_name}:9222", + "rpc_url": _runtime_rpc_url(container_name), + "browser_view": browser_view_url, + "isolation_mode": config.mode, + "owner_hash": owner_hash, + } + + +def cleanup_browser_runtime( + task_id: str | None = None, + metadata: dict[str, Any] | None = None, + thread_id: str | None = None, +) -> None: + config = get_browser_runtime_config() + if config.mode != "docker-per-task": + return + + owner = resolve_isolation_owner(config.mode, task_id, metadata, thread_id) + owner_hash = hash_runtime_owner(owner) + runtime_key = f"{config.mode}:{owner_hash}" + container_name = _container_name(owner_hash) + volume_name = _volume_name(owner_hash) + + with _REGISTRY_LOCK: + registry = _load_registry() + _remove_container(container_name) + _remove_volume(volume_name) + registry.setdefault("runtimes", {}).pop(runtime_key, None) + _save_registry(registry) diff --git a/api/services/task_service.py b/api/services/task_service.py index 6546d9e0..afa5968c 100644 --- a/api/services/task_service.py +++ b/api/services/task_service.py @@ -5,6 +5,7 @@ from typing import Any from api.clients.browser_rpc_contracts import BrowserRpcError, BrowserRpcRunner from api.domain.task_status import TaskStatus from api.repositories.task_store import TaskRecord, TaskStore +from api.services.browser_runtime_manager import cleanup_browser_runtime, ensure_browser_runtime class TaskService: @@ -108,20 +109,28 @@ class TaskService: await self._store.publish(task_id, self._event(task_id, "started", {"status": TaskStatus.running.value})) async with self._semaphore: + runtime: dict[str, str] | None = None try: if rec.cancel_requested: await self._store.set_cancelled(task_id) await self._store.publish(task_id, self._event(task_id, "cancelled", {"status": TaskStatus.cancelled.value})) return + runtime = await asyncio.to_thread( + ensure_browser_runtime, + task_id=task_id, + metadata=rec.metadata, + thread_id=rec.thread_id, + ) rpc_timeout = float(rec.timeout) if self._rpc_timeout_cap is not None: rpc_timeout = min(rpc_timeout, self._rpc_timeout_cap) raw = await asyncio.wait_for( - self._rpc_client.run(task=rec.task, timeout_sec=rpc_timeout), + self._rpc_client.run(task=rec.task, timeout_sec=rpc_timeout, rpc_url=runtime.get("rpc_url")), timeout=float(rec.timeout) + 5, ) + raw = self._with_runtime_metadata(raw, runtime) success = bool(raw.get("success")) await self._store.set_done( task_id=task_id, @@ -188,6 +197,16 @@ class TaskService: "status": failed.status.value, "error": failed.error, })) + finally: + try: + await asyncio.to_thread( + cleanup_browser_runtime, + task_id=task_id, + metadata=rec.metadata, + thread_id=rec.thread_id, + ) + except Exception: + pass async def _publish_history_events(self, rec: TaskRecord) -> None: for index, item in enumerate(rec.history, start=1): @@ -225,3 +244,17 @@ class TaskService: normalized.append(event) return normalized + @staticmethod + def _with_runtime_metadata(raw: dict[str, Any], runtime: dict[str, str] | None) -> dict[str, Any]: + if not isinstance(raw, dict) or not runtime: + return raw + + enriched = dict(raw) + browser_view = runtime.get("browser_view") + if browser_view and not enriched.get("browser_view"): + enriched["browser_view"] = browser_view + enriched["isolation_mode"] = runtime.get("isolation_mode", "shared") + owner_hash = runtime.get("owner_hash") + if owner_hash: + enriched["owner_hash"] = owner_hash + return enriched diff --git a/api/tests/test_browser_runtime_manager.py b/api/tests/test_browser_runtime_manager.py new file mode 100644 index 00000000..b31f4577 --- /dev/null +++ b/api/tests/test_browser_runtime_manager.py @@ -0,0 +1,97 @@ +from unittest.mock import MagicMock, patch + + +def test_resolve_isolation_owner_prefers_user_id(): + from api.services.browser_runtime_manager import resolve_isolation_owner + + owner = resolve_isolation_owner( + "docker-per-principal", + task_id="task-1", + metadata={"user_id": "user-7", "session_id": "session-9"}, + thread_id="thread-1", + ) + + assert owner == "user-7" + + +def test_resolve_isolation_owner_uses_task_for_per_task_mode(): + from api.services.browser_runtime_manager import resolve_isolation_owner + + owner = resolve_isolation_owner( + "docker-per-task", + task_id="task-42", + metadata={"user_id": "user-7"}, + thread_id="thread-1", + ) + + assert owner == "task-42" + + +def test_hash_runtime_owner_is_stable(): + from api.services.browser_runtime_manager import hash_runtime_owner + + assert hash_runtime_owner("owner-1") == hash_runtime_owner("owner-1") + assert hash_runtime_owner("owner-1") != hash_runtime_owner("owner-2") + + +def test_shared_mode_returns_shared_runtime(monkeypatch): + from api.services import browser_runtime_manager + + monkeypatch.setenv("BROWSER_USE_ISOLATION_MODE", "shared") + monkeypatch.setenv("BROWSER_URL", "http://shared-browser:9333") + monkeypatch.setenv("BROWSER_USE_RPC_URL", "http://shared-browser:8787/run") + monkeypatch.setenv("BROWSER_VIEW_BASE_URL", "https://viewer.example.com") + + runtime = browser_runtime_manager.ensure_browser_runtime( + task_id="task-1", + metadata={"user_id": "user-7"}, + thread_id="thread-1", + ) + + assert runtime["cdp_url"] == "http://shared-browser:9333" + assert runtime["rpc_url"] == "http://shared-browser:8787/run" + assert runtime["browser_view"] == "https://viewer.example.com/vnc.html?path=websockify" + assert runtime["isolation_mode"] == "shared" + + +def test_isolated_mode_starts_container(monkeypatch): + from api.services import browser_runtime_manager + + monkeypatch.setenv("BROWSER_USE_ISOLATION_MODE", "docker-per-principal") + monkeypatch.setenv("BROWSER_RUNTIME_IMAGE", "browser-use-browser-runtime:test") + monkeypatch.setenv("BROWSER_RUNTIME_NETWORK", "browser-net") + monkeypatch.setenv("BROWSER_VIEW_BASE_URL", "https://viewer.example.com") + + saved_registry = {} + docker_calls = [] + + def fake_run_docker(args, check=True): + docker_calls.append(args) + if args[:2] == ["inspect", "-f"]: + return MagicMock(returncode=1, stdout="", stderr="") + if args[:1] == ["inspect"]: + return MagicMock(returncode=1, stdout="", stderr="") + return MagicMock(returncode=0, stdout="ok", stderr="") + + with ( + patch.object(browser_runtime_manager, "_load_registry", return_value={"runtimes": {}}), + patch.object(browser_runtime_manager, "_save_registry", side_effect=lambda payload: saved_registry.update(payload)), + patch.object(browser_runtime_manager, "_run_docker", side_effect=fake_run_docker), + patch.object(browser_runtime_manager, "_wait_for_runtime") as mock_wait, + ): + runtime = browser_runtime_manager.ensure_browser_runtime( + task_id="task-1", + metadata={"user_id": "user-7"}, + thread_id="thread-1", + ) + + assert runtime["isolation_mode"] == "docker-per-principal" + assert runtime["cdp_url"].startswith("http://browser-use-browser-") + assert runtime["rpc_url"].startswith("http://browser-use-browser-") + assert runtime["rpc_url"].endswith(":8787/run") + assert "/view/" in runtime["browser_view"] + assert saved_registry["runtimes"] + run_commands = [call for call in docker_calls if call[:2] == ["run", "-d"]] + assert run_commands + assert "browser-use-browser-runtime:test" in run_commands[0] + mock_wait.assert_called_once() diff --git a/api/tests/test_task_service_browser_runtime.py b/api/tests/test_task_service_browser_runtime.py new file mode 100644 index 00000000..d1dd29d2 --- /dev/null +++ b/api/tests/test_task_service_browser_runtime.py @@ -0,0 +1,62 @@ +import asyncio +from typing import Any + + +class FakeRpcClient: + def __init__(self) -> None: + self.calls: list[dict[str, Any]] = [] + + async def run(self, task: str, timeout_sec: float, rpc_url: str | None = None) -> dict[str, Any]: + self.calls.append({"task": task, "timeout_sec": timeout_sec, "rpc_url": rpc_url}) + return {"success": True, "result": "done"} + + +def test_task_service_routes_run_to_browser_runtime(monkeypatch): + from api.repositories.task_store import TaskStore + from api.services import task_service as task_service_module + from api.services.task_service import TaskService + + runtime = { + "rpc_url": "http://browser-use-browser-abc:8787/run", + "browser_view": "https://viewer.example.com/view/abc/vnc.html?path=view/abc/websockify", + "isolation_mode": "docker-per-principal", + "owner_hash": "abc", + } + cleanup_calls = [] + + monkeypatch.setattr(task_service_module, "ensure_browser_runtime", lambda **_: runtime) + monkeypatch.setattr(task_service_module, "cleanup_browser_runtime", lambda **kwargs: cleanup_calls.append(kwargs)) + + async def scenario(): + rpc_client = FakeRpcClient() + service = TaskService( + store=TaskStore(), + rpc_client=rpc_client, + max_concurrency=1, + rpc_timeout_cap=30, + ) + rec = await service.create_run( + thread_id="thread-1", + user_input="open example.com", + timeout=60, + metadata={"user_id": "user-7"}, + ) + done = await service.wait_run(rec.task_id, timeout=2) + await service.close() + return rpc_client, done + + rpc_client, done = asyncio.run(scenario()) + + assert rpc_client.calls == [ + { + "task": "open example.com", + "timeout_sec": 30, + "rpc_url": "http://browser-use-browser-abc:8787/run", + } + ] + assert done is not None + assert done.raw_response is not None + assert done.raw_response["browser_view"] == runtime["browser_view"] + assert done.raw_response["isolation_mode"] == "docker-per-principal" + assert done.raw_response["owner_hash"] == "abc" + assert cleanup_calls diff --git a/browser_env/entrypoint.sh b/browser_env/entrypoint.sh index 052ca6c5..34fbabea 100644 --- a/browser_env/entrypoint.sh +++ b/browser_env/entrypoint.sh @@ -7,10 +7,11 @@ XVFB_LOG="/tmp/xvfb.log" VNC_PORT="${VNC_PORT:-5900}" NOVNC_PORT="${NOVNC_PORT:-6080}" -CHROME_LOCAL_DEBUG_PORT="${CHROME_LOCAL_DEBUG_PORT:-9223}" -CHROME_PUBLIC_DEBUG_PORT="${CHROME_PUBLIC_DEBUG_PORT:-9222}" +CHROME_LOCAL_DEBUG_PORT="${CHROME_LOCAL_DEBUG_PORT:-${BROWSER_CHROME_DEBUG_PORT:-9223}}" +CHROME_PUBLIC_DEBUG_PORT="${CHROME_PUBLIC_DEBUG_PORT:-${BROWSER_CDP_PROXY_PORT:-9222}}" BROWSER_USE_RPC_PORT="${BROWSER_USE_RPC_PORT:-8787}" -CHROME_PROFILE_DIR="${CHROME_PROFILE_DIR:-/src/browser_data}" +CHROME_PROFILE_DIR="${CHROME_PROFILE_DIR:-${BROWSER_DATA_DIR:-/src/browser_data}}" +BROWSER_ENABLE_UI="${BROWSER_ENABLE_UI:-true}" MAX_RESTARTS="${MAX_RESTARTS:-10}" RESTART_WINDOW_SEC="${RESTART_WINDOW_SEC:-60}" @@ -116,19 +117,23 @@ if ! wait_for_x_display 15; then exit 1 fi -start_bg fluxbox -start_bg x11vnc -display "$DISPLAY" -rfbport "$VNC_PORT" -nopw -listen 0.0.0.0 -xkb -forever -shared -start_bg websockify --web=/usr/share/novnc/ "$NOVNC_PORT" "localhost:${VNC_PORT}" +if [ "$BROWSER_ENABLE_UI" != "false" ]; then + start_bg fluxbox + start_bg x11vnc -display "$DISPLAY" -rfbport "$VNC_PORT" -nopw -listen 0.0.0.0 -xkb -forever -shared + start_bg websockify --web=/usr/share/novnc/ "$NOVNC_PORT" "localhost:${VNC_PORT}" +fi start_bg socat "TCP-LISTEN:${CHROME_PUBLIC_DEBUG_PORT},fork,reuseaddr" "TCP:127.0.0.1:${CHROME_LOCAL_DEBUG_PORT}" start_bg python3 -u /src/browser_use_runner.py -if ! wait_for_port 127.0.0.1 "$VNC_PORT" 20; then - log "fatal: x11vnc did not open port ${VNC_PORT}" - exit 1 -fi -if ! wait_for_port 127.0.0.1 "$NOVNC_PORT" 20; then - log "fatal: websockify did not open port ${NOVNC_PORT}" - exit 1 +if [ "$BROWSER_ENABLE_UI" != "false" ]; then + if ! wait_for_port 127.0.0.1 "$VNC_PORT" 20; then + log "fatal: x11vnc did not open port ${VNC_PORT}" + exit 1 + fi + if ! wait_for_port 127.0.0.1 "$NOVNC_PORT" 20; then + log "fatal: websockify did not open port ${NOVNC_PORT}" + exit 1 + fi fi if ! wait_for_port 127.0.0.1 "$BROWSER_USE_RPC_PORT" 20; then log "fatal: browser-use RPC did not open port ${BROWSER_USE_RPC_PORT}" @@ -194,4 +199,3 @@ while true; do unset CHROME_EXIT unset CHROME_PID done - diff --git a/browser_env/nginx.browser-view.conf b/browser_env/nginx.browser-view.conf new file mode 100644 index 00000000..3796234a --- /dev/null +++ b/browser_env/nginx.browser-view.conf @@ -0,0 +1,46 @@ +events {} + +http { + resolver 127.0.0.11 ipv6=off; + + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + + server { + listen 8080; + server_name _; + + location = / { + add_header Content-Type text/plain; + return 200 "Browser view proxy is running.\n"; + } + + location / { + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_buffering off; + proxy_pass http://browser:6080; + } + + location ~ ^/view/(?[a-f0-9]{16})$ { + return 302 /view/$owner/vnc.html?path=view/$owner/websockify; + } + + location ~ ^/view/(?[a-f0-9]{16})/(?.*)$ { + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_buffering off; + proxy_pass http://browser-use-browser-$owner:6080/$rest$is_args$args; + } + } +} diff --git a/docker-compose.yml b/docker-compose.yml index ef959fd4..14f0da92 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,6 +3,7 @@ services: build: context: ./browser_env dockerfile: Dockerfile.browser + image: browser-use-browser-runtime:latest container_name: browser-use-browser environment: - MODEL_DEFAULT=${MODEL_DEFAULT:-qwen3.5-122b} @@ -39,11 +40,23 @@ services: - BROWSER_API_HOST=0.0.0.0 - BROWSER_API_PORT=8088 - BROWSER_API_MAX_CONCURRENCY=2 + - BROWSER_VIEW_BASE_URL=${BROWSER_VIEW_BASE_URL:-http://localhost:6081} + - BROWSER_USE_ISOLATION_MODE=${BROWSER_USE_ISOLATION_MODE:-docker-per-principal} + - BROWSER_RUNTIME_IMAGE=${BROWSER_RUNTIME_IMAGE:-browser-use-browser-runtime:latest} + - BROWSER_RUNTIME_NETWORK=${BROWSER_RUNTIME_NETWORK:-browser-net} + - BROWSER_RUNTIME_TTL_SECONDS=${BROWSER_RUNTIME_TTL_SECONDS:-900} + - BROWSER_RUNTIME_START_TIMEOUT=${BROWSER_RUNTIME_START_TIMEOUT:-45} + - BROWSER_RUNTIME_ENABLE_UI=${BROWSER_RUNTIME_ENABLE_UI:-true} + - MODEL_DEFAULT=${MODEL_DEFAULT:-qwen3.5-122b} + - OPENAI_API_KEY=${OPENAI_API_KEY} + - OPENAI_BASE_URL=${OPENAI_BASE_URL} depends_on: browser: condition: service_healthy ports: - "8088:8088" + volumes: + - /var/run/docker.sock:/var/run/docker.sock healthcheck: test: [ @@ -58,9 +71,34 @@ services: networks: - browser-net + browser-view-proxy: + image: nginx:alpine + container_name: browser-use-view-proxy + volumes: + - ./browser_env/nginx.browser-view.conf:/etc/nginx/nginx.conf:ro + depends_on: + browser: + condition: service_healthy + ports: + - "6081:8080" + restart: always + networks: + - browser-net + + tunnel: + image: cloudflare/cloudflared:latest + profiles: + - remote + container_name: browser-use-tunnel + restart: always + command: tunnel --protocol http2 --url http://browser-view-proxy:8080 --no-tls-verify + networks: + - browser-net + volumes: browser_profiles: networks: browser-net: - driver: bridge \ No newline at end of file + name: browser-net + driver: bridge