feat(plugins): add managed runtime SDK and release 2.4.5

This commit is contained in:
chenmingxuan committed 2026-10-30 14:39:21 +08:00
1 parent 506e3bf1c5
commit 2a3bbaa3fb
45 files changed
+3348 -98

No files matched your search

+57
View File
@@ -0,0 +1,57 @@
"""Private launch barrier for sdk.run_process; stdlib-only under Python -I."""
import json
import os
from pathlib import Path
import signal
import subprocess
import sys
import threading
import time
def watch_parent(expected: int):
while True:
if os.getppid() != expected:
os.killpg(os.getpid(), signal.SIGKILL)
time.sleep(0.1)
def main():
status = Path(sys.argv[1])
if os.name != "nt":
threading.Thread(target=watch_parent, args=(int(sys.argv[2]),), daemon=True).start()
# Until the parent owns our job/session and sends this line, no business
# process can start. Buffering is local; tool stdin uses a separate pipe.
control = sys.stdin.buffer.readline(1024 * 1024 + 1)
if not control.endswith(b"\n") or len(control) > 1024 * 1024:
return
request = json.loads(control)
size = request["input_size"]
if type(size) is not int or not 0 <= size <= 64 * 1024 * 1024:
return
content = sys.stdin.buffer.read(size) if request["has_input"] else None
if content is not None and len(content) != size:
return
try:
result = subprocess.run(
request["argv"],
shell=False,
stdin=None if request["has_input"] else subprocess.DEVNULL,
input=content,
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0,
check=False,
)
response = {"returncode": result.returncode}
except OSError:
response = {"launch_failed": True}
pending = status.with_suffix(".tmp")
pending.write_text(json.dumps(response), encoding="utf-8")
pending.replace(status)
# Remain the session/group leader until the owner reclaims the whole tree.
while True:
time.sleep(60)
if __name__ == "__main__":
main()
+40
View File
@@ -0,0 +1,40 @@
"""Synthetic tool used only by the opt-in SDK fault suite. No device/network I/O."""
import json
import os
from pathlib import Path
import subprocess
import sys
import time
def main():
mode = sys.argv[1]
if mode == "sleep":
time.sleep(60)
elif mode == "tree":
child = subprocess.Popen(
[sys.executable, "-I", "-B", __file__, "sleep"],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0,
)
Path(sys.argv[2]).write_text(json.dumps({"pid": child.pid}), encoding="utf-8")
if sys.argv[3] == "linger":
time.sleep(60)
elif mode == "echo":
data = sys.stdin.buffer.read()
sys.stdout.buffer.write(data)
sys.stderr.buffer.write(b"synthetic-stderr")
elif mode == "overflow":
while True:
os.write(1, b"x" * 65536)
elif mode == "failure":
raise SystemExit(7)
else:
raise SystemExit(2)
if __name__ == "__main__":
main()
+266
View File
@@ -0,0 +1,266 @@
"""Evidence-bound market materials. Declarations still need source/rights review."""
from __future__ import annotations
from collections.abc import Iterator
from contextlib import contextmanager
import hashlib
import json
from pathlib import Path, PurePosixPath
import stat
import tempfile
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
from artemis.config.settings import settings
from artemis.plugins.installer import _extract
from artemis.plugins.models import PluginManifest
REPORT_NAME = "MARKET.json"
CHECKS = (
"installation",
"tool_contract",
"failure_partial",
"timeout_cancel",
"process_tree",
"file_binding",
"resource_conflicts",
"evidence_export",
"history_uninstall",
"secret_redaction",
"real_task",
"source_review",
)
FORBIDDEN_DIRECTORIES = {".git", ".ruff_cache", ".pytest_cache", "__pycache__", ".venv"}
class SubmissionModel(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True)
class Component(SubmissionModel):
name: str = Field(min_length=1)
paths: list[str] = Field(min_length=1)
license: str = Field(min_length=1)
permission: Literal["permitted", "local_only", "unknown"]
license_file: str
source_file: str
class CheckRecord(SubmissionModel):
status: Literal["passed", "failed", "unverified", "not_applicable"]
evidence_file: str = ""
evidence_sha256: str = ""
notes: str = ""
class Traits(SubmissionModel):
uses_subprocesses: bool | None
file_inputs_for_writes: bool | None
class MarketSubmission(SubmissionModel):
schema_version: Literal[1]
plugin_id: str
plugin_version: str
content_sha256: str = Field(pattern=r"^[a-f0-9]{64}$")
traits: Traits
components: list[Component] = Field(min_length=1)
checks: dict[str, CheckRecord]
@contextmanager
def package_directory(path: Path) -> Iterator[tuple[Path, str | None]]:
"""Inspect a directory or safely unpack a ZIP without installing or hooks."""
path = path.resolve(strict=True)
if path.is_dir():
yield path, None
return
if not path.is_file():
raise ValueError("检查输入必须是普通插件目录或 ZIP 文件")
with path.open("rb") as stream:
data = stream.read(settings.ARTEMIS_PLUGIN_DOWNLOAD_MAX_BYTES + 1)
if len(data) > settings.ARTEMIS_PLUGIN_DOWNLOAD_MAX_BYTES:
raise ValueError("插件 ZIP 超过下载大小限制")
with tempfile.TemporaryDirectory(prefix="artemis-plugin-check-") as temporary:
directory = Path(temporary)
_extract(data, directory)
archive_sha256 = hashlib.sha256(data).hexdigest()
yield directory, archive_sha256
# Re-read only to reject changes; never relabel the inspected snapshot.
current_digest = hashlib.sha256()
current_size = 0
with path.open("rb") as stream:
while chunk := stream.read(1024 * 1024):
current_size += len(chunk)
if current_size > settings.ARTEMIS_PLUGIN_DOWNLOAD_MAX_BYTES:
raise ValueError("检查期间 ZIP 发生变化或超过大小限制")
current_digest.update(chunk)
if current_digest.hexdigest() != archive_sha256:
raise ValueError("检查期间 ZIP 发生变化,检查记录失效")
def _relative(value: str, *, root_allowed: bool = False) -> str:
if root_allowed and value == ".":
return value
path = PurePosixPath(value)
if (
not value
or "\\" in value
or ":" in value
or path.is_absolute()
or any(p in {".", ".."} for p in value.split("/"))
or any(ord(c) < 32 for c in value)
or str(path) != value
):
raise ValueError("市场材料必须使用包内普通相对路径")
return value
def package_files(directory: Path) -> dict[str, str]:
"""Hash every distributable file, excluding only the self-referential report."""
directory = directory.resolve(strict=True)
files = {}
seen = set()
size = 0
for path in sorted(directory.rglob("*")):
relative = path.relative_to(directory)
name = relative.as_posix()
if path.is_symlink() or path.is_junction() or not path.resolve().is_relative_to(directory):
raise ValueError("市场包不能包含链接")
if name.casefold() in seen:
raise ValueError("市场包存在大小写冲突的路径")
seen.add(name.casefold())
if len(seen) > 20000:
raise ValueError("市场包超过文件数量限制")
if any(p.casefold() in FORBIDDEN_DIRECTORIES for p in relative.parts):
raise ValueError(f"市场包包含缓存、依赖环境或版本库目录:{name}")
if path.is_dir():
continue
info = path.stat()
if not stat.S_ISREG(info.st_mode):
raise ValueError("市场包只能包含普通文件")
if name == REPORT_NAME:
continue
lower = path.name.casefold()
if (
lower.endswith((".pyc", ".pyo"))
or lower == ".env"
or (
lower.startswith(".env.")
and not lower.endswith((".example", ".sample", ".template"))
)
):
raise ValueError(f"市场包不能包含缓存或个人环境配置:{name}")
_relative(name)
digest = hashlib.sha256()
read = 0
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
read += len(chunk)
size += len(chunk)
if size > settings.ARTEMIS_PLUGIN_EXTRACT_MAX_BYTES:
raise ValueError("市场包超过展开大小或文件数量限制")
digest.update(chunk)
if read != info.st_size:
raise ValueError("文件校验期间大小发生变化")
files[name] = digest.hexdigest()
return files
def content_fingerprint(files: dict[str, str]) -> str:
encoded = json.dumps(files, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
def draft_submission(manifest: PluginManifest, files: dict[str, str]) -> dict:
"""Drafts never fabricate passed results or distribution permission."""
return {
"schema_version": 1,
"plugin_id": manifest.id,
"plugin_version": manifest.version,
"content_sha256": content_fingerprint(files),
"traits": {"uses_subprocesses": None, "file_inputs_for_writes": None},
"components": [
{
"name": "请拆分原始代码、适配层与第三方资料",
"paths": ["."],
"license": manifest.license or "待补充",
"permission": "unknown",
"license_file": "LICENSE",
"source_file": "SOURCE.md",
}
],
"checks": {
name: {"status": "unverified", "evidence_file": "", "evidence_sha256": "", "notes": ""}
for name in CHECKS
},
}
def _document(directory: Path, files: dict[str, str], value: str) -> str:
name = _relative(value)
if name not in files or not 0 < (directory / name).stat().st_size <= 1024 * 1024:
raise ValueError("许可、来源与验证记录必须是非空包内文档,且不超过 1 MiB")
return name
def validate_submission(
directory: Path, manifest: PluginManifest, files: dict[str, str]
) -> MarketSubmission:
path = directory / REPORT_NAME
if not path.is_file() or path.stat().st_size > 1024 * 1024:
raise ValueError("市场检查需要包内 MARKET.json;先生成草稿并补充真实记录")
report = MarketSubmission.model_validate_json(path.read_text("utf-8"))
if (report.plugin_id, report.plugin_version) != (manifest.id, manifest.version):
raise ValueError("市场材料的插件 ID/版本与清单不一致")
if report.content_sha256 != content_fingerprint(files):
raise ValueError("包内容已变化,旧的市场验证记录失效")
if not manifest.license.strip():
raise ValueError("市场清单必须声明许可证或具体的 LicenseRef")
if None in (report.traits.uses_subprocesses, report.traits.file_inputs_for_writes):
raise ValueError("必须明确是否使用子进程、是否将文件用于写操作")
covered = set()
for component in report.components:
if component.permission != "permitted" or component.license.strip().casefold() in {
"",
"unknown",
"todo",
"待补充",
}:
raise ValueError(f"组件未明确允许市场分发或未提供许可证:{component.name}")
_document(directory, files, component.license_file)
_document(directory, files, component.source_file)
for scope in component.paths:
scope = _relative(scope, root_allowed=True)
selected = {
name
for name in files
if scope == "." or name == scope or name.startswith(scope + "/")
}
if not selected:
raise ValueError("来源组件声明了不存在的文件范围")
covered.update(selected)
if set(files) - covered:
raise ValueError("市场包有文件未记录来源与分发许可")
if set(report.checks) != set(CHECKS):
raise ValueError("市场验证项目缺失或包含未知项目")
optional = {
"tool_contract": manifest.server is None,
"timeout_cancel": manifest.server is None,
"process_tree": not report.traits.uses_subprocesses,
"file_binding": not report.traits.file_inputs_for_writes,
"resource_conflicts": not manifest.resources,
}
for name, record in report.checks.items():
if not record.notes.strip():
raise ValueError(f"验证项目必须说明实际范围或不适用原因:{name}")
if record.status == "not_applicable" and optional.get(name, False):
continue
if record.status != "passed":
raise ValueError(f"市场验证项目未通过:{name}")
evidence = _document(directory, files, record.evidence_file)
if files[evidence] != record.evidence_sha256:
raise ValueError(f"验证记录 SHA256 不一致:{name}")
return report
+39 -2
View File
@@ -4,8 +4,8 @@ import ctypes
from ctypes import wintypes
class CaptureProcessJob:
"""A non-inheritable job handle: Windows kills captures when its owner exits."""
class ProcessJob:
"""A non-inheritable job handle that owns a Windows process tree."""
def __init__(self):
class BasicLimits(ctypes.Structure):
@@ -54,6 +54,12 @@ class CaptureProcessJob:
),
("OpenProcess", [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD], wintypes.HANDLE),
("AssignProcessToJobObject", [wintypes.HANDLE, wintypes.HANDLE], wintypes.BOOL),
("TerminateJobObject", [wintypes.HANDLE, wintypes.UINT], wintypes.BOOL),
(
"QueryInformationJobObject",
[wintypes.HANDLE, ctypes.c_int, wintypes.LPVOID, wintypes.DWORD, wintypes.LPVOID],
wintypes.BOOL,
),
("CloseHandle", [wintypes.HANDLE], wintypes.BOOL),
):
function = getattr(self._api, name)
@@ -84,3 +90,34 @@ class CaptureProcessJob:
if self._handle:
self._api.CloseHandle(self._handle)
self._handle = None
def terminate(self):
if self._handle and not self._api.TerminateJobObject(self._handle, 1):
raise ctypes.WinError(ctypes.get_last_error())
def active_processes(self) -> int:
if not self._handle:
return 0
class Accounting(ctypes.Structure):
_fields_ = [
("user_time", ctypes.c_int64),
("kernel_time", ctypes.c_int64),
("period_user_time", ctypes.c_int64),
("period_kernel_time", ctypes.c_int64),
("page_faults", wintypes.DWORD),
("total", wintypes.DWORD),
("active", wintypes.DWORD),
("terminated", wintypes.DWORD),
]
accounting = Accounting()
if not self._api.QueryInformationJobObject(
self._handle, 1, ctypes.byref(accounting), ctypes.sizeof(accounting), None
):
raise ctypes.WinError(ctypes.get_last_error())
return accounting.active
# Keep the recording implementation and its callers on the same ownership primitive.
CaptureProcessJob = ProcessJob
+347
View File
@@ -0,0 +1,347 @@
"""Plugin author helpers; no scheduler, device actions or implicit retries.
Use snapshot_file before approving a write and consume its immutable bytes, not the
original pathname. run_process owns the tool and its descendants on every exit.
"""
from __future__ import annotations
import asyncio
from collections.abc import Iterator, Mapping, Sequence
from dataclasses import dataclass, field
import hashlib
import json
import math
import os
from pathlib import Path
import re
import signal
import stat
import subprocess
import sys
import tempfile
from artemis.plugins.recording_process import ProcessJob
MAX_INPUT_BYTES = 64 * 1024 * 1024
DEFAULT_OUTPUT_BYTES = 4 * 1024 * 1024
class FileVerificationError(ValueError):
"""The input cannot be bound to its explicitly approved digest."""
@dataclass(frozen=True)
class VerifiedFile:
"""Bounded, immutable input. No original path is exposed for reopening."""
data: bytes = field(repr=False)
sha256: str = field(init=False)
def __post_init__(self):
if not isinstance(self.data, bytes):
raise TypeError("快照内容必须是不可变 bytes")
object.__setattr__(self, "sha256", hashlib.sha256(self.data).hexdigest())
@property
def size(self) -> int:
return len(self.data)
def chunks(self, size: int = 256 * 1024) -> Iterator[memoryview]:
if type(size) is not int or size <= 0:
raise ValueError("分块大小必须是正整数")
content = memoryview(self.data)
for offset in range(0, len(content), size):
yield content[offset : offset + size]
def snapshot_file(
path: Path | str, expected_sha256: str, *, max_bytes: int = MAX_INPUT_BYTES
) -> VerifiedFile:
"""Copy once, then verify the exact bytes that will be consumed by the caller.
The source must already be authorized by the plugin. This is not a path or
permission sandbox. Memory use is bounded by max_bytes plus one read byte.
"""
if type(max_bytes) is not int or max_bytes <= 0:
raise ValueError("文件大小上限必须是正整数")
if not isinstance(expected_sha256, str) or not re.fullmatch(
r"[a-fA-F0-9]{64}", expected_sha256
):
raise FileVerificationError("必须明确提供已批准的 SHA256")
source = Path(path)
if source.is_symlink() or not stat.S_ISREG(source.lstat().st_mode):
raise FileVerificationError("输入必须是普通文件,不能是链接或设备")
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
descriptor = os.open(source, flags)
with os.fdopen(descriptor, "rb") as handle:
info = os.fstat(handle.fileno())
if not stat.S_ISREG(info.st_mode) or info.st_size > max_bytes:
raise FileVerificationError("输入不是普通文件或超过大小上限")
data = handle.read(max_bytes + 1)
if len(data) > max_bytes:
raise FileVerificationError("输入超过大小上限")
snapshot = VerifiedFile(data)
if snapshot.sha256 != expected_sha256.lower():
raise FileVerificationError("输入 SHA256 与批准值不一致;不得开始写操作")
return snapshot
class ManagedProcessError(RuntimeError):
"""Stable failure metadata. Do not infer a business verdict or retry a write."""
def __init__(self, code: str, *, may_have_started: bool = True):
super().__init__(code)
self.error_code = code
self.may_have_started = may_have_started
class ProcessTimedOut(ManagedProcessError, TimeoutError):
pass
@dataclass(frozen=True)
class ProcessResult:
returncode: int
stdout: bytes = field(repr=False)
stderr: bytes = field(repr=False)
async def _cleanup(process: asyncio.subprocess.Process, job: ProcessJob | None) -> None:
error = None
try:
if job is not None:
job.terminate()
async with asyncio.timeout(5):
while job.active_processes():
await asyncio.sleep(0.02)
elif os.name != "nt":
# The bootstrap stays alive until cleanup, pinning the owned PGID.
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
except (OSError, TimeoutError) as exc:
error = exc
finally:
if job is not None:
job.close()
if process.returncode is None:
try:
process.kill()
except ProcessLookupError:
pass
# Process.wait also waits for pipe EOF. A paused, full output reader must be
# drained separately; it cannot serve as evidence that the OS process lives.
try:
async with asyncio.timeout(5):
while process.returncode is None:
await asyncio.sleep(0.02)
except TimeoutError as exc:
error = exc
if error is not None:
raise ManagedProcessError("process_cleanup_unconfirmed") from error
async def _finish_cleanup(task: asyncio.Task[None]) -> None:
cancelled = False
while not task.done():
try:
await asyncio.shield(task)
except asyncio.CancelledError:
cancelled = True
task.result()
if cancelled:
raise asyncio.CancelledError
async def _read_output(stream: asyncio.StreamReader, consumed: list[int], limit: int) -> bytes:
chunks = []
while chunk := await stream.read(65536):
consumed[0] += len(chunk)
if consumed[0] > limit:
raise ManagedProcessError("process_output_limit")
chunks.append(chunk)
return b"".join(chunks)
async def _drain(stream: asyncio.StreamReader | None) -> None:
if stream is not None:
while await stream.read(65536):
pass
async def _wait_result(process: asyncio.subprocess.Process, path: Path) -> int:
while not path.exists():
if process.returncode is not None:
raise ManagedProcessError("process_worker_interrupted")
await asyncio.sleep(0.02)
if path.stat().st_size > 4096:
raise ManagedProcessError("process_invalid_status")
try:
result = json.loads(path.read_text("utf-8"))
if not isinstance(result, dict):
raise ValueError
except (OSError, ValueError) as exc:
raise ManagedProcessError("process_invalid_status") from exc
if result.get("launch_failed") is True:
raise ManagedProcessError("process_launch_failed", may_have_started=False)
if type(result.get("returncode")) is not int:
raise ManagedProcessError("process_invalid_status")
return result["returncode"]
async def run_process(
argv: Sequence[str],
*,
cwd: Path | str,
timeout: float,
env: Mapping[str, str] | None = None,
input_data: bytes | None = None,
max_output_bytes: int = DEFAULT_OUTPUT_BYTES,
) -> ProcessResult:
"""Run one fixed argv without a shell; reclaim descendants even on normal exit.
Windows assigns a waiting bootstrap to a Job Object *before* it can start the
tool. POSIX uses a dedicated session. Cancellation completes cleanup before
propagating. Inputs are at most 64 MiB; stdout+stderr share a bounded budget.
Return codes/output are tool results, never a test verdict. No retries occur.
"""
if (
not isinstance(argv, Sequence)
or isinstance(argv, (str, bytes))
or not argv
or any(not isinstance(arg, str) or "\0" in arg for arg in argv)
):
raise ValueError("必须提供固定的 argv 字符串列表,不能使用 shell 命令文本")
if not argv[0] or isinstance(timeout, bool) or not math.isfinite(timeout) or timeout <= 0:
raise ValueError("启动程序和有限正数超时是必填项")
if type(max_output_bytes) is not int or max_output_bytes <= 0:
raise ValueError("输出上限必须是正整数")
if input_data is not None and (
not isinstance(input_data, bytes) or len(input_data) > MAX_INPUT_BYTES
):
raise ValueError("标准输入必须是至多 64 MiB 的 bytes")
directory = Path(cwd).resolve(strict=True)
if not directory.is_dir():
raise ValueError("工作目录必须是已存在的目录")
control = (
json.dumps(
{
"argv": list(argv),
"has_input": input_data is not None,
"input_size": len(input_data or b""),
}
).encode()
+ b"\n"
)
if len(control) > 1024 * 1024:
raise ValueError("启动参数超过大小上限")
job = None
process: asyncio.subprocess.Process | None = None
cleanup: asyncio.Task[None] | None = None
tasks: list[asyncio.Task] = []
async def reclaim():
nonlocal cleanup
if process is None:
if job is not None:
job.close()
return
if cleanup is None:
cleanup = asyncio.create_task(_cleanup(process, job))
await _finish_cleanup(cleanup)
with tempfile.TemporaryDirectory(prefix="artemis-plugin-process-") as temporary:
status = Path(temporary) / "status.json"
try:
job = ProcessJob() if os.name == "nt" else None
except OSError as exc:
raise ManagedProcessError("process_ownership_failed", may_have_started=False) from exc
launch = asyncio.create_task(
asyncio.create_subprocess_exec(
sys.executable,
"-I",
"-B",
str(Path(__file__).with_name("_process_worker.py")),
str(status),
str(os.getpid()),
cwd=str(directory),
env=dict(env) if env is not None else None,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0,
start_new_session=os.name != "nt",
)
)
try:
cancelled = False
while process is None:
try:
process = await asyncio.shield(launch)
except asyncio.CancelledError:
cancelled = True
except OSError as exc:
raise ManagedProcessError(
"process_launch_failed", may_have_started=False
) from exc
if cancelled:
raise asyncio.CancelledError
if job is not None:
try:
job.assign(process.pid)
except OSError as exc:
raise ManagedProcessError(
"process_ownership_failed", may_have_started=False
) from exc
stdin, stdout, stderr = process.stdin, process.stdout, process.stderr
assert stdin is not None and stdout is not None and stderr is not None
consumed = [0]
async def feed():
try:
stdin.write(control)
await stdin.drain()
if input_data:
stdin.write(input_data)
await stdin.drain()
except (BrokenPipeError, ConnectionResetError):
pass
finally:
stdin.close()
async def wait_tool():
code = await _wait_result(process, status)
await reclaim()
return code
tasks = [
asyncio.create_task(feed()),
asyncio.create_task(_read_output(stdout, consumed, max_output_bytes)),
asyncio.create_task(_read_output(stderr, consumed, max_output_bytes)),
asyncio.create_task(wait_tool()),
]
try:
_, stdout, stderr, code = await asyncio.wait_for(asyncio.gather(*tasks), timeout)
except TimeoutError as exc:
raise ProcessTimedOut("process_timeout") from exc
return ProcessResult(code, stdout, stderr)
finally:
for task in tasks:
task.cancel()
async def finalize():
try:
await reclaim()
finally:
await asyncio.gather(*tasks, return_exceptions=True)
if process is not None:
try:
async with asyncio.timeout(5):
await asyncio.gather(_drain(process.stdout), _drain(process.stderr))
await process.wait()
except TimeoutError as exc:
raise ManagedProcessError("process_cleanup_unconfirmed") from exc
await _finish_cleanup(asyncio.create_task(finalize()))
+212
View File
@@ -0,0 +1,212 @@
"""Run opt-in, offline SDK fault regressions in temporary directories only."""
import argparse
import asyncio
from contextlib import suppress
import hashlib
import json
from pathlib import Path
import sys
import tempfile
import time
import psutil
from artemis.plugins.evidence import decode_receipt, persist_attachments
from artemis.plugins.models import PluginCallResult
from artemis.plugins.sdk import (
FileVerificationError,
ManagedProcessError,
ProcessTimedOut,
run_process,
snapshot_file,
)
PROBE = Path(__file__).with_name("_sdk_probe.py").resolve()
def _require(condition, message):
if not condition:
raise AssertionError(message)
def _owned_process(marker: Path, started: float):
if not marker.exists():
return None
pid = json.loads(marker.read_text("utf-8"))["pid"]
try:
process = psutil.Process(pid)
if process.status() == psutil.STATUS_ZOMBIE:
return None
_require(process.create_time() >= started - 1, "测试 PID 的启动时间不匹配")
_require(str(PROBE) in process.cmdline(), "测试 PID 的脚本身份不匹配")
return process
except psutil.NoSuchProcess:
return None
async def _tree_case(root: Path, mode: str):
marker = root / f"{mode}.json"
started = time.time()
task = asyncio.create_task(
run_process(
[
sys.executable,
"-I",
"-B",
str(PROBE),
"tree",
str(marker),
"exit" if mode == "normal" else "linger",
],
cwd=root,
timeout=2 if mode == "timeout" else 10,
)
)
try:
if mode == "cancel":
async with asyncio.timeout(5):
while not marker.exists() and not task.done():
await asyncio.sleep(0.02)
_require(marker.exists(), "取消探针没有启动")
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
else:
raise AssertionError("取消没有传播")
elif mode == "timeout":
try:
await task
except ProcessTimedOut:
pass
else:
raise AssertionError("超时没有生效")
else:
result = await task
_require(result.returncode == 0, "模拟工具异常退出")
_require(marker.exists(), "后代进程探针没有启动")
async with asyncio.timeout(3):
while _owned_process(marker, started) is not None:
await asyncio.sleep(0.02)
finally:
if not task.done():
task.cancel()
with suppress(asyncio.CancelledError, ManagedProcessError):
await task
# Failing implementations must not leave the fault suite's own sleeper alive.
process = _owned_process(marker, started)
if process is not None:
process.kill()
process.wait(timeout=5)
async def run_checks() -> dict:
checks = []
with tempfile.TemporaryDirectory(prefix="artemis-sdk-faults-") as temporary:
root = Path(temporary)
source = root / "synthetic.rom"
approved = b"approved firmware bytes\x00\xff"
source.write_bytes(approved)
digest = hashlib.sha256(approved).hexdigest()
snapshot = snapshot_file(source, digest)
for replacement in (b"X" * len(approved), b"", b"Y" * (len(approved) + 10)):
source.write_bytes(replacement)
_require(b"".join(snapshot.chunks(3)) == approved, "快照受原文件修改影响")
try:
snapshot_file(source, digest)
except FileVerificationError:
pass
else:
raise AssertionError("摘要不符仍被接收")
_require(all(part.readonly for part in snapshot.chunks()), "分块内容可被修改")
checks.append("file_replacement_truncation_growth_and_digest")
result = await run_process(
[sys.executable, "-I", "-B", str(PROBE), "echo"],
cwd=root,
timeout=5,
input_data=snapshot.data,
)
_require(
result.stdout == approved and result.stderr == b"synthetic-stderr",
"二进制输入输出不一致",
)
checks.append("binary_input_and_separate_output")
failed = await run_process(
[sys.executable, "-I", "-B", str(PROBE), "failure"], cwd=root, timeout=5
)
_require(failed.returncode == 7, "非零退出码被隐藏")
checks.append("nonzero_returncode")
for mode in ("normal", "timeout", "cancel"):
await _tree_case(root, mode)
checks.append(f"descendants_reclaimed_on_{mode}")
try:
await run_process(
[sys.executable, "-I", "-B", str(PROBE), "overflow"],
cwd=root,
timeout=5,
max_output_bytes=1024,
)
except ManagedProcessError as exc:
_require(exc.error_code == "process_output_limit", "输出超限错误类型不正确")
else:
raise AssertionError("输出超限没有停止")
checks.append("output_limit_stops_process")
trace = root / "synthetic-trace"
artifacts = trace / "artifacts/plugins/sdk-faults"
artifacts.mkdir(parents=True)
sample = artifacts / "sample.txt"
sample.write_bytes(b"synthetic evidence")
receipt = {"name": sample.name, "sha256": hashlib.sha256(sample.read_bytes()).hexdigest()}
_require(len(persist_attachments(trace, "sdk-faults", [receipt])) == 1, "附件未被回读")
for invalid in ({**receipt, "sha256": "0" * 64}, {**receipt, "name": "../outside.txt"}):
try:
persist_attachments(trace, "sdk-faults", [invalid])
except ValueError:
pass
else:
raise AssertionError("证据摘要不符或路径穿越未拒绝")
checks.append("evidence_hash_and_path_rejection")
for execution in ("partial", "unknown"):
outcome = PluginCallResult(
status="success",
structured_content={
"artemis": {
"version": 1,
"execution": execution,
"side_effect": "possible",
"retryable": True,
}
},
)
decode_receipt(outcome)
_require(
not outcome.retryable and outcome.requires_observation, "不确定写操作被允许重试"
)
checks.append("partial_unknown_never_authorize_retries")
return {
"status": "passed",
"scope": "sdk_faults_only",
"python": sys.version.split()[0],
"platform": sys.platform,
"checks": checks,
"real_device_actions": False,
}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--json-output", type=Path)
args = parser.parse_args()
result = asyncio.run(run_checks())
if args.json_output:
args.json_output.write_text(
json.dumps(result, ensure_ascii=False, indent=2) + "\n", "utf-8"
)
print(json.dumps(result, ensure_ascii=False))
print("仅验证 SDK 自身;插件仍需对真实入口做故障注入、证据与任务验收。")
if __name__ == "__main__":
main()
+157
View File
@@ -0,0 +1,157 @@
"""Plugin development checks and a separate market materials gate."""
import argparse
import asyncio
import json
from pathlib import Path
from pydantic import ValidationError
from artemis.plugins.evidence import validate_tools
from artemis.plugins.market import (
REPORT_NAME,
content_fingerprint,
draft_submission,
package_directory,
package_files,
validate_submission,
)
from artemis.plugins.models import PluginManifest, PluginProfile
from artemis.plugins.session import PluginSession, resolve_server
from artemis.plugins.store import contained_path
async def check(
directory: Path,
live: bool,
values: dict[str, str],
*,
market: bool = False,
fingerprint: bool = False,
prepare_market: bool = False,
) -> dict:
if prepare_market and not directory.is_dir():
raise ValueError("请在插件目录生成市场草稿,再打包 ZIP")
with package_directory(directory) as (root, archive_sha256):
manifest = PluginManifest.model_validate_json((root / "plugin.json").read_text("utf-8"))
manifest.check_compatible()
if manifest.skill:
skill = contained_path(root, manifest.skill)
if (
not skill.is_file()
or skill.stat().st_size > 256 * 1024
or not skill.read_text("utf-8").strip()
):
raise ValueError("缺少有效的 Skill 或超过大小限制")
if manifest.schema_version not in {2, 3}:
raise ValueError("新市场插件必须适配 schema_version=2 或 3")
if any(name not in manifest.config or manifest.config[name].secret for name in values):
raise ValueError("--values 只能包含清单中声明的普通配置,不得包含密钥")
files = package_files(root) if market or fingerprint or prepare_market else None
if prepare_market:
assert files is not None
draft = draft_submission(manifest, files)
with (root / REPORT_NAME).open("x", encoding="utf-8") as handle:
handle.write(json.dumps(draft, ensure_ascii=False, indent=2) + "\n")
return {
"status": "draft",
"plugin_id": manifest.id,
"version": manifest.version,
"content_sha256": draft["content_sha256"],
}
submission = None
if market:
assert files is not None
submission = validate_submission(root, manifest, files)
handshake = bool((live or market) and manifest.server)
if handshake:
server = resolve_server(manifest, PluginProfile(values=values), root)
server.env["PYTHONDONTWRITEBYTECODE"] = "1"
session = await PluginSession(server, root).start()
try:
validate_tools(manifest, session.tools)
finally:
await session.aclose()
if files is not None and package_files(root) != files:
raise ValueError("MCP 握手期间包内容发生变化,检查记录失效")
if market:
assert files is not None
if validate_submission(root, manifest, files) != submission:
raise ValueError("MCP 握手期间市场记录发生变化,检查记录失效")
result = {
"status": "passed",
"plugin_id": manifest.id,
"version": manifest.version,
"scope": "market_materials" if market else "development",
"mcp_handshake": handshake,
}
if files is not None:
result["content_sha256"] = content_fingerprint(files)
if archive_sha256 is not None:
result["archive_sha256"] = archive_sha256
return result
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("directory", type=Path, help="插件目录或 ZIP;不会安装到现有插件库")
parser.add_argument(
"--live", action="store_true", help="仅启动/关闭 MCP 并核对声明,不调用工具"
)
parser.add_argument("--values", default="{}", help="普通配置 JSON;不得包含密钥")
modes = parser.add_mutually_exclusive_group()
modes.add_argument(
"--market", action="store_true", help="核对市场分发、内容绑定和验收材料,含 MCP 握手"
)
modes.add_argument(
"--prepare-market", action="store_true", help="创建未验证的 MARKET.json 草稿,不覆盖旧文件"
)
modes.add_argument(
"--fingerprint", action="store_true", help="输出包内容摘要;不宣称市场验收通过"
)
parser.add_argument("--json-output", type=Path, help="将检查结果写到包外 JSON 文件")
args = parser.parse_args()
result = {}
can_write = True
try:
if args.json_output and (
args.json_output.resolve() == args.directory.resolve()
or (
args.directory.is_dir()
and args.json_output.resolve().is_relative_to(args.directory.resolve())
)
):
can_write = False
raise ValueError("检查输出必须在插件包之外,不能覆盖输入包")
values = json.loads(args.values)
if not isinstance(values, dict) or any(not isinstance(v, str) for v in values.values()):
raise ValueError("--values 必须是字符串值的 JSON 对象")
result = asyncio.run(
check(
args.directory.resolve(),
args.live,
values,
market=args.market,
fingerprint=args.fingerprint,
prepare_market=args.prepare_market,
)
)
except (ValueError, OSError, RuntimeError) as exc:
message = (
"输入格式或清单/市场材料字段不合法"
if isinstance(exc, (json.JSONDecodeError, ValidationError))
else str(exc)
)
result = {"status": "failed", "error": message}
if args.json_output and can_write:
args.json_output.write_text(
json.dumps(result, ensure_ascii=False, indent=2) + "\n", "utf-8"
)
print(json.dumps(result, ensure_ascii=False))
print("检查范围不包括源码正确性与真实业务通过;市场材料仍需来源审查和真实任务验收。")
if result["status"] == "failed":
raise SystemExit(1)
if __name__ == "__main__":
main()