diff --git a/docs/agent-evaluation.md b/docs/agent-evaluation.md index cc3d61d80..a1df2b087 100644 --- a/docs/agent-evaluation.md +++ b/docs/agent-evaluation.md @@ -4,7 +4,7 @@ ## 当前范围 -`scripts/evaluation/` 提供两种显式模式。`--replay` 只回放离线轨迹,不联网,报告标记 `evidence_kind=scripted_replay`、`intelligence_evaluated=false`。`--live` 在独立进程内用真实模型驱动完整生产 Agent,业务接口只进入内存假世界。两者都没有实现 Codex 原生对照,不能把通过率称为 Codex 对比结果。 +`scripts/evaluation/` 显式区分离线回放、MoviePilot 真实运行和原生 Codex 受控运行。`--replay` 不联网,标记 `evidence_kind=scripted_replay`、`intelligence_evaluated=false`;`--live` 用真实模型驱动完整生产 Agent;`--native` 调用原生 Codex CLI。两种真实运行共用内存假业务世界与独立验收器。单条报告仍标记 `codex_comparison=false`,只有固定条件下的配对重复结果才能形成比较。 首批场景: @@ -39,7 +39,7 @@ uv run --locked --no-sync python -m scripts.evaluation --live \ --output report.json ``` -只有显式 `--live` 才会产生模型调用费用。默认读取 `~/.codex/config.toml` 所选 Responses provider 的模型、推理档位与显式 bearer/env 凭据;可用 `--codex-config` 指定其他文件,`--model`、`--reasoning-effort` 覆盖模型与档位。不会借用其他服务的登录凭据,也不会自动降低被供应商拒绝的参数。报告同时保留请求模型与供应商返回的模型标识;本地 Codex 配置不能证明运行中的 MoviePilot 使用相同配置。 +只有显式 `--live` 或 `--native` 才会调用真实模型并产生费用。默认读取 `~/.codex/config.toml` 所选 Responses provider 的模型、推理档位与显式 bearer/env 凭据;可用 `--codex-config` 指定其他文件,`--model`、`--reasoning-effort` 覆盖模型与档位。不会借用其他服务的登录凭据,也不会自动降低被供应商拒绝的参数。报告同时保留请求模型与供应商返回的模型标识;本地 Codex 配置不能证明运行中的 MoviePilot 使用相同配置。 调用配置经私有标准输入传给 worker,凭据不进入命令行、提示词或报告。worker 只继承必要的平台环境,先创建临时 `CONFIG_DIR`,再导入后端;每轮拥有独立回执库、记忆、会话和工具实例。生产 `process/_create_agent`、Skills、计划、权限、持久回执、工具输出预算、压缩和子代理仍按真实路径执行。主工具目录限定为假业务 API 和生产内部工具,API transport 拒绝任何外部目标及场景外 operation;插件、外部 MCP、通知和任意 shell/文件/浏览器工具不开放。此受控目录是当前评测边界,不代表默认部署工具全集。 @@ -51,7 +51,30 @@ uv run --locked --no-sync python -m scripts.evaluation --live \ 单测不调用真实模型或外部网络;测试只验证运行器、隔离、预算与判定合同。真实报告含合成任务轨迹,也可能很长,应保存于评测工作目录;不要把包含私有配置的临时诊断日志提交到仓库。 -## Codex 比较的后续接入要求 +## 原生 Codex 受控运行 + +先使用无真实模型请求的目录探针,再运行真实场景: + +```bash +uv run --locked --no-sync python -m scripts.evaluation --native-probe \ + --scenario dedup_existing --model gpt-6-astra --reasoning-effort max \ + --output probe.json +uv run --locked --no-sync python -m scripts.evaluation --native \ + --scenario dedup_existing --model gpt-6-astra --reasoning-effort max \ + --max-model-calls 12 --timeout-seconds 180 --output codex-report.json +``` + +适配器目前只核对 `codex-cli 0.153.4`,可用 `--codex-executable` 指定文件。其他版本或二进制目录中没有指定模型时明确拒绝,不换模型或套用其他模型的元数据。`--native-probe` 的 `probe_ready` 只代表配置和目录检查,不是任务通过;原生进程预期收到本地探针错误并退出,模型调用数仍为零。 + +每次创建空白临时工作目录;场景源码、初态、账本、oracle、模型凭据留在控制器。原生客户端使用局部随机令牌连接回环模型代理和 MCP 假世界,不继承业务配置、真实模型令牌或其他服务环境。客户端忽略用户配置和规则文件,关闭宿主技能、插件、浏览器、文件查看及 shell 等能力,采用只读沙箱、never 审批与有界退出;仅对当前回环 evaluation 服务的三个假工具显式设置 `approval_mode=approve`,避免原生客户端拒绝已授权的假业务动作。不修改用户 HOME、CODEX_HOME 或现有配置。全局 AGENTS 仍可能由原生客户端加载,因此代理在发送给模型前仅移除规范的独立 AGENTS 用户块,保留原生基础说明和任务文本,并记录被移除块的长度与哈希。 + +原生循环、计划和协作工具仍由 Codex 执行。当前模型可固定使用 Code Mode,单独关闭 feature 无法改变它;适配器通过 `codex debug models --bundled` 读取当前二进制自带目录,只投影指定模型的 `tool_mode=direct`,保持其余字段、`base_instructions` 和 `model_messages`,记录前后指纹。随后代理限制实际广告目录为计划/协作与 evaluation MCP 工具,兼顾请求中的动态目录,并在每个完整响应事件交给客户端前再次拒绝目录以外的调用。这是明确投影过工具模式与目录的原生 Codex 对照,不代表默认部署的完整产品工具环境。 + +MCP 服务只公开 `moviepilot_api`、完整的 `moviepilot-api` Skill 和有界结果续读;不提前给出场景支持 operation 清单。所有原生客户端与子代理 MCP 会话共享同一个世界及 32 次业务调用预算。服务使用已锁定的 Starlette/uvicorn 实现本评测所需 Streamable HTTP 子集;不作为通用生产 MCP 服务或对外部署入口。 + +模型代理固定供应商目标,禁止不同模型或不同推理档位静默替换,主调用和子代理共同消耗硬调用预算,HTTP 与 SSE 自动重试为零。报告记录原生 JSONL 事件、退出码、完成事件、独立业务账本、实际目录投影、模型目录/输入指纹、逐请求用量及失败。超时和输出超限保留已有事件并终止进程组;技术失败即使已有正确 JSON 也不会标记整轮通过。`task_passed` 仅为独立 oracle 的业务判断,`passed` 还要求原生运行完整退出。 + +## 配对比较的验收要求 - MoviePilot 侧应保留完整生产 Agent 装配,只替换业务 transport 与隔离配置;只组装部分中间件的循环不能冒充产品实测。 - Codex 侧必须调用原生 CLI 或 App Server。自建模型循环只能叫受控运行时对照。 diff --git a/scripts/evaluation/__main__.py b/scripts/evaluation/__main__.py index a17343e3e..32a8862a4 100644 --- a/scripts/evaluation/__main__.py +++ b/scripts/evaluation/__main__.py @@ -57,11 +57,14 @@ def replay(scenario_id: str, payload: Any) -> dict[str, Any]: def main(argv: list[str] | None = None) -> int: """显式区分回放与真实调用,并以非零退出码表达验收未通过。""" - parser = argparse.ArgumentParser(description="MoviePilot Agent 隔离任务评测;--live 才会调用真实模型") + parser = argparse.ArgumentParser(description="MoviePilot Agent 隔离任务评测;--live/--native 才会调用真实模型") mode = parser.add_mutually_exclusive_group(required=True) mode.add_argument("--list", action="store_true", help="列出公开场景,不输出初态或答案") mode.add_argument("--replay", type=Path, help="包含 calls 与 final 的 JSON 轨迹") mode.add_argument("--live", action="store_true", help="显式调用真实模型,业务只进入隔离假世界") + mode.add_argument("--native", action="store_true", help="用原生 Codex CLI 操作同一假世界并调用真实模型") + mode.add_argument("--native-probe", action="store_true", help="核对原生 Codex 配置和工具目录,不调用真实模型") + parser.add_argument("--codex-executable", default="codex", help="原生模式使用的 Codex CLI 可执行文件") parser.add_argument("--scenario", choices=[item.scenario_id for item in list_scenarios()]) parser.add_argument("--output", type=Path, help="可选 JSON 报告路径") parser.add_argument("--codex-config", type=Path, default=Path.home() / ".codex" / "config.toml") @@ -77,8 +80,7 @@ def main(argv: list[str] | None = None) -> int: if args.scenario is None: parser.error("评测需要 --scenario") try: - if args.live: - from scripts.evaluation.live import run_live + if args.live or args.native or args.native_probe: from scripts.evaluation.models import load_codex_model_settings settings = load_codex_model_settings( @@ -86,7 +88,14 @@ def main(argv: list[str] | None = None) -> int: max_model_calls=args.max_model_calls, max_output_tokens=args.max_output_tokens, timeout_seconds=args.timeout_seconds, ) - result = run_live(args.scenario, settings) + if args.live: + from scripts.evaluation.live import run_live + + result = run_live(args.scenario, settings) + else: + from scripts.evaluation.codex import run_codex + + result = run_codex(args.scenario, settings, executable=args.codex_executable, probe_only=args.native_probe) else: if args.replay.stat().st_size > MAX_REPLAY_BYTES: raise ValueError("回放文件超过 256 KiB") @@ -98,7 +107,7 @@ def main(argv: list[str] | None = None) -> int: if args.output is not None: args.output.write_text(rendered + "\n", encoding="utf-8") print(rendered) - return 0 if result["passed"] else 1 + return 0 if (result.get("probe_ready") if args.native_probe else result["passed"]) else 1 if __name__ == "__main__": diff --git a/scripts/evaluation/codex.py b/scripts/evaluation/codex.py new file mode 100644 index 000000000..43fe99fc0 --- /dev/null +++ b/scripts/evaluation/codex.py @@ -0,0 +1,269 @@ +"""让原生 Codex CLI 操作同一假世界,模型循环与独立评分器不共用状态。""" + +import asyncio +import copy +import hashlib +import json +import os +import shutil +import signal +import subprocess +import tempfile +import time +from pathlib import Path +from typing import Any + +from scripts.evaluation.live import _parse_final, _validate_report, _worker_environment +from scripts.evaluation.models import ModelSettings +from scripts.evaluation.proxy import EvaluationModelProxy +from scripts.evaluation.score import evaluate +from scripts.evaluation.server import EvaluationMcpServer +from scripts.evaluation.world import EvaluationWorld + +SUPPORTED_CLI_VERSION = "codex-cli 0.153.4" +MAX_PROCESS_OUTPUT_BYTES = 8 * 1024 * 1024 + + +def _catalog(payload: Any, model: str) -> tuple[dict[str, Any], dict[str, Any]]: + """仅修改实际二进制自带模型的工具模式,原生提示词及其他模型字段逐字保留。""" + if not isinstance(payload, dict) or not isinstance(payload.get("models"), list): + raise ValueError("原生模型目录结构无效") + matches = [row for row in payload["models"] if isinstance(row, dict) and row.get("slug") == model] + if len(matches) != 1: + raise ValueError("原生二进制没有唯一的指定模型元数据,不能替换为其他模型") + original = matches[0] + projected = copy.deepcopy(original) + projected["tool_mode"] = "direct" + def digest(value: Any) -> str: + """对相同序列化规则取指纹,便于复验单字段投影。""" + return hashlib.sha256(json.dumps(value, ensure_ascii=False, sort_keys=True).encode()).hexdigest() + + metadata = {"source": "codex_debug_models_bundled", "model": model, + "original_tool_mode": original.get("tool_mode"), "projected_tool_mode": "direct", + "original_model_sha256": digest(original), "projected_model_sha256": digest(projected), + "base_instructions_sha256": digest(original.get("base_instructions")), + "model_messages_sha256": digest(original.get("model_messages"))} + return {"models": [projected]}, metadata + + +def _toml(value: Any) -> str: + """按 TOML 表达配置值,直接传 argv,不经过 shell 或其变量展开。""" + if isinstance(value, dict): + return "{ " + ", ".join(f"{json.dumps(key)} = {_toml(item)}" for key, item in value.items()) + " }" + return json.dumps(value, ensure_ascii=False) + + +def _redact(value: Any, tokens: tuple[str, ...]) -> Any: + """在全部事件和最终 JSON 解码后移除局部令牌,Unicode 转义不能绕过。""" + if isinstance(value, str): + for token in tokens: + value = value.replace(token, "[local evaluation token]") + return value + if isinstance(value, dict): + return {_redact(key, tokens): _redact(item, tokens) for key, item in value.items()} + if isinstance(value, list): + return [_redact(item, tokens) for item in value] + return value + + +def _configuration(settings: ModelSettings, proxy: EvaluationModelProxy, server: EvaluationMcpServer, + control_dir: Path) -> dict[str, Any]: + """显式隔离原生客户端的外部能力,仍保留其默认提示词、计划及子代理循环。""" + disabled = ( + "apps", "browser_use", "browser_use_external", "browser_use_full_cdp_access", "computer_use", + "in_app_browser", "in_app_chat", "image_generation", "view_image", "shell_tool", "unified_exec", + "shell_snapshot", "hooks", "memories", "plugins", "remote_plugin", "plugin_sharing", "skill_search", + "skill_mcp_dependency_install", "workspace_dependencies", "goals", "sleep_tool", "code_mode", + "code_mode_host", "enable_request_compression", "unbounded_connection_retries", "tool_suggest", + ) + return { + "model": settings.model, "model_provider": "evaluation", "model_reasoning_effort": settings.reasoning_effort, + "model_context_window": settings.context_window, "approval_policy": "never", "web_search": "disabled", + "tools.update_plan.enabled": True, + "project_doc_max_bytes": 0, "skills.bundled.enabled": False, "skills.include_instructions": False, + "sqlite_home": str(control_dir / "state"), "log_dir": str(control_dir / "logs"), + "model_catalog_json": str(control_dir / "models.json"), + "features.skip_host_skill_discovery": True, **{f"features.{name}": False for name in disabled}, + "features.code_mode_only": False, + "features.code_mode_host": {"enabled": False, "disable_in_process_fallback": False}, + "model_providers.evaluation": { + "name": "Controlled evaluation proxy", "base_url": proxy.endpoint, + "env_key": "MOVIEPILOT_EVAL_MODEL_TOKEN", "wire_api": "responses", "requires_openai_auth": False, + "request_max_retries": 0, "stream_max_retries": 0, + "stream_idle_timeout_ms": min(settings.timeout_seconds, 120) * 1000, + }, + "mcp_servers": {"evaluation": { + "url": server.endpoint, "bearer_token_env_var": "MOVIEPILOT_EVAL_MCP_TOKEN", + "enabled": True, "required": True, "startup_timeout_sec": 10, "tool_timeout_sec": 15, + "enabled_tools": ["moviepilot_api", "read_skill", "read_tool_result"], + "tools": {name: {"approval_mode": "approve"} for name in ("moviepilot_api", "read_skill", "read_tool_result")}, + }}, + } + + +async def _read_output(stream: asyncio.StreamReader, result: bytearray) -> None: + """持续排空子进程管道,输出超限时让控制器终止整组进程。""" + while chunk := await stream.read(65536): + remaining = MAX_PROCESS_OUTPUT_BYTES - len(result) + result.extend(chunk[:remaining]) + if len(chunk) > remaining: + raise RuntimeError("原生评测进程输出超过 8 MiB") + + +async def _stop_process(process: asyncio.subprocess.Process) -> None: + """父任务取消或超时时回收原生客户端及继承进程组,不遗留后台评测。""" + if os.name == "posix": + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + elif process.returncode is None: + process.kill() + await asyncio.wait_for(process.wait(), timeout=10) + + +async def _execute(command: list[str], prompt: str, environment: dict[str, str], + work_dir: Path, timeout: int) -> dict[str, Any]: + """只向原生 stdin 发送公开任务,结构化事件和退出状态由控制器采集。""" + process = await asyncio.create_subprocess_exec( + *command, cwd=work_dir, env=environment, stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, start_new_session=os.name == "posix", + ) + readers: list[asyncio.Task[None]] = [] + stdout, stderr = bytearray(), bytearray() + failure = None + try: + assert process.stdin is not None and process.stdout is not None and process.stderr is not None + readers = [asyncio.create_task(_read_output(process.stdout, stdout)), + asyncio.create_task(_read_output(process.stderr, stderr))] + process.stdin.write(prompt.encode("utf-8")) + await process.stdin.drain() + process.stdin.close() + async with asyncio.timeout(timeout): + await asyncio.gather(*readers) + await process.wait() + except (RuntimeError, TimeoutError, OSError) as error: + failure = type(error).__name__ + finally: + try: + await _stop_process(process) + finally: + for task in readers: + if not task.done(): + task.cancel() + await asyncio.gather(*readers, return_exceptions=True) + return {"returncode": process.returncode, "stdout": stdout.decode("utf-8", errors="replace"), + "stderr": stderr.decode("utf-8", errors="replace"), "error_type": failure} + + +def _events(text: str) -> tuple[list[dict[str, Any]], str, bool]: + """保留 JSONL 原生事件;只有完成事件之后的有效最终报告才由 oracle 判断。""" + events, final_text, completed, malformed = [], "", False, False + for line in text.splitlines(): + try: + event = json.loads(line) + if not isinstance(event, dict): + raise ValueError("原生客户端输出非事件对象") + except (ValueError, TypeError): + malformed = True + events.append({"type": "evaluation.invalid_event", "sha256": hashlib.sha256(line.encode()).hexdigest()}) + continue + events.append(event) + if event.get("type") == "turn.started": + completed, final_text = False, "" + item = event.get("item") + if event.get("type") == "item.completed" and isinstance(item, dict) and item.get("type") == "agent_message": + final_text = str(item.get("text") or "") + if event.get("type") == "turn.completed": + completed = True + elif event.get("type") in {"turn.failed", "error"}: + completed = False + return events, final_text, completed and not malformed + + +def _probe_ready(usage: dict[str, Any], server_stats: dict[str, Any]) -> bool: + """探针必须到达实际业务工具目录且没有真实模型或业务调用,仅有计划工具不算就绪。""" + retained = {name for record in usage.get("model_requests", []) for name in record.get("retained_tools", [])} + fixture = {f"mcp__evaluation.{name}" for name in ("moviepilot_api", "read_skill", "read_tool_result")} + return bool(usage.get("probe_requests") == 1 and not usage.get("model_calls") + and not usage.get("rejected_requests") and not server_stats.get("world_calls") + and ("tool_search" in retained or fixture <= retained)) + + +async def _run_codex(scenario_id: str, settings: ModelSettings, executable: str, *, probe_only: bool) -> dict[str, Any]: + """每次运行创建新目录、新模型预算和新世界,源码与 oracle 从不进入模型工作目录。""" + from scripts.evaluation.__main__ import _provenance + + world = EvaluationWorld(scenario_id) + provenance = _provenance(world) + started = time.monotonic() + result: dict[str, Any] = {} + failure = None + with tempfile.TemporaryDirectory(prefix="moviepilot-native-evaluation-") as directory: + control_dir = Path(directory) + work_dir = control_dir / "workspace" + work_dir.mkdir() + catalog_result = await _execute([executable, "debug", "models", "--bundled"], "", _worker_environment(), work_dir, 10) + if catalog_result.get("error_type") or catalog_result.get("returncode") != 0: + raise RuntimeError("无法读取当前原生客户端自带模型目录") + catalog, catalog_metadata = _catalog(json.loads(catalog_result["stdout"]), settings.model) + (control_dir / "models.json").write_text(json.dumps(catalog), encoding="utf-8") + async with EvaluationMcpServer(world) as server, EvaluationModelProxy(settings, probe_only=probe_only) as proxy: + config = _configuration(settings, proxy, server, control_dir) + command = [executable, "exec", "--ignore-user-config", "--ignore-rules", "--strict-config", "--ephemeral", + "--skip-git-repo-check", "--sandbox", "read-only", "--json", "--color", "never", "-C", str(work_dir)] + for key, value in config.items(): + command.extend(["-c", f"{key}={_toml(value)}"]) + command.append("-") + environment = _worker_environment() + environment.update(MOVIEPILOT_EVAL_MODEL_TOKEN=proxy.bearer_token, + MOVIEPILOT_EVAL_MCP_TOKEN=server.bearer_token) + try: + result = await _execute(command, world.scenario.model_input(), environment, work_dir, settings.timeout_seconds) + failure = result.get("error_type") + except (RuntimeError, TimeoutError, OSError) as error: + failure = type(error).__name__ + local_tokens = (proxy.bearer_token, server.bearer_token) + usage = proxy.snapshot() + server_stats = server.stats + skill_sha256 = server.skill_sha256 + events, final_text, completed = _events(result.get("stdout", "")) + if any(event.get("type") == "evaluation.invalid_event" for event in events): + failure = failure or "invalid_native_events" + final_report = _parse_final(final_text) + source_unchanged = _provenance(world) == provenance + if not source_unchanged: + failure = failure or "source_changed_during_run" + stderr = result.get("stderr", "") + report = { + **evaluate(world, final_report).to_dict(), **provenance, **usage, "source_unchanged": source_unchanged, + "evidence_kind": "codex_native_probe" if probe_only else "codex_native_controlled", + "intelligence_evaluated": usage["completed_model_calls"] > 0, "codex_comparison": False, + "model": settings.public_metadata(), "elapsed_seconds": round(time.monotonic() - started, 3), + "cli_version": SUPPORTED_CLI_VERSION, "native_exit_code": result.get("returncode"), + "native_model_catalog": catalog_metadata, + "native_turn_completed": completed, "runner_error_type": failure, + "native_events": events, "native_stderr": stderr, + "native_stderr_sha256": hashlib.sha256(stderr.encode()).hexdigest(), + "final_text": final_text, "final_report": final_report, "ledger": world.ledger, + "fixture_server": server_stats, "exposed_skill_sha256": skill_sha256, + "tool_catalog_scope": "native_control_tools_and_shared_fixture_mcp", + } + report["task_passed"] = report["passed"] + report["passed"] = bool(report["passed"] and completed and result.get("returncode") == 0 and not failure) + report["probe_ready"] = bool(probe_only and not failure and _probe_ready(usage, server_stats)) + checked: dict[str, Any] = _validate_report(_redact(report, local_tokens), settings.api_key) + checked["native_stderr_sha256"] = hashlib.sha256(checked["native_stderr"].encode()).hexdigest() + return checked + + +def run_codex(scenario_id: str, settings: ModelSettings, *, executable: str = "codex", probe_only: bool = False) -> dict[str, Any]: + """仅运行已核对的原生版本,探针不会向真实供应商发送请求。""" + binary = shutil.which(executable) + if binary is None: + raise RuntimeError("未找到原生 Codex CLI") + version = subprocess.run([binary, "--version"], capture_output=True, text=True, check=False, + env=_worker_environment(), timeout=10) + if version.returncode != 0 or version.stdout.strip() != SUPPORTED_CLI_VERSION: + raise RuntimeError(f"此适配器只核对过 {SUPPORTED_CLI_VERSION},请先验证当前 CLI 合同") + return asyncio.run(_run_codex(scenario_id, settings, binary, probe_only=probe_only)) diff --git a/scripts/evaluation/proxy.py b/scripts/evaluation/proxy.py new file mode 100644 index 000000000..fc933c73d --- /dev/null +++ b/scripts/evaluation/proxy.py @@ -0,0 +1,569 @@ +"""给原生客户端提供有调用上限的模型连接,凭据和业务答案留在控制器。""" + +import asyncio +import copy +import hashlib +import json +import re +import secrets +import socket +from collections.abc import AsyncGenerator, Iterator +from contextlib import contextmanager, suppress +from typing import Any, Optional + +import anyio +import httpx +import uvicorn +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import JSONResponse, Response, StreamingResponse +from starlette.routing import Route +from starlette.types import Receive, Scope, Send + +from scripts.evaluation.models import ModelSettings + +MAX_REQUEST_BYTES = 4 * 1024 * 1024 +MAX_RESPONSE_BYTES = 4 * 1024 * 1024 +MAX_EVENT_BYTES = 1024 * 1024 +TOKEN_KEYS = ("input_tokens", "output_tokens", "total_tokens") +NATIVE_CONTROL_TOOLS = frozenset({ + "update_plan", "spawn_agent", "send_input", "resume_agent", "close_agent", "wait", "list_agents", + "request_user_input", "search_tools", "tool_search", "search_tool_bm25", +}) +NATIVE_COLLABORATION_TOOLS = frozenset({ + "spawn_agent", "followup_task", "interrupt_agent", "list_agents", "send_message", "wait_agent", +}) +KNOWN_NAMESPACES = frozenset({"functions", "clock", "collaboration", "mcp__evaluation"}) +FIXTURE_TOOLS = frozenset({"moviepilot_api", "read_skill", "read_tool_result"}) + + +def allowed_tool(name: str) -> bool: + """只允许原生计划/协作控制与本地固定 MCP 目录,不接入真实系统工具。""" + normalized = name.removeprefix("functions.").replace(".", "__") + return (normalized in NATIVE_CONTROL_TOOLS + or normalized in {f"collaboration__{tool}" for tool in NATIVE_COLLABORATION_TOOLS} + or normalized in {f"mcp__evaluation__{tool}" for tool in FIXTURE_TOOLS}) + + +def project_tools(tools: Any, prefix: str = "") -> tuple[list[dict[str, Any]], list[str], list[str]]: + """保留允许工具的原始 schema;外部 MCP 配置污染必须中止,不能静默投影成合格评测。""" + if not isinstance(tools, list) or prefix.count(".") > 8: + raise ValueError("工具目录结构无效") + projected, retained, removed = [], [], [] + for tool in tools: + if not isinstance(tool, dict) or not isinstance(tool.get("type"), str): + raise ValueError("工具定义无效") + if tool["type"] in {"function", "custom", "namespace"} and "name" not in tool: + raise ValueError("工具缺少名称") + if "parameters" in tool and not isinstance(tool["parameters"], dict): + raise ValueError("工具参数 schema 无效") + name = tool.get("name", tool["type"]) + if not isinstance(name, str) or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_.-]{0,255}", name): + raise ValueError("工具名称无效") + qualified = f"{prefix}.{name}" if prefix else name + normalized = qualified.removeprefix("functions.").replace(".", "__") + if tool["type"] == "mcp" or (normalized.startswith("mcp__") and not ( + normalized == "mcp__evaluation" or normalized.startswith("mcp__evaluation__") + )): + raise ValueError("评测目录包含外部 MCP") + if tool["type"] == "namespace": + if qualified not in KNOWN_NAMESPACES: + raise ValueError("评测目录包含未知命名空间") + if "tools" not in tool: + raise ValueError("命名空间没有工具目录") + children, kept, dropped = project_tools(tool["tools"], qualified) + if children: + projected.append({**tool, "tools": children}) + retained.extend(kept) + removed.extend(dropped) + elif tool["type"] == "tool_search": + description = tool.get("description") + sources = re.findall(r"(?m)^- ([^:\n]+):", description) if isinstance(description, str) else [] + if prefix or tool.get("execution") != "client" or sources != ["evaluation"]: + raise ValueError("原生工具搜索不是固定评测目录") + projected.append(copy.deepcopy(tool)) + retained.append(qualified) + elif allowed_tool(qualified) and tool["type"] in {"function", "custom"}: + projected.append(copy.deepcopy(tool)) + retained.append(qualified) + else: + removed.append(qualified) + return projected, retained, removed + + +def _project_catalogs(payload: dict[str, Any]) -> tuple[list[str], list[str], list[dict[str, Any]]]: + """同时约束传统顶层目录和原生 additional_tools 输入目录,保持各自 wire 位置。""" + retained, removed, catalogs = [], [], [] + + def project(value: dict[str, Any], location: str) -> None: + """替换已存在的目录并记录位置,不能凭空创建第二份工具定义。""" + projected, kept, dropped = project_tools(value["tools"]) + value["tools"] = projected + retained.extend(kept) + removed.extend(dropped) + catalogs.append({"location": location, "retained_tools": kept, "removed_tools": dropped}) + + def visit(value: Any, location: str) -> None: + """递归检查输入及子代理历史,未知额外目录封装必须拒绝。""" + if isinstance(value, dict): + kind = value.get("type") + if isinstance(kind, str) and kind.startswith("additional_tools"): + if (kind != "additional_tools" or value.get("role") != "developer" or "tools" not in value + or set(value) - {"type", "id", "role", "tools"}): + raise ValueError("原生额外工具目录结构无效") + project(value, f"{location}.tools") + return + if kind == "tool_search_output": + _validate_search_output(value) + project(value, f"{location}.tools") + return + for key, child in value.items(): + visit(child, f"{location}.{key}") + elif isinstance(value, list): + for index, child in enumerate(value): + visit(child, f"{location}[{index}]") + + if "tools" in payload: + project(payload, "tools") + visit(payload.get("input", []), "input") + return retained, removed, catalogs + + +def project_host_instructions(payload: dict[str, Any]) -> tuple[dict[str, Any], list[dict[str, Any]]]: + """只去除原生独立 AGENTS 用户说明块,保留系统提示及同消息内的业务文本。""" + removed: list[dict[str, Any]] = [] + envelope = re.compile(r"\A# AGENTS\.md instructions(?: for [^\r\n]+)?\n\n\n[\s\S]*\n\Z") + marker = "# AGENTS.md instructions" + omitted = object() + + def visit(value: Any, *, top_level: bool = False) -> Any: + """递归处理子代理历史,未知封装不能靠关键词删除后继续发送。""" + if isinstance(value, dict): + result = {} + removed_content = False + for key, child in value.items(): + if top_level and key == "instructions": + result[key] = copy.deepcopy(child) + elif key == "content" and value.get("role") == "user" and isinstance(child, list): + blocks = [] + for block in child: + content = block.get("text") if isinstance(block, dict) else None + kind = block.get("kind") if isinstance(block, dict) else None + if isinstance(content, str) and (marker in content or kind == "agents_md.instructions"): + if (block.get("type") not in {"input_text", "text"} + or kind not in {None, "agents_md.instructions"} or not envelope.fullmatch(content)): + raise ValueError("无法确认宿主说明块边界") + encoded = content.encode("utf-8") + removed.append({"sha256": hashlib.sha256(encoded).hexdigest(), "bytes": len(encoded)}) + removed_content = True + else: + blocks.append(visit(block)) + result[key] = blocks + else: + result[key] = visit(child) + return omitted if removed_content and not result.get("content") else result + if isinstance(value, list): + values = [visit(child) for child in value] + return [child for child in values if child is not omitted] + if isinstance(value, str) and any(boundary in value for boundary in ( + marker, "", "", "", "", + )): + raise ValueError("宿主说明出现在未知位置") + return value + + return visit(payload, top_level=True), removed + + +def _input_metadata(payload: Any) -> list[dict[str, Any]]: + """仅审计主输入文本块的角色、类型和指纹,不保存宿主偏好或用户任务正文。""" + if not isinstance(payload, dict): + return [] + result = [] + for item in payload.get("input", []) if isinstance(payload.get("input"), list) else []: + if not isinstance(item, dict): + continue + role = item.get("role") + content = item.get("content") + blocks = content if isinstance(content, list) else [{"type": "text", "text": content}] + for block in blocks: + text = block.get("text") if isinstance(block, dict) else None + if isinstance(text, str): + encoded = text.encode("utf-8") + result.append({"role": role if isinstance(role, str) and role in {"user", "assistant", "developer", "system", "tool"} else None, + "type": block.get("type") if isinstance(block.get("type"), str) and block["type"] in {"text", "input_text", "output_text"} else None, + "bytes": len(encoded), "sha256": hashlib.sha256(encoded).hexdigest()}) + return result + + +def _strings(payload: Any) -> Iterator[str]: + """遍历解析后的字段和值,转义后的凭据也不能从响应或审计元数据泄露。""" + if isinstance(payload, str): + yield payload + elif isinstance(payload, dict): + for key, value in payload.items(): + yield from _strings(key) + yield from _strings(value) + elif isinstance(payload, list): + for value in payload: + yield from _strings(value) + + +def _validate_search_call(payload: dict[str, Any], *, partial: bool = False) -> None: + """只接受原生客户端的查询参数,服务端搜索或自选来源不能进入受控运行。""" + if payload.get("execution") != "client" or not isinstance(payload.get("call_id"), str) or not payload["call_id"]: + raise ValueError("原生工具搜索调用边界无效") + arguments = payload.get("arguments") + if partial and arguments in (None, "", {}): + return + if not isinstance(arguments, dict) or set(arguments) - {"query", "limit"}: + raise ValueError("原生工具搜索参数无效") + if not isinstance(arguments.get("query"), str) or not arguments["query"].strip(): + raise ValueError("原生工具搜索缺少查询") + limit = arguments.get("limit", 8) + if limit is not None and (type(limit) is not int or limit <= 0): + raise ValueError("原生工具搜索数量无效") + + +def _validate_search_output(payload: dict[str, Any]) -> None: + """延迟发现返回的定义只能属于三个评测工具,不能借搜索结果再引入原生危险工具。""" + if (payload.get("execution") != "client" or payload.get("status") != "completed" + or not isinstance(payload.get("call_id"), str) or not payload["call_id"]): + raise ValueError("原生工具搜索结果边界无效") + _, retained, removed = project_tools(payload.get("tools")) + fixture_names = {f"mcp__evaluation__{tool}" for tool in FIXTURE_TOOLS} + if removed or any(name.removeprefix("functions.").replace(".", "__") not in fixture_names for name in retained): + raise ValueError("搜索结果包含评测目录以外的定义") + + +def _reject_unsafe_calls(payload: Any, *, partial: bool = False) -> None: + """校验实际调用;猜出被隐藏的函数名或内置调用类型也不能交给原生执行器。""" + if isinstance(payload, dict): + kind = payload.get("type") + if kind == "tool_search_call": + _validate_search_call(payload, partial=partial) + elif kind == "tool_search_output": + _validate_search_output(payload) + elif isinstance(kind, str) and kind.endswith("_call"): + name = payload.get("name") + namespace = payload.get("namespace") + if namespace: + name = f"{namespace}.{name}" + if kind not in {"function_call", "custom_tool_call"} or not isinstance(name, str) or not allowed_tool(name): + raise ValueError("模型返回了受控目录以外的工具调用") + for value in payload.values(): + _reject_unsafe_calls(value, partial=partial or kind == "response.output_item.added") + elif isinstance(payload, list): + for value in payload: + _reject_unsafe_calls(value, partial=partial) + + +class _LocalServer(uvicorn.Server): + """回环服务的取消由评测控制器统一管理,不占用宿主进程的信号处理器。""" + + @contextmanager + def capture_signals(self) -> Iterator[None]: + """控制器并行管理 MCP 和模型连接,服务不得互相覆盖全局信号处理。""" + yield + + +class _ModelStreamingResponse(StreamingResponse): + """客户端断开时显式等待生成器收尾,确保连接和审计结果不依赖垃圾回收。""" + + def __init__(self, body: AsyncGenerator[str, None]) -> None: + """保留本次响应独占的生成器,后续关闭不会影响其他模型请求。""" + super().__init__(body, media_type="text/event-stream") + self._body = body + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + """流式发送被取消也要完成局部清理,之后才让控制器读取终态。""" + try: + await super().__call__(scope, receive, send) + finally: + with anyio.CancelScope(shield=True): + await self._body.aclose() + + +class EvaluationModelProxy: + """原生 Codex 使用本地随机令牌,真正供应商凭据只由此固定目标代理持有。""" + + def __init__(self, settings: ModelSettings, *, probe_only: bool = False, + transport: Optional[httpx.AsyncBaseTransport] = None) -> None: + """创建独立计数和目录记录;probe_only 只记录目录,绝不真正出站。""" + self.settings = settings + self.probe_only = probe_only + self.bearer_token = secrets.token_urlsafe(32) + self.endpoint = "" + self.requests: list[dict[str, Any]] = [] + self.blocked_calls = 0 + self._rejected_requests: list[dict[str, Any]] = [] + self._client = httpx.AsyncClient(transport=transport, trust_env=False, timeout=min(120, settings.timeout_seconds)) + self._socket: Optional[socket.socket] = None + self._server: Optional[uvicorn.Server] = None + self._task: Optional[asyncio.Task[Any]] = None + self._closed = False + self._server_error: Optional[str] = None + self.app = Starlette(routes=[Route("/v1/responses", self._respond, methods=["POST"])]) + + async def __aenter__(self) -> "EvaluationModelProxy": + """显式启动本地 HTTP 服务,不访问真实模型。""" + await self.start() + return self + + async def __aexit__(self, *_args: Any) -> None: + """退出时回收服务和连接,不把运行任务交给解释器退出。""" + await self.close() + + async def start(self) -> None: + """绑定随机回环端口;启动失败或取消也必须关闭连接、监听和子任务。""" + if self._task is not None or self._closed: + raise RuntimeError("模型代理已经启动或关闭") + try: + self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._socket.bind(("127.0.0.1", 0)) + self.endpoint = f"http://127.0.0.1:{self._socket.getsockname()[1]}/v1" + self._server = _LocalServer(uvicorn.Config(self.app, log_config=None, access_log=False, log_level="error")) + self._task = asyncio.create_task(self._server.serve(sockets=[self._socket])) + async with asyncio.timeout(10): + while not self._server.started: + if self._task.done(): + await self._task + raise RuntimeError("模型代理未能启动") + await asyncio.sleep(0.01) + except BaseException: + await self.close() + raise + + async def close(self) -> None: + """有界等待退出,幂等回收;子任务异常也不能跳过后续资源关闭。""" + if self._server is not None: + self._server.should_exit = True + try: + if self._task is not None: + try: + await asyncio.wait_for(asyncio.shield(self._task), 10) + except asyncio.TimeoutError: + self._task.cancel() + except Exception as error: + self._server_error = type(error).__name__ + finally: + if not self._task.done(): + self._task.cancel() + with suppress(asyncio.CancelledError, Exception): + await self._task + finally: + self._closed = True + try: + await self._client.aclose() + finally: + if self._socket is not None: + self._socket.close() + + @staticmethod + def _error(message: str, status: int = 400) -> JSONResponse: + """只返回固定控制错误,不回显原始供应商响应、密钥或提示词。""" + return JSONResponse({"error": {"message": message, "type": "evaluation_error"}}, status_code=status) + + def _validate_payload(self, payload: Any) -> tuple[dict[str, Any], dict[str, Any]]: + """请求边界同时约束模型、预算和目录,子模型不能静默替换对照条件。""" + if not isinstance(payload, dict) or payload.get("model") != self.settings.model: + raise ValueError("模型与评测配置不符") + if self.settings.api_key in "".join(_strings(payload)): + raise ValueError("请求包含私有凭据") + payload, host_instructions = project_host_instructions(payload) + reasoning = payload.get("reasoning") + if reasoning is None: + reasoning = {} + if not isinstance(reasoning, dict) or reasoning.get("effort") not in {None, self.settings.reasoning_effort}: + raise ValueError("推理预算与评测配置不符") + tokens = payload.get("max_output_tokens", self.settings.max_output_tokens) + if type(tokens) is not int or tokens <= 0 or type(payload.get("stream", False)) is not bool: + raise ValueError("模型请求预算无效") + retained, removed, catalogs = _project_catalogs(payload) + _reject_unsafe_calls(payload.get("input", [])) + choice = payload.get("tool_choice") + if isinstance(choice, dict) and not (choice.get("type") in {"function", "custom"} + and allowed_tool(str(choice.get("name", "")))): + raise ValueError("强制工具不在受控目录") + record = {"requested_model": payload["model"], "reasoning_effort": reasoning.get("effort"), + "retained_tools": retained, "removed_tools": removed, "tool_catalogs": catalogs, "forwarded": False, + "removed_host_instructions": host_instructions, "input_blocks": _input_metadata(payload), + "request_field_types": {key: {"type": type(value).__name__, + "length": len(value) if isinstance(value, (str, list, dict)) else None} + for key, value in payload.items()}, + "completed": False, "usage": None, "response_model": None, "error_type": None} + payload["max_output_tokens"] = min(tokens, self.settings.max_output_tokens) + return payload, record + + async def _respond(self, request: Request) -> Response: + """认证与原子预算判断先于真正出站,探测请求单独计数。""" + if request.headers.get("authorization") != f"Bearer {self.bearer_token}": + return self._error("Local evaluation authentication failed", 401) + if self._closed: + return self._error("Local evaluation proxy is closed", 503) + origin = request.headers.get("origin") + if origin is not None and origin != self.endpoint.removesuffix("/v1"): + return self._error("Unexpected request origin", 403) + body = bytearray() + async for chunk in request.stream(): + body.extend(chunk) + if len(body) > MAX_REQUEST_BYTES: + return self._error("Evaluation request is too large", 413) + parsed = None + try: + parsed = json.loads(body) + payload, record = self._validate_payload(parsed) + except (ValueError, TypeError, RecursionError) as error: + self._rejected_requests.append({"error_type": type(error).__name__, "input_blocks": _input_metadata(parsed)}) + return self._error("Invalid evaluation model request") + if self.probe_only: + self.requests.append(record) + return self._error("Evaluation probe complete; no model request was forwarded") + if len(self.requests) >= self.settings.max_model_calls: + self.blocked_calls += 1 + return self._error("Evaluation model call budget exhausted") + record["forwarded"] = True + self.requests.append(record) + try: + upstream = await self._client.send(self._client.build_request( + "POST", f"{self.settings.base_url.rstrip('/')}/responses", json=payload, + headers={"Authorization": f"Bearer {self.settings.api_key}", "Content-Type": "application/json"}, + ), stream=True) + except httpx.HTTPError as error: + record["error_type"] = type(error).__name__ + return self._error("Configured evaluation model provider could not be reached", 502) + if upstream.status_code != 200: + record["http_status"] = upstream.status_code + record["error_type"] = "upstream_rejected" + await upstream.aclose() + return self._error("Configured evaluation model provider rejected the request") + if payload.get("stream"): + return _ModelStreamingResponse(self._stream(upstream, record)) + return await self._response(upstream, record) + + async def _response(self, upstream: httpx.Response, record: dict[str, Any]) -> Response: + """非流式响应同样有大小和工具边界,错误正文始终留在代理内部。""" + body = bytearray() + try: + async for chunk in upstream.aiter_bytes(): + body.extend(chunk) + if len(body) > MAX_RESPONSE_BYTES: + raise ValueError("响应超限") + response = json.loads(body) + self._validate_response(response) + self._capture(response, record) + return JSONResponse(response) + except (ValueError, TypeError, RecursionError, httpx.HTTPError): + record["error_type"] = "invalid_response" + return self._error("Model response violated the evaluation contract") + finally: + await upstream.aclose() + + def _validate_response(self, value: Any) -> None: + """在任何正文交给原生客户端之前拒绝凭据、非法调用与非对象响应。""" + if not isinstance(value, dict) or self.settings.api_key in "".join(_strings(value)): + raise ValueError("响应格式或凭据边界无效") + _reject_unsafe_calls(value) + + @staticmethod + def _capture(response: dict[str, Any], record: dict[str, Any]) -> None: + """只记录完整非负用量;响应声明完成与流后续失败分别保留。""" + if not isinstance(response, dict): + raise ValueError("响应完成对象无效") + record["completed"] = response.get("status") == "completed" + usage = response.get("usage") + record["usage"] = ({key: usage[key] for key in TOKEN_KEYS} if isinstance(usage, dict) and all( + type(usage.get(key)) is int and usage[key] >= 0 for key in TOKEN_KEYS + ) else None) + model = response.get("model") + record["response_model"] = model if isinstance(model, str) else None + + def _event(self, raw: bytes, record: dict[str, Any], tails: dict[str, str]) -> str: + """校验完整 SSE 帧以及跨帧文本片段,非法调用所在帧不向客户端发送。""" + text = raw.decode("utf-8") + data = "\n".join(line[5:].removeprefix(" ") for line in text.splitlines() if line.startswith("data:")) + if self.settings.api_key in text: + raise ValueError("事件包含私有凭据") + if data and data != "[DONE]": + value = json.loads(data) + self._validate_response(value) + delta = value.get("delta") + if isinstance(delta, str): + identity = str(value.get("item_id", "")) + combined = tails.get(identity, "") + delta + if self.settings.api_key in combined: + raise ValueError("跨事件包含私有凭据") + tails[identity] = combined[-max(1, len(self.settings.api_key) - 1):] + kind = value.get("type") + if kind in {"response.completed", "response.incomplete", "response.failed"}: + self._capture(value.get("response"), record) + record["terminal_event"] = kind + elif kind == "error": + record["error_type"] = "upstream_stream_error" + return text + "\n\n" + + async def _stream(self, upstream: httpx.Response, record: dict[str, Any]) -> AsyncGenerator[str, None]: + """完整事件校验后转发,终态交付后即关闭上游,不再等待客户端通常忽略的尾帧。""" + pending = bytearray() + tails: dict[str, str] = {} + received = 0 + try: + async for chunk in upstream.aiter_bytes(): + received += len(chunk) + if received > MAX_RESPONSE_BYTES: + raise ValueError("事件流总量超限") + pending.extend(chunk) + while True: + match = re.search(rb"\r?\n\r?\n", pending) + if match is None: + break + if match.start() > MAX_EVENT_BYTES: + raise ValueError("事件超限") + raw = bytes(pending[:match.start()]) + del pending[:match.end()] + yield self._event(raw, record, tails) + if "terminal_event" in record: + return + if len(pending) > MAX_EVENT_BYTES: + raise ValueError("事件超限") + if pending: + yield self._event(bytes(pending), record, tails) + if "terminal_event" in record: + return + if "terminal_event" not in record and record["error_type"] is None: + raise ValueError("事件流缺少终态") + except (ValueError, TypeError, RecursionError, httpx.HTTPError): + record["error_type"] = "invalid_stream" + record["completed"] = False + yield 'event: error\ndata: {"error":{"type":"evaluation_error","message":"Model stream violated the evaluation contract"}}\n\n' + except (asyncio.CancelledError, GeneratorExit): + self._record_disconnect(record) + raise + finally: + try: + await upstream.aclose() + except asyncio.CancelledError: + self._record_disconnect(record) + raise + + @staticmethod + def _record_disconnect(record: dict[str, Any]) -> None: + """客户端收到终态后关闭属于正常完成,终态前断开则保留未完成事实。""" + if "terminal_event" in record: + record["client_closed_after_terminal"] = True + else: + record["error_type"] = "stream_cancelled" + record["completed"] = False + + def snapshot(self) -> dict[str, Any]: + """探测不计真实消耗;缺失用量保持未知,已知总量是可审计下界。""" + records = copy.deepcopy(self.requests) + forwarded = [record for record in records if record["forwarded"]] + known = [record["usage"] for record in forwarded if isinstance(record["usage"], dict)] + return {"model_calls": len(forwarded), "probe_requests": len(records) - len(forwarded), + "completed_model_calls": sum(bool(record["completed"]) for record in forwarded), + "blocked_model_calls": self.blocked_calls, "usage_complete": len(known) == len(forwarded), + "reported_models": sorted({record["response_model"] for record in forwarded + if isinstance(record["response_model"], str)}), + "tokens": {key: sum(usage[key] for usage in known) for key in TOKEN_KEYS} if known else None, + "model_requests": records, "server_error": self._server_error, + "rejected_requests": copy.deepcopy(self._rejected_requests)} diff --git a/scripts/evaluation/server.py b/scripts/evaluation/server.py new file mode 100644 index 000000000..f5e29e257 --- /dev/null +++ b/scripts/evaluation/server.py @@ -0,0 +1,459 @@ +"""仅绑定回环地址的受控 MCP JSON-RPC 子集,不加载 MoviePilot 运行时或真实业务服务。""" + +import asyncio +import hashlib +import json +import re +import secrets +import socket +import time +from collections import Counter, OrderedDict +from collections.abc import Iterator +from contextlib import contextmanager +from copy import deepcopy +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Optional + +import uvicorn +import yaml # type: ignore[import-untyped] # 锁定的 PyYAML 不提供类型声明。 +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import JSONResponse, Response +from starlette.routing import Route + +from scripts.evaluation.world import EvaluationWorld + +PROTOCOL_VERSIONS = ("2025-11-25", "2025-06-18", "2025-03-26") +MAX_REQUEST_BYTES = 1024 * 1024 +MAX_SKILL_BYTES = 512 * 1024 +MAX_RESULT_BYTES = 1024 * 1024 +MAX_TOTAL_RESULT_BYTES = 4 * MAX_RESULT_BYTES +MAX_RESULTS = 8 +RESULT_TTL_SECONDS = 900 +FIRST_PAGE_CHARS = 8192 +NEXT_PAGE_CHARS = 16000 + +# 与 Agent 的 MoviePilotApiInput 保持相同字段,不提前暴露场景支持操作或 oracle 判据。 +API_INPUT_SCHEMA: dict[str, Any] = { + "type": "object", "required": ["operation_id"], "additionalProperties": False, + "properties": { + "operation_id": {"type": "string", "description": ( + "Exact allowlisted MoviePilot operation ID selected from the loaded domain Skill. " + "Never supply a URL, authentication header, or API token." + )}, + "path_params": {"type": "object", "additionalProperties": True, "description": "Route placeholder values declared by the selected operation."}, + "query": {"type": "object", "additionalProperties": True, "description": "Query-string fields declared by the selected operation."}, + "body": {"default": None, "description": ( + "JSON request value declared by the selected operation and its loaded Skill contract. " + "Most operations use an object; a oneOf branch may require an exact scalar." + )}, + }, +} + + +@dataclass(frozen=True) +class _StoredResult: + """本次评测独占的有界结果归档,MCP 客户端会话共享但其他服务实例不可读取。""" + + text: str + tool_name: str + byte_size: int + expires_at: float + + +@dataclass +class _Session: + """只保存 MCP 握手状态,不拥有或重置业务世界。""" + + protocol: str + initialized: bool = False + + +class _LocalServer(uvicorn.Server): # type: ignore[misc] # follow_imports=skip 不展开外部 Server 基类。 + """让控制器拥有服务启停,嵌入运行时不替换进程信号处理器。""" + + def __init__(self, config: uvicorn.Config, ready: asyncio.Event) -> None: + """通过事件通知实际套接字监听已就绪。""" + super().__init__(config) + self.ready = ready + + @contextmanager + def capture_signals(self) -> Iterator[None]: + """主控制器处理取消和信号,MCP 服务不接管全局状态。""" + yield + + async def startup(self, sockets: Optional[list[socket.socket]] = None) -> None: + """完成 uvicorn 的真实监听后才允许模型开始握手。""" + await super().startup(sockets=sockets) + self.ready.set() + + +class EvaluationMcpServer: + """为一个独立世界提供受认证的本地 Streamable HTTP 必需子集。""" + + def __init__(self, world: EvaluationWorld, *, root_dir: Optional[Path] = None, max_world_calls: int = 32) -> None: + """固定世界与可信源码目录;模型不能通过请求选择场景、文件或真实后端。""" + if type(max_world_calls) is not int or not 1 <= max_world_calls <= 32: + raise ValueError("max_world_calls 必须为 1 到 32 的整数") + self._world = world + self._max_world_calls = max_world_calls + self._world_calls = 0 + self._blocked_world_calls = 0 + self._tool_calls: Counter[str] = Counter() + self._bearer_token = secrets.token_urlsafe(32) + self._endpoint: Optional[str] = None + self._listener: Optional[socket.socket] = None + self._server: Optional[_LocalServer] = None + self._task: Optional[asyncio.Task[None]] = None + self._ready = asyncio.Event() + self._lifecycle_lock = asyncio.Lock() + self._closed = False + self._sessions: dict[str, _Session] = {} + self._results: OrderedDict[str, _StoredResult] = OrderedDict() + self._skill = self._load_skill(Path(root_dir) if root_dir is not None else Path(__file__).resolve().parents[2]) + + @property + def endpoint(self) -> str: + """控制器在 start 完成后取得回环 MCP 地址,不包含认证秘密。""" + if self._endpoint is None or self._closed: + raise RuntimeError("MCP 评测服务尚未运行") + return self._endpoint + + @property + def bearer_token(self) -> str: + """仅供控制器放入客户端局部环境,不会出现在任何工具或协议响应中。""" + return self._bearer_token + + @property + def stats(self) -> dict[str, Any]: + """提供无场景答案、无参数正文的控制器计数快照。""" + return {"world_calls": self._world_calls, "blocked_world_calls": self._blocked_world_calls, + "tool_calls": dict(self._tool_calls), "max_world_calls": self._max_world_calls} + + @property + def skill_sha256(self) -> str: + """控制器可记录实际公开技能正文的指纹,模型工具不暴露额外上下文。""" + return hashlib.sha256(self._skill["content"].encode("utf-8")).hexdigest() + + async def __aenter__(self) -> "EvaluationMcpServer": + """异步上下文进入时等待真实端点就绪。""" + return await self.start() + + async def __aexit__(self, *_args: Any) -> None: + """控制器正常退出、失败或取消时均关闭端点。""" + await self.close() + + async def start(self) -> "EvaluationMcpServer": + """绑定系统分配的 127.0.0.1 端口,不读取任何业务运行配置。""" + async with self._lifecycle_lock: + if self._closed: + raise RuntimeError("已关闭的评测服务不能重新启动") + if self._task is not None: + return self + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._listener = listener + try: + listener.bind(("127.0.0.1", 0)) + listener.setblocking(False) + self._endpoint = f"http://127.0.0.1:{listener.getsockname()[1]}/mcp" + app = Starlette(routes=[Route("/mcp", self._handle, methods=["POST", "GET", "DELETE"])]) + config = uvicorn.Config(app, host="127.0.0.1", log_config=None, log_level=None, access_log=False, + lifespan="off", proxy_headers=False, ws="none", timeout_graceful_shutdown=2) + self._server = _LocalServer(config, self._ready) + self._task = asyncio.create_task(self._server.serve(sockets=[listener])) + self._task.add_done_callback(lambda _task: self._ready.set()) + await asyncio.wait_for(self._ready.wait(), timeout=10) + if self._task.done() or not self._server.started: + raise RuntimeError("本地 MCP 服务启动失败") + except BaseException: + await self._stop() + raise + return self + + async def close(self) -> None: + """显式、幂等关闭 HTTP 服务,保留业务世界供控制器独立评分。""" + async with self._lifecycle_lock: + await self._stop() + + async def _stop(self) -> None: + """有限等待服务退出,异常路径也回收监听套接字与归档。""" + self._closed = True + try: + if self._server is not None: + self._server.should_exit = True + if self._task is not None: + try: + await asyncio.wait_for(asyncio.shield(self._task), timeout=5) + except TimeoutError: + self._task.cancel() + await asyncio.gather(self._task, return_exceptions=True) + except asyncio.CancelledError: + self._task.cancel() + await asyncio.gather(self._task, return_exceptions=True) + raise + finally: + if self._listener is not None: + self._listener.close() + self._sessions.clear() + self._results.clear() + + @staticmethod + def _error(request_id: Any, code: int, message: str, status: int = 400) -> JSONResponse: + """错误只使用固定协议文案,不回显异常、环境、参数或私有文件路径。""" + return JSONResponse({"jsonrpc": "2.0", "id": request_id, "error": {"code": code, "message": message}}, status_code=status) + + def _http_error(self, request: Request) -> Optional[Response]: + """先校验认证、Host 与 Origin,防止回环服务被浏览器或代理借用。""" + expected = f"Bearer {self._bearer_token}" + if len(request.headers.getlist("authorization")) != 1 or not secrets.compare_digest( + request.headers.get("authorization", "").encode("utf-8"), expected.encode("ascii"), + ): + return self._error(None, -32001, "Unauthorized", 401) + origin = self.endpoint.removesuffix("/mcp") + if request.headers.get("host") != origin.removeprefix("http://") or request.headers.get("origin", origin) != origin: + return self._error(None, -32001, "Forbidden", 403) + if request.client is None or request.client.host != "127.0.0.1": + return self._error(None, -32001, "Forbidden", 403) + version = request.headers.get("mcp-protocol-version") + if version is not None and version not in PROTOCOL_VERSIONS: + return self._error(None, -32600, "Unsupported protocol version") + return None + + async def _handle(self, request: Request) -> Response: + """处理 JSON-RPC POST;本服务不提供服务器推送 SSE 或远程关闭能力。""" + rejected = self._http_error(request) + if rejected is not None: + return rejected + if request.method != "POST": + return Response(status_code=405, headers={"Allow": "POST"}) + accepted = {item.split(";", 1)[0].strip().lower() for item in request.headers.get("accept", "").split(",")} + if not {"application/json", "text/event-stream"} <= accepted: + return self._error(None, -32600, "Accept must include application/json and text/event-stream", 406) + if request.headers.get("content-type", "").split(";", 1)[0].lower() != "application/json": + return self._error(None, -32600, "Content-Type must be application/json", 415) + try: + data = bytearray() + async for chunk in request.stream(): + data.extend(chunk) + if len(data) > MAX_REQUEST_BYTES: + return self._error(None, -32600, "Request too large", 413) + payload = json.loads(data.decode("utf-8"), parse_constant=self._invalid_json_constant) + except (ValueError, UnicodeError): + return self._error(None, -32700, "Parse error") + if not self._valid_request(payload): + return self._error(None, -32600, "Invalid Request") + try: + return self._dispatch(request, payload) + except Exception: + return self._error(payload.get("id"), -32603, "Internal error", 500) + + @staticmethod + def _invalid_json_constant(_value: str) -> None: + """JSON-RPC 仅接受标准 JSON,拒绝 Python 默认允许的 NaN/Infinity 扩展。""" + raise ValueError("Non-finite JSON number") + + @staticmethod + def _valid_request(payload: Any) -> bool: + """仅接受单条 JSON-RPC 请求或通知,拒绝 batch 和可混淆的标量类型。""" + return (type(payload) is dict and payload.get("jsonrpc") == "2.0" + and not set(payload) - {"jsonrpc", "id", "method", "params"} + and type(payload.get("method")) is str and bool(payload["method"]) + and type(payload.get("params", {})) is dict + and ("_meta" not in payload.get("params", {}) or type(payload["params"]["_meta"]) is dict) + and ("id" not in payload or type(payload["id"]) in (str, int))) + + def _dispatch(self, request: Request, payload: dict[str, Any]) -> Response: + """握手会话只管理协议状态,所有合法工具调用始终进入同一评测世界。""" + method, params, request_id = payload["method"], payload.get("params", {}), payload.get("id") + if method == "initialize": + return self._initialize(params, request_id) if "id" in payload else self._error(None, -32600, "initialize requires an id") + session = self._sessions.get(request.headers.get("mcp-session-id", "")) + if session is None: + return self._error(request_id, -32000, "Unknown MCP session", 404 if request.headers.get("mcp-session-id") else 400) + if request.headers.get("mcp-protocol-version", session.protocol) != session.protocol: + return self._error(request_id, -32600, "Protocol version differs from initialized session") + if method == "notifications/initialized": + if "id" in payload or set(params) - {"_meta"}: + return self._error(request_id, -32600, "Invalid initialized notification") + session.initialized = True + return Response(status_code=202) + if method == "notifications/cancelled" and "id" not in payload: + if (set(params) - {"requestId", "reason", "_meta"} or type(params.get("requestId")) not in (str, int) + or ("reason" in params and type(params["reason"]) is not str)): + return self._error(None, -32600, "Invalid cancellation notification") + return Response(status_code=202) + if "id" not in payload: + return self._error(None, -32600, "Unsupported notification") + if not session.initialized and method != "ping": + return self._error(request_id, -32000, "MCP session is not initialized") + if method in {"ping", "tools/list"}: + allowed = {"_meta", "cursor"} if method == "tools/list" else {"_meta"} + if set(params) - allowed or params.get("cursor") not in (None, ""): + return self._error(request_id, -32602, "Invalid params") + result: Any = {} if method == "ping" else {"tools": self._tool_catalog()} + elif method == "tools/call": + if set(params) - {"name", "arguments", "_meta"} or type(params.get("name")) is not str or type(params.get("arguments", {})) is not dict: + return self._error(request_id, -32602, "Invalid tools/call params") + result = self._call_tool(params["name"], params.get("arguments", {})) + else: + return self._error(request_id, -32601, "Method not found", 200) + return JSONResponse({"jsonrpc": "2.0", "id": request_id, "result": result}) + + def _initialize(self, params: dict[str, Any], request_id: Any) -> Response: + """按正式握手版本协商,新客户端会话不会重置业务状态或调用预算。""" + client = params.get("clientInfo") + if (set(params) - {"protocolVersion", "capabilities", "clientInfo", "_meta"} + or type(params.get("protocolVersion")) is not str + or not re.fullmatch(r"\d{4}-\d{2}-\d{2}", params["protocolVersion"]) + or type(params.get("capabilities")) is not dict or type(client) is not dict + or any(type(client.get(key)) is not str or not client[key] for key in ("name", "version"))): + return self._error(request_id, -32602, "Invalid initialize params") + if len(self._sessions) >= 64: + return self._error(request_id, -32000, "Session limit reached", 429) + protocol = params["protocolVersion"] if params["protocolVersion"] in PROTOCOL_VERSIONS else PROTOCOL_VERSIONS[0] + session_id = secrets.token_hex(24) + self._sessions[session_id] = _Session(protocol) + result = {"protocolVersion": protocol, "capabilities": {"tools": {"listChanged": False}}, + "serverInfo": {"name": "MoviePilot Evaluation", "version": "1"}, + "instructions": "Use the MoviePilot domain Skill to select API operations and interpret results."} + return JSONResponse({"jsonrpc": "2.0", "id": request_id, "result": result}, headers={"Mcp-Session-Id": session_id}) + + @staticmethod + def _tool_catalog() -> list[dict[str, Any]]: + """只公开与 MoviePilot 任务输入等价的工具合同,不注入场景答案。""" + return [ + {"name": "moviepilot_api", "description": ( + "Call allowlisted MoviePilot business APIs. Use the domain Skill to select operation_id, parameters, and failure handling. " + "For collection counts, use the smallest documented page and read collection.total_count instead of querying the database after item truncation. " + "Arbitrary URLs, commands, and authentication endpoints are forbidden." + ), "inputSchema": deepcopy(API_INPUT_SCHEMA)}, + {"name": "read_skill", "description": "Read the named MoviePilot Skill including its full body and supporting file paths. Available skill: moviepilot-api.", "inputSchema": { + "type": "object", "required": ["name"], "additionalProperties": False, "properties": {"name": {"type": "string"}}, + }}, + {"name": "read_tool_result", "description": "Read an archived tool result using result_id and next_offset. Offsets count Unicode characters. Results expire after 15 minutes or eviction; never guess result IDs.", "inputSchema": { + "type": "object", "required": ["result_id"], "additionalProperties": False, "properties": { + "result_id": {"type": "string", "minLength": 32, "maxLength": 32}, + "offset": {"type": "integer", "minimum": 0, "default": 0}, + "limit": {"type": "integer", "minimum": 1, "maximum": NEXT_PAGE_CHARS, "default": 4000}, + }, + }}, + ] + + @staticmethod + def _failure(error: str, message: str) -> dict[str, Any]: + """工具失败使用固定四态字段,异常细节不交给模型。""" + return {"success": False, "execution_outcome": "failed", "error": error, "message": message} + + def _call_tool(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]: + """精确工具白名单与全局预算均在进入业务世界前检查。""" + self._tool_calls[name if name in {"moviepilot_api", "read_skill", "read_tool_result"} else "unknown"] += 1 + try: + if name == "moviepilot_api": + if set(arguments) - set(API_INPUT_SCHEMA["properties"]) or type(arguments.get("operation_id")) is not str: + result: Any = self._failure("invalid_arguments", "Invalid moviepilot_api arguments") + elif any(key in arguments and type(arguments[key]) is not dict for key in ("path_params", "query")): + result = self._failure("invalid_arguments", "path_params and query must be objects") + elif self._world_calls >= self._max_world_calls: + self._blocked_world_calls += 1 + result = self._failure("world_call_limit", "World tool call budget exhausted") + else: + self._world_calls += 1 + result = self._world.execute(**arguments) + elif name == "read_skill": + result = self._read_skill(arguments) + elif name == "read_tool_result": + result = self._read_result(arguments) + else: + result = self._failure("tool_not_found", "Unknown tool") + except Exception: + result = self._failure("evaluation_tool_error", "Controlled tool execution failed") + text = result if isinstance(result, str) else json.dumps(result, ensure_ascii=False, indent=2) + outcome = json.loads(text).get("execution_outcome") + return {"content": [{"type": "text", "text": text}], "isError": outcome in {"failed", "unknown"}} + + @staticmethod + def _load_skill(root: Path) -> dict[str, Any]: + """读取唯一可信技能文件,不从请求中接受路径,也不加载宿主元数据解析器。""" + skill_path = root / "skills/moviepilot-api/SKILL.md" + with skill_path.open("rb") as handle: + data = handle.read(MAX_SKILL_BYTES + 1) + truncated = len(data) > MAX_SKILL_BYTES + content = data[:MAX_SKILL_BYTES].decode("utf-8", errors="ignore" if truncated else "replace") + match = re.match(r"^---\s*\n(.*?)\n---\s*\n", content, re.DOTALL) + metadata = yaml.safe_load(match.group(1)) if match else None + if not isinstance(metadata, dict) or metadata.get("name") != "moviepilot-api": + raise ValueError("moviepilot-api Skill 元数据无效") + return {"success": True, "skill": { + "id": "moviepilot-api", "name": "moviepilot-api", "description": str(metadata.get("description", ""))[:1024], + "path": "skills/moviepilot-api/SKILL.md", "allowed_tools": str(metadata.get("allowed-tools", "")).split(), + "allowed_api_operations": str(metadata.get("allowed-api-operations", "")).split(), + }, "content": content, "content_limit_bytes": MAX_SKILL_BYTES, "supporting_files": sorted( + str(path.relative_to(skill_path.parent)) for path in skill_path.parent.rglob("*") if path.is_file() and path != skill_path + ), "truncated": truncated, + "truncation_message": "SKILL.md exceeds 512 KiB; content contains only the first 512 KiB." if truncated else None} + + def _prune_results(self) -> None: + """匹配 MoviePilot 的 15 分钟、8 项、4MiB 会话归档约束。""" + now = time.monotonic() + for key in list(self._results): + if self._results[key].expires_at <= now: + del self._results[key] + while self._results and (len(self._results) > MAX_RESULTS or sum(value.byte_size for value in self._results.values()) > MAX_TOTAL_RESULT_BYTES): + self._results.popitem(last=False) + + @staticmethod + def _bounded_page(build: Any, limit: int, length: int) -> str: + """按最终 JSON 文本长度二分页边界,游标只覆盖实际交付的字符。""" + low, high = 0, length + best = json.dumps(build(0), ensure_ascii=False, indent=2) + while low <= high: + middle = (low + high) // 2 + candidate = json.dumps(build(middle), ensure_ascii=False, indent=2) + if len(candidate) <= limit: + best, low = candidate, middle + 1 + else: + high = middle - 1 + return best + + def _read_skill(self, arguments: dict[str, Any]) -> Any: + """返回完整真实技能的首屏或可续读归档,不提供精简场景专用说明。""" + if set(arguments) != {"name"} or arguments.get("name") != "moviepilot-api": + return self._failure("skill_not_found", "Unknown Skill") + text = json.dumps(self._skill, ensure_ascii=False, indent=2) + if len(text) <= FIRST_PAGE_CHARS: + return text + if len(text.encode("utf-8")) > MAX_RESULT_BYTES: + return self._failure("result_too_large", "Skill result exceeds archive limit") + result_id = secrets.token_hex(16) + self._results[result_id] = _StoredResult(text, "read_skill", len(text.encode("utf-8")), time.monotonic() + RESULT_TTL_SECONDS) + self._prune_results() + + def page(length: int) -> dict[str, Any]: + """首屏与 MoviePilot 归档预览使用相同的定位字段。""" + return {"result_id": result_id, "read_tool": "read_tool_result", "expires_in_seconds": RESULT_TTL_SECONDS, + "offset_unit": "unicode_characters", "next_offset": length, "execution_outcome": "succeeded", + "tool_result_truncated": True, "tool_name": "read_skill", "total_chars": len(text), + "returned_chars": length, "content_preview": text[:length], + "message": "完整结果已在当前会话临时保存;使用 read_tool_result 的 result_id 和 next_offset 继续读取,无需重复执行原工具。"} + return self._bounded_page(page, FIRST_PAGE_CHARS, len(text)) + + def _read_result(self, arguments: dict[str, Any]) -> Any: + """按 Unicode 字符续读本次服务归档,非法位置不得生成伪观察内容。""" + offset, limit, result_id = arguments.get("offset", 0), arguments.get("limit", 4000), arguments.get("result_id") + if (set(arguments) - {"result_id", "offset", "limit"} or type(result_id) is not str or len(result_id) != 32 + or type(offset) is not int or offset < 0 or type(limit) is not int or not 1 <= limit <= NEXT_PAGE_CHARS): + return self._failure("invalid_arguments", "Invalid result cursor or limit") + self._prune_results() + result = self._results.get(result_id) + if result is None: + return self._failure("result_unavailable", "Result unavailable") + if offset > len(result.text): + return self._failure("offset_out_of_range", "Result offset out of range") + + def page(length: int) -> dict[str, Any]: + """最终响应预算包含转义开销,next_offset 始终对应实际返回的正文。""" + end = offset + length + return {"success": True, "result_id": result_id, "tool_name": result.tool_name, "offset": offset, + "next_offset": end if end < len(result.text) else None, "total_chars": len(result.text), "content": result.text[offset:end]} + return self._bounded_page(page, NEXT_PAGE_CHARS, min(limit, len(result.text) - offset)) diff --git a/tests/test_agent_evaluation_codex.py b/tests/test_agent_evaluation_codex.py new file mode 100644 index 000000000..72bd6d9e0 --- /dev/null +++ b/tests/test_agent_evaluation_codex.py @@ -0,0 +1,353 @@ +"""原生适配器只运行假进程或控制边界,核验生命周期、目录隔离和独立评分真实性。""" + +import asyncio +import json +import os +import subprocess +import sys +import tomllib +from copy import deepcopy +from pathlib import Path +from types import SimpleNamespace +from typing import Any, Optional +from unittest.mock import AsyncMock + +import pytest + +from scripts.evaluation import codex +from scripts.evaluation.models import ModelSettings + + +def _program(tmp_path: Path, code: str) -> list[str]: + """把无害 Python 假程序放在测试目录,不调用真实 Codex 或模型。""" + script = tmp_path / "fake_native.py" + script.write_text(code, encoding="utf-8") + return [sys.executable, "-u", str(script)] + + +def _jsonl(events: list[dict[str, Any]]) -> str: + """构造与原生客户端 stdout 相同的一行一个事件格式。""" + return "\n".join(json.dumps(event, ensure_ascii=False) for event in events) + "\n" + + +def _completed_events(final: dict[str, Any]) -> list[dict[str, Any]]: + """完成证据来自终态事件,最终业务报告仍交给真实 oracle。""" + return [{"type": "turn.started"}, {"type": "item.completed", "item": { + "type": "agent_message", "text": json.dumps(final), + }}, {"type": "turn.completed"}] + + +def test_new_turn_cannot_reuse_previous_completion_or_final_text() -> None: + """上一轮成功后开始了新轮但尚未完成,不能继续标成已完成。""" + events, final, completed = codex._events(_jsonl([*_completed_events({"status": "completed"}), {"type": "turn.started"}])) + assert len(events) == 4 + assert completed is False + assert final == "" + + +def test_invalid_jsonl_preserves_valid_events_without_claiming_completion() -> None: + """损坏日志用摘要保留诊断,不泄露非事件正文,也不能被后续 completed 抹平。""" + text = _jsonl(_completed_events({"status": "completed"})) + "private-broken-event\n" + events, final, completed = codex._events(text) + assert completed is False + assert json.loads(final)["status"] == "completed" + assert events[-1]["type"] == "evaluation.invalid_event" + assert len(events[-1]["sha256"]) == 64 + assert "private-broken-event" not in json.dumps(events) + + +@pytest.mark.asyncio +async def test_execute_sends_exact_prompt_and_preserves_nonzero_exit_output(tmp_path: Path) -> None: + """真实假程序从 stdin 接收公开输入,非零退出与两路输出不得被丢弃。""" + command = _program(tmp_path, "import sys\ntext=sys.stdin.read()\nprint(text)\nprint('stderr-marker',file=sys.stderr)\nraise SystemExit(7)\n") + result = await codex._execute(command, "公开任务:保持原样 $HOME `echo no`", codex._worker_environment(), tmp_path, 5) + assert result["returncode"] == 7 + assert result["error_type"] is None + assert result["stdout"].strip() == "公开任务:保持原样 $HOME `echo no`" + assert "stderr-marker" in result["stderr"] + + +@pytest.mark.asyncio +@pytest.mark.skipif(os.name != "posix", reason="继承进程组回收是 POSIX 合同") +@pytest.mark.parametrize(("cancel", "parent_exits"), [(False, False), (False, True), (True, False)]) +async def test_execute_timeout_or_cancellation_closes_descendant_pipes( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, cancel: bool, parent_exits: bool, +) -> None: + """继承 stdout 的后代不关闭管道就不会出现 EOF,用真实 EOF 验证整组回收。""" + command = _program(tmp_path, ( + "import subprocess,sys,time\n" + "sys.stdin.read()\n" + "child=subprocess.Popen([sys.executable,'-c','import time; time.sleep(30)'],stdin=subprocess.DEVNULL)\n" + "print('READY',flush=True)\n" + + ("raise SystemExit(0)\n" if parent_exits else "time.sleep(30)\n") + )) + ready = asyncio.Event() + captured: list[asyncio.subprocess.Process] = [] + original_create = asyncio.create_subprocess_exec + + async def capture(*args: Any, **kwargs: Any) -> asyncio.subprocess.Process: + """在真实 stdout 读取边界发布握手,取消测试不依靠固定休眠。""" + process = await original_create(*args, **kwargs) + captured.append(process) + original_read = process.stdout.read + + async def read(size: int = -1) -> bytes: + """确认父进程已创建后代并输出 READY 后才允许测试发起取消。""" + data = await original_read(size) + if b"READY" in data: + ready.set() + return data + + monkeypatch.setattr(process.stdout, "read", read) + return process + + monkeypatch.setattr(codex.asyncio, "create_subprocess_exec", capture) + task = asyncio.create_task(codex._execute(command, "task", codex._worker_environment(), tmp_path, 10 if cancel else 1)) + try: + await asyncio.wait_for(ready.wait(), 5) + if cancel: + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + else: + result = await task + assert result["error_type"] == "TimeoutError" + assert "READY" in result["stdout"] + assert result["returncode"] == (0 if parent_exits else -9) + assert captured[0].returncode is not None + assert await asyncio.wait_for(captured[0].stdout.read(), 2) == b"" + finally: + if not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + for process in captured: + if process.returncode is None: + await codex._stop_process(process) + + +@pytest.mark.asyncio +async def test_output_limit_preserves_bounded_prefix_and_stops_process(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """超过输出预算时保留已取得的前缀,记录控制器失败并终止假进程。""" + monkeypatch.setattr(codex, "MAX_PROCESS_OUTPUT_BYTES", 128) + command = _program(tmp_path, "import sys,time\nsys.stdout.write('x'*4096)\nsys.stdout.flush()\ntime.sleep(30)\n") + result = await codex._execute(command, "", codex._worker_environment(), tmp_path, 5) + assert result["error_type"] == "RuntimeError" + assert result["stdout"] == "x" * 128 + assert result["returncode"] is not None + + +@pytest.mark.asyncio +async def test_cleanup_failure_still_cancels_and_joins_output_readers(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """组终止失败不能跳过其他读取器的 finally 回收。""" + stdout, stderr = object(), object() + readers: list[asyncio.Task[Any]] = [] + process = SimpleNamespace(stdin=SimpleNamespace(write=lambda _data: None, drain=AsyncMock(), close=lambda: None), + stdout=stdout, stderr=stderr, returncode=None) + + async def read(stream: Any, _buffer: bytearray) -> None: + """一条读取器失败,另一条保持挂起以验证 gather 不会自动替控制器清理。""" + readers.append(asyncio.current_task()) + if stream is stdout: + raise RuntimeError("read failed") + await asyncio.Event().wait() + + monkeypatch.setattr(codex.asyncio, "create_subprocess_exec", AsyncMock(return_value=process)) + monkeypatch.setattr(codex, "_read_output", read) + monkeypatch.setattr(codex, "_stop_process", AsyncMock(side_effect=RuntimeError("cleanup failed"))) + try: + with pytest.raises(RuntimeError, match="cleanup failed"): + await codex._execute(["fake"], "", {}, tmp_path, 5) + assert len(readers) == 2 and all(reader.done() for reader in readers) + finally: + for reader in readers: + if not reader.done(): + reader.cancel() + await asyncio.gather(*readers, return_exceptions=True) + + +def _model_catalog() -> dict[str, Any]: + """模拟二进制自带的模型记录,保留与工具模式无关的嵌套原生配置。""" + return {"models": [{"slug": "gpt-test", "tool_mode": "code_mode_only", "base_instructions": "native instructions", + "model_messages": {"instructions_template": "native message template"}, "limits": [1, 2]}, + {"slug": "unrelated", "tool_mode": "direct"}]} + + +def test_catalog_projection_changes_only_selected_tool_mode_and_is_independent() -> None: + """控制条件投影不能替换模型提示词,也不能原地改写从二进制取得的目录。""" + original = _model_catalog() + before = deepcopy(original) + projected, metadata = codex._catalog(original, "gpt-test") + assert projected == {"models": [{**original["models"][0], "tool_mode": "direct"}]} + assert metadata["original_tool_mode"] == "code_mode_only" + assert metadata["projected_tool_mode"] == "direct" + assert metadata["original_model_sha256"] != metadata["projected_model_sha256"] + projected["models"][0]["model_messages"]["instructions_template"] = "changed copy" + assert original == before + + +@pytest.mark.parametrize("catalog", [{}, {"models": []}, {"models": [{"slug": "gpt-test"}, {"slug": "gpt-test"}]}]) +def test_missing_or_ambiguous_selected_model_never_falls_back(catalog: dict[str, Any]) -> None: + """模型元数据缺失或重复时必须中止,不能换另一模型制造成功对照。""" + with pytest.raises(ValueError): + codex._catalog(catalog, "gpt-test") + + +async def _run_case(monkeypatch: pytest.MonkeyPatch, *, returncode: int = 0, completed: bool = True, + error_type: Optional[str] = None, echo_tokens: bool = False) -> dict[str, Any]: + """只替换原生进程与模型边界,业务状态和 oracle 仍使用真正内存世界。""" + worlds = [] + settings = ModelSettings("gpt-test", "https://provider.invalid/v1", "private-provider-test-key") + + class Server: + """捕获同一世界,等价模拟真实 MCP 已读取的独立业务事实。""" + + endpoint = "http://127.0.0.1:49997/mcp" + bearer_token = "local-mcp-test-token" + skill_sha256 = "skill-digest" + stats: dict[str, Any] = {} + + def __init__(self, world: Any) -> None: + """保留控制器传入的真实世界,模拟器不能替换 oracle。""" + worlds.append(world) + + async def __aenter__(self) -> "Server": + """不启动服务,所有验证都在本进程的模拟边界内。""" + return self + + async def __aexit__(self, *_args: Any) -> None: + """没有外部监听或进程需要清理。""" + + class Proxy(Server): + """模型调用统计由模拟控制边界给出,不产生真实请求。""" + + endpoint = "http://127.0.0.1:49998/v1" + bearer_token = "local-model-test-token" + + def __init__(self, _settings: ModelSettings, **_kwargs: Any) -> None: + """接受真实控制器参数,但不把连接凭据送到子进程。""" + self.closed = False + + async def __aexit__(self, *_args: Any) -> None: + """模拟客户端断开后的最终资源收敛,统计必须在此之后取得。""" + self.closed = True + + def snapshot(self) -> dict[str, Any]: + """返回最终模拟用量,关闭前不能冻结仍可能变化的完成/取消状态。""" + assert self.closed + return {"model_calls": 1, "completed_model_calls": 1, "probe_requests": 0, "model_requests": [], + "tokens": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}} + + async def execute(command: list[str], prompt: str, environment: dict[str, str], work_dir: Path, _timeout: int) -> dict[str, Any]: + """检查 argv、私有环境与中立目录,然后生成原生 JSONL 事件。""" + assert settings.api_key not in json.dumps(environment) + assert "OPENAI_API_KEY" not in environment and "CONFIG_DIR" not in environment + assert list(work_dir.iterdir()) == [] + if command[1:] == ["debug", "models", "--bundled"]: + assert not prompt + return {"returncode": 0, "stdout": json.dumps(_model_catalog()), "stderr": "", "error_type": None} + assert command[1] == "exec" and command[-1] == "-" + for flag in ("--ignore-user-config", "--ignore-rules", "--strict-config", "--ephemeral", "--json", "--skip-git-repo-check"): + assert flag in command + assert command[command.index("--sandbox") + 1] == "read-only" + assert command[command.index("-C") + 1] == str(work_dir) + assert 'approval_policy="never"' in command + mcp = tomllib.loads(next(value for value in command if value.startswith("mcp_servers=")))["mcp_servers"] + assert set(mcp) == {"evaluation"} + assert set(mcp["evaluation"]["enabled_tools"]) == {"moviepilot_api", "read_skill", "read_tool_result"} + assert mcp["evaluation"]["tools"] == {name: {"approval_mode": "approve"} + for name in mcp["evaluation"]["enabled_tools"]} + assert settings.api_key not in json.dumps(command) + assert "oracle" not in prompt and "dedup_existing" not in prompt + world = worlds[0] + subscription = world.execute("subscription.find", path_params={"media_id": world.scenario.media_id}, + query={"media_source": world.scenario.media_source})["data"] + downloads = world.execute("download.tasks.active")["data"] + final = {"status": "completed", "subscription_ids": [subscription["id"]], + "download_ids": [row["id"] for row in downloads if row["infohash"] == world.scenario.infohash], + "enabled_site_ids": [], "completed": ["subscription", "download"], "unresolved": []} + if echo_tokens: + final["debug"] = [Server.bearer_token, Proxy.bearer_token] + events = _completed_events(final) + if not completed: + events[-1] = {"type": "turn.failed", "error": {"message": "native failed"}} + stdout = _jsonl(events) + if echo_tokens: + for token in (Server.bearer_token, Proxy.bearer_token): + stdout = stdout.replace(token, "".join(f"\\u{ord(char):04x}" for char in token)) + return {"returncode": returncode, "stdout": stdout, "stderr": "", "error_type": error_type} + + monkeypatch.setattr(codex, "EvaluationMcpServer", Server) + monkeypatch.setattr(codex, "EvaluationModelProxy", Proxy) + monkeypatch.setattr(codex, "_execute", execute) + monkeypatch.setenv("OPENAI_API_KEY", "unrelated-private-key") + return await codex._run_codex("dedup_existing", settings, "fake-codex", probe_only=False) + + +@pytest.mark.asyncio +@pytest.mark.parametrize(("returncode", "completed", "error_type"), [(7, True, None), (0, False, None), (0, True, "TimeoutError"), (0, True, None)]) +async def test_native_success_is_required_even_when_real_oracle_passes(monkeypatch: pytest.MonkeyPatch, returncode: int, + completed: bool, error_type: Optional[str]) -> None: + """真实状态和读取证据正确也不能掩盖原生退出、未完成或控制器异常。""" + report = await _run_case(monkeypatch, returncode=returncode, completed=completed, error_type=error_type) + assert report["task_passed"] is True + assert report["passed"] is (returncode == 0 and completed and error_type is None) + assert report["native_exit_code"] == returncode + assert report["native_turn_completed"] is completed + + +@pytest.mark.asyncio +async def test_local_tokens_are_removed_after_native_json_unicode_decoding(monkeypatch: pytest.MonkeyPatch) -> None: + """JSON Unicode 转义不能绕过本地代理和 MCP 令牌的最终报告脱敏。""" + report = await _run_case(monkeypatch, echo_tokens=True) + serialized = json.dumps(report) + assert "local-mcp-test-token" not in serialized + assert "local-model-test-token" not in serialized + + +@pytest.mark.parametrize(("names", "calls", "rejected", "world_calls", "ready"), [ + (["functions.update_plan"], 0, [], 0, False), + (["tool_search"], 0, [], 0, True), + (["tool_search"], 1, [], 0, False), + (["tool_search"], 0, [{}], 0, False), + (["tool_search"], 0, [], 1, False), +]) +def test_probe_requires_fixture_discovery_without_model_or_world_execution(names: list[str], calls: int, + rejected: list[Any], world_calls: int, + ready: bool) -> None: + """空目录和仅计划目录不能被作为可执行对照入口,探针也不能消耗真实预算。""" + usage = {"probe_requests": 1, "model_calls": calls, "rejected_requests": rejected, + "model_requests": [{"retained_tools": names}]} + assert codex._probe_ready(usage, {"world_calls": world_calls}) is ready + + +@pytest.mark.parametrize("version", ["codex-cli 0.153.3", codex.SUPPORTED_CLI_VERSION]) +def test_cli_version_gate_uses_isolated_environment_before_any_execution(monkeypatch: pytest.MonkeyPatch, version: str) -> None: + """只在已核对版本上进入适配器;版本查询本身也不继承真实模型凭据。""" + calls = [] + + def check(command: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]: + """模拟真实 --version 响应,不运行原生客户端。""" + calls.append((command, kwargs)) + return subprocess.CompletedProcess(command, 0, stdout=version + "\n", stderr="") + + execute = AsyncMock(return_value={"checked": True}) + monkeypatch.setattr(codex.shutil, "which", lambda _name: "/fake/codex") + monkeypatch.setattr(codex.subprocess, "run", check) + monkeypatch.setattr(codex, "_run_codex", execute) + monkeypatch.setenv("OPENAI_API_KEY", "private-key-not-for-native") + settings = ModelSettings("gpt-test", "https://provider.invalid/v1", "provider-key") + if version != codex.SUPPORTED_CLI_VERSION: + with pytest.raises(RuntimeError, match="只核对过"): + codex.run_codex("dedup_existing", settings) + execute.assert_not_awaited() + else: + assert codex.run_codex("dedup_existing", settings) == {"checked": True} + assert calls[0][0] == ["/fake/codex", "--version"] + assert calls[0][1]["timeout"] == 10 + assert "OPENAI_API_KEY" not in calls[0][1]["env"] + + +def test_toml_arguments_preserve_literals_without_shell_interpretation() -> None: + """带引号、换行和 shell 字符的模型配置仍是单个正确 TOML 值。""" + value = {"name": "引号\"与换行\n以及$(command)", "enabled": False, "budget": 12} + assert tomllib.loads("value=" + codex._toml(value))["value"] == value diff --git a/tests/test_agent_evaluation_proxy.py b/tests/test_agent_evaluation_proxy.py new file mode 100644 index 000000000..2c9a387ce --- /dev/null +++ b/tests/test_agent_evaluation_proxy.py @@ -0,0 +1,645 @@ +"""模型代理的离线协议、工具隔离、预算、凭据和资源回收回归。""" + +import asyncio +import json +import signal +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import Any, Optional + +import httpx +import pytest + +from scripts.evaluation import proxy as proxy_module +from scripts.evaluation.models import ModelSettings +from scripts.evaluation.proxy import EvaluationModelProxy, project_host_instructions, project_tools + +SETTINGS = ModelSettings("test-model", "https://model.invalid/v1", "private-test-provider-key", max_model_calls=2) +USAGE = {"input_tokens": 12, "output_tokens": 8, "total_tokens": 20} + + +def _payload(**updates: Any) -> dict[str, Any]: + """构造与原生 Responses 客户端相同形态的受控工具请求。""" + return {"model": SETTINGS.model, "tools": [ + {"type": "function", "name": "update_plan", "parameters": {}}, + {"type": "function", "name": "mcp__evaluation__moviepilot_api", "parameters": {}}, + {"type": "custom", "name": "apply_patch", "format": {"type": "text"}}, + ], **updates} + + +def _completed(**updates: Any) -> dict[str, Any]: + """提供真实协议的完成信号,用量缺失与返回模型可以独立变化。""" + return {"id": "response-test", "status": "completed", "model": SETTINGS.model, "usage": USAGE, "output": [], **updates} + + +def _event(value: Any) -> bytes: + """生成标准 SSE 帧,测试仍可刻意跨任意字节拆分。""" + return f"data: {json.dumps(value)}\n\n".encode() + + +class _ByteStream(httpx.AsyncByteStream): + """可检查关闭状态的离线字节流,允许模拟帧间故障。""" + + def __init__(self, chunks: list[Any]) -> None: + """保存测试字节及异常,不持有套接字。""" + self.chunks = chunks + self.closed = False + + async def __aiter__(self) -> AsyncIterator[bytes]: + """按固定顺序返回片段,以覆盖真正的增量解析路径。""" + for chunk in self.chunks: + if isinstance(chunk, Exception): + raise chunk + yield chunk + + async def aclose(self) -> None: + """记录代理是否在成功、拒绝和中途失败时回收上游。""" + self.closed = True + + +@asynccontextmanager +async def _client(proxy: EvaluationModelProxy) -> AsyncIterator[httpx.AsyncClient]: + """ASGI 与 MockTransport 两侧都留在内存,不访问真实网络。""" + try: + async with httpx.AsyncClient(transport=httpx.ASGITransport(proxy.app), base_url="http://127.0.0.1", + headers={"Authorization": f"Bearer {proxy.bearer_token}"}) as client: + yield client + finally: + await proxy.close() + + +def test_namespace_projection_preserves_allowed_schema_and_audits_removed_tools() -> None: + """目录投影保留函数原定义,不能把原生文件能力混入对照世界。""" + tools = [{"type": "namespace", "name": "functions", "description": "native controls", "tools": _payload()["tools"]}] + projected, retained, removed = project_tools(tools) + assert retained == ["functions.update_plan", "functions.mcp__evaluation__moviepilot_api"] + assert removed == ["functions.apply_patch"] + assert projected[0]["description"] == "native controls" + assert projected[0]["tools"] == tools[0]["tools"][:2] + projected[0]["tools"][0]["parameters"]["changed"] = True + assert tools[0]["tools"][0]["parameters"] == {} + + +@pytest.mark.asyncio +async def test_probe_records_catalog_without_forwarding_or_spending_budget() -> None: + """探测首个请求目录即可继续调试,统计不能把探测伪装为真实模型调用。""" + outbound = [] + proxy = EvaluationModelProxy(SETTINGS, probe_only=True, transport=httpx.MockTransport(lambda request: outbound.append(request))) + async with _client(proxy) as client: + for _ in range(3): + assert (await client.post("/v1/responses", json=_payload())).status_code == 400 + result = proxy.snapshot() + assert not outbound and result["model_calls"] == 0 and result["probe_requests"] == 3 + assert result["tokens"] is None and result["blocked_model_calls"] == 0 + result["model_requests"][0]["retained_tools"].append("forged") + assert "forged" not in proxy.snapshot()["model_requests"][0]["retained_tools"] + + +@pytest.mark.parametrize("updates", [ + {"tools": None}, {"tools": [None]}, {"tools": [{"type": "function"}]}, + {"tools": [{"type": "function", "name": 42}]}, {"tools": [{"type": "namespace", "name": "functions", "tools": {}}]}, + {"tools": [{"type": "function", "name": "update_plan", "parameters": []}]}, + {"tools": [{"type": "function", "name": "mcp__private__message"}]}, + {"tools": [{"type": "mcp", "server_url": "https://private.invalid"}]}, + {"max_output_tokens": "100"}, {"max_output_tokens": 0}, {"max_output_tokens": -1}, + {"max_output_tokens": True}, {"max_output_tokens": 0.5}, {"max_output_tokens": None}, + {"stream": "true"}, {"model": "different-model"}, {"reasoning": {"effort": "ultra"}}, + {"reasoning": ["high"]}, {"reasoning": []}, {"reasoning": False}, {"tool_choice": {"type": "function", "name": "exec_command"}}, + {"input": [{"type": "custom_tool_call", "name": "apply_patch"}]}, + {"metadata": {SETTINGS.api_key: "hidden-in-key"}}, +]) +@pytest.mark.asyncio +async def test_invalid_request_is_rejected_before_outbound_or_accounting(updates: dict[str, Any]) -> None: + """恶意目录、错模型和非法预算必须在真正调用前明确失败。""" + outbound = [] + proxy = EvaluationModelProxy(SETTINGS, transport=httpx.MockTransport(lambda request: outbound.append(request))) + async with _client(proxy) as client: + response = await client.post("/v1/responses", json=_payload(**updates)) + assert response.status_code == 400 and not outbound and proxy.snapshot()["model_calls"] == 0 + assert SETTINGS.api_key not in response.text + json.dumps(proxy.snapshot()) + + +@pytest.mark.asyncio +async def test_request_authentication_origin_and_size_fail_without_forwarding(monkeypatch) -> None: + """本地地址也需要随机认证,错误来源与过大正文不消耗模型预算。""" + proxy = EvaluationModelProxy(SETTINGS, probe_only=True) + monkeypatch.setattr(proxy_module, "MAX_REQUEST_BYTES", 20) + async with _client(proxy) as client: + assert (await client.post("/v1/responses", json={}, headers={"Authorization": "Bearer wrong"})).status_code == 401 + assert (await client.post("/v1/responses", json={}, headers={"Origin": "https://foreign.invalid"})).status_code == 403 + assert (await client.post("/v1/responses", json=_payload())).status_code == 413 + assert (await client.post("/v1/responses", content="malformed")).status_code == 400 + assert proxy.snapshot()["probe_requests"] == 0 + + +@pytest.mark.asyncio +async def test_forwarding_uses_only_fixed_provider_and_real_key_inside_transport() -> None: + """真实凭据只进入固定供应商请求,原生客户端看到的令牌不会被转发。""" + outbound = [] + + def handler(request: httpx.Request) -> httpx.Response: + """捕获私有连接边界并返回完成响应。""" + outbound.append(request) + return httpx.Response(200, json=_completed(model="provider-reported-model")) + + proxy = EvaluationModelProxy(SETTINGS, transport=httpx.MockTransport(handler)) + async with _client(proxy) as client: + response = await client.post("/v1/responses", json=_payload(max_output_tokens=999999)) + assert response.status_code == 200 and len(outbound) == 1 + request = outbound[0] + assert str(request.url) == "https://model.invalid/v1/responses" + assert request.headers["Authorization"] == f"Bearer {SETTINGS.api_key}" + assert proxy.bearer_token not in str(request.headers) + payload = json.loads(request.content) + assert payload["max_output_tokens"] == SETTINGS.max_output_tokens + assert [tool["name"] for tool in payload["tools"]] == ["update_plan", "mcp__evaluation__moviepilot_api"] + result = proxy.snapshot() + assert result["tokens"] == USAGE and result["usage_complete"] is True + assert result["reported_models"] == ["provider-reported-model"] + assert SETTINGS.api_key not in response.text + json.dumps(result) + + +@pytest.mark.asyncio +async def test_parallel_requests_share_atomic_hard_limit_and_unknown_failure_cost() -> None: + """多子代理同时请求也不能超额,已发送失败请求保留为用量未知。""" + outbound = [] + + async def handler(request: httpx.Request) -> httpx.Response: + """让请求挂起一个调度点以模拟重叠调用。""" + outbound.append(request) + await asyncio.sleep(0) + return httpx.Response(500, text=SETTINGS.api_key) + + proxy = EvaluationModelProxy(SETTINGS, transport=httpx.MockTransport(handler)) + async with _client(proxy) as client: + responses = await asyncio.gather(*(client.post("/v1/responses", json=_payload()) for _ in range(5))) + result = proxy.snapshot() + assert len(outbound) == result["model_calls"] == 2 and result["blocked_model_calls"] == 3 + assert result["usage_complete"] is False and result["tokens"] is None + assert all(SETTINGS.api_key not in response.text for response in responses) + + +@pytest.mark.parametrize("usage", [None, {}, {"input_tokens": 2}, {**USAGE, "input_tokens": True}, + {**USAGE, "input_tokens": -1}, {**USAGE, "input_tokens": 2.5}]) +@pytest.mark.asyncio +async def test_partial_or_invalid_usage_stays_unknown(usage: Any) -> None: + """完成响应缺少完整合法用量时,不能输出虚构的零 token。""" + proxy = EvaluationModelProxy(SETTINGS, transport=httpx.MockTransport(lambda _: httpx.Response(200, json=_completed(usage=usage)))) + async with _client(proxy) as client: + assert (await client.post("/v1/responses", json=_payload())).status_code == 200 + result = proxy.snapshot() + assert result["completed_model_calls"] == 1 and result["tokens"] is None and result["usage_complete"] is False + + +@pytest.mark.parametrize("output", [ + [{"type": "function_call", "name": "exec_command", "arguments": "{}"}], + [{"type": "custom_tool_call", "name": "apply_patch", "input": "arbitrary"}], + [{"type": "computer_call", "action": {"type": "click"}}], + [{"type": "message", "content": [{"type": "output_text", "text": SETTINGS.api_key}]}], +]) +@pytest.mark.asyncio +async def test_non_streaming_forbidden_calls_and_credentials_never_reach_client(output: Any) -> None: + """非流式同样验证整个完成对象,不能依赖模型遵守已过滤目录。""" + proxy = EvaluationModelProxy(SETTINGS, transport=httpx.MockTransport(lambda _: httpx.Response(200, json=_completed(output=output)))) + async with _client(proxy) as client: + response = await client.post("/v1/responses", json=_payload()) + assert response.status_code == 400 and proxy.snapshot()["completed_model_calls"] == 0 + assert SETTINGS.api_key not in response.text and "exec_command" not in response.text + + +@pytest.mark.parametrize("ending", [b"\n\n", b"\r\n\r\n", b""]) +@pytest.mark.asyncio +async def test_sse_fragmented_frames_and_final_unterminated_event_are_captured(ending: bytes) -> None: + """帧可以跨 TCP 片段,末尾无空行的完整 JSON 事件也必须处理。""" + completed = _event({"type": "response.completed", "response": _completed()}).rstrip(b"\n") + ending + stream = _ByteStream([completed[:3], completed[3:19], completed[19:]]) + proxy = EvaluationModelProxy(SETTINGS, transport=httpx.MockTransport(lambda _: httpx.Response(200, stream=stream))) + async with _client(proxy) as client: + response = await client.post("/v1/responses", json=_payload(stream=True)) + assert response.status_code == 200 and "response.completed" in response.text and stream.closed + assert proxy.snapshot()["tokens"] == USAGE and proxy.snapshot()["completed_model_calls"] == 1 + + +@pytest.mark.parametrize("bad_event", [ + {"type": "response.output_item.added", "item": {"type": "function_call", "name": "exec_command"}}, + {"type": "response.output_item.done", "item": {"type": "custom_tool_call", "name": "apply_patch"}}, + {"type": "response.completed", "response": _completed(output=[{"type": "function_call", "name": "exec_command"}])}, + {"type": "response.output_text.delta", "delta": SETTINGS.api_key}, +]) +@pytest.mark.asyncio +async def test_sse_blocks_unsafe_frame_before_native_execution(bad_event: dict[str, Any]) -> None: + """已经转发安全进度不代表后续帧可信,非法调用帧须阻断并标记失败。""" + safe = _event({"type": "response.created", "response": {"id": "safe-progress"}}) + stream = _ByteStream([safe, _event(bad_event), _event({"type": "response.completed", "response": _completed()})]) + proxy = EvaluationModelProxy(SETTINGS, transport=httpx.MockTransport(lambda _: httpx.Response(200, stream=stream))) + async with _client(proxy) as client: + response = await client.post("/v1/responses", json=_payload(stream=True)) + assert "safe-progress" in response.text and "evaluation_error" in response.text and stream.closed + assert "exec_command" not in response.text and "apply_patch" not in response.text and SETTINGS.api_key not in response.text + result = proxy.snapshot() + assert result["completed_model_calls"] == 0 and result["model_requests"][0]["error_type"] == "invalid_stream" + + +@pytest.mark.asyncio +async def test_credentials_split_across_deltas_cannot_be_reassembled_by_client() -> None: + """密钥拆成多个合法 JSON 帧时,形成完整秘密的帧仍必须被扣留。""" + key = SETTINGS.api_key + frames = [_event({"type": "response.output_text.delta", "item_id": "message-one", "delta": part}) + for part in (key[:7], key[7:15], key[15:])] + stream = _ByteStream(frames) + proxy = EvaluationModelProxy(SETTINGS, transport=httpx.MockTransport(lambda _: httpx.Response(200, stream=stream))) + async with _client(proxy) as client: + response = await client.post("/v1/responses", json=_payload(stream=True)) + events = [json.loads(line[6:]) for line in response.text.splitlines() if line.startswith("data: ")] + assert key not in "".join(event.get("delta", "") for event in events) + assert stream.closed and proxy.snapshot()["completed_model_calls"] == 0 + + +@pytest.mark.parametrize("chunks", [[b"data: invalid\n\n"], [b"data: {"], + [_event({"type": "response.created"})], [httpx.ReadError("private failure")]]) +@pytest.mark.asyncio +async def test_malformed_truncated_or_failed_stream_has_unknown_completion(chunks: list[Any]) -> None: + """没有完整终态或网络中断的流必须保留失败证据,且关闭上游。""" + stream = _ByteStream(chunks) + proxy = EvaluationModelProxy(SETTINGS, transport=httpx.MockTransport(lambda _: httpx.Response(200, stream=stream))) + async with _client(proxy) as client: + response = await client.post("/v1/responses", json=_payload(stream=True)) + assert "evaluation_error" in response.text and "private failure" not in response.text and stream.closed + assert proxy.snapshot()["completed_model_calls"] == 0 and proxy.snapshot()["tokens"] is None + + +@pytest.mark.asyncio +async def test_oversized_event_and_nonstream_response_are_bounded(monkeypatch) -> None: + """无分隔符流和大响应都不能无限累计内存。""" + monkeypatch.setattr(proxy_module, "MAX_EVENT_BYTES", 16) + monkeypatch.setattr(proxy_module, "MAX_RESPONSE_BYTES", 16) + for streaming in (False, True): + stream = _ByteStream([b"data: " + b"x" * 30]) + proxy = EvaluationModelProxy(SETTINGS, transport=httpx.MockTransport(lambda _: httpx.Response(200, stream=stream))) + async with _client(proxy) as client: + response = await client.post("/v1/responses", json=_payload(stream=streaming)) + assert "evaluation_error" in response.text and stream.closed + + +@pytest.mark.asyncio +async def test_startup_failure_closes_client_and_socket(monkeypatch) -> None: + """服务启动异常发生在上下文进入前,也必须清理预先分配的资源。""" + async def fail(_server: Any, **_kwargs: Any) -> None: + """模拟监听服务子任务在报告就绪前失败。""" + raise RuntimeError("offline-startup-failure") + + monkeypatch.setattr(proxy_module.uvicorn.Server, "serve", fail) + proxy = EvaluationModelProxy(SETTINGS) + with pytest.raises(RuntimeError, match="offline-startup-failure"): + await proxy.start() + assert proxy._client.is_closed and proxy._socket.fileno() == -1 and proxy._task.done() + await proxy.close() + assert proxy.snapshot()["server_error"] == "RuntimeError" + + +@pytest.mark.asyncio +async def test_native_host_instructions_are_removed_without_changing_core_or_user_task() -> None: + """只移除精确原生用户说明块,不能改写原生系统提示或邻接的任务内容。""" + removed = "# AGENTS.md instructions for /synthetic/private\n\n\nsynthetic host preference\n" + outbound = [] + + def handler(request: httpx.Request) -> httpx.Response: + """保留供应商请求以断言正文仅含允许的评测上下文。""" + outbound.append(json.loads(request.content)) + return httpx.Response(200, json=_completed()) + + proxy = EvaluationModelProxy(SETTINGS, transport=httpx.MockTransport(handler)) + core = "native core instructions including literal # AGENTS.md instructions example" + ordinary = {"type": "input_text", "text": "business task must remain identical"} + nested = {"role": "user", "content": [{"type": "text", "kind": "agents_md.instructions", "text": removed}, ordinary]} + async with _client(proxy) as client: + response = await client.post("/v1/responses", json=_payload( + instructions=core, input=[{"role": "user", "content": [{"type": "input_text", "text": removed}, ordinary]}, + {"nested_history": [nested]}], + )) + assert response.status_code == 200 and outbound[0]["instructions"] == core + assert outbound[0]["input"][0]["content"] == [ordinary] + assert outbound[0]["input"][1]["nested_history"][0]["content"] == [ordinary] + records = proxy.snapshot()["model_requests"][0]["removed_host_instructions"] + assert len(records) == 2 and records[0]["bytes"] == len(removed.encode()) and len(records[0]["sha256"]) == 64 + assert "synthetic host preference" not in json.dumps(proxy.snapshot()) + + +@pytest.mark.parametrize("block", [ + {"type": "input_text", "text": "# AGENTS.md instructions\n\n\nmissing closing tag"}, + {"type": "input_text", "text": "quoted # AGENTS.md instructions should not be silently removed"}, + {"type": "output_text", "text": "# AGENTS.md instructions\n\n\nbody\n"}, + {"type": "text", "kind": "foreign.kind", "text": "# AGENTS.md instructions\n\n\nbody\n"}, + {"type": "input_text", "kind": "agents_md.instructions", "text": "missing known envelope"}, +]) +@pytest.mark.asyncio +async def test_ambiguous_host_instructions_abort_before_model_request(block: dict[str, Any]) -> None: + """无法确认的宿主文本必须停止评测,不能任意删改内容或泄漏给供应商。""" + outbound = [] + proxy = EvaluationModelProxy(SETTINGS, transport=httpx.MockTransport(lambda request: outbound.append(request))) + async with _client(proxy) as client: + response = await client.post("/v1/responses", json=_payload(input=[{"role": "user", "content": [block]}])) + assert response.status_code == 400 and not outbound and proxy.snapshot()["model_calls"] == 0 + + +@pytest.mark.asyncio +async def test_standalone_host_message_is_omitted_in_probe_audit() -> None: + """完整用户消息只有宿主块时必须连空消息一起去除,避免产生无效 Responses 输入。""" + text = "# AGENTS.md instructions\n\n\nhost\n" + payload = _payload(input=[{"role": "user", "content": [{"type": "input_text", "text": text}]}, + {"role": "user", "content": [{"type": "input_text", "text": "business"}]}]) + projected, audit = project_host_instructions(payload) + assert len(projected["input"]) == 1 and len(audit) == 1 + proxy = EvaluationModelProxy(SETTINGS, probe_only=True) + async with _client(proxy) as client: + assert (await client.post("/v1/responses", json=payload)).status_code == 400 + assert len(proxy.snapshot()["model_requests"][0]["removed_host_instructions"]) == 1 + + +@pytest.mark.parametrize("text", ["host skills", + "ordinary unrecognized host"]) +@pytest.mark.asyncio +async def test_unknown_host_skill_boundaries_abort_with_text_fingerprint_only(text: str) -> None: + """原生宿主技能残留未识别时拒绝出站,保留可私下比对的指纹证据。""" + outbound = [] + proxy = EvaluationModelProxy(SETTINGS, transport=httpx.MockTransport(lambda request: outbound.append(request))) + async with _client(proxy) as client: + response = await client.post("/v1/responses", json=_payload(input=[{"role": "user", "content": [{"type": "input_text", "text": text}]}])) + result = proxy.snapshot() + assert response.status_code == 400 and not outbound + metadata = result["rejected_requests"][0]["input_blocks"][0] + assert metadata["role"] == "user" and metadata["type"] == "input_text" and metadata["bytes"] == len(text.encode()) + assert text not in json.dumps(result) and len(metadata["sha256"]) == 64 + + +@pytest.mark.asyncio +async def test_clean_probe_reports_only_projected_input_fingerprints() -> None: + """探测报告可核对送出任务完全相同,又不会持久化任务和宿主说明正文。""" + proxy = EvaluationModelProxy(SETTINGS, probe_only=True) + async with _client(proxy) as client: + await client.post("/v1/responses", json=_payload(input=[{"role": "user", "content": [{"type": "input_text", "text": "business task"}]}])) + result = proxy.snapshot() + assert result["model_requests"][0]["input_blocks"][0]["bytes"] == 13 + fields = result["model_requests"][0]["request_field_types"] + assert fields["tools"] == {"type": "list", "length": 2} + assert fields["model"] == {"type": "str", "length": len(SETTINGS.model)} + assert "business task" not in json.dumps(result) + + +def _search_tool(description: str = 'Tools from the following sources:\n- evaluation: Controlled world') -> dict[str, Any]: + """构造已在原生线协议观察到的客户端工具搜索定义。""" + return {"type": "tool_search", "execution": "client", "description": description, + "parameters": {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}} + + +def _native_payload(**updates: Any) -> dict[str, Any]: + """原生工具随 additional_tools 输入传递,不应被代理改成空的顶层 tools。""" + return {"model": SETTINGS.model, "input": [{"type": "additional_tools", "id": "synthetic-catalog", "role": "developer", "tools": [ + {"type": "namespace", "name": "functions", "tools": _payload()["tools"]}, + {"type": "namespace", "name": "clock", "tools": [{"type": "function", "name": "curr_time", "parameters": {}}]}, + {"type": "namespace", "name": "collaboration", "tools": [ + {"type": "function", "name": name, "parameters": {}} for name in + ("spawn_agent", "followup_task", "interrupt_agent", "list_agents", "send_message", "wait_agent") + ]}, _search_tool(), + ]}], **updates} + + +@pytest.mark.asyncio +async def test_native_additional_tools_are_projected_in_place_and_audited() -> None: + """真实原生目录位置必须执行同样隔离,不能只检查不存在的顶层字段。""" + outbound = [] + + def handler(request: httpx.Request) -> httpx.Response: + """捕获投影后的 wire 结构,不连接任何外部服务。""" + outbound.append(json.loads(request.content)) + return httpx.Response(200, json=_completed()) + + proxy = EvaluationModelProxy(SETTINGS, transport=httpx.MockTransport(handler)) + async with _client(proxy) as client: + assert (await client.post("/v1/responses", json=_native_payload())).status_code == 200 + assert "tools" not in outbound[0] + catalog = outbound[0]["input"][0]["tools"] + assert [tool.get("name", tool["type"]) for tool in catalog] == ["functions", "collaboration", "tool_search"] + record = proxy.snapshot()["model_requests"][0] + assert record["removed_tools"] == ["functions.apply_patch", "clock.curr_time"] + assert len(record["retained_tools"]) == 9 and "collaboration.spawn_agent" in record["retained_tools"] + assert record["tool_catalogs"][0]["location"] == "input[0].tools" + + +@pytest.mark.parametrize("catalog", [ + [{"type": "namespace", "name": "unknown", "tools": [{"type": "function", "name": "update_plan"}]}], + [_search_tool('Tools from sources:\n- evaluation: fake\n- private: external')], + [_search_tool('No explicit source list')], + [{**_search_tool(), "execution": "server"}], +]) +@pytest.mark.asyncio +async def test_native_unknown_namespace_or_foreign_discovery_aborts_locally(catalog: list[dict[str, Any]]) -> None: + """原生未知目录与搜索源污染必须拒绝,而非静默缩减后宣称隔离成功。""" + outbound = [] + proxy = EvaluationModelProxy(SETTINGS, transport=httpx.MockTransport(lambda request: outbound.append(request))) + payload = _native_payload() + payload["input"][0]["tools"] = catalog + async with _client(proxy) as client: + response = await client.post("/v1/responses", json=payload) + assert response.status_code == 400 and not outbound + + +@pytest.mark.parametrize("updates", [{"type": "additional_tools_v2"}, {"role": "user"}, {"unexpected": "field"}, {"tools": None}]) +@pytest.mark.asyncio +async def test_native_unknown_additional_tools_shape_aborts_locally(updates: dict[str, Any]) -> None: + """额外工具消息未知变体不能绕过目录投影。""" + outbound = [] + proxy = EvaluationModelProxy(SETTINGS, transport=httpx.MockTransport(lambda request: outbound.append(request))) + payload = _native_payload() + payload["input"][0].update(updates) + async with _client(proxy) as client: + response = await client.post("/v1/responses", json=payload) + assert response.status_code == 400 and not outbound + + +def _search_call(**updates: Any) -> dict[str, Any]: + """原生工具搜索调用使用独立响应变体,参数不是 function_call.arguments 字符串。""" + return {"type": "tool_search_call", "id": "tool-search-one", "call_id": "search-one", "execution": "client", + "arguments": {"query": "MoviePilot API Skill", "limit": 3}, **updates} + + +def _search_output(**updates: Any) -> dict[str, Any]: + """模拟原生搜索注册目录后合并出的 MCP 命名空间结果。""" + return {"type": "tool_search_output", "call_id": "search-one", "status": "completed", "execution": "client", "tools": [ + {"type": "namespace", "name": "mcp__evaluation", "tools": [ + {"type": "function", "name": name, "parameters": {}} for name in ("moviepilot_api", "read_skill", "read_tool_result") + ]}, + ], **updates} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("limit", [3, None]) +async def test_native_client_discovery_call_and_namespace_output_are_allowed(limit: Optional[int]) -> None: + """真实搜索调用返回完整命名空间后,下一次模型请求仍只收到同一评测目录。""" + call = _search_call(arguments={"query": "MoviePilot API Skill", "limit": limit}) + frames = [_event({"type": "response.output_item.added", "item": _search_call(arguments={})}), + _event({"type": "response.output_item.done", "item": call}), + _event({"type": "response.completed", "response": _completed(output=[call])})] + stream = _ByteStream(frames) + outbound = [] + + def handler(request: httpx.Request) -> httpx.Response: + """第一次返回搜索指令,第二次确认已发现工具的后续请求。""" + outbound.append(json.loads(request.content)) + return httpx.Response(200, stream=stream) if len(outbound) == 1 else httpx.Response(200, json=_completed()) + + proxy = EvaluationModelProxy(SETTINGS, transport=httpx.MockTransport(handler)) + async with _client(proxy) as client: + response = await client.post("/v1/responses", json=_native_payload(stream=True)) + assert "tool_search_call" in response.text and "evaluation_error" not in response.text + payload = _native_payload() + payload["input"].extend([call, _search_output()]) + assert (await client.post("/v1/responses", json=payload)).status_code == 200 + assert "tools" not in outbound[1] + record = proxy.snapshot()["model_requests"][1] + assert record["tool_catalogs"][-1]["location"] == "input[2].tools" + assert record["tool_catalogs"][-1]["retained_tools"] == [ + "mcp__evaluation.moviepilot_api", "mcp__evaluation.read_skill", "mcp__evaluation.read_tool_result", + ] + + +@pytest.mark.parametrize("updates", [ + {"execution": "server"}, {"call_id": None}, {"call_id": ""}, {"arguments": {"query": " "}}, + {"arguments": {"query": "x", "sources": ["external"]}}, {"arguments": {"query": "x", "limit": 0}}, + {"arguments": {"query": "x", "limit": True}}, {"arguments": {"query": "x", "limit": 1.5}}, + {"arguments": '{"query":"x"}'}, +]) +@pytest.mark.asyncio +async def test_native_discovery_rejects_server_execution_and_unknown_arguments(updates: dict[str, Any]) -> None: + """工具搜索只能操作已注册本地目录,不能让模型提供外部来源或服务端执行指令。""" + stream = _ByteStream([_event({"type": "response.output_item.done", "item": _search_call(**updates)})]) + proxy = EvaluationModelProxy(SETTINGS, transport=httpx.MockTransport(lambda _: httpx.Response(200, stream=stream))) + async with _client(proxy) as client: + response = await client.post("/v1/responses", json=_native_payload(stream=True)) + assert "evaluation_error" in response.text and '"type": "tool_search_call"' not in response.text + + +@pytest.mark.parametrize("updates", [ + {"execution": "server"}, {"call_id": None}, {"status": "unknown"}, {"tools": None}, + {"tools": [{"type": "function", "name": "mcp__private__read_secret", "parameters": {}}]}, + {"tools": [{"type": "custom", "name": "apply_patch"}]}, + {"tools": [{"type": "function", "name": "update_plan", "parameters": {}}]}, +]) +@pytest.mark.asyncio +async def test_native_discovery_output_cannot_inject_other_tool_definitions(updates: dict[str, Any]) -> None: + """工具搜索输出也是目录入口,不能通过历史结果或模型返回另行引入工具。""" + outbound = [] + proxy = EvaluationModelProxy(SETTINGS, transport=httpx.MockTransport(lambda request: outbound.append(request))) + payload = _native_payload() + payload["input"].append(_search_output(**updates)) + async with _client(proxy) as client: + response = await client.post("/v1/responses", json=payload) + assert response.status_code == 400 and not outbound + + +class _WaitingStream(_ByteStream): + """发出首帧后等待,复现客户端不等供应商结束连接的真实 SSE 行为。""" + + def __init__(self, first: bytes) -> None: + """保留尾部读取事实,用于证明终态后代理主动停止读取。""" + super().__init__([first]) + self.tail_read = False + + async def __aiter__(self) -> AsyncIterator[bytes]: + """尾部故意不结束,由客户端断开或代理主动关闭来结束本次响应。""" + yield self.chunks[0] + self.tail_read = True + await asyncio.Event().wait() + + +@pytest.mark.asyncio +async def test_terminal_frame_closes_upstream_without_waiting_for_done_or_eof() -> None: + """原生收到完成事件即结束,代理不能继续等待尾帧并把正常结束误记为取消。""" + stream = _WaitingStream(_event({"type": "response.completed", "response": _completed()})) + proxy = EvaluationModelProxy(SETTINGS, transport=httpx.MockTransport(lambda _: httpx.Response(200, stream=stream))) + async with _client(proxy) as client: + async with asyncio.timeout(1): + response = await client.post("/v1/responses", json=_payload(stream=True)) + assert "response.completed" in response.text and stream.closed and not stream.tail_read + result = proxy.snapshot() + assert result["completed_model_calls"] == 1 and result["tokens"] == USAGE + assert result["model_requests"][0]["error_type"] is None + + +@pytest.mark.parametrize("completed", [True, False]) +@pytest.mark.parametrize("cancel", [True, False]) +@pytest.mark.asyncio +async def test_generator_close_or_cancellation_preserves_only_verified_terminal(completed: bool, cancel: bool) -> None: + """生成器取消与关闭都区分已观察终态和提前中断,不抹掉已完成模型的真实消耗。""" + event = {"type": "response.completed", "response": _completed()} if completed else {"type": "response.created"} + stream = _WaitingStream(_event(event)) + upstream = httpx.Response(200, stream=stream) + proxy = EvaluationModelProxy(SETTINGS) + record = {"completed": False, "usage": None, "error_type": None} + body = proxy._stream(upstream, record) + await anext(body) + if cancel: + with pytest.raises(asyncio.CancelledError): + await body.athrow(asyncio.CancelledError()) + else: + await body.aclose() + await proxy.close() + assert stream.closed and record["completed"] is completed + if completed: + assert record["usage"] == USAGE and record["error_type"] is None and record["client_closed_after_terminal"] is True + else: + assert record["usage"] is None and record["error_type"] == "stream_cancelled" + + +@pytest.mark.parametrize("completed", [True, False]) +@pytest.mark.asyncio +async def test_real_asgi_client_disconnect_keeps_terminal_and_rejects_early_close(completed: bool) -> None: + """通过真实 ASGI 断开事件取消流式传输,验证客户端结束时的最终审计结果。""" + event = {"type": "response.completed", "response": _completed()} if completed else {"type": "response.created"} + stream = _WaitingStream(_event(event)) + proxy = EvaluationModelProxy(SETTINGS, transport=httpx.MockTransport(lambda _: httpx.Response(200, stream=stream))) + disconnect = asyncio.Event() + request_sent = False + delivered = [] + + async def receive() -> dict[str, Any]: + """首个事件传入请求,客户端收到首帧后立刻报告断开。""" + nonlocal request_sent + if not request_sent: + request_sent = True + return {"type": "http.request", "body": json.dumps(_payload(stream=True)).encode(), "more_body": False} + await disconnect.wait() + return {"type": "http.disconnect"} + + async def send(message: dict[str, Any]) -> None: + """完成交付首帧后让断开监听器抢占,覆盖真正传输生命周期中的取消。""" + if message["type"] == "http.response.body" and message.get("body"): + delivered.append(message["body"]) + disconnect.set() + await asyncio.sleep(0) + + scope = {"type": "http", "asgi": {"version": "3.0", "spec_version": "2.3"}, "method": "POST", + "scheme": "http", "path": "/v1/responses", "raw_path": b"/v1/responses", "query_string": b"", + "root_path": "", "http_version": "1.1", "server": ("127.0.0.1", 80), "client": ("127.0.0.1", 1234), + "headers": [(b"authorization", f"Bearer {proxy.bearer_token}".encode())]} + try: + async with asyncio.timeout(1): + await proxy.app(scope, receive, send) + await asyncio.sleep(0) + finally: + await proxy.close() + assert delivered and stream.closed + result = proxy.snapshot() + assert result["completed_model_calls"] == int(completed) + assert result["tokens"] == (USAGE if completed else None) + assert result["model_requests"][0]["error_type"] == (None if completed else "stream_cancelled") + + +@pytest.mark.asyncio +async def test_proxy_start_and_close_do_not_replace_host_signal_handlers() -> None: + """本地模型服务与 MCP 共用控制器时,启动和关闭都不能接管进程全局信号。""" + signals = (signal.SIGINT, signal.SIGTERM) + previous = {item: signal.getsignal(item) for item in signals} + async with EvaluationModelProxy(SETTINGS, probe_only=True): + assert {item: signal.getsignal(item) for item in signals} == previous + assert {item: signal.getsignal(item) for item in signals} == previous diff --git a/tests/test_agent_evaluation_server.py b/tests/test_agent_evaluation_server.py new file mode 100644 index 000000000..762ada6c6 --- /dev/null +++ b/tests/test_agent_evaluation_server.py @@ -0,0 +1,299 @@ +"""通过真实回环 HTTP 验证受控 MCP 握手、世界共享、结果分页和关闭,不访问外部服务。""" + +import ast +import asyncio +import json +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import replace +from pathlib import Path +from typing import Any + +import httpx +import pytest + +from scripts.evaluation.server import API_INPUT_SCHEMA, EvaluationMcpServer +from scripts.evaluation.world import EvaluationWorld + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +def _headers(server: EvaluationMcpServer) -> dict[str, str]: + """客户端局部传入随机认证值,不读取进程凭据或代理设置。""" + return {"Authorization": f"Bearer {server.bearer_token}", "Accept": "application/json, text/event-stream"} + + +async def _rpc(client: httpx.AsyncClient, method: str, params: Any = None, request_id: Any = 1) -> httpx.Response: + """通过实际 TCP 端点发送单条 JSON-RPC 请求。""" + payload = {"jsonrpc": "2.0", "id": request_id, "method": method} + if params is not None: + payload["params"] = params + return await client.post("/mcp", json=payload) + + +@asynccontextmanager +async def _client(server: EvaluationMcpServer, protocol: str = "2025-11-25") -> AsyncIterator[httpx.AsyncClient]: + """完成真实 initialize/initialized 握手后交付一个独立 MCP 客户端会话。""" + async with httpx.AsyncClient(base_url=server.endpoint.removesuffix("/mcp"), headers=_headers(server), trust_env=False, timeout=5) as client: + response = await _rpc(client, "initialize", { + "protocolVersion": protocol, "capabilities": {}, "clientInfo": {"name": "offline-test", "version": "1"}, + }) + assert response.status_code == 200 + client.headers["Mcp-Session-Id"] = response.headers["Mcp-Session-Id"] + client.headers["MCP-Protocol-Version"] = response.json()["result"]["protocolVersion"] + initialized = await client.post("/mcp", json={"jsonrpc": "2.0", "method": "notifications/initialized"}) + assert initialized.status_code == 202 and not initialized.content + yield client + + +async def _tool(client: httpx.AsyncClient, name: str, arguments: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]: + """读取 MCP 标准文本工具结果,业务状态仍从其独立 JSON 协议判断。""" + response = await _rpc(client, "tools/call", {"name": name, "arguments": arguments}) + assert response.status_code == 200 + result = response.json()["result"] + assert result["content"][0]["type"] == "text" + return result, json.loads(result["content"][0]["text"]) + + +@pytest.mark.asyncio +async def test_real_http_handshake_exposes_only_equivalent_public_tools() -> None: + """端点真实监听回环,公开目录没有场景名称、初态、账本或 oracle 入口。""" + world = EvaluationWorld("dedup_existing") + async with EvaluationMcpServer(world) as server: + assert server.endpoint.startswith("http://127.0.0.1:") + async with _client(server) as client: + response = await _rpc(client, "tools/list") + tools = response.json()["result"]["tools"] + assert [tool["name"] for tool in tools] == ["moviepilot_api", "read_skill", "read_tool_result"] + assert tools[0]["inputSchema"] == API_INPUT_SCHEMA + assert set(tools[0]["inputSchema"]["properties"]) == {"operation_id", "path_params", "query", "body"} + serialized = json.dumps(response.json()) + for private in ("dedup_existing", "honest_unknown", "oracle", "snapshot", "ledger", server.bearer_token, str(PROJECT_ROOT)): + assert private not in serialized + assert (await _rpc(client, "ping")).json()["result"] == {} + assert (await client.get("/mcp")).status_code == 405 + assert (await client.delete("/mcp")).status_code == 405 + assert world.snapshot() == world.initial_snapshot() + + +@pytest.mark.asyncio +async def test_two_client_sessions_share_unknown_side_effect_and_duplicate_attempt() -> None: + """新 MCP 连接不重置世界,未知回执后的副作用能被另一会话读取确认。""" + world = EvaluationWorld("unknown_download") + body = {"torrent_in": {"title": world.scenario.title, "enclosure": world.scenario.magnet}, + "media_source": world.scenario.media_source, "media_id": world.scenario.media_id} + async with EvaluationMcpServer(world) as server: + async with _client(server) as first, _client(server) as second: + assert first.headers["Mcp-Session-Id"] != second.headers["Mcp-Session-Id"] + result, unknown = await _tool(first, "moviepilot_api", {"operation_id": "download.add", "body": body}) + assert result["isError"] is True and unknown["execution_outcome"] == "unknown" + _, observed = await _tool(second, "moviepilot_api", {"operation_id": "download.tasks.active"}) + assert any(row["infohash"] == world.scenario.infohash for row in observed["data"]) + _, duplicate = await _tool(second, "moviepilot_api", {"operation_id": "download.add", "body": body}) + assert duplicate["execution_outcome"] == "failed" + assert world.ledger[-1]["duplicate_attempt"] is True + assert server.stats["world_calls"] == 3 + assert sum(row["infohash"] == world.scenario.infohash for row in world.snapshot()["downloads"]) == 1 + + +@pytest.mark.asyncio +async def test_skill_pagination_reconstructs_unmodified_repository_skill() -> None: + """首屏与后页实际 JSON 文本有界,所有分页拼接后仍是完整真实技能而非评测特制摘要。""" + async with EvaluationMcpServer(EvaluationWorld("dedup_existing")) as server: + async with _client(server) as client: + first_result, first = await _tool(client, "read_skill", {"name": "moviepilot-api"}) + assert len(first_result["content"][0]["text"]) <= 8192 + assert first["tool_result_truncated"] is True + restored = first["content_preview"] + cursor = first["next_offset"] + while cursor is not None: + response, page = await _tool(client, "read_tool_result", {"result_id": first["result_id"], "offset": cursor, "limit": 16000}) + assert len(response["content"][0]["text"]) <= 16000 + assert page["offset"] == len(restored) + restored += page["content"] + if page["next_offset"] is not None: + assert page["next_offset"] > cursor + cursor = page["next_offset"] + skill = json.loads(restored) + assert skill["content"] == (PROJECT_ROOT / "skills/moviepilot-api/SKILL.md").read_text(encoding="utf-8") + assert skill["skill"]["name"] == "moviepilot-api" + assert skill["skill"]["allowed_tools"] == ["moviepilot_api"] + assert len(skill["skill"]["allowed_api_operations"]) > 100 + assert "library.exists" in skill["skill"]["allowed_api_operations"] + assert skill["supporting_files"] == [] + assert server.stats["world_calls"] == 0 + + +@pytest.mark.asyncio +async def test_world_call_limit_is_global_across_sessions_and_parallel_requests() -> None: + """并行连接也共享硬调用上限,超限不会继续读取或改变独立世界。""" + world = EvaluationWorld("dedup_existing") + async with EvaluationMcpServer(world, max_world_calls=2) as server: + async with _client(server) as first, _client(server) as second: + replies = await asyncio.gather(*[ + _tool(client, "moviepilot_api", {"operation_id": "site.list"}) for client in (first, second, first, second) + ]) + assert sum(payload.get("error") == "world_call_limit" for _, payload in replies) == 2 + assert len(world.ledger) == 2 + assert server.stats["world_calls"] == 2 + assert server.stats["blocked_world_calls"] == 2 + assert server.stats["tool_calls"]["moviepilot_api"] == 4 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("headers,expected", [ + ({"Authorization": "Bearer invalid"}, 401), ({"Authorization": ""}, 401), + ({"Origin": "https://outside.invalid"}, 403), ({"Host": "outside.invalid"}, 403), + ({"MCP-Protocol-Version": "unknown"}, 400), ({"Accept": "application/json"}, 406), +]) +async def test_http_boundary_rejects_bad_auth_origin_host_protocol_and_accept(headers: dict[str, str], expected: int) -> None: + """认证与 HTTP 协议失败不能触及世界,也不能回显服务器秘密。""" + world = EvaluationWorld("dedup_existing") + async with EvaluationMcpServer(world) as server: + async with _client(server) as client: + response = await client.post("/mcp", headers=headers, json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"}) + assert response.status_code == expected + assert server.bearer_token not in response.text + assert not world.ledger + + +@pytest.mark.asyncio +@pytest.mark.parametrize("payload", [[], {"jsonrpc": "1.0", "id": 1, "method": "ping"}, + {"jsonrpc": "2.0", "id": True, "method": "ping"}, {"jsonrpc": "2.0", "id": 1, "method": []}, + {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": []}, + {"jsonrpc": "2.0", "id": 1, "method": "ping", "oracle": True}, +]) +async def test_jsonrpc_shape_errors_are_fixed_and_do_not_execute(payload: Any) -> None: + """batch、错误 ID/参数类型及未知顶层字段均明确拒绝。""" + world = EvaluationWorld("dedup_existing") + async with EvaluationMcpServer(world) as server: + async with _client(server) as client: + response = await client.post("/mcp", json=payload) + assert response.status_code == 400 + assert response.json()["error"]["code"] == -32600 + assert not world.ledger + + +@pytest.mark.asyncio +@pytest.mark.parametrize("constant", ["NaN", "Infinity", "-Infinity"]) +async def test_non_finite_json_numbers_are_rejected_before_world_execution(constant: str) -> None: + """非标准 JSON 数字不能被解析后进入业务状态或破坏响应序列化。""" + world = EvaluationWorld("dedup_existing") + async with EvaluationMcpServer(world) as server: + async with _client(server) as client: + response = await client.post("/mcp", headers={"Content-Type": "application/json"}, content=( + '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"moviepilot_api",' + '"arguments":{"operation_id":"site.list","query":{"count":' + constant + '}}}}' + )) + assert response.status_code == 400 + assert response.json()["error"]["code"] == -32700 + assert not world.ledger + + +@pytest.mark.asyncio +async def test_initialization_version_negotiation_and_session_requirements() -> None: + """协商支持版本,已建会话拒绝协议切换;初始化完成前不开放工具。""" + async with EvaluationMcpServer(EvaluationWorld("dedup_existing")) as server: + async with httpx.AsyncClient(base_url=server.endpoint.removesuffix("/mcp"), headers=_headers(server), trust_env=False) as client: + assert (await _rpc(client, "tools/list")).status_code == 400 + response = await _rpc(client, "initialize", {"protocolVersion": "2099-01-01", "capabilities": {}, "clientInfo": {"name": "future", "version": "1"}}) + assert response.json()["result"]["protocolVersion"] == "2025-11-25" + client.headers["Mcp-Session-Id"] = response.headers["Mcp-Session-Id"] + assert (await _rpc(client, "tools/list")).status_code == 400 + client.headers["MCP-Protocol-Version"] = "2025-06-18" + assert (await _rpc(client, "ping")).status_code == 400 + client.headers["MCP-Protocol-Version"] = "2025-11-25" + assert (await _rpc(client, "ping")).status_code == 200 + async with _client(server, "2025-03-26") as client: + assert (await _rpc(client, "tools/list")).status_code == 200 + + +@pytest.mark.asyncio +async def test_unknown_tools_and_invalid_arguments_never_expose_control_state() -> None: + """只能执行三种公开工具,路径穿越、私有状态名称和错放字段都不能读取控制器状态。""" + world = EvaluationWorld("dedup_existing") + async with EvaluationMcpServer(world) as server: + async with _client(server) as client: + for name, arguments in [ + ("snapshot", {}), ("ledger", {}), ("read_skill", {"name": "../../scripts/evaluation/score.py"}), + ("moviepilot_api", {"operation_id": "site.list", "scenario_id": "honest_unknown"}), + ("moviepilot_api", {"operation_id": "site.list", "query": []}), + ("read_tool_result", {"result_id": "0" * 32, "offset": True}), + ]: + result, payload = await _tool(client, name, arguments) + assert result["isError"] is True + assert payload["execution_outcome"] == "failed" + assert "subscriptions" not in payload and "ledger" not in payload + assert not world.ledger + missing = await _rpc(client, "resources/read", {"uri": "oracle"}) + assert missing.json()["error"]["code"] == -32601 + + +@pytest.mark.asyncio +async def test_archive_expiry_and_cross_server_isolation() -> None: + """归档跨同次运行的会话可用,但别的 server 或过期归档不能被猜 ID 读取。""" + async with EvaluationMcpServer(EvaluationWorld("dedup_existing")) as first_server, EvaluationMcpServer(EvaluationWorld("unknown_download")) as second_server: + async with _client(first_server) as first, _client(first_server) as sibling, _client(second_server) as second: + _, preview = await _tool(first, "read_skill", {"name": "moviepilot-api"}) + arguments = {"result_id": preview["result_id"], "offset": preview["next_offset"]} + result, _ = await _tool(sibling, "read_tool_result", arguments) + assert result["isError"] is False + _, unavailable = await _tool(second, "read_tool_result", arguments) + assert unavailable["error"] == "result_unavailable" + key = preview["result_id"] + first_server._results[key] = replace(first_server._results[key], expires_at=0) + _, expired = await _tool(first, "read_tool_result", arguments) + assert expired["error"] == "result_unavailable" + + +@pytest.mark.asyncio +async def test_internal_tool_exception_does_not_leak_environment_or_details(monkeypatch: pytest.MonkeyPatch) -> None: + """世界异常只能返回固定失败,不把进程秘密或异常正文传给模型。""" + world = EvaluationWorld("dedup_existing") + + def fail(**_arguments: Any) -> dict[str, Any]: + """模拟内部故障而不读取真实环境。""" + raise RuntimeError("private-oracle-and-token-value") + + monkeypatch.setattr(world, "execute", fail) + async with EvaluationMcpServer(world) as server: + async with _client(server) as client: + result, payload = await _tool(client, "moviepilot_api", {"operation_id": "site.list"}) + assert result["isError"] is True + assert payload["error"] == "evaluation_tool_error" + assert "private-oracle" not in json.dumps(result) + + +@pytest.mark.asyncio +async def test_close_stops_listener_and_cannot_be_triggered_by_client() -> None: + """HTTP 客户端无权结束整次评测,控制器关闭后真实监听端口不可连接。""" + server = EvaluationMcpServer(EvaluationWorld("dedup_existing")) + await server.start() + endpoint = server.endpoint + async with _client(server) as client: + assert (await _rpc(client, "shutdown")).json()["error"]["code"] == -32601 + assert (await _rpc(client, "ping")).status_code == 200 + await server.close() + await server.close() + assert server._task.done() + with pytest.raises(RuntimeError): + _ = server.endpoint + async with httpx.AsyncClient(trust_env=False) as client: + with pytest.raises(httpx.ConnectError): + await client.post(endpoint, json={}) + + +@pytest.mark.parametrize("limit", [0, 33, True, -1]) +def test_invalid_world_call_budget_is_rejected(limit: Any) -> None: + """控制器也不能把单次原生评测放大到声明的 32 次世界调用以上。""" + with pytest.raises(ValueError): + EvaluationMcpServer(EvaluationWorld("dedup_existing"), max_world_calls=limit) + + +def test_server_imports_no_moviepilot_runtime_or_oracle() -> None: + """服务与世界都独立于 app 运行时,判定器不能通过服务依赖进入可见目录。""" + tree = ast.parse((PROJECT_ROOT / "scripts/evaluation/server.py").read_text(encoding="utf-8")) + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom): + assert not (node.module or "").startswith("app.") + assert node.module != "scripts.evaluation.score"