Merge browser runtime deployment changes
Some checks are pending
Deploy to BrowserUse VPS / deploy (push) Waiting to run

This commit is contained in:
andreysk0304 2026-04-28 18:16:57 +03:00
commit 6e94cc07c3
15 changed files with 1014 additions and 24 deletions

View file

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

@ -0,0 +1,22 @@
name: Deploy to BrowserUse VPS
on:
push:
branches:
- feature/api-for-subagent
workflow_dispatch:
jobs:
deploy:
runs-on: deploy-vps
env:
DEPLOY_DIR: /home/BrowserUse-vps/apps/BrowserUse_and_ComputerUse_skills
DEPLOY_BRANCH: feature/api-for-subagent
HEALTH_URL: http://127.0.0.1:8088/health
steps:
- name: Deploy Docker Compose stack
shell: bash
run: |
set -Eeuo pipefail
cd "$DEPLOY_DIR"
bash scripts/deploy_vps.sh

View file

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

View file

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

View file

@ -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]: ...

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

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}"
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,12 +117,15 @@ if ! wait_for_x_display 15; then
exit 1
fi
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 [ "$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
@ -130,6 +134,7 @@ 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}"
exit 1
@ -194,4 +199,3 @@ while true; do
unset CHROME_EXIT
unset CHROME_PID
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;
}
}
}

14
docker-compose.vps.yml Normal file
View file

@ -0,0 +1,14 @@
services:
browser-api:
networks:
- browser-net
- lambdalab_frontend
browser-view-proxy:
networks:
- browser-net
- lambdalab_frontend
networks:
lambdalab_frontend:
external: true

View file

@ -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}
@ -11,8 +12,8 @@ services:
- BROWSER_USE_RPC_HOST=0.0.0.0
- BROWSER_USE_RPC_PORT=8787
ports:
- "6080:6080"
- "9222:9222"
- "${BROWSER_NOVNC_PUBLISH:-6080:6080}"
- "${BROWSER_CDP_PUBLISH:-9222:9222}"
networks:
browser-net:
aliases:
@ -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"
- "${BROWSER_API_PUBLISH:-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:
- "${BROWSER_VIEW_PROXY_PUBLISH:-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:
name: browser-net
driver: bridge

133
docs/vps-deploy.md Normal file
View file

@ -0,0 +1,133 @@
# BrowserUse VPS Deployment
This project deploys to `BrowserUse-vps@lambda.coredump.ru` with a Gitea/Forgejo Actions runner installed on the VPS.
The server already has a root-owned `/opt/lambdalab` stack with Caddy on ports `80/443`. Keep this browser service as a separate app under the deploy user home directory, then attach the public-facing containers to the existing `lambdalab_frontend` Docker network through `docker-compose.vps.yml`.
## SSH Access
Add the public SSH key to the VPS user:
```sh
ssh BrowserUse-vps@lambda.coredump.ru
mkdir -p ~/.ssh
chmod 700 ~/.ssh
printf '%s\n' '<ssh-ed25519 public key>' >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys
```
The fingerprint `SHA256:/XC5ifPX8j+uRyp0Yw2zAl5nteWc3YcHeVHfCG+rhP4` is not enough by itself. `authorized_keys` needs the full public key line that starts with `ssh-ed25519`.
## Initial Server Checkout
Run once on the VPS:
```sh
mkdir -p ~/apps
cd ~/apps
git clone -b feature/api-for-subagent https://git.lambda.coredump.ru/APEX/BrowserUse_and_ComputerUse_skills.git
cd BrowserUse_and_ComputerUse_skills
```
Create a server-local `.env` file in the checkout. It is intentionally not committed:
```sh
OPENAI_API_KEY=...
OPENAI_BASE_URL=...
MODEL_DEFAULT=qwen3.5-122b
BROWSER_VIEW_BASE_URL=https://browser-view.lambda.coredump.ru
BROWSER_API_PUBLISH=127.0.0.1:8088:8088
BROWSER_VIEW_PROXY_PUBLISH=127.0.0.1:6081:8080
BROWSER_NOVNC_PUBLISH=127.0.0.1:6080:6080
BROWSER_CDP_PUBLISH=127.0.0.1:9222:9222
```
Then run the first deploy manually:
```sh
bash scripts/deploy_vps.sh
curl -fsS http://127.0.0.1:8088/health
```
The deploy script uses both Compose files by default:
```sh
docker-compose.yml:docker-compose.vps.yml
```
`docker-compose.vps.yml` connects `browser-api` and `browser-view-proxy` to the existing external `lambdalab_frontend` network so Caddy can reach them by Docker DNS.
## Domain Binding
The active Caddy config is root-owned at:
```sh
/opt/lambdalab/caddy/Caddyfile
```
Add these vhosts to that file from an admin/root account:
```caddyfile
browser-api.lambda.coredump.ru {
reverse_proxy browser-use-api:8088
}
browser-view.lambda.coredump.ru {
reverse_proxy browser-use-view-proxy:8080
}
```
Then reload the existing Caddy container from `/opt/lambdalab`:
```sh
cd /opt/lambdalab
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile
```
DNS must point both subdomains to the VPS public IP `155.212.185.120`. At inspection time, `lambda.coredump.ru` resolved to that IP, while `browser-api.lambda.coredump.ru` and `browser-view.lambda.coredump.ru` did not resolve yet.
## Gitea/Forgejo Runner
Install `act_runner` as the `BrowserUse-vps` user and register it with the repository, organization, or instance runner token:
```sh
mkdir -p ~/act_runner
cd ~/act_runner
./act_runner generate-config > config.yaml
./act_runner --config config.yaml register \
--no-interactive \
--instance https://git.lambda.coredump.ru \
--token '<runner-registration-token>' \
--name BrowserUse-vps \
--labels deploy-vps:host
```
Start it under the same user:
```sh
cd ~/act_runner
nohup ./act_runner daemon --config config.yaml > act_runner.log 2>&1 &
```
Because this account has `sudo: no`, a system-wide service cannot be installed from this user. If an admin enables a user-level systemd service for this account, run the same daemon command from that service instead of `nohup`.
## CI/CD Behavior
The workflow lives at `.gitea/workflows/deploy.yml`.
It runs on:
- push to `feature/api-for-subagent`
- manual `workflow_dispatch`
The job expects a runner label named `deploy-vps`, registered as `deploy-vps:host`. It enters:
```sh
/home/BrowserUse-vps/apps/BrowserUse_and_ComputerUse_skills
```
Then it fetches `origin/feature/api-for-subagent`, resets the tracked checkout to that commit, runs Docker Compose, and verifies:
```sh
curl -fsS http://127.0.0.1:8088/health
```

