Compare commits
3 commits
952b2e7d17
...
6e94cc07c3
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e94cc07c3 | |||
| df7e4de7f9 | |||
| 280247e1e5 |
15 changed files with 1014 additions and 24 deletions
11
.env.example
11
.env.example
|
|
@ -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
|
||||||
|
|
|
||||||
22
.gitea/workflows/deploy.yml
Normal file
22
.gitea/workflows/deploy.yml
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}")
|
||||||
|
|
|
||||||
|
|
@ -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]: ...
|
||||||
|
|
|
||||||
464
api/services/browser_runtime_manager.py
Normal file
464
api/services/browser_runtime_manager.py
Normal 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)
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
97
api/tests/test_browser_runtime_manager.py
Normal file
97
api/tests/test_browser_runtime_manager.py
Normal 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()
|
||||||
62
api/tests/test_task_service_browser_runtime.py
Normal file
62
api/tests/test_task_service_browser_runtime.py
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
46
browser_env/nginx.browser-view.conf
Normal file
46
browser_env/nginx.browser-view.conf
Normal 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
14
docker-compose.vps.yml
Normal 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
|
||||||
|
|
@ -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}
|
||||||
|
|
@ -11,8 +12,8 @@ services:
|
||||||
- BROWSER_USE_RPC_HOST=0.0.0.0
|
- BROWSER_USE_RPC_HOST=0.0.0.0
|
||||||
- BROWSER_USE_RPC_PORT=8787
|
- BROWSER_USE_RPC_PORT=8787
|
||||||
ports:
|
ports:
|
||||||
- "6080:6080"
|
- "${BROWSER_NOVNC_PUBLISH:-6080:6080}"
|
||||||
- "9222:9222"
|
- "${BROWSER_CDP_PUBLISH:-9222:9222}"
|
||||||
networks:
|
networks:
|
||||||
browser-net:
|
browser-net:
|
||||||
aliases:
|
aliases:
|
||||||
|
|
@ -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"
|
- "${BROWSER_API_PUBLISH:-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:
|
||||||
|
- "${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:
|
volumes:
|
||||||
browser_profiles:
|
browser_profiles:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
browser-net:
|
browser-net:
|
||||||
driver: bridge
|
name: browser-net
|
||||||
|
driver: bridge
|
||||||
|
|
|
||||||
133
docs/vps-deploy.md
Normal file
133
docs/vps-deploy.md
Normal 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
65
scripts/deploy_vps.sh
Executable 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}"
|
||||||
Loading…
Add table
Add a link
Reference in a new issue