Jelajahi Sumber

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

保持浏览器工具类和 import 路径不变,将 Provider 判定和下载等待逻辑拆出,修复浏览器下载结果处理问题,并补充 Provider、下载与公开入口回归测试。
SamLee 17 jam lalu
induk
melakukan
6fd566f4f7

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

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

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

@@ -43,35 +43,29 @@ Native Browser-Use Tools Adapter
   这些工具功能更完善,支持diff预览、智能匹配、分页读取等
   这些工具功能更完善,支持diff预览、智能匹配、分页读取等
 """
 """
 import logging
 import logging
-import sys
-import os
 import json
 import json
-import httpx
 import asyncio
 import asyncio
-import aiohttp
 import re
 import re
 import base64
 import base64
 from urllib.parse import urlparse, parse_qs, unquote
 from urllib.parse import urlparse, parse_qs, unquote
 from typing import Literal, Optional, List, Dict, Any, Tuple, Union
 from typing import Literal, Optional, List, Dict, Any, Tuple, Union
 from pathlib import Path
 from pathlib import Path
 from langchain_core.runnables import RunnableLambda
 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
 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 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 的核心类
 # 导入 browser-use 的核心类
 from browser_use import BrowserSession, BrowserProfile
 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
 from browser_use.tools.service import Tools
+
 try:
 try:
     from browser_use.tools.views import ReadContentAction  # type: ignore
     from browser_use.tools.views import ReadContentAction  # type: ignore
 except Exception:
 except Exception:
@@ -81,8 +75,9 @@ except Exception:
         goal: str
         goal: str
         source: str = "page"
         source: str = "page"
         context: str = ""
         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
 _live_url: Optional[str] = None
 
 
 async def create_container(url: str, account_name: str = "liuwenwu") -> Dict[str, Any]:
 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(
 async def init_browser_session(
     browser_type: str = "local",
     browser_type: str = "local",
@@ -260,8 +135,9 @@ async def init_browser_session(
     }
     }
 
 
     if browser_type == "container":
     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)
         container_info = await create_container(url=url, account_name=profile_name)
         if not container_info["success"]:
         if not container_info["success"]:
             raise RuntimeError(f"容器创建失败: {container_info['error']}")
             raise RuntimeError(f"容器创建失败: {container_info['error']}")
@@ -269,13 +145,13 @@ async def init_browser_session(
         await asyncio.sleep(3)
         await asyncio.sleep(3)
 
 
     elif browser_type == "cloud":
     elif browser_type == "cloud":
-        print("🌐 使用云浏览器模式")
+        logger.info("Initializing cloud browser session")
         session_params["use_cloud"] = True
         session_params["use_cloud"] = True
         if profile_name and profile_name != "default":
         if profile_name and profile_name != "default":
             session_params["cloud_profile_id"] = profile_name
             session_params["cloud_profile_id"] = profile_name
 
 
     else:  # local
     else:  # local
-        print("💻 使用本地浏览器模式")
+        logger.info("Initializing local browser session")
         session_params["is_local"] = True
         session_params["is_local"] = True
         if user_data_dir is None and profile_name:
         if user_data_dir is None and profile_name:
             user_data_dir = str(Path.home() / ".browser_use" / "profiles" / 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()
     _browser_tools = Tools()
     _file_system = FileSystem(base_dir=str(save_dir))
     _file_system = FileSystem(base_dir=str(save_dir))
 
 
-    print(f"✅ 浏览器会话初始化成功 | 默认下载路径: {save_dir}")
+    logger.info("Browser session initialized: downloads=%s", save_dir)
 
 
     # 云浏览器:捕获 live URL
     # 云浏览器:捕获 live URL
     if browser_type == "cloud":
     if browser_type == "cloud":
@@ -314,7 +190,7 @@ async def init_browser_session(
             parsed = urllib.parse.urlparse(cdp_url)
             parsed = urllib.parse.urlparse(cdp_url)
             host_url = f"https://{parsed.hostname}"
             host_url = f"https://{parsed.hostname}"
             _live_url = f"https://live.browser-use.com?wss={urllib.parse.quote(host_url)}"
             _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:
     if browser_type in ["local", "cloud"] and url:
         await _browser_tools.navigate(url=url, browser_session=_browser_session)
         await _browser_tools.navigate(url=url, browser_session=_browser_session)
@@ -355,10 +231,10 @@ async def get_browser_session() -> tuple[BrowserSession, Tools]:
                 )
                 )
                 alive = True
                 alive = True
         except Exception:
         except Exception:
-            pass
+            logger.debug("Browser CDP health check failed", exc_info=True)
 
 
         if not alive:
         if not alive:
-            print("⚠️ 浏览器会话连接已断开,正在重新初始化...")
+            logger.warning("Browser session disconnected; reinitializing")
             try:
             try:
                 await cleanup_browser_session()
                 await cleanup_browser_session()
             except Exception:
             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]:
 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 "")
     parsed = urlparse(url or "")
     domain = parsed.netloc or ""
     domain = parsed.netloc or ""
     domain = domain.replace("www.", "")
     domain = domain.replace("www.", "")
