Files
Artemis-Plugins/plugins/artemis-device-test/server.py
T

646 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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")