Integrate per-user browser runtimes into subagent API

This commit is contained in:
andreysk0304 2026-04-27 22:06:57 +03:00
parent 952b2e7d17
commit 280247e1e5
11 changed files with 777 additions and 21 deletions

View file

@ -13,10 +13,17 @@ TELEGRAM_ALLOWED_USERS=
TELEGRAM_HOME_CHANNEL= TELEGRAM_HOME_CHANNEL=
BROWSER_URL=http://browser:9222 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_HOST=0.0.0.0
BROWSER_API_PORT=8088 BROWSER_API_PORT=8088
BROWSER_USE_RPC_URL=http://browser:8787/run BROWSER_USE_RPC_URL=http://browser:8787/run
BROWSER_USE_RPC_TIMEOUT=900 BROWSER_USE_RPC_TIMEOUT=900
BROWSER_API_MAX_CONCURRENCY=2 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

View file

@ -5,6 +5,10 @@ ENV PYTHONUNBUFFERED=1
WORKDIR /app 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 COPY requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir uv \ RUN pip install --no-cache-dir uv \
&& uv pip install --system --no-cache-dir -r /app/requirements.txt && uv pip install --system --no-cache-dir -r /app/requirements.txt

View file

@ -10,12 +10,13 @@ class BrowserRpcClient:
self._rpc_url = rpc_url self._rpc_url = rpc_url
self._session = session 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} payload = {"task": task}
timeout = aiohttp.ClientTimeout(total=timeout_sec) timeout = aiohttp.ClientTimeout(total=timeout_sec)
target_url = rpc_url or self._rpc_url
try: 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: if response.status >= 400:
body = await response.text() body = await response.text()
raise BrowserRpcError(f"RPC HTTP: {response.status}: {body}") raise BrowserRpcError(f"RPC HTTP: {response.status}: {body}")

View file

@ -5,4 +5,4 @@ class BrowserRpcError(RuntimeError): ...
class BrowserRpcRunner(Protocol): 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]: ...

View file

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

View file

