Просмотр исходного кода

重构(浏览器): 分离提供商选择与下载管理

保持浏览器工具类和 import 路径不变,将 Provider 判定和下载等待逻辑拆出,修复浏览器下载结果处理问题,并补充 Provider、下载与公开入口回归测试。
SamLee 17 часов назад
Родитель
Сommit
6fd566f4f7

+ 11 - 1
agent/agent/tools/builtin/browser/__init__.py

@@ -2,7 +2,7 @@
 浏览器工具 - Browser-Use 原生工具适配器
 
 基于 browser-use 实现的浏览器自动化工具集。
-28 个原始工具已合并为 14 个语义化入口(2026-04 重构)
+对外保留稳定的语义化工具入口
 """
 
 from agent.tools.builtin.browser.baseClass import (
@@ -29,6 +29,12 @@ from agent.tools.builtin.browser.baseClass import (
     browser_js,
     browser_download,
 )
+from agent.tools.builtin.browser.providers import (
+    BrowserContainerProvider,
+    BrowserCookieProvider,
+    configure_browser_providers,
+    reset_browser_providers,
+)
 
 __all__ = [
     # 会话管理
@@ -37,6 +43,10 @@ __all__ = [
     'get_browser_live_url',
     'cleanup_browser_session',
     'kill_browser_session',
+    'BrowserContainerProvider',
+    'BrowserCookieProvider',
+    'configure_browser_providers',
+    'reset_browser_providers',
 
     # @tool 入口
     'browser_navigate',

+ 43 - 248
agent/agent/tools/builtin/browser/baseClass.py

@@ -43,35 +43,29 @@ Native Browser-Use Tools Adapter
   这些工具功能更完善,支持diff预览、智能匹配、分页读取等
 """
 import logging
-import sys
-import os
 import json
-import httpx
 import asyncio
-import aiohttp
 import re
 import base64
 from urllib.parse import urlparse, parse_qs, unquote
 from typing import Literal, Optional, List, Dict, Any, Tuple, Union
 from pathlib import Path
 from langchain_core.runnables import RunnableLambda
-from argparse import Namespace # 使用 Namespace 快速构造带属性的对象
-from langchain_core.messages import AIMessage
+from argparse import Namespace
 from ....llm.qwen import qwen_llm_call
-
-# 将项目根目录添加到 Python 路径
-sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
-
-# 配置日志
-logger = logging.getLogger(__name__)
-
-# 导入框架的工具装饰器和结果类
 from agent.tools import tool, ToolResult
-from agent.tools.builtin.browser.sync_mysql_help import mysql
+from agent.tools.builtin.browser.downloads import download_direct_url
+from agent.tools.builtin.browser.providers import (
+    get_browser_container_provider,
+    get_browser_cookie_provider,
+)
 
 # 导入 browser-use 的核心类
 from browser_use import BrowserSession, BrowserProfile
+from browser_use.agent.views import ActionResult
+from browser_use.filesystem.file_system import FileSystem
 from browser_use.tools.service import Tools
+
 try:
     from browser_use.tools.views import ReadContentAction  # type: ignore
 except Exception:
@@ -81,8 +75,9 @@ except Exception:
         goal: str
         source: str = "page"
         context: str = ""
-from browser_use.agent.views import ActionResult
-from browser_use.filesystem.file_system import FileSystem
+
+
+logger = logging.getLogger(__name__)
 
 
 # ============================================================
@@ -103,129 +98,9 @@ _last_headless: bool = True
 _live_url: Optional[str] = None
 
 async def create_container(url: str, account_name: str = "liuwenwu") -> Dict[str, Any]:
