fix(device-test): contain tool processes and add reviewed preview
This commit is contained in:
1 parent
c98d7527a0
commit
7d31e09cb7
59 files changed
+23722
No files matched your search
@@ -0,0 +1,645 @@
|
||||
"""Native Artemis MCP entry points for the user-provided multi-tool testing Skill."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import wraps
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import tempfile
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from mcp.types import CallToolResult, ToolAnnotations
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from device_runtime import (
|
||||
InputError,
|
||||
PACKAGE,
|
||||
artifact_root,
|
||||
attach_file,
|
||||
check_budget,
|
||||
failure,
|
||||
local_file,
|
||||
new_artifact,
|
||||
receipt,
|
||||
rtsp_environment,
|
||||
run_cli,
|
||||
runner_arguments,
|
||||
sanitized,
|
||||
sha256,
|
||||
task_evidence,
|
||||
unicast_host,
|
||||
)
|
||||
|
||||
|
||||
mcp = FastMCP("artemis-device-test")
|
||||
Text = Annotated[str, Field(min_length=1, max_length=4096)]
|
||||
Identity = Annotated[str, Field(min_length=1, max_length=256)]
|
||||
Protocol = Literal["v1", "v2"]
|
||||
VersionField = Literal["firmware", "dev_ver", "inner.version", "outer.version"]
|
||||
Seconds = Annotated[int, Field(ge=1, le=300)]
|
||||
|
||||
|
||||
def tool(*, write: bool = False):
|
||||
def register(function):
|
||||
@wraps(function)
|
||||
async def guarded(*args, **kwargs):
|
||||
try:
|
||||
return await function(*args, **kwargs)
|
||||
except InputError as exc:
|
||||
return failure(str(exc))
|
||||
except (OSError, ValueError, TypeError):
|
||||
return failure(
|
||||
"本机文件或配置无法读取;未将异常原文写入回执。",
|
||||
"local_configuration_error",
|
||||
execution="unknown",
|
||||
side_effect="possible" if write else "none",
|
||||
)
|
||||
|
||||
mcp.tool(
|
||||
annotations=ToolAnnotations(
|
||||
readOnlyHint=not write,
|
||||
destructiveHint=write,
|
||||
idempotentHint=not write,
|
||||
openWorldHint=True,
|
||||
)
|
||||
)(guarded)
|
||||
return guarded
|
||||
|
||||
return register
|
||||
|
||||
|
||||
async def runner(
|
||||
command: str,
|
||||
arguments: list[str],
|
||||
*,
|
||||
target="local-environment",
|
||||
files=None,
|
||||
env=None,
|
||||
secrets=(),
|
||||
write=False,
|
||||
):
|
||||
return await run_cli(
|
||||
"dependency_runner.py",
|
||||
runner_arguments(command, arguments),
|
||||
operation=command,
|
||||
target=target,
|
||||
files=files,
|
||||
env=env,
|
||||
secrets=secrets,
|
||||
write=write,
|
||||
)
|
||||
|
||||
|
||||
def identity(
|
||||
protocol: Protocol, host: str, expected_sn: str, expected_model: str | None, timeout: int
|
||||
) -> list[str]:
|
||||
if not expected_sn.strip() or (expected_model is not None and not expected_model.strip()):
|
||||
raise InputError("必须提供完整 SN 和设备上报的准确型号。")
|
||||
check_budget(timeout + 5)
|
||||
arguments = [
|
||||
"--protocol",
|
||||
protocol,
|
||||
"--host",
|
||||
unicast_host(host),
|
||||
"--expected-sn",
|
||||
expected_sn,
|
||||
"--timeout",
|
||||
str(timeout),
|
||||
]
|
||||
if expected_model is not None:
|
||||
arguments += ["--expected-model", expected_model]
|
||||
return arguments
|
||||
|
||||
|
||||
def version(protocol: Protocol, target_version: str, version_field: VersionField) -> list[str]:
|
||||
allowed = {"firmware", "dev_ver"} if protocol == "v1" else {"inner.version", "outer.version"}
|
||||
if version_field not in allowed or not target_version.strip():
|
||||
raise InputError(
|
||||
"验收字段与设备协议不符;V1 使用 firmware/dev_ver,V2 使用 inner.version/outer.version。"
|
||||
)
|
||||
return ["--target-version", target_version, "--version-field", version_field]
|
||||
|
||||
|
||||
@tool()
|
||||
async def environment_doctor(
|
||||
check_versions: bool = False, check_functional: bool = False
|
||||
) -> CallToolResult:
|
||||
"""逐项检查 PATH/统一目录/profile。check_versions 验证启动/DLL;check_functional 加做固定本地样本,不扫描、抓包或打开串口。缺失只阻塞相关能力。"""
|
||||
arguments = ["--check-versions"] if check_versions else []
|
||||
if check_functional:
|
||||
arguments.append("--check-functional")
|
||||
return await runner("doctor", arguments)
|
||||
|
||||
|
||||
@tool()
|
||||
async def lookup_case(
|
||||
sheet: Annotated[str, Field(max_length=256)] = "",
|
||||
row: Annotated[int, Field(ge=0, le=1000000)] = 0,
|
||||
query: Annotated[str, Field(max_length=256)] = "",
|
||||
limit: Annotated[int, Field(ge=1, le=20)] = 10,
|
||||
) -> CallToolResult:
|
||||
"""查询原 Skill 的 357 条路由;用 sheet+row 唯一定位,query 可查编号/标题;路由不是完整用例。"""
|
||||
if row and not sheet:
|
||||
raise InputError("按行查询必须同时提供工作表 sheet。")
|
||||
catalog = json.loads((PACKAGE / "skill" / "assets" / "case-routing.json").read_text("utf-8"))
|
||||
matches = []
|
||||
for case in catalog["cases"]:
|
||||
source = case["source"]
|
||||
if sheet and source["sheet"] != sheet:
|
||||
continue
|
||||
if row and source["row"] != row:
|
||||
continue
|
||||
if query and query.casefold() not in json.dumps(case, ensure_ascii=False).casefold():
|
||||
continue
|
||||
matches.append(case)
|
||||
return receipt(
|
||||
"case-lookup",
|
||||
{
|
||||
"total_matches": len(matches),
|
||||
"cases": matches[:limit],
|
||||
"complete_case_specification_required": True,
|
||||
},
|
||||
kind="file",
|
||||
target="skill/assets/case-routing.json",
|
||||
)
|
||||
|
||||
|
||||
@tool()
|
||||
async def probe_media(
|
||||
input_file: Annotated[str, Field(max_length=4096)] = "",
|
||||
rtsp_env: Annotated[str, Field(max_length=100)] = "",
|
||||
transport: Literal["tcp", "udp"] = "tcp",
|
||||
) -> CallToolResult:
|
||||
"""ffprobe 白名单媒体元数据;input_file 与 rtsp_env 二选一。RTSP 只收本机环境变量名。"""
|
||||
if bool(input_file) == bool(rtsp_env):
|
||||
raise InputError("本地文件和 RTSP 环境变量必须且只能提供一个。")
|
||||
env, secrets = rtsp_environment(rtsp_env) if rtsp_env else ({}, ())
|
||||
arguments = (
|
||||
["--rtsp-env", rtsp_env, "--transport", transport]
|
||||
if rtsp_env
|
||||
else ["--input", str(local_file(input_file))]
|
||||
)
|
||||
return await runner(
|
||||
"probe",
|
||||
arguments,
|
||||
target="rtsp-env:" + rtsp_env if rtsp_env else input_file,
|
||||
env=env,
|
||||
secrets=secrets,
|
||||
)
|
||||
|
||||
|
||||
@tool()
|
||||
async def media_info(input_file: Text) -> CallToolResult:
|
||||
"""通过 profile 指定的 MediaInfo.dll 有界分析本地媒体,输出固定元数据;不调用 GUI。"""
|
||||
return await runner("media-info", ["--input", str(local_file(input_file))], target=input_file)
|
||||
|
||||
|
||||
@tool()
|
||||
async def analyze_pcap(
|
||||
input_file: Text, max_packets: Annotated[int, Field(ge=1, le=10000)] = 1000
|
||||
) -> CallToolResult:
|
||||
"""TShark 离线提取最多 10000 包的固定头字段,不返回 payload 或认证头。"""
|
||||
return await runner(
|
||||
"analyze-pcap",
|
||||
["--input", str(local_file(input_file)), "--max-packets", str(max_packets)],
|
||||
target=input_file,
|
||||
)
|
||||
|
||||
|
||||
@tool(write=True)
|
||||
async def capture_packets(
|
||||
interface: Identity, host: Identity, duration_seconds: Seconds = 10
|
||||
) -> CallToolResult:
|
||||
"""显式网卡+单 IP 的有界抓包;仅在当前用例授权采集时使用,输出到本任务目录。"""
|
||||
host = unicast_host(host)
|
||||
check_budget(duration_seconds + 8)
|
||||
output = new_artifact("capture", ".pcapng")
|
||||
return await runner(
|
||||
"capture",
|
||||
[
|
||||
"--interface",
|
||||
interface,
|
||||
"--host",
|
||||
host,
|
||||
"--duration",
|
||||
str(duration_seconds),
|
||||
"--output",
|
||||
str(output),
|
||||
],
|
||||
target=host,
|
||||
files=[output],
|
||||
write=True,
|
||||
)
|
||||
|
||||
|
||||
@tool()
|
||||
async def scan_ports(
|
||||
host: Identity,
|
||||
ports: Annotated[
|
||||
list[Annotated[int, Field(ge=1, le=65535)]], Field(min_length=1, max_length=128)
|
||||
],
|
||||
) -> CallToolResult:
|
||||
"""Nmap 单 IP 的最多 128 个明确 TCP 端口检查;无网段、NSE、服务版本探测。"""
|
||||
host = unicast_host(host)
|
||||
return await runner(
|
||||
"scan", ["--host", host, "--ports", ",".join(map(str, sorted(set(ports))))], target=host
|
||||
)
|
||||
|
||||
|
||||
@tool()
|
||||
async def scan_firmware(input_file: Text) -> CallToolResult:
|
||||
"""Binwalk 本地固件签名扫描;不解包、不运行提取物,解包失败不能证明 Flash 已加密。"""
|
||||
return await runner(
|
||||
"firmware-scan", ["--input", str(local_file(input_file))], target=input_file
|
||||
)
|
||||
|
||||
|
||||
@tool(write=True)
|
||||
async def capture_serial(
|
||||
port: Identity,
|
||||
baud_rate: Annotated[int, Field(ge=1, le=4000000)],
|
||||
duration_seconds: Seconds = 10,
|
||||
dtr_enable: bool = False,
|
||||
rts_enable: bool = False,
|
||||
) -> CallToolResult:
|
||||
"""明确 COM/波特率的有界采样,不发送串口命令;DTR/RTS 默认关闭,开启可能改变设备状态。"""
|
||||
if not re.fullmatch(r"COM[1-9][0-9]{0,3}", port, re.IGNORECASE):
|
||||
raise InputError("串口必须是明确的 COM 口。")
|
||||
check_budget(duration_seconds + 8)
|
||||
output = new_artifact("serial", ".txt")
|
||||
arguments = [
|
||||
"--port",
|
||||
port.upper(),
|
||||
"--baud",
|
||||
str(baud_rate),
|
||||
"--duration",
|
||||
str(duration_seconds),
|
||||
"--output",
|
||||
str(output),
|
||||
]
|
||||
if dtr_enable:
|
||||
arguments.append("--dtr-enable")
|
||||
if rts_enable:
|
||||
arguments.append("--rts-enable")
|
||||
return await runner(
|
||||
"serial",
|
||||
arguments,
|
||||
target=port.upper(),
|
||||
files=[output, Path(str(output) + ".raw.bin")],
|
||||
write=True,
|
||||
)
|
||||
|
||||
|
||||
@tool()
|
||||
async def device_capabilities() -> CallToolResult:
|
||||
"""离线读取设备适配器已实现的 V1/V2 操作与字段;不接触设备,不代表真机验证。"""
|
||||
return await run_cli(
|
||||
"device_adapter.py", ["capabilities"], operation="device-capabilities", kind="api"
|
||||
)
|
||||
|
||||
|
||||
@tool()
|
||||
async def list_network_interfaces() -> CallToolResult:
|
||||
"""只读枚举已连接的本机 IPv4 网卡,返回可用于发现的 IPv4/PREFIX;不发送设备发现包。"""
|
||||
return await run_cli(
|
||||
"network_inventory.py", [], operation="network-interfaces", kind="computer"
|
||||
)
|
||||
|
||||
|
||||
@tool()
|
||||
async def discover_devices(
|
||||
protocol: Protocol,
|
||||
interfaces: Annotated[list[Identity], Field(min_length=1, max_length=8)],
|
||||
expected_sn: Annotated[str, Field(max_length=256)] = "",
|
||||
expected_model: Annotated[str, Field(max_length=256)] = "",
|
||||
wait_seconds: Annotated[int, Field(ge=1, le=10)] = 3,
|
||||
) -> CallToolResult:
|
||||
"""在本机 IPv4/PREFIX 上只读发现。先 list_network_interfaces;未知设备盘点可分别查 v1/v2。"""
|
||||
check_budget(wait_seconds * len(interfaces) + 5)
|
||||
arguments = ["discover", "--protocol", protocol, "--timeout", str(wait_seconds)]
|
||||
for interface in interfaces:
|
||||
try:
|
||||
value = ipaddress.IPv4Interface(interface)
|
||||
if value.ip.is_multicast or value.ip.is_unspecified or value.network.prefixlen > 30:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
raise InputError("发现网卡须为明确的本机 IPv4/PREFIX,前缀不大于 30。") from None
|
||||
arguments += ["--interface", str(value)]
|
||||
if expected_sn:
|
||||
arguments += ["--expected-sn", expected_sn]
|
||||
if expected_model:
|
||||
arguments += ["--expected-model", expected_model]
|
||||
return await run_cli(
|
||||
"device_adapter.py",
|
||||
arguments,
|
||||
operation="device-discover",
|
||||
target=",".join(interfaces),
|
||||
kind="api",
|
||||
)
|
||||
|
||||
|
||||
@tool()
|
||||
async def identify_device(
|
||||
protocol: Protocol,
|
||||
host: Identity,
|
||||
expected_sn: Identity,
|
||||
timeout_seconds: Annotated[int, Field(ge=1, le=20)] = 5,
|
||||
) -> CallToolResult:
|
||||
"""已知完整 SN 时只读观察实际上报型号;型号未核验前不能升级。"""
|
||||
arguments = identity(protocol, host, expected_sn, None, timeout_seconds)
|
||||
return await run_cli(
|
||||
"device_adapter.py",
|
||||
["identify", *arguments],
|
||||
operation="device-identify",
|
||||
target=unicast_host(host),
|
||||
kind="api",
|
||||
)
|
||||
|
||||
|
||||
@tool()
|
||||
async def device_info(
|
||||
protocol: Protocol,
|
||||
host: Identity,
|
||||
expected_sn: Identity,
|
||||
expected_model: Identity,
|
||||
timeout_seconds: Annotated[int, Field(ge=1, le=20)] = 5,
|
||||
) -> CallToolResult:
|
||||
"""按明确协议只读查询,并精确核验完整 SN 与上报型号。"""
|
||||
arguments = identity(protocol, host, expected_sn, expected_model, timeout_seconds)
|
||||
return await run_cli(
|
||||
"device_adapter.py",
|
||||
["info", *arguments],
|
||||
operation="device-info",
|
||||
target=unicast_host(host),
|
||||
kind="api",
|
||||
)
|
||||
|
||||
|
||||
@tool()
|
||||
async def inspect_firmware(
|
||||
rom_file: Text, expected_sha256: Annotated[str, Field(max_length=64)] = ""
|
||||
) -> CallToolResult:
|
||||
"""离线检查本地 ROM 格式和 SHA-256;不联网。"""
|
||||
arguments = ["offline", "--rom", str(local_file(rom_file))]
|
||||
if expected_sha256:
|
||||
if not re.fullmatch(r"[a-fA-F0-9]{64}", expected_sha256):
|
||||
raise InputError("ROM 摘要必须是完整的 SHA-256。")
|
||||
arguments += ["--rom-sha256", expected_sha256]
|
||||
return await run_cli(
|
||||
"device_adapter.py", arguments, operation="firmware-inspect", target=rom_file, kind="file"
|
||||
)
|
||||
|
||||
|
||||
async def firmware_operation(
|
||||
protocol,
|
||||
host,
|
||||
expected_sn,
|
||||
expected_model,
|
||||
rom_file,
|
||||
rom_sha256,
|
||||
target_version,
|
||||
version_field,
|
||||
execute=False,
|
||||
review_summary="",
|
||||
transfer_timeout_seconds=30,
|
||||
):
|
||||
if not re.fullmatch(r"[a-fA-F0-9]{64}", rom_sha256):
|
||||
raise InputError("固件流程需要完整的已审阅 ROM SHA-256。")
|
||||
arguments = [
|
||||
"upgrade",
|
||||
*identity(protocol, host, expected_sn, expected_model, 5),
|
||||
*version(protocol, target_version, version_field),
|
||||
"--rom",
|
||||
str(local_file(rom_file)),
|
||||
"--rom-sha256",
|
||||
rom_sha256.lower(),
|
||||
]
|
||||
if execute:
|
||||
if os.environ.get("ARTEMIS_DEVICE_TEST_ALLOW_FIRMWARE_WRITE", "false").casefold() != "true":
|
||||
raise InputError(
|
||||
"固件写入默认关闭;当前调用未传包。需在本机插件设置明确允许后,按当前用例授权执行。"
|
||||
)
|
||||
if len(review_summary.strip()) < 20:
|
||||
raise InputError(
|
||||
"执行升级须提供审阅摘要,说明当前授权、目标身份、ROM 兼容性与指定验收字段。"
|
||||
)
|
||||
check_budget(transfer_timeout_seconds + 10)
|
||||
arguments += [
|
||||
"--execute",
|
||||
"--stall-timeout",
|
||||
str(min(15, transfer_timeout_seconds)),
|
||||
"--total-timeout",
|
||||
str(transfer_timeout_seconds),
|
||||
]
|
||||
return await run_cli(
|
||||
"device_adapter.py",
|
||||
arguments,
|
||||
operation="firmware-upgrade" if execute else "firmware-preflight",
|
||||
target=unicast_host(host),
|
||||
kind="api",
|
||||
write=execute,
|
||||
)
|
||||
|
||||
|
||||
@tool()
|
||||
async def firmware_preflight(
|
||||
protocol: Protocol,
|
||||
host: Identity,
|
||||
expected_sn: Identity,
|
||||
expected_model: Identity,
|
||||
rom_file: Text,
|
||||
rom_sha256: Identity,
|
||||
target_version: Identity,
|
||||
version_field: VersionField,
|
||||
) -> CallToolResult:
|
||||
"""联网只读预检身份及本地 ROM,不传包;不以配置软件版本推断设备协议。"""
|
||||
return await firmware_operation(
|
||||
protocol,
|
||||
host,
|
||||
expected_sn,
|
||||
expected_model,
|
||||
rom_file,
|
||||
rom_sha256,
|
||||
target_version,
|
||||
version_field,
|
||||
)
|
||||
|
||||
|
||||
@tool(write=True)
|
||||
async def upgrade_firmware(
|
||||
protocol: Protocol,
|
||||
host: Identity,
|
||||
expected_sn: Identity,
|
||||
expected_model: Identity,
|
||||
rom_file: Text,
|
||||
rom_sha256: Identity,
|
||||
target_version: Identity,
|
||||
version_field: VersionField,
|
||||
execute: bool = False,
|
||||
review_summary: Annotated[str, Field(max_length=4000)] = "",
|
||||
transfer_timeout_seconds: Annotated[int, Field(ge=1, le=3000)] = 30,
|
||||
) -> CallToolResult:
|
||||
"""默认仅预检;本机允许写入+execute=true+审阅摘要才最多传包一次。成功后仍须同 SN 版本回读,不重试。"""
|
||||
return await firmware_operation(
|
||||
protocol,
|
||||
host,
|
||||
expected_sn,
|
||||
expected_model,
|
||||
rom_file,
|
||||
rom_sha256,
|
||||
target_version,
|
||||
version_field,
|
||||
execute,
|
||||
review_summary,
|
||||
transfer_timeout_seconds,
|
||||
)
|
||||
|
||||
|
||||
@tool()
|
||||
async def verify_firmware(
|
||||
protocol: Protocol,
|
||||
host: Identity,
|
||||
expected_sn: Identity,
|
||||
expected_model: Identity,
|
||||
target_version: Identity,
|
||||
version_field: VersionField,
|
||||
timeout_seconds: Annotated[int, Field(ge=1, le=20)] = 5,
|
||||
) -> CallToolResult:
|
||||
"""升级后独立只读回查同 SN/型号及原用例指定固件字段;仅核验该字段,不生成整条用例结论。"""
|
||||
arguments = [
|
||||
"verify-version",
|
||||
*identity(protocol, host, expected_sn, expected_model, timeout_seconds),
|
||||
*version(protocol, target_version, version_field),
|
||||
]
|
||||
return await run_cli(
|
||||
"device_adapter.py",
|
||||
arguments,
|
||||
operation="firmware-verify",
|
||||
target=unicast_host(host),
|
||||
kind="api",
|
||||
)
|
||||
|
||||
|
||||
@tool(write=True)
|
||||
async def import_evidence(
|
||||
input_file: Text, expected_sha256: Annotated[str, Field(max_length=64)] = ""
|
||||
) -> CallToolResult:
|
||||
"""将当前用例明确选择的本机证据复制到任务资料;最多 64 MiB,返回回读 SHA-256,不执行文件。"""
|
||||
path = local_file(input_file)
|
||||
if path.stat().st_size > 64 * 1024 * 1024:
|
||||
raise InputError("单份证据不能超过 64 MiB。")
|
||||
digest = sha256(path)
|
||||
if expected_sha256 and expected_sha256.lower() != digest:
|
||||
raise InputError("输入证据与期望 SHA-256 不匹配;未归档。")
|
||||
attachment = attach_file(path)
|
||||
if attachment["sha256"] != digest:
|
||||
raise InputError("证据在导入期间发生变化;未确认归档完整性。")
|
||||
return receipt(
|
||||
"evidence-import",
|
||||
{"imported_evidence": attachment},
|
||||
target=input_file,
|
||||
kind="file",
|
||||
side_effect="confirmed",
|
||||
files=[artifact_root() / attachment["name"]],
|
||||
)
|
||||
|
||||
|
||||
class EvidenceRef(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
path: Identity
|
||||
sha256: Annotated[str | None, Field(pattern=r"^[a-fA-F0-9]{64}$")] = None
|
||||
|
||||
|
||||
class Assertion(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
id: Identity
|
||||
required: bool = True
|
||||
expected: Text
|
||||
actual: Text
|
||||
verdict: Literal["passed", "failed", "blocked", "inconclusive"]
|
||||
evidence: Annotated[list[Identity | EvidenceRef], Field(max_length=128)] = Field(
|
||||
default_factory=list
|
||||
)
|
||||
|
||||
|
||||
class CaseContext(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
sheet: Identity
|
||||
row: Annotated[int, Field(ge=1, le=1000000)]
|
||||
case_id: Identity
|
||||
|
||||
|
||||
class ReportContext(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
execution_mode: Literal["live", "offline", "simulation"]
|
||||
real_device_test_executed: bool = False
|
||||
case: CaseContext | None = None
|
||||
artemis_session_id: Annotated[str, Field(max_length=256)] = ""
|
||||
notes: Annotated[str, Field(max_length=4000)] = ""
|
||||
manual_verification: Annotated[list[Text], Field(max_length=50)] = Field(default_factory=list)
|
||||
|
||||
|
||||
@tool(write=True)
|
||||
async def bundle_evidence(
|
||||
run_id: Identity,
|
||||
assertions: Annotated[list[Assertion], Field(max_length=128)],
|
||||
context: ReportContext,
|
||||
) -> CallToolResult:
|
||||
"""依据显式观测断言生成独立 Markdown/JSON/ZIP;只引用本任务附件,缺失或哈希不符的 PASS 降为未验证。"""
|
||||
if not re.fullmatch(r"[A-Za-z0-9_.-]{1,100}", run_id):
|
||||
raise InputError("run_id 仅允许 1..100 个字母、数字、下划线、点或连字符。")
|
||||
if context.execution_mode != "live" and context.real_device_test_executed:
|
||||
raise InputError("离线/模拟记录不能声明已执行真机测试。")
|
||||
files = set()
|
||||
normalized = []
|
||||
for assertion in assertions:
|
||||
item = assertion.model_dump(exclude_none=True)
|
||||
for ref in item["evidence"]:
|
||||
name = ref if isinstance(ref, str) else ref["path"]
|
||||
file = task_evidence(name, allow_missing=True)
|
||||
if file.is_file():
|
||||
files.add(file)
|
||||
if isinstance(ref, dict) and ref.get("sha256"):
|
||||
ref["sha256"] = ref["sha256"].lower()
|
||||
normalized.append(item)
|
||||
if sum(path.stat().st_size for path in files) > 48 * 1024 * 1024:
|
||||
raise InputError(
|
||||
"联合包输入证据总计不能超过 48 MiB;请为大文件保留单独附件并按需选取证据。"
|
||||
)
|
||||
# The imported bundler executes in a cancellable worker; staging never enters task exports.
|
||||
with tempfile.TemporaryDirectory(prefix="artemis-device-evidence-") as staging:
|
||||
directory = Path(staging)
|
||||
manifest = directory / "input.json"
|
||||
manifest.write_text(
|
||||
json.dumps(
|
||||
sanitized(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"run_id": run_id,
|
||||
"evidence_root": str(artifact_root()),
|
||||
"context": context.model_dump(exclude_none=True),
|
||||
"assertions": normalized,
|
||||
}
|
||||
),
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
output = directory / "result"
|
||||
return await run_cli(
|
||||
"evidence.py",
|
||||
["bundle", "--manifest", str(manifest), "--out", str(output)],
|
||||
operation="evidence-bundle",
|
||||
kind="file",
|
||||
target=run_id,
|
||||
write=True,
|
||||
files=[output / "report.md", output / "manifest.json", output / "bundle.zip"],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run(transport="stdio")
|
||||
Reference in new issue
Block a user