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