-    """
-    创建浏览器容器并导航到指定URL
-
-    按照 test.md 的要求:
-    1.1 调用接口创建容器
-    1.2 调用接口创建窗口并导航到URL
+    """Create a container through the Host-configurable provider."""
 
-    Args:
-        url: 要导航的URL地址
-        account_name: 账户名称
-
-    Returns:
-        包含容器信息的字典:
-        - success: 是否成功
-        - container_id: 容器ID
-        - vnc: VNC访问URL
-        - cdp: CDP协议URL(用于浏览器连接)
-        - connection_id: 窗口连接ID
-        - error: 错误信息(如果失败)
-    """
-    result = {
-        "success": False,
-        "container_id": None,
-        "vnc": None,
-        "cdp": None,
-        "connection_id": None,
-        "error": None
-    }
-
-    try:
-        async with aiohttp.ClientSession() as session:
-            # 步骤1.1: 创建容器
-            print("📦 步骤1.1: 创建容器...")
-            create_url = "http://47.84.182.56:8200/api/v1/container/create"
-            create_payload = {
-                "auto_remove": True,
-                "need_port_binding": True,
-                "max_lifetime_seconds": 900
-            }
-
-            async with session.post(create_url, json=create_payload) as resp:
-                if resp.status != 200:
-                    raise RuntimeError(f"创建容器失败: HTTP {resp.status}")
-
-                create_result = await resp.json()
-                if create_result.get("code") != 0:
-                    raise RuntimeError(f"创建容器失败: {create_result.get('msg')}")
-
-                data = create_result.get("data", {})
-                result["container_id"] = data.get("container_id")
-                result["vnc"] = data.get("vnc")
-                result["cdp"] = data.get("cdp")
-
-                print(f"✅ 容器创建成功")
-                print(f"   Container ID: {result['container_id']}")
-                print(f"   VNC: {result['vnc']}")
-                print(f"   CDP: {result['cdp']}")
-
-            # 等待容器内的浏览器启动
-            print(f"\n⏳ 等待容器内浏览器启动...")
-            await asyncio.sleep(5)
-
-            # 步骤1.2: 创建页面并导航
-            print(f"\n📱 步骤1.2: 创建页面并导航到 {url}...")
-
-            page_create_url = "http://47.84.182.56:8200/api/v1/browser/page/create"
-            page_payload = {
-                "container_id": result["container_id"],
-                "url": url,
-                "account_name": account_name,
-                "need_wait": True,
-                "timeout": 30
-            }
-
-            # 重试机制:最多尝试3次
-            max_retries = 3
-            page_created = False
-            last_error = None
-
-            for attempt in range(max_retries):
-                try:
-                    if attempt > 0:
-                        print(f"   重试 {attempt + 1}/{max_retries}...")
-                        await asyncio.sleep(3)  # 重试前等待
-
-                    async with session.post(page_create_url, json=page_payload, timeout=aiohttp.ClientTimeout(total=60)) as resp:
-                        if resp.status != 200:
-                            response_text = await resp.text()
-                            last_error = f"HTTP {resp.status}: {response_text[:200]}"
-                            continue
-
-                        page_result = await resp.json()
-                        if page_result.get("code") != 0:
-                            last_error = f"{page_result.get('msg')}"
-                            continue
-
-                        page_data = page_result.get("data", {})
-                        result["connection_id"] = page_data.get("connection_id")
-                        result["success"] = True
-                        page_created = True
-
-                        print(f"✅ 页面创建成功")
-                        print(f"   Connection ID: {result['connection_id']}")
-                        break
-
-                except asyncio.TimeoutError:
-                    last_error = "请求超时"
-                    continue
-                except aiohttp.ClientError as e:
-                    last_error = f"网络错误: {str(e)}"
-                    continue
-                except Exception as e:
-                    last_error = f"未知错误: {str(e)}"
-                    continue
-
-            if not page_created:
-                raise RuntimeError(f"创建页面失败(尝试{max_retries}次后): {last_error}")
-
-    except Exception as e:
-        result["error"] = str(e)
-        print(f"❌ 错误: {str(e)}")
-
-    return result
+    return await get_browser_container_provider().create(url, account_name)
 
 async def init_browser_session(
     browser_type: str = "local",
@@ -260,8 +135,9 @@ async def init_browser_session(
     }
 
     if browser_type == "container":
-        print("🐳 使用容器浏览器模式")
-        if not url: url = "about:blank"
+        logger.info("Initializing container browser session")
+        if not url:
+            url = "about:blank"
         container_info = await create_container(url=url, account_name=profile_name)
         if not container_info["success"]:
             raise RuntimeError(f"容器创建失败: {container_info['error']}")
@@ -269,13 +145,13 @@ async def init_browser_session(
         await asyncio.sleep(3)
 
     elif browser_type == "cloud":
-        print("🌐 使用云浏览器模式")
+        logger.info("Initializing cloud browser session")
         session_params["use_cloud"] = True
         if profile_name and profile_name != "default":
             session_params["cloud_profile_id"] = profile_name
 
     else:  # local
-        print("💻 使用本地浏览器模式")
+        logger.info("Initializing local browser session")
         session_params["is_local"] = True
         if user_data_dir is None and profile_name:
             user_data_dir = str(Path.home() / ".browser_use" / "profiles" / profile_name)
@@ -303,7 +179,7 @@ async def init_browser_session(
     _browser_tools = Tools()
     _file_system = FileSystem(base_dir=str(save_dir))
 
-    print(f"✅ 浏览器会话初始化成功 | 默认下载路径: {save_dir}")
+    logger.info("Browser session initialized: downloads=%s", save_dir)
 
     # 云浏览器:捕获 live URL
     if browser_type == "cloud":
@@ -314,7 +190,7 @@ async def init_browser_session(
             parsed = urllib.parse.urlparse(cdp_url)
             host_url = f"https://{parsed.hostname}"
             _live_url = f"https://live.browser-use.com?wss={urllib.parse.quote(host_url)}"
-            print(f"📡 实时画面链接: {_live_url}")
+            logger.info("Browser live view available: %s", _live_url)
 
     if browser_type in ["local", "cloud"] and url:
         await _browser_tools.navigate(url=url, browser_session=_browser_session)
@@ -355,10 +231,10 @@ async def get_browser_session() -> tuple[BrowserSession, Tools]:
                 )
                 alive = True
         except Exception:
-            pass
+            logger.debug("Browser CDP health check failed", exc_info=True)
 
         if not alive:
-            print("⚠️ 浏览器会话连接已断开,正在重新初始化...")
+            logger.warning("Browser session disconnected; reinitializing")
             try:
                 await cleanup_browser_session()
             except Exception:
@@ -432,10 +308,8 @@ def action_result_to_tool_result(result: ActionResult, title: str = None) -> Too
 
 
 def _cookie_domain_for_type(cookie_type: str, url: str) -> Tuple[str, str]:
-    if cookie_type:
-        key = cookie_type.lower()
-        if key in {"xiaohongshu", "xhs"}:
-            return ".xiaohongshu.com", "https://www.xiaohongshu.com"
+    # ``cookie_type`` remains in the compatibility signature; scope is derived
+    # from the requested URL so the framework does not encode platform names.
     parsed = urlparse(url or "")
     domain = parsed.netloc or ""
     domain = domain.replace("www.", "")
@@ -516,27 +390,9 @@ def _fetch_cookie_row(cookie_type: str) -> Optional[Dict[str, Any]]:
     if not cookie_type:
         return None
     try:
-        return mysql.fetchone(
-            "select * from agent_channel_cookies where type=%s limit 1",
-            (cookie_type,)
-        )
-    except Exception:
-        return None
-
-
-def _fetch_profile_id(cookie_type: str) -> Optional[str]:
-    """从数据库获取 cloud_profile_id"""
-    if not cookie_type:
-        return None
-    try:
-        row = mysql.fetchone(
-            "select profileId from agent_channel_cookies where type=%s limit 1",
-            (cookie_type,)
-        )
-        if row and "profileId" in row:
-            return row["profileId"]
-        return None
-    except Exception:
+        return get_browser_cookie_provider().get_cookie_row(cookie_type)
+    except Exception as exc:
+        logger.warning("Browser cookie lookup failed for %s: %s", cookie_type, exc)
         return None
 
 
@@ -725,71 +581,11 @@ class DownloadLinkCaptureHandler(logging.Handler):
                 # 再次过滤:如果发现提取出的 URL 确实包含三个点,说明依然抓到了截断版,跳过
                 if "..." not in url:
                     self.captured_url = url
-                    # print(f"🎯 成功锁定完整直链: {url[:50]}...") # 调试用
 
 async def browser_download_direct_url(url: str, save_name: str = "book.epub") -> ToolResult:
-    save_dir = Path.cwd() / ".cache/.browser_use_files"
-    save_dir.mkdir(parents=True, exist_ok=True)
-    
-    # 提取域名作为 Referer,这能骗过 90% 的防盗链校验
-    from urllib.parse import urlparse
-    parsed_url = urlparse(url)
-    base_url = f"{parsed_url.scheme}://{parsed_url.netloc}/"
-    
-    # 如果没传 save_name,自动从 URL 获取
-    if not save_name:
-        import unquote
-        # 尝试从 URL 路径获取文件名并解码(处理中文)
-        save_name = Path(urlparse(url).path).name or f"download_{int(time.time())}"
-        save_name = unquote(save_name) 
-
-    target_path = save_dir / save_name
-
-    headers = {
-        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
-        "Accept": "*/*",
-        "Referer": base_url,  # 动态设置 Referer
-        "Range": "bytes=0-",  # 有时对大文件下载有奇效
-    }
+    """Compatibility wrapper for the isolated download implementation."""
 
-    try:
-        print(f"🚀 开始下载: {url[:60]}...")
-        
-        # 使用 follow_redirects=True 处理链接中的 redirection
-        async with httpx.AsyncClient(headers=headers, follow_redirects=True, timeout=60.0) as client:
-            async with client.stream("GET", url) as response:
-                if response.status_code != 200:
-                    print(f"❌ 下载失败,HTTP 状态码: {response.status_code}")
-                    return
-                
-                # 获取实际文件名(如果服务器提供了)
-                # 这里会优先使用你指定的 save_name
-                
-                with open(target_path, "wb") as f:
-                    downloaded_bytes = 0
-                    async for chunk in response.aiter_bytes():
-                        f.write(chunk)
-                        downloaded_bytes += len(chunk)
-                        if downloaded_bytes % (1024 * 1024) == 0: # 每下载 1MB 打印一次
-                            print(f"📥 已下载: {downloaded_bytes // (1024 * 1024)} MB")
-
-        print(f"✅ 下载完成!文件已存至: {target_path}")
-        success_msg = f"✅ 下载完成!文件已存至: {target_path}"
-        return ToolResult(
-            title="直链下载成功",
-            output=success_msg,
-            long_term_memory=success_msg,
-            metadata={"path": str(target_path)}
-        )
-
-    except Exception as e:
-        # 异常捕获返回
-        return ToolResult(
-            title="下载异常",
-            output="",
-            error=f"💥 发生错误: {str(e)}",
-            long_term_memory=f"下载任务由于异常中断: {str(e)}"
-        )
+    return await download_direct_url(url, save_name)
     
 async def browser_click_element(index: int) -> ToolResult:
     """
@@ -873,7 +669,7 @@ async def browser_input_text(index: int, text: str, clear: bool = True) -> ToolR
             title="输入失败",
             output="",
             error=f"Failed to input text into element {index}: {str(e)}",
-            long_term_memory=f"输入文本失败"
+            long_term_memory="输入文本失败"
         )
 
 
@@ -1362,7 +1158,7 @@ def scrub_search_redirect_url(url: str) -> str:
                 return unquote(found)
                 
     except Exception:
-        pass # 解析失败则返回原链接
+        logger.debug("Search redirect URL parsing failed", exc_info=True)
     
     return url
 
@@ -1387,7 +1183,6 @@ async def extraction_adapter(input_data):
         if clean_url != original_url:
             content = content.replace(original_url, clean_url)
     
-    from argparse import Namespace
     return Namespace(completion=content)
 
 async def browser_extract_content(query: str, extract_links: bool = False,
@@ -1461,7 +1256,7 @@ async def _detect_and_download_pdf_via_cdp(browser) -> Optional[str]:
                 content_type = ct_result.get('result', {}).get('value', '')
                 is_pdf = 'pdf' in content_type.lower()
             except Exception:
-                pass
+                logger.debug("Browser PDF content-type probe failed", exc_info=True)
 
         if not is_pdf:
             return None
@@ -1497,12 +1292,12 @@ async def _detect_and_download_pdf_via_cdp(browser) -> Optional[str]:
 
         value = result.get('result', {}).get('value', '')
         if not value:
-            print("⚠️ CDP fetch PDF: 无返回值")
+            logger.warning("CDP PDF fetch returned no value")
             return None
 
         data = json.loads(value)
         if 'error' in data:
-            print(f"⚠️ CDP fetch PDF 失败: {data['error']}")
+            logger.warning("CDP PDF fetch failed: %s", data["error"])
             return None
 
         # 从 data URL 中提取 base64 并解码
@@ -1523,11 +1318,11 @@ async def _detect_and_download_pdf_via_cdp(browser) -> Optional[str]:
         with open(save_path, 'wb') as f:
             f.write(pdf_bytes)
 
-        print(f"📄 PDF 已通过 CDP 下载到: {save_path} ({len(pdf_bytes)} bytes)")
+        logger.info("CDP PDF saved: path=%s bytes=%d", save_path, len(pdf_bytes))
         return save_path
 
     except Exception as e:
-        print(f"⚠️ PDF 检测/下载异常: {e}")
+        logger.warning("CDP PDF detection/download failed: %s", e)
         return None
 
 
@@ -1885,12 +1680,12 @@ async def browser_wait_for_user_action(message: str = "Please complete the actio
         import asyncio
 
         print(f"\n{'='*60}")
-        print(f"⏸️  WAITING FOR USER ACTION")
+        print("⏸️  WAITING FOR USER ACTION")
         print(f"{'='*60}")
         print(f"📝 {message}")
         print(f"⏱️  Timeout: {timeout} seconds")
-        print(f"\n👉 Please complete the action in the browser window")
-        print(f"👉 Press ENTER when done, or wait for timeout")
+        print("\n👉 Please complete the action in the browser window")
+        print("👉 Press ENTER when done, or wait for timeout")
         print(f"{'='*60}\n")
 
         # Wait for user input or timeout
@@ -2150,7 +1945,7 @@ async def browser_load_cookies(url: str, name: str = "", auto_navigate: bool = T
 
 
 # ============================================================
-# 新版统一入口(13 个 @tool,替代原来 28 个)
+# 对外统一的浏览器工具入口
 # ============================================================
 
 
@@ -2461,7 +2256,7 @@ async def browser_download(url: str, save_name: str = "") -> ToolResult:
         url: 文件 URL
         save_name: 保存文件名(可选,默认自动推断)
     """
-    return await browser_download_direct_url(url=url, save_name=save_name or "download")
+    return await browser_download_direct_url(url=url, save_name=save_name)
 
 
 # ============================================================
@@ -2476,7 +2271,7 @@ __all__ = [
     'cleanup_browser_session',
     'kill_browser_session',
 
-    # 13 个 @tool 入口
+    # 14 个 @tool 入口
     'browser_navigate',
     'browser_search',
     'browser_back',

+ 110 - 0
agent/agent/tools/builtin/browser/downloads.py

@@ -0,0 +1,110 @@
+"""Reliable direct-download implementation for browser tools."""
+
+from __future__ import annotations
+
+import logging
+import os
+import time
+from pathlib import Path
+from typing import Optional
+from urllib.parse import unquote, urlparse
+
+import httpx
+
+from agent.tools import ToolResult
+
+
+logger = logging.getLogger(__name__)
+
+
+def _safe_download_name(url: str, requested_name: str) -> str:
+    candidate = requested_name.strip()
+    if not candidate:
+        candidate = unquote(Path(urlparse(url).path).name)
+    candidate = Path(candidate).name
+    if candidate in {"", ".", ".."}:
+        candidate = f"download_{int(time.time())}"
+    return candidate
+
+
+async def download_direct_url(
+    url: str,
+    save_name: str = "book.epub",
+    *,
+    download_dir: Optional[Path] = None,
+) -> ToolResult:
+    """Stream a URL to an atomic local file and always return ``ToolResult``."""
+
+    target_dir = download_dir or Path.cwd() / ".cache/.browser_use_files"
+    target_dir.mkdir(parents=True, exist_ok=True)
+    target_path = target_dir / _safe_download_name(url, save_name)
+    partial_path = target_path.with_name(f".{target_path.name}.part")
+    partial_path.unlink(missing_ok=True)
+    parsed_url = urlparse(url)
+    referer = f"{parsed_url.scheme}://{parsed_url.netloc}/"
+    headers = {
+        "User-Agent": (
+            "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
+            "AppleWebKit/537.36 (KHTML, like Gecko) "
+            "Chrome/120.0.0.0 Safari/537.36"
+        ),
+        "Accept": "*/*",
+        "Referer": referer,
+        "Range": "bytes=0-",
+    }
+
+    try:
+        downloaded_bytes = 0
+        status_code = 0
+        async with httpx.AsyncClient(
+            headers=headers,
+            follow_redirects=True,
+            timeout=60.0,
+        ) as client:
+            async with client.stream("GET", url) as response:
+                status_code = response.status_code
+                if status_code not in {200, 206}:
+                    error = f"HTTP {status_code}"
+                    return ToolResult(
+                        title="直链下载失败",
+                        output="",
+                        error=error,
+                        long_term_memory=f"下载失败: {error}",
+                        metadata={"url": url, "status_code": status_code},
+                    )
+                with partial_path.open("wb") as output:
+                    async for chunk in response.aiter_bytes():
+                        output.write(chunk)
+                        downloaded_bytes += len(chunk)
+        os.replace(partial_path, target_path)
+        message = f"下载完成,文件已保存至: {target_path}"
+        logger.info(
+            "Browser download completed: path=%s bytes=%d status=%d",
+            target_path,
+            downloaded_bytes,
+            status_code,
+        )
+        return ToolResult(
+            title="直链下载成功",
+            output=message,
+            long_term_memory=message,
+            metadata={
+                "path": str(target_path),
+                "bytes": downloaded_bytes,
+                "status_code": status_code,
+            },
+        )
+    except (httpx.HTTPError, OSError) as exc:
+        partial_path.unlink(missing_ok=True)
+        error = str(exc) or type(exc).__name__
+        logger.warning("Browser download failed: url=%s error=%s", url, error)
+        return ToolResult(
+            title="下载异常",
+            output="",
+            error=error,
+            long_term_memory=f"下载任务由于异常中断: {error}",
+            metadata={"url": url},
+        )
+
+
+__all__ = ["download_direct_url"]

+ 192 - 0
agent/agent/tools/builtin/browser/providers.py

@@ -0,0 +1,192 @@
+"""Host-configurable providers used by the browser adapter.
+
+The browser tools remain usable with their historical defaults, while Host
+applications can replace container provisioning and cookie lookup without the
+framework knowing their service endpoints or database schema.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import os
+from dataclasses import dataclass
+from typing import Any, Dict, Optional, Protocol, runtime_checkable
+
+import httpx
+
+
+logger = logging.getLogger(__name__)
+
+LEGACY_CONTAINER_BASE_URL = "http://47.84.182.56:8200"
+
+
+@runtime_checkable
+class BrowserContainerProvider(Protocol):
+    """Provision a browser container and navigate its first page."""
+
+    async def create(self, url: str, account_name: str) -> Dict[str, Any]: ...
+
+
+@runtime_checkable
+class BrowserCookieProvider(Protocol):
+    """Look up a cookie record by a Host-defined logical name."""
+
+    def get_cookie_row(self, cookie_type: str) -> Optional[Dict[str, Any]]: ...
+
+
+@dataclass
+class HttpContainerProvider:
+    """HTTP adapter for the legacy container service contract."""
+
+    base_url: str
+    startup_delay: float = 5.0
+    retry_delay: float = 3.0
+    max_retries: int = 3
+
+    @classmethod
+    def from_environment(cls) -> "HttpContainerProvider":
+        return cls(
+            base_url=os.getenv(
+                "AGENT_BROWSER_CONTAINER_BASE_URL",
+                LEGACY_CONTAINER_BASE_URL,
+            ).rstrip("/"),
+        )
+
+    async def create(self, url: str, account_name: str) -> Dict[str, Any]:
+        result: Dict[str, Any] = {
+            "success": False,
+            "container_id": None,
+            "vnc": None,
+            "cdp": None,
+            "connection_id": None,
+            "error": None,
+        }
+        try:
+            async with httpx.AsyncClient(timeout=60.0) as client:
+                response = await client.post(
+                    f"{self.base_url}/api/v1/container/create",
+                    json={
+                        "auto_remove": True,
+                        "need_port_binding": True,
+                        "max_lifetime_seconds": 900,
+                    },
+                )
+                response.raise_for_status()
+                payload = response.json()
+                if payload.get("code") != 0:
+                    raise RuntimeError(
+                        f"创建容器失败: {payload.get('msg') or 'unknown error'}"
+                    )
+                data = payload.get("data") or {}
+                result.update(
+                    container_id=data.get("container_id"),
+                    vnc=data.get("vnc"),
+                    cdp=data.get("cdp"),
+                )
+
+                if self.startup_delay > 0:
+                    await asyncio.sleep(self.startup_delay)
+
+                last_error = "unknown error"
+                for attempt in range(self.max_retries):
+                    if attempt and self.retry_delay > 0:
+                        await asyncio.sleep(self.retry_delay)
+                    try:
+                        page_response = await client.post(
+                            f"{self.base_url}/api/v1/browser/page/create",
+                            json={
+                                "container_id": result["container_id"],
+                                "url": url,
+                                "account_name": account_name,
+                                "need_wait": True,
+                                "timeout": 30,
+                            },
+                        )
+                        page_response.raise_for_status()
+                        page_payload = page_response.json()
+                        if page_payload.get("code") != 0:
+                            last_error = str(
+                                page_payload.get("msg") or "unknown service error"
+                            )
+                            continue
+                        page_data = page_payload.get("data") or {}
+                        result.update(
+                            success=True,
+                            connection_id=page_data.get("connection_id"),
+                        )
+                        return result
+                    except (httpx.HTTPError, ValueError) as exc:
+                        last_error = str(exc) or type(exc).__name__
+                raise RuntimeError(
+                    f"创建页面失败(尝试{self.max_retries}次后): {last_error}"
+                )
+        except Exception as exc:
+            result["error"] = str(exc) or type(exc).__name__
+            logger.exception(
+                "Browser container provisioning failed: %s",
+                result["error"],
+            )
+            return result
+
+
+class LegacyDatabaseCookieProvider:
+    """Compatibility adapter for the historical browser cookie table."""
+
+    def get_cookie_row(self, cookie_type: str) -> Optional[Dict[str, Any]]:
+        from agent.tools.builtin.browser.sync_mysql_help import mysql
+
+        return mysql.fetchone(
+            "select * from agent_channel_cookies where type=%s limit 1",
+            (cookie_type,),
+        )
+
+
+_container_provider: BrowserContainerProvider = HttpContainerProvider.from_environment()
+_cookie_provider: BrowserCookieProvider = LegacyDatabaseCookieProvider()
+
+
+def configure_browser_providers(
+    *,
+    container_provider: Optional[BrowserContainerProvider] = None,
+    cookie_provider: Optional[BrowserCookieProvider] = None,
+) -> None:
+    """Replace browser infrastructure adapters for the current process."""
+
+    global _container_provider, _cookie_provider
+    if container_provider is not None:
+        if not isinstance(container_provider, BrowserContainerProvider):
+            raise TypeError(
+                "container_provider must implement BrowserContainerProvider"
+            )
+        _container_provider = container_provider
+    if cookie_provider is not None:
+        if not isinstance(cookie_provider, BrowserCookieProvider):
+            raise TypeError("cookie_provider must implement BrowserCookieProvider")
+        _cookie_provider = cookie_provider
+
+
+def reset_browser_providers() -> None:
+    """Restore compatibility providers; intended for Host teardown and tests."""
+
+    global _container_provider, _cookie_provider
+    _container_provider = HttpContainerProvider.from_environment()
+    _cookie_provider = LegacyDatabaseCookieProvider()
+
+
+def get_browser_container_provider() -> BrowserContainerProvider:
+    return _container_provider
+
+
+def get_browser_cookie_provider() -> BrowserCookieProvider:
+    return _cookie_provider
+
+
+__all__ = [
+    "BrowserContainerProvider",
+    "BrowserCookieProvider",
+    "HttpContainerProvider",
+    "LegacyDatabaseCookieProvider",
+    "configure_browser_providers",
+    "reset_browser_providers",
+]

+ 166 - 0
agent/tests/test_browser_governance.py

@@ -0,0 +1,166 @@
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any, Dict, Optional
+
+import pytest
+
+from agent.tools.builtin.browser import downloads, providers
+
+
+class _FakeResponse:
+    def __init__(self, status_code: int, chunks: tuple[bytes, ...] = ()) -> None:
+        self.status_code = status_code
+        self._chunks = chunks
+
+    async def __aenter__(self) -> "_FakeResponse":
+        return self
+
+    async def __aexit__(self, *_args: object) -> None:
+        return None
+
+    async def aiter_bytes(self):
+        for chunk in self._chunks:
+            yield chunk
+
+
+class _FakeClient:
+    response = _FakeResponse(200)
+
+    def __init__(self, **_kwargs: object) -> None:
+        pass
+
+    async def __aenter__(self) -> "_FakeClient":
+        return self
+
+    async def __aexit__(self, *_args: object) -> None:
+        return None
+
+    def stream(self, _method: str, _url: str) -> _FakeResponse:
+        return self.response
+
+
+class _ContainerProvider:
+    async def create(self, url: str, account_name: str) -> Dict[str, Any]:
+        return {"success": True, "url": url, "account_name": account_name}
+
+
+class _CookieProvider:
+    def get_cookie_row(self, cookie_type: str) -> Optional[Dict[str, Any]]:
+        return {"type": cookie_type, "cookies": "session=test"}
+
+
+@pytest.fixture(autouse=True)
+def _reset_providers() -> None:
+    yield
+    providers.reset_browser_providers()
+
+
+@pytest.mark.asyncio
+async def test_download_accepts_partial_content_and_sanitizes_name(
+    monkeypatch: pytest.MonkeyPatch,
+    tmp_path: Path,
+) -> None:
+    _FakeClient.response = _FakeResponse(206, (b"abc", b"def"))
+    monkeypatch.setattr(downloads.httpx, "AsyncClient", _FakeClient)
+
+    result = await downloads.download_direct_url(
+        "https://files.example.invalid/path/report.txt",
+        "../../escaped.txt",
+        download_dir=tmp_path,
+    )
+
+    assert result.error is None
+    assert result.metadata["status_code"] == 206
+    assert result.metadata["bytes"] == 6
+    assert Path(result.metadata["path"]) == tmp_path / "escaped.txt"
+    assert (tmp_path / "escaped.txt").read_bytes() == b"abcdef"
+    assert not (tmp_path / ".escaped.txt.part").exists()
+
+
+@pytest.mark.asyncio
+async def test_download_failure_always_returns_tool_result(
+    monkeypatch: pytest.MonkeyPatch,
+    tmp_path: Path,
+) -> None:
+    _FakeClient.response = _FakeResponse(404)
+    monkeypatch.setattr(downloads.httpx, "AsyncClient", _FakeClient)
+    stale_partial = tmp_path / ".missing.bin.part"
+    stale_partial.write_bytes(b"stale")
+
+    result = await downloads.download_direct_url(
+        "https://files.example.invalid/missing.bin",
+        "",
+        download_dir=tmp_path,
+    )
+
+    assert result.error == "HTTP 404"
+    assert result.metadata["status_code"] == 404
+    assert not list(tmp_path.iterdir())
+
+
+@pytest.mark.asyncio
+async def test_download_infers_and_decodes_url_filename(
+    monkeypatch: pytest.MonkeyPatch,
+    tmp_path: Path,
+) -> None:
+    _FakeClient.response = _FakeResponse(200, (b"content",))
+    monkeypatch.setattr(downloads.httpx, "AsyncClient", _FakeClient)
+
+    result = await downloads.download_direct_url(
+        "https://files.example.invalid/%E6%8A%A5%E5%91%8A.txt",
+        "",
+        download_dir=tmp_path,
+    )
+
+    assert result.error is None
+    assert Path(result.metadata["path"]).name == "报告.txt"
+    assert (tmp_path / "报告.txt").read_bytes() == b"content"
+
+
+@pytest.mark.asyncio
+async def test_container_provider_normalizes_unexpected_failures(
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    class _BrokenClient:
+        def __init__(self, **_kwargs: object) -> None:
+            raise TypeError("malformed service response")
+
+    monkeypatch.setattr(providers.httpx, "AsyncClient", _BrokenClient)
+    provider = providers.HttpContainerProvider(
+        base_url="https://browser.example.invalid",
+        startup_delay=0,
+        retry_delay=0,
+    )
+
+    result = await provider.create("https://example.invalid", "account")
+
+    assert result["success"] is False
+    assert result["error"] == "malformed service response"
+
+
+def test_browser_providers_are_host_replaceable() -> None:
+    container = _ContainerProvider()
+    cookie = _CookieProvider()
+
+    providers.configure_browser_providers(
+        container_provider=container,
+        cookie_provider=cookie,
+    )
+
+    assert providers.get_browser_container_provider() is container
+    assert providers.get_browser_cookie_provider() is cookie
+
+
+def test_base_class_no_longer_knows_host_endpoint_or_cookie_table() -> None:
+    source = (
+        Path(__file__).parents[1]
+        / "agent"
+        / "tools"
+        / "builtin"
+        / "browser"
+        / "baseClass.py"
+    ).read_text(encoding="utf-8")
+
+    assert "47.84.182.56" not in source
+    assert "agent_channel_cookies" not in source