fix(device-test): contain tool processes and add reviewed preview

This commit is contained in:
chenmingxuan committed 2026-10-30 12:43:36 +08:00
1 parent c98d7527a0
commit 7d31e09cb7
59 files changed
+23722

No files matched your search

@@ -0,0 +1,484 @@
"""Bounded script execution and task-owned evidence for the device-test plugin."""
from __future__ import annotations
import asyncio
from contextlib import suppress
import hashlib
import ipaddress
import json
import mimetypes
import os
from pathlib import Path
import re
import shutil
import signal
import subprocess
import sys
import time
from typing import Any
from urllib.parse import parse_qsl, unquote, urlsplit
import uuid
from mcp.types import CallToolResult, TextContent
PACKAGE = Path(__file__).resolve().parent
SCRIPTS = PACKAGE / "skill" / "scripts"
MAX_FILE_BYTES = 64 * 1024 * 1024
MAX_OUTPUT_BYTES = 4 * 1024 * 1024
SECRET_KEY = re.compile(
r"(?:^|_)(?:password|passwd|pwd|secret|token|access_token|api_key|apikey|authorization)$",
re.IGNORECASE,
)
class InputError(ValueError):
"""Only constant, public messages belong in this exception."""
def local_file(value: str) -> Path:
if (
not value
or "://" in value
or value.startswith(("\\\\", "//"))
or re.search(r"[\x00-\x1f]", value)
or not Path(value).is_absolute()
):
raise InputError("需要本机文件的绝对路径;不接受 URL 或网络共享。")
path = Path(value).resolve()
if not path.is_file():
raise InputError("本机输入文件不存在。")
return path
def unicast_host(value: str) -> str:
try:
if "%" in value:
raise ValueError
address = ipaddress.ip_address(value)
effective = address.ipv4_mapped if isinstance(address, ipaddress.IPv6Address) else None
effective = effective or address
if (
effective.is_unspecified
or effective.is_multicast
or str(effective) == "255.255.255.255"
):
raise ValueError
except ValueError:
raise InputError("需要一个明确的单播 IP;不接受主机名、网段或广播地址。") from None
return str(address)
def operation_budget() -> int:
value = os.environ.get("ARTEMIS_DEVICE_TEST_TIMEOUT", "45")
if not re.fullmatch(r"[0-9]+", value) or not 10 <= int(value) <= 3300:
raise InputError("插件操作时限必须是 10..3300 秒的整数。")
return int(value)
def check_budget(required: float) -> None:
if required > operation_budget():
raise InputError("所需时限超过插件操作预算;请缩短操作或同时调整宿主与插件时限。")
def artifact_root() -> Path:
value = os.environ.get("ARTEMIS_ARTIFACTS_DIR", "")
if not value:
raise InputError("缺少 Artemis 注入的任务资料目录;请通过 Artemis 任务调用。")
path = Path(value).absolute()
if path.resolve() != path or path.is_symlink():
raise InputError("任务资料目录不能通过链接指向其他位置。")
path.mkdir(parents=True, exist_ok=True)
return path
def new_artifact(prefix: str, suffix: str) -> Path:
return artifact_root() / f"{prefix}-{uuid.uuid4().hex}{suffix}"
def task_evidence(name: str, *, allow_missing: bool = False) -> Path:
if (
not name
or len(name) > 180
or name != Path(name).name
or any(char in name for char in '/\\:*?"<>|')
or name in {".", ".."}
or name.rstrip(" .") != name
or re.search(r"[\x00-\x1f]", name)
):
raise InputError("证据必须引用当前任务中的普通附件文件名。")
path = artifact_root() / name
if path.is_symlink() or path.resolve().parent != artifact_root():
raise InputError("证据文件不存在或超出当前任务资料目录。")
if not path.is_file() and not (allow_missing and not path.exists()):
raise InputError("证据文件不存在或超出当前任务资料目录。")
if path.is_file() and path.stat().st_size > MAX_FILE_BYTES:
raise InputError("单份证据不能超过 64 MiB。")
return path
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for block in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def sanitized(value: Any, secrets: tuple[str, ...] = ()) -> Any:
if isinstance(value, dict):
return {
key: "[REDACTED]" if SECRET_KEY.search(str(key)) else sanitized(item, secrets)
for key, item in value.items()
}
if isinstance(value, list):
return [sanitized(item, secrets) for item in value]
if not isinstance(value, str):
return value
for secret in sorted(filter(None, secrets), key=len, reverse=True):
if value == secret:
return "[REDACTED]"
if len(secret) >= 4:
value = value.replace(secret, "[REDACTED]")
value = re.sub(r"(?i)rtsps?://[^\s\"<>]+", "[RTSP_URL_REDACTED]", value)
return re.sub(
r"(?i)(password|passwd|pwd|token|secret|api_key|authorization)\s*[:=]\s*[^\s,;]+",
r"\1=[REDACTED]",
value,
)
def rtsp_environment(name: str) -> tuple[dict[str, str], tuple[str, ...]]:
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]{0,99}", name):
raise InputError("RTSP 输入必须是环境变量名称。")
value = os.environ.get(name, "")
config = os.environ.get("ARTEMIS_DEVICE_TEST_ENV_FILE", "")
if not value and config:
from dotenv import dotenv_values
value = dotenv_values(local_file(config), interpolate=False).get(name) or ""
try:
parsed = urlsplit(value)
if (
parsed.scheme not in {"rtsp", "rtsps"}
or not parsed.hostname
or parsed.port == 0
or parsed.fragment
or re.search(r"[\x00-\x20\x7f]", value)
):
raise ValueError
except ValueError:
raise InputError("指定的 RTSP 环境变量未配置或格式无效;请在本机凭据环境中配置。") from None
secrets = tuple(
filter(
None,
(
value,
parsed.username,
parsed.password,
unquote(parsed.password or ""),
*(item for _, item in parse_qsl(parsed.query)),
),
)
)
return {name: value}, secrets
def runner_arguments(command: str, arguments: list[str]) -> list[str]:
budget = operation_budget()
result = ["--timeout", str(budget)]
profile = os.environ.get("ARTEMIS_DEVICE_TEST_PROFILE", "")
tool_root = os.environ.get("ARTEMIS_DEVICE_TEST_TOOL_ROOT", "")
if profile:
result += ["--profile", str(local_file(profile))]
if tool_root:
root = Path(tool_root)
if not root.is_absolute() or not root.is_dir() or tool_root.startswith(("\\\\", "//")):
raise InputError("工具目录必须是本机已解压目录的绝对路径。")
result += ["--tool-root", str(root.resolve())]
# Leave time for the runner to terminate its own child before the outer deadline.
result[1] = str(max(1, budget - 3))
return [*result, command, *arguments]
def failure(
message: str,
code: str = "invalid_input",
*,
execution: str = "not_started",
side_effect: str = "none",
) -> CallToolResult:
return CallToolResult(
isError=True,
content=[TextContent(type="text", text=message)],
structuredContent={
"test_verdict": "not_evaluated",
"artemis": {
"version": 1,
"execution": execution,
"side_effect": side_effect,
"retryable": False,
"error_code": code,
"attachments": [],
},
},
)
def attach_file(path: Path) -> dict:
if path.is_symlink() or not path.is_file() or path.stat().st_size > MAX_FILE_BYTES:
raise InputError("附件缺失、使用链接或超过 64 MiB;不能声称已归档。")
root = artifact_root()
if path.parent.resolve() != root:
destination = new_artifact("evidence", path.suffix[:16])
temporary = destination.with_name(destination.name + ".tmp")
with path.open("rb") as source, temporary.open("xb") as target:
shutil.copyfileobj(source, target)
if sha256(path) != sha256(temporary):
raise InputError("证据复制后的 SHA-256 不匹配。")
temporary.rename(destination)
path = destination
return {
"name": path.name,
"media_type": mimetypes.guess_type(path.name)[0] or "application/octet-stream",
"sha256": sha256(path),
}
def receipt(
operation: str,
data: dict,
*,
target: str = "local-environment",
kind: str = "terminal",
execution: str = "completed",
side_effect: str = "none",
error_code: str = "",
files: list[Path] | None = None,
) -> CallToolResult:
data = sanitized(data)
attachments = []
issues = []
for path in files or []:
try:
attachments.append(attach_file(path))
except (InputError, OSError):
issues.append("某份输出未能回读归档;可能缺失、使用链接或超过 64 MiB。")
if issues:
data["attachment_issues"] = issues
error_code = error_code or "attachment_unavailable"
data["test_verdict"] = "not_evaluated"
log = new_artifact(operation, ".json")
with log.open("x", encoding="utf-8") as stream:
json.dump(data, stream, ensure_ascii=False, indent=2)
attachments.append(attach_file(log))
target_id = (
target if len(target) <= 256 else "sha256:" + hashlib.sha256(target.encode()).hexdigest()
)
data["artemis"] = {
"version": 1,
"execution": execution,
"side_effect": side_effect,
"retryable": False,
"source": {
"kind": kind,
"target_id": target_id,
"label": operation,
"captured_at": time.time(),
},
"attachments": attachments,
}
if error_code:
data["artemis"]["error_code"] = error_code
text = json.dumps(data, ensure_ascii=False)
if len(text) > 16000:
text = text[:15000] + "\n结果较长;完整结构与任务 JSON 附件保留原始字段。"
return CallToolResult(
isError=bool(error_code),
content=[TextContent(type="text", text=text)],
structuredContent=data,
)
async def stop_process(process: asyncio.subprocess.Process) -> None:
"""Reap the worker and its fixed tool descendants before returning from cancellation."""
if os.name == "nt":
command = str(
Path(os.environ.get("SystemRoot", r"C:\Windows")) / "System32" / "taskkill.exe"
)
killer = await asyncio.create_subprocess_exec(
command,
"/PID",
str(process.pid),
"/T",
"/F",
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
creationflags=subprocess.CREATE_NO_WINDOW,
)
try:
await asyncio.wait_for(killer.wait(), 5)
except TimeoutError:
killer.kill()
await killer.wait()
else:
with suppress(ProcessLookupError):
os.killpg(process.pid, signal.SIGKILL)
if process.returncode is None:
with suppress(ProcessLookupError):
process.kill()
await process.wait()
async def read_output(process: asyncio.subprocess.Process) -> bytes:
chunks = []
size = 0
while chunk := await process.stdout.read(65536):
size += len(chunk)
if size > MAX_OUTPUT_BYTES:
raise InputError("工具输出超过 4 MiB,已停止;部分输出不作为有效观测。")
chunks.append(chunk)
await process.wait()
return b"".join(chunks)
async def run_cli(
script: str,
arguments: list[str],
*,
operation: str,
target: str = "local-environment",
kind: str = "terminal",
write: bool = False,
files: list[Path] | None = None,
env: dict[str, str] | None = None,
secrets: tuple[str, ...] = (),
timeout: float | None = None,
) -> CallToolResult:
artifact_root()
budget = timeout or operation_budget()
native_scripts = {"network_inventory.py", "dependency_runner.py"}
if script not in {"tool_runner.py", "device_adapter.py", "evidence.py", *native_scripts}:
raise InputError("不支持的脚本入口。")
script_path = (PACKAGE if script in native_scripts else SCRIPTS) / script
child_env = os.environ.copy()
for name in ("FFREPORT", "PYTHONPATH", "PYTHONHOME"):
child_env.pop(name, None)
child_env.update(env or {})
options = (
{"creationflags": subprocess.CREATE_NO_WINDOW | subprocess.CREATE_NEW_PROCESS_GROUP}
if os.name == "nt"
else {"start_new_session": True}
)
try:
process = await asyncio.create_subprocess_exec(
sys.executable,
"-I",
"-B",
str(script_path),
*arguments,
cwd=str(PACKAGE),
env=child_env,
stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
**options,
)
except OSError:
return failure("固定脚本无法启动;未执行设备动作。", "worker_launch_failed")
try:
output = await asyncio.wait_for(read_output(process), budget)
except (TimeoutError, InputError) as exc:
await stop_process(process)
return receipt(
operation,
{"execution_status": "timed_out" if isinstance(exc, TimeoutError) else "output_limit"},
target=target,
kind=kind,
execution="partial",
side_effect="possible" if write else "none",
error_code="worker_timeout" if isinstance(exc, TimeoutError) else "output_limit",
files=files,
)
except asyncio.CancelledError:
await asyncio.shield(stop_process(process))
raise
except OSError:
await stop_process(process)
return failure(
"工具输出通道中断;执行结果待核对,不能自动重试。",
"worker_output_interrupted",
execution="unknown",
side_effect="possible" if write else "none",
)
try:
decoded = output.decode("utf-8")
try:
result = json.loads(decoded)
except ValueError:
# V2 discovery emits one JSON event per line, rather than one envelope.
result = {"events": [json.loads(line) for line in decoded.splitlines() if line.strip()]}
if not isinstance(result, dict):
raise ValueError
except (UnicodeError, ValueError):
return receipt(
operation,
{"returncode": process.returncode, "execution_status": "invalid_output_suppressed"},
target=target,
kind=kind,
execution="unknown" if write else "completed",
side_effect="possible" if write else "none",
error_code="invalid_worker_output",
files=files,
)
result = sanitized(result, secrets)
if script == "evidence.py":
result.pop("output", None)
result["output_location"] = "artemis_task_attachments"
status = result.get("execution_status", "")
not_started = status in {
"invalid_arguments",
"tool_missing",
"configuration_error",
"launch_failed",
}
attempted = bool(
result.get("write_executed")
or result.get("write_may_have_started")
or result.get("may_have_started")
)
verified_mismatch = (
script == "device_adapter.py"
and arguments[0] == "verify-version"
and result.get("status") in {"FAIL", "INCOMPLETE"}
)
error = process.returncode != 0 and not verified_mismatch
execution = (
"not_started"
if not_started
else "unknown"
if status in {"cleanup_unconfirmed", "unknown"}
else "partial"
if status in {"timed_out", "output_limit"} or (error and attempted)
else "completed"
)
side_effect = (
"confirmed"
if write and result.get("write_executed")
else "possible"
if write and attempted
else "none"
)
if write and script != "device_adapter.py" and not not_started:
side_effect = "possible" if error else "confirmed"
return receipt(
operation,
{"returncode": process.returncode, "tool_result": result, "stderr_suppressed": True},
target=target,
kind=kind,
execution=execution,
side_effect=side_effect,
error_code=str(result.get("error_code") or "tool_execution_failed") if error else "",
files=files,
)