@ -5,6 +5,7 @@ from typing import Any
from api.clients.browser_rpc_contracts import BrowserRpcError, BrowserRpcRunner from api.clients.browser_rpc_contracts import BrowserRpcError, BrowserRpcRunner
from api.domain.task_status import TaskStatus from api.domain.task_status import TaskStatus
from api.repositories.task_store import TaskRecord, TaskStore from api.repositories.task_store import TaskRecord, TaskStore
from api.services.browser_runtime_manager import cleanup_browser_runtime, ensure_browser_runtime
class TaskService: class TaskService:
@ -108,20 +109,28 @@ class TaskService:
await self._store.publish(task_id, self._event(task_id, "started", {"status": TaskStatus.running.value})) await self._store.publish(task_id, self._event(task_id, "started", {"status": TaskStatus.running.value}))
async with self._semaphore: async with self._semaphore:
runtime: dict[str, str] | None = None
try: try:
if rec.cancel_requested: if rec.cancel_requested:
await self._store.set_cancelled(task_id) await self._store.set_cancelled(task_id)
await self._store.publish(task_id, self._event(task_id, "cancelled", {"status": TaskStatus.cancelled.value})) await self._store.publish(task_id, self._event(task_id, "cancelled", {"status": TaskStatus.cancelled.value}))
return 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) rpc_timeout = float(rec.timeout)
if self._rpc_timeout_cap is not None: if self._rpc_timeout_cap is not None:
rpc_timeout = min(rpc_timeout, self._rpc_timeout_cap) rpc_timeout = min(rpc_timeout, self._rpc_timeout_cap)
raw = await asyncio.wait_for( 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, timeout=float(rec.timeout) + 5,
) )
raw = self._with_runtime_metadata(raw, runtime)
success = bool(raw.get("success")) success = bool(raw.get("success"))
await self._store.set_done( await self._store.set_done(
task_id=task_id, task_id=task_id,
@ -188,6 +197,16 @@ class TaskService:
"status": failed.status.value, "status": failed.status.value,
"error": failed.error, "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: async def _publish_history_events(self, rec: TaskRecord) -> None:
for index, item in enumerate(rec.history, start=1): for index, item in enumerate(rec.history, start=1):
@ -225,3 +244,17 @@ class TaskService:
normalized.append(event) normalized.append(event)
return normalized 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

View file

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

View file

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

View file

@ -7,10 +7,11 @@ XVFB_LOG="/tmp/xvfb.log"
VNC_PORT="${VNC_PORT:-5900}" VNC_PORT="${VNC_PORT:-5900}"
NOVNC_PORT="${NOVNC_PORT:-6080}" NOVNC_PORT="${NOVNC_PORT:-6080}"
CHROME_LOCAL_DEBUG_PORT="${CHROME_LOCAL_DEBUG_PORT:-9223}" CHROME_LOCAL_DEBUG_PORT="${CHROME_LOCAL_DEBUG_PORT:-${BROWSER_CHROME_DEBUG_PORT:-9223}}"
CHROME_PUBLIC_DEBUG_PORT="${CHROME_PUBLIC_DEBUG_PORT:-9222}" CHROME_PUBLIC_DEBUG_PORT="${CHROME_PUBLIC_DEBUG_PORT:-${BROWSER_CDP_PROXY_PORT:-9222}}"
BROWSER_USE_RPC_PORT="${BROWSER_USE_RPC_PORT:-8787}" 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}" MAX_RESTARTS="${MAX_RESTARTS:-10}"
RESTART_WINDOW_SEC="${RESTART_WINDOW_SEC:-60}" RESTART_WINDOW_SEC="${RESTART_WINDOW_SEC:-60}"
@ -116,19 +117,23 @@ if ! wait_for_x_display 15; then
exit 1 exit 1
fi fi
start_bg fluxbox if [ "$BROWSER_ENABLE_UI" != "false" ]; then
start_bg x11vnc -display "$DISPLAY" -rfbport "$VNC_PORT" -nopw -listen 0.0.0.0 -xkb -forever -shared start_bg fluxbox
start_bg websockify --web=/usr/share/novnc/ "$NOVNC_PORT" "localhost:${VNC_PORT}" 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 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 start_bg python3 -u /src/browser_use_runner.py
if ! wait_for_port 127.0.0.1 "$VNC_PORT" 20; then if [ "$BROWSER_ENABLE_UI" != "false" ]; then
log "fatal: x11vnc did not open port ${VNC_PORT}" if ! wait_for_port 127.0.0.1 "$VNC_PORT" 20; then
exit 1 log "fatal: x11vnc did not open port ${VNC_PORT}"
fi exit 1
if ! wait_for_port 127.0.0.1 "$NOVNC_PORT" 20; then fi
log "fatal: websockify did not open port ${NOVNC_PORT}" if ! wait_for_port 127.0.0.1 "$NOVNC_PORT" 20; then
exit 1 log "fatal: websockify did not open port ${NOVNC_PORT}"
exit 1
fi
fi fi
if ! wait_for_port 127.0.0.1 "$BROWSER_USE_RPC_PORT" 20; then 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}" 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_EXIT
unset CHROME_PID unset CHROME_PID
done done

View file

@ -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/(?<owner>[a-f0-9]{16})$ {
return 302 /view/$owner/vnc.html?path=view/$owner/websockify;
}
location ~ ^/view/(?<owner>[a-f0-9]{16})/(?<rest>.*)$ {
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;
}
}
}

View file

@ -3,6 +3,7 @@ services:
build: build:
context: ./browser_env context: ./browser_env
dockerfile: Dockerfile.browser dockerfile: Dockerfile.browser
image: browser-use-browser-runtime:latest
container_name: browser-use-browser container_name: browser-use-browser
environment: environment:
- MODEL_DEFAULT=${MODEL_DEFAULT:-qwen3.5-122b} - MODEL_DEFAULT=${MODEL_DEFAULT:-qwen3.5-122b}
@ -39,11 +40,23 @@ services:
- BROWSER_API_HOST=0.0.0.0 - BROWSER_API_HOST=0.0.0.0
- BROWSER_API_PORT=8088 - BROWSER_API_PORT=8088
- BROWSER_API_MAX_CONCURRENCY=2 - 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: depends_on:
browser: browser:
condition: service_healthy condition: service_healthy
ports: ports:
- "8088:8088" - "8088:8088"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
healthcheck: healthcheck:
test: test:
[ [
@ -58,9 +71,34 @@ services:
networks: networks:
- browser-net - 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: volumes:
browser_profiles: browser_profiles:
networks: networks:
browser-net: browser-net:
name: browser-net
driver: bridge driver: bridge