@@ -516,27 +390,9 @@ def _fetch_cookie_row(cookie_type: str) -> Optional[Dict[str, Any]]:
     if not cookie_type:
     if not cookie_type:
         return None
         return None
     try:
     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
         return None
 
 
 
 
@@ -725,71 +581,11 @@ class DownloadLinkCaptureHandler(logging.Handler):
                 # 再次过滤:如果发现提取出的 URL 确实包含三个点,说明依然抓到了截断版,跳过
                 # 再次过滤:如果发现提取出的 URL 确实包含三个点,说明依然抓到了截断版,跳过
                 if "..." not in url:
                 if "..." not in url:
                     self.captured_url = url
                     self.captured_url = url
-                    # print(f"🎯 成功锁定完整直链: {url[:50]}...") # 调试用
 
 
 async def browser_download_direct_url(url: str, save_name: str = "book.epub") -> ToolResult:
 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:
 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="输入失败",
             title="输入失败",
             output="",
             output="",
             error=f"Failed to input text into element {index}: {str(e)}",
             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)
                 return unquote(found)
                 
                 
     except Exception:
     except Exception:
-        pass # 解析失败则返回原链接
+        logger.debug("Search redirect URL parsing failed", exc_info=True)
     
     
     return url
     return url
 
 
@@ -1387,7 +1183,6 @@ async def extraction_adapter(input_data):
         if clean_url != original_url:
         if clean_url != original_url:
             content = content.replace(original_url, clean_url)
             content = content.replace(original_url, clean_url)
     
     
-    from argparse import Namespace
     return Namespace(completion=content)
     return Namespace(completion=content)
 
 
 async def browser_extract_content(query: str, extract_links: bool = False,
 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', '')
                 content_type = ct_result.get('result', {}).get('value', '')
                 is_pdf = 'pdf' in content_type.lower()
                 is_pdf = 'pdf' in content_type.lower()
             except Exception:
             except Exception:
-                pass
+                logger.debug("Browser PDF content-type probe failed", exc_info=True)
 
 
         if not is_pdf:
         if not is_pdf:
             return None
             return None
@@ -1497,12 +1292,12 @@ async def _detect_and_download_pdf_via_cdp(browser) -> Optional[str]:
 
 
         value = result.get('result', {}).get('value', '')
         value = result.get('result', {}).get('value', '')
         if not value:
         if not value:
-            print("⚠️ CDP fetch PDF: 无返回值")
+            logger.warning("CDP PDF fetch returned no value")
             return None
             return None
 
 
         data = json.loads(value)
         data = json.loads(value)
         if 'error' in data:
         if 'error' in data:
-            print(f"⚠️ CDP fetch PDF 失败: {data['error']}")
+            logger.warning("CDP PDF fetch failed: %s", data["error"])
             return None
             return None
 
 
         # 从 data URL 中提取 base64 并解码
         # 从 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:
         with open(save_path, 'wb') as f:
             f.write(pdf_bytes)
             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
         return save_path
 
 
     except Exception as e:
     except Exception as e:
-        print(f"⚠️ PDF 检测/下载异常: {e}")
+        logger.warning("CDP PDF detection/download failed: %s", e)
         return None
         return None
 
 
 
 
@@ -1885,12 +1680,12 @@ async def browser_wait_for_user_action(message: str = "Please complete the actio
         import asyncio
         import asyncio
 
 
         print(f"\n{'='*60}")
         print(f"\n{'='*60}")
-        print(f"⏸️  WAITING FOR USER ACTION")
+        print("⏸️  WAITING FOR USER ACTION")
         print(f"{'='*60}")
         print(f"{'='*60}")
         print(f"📝 {message}")
         print(f"📝 {message}")
         print(f"⏱️  Timeout: {timeout} seconds")
         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")
         print(f"{'='*60}\n")
 
 
         # Wait for user input or timeout
         # 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
         url: 文件 URL
         save_name: 保存文件名(可选,默认自动推断)
         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',
     'cleanup_browser_session',
     'kill_browser_session',
     'kill_browser_session',
 
 
-    # 13 个 @tool 入口
+    # 14 个 @tool 入口
     'browser_navigate',
     'browser_navigate',
     'browser_search',
     'browser_search',
     'browser_back',
     '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