65
scripts/deploy_vps.sh Executable file
View file

@ -0,0 +1,65 @@
#!/usr/bin/env bash
set -Eeuo pipefail
DEPLOY_BRANCH="${DEPLOY_BRANCH:-feature/api-for-subagent}"
HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:8088/health}"
COMPOSE_FILES="${COMPOSE_FILES:-docker-compose.yml:docker-compose.vps.yml}"
log() {
printf '[deploy] %s\n' "$*"
}
fail() {
printf '[deploy] fatal: %s\n' "$*" >&2
exit 1
}
command -v git >/dev/null 2>&1 || fail "git is not installed"
command -v docker >/dev/null 2>&1 || fail "docker is not installed"
command -v curl >/dev/null 2>&1 || fail "curl is not installed"
docker compose version >/dev/null 2>&1 || fail "docker compose plugin is not available"
[ -d .git ] || fail "current directory is not a git checkout"
[ -f docker-compose.yml ] || fail "docker-compose.yml not found in current directory"
[ -f .env ] || fail ".env is missing; create it on the VPS with OPENAI_API_KEY and related runtime settings"
compose_args=()
IFS=':' read -r -a compose_files <<< "$COMPOSE_FILES"
for compose_file in "${compose_files[@]}"; do
if [ -f "$compose_file" ]; then
compose_args+=("-f" "$compose_file")
else
fail "compose file not found: ${compose_file}"
fi
done
log "fetching origin/${DEPLOY_BRANCH}"
git fetch --prune origin "+refs/heads/${DEPLOY_BRANCH}:refs/remotes/origin/${DEPLOY_BRANCH}"
log "checking out ${DEPLOY_BRANCH}"
git checkout -B "$DEPLOY_BRANCH" "origin/$DEPLOY_BRANCH"
git reset --hard "origin/$DEPLOY_BRANCH"
log "building Docker Compose services"
docker compose "${compose_args[@]}" build
log "starting Docker Compose stack"
docker compose "${compose_args[@]}" up -d --remove-orphans
log "current service state"
docker compose "${compose_args[@]}" ps
log "waiting for API health at ${HEALTH_URL}"
for attempt in {1..30}; do
if curl -fsS "$HEALTH_URL" >/dev/null; then
log "API is healthy"
exit 0
fi
log "health check failed, retry ${attempt}/30"
sleep 2
done
log "API did not become healthy; browser-api logs follow"
docker compose "${compose_args[@]}" logs --tail=120 browser-api || true
fail "health check failed: ${HEALTH_URL}"