MoviePilot Browser Fixture
" "PENDING
" "" """" 用完整生产 Agent 驱动隔离业务世界;工具目录受控,不代表真实部署配置。 """ import asyncio import json import os import re import sys from contextlib import ExitStack, contextmanager from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from tempfile import TemporaryDirectory from threading import Lock, Thread from types import SimpleNamespace from typing import TYPE_CHECKING, Any, Awaitable, Callable, Iterator, Optional from unittest.mock import patch from urllib.parse import unquote, urlsplit from uuid import uuid4 from pydantic import PrivateAttr from app.agent.terminal.ownership import current_terminal_scope from app.agent.tools.impl.browse_webpage import BrowseWebpageTool from app.agent.tools.impl.execute_command import ExecuteCommandTool from app.agent.tools.impl.read_file import ReadFileTool from scripts.evaluation.world import EvaluationWorld if TYPE_CHECKING: from langchain_core.language_models.chat_models import BaseChatModel _RUN_LOCK = Lock() _OPERATIONS = ( "subscription.list", "subscription.find", "subscription.get", "subscription.add", "subscription.delete", "download.tasks.active", "download.history.list", "download.clients", "download.paths", "download.add", "site.list", "library.exists", "transfer.queue", "search.results", ) def _config_path() -> Path: """ 拒绝未隔离或导入后才换目录的调用,不主动导入可能读取真实配置的模块。 """ configured = os.environ.get("CONFIG_DIR") bootstrap = sys.modules.get("app.testing.bootstrap") config = sys.modules.get("app.runtime.config") isolated = getattr(bootstrap, "_isolated_config_dir", None) if not configured or not isolated or config is None: raise RuntimeError("评测必须先设置独立 CONFIG_DIR 并调用 prepare_backend") expected = Path(configured).resolve() actual = Path(config.settings.CONFIG_PATH).resolve() if expected != actual or Path(isolated).resolve() != expected: raise RuntimeError("评测 CONFIG_DIR 与已初始化后端目录不一致") return expected class _Response: """ 内存响应保留执行器真实的读取和关闭协议,不产生网络请求。 """ status_code = 200 headers: dict[str, str] = {} def __init__(self, payload: dict[str, Any]) -> None: """ 保存世界返回的成功、失败或未知结果,不提升为成功。 """ self._payload = payload def json(self) -> dict[str, Any]: """ 仅返回此次 API 执行得到的可见响应。 """ return self._payload async def aclose(self) -> None: """ 满足生产响应的显式关闭协议。 """ class _Transport: """ 按生产固定路由反向匹配 operation,无法向任意主机发送请求。 """ def __init__( self, world: EvaluationWorld, *, after_operation: Optional[Callable[[str, dict[str, Any]], Awaitable[None]]] = None, **_kwargs: Any, ) -> None: """ 绑定当前世界;执行器传来的认证头不记录、不使用。 """ from app.agent.policy.api import resolve_api_route self._world = world self._after_operation = after_operation self._routes = [] for operation in _OPERATIONS: route = resolve_api_route(operation) if route is None: raise RuntimeError(f"评测 operation 已不在生产路由注册表:{operation}") pattern = re.escape(route.path) pattern = re.sub(r"\\\{(\w+)\\\}", r"(?P<\1>[^/]+)", pattern) self._routes.append((operation, route.method, re.compile(f"^{pattern}$"))) async def request( self, *, method: str, url: str, params: Optional[dict[str, Any]], json: Any, raise_exception: bool, ) -> _Response: """ 解释执行器生成的 HTTP 请求,未纳入场景的真实 API 同样拒绝。 """ del raise_exception parsed = urlsplit(url) if parsed.netloc != "evaluation.invalid" or parsed.scheme != "http" or parsed.query or parsed.fragment: raise ValueError("评测传输只接受内存 API 地址") for operation, route_method, pattern in self._routes: match = pattern.fullmatch(parsed.path) if route_method != method or match is None: continue path_params: dict[str, Any] = {name: unquote(value) for name, value in match.groupdict().items()} if "subscribe_id" in path_params: try: path_params["subscribe_id"] = int(path_params["subscribe_id"]) except ValueError: pass # 取消场景只让子代理的只读请求保持在途,确保 action=cancel # 真正打断一个运行中的调用,而不是仅取消已经完成的空任务。 scope = current_terminal_scope() if ( self._world.scenario.scenario_id == "subagent_cancel_recovery" and operation == "subscription.list" and scope is not None and scope.kind == "subagent" ): await asyncio.sleep(30) response = self._world.execute(operation, path_params=path_params, query=params, body=json) if self._after_operation is not None: await self._after_operation(operation, response) return _Response(response) return _Response({"success": False, "execution_outcome": "failed", "message": "该 API 不属于受控评测目录"}) class _MemoryPort: """ 为真实 MemoryManager 提供一次运行独占的会话读取与持久化边界。 """ def __init__(self) -> None: """ 存储仅属于当前运行的消息快照。 """ self.messages: list[dict[str, Any]] = [] async def get(self, **_kwargs: Any) -> None: """ 新评测会话没有历史消息,不读取宿主会话表。 """ async def async_save_agent_messages(self, *, messages: list[dict[str, Any]], **_kwargs: Any) -> None: """ 接收 MemoryManager 的序列化结果,不访问宿主全局持久化服务。 """ self.messages = messages class _EvaluationReadFileTool(ReadFileTool): """ 评测专用文件读取工具,只允许访问本次运行的临时 Agent 根目录。 """ _evaluation_allowed_root: Path = PrivateAttr() def __init__(self, *, allowed_root: Path, **kwargs: Any) -> None: """ 绑定隔离根目录后复用生产文件读取、大小限制和行范围合同。 """ super().__init__(**kwargs) self._evaluation_allowed_root = allowed_root.resolve() def _get_non_admin_local_file_roots(self) -> list[Path]: """ 返回评测临时 Agent 根,避免回退到宿主 CONFIG_PATH/agent。 """ return [self._evaluation_allowed_root] class _EvaluationExecuteCommandTool(ExecuteCommandTool): """ 在临时工作目录执行命令或交互终端并保留生产工具的真实回执合同。 """ _evaluation_world: Any = PrivateAttr() _evaluation_allowed_root: Path = PrivateAttr() _evaluation_terminal_session_id: Optional[str] = PrivateAttr(default=None) def __init__(self, *, world: EvaluationWorld, allowed_root: Path, **kwargs: Any) -> None: """ 绑定假世界与临时目录,拒绝环境覆盖和目录逃逸。 """ super().__init__(**kwargs) self._evaluation_world = world self._evaluation_allowed_root = allowed_root.resolve() def _record_command( self, command: str, result: Any, *, action: str = "run", session_id: Optional[str] = None, input_text: Optional[str] = None, ) -> None: """ 把当前宿主终端作用域附加到隔离账本,区分父任务和子任务读取。 """ scope = current_terminal_scope() self._evaluation_world.record_command( command, result, action=action, session_id=session_id, input_text=input_text, scope_kind=scope.kind if scope is not None else None, scope_task_id=scope.task_id if scope is not None else None, ) @staticmethod def _failure( message: str, command: str = "", *, action: str = "run", session_id: Optional[str] = None, input_text: Optional[str] = None, ) -> str: """ 返回带纠正提示的受控失败,供模型按评测合同修正输入。 """ payload: dict[str, Any] = { "action": action, "success": False, "execution_outcome": "failed", "status": "error", "exit_code": None, "timed_out": False, "command": command, "error": "evaluation_command_rejected", "message": message, } if session_id is not None: payload["session_id"] = session_id if input_text is not None: payload["input_text"] = input_text return ExecuteCommandTool._dump(payload) @staticmethod def _payload(result: str) -> dict[str, Any]: """ 解码生产命令工具的 JSON 回执,异常文本仍作为失败证据保留。 """ try: decoded = json.loads(result) except (TypeError, ValueError): return {"execution_outcome": "failed", "raw": result} return decoded if isinstance(decoded, dict) else {"execution_outcome": "failed", "raw": result} async def _run_terminal( self, action: str, *, command: Optional[str], cwd: Optional[str], env: Optional[dict[str, Any]], kwargs: dict[str, Any], ) -> str: """ 按固定顺序代理启动、读取、写入和等待动作,验证真实会话句柄不会被替换。 """ command_text = (command or "").strip() session_id = kwargs.get("session_id") input_text = kwargs.get("input_text") close_stdin = kwargs.get("close_stdin", False) if action not in {"start", "read", "wait", "write"}: result = self._failure( "终端场景只接受 action=start、read、wait、write;不要使用 action=run", command_text, action=action, session_id=session_id, ) self._record_command(command_text, self._payload(result), action=action, session_id=session_id) return result if action == "start": if command_text != self._evaluation_world.scenario.command: result = self._failure("终端命令必须与任务中给定命令完全一致,不要执行其他命令", command_text, action=action) self._record_command(command_text, self._payload(result), action=action) return result if env: result = self._failure("终端启动不接受 env;请删除 env 后重试", command_text, action=action) self._record_command(command_text, self._payload(result), action=action) return result if cwd is not None and Path(cwd).expanduser().resolve() != self._evaluation_allowed_root: result = self._failure("cwd 必须省略或使用当前评测工作目录", command_text, action=action) self._record_command(command_text, self._payload(result), action=action) return result expected_use_pty = self._evaluation_world.scenario.terminal_use_pty if expected_use_pty is None: expected_use_pty = False if kwargs.get("use_pty", True) is not expected_use_pty: mode = "PTY" if expected_use_pty else "pipe" result = self._failure( f"终端场景必须使用 use_pty={str(expected_use_pty).lower()} 的 {mode} 模式", command_text, action=action, ) self._record_command(command_text, self._payload(result), action=action) return result if session_id: result = self._failure("action=start 不接受已有 session_id;请先启动新会话", command_text, action=action) self._record_command(command_text, self._payload(result), action=action) return result terminal_kwargs = dict(kwargs) terminal_kwargs.update({ "action": "start", "command": command_text, "session_id": None, "input_text": None, "close_stdin": False, "cwd": str(self._evaluation_allowed_root), "env": None, "use_pty": expected_use_pty, }) result = await super().run(**terminal_kwargs) payload = self._payload(result) returned_session = payload.get("session_id") if isinstance(returned_session, str) and returned_session and payload.get("execution_outcome") in {"pending", "succeeded"}: self._evaluation_terminal_session_id = returned_session self._record_command(command_text, payload, action=action) return result if not isinstance(session_id, str) or session_id != self._evaluation_terminal_session_id: result = self._failure( "请使用 action=start 返回的同一 session_id,再执行 read、wait 或 write", action=action, session_id=session_id, ) self._record_command("", self._payload(result), action=action, session_id=session_id) return result if action == "write": if input_text != "MOVIEPILOT_TERMINAL_OK\n": result = self._failure( "write 必须向当前 session_id 写入 MOVIEPILOT_TERMINAL_OK 并保留结尾换行", action=action, session_id=session_id, input_text=input_text, ) self._record_command( "", self._payload(result), action=action, session_id=session_id, input_text=input_text, ) return result if close_stdin is not False: result = self._failure( "pipe 场景的 write 必须使用 close_stdin=false;写入后再读取或等待退出", action=action, session_id=session_id, input_text=input_text, ) self._record_command( "", self._payload(result), action=action, session_id=session_id, input_text=input_text, ) return result terminal_kwargs = dict(kwargs) terminal_kwargs.update({ "action": action, "command": None, "session_id": session_id, "cwd": None, "env": None, "use_pty": None, }) if action != "write": terminal_kwargs["input_text"] = None result = await super().run(**terminal_kwargs) payload = self._payload(result) self._record_command( "", payload, action=action, session_id=session_id, input_text=input_text if action == "write" else None, ) return result async def run(self, action: Optional[str] = "run", command: Optional[str] = None, cwd: Optional[str] = None, env: Optional[dict[str, Any]] = None, **kwargs: Any) -> str: """ 仅允许场景给定的短命令,执行路径仍经过生产命令安全、超时和作用域清理。 """ scenario = self._evaluation_world.scenario normalized_action = (action or ("start" if scenario.kind == "terminal" else "run")).strip().lower() if scenario.kind == "terminal": return await self._run_terminal( normalized_action, command=command, cwd=cwd, env=env, kwargs=kwargs, ) command_text = (command or "").strip() if normalized_action != "run": result = self._failure("命令场景只接受 action=run;请直接提交给定命令", command_text) self._record_command(command_text, json.loads(result)) return result if command_text != self._evaluation_world.scenario.command: result = self._failure("命令必须与任务中给定命令完全一致,不要执行其他命令", command_text) self._record_command(command_text, json.loads(result)) return result if env: result = self._failure("命令场景不接受 env;请删除 env 后重试", command_text) self._record_command(command_text, json.loads(result)) return result if cwd is not None and Path(cwd).expanduser().resolve() != self._evaluation_allowed_root: result = self._failure("cwd 必须省略或使用当前评测工作目录", command_text) self._record_command(command_text, json.loads(result)) return result result = await super().run( action="run", command=command_text, cwd=str(self._evaluation_allowed_root), env=None, **kwargs, ) try: payload = json.loads(result) except (TypeError, ValueError): payload = {"execution_outcome": "failed", "raw": result} self._record_command(command_text, payload) return result class _EvaluationBrowseWebpageTool(BrowseWebpageTool): """ 在本地固定页面上复用生产 Playwright 工具,拒绝外部 URL 和任意脚本。 """ _evaluation_world: Any = PrivateAttr() def __init__(self, *, world: EvaluationWorld, **kwargs: Any) -> None: """ 绑定本轮世界;浏览器会话归属仍由生产 TerminalScope 管理。 """ super().__init__(**kwargs) self._evaluation_world = world @staticmethod def _failure(action: str, message: str) -> str: """ 返回告诉模型正确动作或输入的结构化失败。 """ return json.dumps({ "action": action, "success": False, "execution_outcome": "failed", "error": "evaluation_browser_rejected", "message": message, }, ensure_ascii=False) async def run(self, action: str, url: Optional[str] = None, selector: Optional[str] = None, ref: Optional[str] = None, value: Optional[str] = None, script: Optional[str] = None, content_type: Optional[str] = "text", timeout: Optional[int] = 30, cookies: Optional[str] = None, user_agent: Optional[str] = None, session_key: Optional[str] = None, tab_index: Optional[int] = None, allow_private_network: bool = False, **kwargs: Any) -> Any: """ 仅允许页面读取与按快照 ref 点击,执行仍走生产浏览器实现。 """ allowed_actions = {"goto", "snapshot", "click_ref", "get_content", "close_session"} normalized_action = str(action or "").strip().lower() browser_url = self._evaluation_world.browser_url if normalized_action not in allowed_actions: result = self._failure(normalized_action, "评测浏览器只接受 goto、snapshot、click_ref、get_content、close_session") self._evaluation_world.record_browser(normalized_action, json.loads(result)) return result if normalized_action == "goto" and (url != browser_url or not allow_private_network): result = self._failure(normalized_action, "goto 必须使用任务给定的本地页面,并设置 allow_private_network=true") self._evaluation_world.record_browser(normalized_action, json.loads(result)) return result if normalized_action == "click_ref" and not ref: result = self._failure(normalized_action, "click_ref 必须使用 snapshot 返回的 ref") self._evaluation_world.record_browser(normalized_action, json.loads(result)) return result if normalized_action == "get_content" and content_type not in (None, "text"): result = self._failure(normalized_action, "get_content 只接受 content_type=text") self._evaluation_world.record_browser(normalized_action, json.loads(result)) return result if session_key is not None and session_key != self._session_id: result = self._failure(normalized_action, "session_key 必须省略或使用当前 Agent 会话") self._evaluation_world.record_browser(normalized_action, json.loads(result)) return result result = await super().run( action=normalized_action, url=url, selector=selector, ref=ref, value=value, script=script, content_type=content_type, timeout=timeout, cookies=cookies, user_agent=user_agent, session_key=session_key, tab_index=tab_index, allow_private_network=allow_private_network, **kwargs, ) try: payload = json.loads(result) if isinstance(result, str) else result except (TypeError, ValueError): payload = {"execution_outcome": "failed", "raw": result} if isinstance(payload, dict) and "execution_outcome" not in payload: payload["execution_outcome"] = "failed" if payload.get("success") is False else "succeeded" self._evaluation_world.record_browser(normalized_action, payload) return result @contextmanager def _browser_fixture() -> Iterator[str]: """ 启动只绑定回环地址的动态测试页,并在评测结束后等待线程退出。 """ class _Handler(BaseHTTPRequestHandler): """ 返回固定页面,不记录请求正文或客户端环境。 """ def do_GET(self) -> None: # noqa: N802 - 标准库处理器方法名 """ 仅提供固定路径和最小 HTML,其他请求返回 404。 """ if self.path != "/fixture": self.send_error(404) return body = ( "
PENDING
" "" "