From 2a3bbaa3fb410d321bc02c0d9f0d69d9d6074fda Mon Sep 17 00:00:00 2001 From: chenmingxuan Date: Fri, 30 Oct 2026 14:39:21 +0800 Subject: [PATCH] feat(plugins): add managed runtime SDK and release 2.4.5 --- DESIGN.md | 2 + README.md | 2 +- apps/showcase_ui/package-lock.json | 4 +- apps/showcase_ui/package.json | 2 +- artemis/plugins/_process_worker.py | 57 +++ artemis/plugins/_sdk_probe.py | 40 ++ artemis/plugins/market.py | 266 ++++++++++++++ artemis/plugins/recording_process.py | 41 ++- artemis/plugins/sdk.py | 347 ++++++++++++++++++ artemis/plugins/sdk_checks.py | 212 +++++++++++ artemis/plugins/validation.py | 157 ++++++++ docs/plugin-development.md | 8 +- docs/plugin-sdk.md | 121 ++++++ docs/releases/2.4.5.md | 29 ++ examples/plugins/evidence-demo/LICENSE | 202 ++++++++++ examples/plugins/evidence-demo/SOURCE.md | 5 + examples/plugins/evidence-demo/plugin.json | 3 +- examples/plugins/managed-tool-demo/LICENSE | 202 ++++++++++ examples/plugins/managed-tool-demo/README.md | 19 + examples/plugins/managed-tool-demo/SKILL.md | 8 + examples/plugins/managed-tool-demo/SOURCE.md | 5 + .../plugins/managed-tool-demo/plugin.json | 23 ++ examples/plugins/managed-tool-demo/server.py | 128 +++++++ .../managed-tool-demo/test_contract.py | 69 ++++ pyproject.toml | 2 +- pyright-core.json | 4 + scripts/check_plugin.py | 44 +-- skills/artemis-use/SKILL.md | 1 + .../artemis-use/assets/evidence-demo/LICENSE | 202 ++++++++++ .../assets/evidence-demo/SOURCE.md | 5 + .../assets/evidence-demo/plugin.json | 3 +- .../assets/managed-tool-demo/LICENSE | 202 ++++++++++ .../assets/managed-tool-demo/README.md | 19 + .../assets/managed-tool-demo/SKILL.md | 8 + .../assets/managed-tool-demo/SOURCE.md | 5 + .../assets/managed-tool-demo/plugin.json | 23 ++ .../assets/managed-tool-demo/server.py | 128 +++++++ .../assets/managed-tool-demo/test_contract.py | 69 ++++ .../references/plugin-development.md | 10 +- skills/artemis-use/references/plugin-sdk.md | 121 ++++++ skills/artemis-use/scripts/check_plugin.py | 44 +-- .../admin_console/test_server_lifecycle.py | 2 + tests/unit/test_plugin_market.py | 326 ++++++++++++++++ tests/unit/test_plugin_sdk.py | 274 ++++++++++++++ uv.lock | 2 +- 45 files changed, 3348 insertions(+), 98 deletions(-) create mode 100644 artemis/plugins/_process_worker.py create mode 100644 artemis/plugins/_sdk_probe.py create mode 100644 artemis/plugins/market.py create mode 100644 artemis/plugins/sdk.py create mode 100644 artemis/plugins/sdk_checks.py create mode 100644 artemis/plugins/validation.py create mode 100644 docs/plugin-sdk.md create mode 100644 docs/releases/2.4.5.md create mode 100644 examples/plugins/evidence-demo/LICENSE create mode 100644 examples/plugins/evidence-demo/SOURCE.md create mode 100644 examples/plugins/managed-tool-demo/LICENSE create mode 100644 examples/plugins/managed-tool-demo/README.md create mode 100644 examples/plugins/managed-tool-demo/SKILL.md create mode 100644 examples/plugins/managed-tool-demo/SOURCE.md create mode 100644 examples/plugins/managed-tool-demo/plugin.json create mode 100644 examples/plugins/managed-tool-demo/server.py create mode 100644 examples/plugins/managed-tool-demo/test_contract.py create mode 100644 skills/artemis-use/assets/evidence-demo/LICENSE create mode 100644 skills/artemis-use/assets/evidence-demo/SOURCE.md create mode 100644 skills/artemis-use/assets/managed-tool-demo/LICENSE create mode 100644 skills/artemis-use/assets/managed-tool-demo/README.md create mode 100644 skills/artemis-use/assets/managed-tool-demo/SKILL.md create mode 100644 skills/artemis-use/assets/managed-tool-demo/SOURCE.md create mode 100644 skills/artemis-use/assets/managed-tool-demo/plugin.json create mode 100644 skills/artemis-use/assets/managed-tool-demo/server.py create mode 100644 skills/artemis-use/assets/managed-tool-demo/test_contract.py create mode 100644 skills/artemis-use/references/plugin-sdk.md create mode 100644 tests/unit/test_plugin_market.py create mode 100644 tests/unit/test_plugin_sdk.py diff --git a/DESIGN.md b/DESIGN.md index 99da36c..1881f9b 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -64,6 +64,8 @@ ARTEMIS 面向使用自然语言执行、探索和复跑测试的人。当前产 | 插件与证据 | `artemis/plugins` 管理安装、会话、版本固定、来源、截图、录像和附件;插件执行工具动作,宿主管理任务生命周期和最终验收 | | 配置与交付 | `artemis/config`、两份 `artemis.jsonc` 和环境配置提供默认值;`scripts` 负责启动、镜像、独立升级和源码打包 | +插件运行辅助接口见 [SDK 与市场材料](docs/plugin-sdk.md):已批准文件用不可变快照绑定实际消费字节,受管外部工具在返回前回收后代。宿主负责 MCP 总预算,插件负责内部超时和取消。开发检查保持轻量;市场材料使用独立内容指纹与验收记录校验,不代替源码、权利来源或真实业务审查。 + 请求重试、业务执行与最终验收分别处理。提交使用稳定标识;创建批次或探索草稿不自动执行,查询不产生设备动作。执行准入和恢复遵守现有资源锁,任务固定所用插件版本,不能通过另建队列绕开互斥。 设备保活是辅助服务。保活所属锁或旧保活清理目录不可访问时记录警告,不使设备连接、恢复或监控退出;未取得保活所属锁时不发送保活动作,也不创建替代锁绕过所属权。执行资源锁、主服务注册锁和队列写锁仍须成功取得。 diff --git a/README.md b/README.md index de91c79..e065516 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 基于 [ARTEMIS](https://github.com/google/artemis) 扩展的 Android AI 自动化测试平台。用自然语言描述任务,即可通过简体中文网页或 MCP 驱动真机、模拟器执行操作并收集结果。模型统一使用 **OpenAI Responses API**。 -**2.4.4**:任务结束说明默认显示一行摘要,点击展开 Markdown 正文和已记录依据,避免失败长文本占满列表;不新增 AI 复盘请求。见 [2.4.4 发行说明](docs/releases/2.4.4.md)。人工评审插件保持 1.1.0;批次异常隔离见 [2.4.3](docs/releases/2.4.3.md),列表优化见 [2.4.2](docs/releases/2.4.2.md),安装修复见 [2.4.1](docs/releases/2.4.1.md)。 +**2.4.5**:补齐插件开发辅助接口与市场材料检查,提供已批准文件快照、受管外部工具、故障自测和可运行模板。新 SDK 需 ARTEMIS 2.4.5;现有插件继续按自身清单兼容旧版本。见 [2.4.5 发行说明](docs/releases/2.4.5.md)与 [SDK 使用说明](docs/plugin-sdk.md)。人工评审插件保持 1.1.0;任务说明折叠见 [2.4.4](docs/releases/2.4.4.md),批次异常隔离见 [2.4.3](docs/releases/2.4.3.md)。 ## 主要功能 diff --git a/apps/showcase_ui/package-lock.json b/apps/showcase_ui/package-lock.json index 3617cee..5b77445 100644 --- a/apps/showcase_ui/package-lock.json +++ b/apps/showcase_ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "artemis-showcase-ui", - "version": "2.4.4", + "version": "2.4.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "artemis-showcase-ui", - "version": "2.4.4", + "version": "2.4.5", "dependencies": { "@angular/cdk": "^22.2.4", "@angular/common": "^22.2.3", diff --git a/apps/showcase_ui/package.json b/apps/showcase_ui/package.json index 344663e..63ef40a 100644 --- a/apps/showcase_ui/package.json +++ b/apps/showcase_ui/package.json @@ -1,6 +1,6 @@ { "name": "artemis-showcase-ui", - "version": "2.4.4", + "version": "2.4.5", "scripts": { "ng": "ng", "start": "ng serve --proxy-config proxy.conf.json", diff --git a/artemis/plugins/_process_worker.py b/artemis/plugins/_process_worker.py new file mode 100644 index 0000000..0550141 --- /dev/null +++ b/artemis/plugins/_process_worker.py @@ -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() diff --git a/artemis/plugins/_sdk_probe.py b/artemis/plugins/_sdk_probe.py new file mode 100644 index 0000000..67d9c4a --- /dev/null +++ b/artemis/plugins/_sdk_probe.py @@ -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() diff --git a/artemis/plugins/market.py b/artemis/plugins/market.py new file mode 100644 index 0000000..a7959fd --- /dev/null +++ b/artemis/plugins/market.py @@ -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 diff --git a/artemis/plugins/recording_process.py b/artemis/plugins/recording_process.py index 6dba6e0..7bf5a45 100644 --- a/artemis/plugins/recording_process.py +++ b/artemis/plugins/recording_process.py @@ -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 diff --git a/artemis/plugins/sdk.py b/artemis/plugins/sdk.py new file mode 100644 index 0000000..dd41f8e --- /dev/null +++ b/artemis/plugins/sdk.py @@ -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())) diff --git a/artemis/plugins/sdk_checks.py b/artemis/plugins/sdk_checks.py new file mode 100644 index 0000000..e7d7036 --- /dev/null +++ b/artemis/plugins/sdk_checks.py @@ -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() diff --git a/artemis/plugins/validation.py b/artemis/plugins/validation.py new file mode 100644 index 0000000..3856b06 --- /dev/null +++ b/artemis/plugins/validation.py @@ -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() diff --git a/docs/plugin-development.md b/docs/plugin-development.md index ac36eec..dc60a51 100644 --- a/docs/plugin-development.md +++ b/docs/plugin-development.md @@ -1,6 +1,6 @@ -# 插件开发 SDK 与证据协议(ARTEMIS 2.3.0) +# 插件开发 SDK 与证据协议(ARTEMIS 2.4.5) -新插件必须适配本文规则,再通过实测进入市场。旧 `schema_version: 1` 仍可读取;执行插件使用版本 2,并声明 `capabilities`。需要回放面板、人工表单和报告字段时使用版本 3,见[工作区插件接口](workspace-plugins.md)。模型执行、任务队列、资源锁、超时和报告由 ARTEMIS 管理,插件不另建任务调度器,也不替用户决定用例通过。 +新插件必须适配本文规则,再通过实测进入市场。旧 `schema_version: 1` 仍可读取;执行插件使用版本 2,并声明 `capabilities`。需要回放面板、人工表单和报告字段时使用版本 3,见[工作区插件接口](workspace-plugins.md)。模型执行、任务队列、资源锁、整体调用预算和报告由 ARTEMIS 管理;插件负责内部工具的超时、取消与子进程收尾,不另建任务调度器,也不替用户决定用例通过。 ## 从可运行模板开始 @@ -23,6 +23,8 @@ uv run python -c "import shutil; shutil.make_archive('evidence-demo', 'zip', 'ex 静态检查通过不等于市场准入,也不表示业务测试通过。需按下面清单提交实际验证记录。 +涉及外部程序或已批准文件输入时,从 [managed-tool-demo](../examples/plugins/managed-tool-demo/README.md) 开始;它提供真实 MCP 离线回归。通用接口、故障自测和独立 `--market` 检查见[运行辅助接口与市场材料](plugin-sdk.md)。SDK 自测在临时目录运行,不访问真实设备。 + ## 清单与工具能力 `plugin.json` 的安装、版本约束、资源、权限设置沿用 [插件说明](plugins.md)。版本 2 增加: @@ -87,3 +89,5 @@ stdio 宿主注入 `ARTEMIS_ARTIFACTS_DIR`,插件默认把生成文件写入 - 视觉:截图来源、遮挡、DPI、窗口切换、坐标一致、无图时拒绝盲点;录屏启动/结束/暂停恢复、编码回退、磁盘不足、时间缺口、关键节点、播放状态、中文可读性及长程体积。 - 文件:中文文件名、覆盖保护、校验失败;终端/API:退出码、业务结果和不确定副作用。 - Skill 必须只描述真实存在且已适配的能力,不绕过权限、调度和证据协议。保留来源和必要许可证,版本与实测范围明确;未测平台/场景不能写成已通过。 +- 外部程序验证正常退出、内部超时、取消和输出超限后的整个进程树回收;不能只确认 MCP/直接子进程退出。写操作消费文件时,批准摘要必须绑定实际消费的不可变内容,覆盖校验后替换、截断与增长。 +- 市场提交须附 [MARKET.json](plugin-sdk.md#marketjson),绑定候选内容、组件分发授权和验收证据;对最终 ZIP 运行 `--market`。本地开发检查不强制公开许可,市场材料检查不替代源码和业务审查。 diff --git a/docs/plugin-sdk.md b/docs/plugin-sdk.md new file mode 100644 index 0000000..c1d7ca2 --- /dev/null +++ b/docs/plugin-sdk.md @@ -0,0 +1,121 @@ +# 插件运行辅助接口与市场检查 + +这些接口从 ARTEMIS **2.4.5** 开始提供,位于 `artemis.plugins.sdk`;使用 ARTEMIS 2.4.5 或更新版本的 Python,并在插件清单中声明 `artemis_version: ">=2.4.5,<3.0.0"`。它们不属于远程薄客户端 `artemis-client`,不启动后台、模型或第二套任务队列。现有不依赖这些辅助接口的插件仍按自身清单兼容旧宿主;采用这些接口才需要提高最低版本。 + +## 已批准文件必须绑定实际使用的字节 + +```python +from artemis.plugins.sdk import snapshot_file + +snapshot = snapshot_file(authorized_path, approved_sha256) +# 此后只消费快照。连接建立、预检或等待期间原文件发生变化,也不会改变快照。 +for chunk in snapshot.chunks(): + transport.sendall(chunk) +``` + +`approved_sha256` 必须来自已批准的内容身份,不能在执行时把新文件的摘要自动当作新的批准。制作快照、验证设备身份和写入授权都应在真正写操作前完成。使用 `.data` 或只读的 `.chunks()`,不要校验之后重新打开原路径;传输后才发现摘要不符不能撤销已经发生的写入。 + +`snapshot_file` 一次读取授权的普通文件并核对 SHA256,拒绝链接、特殊文件、大小超限和摘要不符。默认上限 64 MiB,副本是不可变内存 bytes;参数 `max_bytes` 仅作用于本次调用,选择更大上限须评估内存。它不负责用户授权或路径沙箱,插件仍要限制允许读取的范围。大文件如采用磁盘快照或流式方案,应提供等价的防修改保证并另行验证,不能恢复到“检查后重开路径”。 + +`VerifiedFile.size`、`.sha256` 对应快照本身。原路径同长度覆盖、替换、增长或截断后,快照仍保持原内容。对写入端到端的回归必须检查实际发送字节,不能只断言预检函数被调用。 + +## 外部工具的生命周期 + +```python +from artemis.plugins.sdk import ManagedProcessError, run_process + +try: + result = await run_process( + fixed_argv, + cwd=authorized_directory, + timeout=20, + input_data=snapshot.data, + ) +except ManagedProcessError as exc: + # 用稳定的 error_code 返回部分执行/未知;不回显命令或原始敏感输出,不自动重试。 + ... +``` + +`fixed_argv` 必须是业务层构造的固定参数列表,不能向模型暴露任意 shell;`cwd` 为明确的现有目录。可通过 `env` 提供所需的环境白名单;未提供时继承当前插件环境,插件应自行排除不需要的密钥和工具日志变量。宿主的凭据管理不会自动证明外部程序的输出安全。 + +`timeout` 必须为有限正数,作用于本次工具运行;标准输入最多 64 MiB。stdout、stderr 分别返回 bytes,共用默认 4 MiB 上限,可通过本次调用的 `max_output_bytes` 调整。输出超限立即停止;部分输出不作有效结果返回。`ProcessResult.returncode` 保留非零退出码,不据此生成业务用例通过结论。 + +运行器在 Windows 先将等待启动的引导进程加入不可继承的 Job Object,再允许启动实际工具,防止快速派生后代漏出归属;父进程退出时 Job Object 同样回收其树。POSIX 使用独立会话,正常退出、超时、取消和输出超限均回收所属进程组。它是生命周期辅助,不是恶意程序沙箱,不能承诺回收主动逃离会话的程序。 + +调用结束后不保留后台子进程。需要常驻驱动的插件应实现会话级归属、关闭与验证,不借本接口后台化工具。取消在清理后继续传播,不能吞掉 `CancelledError`。清理另有固定的有界等待;未确认完成时抛出 `process_cleanup_unconfirmed`,插件必须返回未知状态并核对资源,不声明已全部停止。 + +常见错误码:`process_timeout`、`process_output_limit`、`process_launch_failed`、`process_ownership_failed`、`process_worker_interrupted`、`process_invalid_status`、`process_cleanup_unconfirmed`。`may_have_started=false` 只表示已知工具尚未启动;其余情况不能授权重发写操作。运行器从不重试。原始输出可能包含密钥,返回给模型前按业务字段白名单处理。 + +宿主负责整体任务与 MCP 调用预算,插件负责内部操作及其后代。内部超时先发生时,必须在向宿主返回前收尾;宿主未超时、或只关闭 MCP 连接,都不能替代这一步。 + +## 可运行回归 + +```sh + -B -m artemis.plugins.sdk_checks --json-output sdk-faults.json + -B <受管工具模板目录>/test_contract.py +``` + +模板目录在源码仓库的 `examples/plugins/managed-tool-demo`,或本 Skill 的 `assets/managed-tool-demo`;先复制到用户指定开发目录。以下检查均使用 ARTEMIS 2.4.5 或更新版本的 Python。 + +第一条只在临时目录创建合成文件和自己的测试进程,覆盖文件同长度覆盖、截断/增长、错误摘要、二进制 stdin、非零退出码、正常结束/超时/取消后的后代回收、输出超限、附件摘要与路径拒绝,以及部分/未知执行不能授权重试。它不连接设备、不调用模型、不修改现有任务或配置。结果明确标为 `sdk_faults_only`。 + +第二条额外通过真实 stdio MCP 调用模板工具,核对有效输入、错误摘要、目录穿越拒绝及宿主附件回读,结果标为 `template_offline_contract`。模板见插件开发文档的受管工具示例。 + +改写插件后,必须扩展模板测试到自己的实际入口:在最后一次预检与真正消费文件之间替换输入、让实际工具创建后代再超时/取消,并检查实际传输和进程存活。SDK 自测通过不能证明插件调用正确。还需覆盖错误/部分执行、证据丢失或摘要不符、权限、资源互斥以及真实任务时间线。 + +## 开发检查与市场材料检查 + +统一入口为仓库/Skill 的 `check_plugin.py`,也可执行 `python -m artemis.plugins.validation`。两处脚本调用同一个实现,不单独维护规则。 + +```sh +# 日常本地开发,无需公开分发许可;--live 只握手,不派发工具。 + <检查脚本> <插件目录或ZIP> --live --values '{"python":"ARTEMIS解释器绝对路径"}' + +# 在清洁候选目录生成草稿;所有结论未验证、许可未知,不覆盖已有 MARKET.json。 + <检查脚本> <插件目录> --prepare-market + +# 对最终候选 ZIP 检查市场材料;包含只读 MCP 声明握手,不调用业务工具。 + <检查脚本> <最终ZIP> --market --values '{"python":"ARTEMIS解释器绝对路径"}' --json-output market-check.json +``` + +路径与 JSON 按当前 Shell 引用;密钥不放进 `--values`。`--json-output` 必须在候选目录外,不能覆盖输入 ZIP。ZIP 沿用宿主的安全解压和大小限制,检查不向已有插件库安装。`--market` 先核对材料,再握手,普通开发检查仍保持轻量。 + +市场检查拒绝缓存、`.pyc`、虚拟环境、版本库目录、个人 `.env` 配置、链接和冲突路径。环境样例可保留 `.env.example`。不要为通过检查而清理用户运行目录;从可分发输入构建独立候选。 + +## MARKET.json + +此文件随包提交,版本固定为 1,不增加 `plugin.json` 字段,旧宿主仍可安装符合其能力的插件。草稿由检查脚本生成,包含: + +| 字段 | 要求 | +| --- | --- | +| `plugin_id`、`plugin_version` | 与候选清单完全一致 | +| `content_sha256` | 全部包内文件路径及 SHA256 的确定性摘要,只排除根层 `MARKET.json` 自身 | +| `traits.uses_subprocesses` | 明确 true/false,不能保留 null | +| `traits.file_inputs_for_writes` | 是否消费文件执行写操作,明确 true/false | +| `components` | 分开列原始实现、适配层、第三方依赖/参考资料的许可和来源 | +| `checks` | 下表全部验收项目,保留实际范围和证据 | + +每个组件包含 `name`、`paths`、`license`、`permission`、`license_file`、`source_file`。`paths` 为精确包内相对路径或目录前缀,`.` 表示整个包;全部可分发文件须被覆盖。只有明确的 `permission: "permitted"` 才接受,`local_only` 和 `unknown` 均阻止市场提交。许可和来源文件必须实际在包内;适配层开源许可不会重新许可原始代码、用例、协议或内部资料。可使用合适的 LicenseRef,不强制统一开源,但具体分发授权必须可审查。 + +每条验收记录包含 `status`、`evidence_file`、`evidence_sha256`、`notes`。`passed` 必须指向真实的非空包内文档及其正确摘要,notes 说明验证环境、范围与结果;缺失、失败和未验证均拒绝。不能把历史记录、SDK 自测或模拟设备改写为本次真实业务通过。 + +| 验收项目 | 要记录的内容 | +| --- | --- | +| `installation` | 安装、升级、平台和依赖兼容 | +| `tool_contract` | 实际 MCP Schema、能力和参数限制 | +| `failure_partial` | 错误、部分执行、未知副作用与不盲目重试 | +| `timeout_cancel` | 实际工具入口超时与取消 | +| `process_tree` | 工具创建后代后,正常/超时/取消的回收验证 | +| `file_binding` | 已批准文件被替换、截断、增长时的实际消费字节 | +| `resource_conflicts` | 同设备/桌面资源互斥 | +| `evidence_export` | 导出索引、中文路径、摘要不符与缺失附件 | +| `history_uninstall` | 卸载/升级后历史仍可读 | +| `secret_redaction` | 配置、命令、输出和回执脱敏 | +| `real_task` | 经宿主正常任务的操作、时间线、暂停/恢复、证据和报告 | +| `source_review` | 对本次包的源码审查、来源与分发授权核对 | + +仅在声明无相应行为时,`process_tree`、`file_binding` 可 `not_applicable`;无 MCP 时 `tool_contract`、`timeout_cancel` 可不适用;无声明资源时 `resource_conflicts` 可不适用。仍须在 notes 中给出理由,不能把未测写成不适用。其他项目须有真实通过记录。 + +更新任何文件后,旧材料会失效。先重跑受影响验证,再用 `--fingerprint` 查看新的内容摘要,并更新记录;不要只刷新摘要来沿用过期结论。握手结束后重新核对内容与市场记录,检查期间改动会使检查失败。最终 ZIP 检查结果另包含实际解压检查的同一份字节的 SHA256,不重新打开原路径来记录替换文件;公开发布时仍需核对固定附件、市场索引与无认证下载字节。 + +市场材料检查验证字段、覆盖范围、摘要和证据存在性,不证明声明真实、不替作者确认权利归属,也不自动发布。源码审查和实际业务验收仍由发布方核对;文档、静态检查、握手、SDK 故障自测与真实业务结论分别记录。 diff --git a/docs/releases/2.4.5.md b/docs/releases/2.4.5.md new file mode 100644 index 0000000..29b7d2c --- /dev/null +++ b/docs/releases/2.4.5.md @@ -0,0 +1,29 @@ +# ARTEMIS 2.4.5 + +本版基于 2.4.4,补齐插件作者管理外部工具、已批准文件与市场审查材料的公共能力。远程客户端 `artemis-client`、市场插件及第三方依赖独立版本化,本次不升版。 + +## 更新 + +- 新增 `artemis.plugins.sdk.snapshot_file`:按明确批准的 SHA256 读取不可变快照,实际工具消费快照字节,避免校验后原文件被替换、覆盖或截断;默认输入上限 64 MiB。 +- 新增 `run_process`:固定 argv、工作目录和超时,限制 stdout/stderr 总量,在正常结束、超时与取消后回收自己启动的后代。Windows 先取得 Job Object 归属再启动实际工具;提供稳定错误码,执行结果不明时不声称尚未执行,也不自动重试。 +- 新增可独立运行的 SDK 故障检查,以及受管工具模板。模板覆盖真实 stdio MCP 调用和宿主附件回读,随源码与官方 Skill 同步分发。 +- 开发与市场检查共用一份实现。`check_plugin.py --prepare-market` 生成未验证的 `MARKET.json` 草稿;`--market` 检查来源许可、验收材料、候选内容指纹和 MCP 工具声明,并拒绝缓存、私密环境文件及握手期间包内容变化。 +- 补充模板的 LICENSE、SOURCE.md 和开发说明。市场材料检查仍需结合源码、分发权利与真实业务审查;检查命令通过不会自动发布插件。 + +这些辅助接口管理工具生命周期和文件内容绑定;插件仍负责权限范围、业务验收、写操作状态及敏感信息处理。POSIX 使用独立进程组,不将运行器作为恶意程序沙箱。 + +## 兼容性 + +采用新增 SDK 的插件须声明 `artemis_version: ">=2.4.5,<3.0.0"`,并使用 ARTEMIS 2.4.5 或更新版本的 Python。正式 2.4.4 包不包含该 SDK。 + +插件清单和证据协议保持兼容,`MARKET.json` 是独立的审查材料。未使用新增 SDK 的现有插件继续按自身最低版本运行。已独立发布的设备测试插件 `0.5.0+artemis.6` 将修复包含在插件自身,仍可用于正式 ARTEMIS 2.4.4;主程序升级不会自动更新或授权插件。 + +## 验证 + +Python 默认确定性回归、Ruff 格式与静态检查、质量基线、核心类型检查,以及最终源码 ZIP 的官方源和国内镜像全新安装、生产构建结果,随发布附件 `INSTALL-VERIFICATION.md` 提供。验证记录注明实际 Python、Node/npm、源码提交及 ZIP SHA256。 + +SDK 故障检查与模板契约使用合成文件及测试进程,不替代设备、固件升级或用户业务验收。Windows 为本次实际验证环境,POSIX 分支未实测。 + +## 升级 + +已有安装请等待任务结束,运行 `update-cn.bat` 或 `update.bat` 后重启。新安装下载 `artemis-2.4.5-source.zip`,按 `SHA256SUMS.txt` 校验后启动。请勿直接覆盖旧安装目录。 diff --git a/examples/plugins/evidence-demo/LICENSE b/examples/plugins/evidence-demo/LICENSE new file mode 100644 index 0000000..ba26287 --- /dev/null +++ b/examples/plugins/evidence-demo/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + https://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Google LLC. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/examples/plugins/evidence-demo/SOURCE.md b/examples/plugins/evidence-demo/SOURCE.md new file mode 100644 index 0000000..243c83d --- /dev/null +++ b/examples/plugins/evidence-demo/SOURCE.md @@ -0,0 +1,5 @@ +# 模板来源 + +本目录为 ARTEMIS 主程序仓库提供的文件证据开发模板,适用本目录 LICENSE 的 Apache-2.0,来源以随模板交付的主程序源码提交为准。没有包含厂商协议、内部资料或用户测试数据。 + +复制模板后,新增代码、资料和第三方依赖需要分别记录实际来源与分发许可,并为本次候选包保存真实验证结果。模板许可不重新许可新增内容。 diff --git a/examples/plugins/evidence-demo/plugin.json b/examples/plugins/evidence-demo/plugin.json index 60ac4b5..21328e5 100644 --- a/examples/plugins/evidence-demo/plugin.json +++ b/examples/plugins/evidence-demo/plugin.json @@ -7,7 +7,8 @@ "artemis_version": ">=2.0.0,<3.0.0", "platforms": ["win32", "linux", "darwin"], "architectures": ["amd64", "arm64"], - "server": {"command": "${config.python}", "args": ["${plugin_dir}/server.py"]}, + "license": "Apache-2.0", + "server": {"command": "${config.python}", "args": ["-B", "${plugin_dir}/server.py"]}, "config": {"python": {"label": "含 MCP 依赖的 Python", "default": "python"}}, "skill": "SKILL.md", "targets": ["mixed", "computer"], diff --git a/examples/plugins/managed-tool-demo/LICENSE b/examples/plugins/managed-tool-demo/LICENSE new file mode 100644 index 0000000..ba26287 --- /dev/null +++ b/examples/plugins/managed-tool-demo/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + https://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Google LLC. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/examples/plugins/managed-tool-demo/README.md b/examples/plugins/managed-tool-demo/README.md new file mode 100644 index 0000000..847a74e --- /dev/null +++ b/examples/plugins/managed-tool-demo/README.md @@ -0,0 +1,19 @@ +# 受管工具与文件快照模板 + +本模板使用 ARTEMIS 2.4.5 新增的 `artemis.plugins.sdk`,最低宿主版本为 2.4.5。复制模板后使用该版本或更新版本的 ARTEMIS Python;正式的 2.4.4 包不提供这些接口。模板随源码和 Skill 分发,尚未发布到市场。纯文件写入可用相邻 evidence-demo,本模板用于需要外部工具或已批准文件输入的插件。 + +复制整个目录,使用 ARTEMIS 自己的 Python: + +```sh + -B test_contract.py +``` + +测试只在临时目录创建样例和休眠进程,验证 SDK 的文件替换、超时、取消、后代回收、输出上限,以及真实 stdio MCP 调用、错误输入拒绝和宿主附件回读。结果分为 SDK 自测与本模板离线契约,不证明改写后的插件、设备或业务通过。 + +扩展时保持固定 argv 和工作目录。先用 `snapshot_file(path, approved_sha256)` 得到不可变 bytes,再将 `snapshot.data` 或 `snapshot.chunks()` 交给实际消费者;不要重新打开原路径。默认内存输入上限 64 MiB,按明确需求选择上限;超大固件需要另外实现有等价保护的流式存储,并单独验证。 + +`await run_process(argv, cwd=..., timeout=..., input_data=...)` 不执行 shell、不重试,返回原始退出码与 stdout/stderr。标准输入最多 64 MiB,stdout/stderr 共用默认 4 MiB 上限。它在正常退出、超时、取消、输出超限时回收后代;Windows 先将等待启动的引导进程加入 Job Object,再运行工具。POSIX 使用独立会话;这些是生命周期管理,不是恶意程序沙箱,不能托管主动逃离进程组的程序。 + +宿主负责整个 MCP 调用的期限;插件负责内部操作和子进程。内部超时先发生时,插件必须在返回前完成回收。取消会在回收后传播,不能吞掉 `CancelledError` 后声称完成。`ManagedProcessError.error_code` 稳定且不回显命令,`may_have_started` 决定能否声明尚未执行;`process_cleanup_unconfirmed` 必须按未知处理。命令输出可能包含敏感数据,按业务白名单转换后再返回。 + +本工具只处理已有任务文件,不提供观察或任意命令能力。保留模板 LICENSE、SOURCE.md,并补充新增代码、协议、资料与依赖的来源和业务回归。在副本中执行 `check_plugin.py --prepare-market` 生成全部未验证的市场记录;本模板自身的离线通过不能填作真实业务验收。 diff --git a/examples/plugins/managed-tool-demo/SKILL.md b/examples/plugins/managed-tool-demo/SKILL.md new file mode 100644 index 0000000..b426311 --- /dev/null +++ b/examples/plugins/managed-tool-demo/SKILL.md @@ -0,0 +1,8 @@ +--- +name: managed-tool-demo +description: 对任务目录中已明确批准的文件制作不可变快照,并通过固定工具校验、保存回执。 +--- + +调用 plugin_load 返回映射后的 verify_note。name 只允许任务目录内的普通文件名,sha256 必须是用户明确批准的内容摘要;不要临时计算另一份内容的摘要来绕过批准。 + +固定工具只读取快照并计算摘要,不提供任意命令、不访问设备或网络。校验与回执保存成功不等于业务测试通过。超时、取消或结果不明时先观察实际状态,不自动重试写操作。没有视觉能力,不能独立承担纯电脑任务。 diff --git a/examples/plugins/managed-tool-demo/SOURCE.md b/examples/plugins/managed-tool-demo/SOURCE.md new file mode 100644 index 0000000..4020eb7 --- /dev/null +++ b/examples/plugins/managed-tool-demo/SOURCE.md @@ -0,0 +1,5 @@ +# 模板来源 + +本目录为 ARTEMIS 主程序仓库提供的插件开发模板,适用本目录 LICENSE 的 Apache-2.0。来源为主程序仓库中随该模板交付的对应源码提交;模板使用主程序的 `artemis.plugins.sdk`,不另行分发设备协议、厂商资料或用户用例。 + +复制后应记录自己的代码、所适配上游、第三方依赖、协议资料和用例的实际来源与许可证;新增内容的分发范围需要分别确认。模板的许可不会重新许可这些新增材料。运行记录须使用自己的实际候选版本和验证结果,不能沿用模板演示结论。 diff --git a/examples/plugins/managed-tool-demo/plugin.json b/examples/plugins/managed-tool-demo/plugin.json new file mode 100644 index 0000000..9b34c98 --- /dev/null +++ b/examples/plugins/managed-tool-demo/plugin.json @@ -0,0 +1,23 @@ +{ + "schema_version": 2, + "id": "managed-tool-demo", + "name": "受管工具与文件快照示例", + "version": "1.0.0", + "description": "开发模板:读取已批准的任务文件快照,调用固定校验程序并保存真实回执。", + "artemis_version": ">=2.4.5,<3.0.0", + "platforms": ["win32", "linux", "darwin"], + "architectures": ["amd64", "arm64"], + "license": "Apache-2.0", + "server": {"command": "${config.python}", "args": ["-B", "${plugin_dir}/server.py"]}, + "config": {"python": {"label": "当前 ARTEMIS 环境的 Python", "default": "python"}}, + "skill": "SKILL.md", + "targets": ["android", "mixed", "computer"], + "resources": [], + "capabilities": { + "kind": "file", "recording": "none", "required_tools": ["verify_note"], + "tools": {"verify_note": { + "effect": "read", "observation": false, "idempotent": false, + "cancellation": "confirmed", "evidence": ["text", "file"] + }} + } +} diff --git a/examples/plugins/managed-tool-demo/server.py b/examples/plugins/managed-tool-demo/server.py new file mode 100644 index 0000000..33e2d96 --- /dev/null +++ b/examples/plugins/managed-tool-demo/server.py @@ -0,0 +1,128 @@ +"""A fixed checksum tool using the installed ARTEMIS authoring SDK.""" + +import hashlib +import json +import os +from pathlib import Path +import sys +from typing import Annotated +import uuid + +from mcp.server.fastmcp import FastMCP +from mcp.types import CallToolResult, TextContent, ToolAnnotations +from pydantic import Field + +from artemis.plugins.sdk import ManagedProcessError, run_process, snapshot_file + +mcp = FastMCP("managed-tool-demo") +CHECKSUM = "import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())" + + +def failure(code: str, *, execution: str = "not_started") -> CallToolResult: + return CallToolResult( + isError=True, + content=[TextContent(type="text", text=code)], + structuredContent={ + "artemis": { + "version": 1, + "execution": execution, + "side_effect": "none", + "error_code": code, + "retryable": False, + } + }, + ) + + +@mcp.tool( + annotations=ToolAnnotations(readOnlyHint=True, destructiveHint=False, idempotentHint=False) +) +async def verify_note( + name: Annotated[str, Field(min_length=1, max_length=180)], + sha256: Annotated[str, Field(pattern=r"^[a-fA-F0-9]{64}$")], +) -> CallToolResult: + """Verify a task-owned file of at most 64 MiB against its explicitly approved SHA256.""" + location = os.environ.get("ARTEMIS_ARTIFACTS_DIR") + if ( + not location + or name in {".", ".."} + or Path(name).name != name + or any(c in name for c in '/\\:*?"<>|') + or name.rstrip(" .") != name + or any(ord(c) < 32 for c in name) + ): + return failure("invalid_task_filename") + root = Path(location) + try: + if root.is_symlink() or root.is_junction() or root.resolve() != root.absolute(): + return failure("invalid_task_directory") + snapshot = snapshot_file(root / name, sha256) + except (OSError, ValueError): + return failure("input_not_bound_to_approved_digest") + try: + # Never reopen the original pathname after verification. The fixed tool + # receives the same immutable bytes even if the source file changes. + result = await run_process( + [sys.executable, "-I", "-B", "-c", CHECKSUM], + cwd=root, + timeout=5, + input_data=snapshot.data, + ) + except ManagedProcessError as exc: + return failure( + exc.error_code, + execution="unknown" + if exc.error_code == "process_cleanup_unconfirmed" + else "partial" + if exc.may_have_started + else "not_started", + ) + if ( + result.returncode != 0 + or result.stdout.strip().decode("ascii", errors="replace") != snapshot.sha256 + ): + return failure("checksum_tool_result_invalid", execution="partial") + content = json.dumps( + { + "source_name": name, + "snapshot_sha256": snapshot.sha256, + "snapshot_bytes": snapshot.size, + "tool_returncode": result.returncode, + "test_verdict": "not_evaluated", + }, + ensure_ascii=False, + ).encode("utf-8") + receipt_name = f"verified-{uuid.uuid4().hex}.json" + try: + with (root / receipt_name).open("xb") as handle: + handle.write(content) + except OSError: + return failure("receipt_write_failed", execution="partial") + return CallToolResult( + content=[TextContent(type="text", text="已核对批准的文件快照,回执已保存。")], + structuredContent={ + "snapshot_sha256": snapshot.sha256, + "artemis": { + "version": 1, + "execution": "completed", + "side_effect": "none", + "retryable": False, + "source": { + "kind": "file", + "target_id": "task-artifacts", + "label": "已批准文件快照", + }, + "attachments": [ + { + "name": receipt_name, + "media_type": "application/json", + "sha256": hashlib.sha256(content).hexdigest(), + } + ], + }, + }, + ) + + +if __name__ == "__main__": + mcp.run(transport="stdio") diff --git a/examples/plugins/managed-tool-demo/test_contract.py b/examples/plugins/managed-tool-demo/test_contract.py new file mode 100644 index 0000000..1023690 --- /dev/null +++ b/examples/plugins/managed-tool-demo/test_contract.py @@ -0,0 +1,69 @@ +"""Run with the current ARTEMIS Python; no pytest/device/service/config needed.""" + +import asyncio +import hashlib +import json +from pathlib import Path +import sys +import tempfile + +from artemis.plugins.evidence import persist_attachments, validate_tools +from artemis.plugins.models import PluginManifest, PluginProfile +from artemis.plugins.sdk_checks import run_checks +from artemis.plugins.session import PluginSession, resolve_server + + +async def run_contract(): + directory = Path(__file__).resolve().parent + manifest = PluginManifest.model_validate_json((directory / "plugin.json").read_text("utf-8")) + with tempfile.TemporaryDirectory(prefix="artemis-template-contract-") as temporary: + trace = Path(temporary).resolve() + artifacts = trace / "artifacts" / "plugins" / manifest.id + artifacts.mkdir(parents=True) + content = "synthetic local sample\n中文\x00".encode() + (artifacts / "样例.txt").write_bytes(content) + server = resolve_server( + manifest, PluginProfile(values={"python": sys.executable}), directory + ) + server.env["ARTEMIS_ARTIFACTS_DIR"] = str(artifacts) + server.env["PYTHONDONTWRITEBYTECODE"] = "1" + session = await PluginSession(server, directory).start() + try: + validate_tools(manifest, session.tools) + success = await session.call( + "verify_note", {"name": "样例.txt", "sha256": hashlib.sha256(content).hexdigest()} + ) + if success.isError: + raise AssertionError("MCP 校验入口失败") + receipt = success.structuredContent["artemis"] + attachments = persist_attachments(trace, manifest.id, receipt["attachments"]) + if len(attachments) != 1: + raise AssertionError("宿主未回读到校验回执") + for name, digest in (("样例.txt", "0" * 64), ("../outside.txt", "0" * 64)): + denied = await session.call("verify_note", {"name": name, "sha256": digest}) + if ( + not denied.isError + or denied.structuredContent["artemis"]["execution"] != "not_started" + ): + raise AssertionError("无效输入没有在工具启动前拒绝") + finally: + await session.aclose() + return { + "status": "passed", + "scope": "template_offline_contract", + "mcp_calls": 3, + "host_attachments_verified": 1, + "real_device_actions": False, + } + + +async def main(): + print( + json.dumps( + {"sdk": await run_checks(), "template": await run_contract()}, ensure_ascii=False + ) + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/pyproject.toml b/pyproject.toml index 24b7aed..4d9901c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ [project] name = "artemis" -version = "2.4.4" +version = "2.4.5" description = "Autonomous AI Assistant for Mobile Automation" authors = [ diff --git a/pyright-core.json b/pyright-core.json index 94e3b74..f014c33 100644 --- a/pyright-core.json +++ b/pyright-core.json @@ -6,6 +6,10 @@ "artemis/graph/visibility.py", "artemis/runtime/adb_endpoint.py", "artemis/runtime/process_probe.py", + "artemis/plugins/sdk.py", + "artemis/plugins/market.py", + "artemis/plugins/validation.py", + "artemis/plugins/sdk_checks.py", "artemis/mcp/action_types.py", "artemis/data_engine/models.py", "artemis/agents/explorer/perception_tools.py", diff --git a/scripts/check_plugin.py b/scripts/check_plugin.py index 306fe0e..1faa6e3 100644 --- a/scripts/check_plugin.py +++ b/scripts/check_plugin.py @@ -1,46 +1,6 @@ -"""Validate a plugin package; --live checks MCP declarations without executing actions.""" +"""Shared plugin development and market-materials checker; no tool actions.""" -import argparse -import asyncio -import json -from pathlib import Path - -from artemis.plugins.evidence import validate_tools -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]) -> None: - manifest = PluginManifest.model_validate_json((directory / "plugin.json").read_text("utf-8")) - manifest.check_compatible() - if manifest.skill: - skill = contained_path(directory, manifest.skill) - if not skill.is_file() 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 live and manifest.server: - server = resolve_server(manifest, PluginProfile(values=values), directory) - session = await PluginSession(server, directory).start() - try: - validate_tools(manifest, session.tools) - finally: - await session.aclose() - print( - f"PASS {manifest.id} {manifest.version}: 清单/兼容性/Skill" - + ("/MCP 工具声明" if live and manifest.server else "") - ) - print("此检查不代替实际执行、超时取消、证据导出和视觉能力验收。") - - -def main() -> None: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("directory", type=Path) - parser.add_argument("--live", action="store_true") - parser.add_argument("--values", default="{}", help="普通配置 JSON;不得包含密钥") - args = parser.parse_args() - asyncio.run(check(args.directory.resolve(), args.live, json.loads(args.values))) +from artemis.plugins.validation import check as check, main if __name__ == "__main__": diff --git a/skills/artemis-use/SKILL.md b/skills/artemis-use/SKILL.md index 44901fc..4f57700 100644 --- a/skills/artemis-use/SKILL.md +++ b/skills/artemis-use/SKILL.md @@ -17,6 +17,7 @@ ARTEMIS 自己编排手机和已授权插件,共用网页的任务队列、历 | 给出任务编号,问失败原因、token、缓存、录像或最终结论 | [诊断与复盘](references/diagnostics.md) | | 使用电脑、路由器、终端等已安装能力 | [插件任务参数](references/plugins.md) | | 编写、适配、验证或发布 ARTEMIS 插件 | [插件开发规范](references/plugin-development.md),按需读取 [清单与市场协议](references/plugin-manifest.md) | +| 插件调用外部程序、消费已批准文件或准备市场材料 | [运行辅助接口、故障自测与市场检查](references/plugin-sdk.md) | | 开发回放侧栏、人工表单或扩展报告字段 | [工作区插件接口(ARTEMIS 2.3.0+)](references/workspace-plugins.md) | 只读当前任务相关参考。普通执行不加载插件开发文档。 diff --git a/skills/artemis-use/assets/evidence-demo/LICENSE b/skills/artemis-use/assets/evidence-demo/LICENSE new file mode 100644 index 0000000..ba26287 --- /dev/null +++ b/skills/artemis-use/assets/evidence-demo/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + https://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Google LLC. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/skills/artemis-use/assets/evidence-demo/SOURCE.md b/skills/artemis-use/assets/evidence-demo/SOURCE.md new file mode 100644 index 0000000..243c83d --- /dev/null +++ b/skills/artemis-use/assets/evidence-demo/SOURCE.md @@ -0,0 +1,5 @@ +# 模板来源 + +本目录为 ARTEMIS 主程序仓库提供的文件证据开发模板,适用本目录 LICENSE 的 Apache-2.0,来源以随模板交付的主程序源码提交为准。没有包含厂商协议、内部资料或用户测试数据。 + +复制模板后,新增代码、资料和第三方依赖需要分别记录实际来源与分发许可,并为本次候选包保存真实验证结果。模板许可不重新许可新增内容。 diff --git a/skills/artemis-use/assets/evidence-demo/plugin.json b/skills/artemis-use/assets/evidence-demo/plugin.json index 60ac4b5..21328e5 100644 --- a/skills/artemis-use/assets/evidence-demo/plugin.json +++ b/skills/artemis-use/assets/evidence-demo/plugin.json @@ -7,7 +7,8 @@ "artemis_version": ">=2.0.0,<3.0.0", "platforms": ["win32", "linux", "darwin"], "architectures": ["amd64", "arm64"], - "server": {"command": "${config.python}", "args": ["${plugin_dir}/server.py"]}, + "license": "Apache-2.0", + "server": {"command": "${config.python}", "args": ["-B", "${plugin_dir}/server.py"]}, "config": {"python": {"label": "含 MCP 依赖的 Python", "default": "python"}}, "skill": "SKILL.md", "targets": ["mixed", "computer"], diff --git a/skills/artemis-use/assets/managed-tool-demo/LICENSE b/skills/artemis-use/assets/managed-tool-demo/LICENSE new file mode 100644 index 0000000..ba26287 --- /dev/null +++ b/skills/artemis-use/assets/managed-tool-demo/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + https://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Google LLC. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/skills/artemis-use/assets/managed-tool-demo/README.md b/skills/artemis-use/assets/managed-tool-demo/README.md new file mode 100644 index 0000000..847a74e --- /dev/null +++ b/skills/artemis-use/assets/managed-tool-demo/README.md @@ -0,0 +1,19 @@ +# 受管工具与文件快照模板 + +本模板使用 ARTEMIS 2.4.5 新增的 `artemis.plugins.sdk`,最低宿主版本为 2.4.5。复制模板后使用该版本或更新版本的 ARTEMIS Python;正式的 2.4.4 包不提供这些接口。模板随源码和 Skill 分发,尚未发布到市场。纯文件写入可用相邻 evidence-demo,本模板用于需要外部工具或已批准文件输入的插件。 + +复制整个目录,使用 ARTEMIS 自己的 Python: + +```sh + -B test_contract.py +``` + +测试只在临时目录创建样例和休眠进程,验证 SDK 的文件替换、超时、取消、后代回收、输出上限,以及真实 stdio MCP 调用、错误输入拒绝和宿主附件回读。结果分为 SDK 自测与本模板离线契约,不证明改写后的插件、设备或业务通过。 + +扩展时保持固定 argv 和工作目录。先用 `snapshot_file(path, approved_sha256)` 得到不可变 bytes,再将 `snapshot.data` 或 `snapshot.chunks()` 交给实际消费者;不要重新打开原路径。默认内存输入上限 64 MiB,按明确需求选择上限;超大固件需要另外实现有等价保护的流式存储,并单独验证。 + +`await run_process(argv, cwd=..., timeout=..., input_data=...)` 不执行 shell、不重试,返回原始退出码与 stdout/stderr。标准输入最多 64 MiB,stdout/stderr 共用默认 4 MiB 上限。它在正常退出、超时、取消、输出超限时回收后代;Windows 先将等待启动的引导进程加入 Job Object,再运行工具。POSIX 使用独立会话;这些是生命周期管理,不是恶意程序沙箱,不能托管主动逃离进程组的程序。 + +宿主负责整个 MCP 调用的期限;插件负责内部操作和子进程。内部超时先发生时,插件必须在返回前完成回收。取消会在回收后传播,不能吞掉 `CancelledError` 后声称完成。`ManagedProcessError.error_code` 稳定且不回显命令,`may_have_started` 决定能否声明尚未执行;`process_cleanup_unconfirmed` 必须按未知处理。命令输出可能包含敏感数据,按业务白名单转换后再返回。 + +本工具只处理已有任务文件,不提供观察或任意命令能力。保留模板 LICENSE、SOURCE.md,并补充新增代码、协议、资料与依赖的来源和业务回归。在副本中执行 `check_plugin.py --prepare-market` 生成全部未验证的市场记录;本模板自身的离线通过不能填作真实业务验收。 diff --git a/skills/artemis-use/assets/managed-tool-demo/SKILL.md b/skills/artemis-use/assets/managed-tool-demo/SKILL.md new file mode 100644 index 0000000..b426311 --- /dev/null +++ b/skills/artemis-use/assets/managed-tool-demo/SKILL.md @@ -0,0 +1,8 @@ +--- +name: managed-tool-demo +description: 对任务目录中已明确批准的文件制作不可变快照,并通过固定工具校验、保存回执。 +--- + +调用 plugin_load 返回映射后的 verify_note。name 只允许任务目录内的普通文件名,sha256 必须是用户明确批准的内容摘要;不要临时计算另一份内容的摘要来绕过批准。 + +固定工具只读取快照并计算摘要,不提供任意命令、不访问设备或网络。校验与回执保存成功不等于业务测试通过。超时、取消或结果不明时先观察实际状态,不自动重试写操作。没有视觉能力,不能独立承担纯电脑任务。 diff --git a/skills/artemis-use/assets/managed-tool-demo/SOURCE.md b/skills/artemis-use/assets/managed-tool-demo/SOURCE.md new file mode 100644 index 0000000..4020eb7 --- /dev/null +++ b/skills/artemis-use/assets/managed-tool-demo/SOURCE.md @@ -0,0 +1,5 @@ +# 模板来源 + +本目录为 ARTEMIS 主程序仓库提供的插件开发模板,适用本目录 LICENSE 的 Apache-2.0。来源为主程序仓库中随该模板交付的对应源码提交;模板使用主程序的 `artemis.plugins.sdk`,不另行分发设备协议、厂商资料或用户用例。 + +复制后应记录自己的代码、所适配上游、第三方依赖、协议资料和用例的实际来源与许可证;新增内容的分发范围需要分别确认。模板的许可不会重新许可这些新增材料。运行记录须使用自己的实际候选版本和验证结果,不能沿用模板演示结论。 diff --git a/skills/artemis-use/assets/managed-tool-demo/plugin.json b/skills/artemis-use/assets/managed-tool-demo/plugin.json new file mode 100644 index 0000000..9b34c98 --- /dev/null +++ b/skills/artemis-use/assets/managed-tool-demo/plugin.json @@ -0,0 +1,23 @@ +{ + "schema_version": 2, + "id": "managed-tool-demo", + "name": "受管工具与文件快照示例", + "version": "1.0.0", + "description": "开发模板:读取已批准的任务文件快照,调用固定校验程序并保存真实回执。", + "artemis_version": ">=2.4.5,<3.0.0", + "platforms": ["win32", "linux", "darwin"], + "architectures": ["amd64", "arm64"], + "license": "Apache-2.0", + "server": {"command": "${config.python}", "args": ["-B", "${plugin_dir}/server.py"]}, + "config": {"python": {"label": "当前 ARTEMIS 环境的 Python", "default": "python"}}, + "skill": "SKILL.md", + "targets": ["android", "mixed", "computer"], + "resources": [], + "capabilities": { + "kind": "file", "recording": "none", "required_tools": ["verify_note"], + "tools": {"verify_note": { + "effect": "read", "observation": false, "idempotent": false, + "cancellation": "confirmed", "evidence": ["text", "file"] + }} + } +} diff --git a/skills/artemis-use/assets/managed-tool-demo/server.py b/skills/artemis-use/assets/managed-tool-demo/server.py new file mode 100644 index 0000000..33e2d96 --- /dev/null +++ b/skills/artemis-use/assets/managed-tool-demo/server.py @@ -0,0 +1,128 @@ +"""A fixed checksum tool using the installed ARTEMIS authoring SDK.""" + +import hashlib +import json +import os +from pathlib import Path +import sys +from typing import Annotated +import uuid + +from mcp.server.fastmcp import FastMCP +from mcp.types import CallToolResult, TextContent, ToolAnnotations +from pydantic import Field + +from artemis.plugins.sdk import ManagedProcessError, run_process, snapshot_file + +mcp = FastMCP("managed-tool-demo") +CHECKSUM = "import hashlib,sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())" + + +def failure(code: str, *, execution: str = "not_started") -> CallToolResult: + return CallToolResult( + isError=True, + content=[TextContent(type="text", text=code)], + structuredContent={ + "artemis": { + "version": 1, + "execution": execution, + "side_effect": "none", + "error_code": code, + "retryable": False, + } + }, + ) + + +@mcp.tool( + annotations=ToolAnnotations(readOnlyHint=True, destructiveHint=False, idempotentHint=False) +) +async def verify_note( + name: Annotated[str, Field(min_length=1, max_length=180)], + sha256: Annotated[str, Field(pattern=r"^[a-fA-F0-9]{64}$")], +) -> CallToolResult: + """Verify a task-owned file of at most 64 MiB against its explicitly approved SHA256.""" + location = os.environ.get("ARTEMIS_ARTIFACTS_DIR") + if ( + not location + or name in {".", ".."} + or Path(name).name != name + or any(c in name for c in '/\\:*?"<>|') + or name.rstrip(" .") != name + or any(ord(c) < 32 for c in name) + ): + return failure("invalid_task_filename") + root = Path(location) + try: + if root.is_symlink() or root.is_junction() or root.resolve() != root.absolute(): + return failure("invalid_task_directory") + snapshot = snapshot_file(root / name, sha256) + except (OSError, ValueError): + return failure("input_not_bound_to_approved_digest") + try: + # Never reopen the original pathname after verification. The fixed tool + # receives the same immutable bytes even if the source file changes. + result = await run_process( + [sys.executable, "-I", "-B", "-c", CHECKSUM], + cwd=root, + timeout=5, + input_data=snapshot.data, + ) + except ManagedProcessError as exc: + return failure( + exc.error_code, + execution="unknown" + if exc.error_code == "process_cleanup_unconfirmed" + else "partial" + if exc.may_have_started + else "not_started", + ) + if ( + result.returncode != 0 + or result.stdout.strip().decode("ascii", errors="replace") != snapshot.sha256 + ): + return failure("checksum_tool_result_invalid", execution="partial") + content = json.dumps( + { + "source_name": name, + "snapshot_sha256": snapshot.sha256, + "snapshot_bytes": snapshot.size, + "tool_returncode": result.returncode, + "test_verdict": "not_evaluated", + }, + ensure_ascii=False, + ).encode("utf-8") + receipt_name = f"verified-{uuid.uuid4().hex}.json" + try: + with (root / receipt_name).open("xb") as handle: + handle.write(content) + except OSError: + return failure("receipt_write_failed", execution="partial") + return CallToolResult( + content=[TextContent(type="text", text="已核对批准的文件快照,回执已保存。")], + structuredContent={ + "snapshot_sha256": snapshot.sha256, + "artemis": { + "version": 1, + "execution": "completed", + "side_effect": "none", + "retryable": False, + "source": { + "kind": "file", + "target_id": "task-artifacts", + "label": "已批准文件快照", + }, + "attachments": [ + { + "name": receipt_name, + "media_type": "application/json", + "sha256": hashlib.sha256(content).hexdigest(), + } + ], + }, + }, + ) + + +if __name__ == "__main__": + mcp.run(transport="stdio") diff --git a/skills/artemis-use/assets/managed-tool-demo/test_contract.py b/skills/artemis-use/assets/managed-tool-demo/test_contract.py new file mode 100644 index 0000000..1023690 --- /dev/null +++ b/skills/artemis-use/assets/managed-tool-demo/test_contract.py @@ -0,0 +1,69 @@ +"""Run with the current ARTEMIS Python; no pytest/device/service/config needed.""" + +import asyncio +import hashlib +import json +from pathlib import Path +import sys +import tempfile + +from artemis.plugins.evidence import persist_attachments, validate_tools +from artemis.plugins.models import PluginManifest, PluginProfile +from artemis.plugins.sdk_checks import run_checks +from artemis.plugins.session import PluginSession, resolve_server + + +async def run_contract(): + directory = Path(__file__).resolve().parent + manifest = PluginManifest.model_validate_json((directory / "plugin.json").read_text("utf-8")) + with tempfile.TemporaryDirectory(prefix="artemis-template-contract-") as temporary: + trace = Path(temporary).resolve() + artifacts = trace / "artifacts" / "plugins" / manifest.id + artifacts.mkdir(parents=True) + content = "synthetic local sample\n中文\x00".encode() + (artifacts / "样例.txt").write_bytes(content) + server = resolve_server( + manifest, PluginProfile(values={"python": sys.executable}), directory + ) + server.env["ARTEMIS_ARTIFACTS_DIR"] = str(artifacts) + server.env["PYTHONDONTWRITEBYTECODE"] = "1" + session = await PluginSession(server, directory).start() + try: + validate_tools(manifest, session.tools) + success = await session.call( + "verify_note", {"name": "样例.txt", "sha256": hashlib.sha256(content).hexdigest()} + ) + if success.isError: + raise AssertionError("MCP 校验入口失败") + receipt = success.structuredContent["artemis"] + attachments = persist_attachments(trace, manifest.id, receipt["attachments"]) + if len(attachments) != 1: + raise AssertionError("宿主未回读到校验回执") + for name, digest in (("样例.txt", "0" * 64), ("../outside.txt", "0" * 64)): + denied = await session.call("verify_note", {"name": name, "sha256": digest}) + if ( + not denied.isError + or denied.structuredContent["artemis"]["execution"] != "not_started" + ): + raise AssertionError("无效输入没有在工具启动前拒绝") + finally: + await session.aclose() + return { + "status": "passed", + "scope": "template_offline_contract", + "mcp_calls": 3, + "host_attachments_verified": 1, + "real_device_actions": False, + } + + +async def main(): + print( + json.dumps( + {"sdk": await run_checks(), "template": await run_contract()}, ensure_ascii=False + ) + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/skills/artemis-use/references/plugin-development.md b/skills/artemis-use/references/plugin-development.md index 2d98dce..a6d00ae 100644 --- a/skills/artemis-use/references/plugin-development.md +++ b/skills/artemis-use/references/plugin-development.md @@ -1,6 +1,6 @@ -# 插件开发 SDK 与证据协议(ARTEMIS 2.3.0) +# 插件开发 SDK 与证据协议(ARTEMIS 2.4.5) -新插件必须适配本文规则,再通过实测进入市场。旧 `schema_version: 1` 仍可读取;执行插件使用版本 2,并声明 `capabilities`。工作区面板和人工表单使用版本 3,详见[工作区插件接口](workspace-plugins.md)。纯工作区插件不加入任务执行选择,不向模型开放人工评审写入工具。模型执行、任务队列、资源锁、超时和报告由 ARTEMIS 管理,插件不另建任务调度器,也不替用户决定用例通过。 +新插件必须适配本文规则,再通过实测进入市场。旧 `schema_version: 1` 仍可读取;执行插件使用版本 2,并声明 `capabilities`。工作区面板和人工表单使用版本 3,详见[工作区插件接口](workspace-plugins.md)。纯工作区插件不加入任务执行选择,不向模型开放人工评审写入工具。模型执行、任务队列、资源锁、整体调用预算和报告由 ARTEMIS 管理;插件负责内部工具的超时、取消与子进程收尾,不另建任务调度器,也不替用户决定用例通过。 ## 从可运行模板开始 @@ -25,6 +25,12 @@ 静态检查通过不等于市场准入,也不表示业务测试通过。需按下面清单提交实际验证记录。 +## 运行辅助接口与市场材料 + +涉及外部程序或已批准文件输入时,复制 [managed-tool-demo](../assets/managed-tool-demo/README.md) 整个目录开始;最低宿主为 ARTEMIS 2.4.5,使用该版本或更新版本的 Python。固定 argv 和工作目录,用 `snapshot_file` 取得已批准的不可变字节并交给工具,不重新打开原路径。内部超时、取消和正常结束都要回收自己启动的后代;无法确认收尾时如实返回未知状态,不自动重发写操作。SDK 故障检查与模板离线通过不能替代真实任务。 + +接口与命令见 [SDK 与市场材料](plugin-sdk.md)。在用户插件目录的副本中,使用 ` <本Skill目录>/scripts/check_plugin.py <插件目录> --prepare-market` 生成未验证的 `MARKET.json`,再补全来源许可、组件分发授权与实际验收证据。对最终 ZIP 使用同一脚本的 `--market` 核对内容摘要与材料;它只握手核对 MCP 声明,不执行业务工具,也不自动发布。 + ## 清单与工具能力 `plugin.json` 的安装、版本约束、资源、权限设置沿用 [插件说明](plugin-manifest.md)。版本 2 增加: diff --git a/skills/artemis-use/references/plugin-sdk.md b/skills/artemis-use/references/plugin-sdk.md new file mode 100644 index 0000000..c1d7ca2 --- /dev/null +++ b/skills/artemis-use/references/plugin-sdk.md @@ -0,0 +1,121 @@ +# 插件运行辅助接口与市场检查 + +这些接口从 ARTEMIS **2.4.5** 开始提供,位于 `artemis.plugins.sdk`;使用 ARTEMIS 2.4.5 或更新版本的 Python,并在插件清单中声明 `artemis_version: ">=2.4.5,<3.0.0"`。它们不属于远程薄客户端 `artemis-client`,不启动后台、模型或第二套任务队列。现有不依赖这些辅助接口的插件仍按自身清单兼容旧宿主;采用这些接口才需要提高最低版本。 + +## 已批准文件必须绑定实际使用的字节 + +```python +from artemis.plugins.sdk import snapshot_file + +snapshot = snapshot_file(authorized_path, approved_sha256) +# 此后只消费快照。连接建立、预检或等待期间原文件发生变化,也不会改变快照。 +for chunk in snapshot.chunks(): + transport.sendall(chunk) +``` + +`approved_sha256` 必须来自已批准的内容身份,不能在执行时把新文件的摘要自动当作新的批准。制作快照、验证设备身份和写入授权都应在真正写操作前完成。使用 `.data` 或只读的 `.chunks()`,不要校验之后重新打开原路径;传输后才发现摘要不符不能撤销已经发生的写入。 + +`snapshot_file` 一次读取授权的普通文件并核对 SHA256,拒绝链接、特殊文件、大小超限和摘要不符。默认上限 64 MiB,副本是不可变内存 bytes;参数 `max_bytes` 仅作用于本次调用,选择更大上限须评估内存。它不负责用户授权或路径沙箱,插件仍要限制允许读取的范围。大文件如采用磁盘快照或流式方案,应提供等价的防修改保证并另行验证,不能恢复到“检查后重开路径”。 + +`VerifiedFile.size`、`.sha256` 对应快照本身。原路径同长度覆盖、替换、增长或截断后,快照仍保持原内容。对写入端到端的回归必须检查实际发送字节,不能只断言预检函数被调用。 + +## 外部工具的生命周期 + +```python +from artemis.plugins.sdk import ManagedProcessError, run_process + +try: + result = await run_process( + fixed_argv, + cwd=authorized_directory, + timeout=20, + input_data=snapshot.data, + ) +except ManagedProcessError as exc: + # 用稳定的 error_code 返回部分执行/未知;不回显命令或原始敏感输出,不自动重试。 + ... +``` + +`fixed_argv` 必须是业务层构造的固定参数列表,不能向模型暴露任意 shell;`cwd` 为明确的现有目录。可通过 `env` 提供所需的环境白名单;未提供时继承当前插件环境,插件应自行排除不需要的密钥和工具日志变量。宿主的凭据管理不会自动证明外部程序的输出安全。 + +`timeout` 必须为有限正数,作用于本次工具运行;标准输入最多 64 MiB。stdout、stderr 分别返回 bytes,共用默认 4 MiB 上限,可通过本次调用的 `max_output_bytes` 调整。输出超限立即停止;部分输出不作有效结果返回。`ProcessResult.returncode` 保留非零退出码,不据此生成业务用例通过结论。 + +运行器在 Windows 先将等待启动的引导进程加入不可继承的 Job Object,再允许启动实际工具,防止快速派生后代漏出归属;父进程退出时 Job Object 同样回收其树。POSIX 使用独立会话,正常退出、超时、取消和输出超限均回收所属进程组。它是生命周期辅助,不是恶意程序沙箱,不能承诺回收主动逃离会话的程序。 + +调用结束后不保留后台子进程。需要常驻驱动的插件应实现会话级归属、关闭与验证,不借本接口后台化工具。取消在清理后继续传播,不能吞掉 `CancelledError`。清理另有固定的有界等待;未确认完成时抛出 `process_cleanup_unconfirmed`,插件必须返回未知状态并核对资源,不声明已全部停止。 + +常见错误码:`process_timeout`、`process_output_limit`、`process_launch_failed`、`process_ownership_failed`、`process_worker_interrupted`、`process_invalid_status`、`process_cleanup_unconfirmed`。`may_have_started=false` 只表示已知工具尚未启动;其余情况不能授权重发写操作。运行器从不重试。原始输出可能包含密钥,返回给模型前按业务字段白名单处理。 + +宿主负责整体任务与 MCP 调用预算,插件负责内部操作及其后代。内部超时先发生时,必须在向宿主返回前收尾;宿主未超时、或只关闭 MCP 连接,都不能替代这一步。 + +## 可运行回归 + +```sh + -B -m artemis.plugins.sdk_checks --json-output sdk-faults.json + -B <受管工具模板目录>/test_contract.py +``` + +模板目录在源码仓库的 `examples/plugins/managed-tool-demo`,或本 Skill 的 `assets/managed-tool-demo`;先复制到用户指定开发目录。以下检查均使用 ARTEMIS 2.4.5 或更新版本的 Python。 + +第一条只在临时目录创建合成文件和自己的测试进程,覆盖文件同长度覆盖、截断/增长、错误摘要、二进制 stdin、非零退出码、正常结束/超时/取消后的后代回收、输出超限、附件摘要与路径拒绝,以及部分/未知执行不能授权重试。它不连接设备、不调用模型、不修改现有任务或配置。结果明确标为 `sdk_faults_only`。 + +第二条额外通过真实 stdio MCP 调用模板工具,核对有效输入、错误摘要、目录穿越拒绝及宿主附件回读,结果标为 `template_offline_contract`。模板见插件开发文档的受管工具示例。 + +改写插件后,必须扩展模板测试到自己的实际入口:在最后一次预检与真正消费文件之间替换输入、让实际工具创建后代再超时/取消,并检查实际传输和进程存活。SDK 自测通过不能证明插件调用正确。还需覆盖错误/部分执行、证据丢失或摘要不符、权限、资源互斥以及真实任务时间线。 + +## 开发检查与市场材料检查 + +统一入口为仓库/Skill 的 `check_plugin.py`,也可执行 `python -m artemis.plugins.validation`。两处脚本调用同一个实现,不单独维护规则。 + +```sh +# 日常本地开发,无需公开分发许可;--live 只握手,不派发工具。 + <检查脚本> <插件目录或ZIP> --live --values '{"python":"ARTEMIS解释器绝对路径"}' + +# 在清洁候选目录生成草稿;所有结论未验证、许可未知,不覆盖已有 MARKET.json。 + <检查脚本> <插件目录> --prepare-market + +# 对最终候选 ZIP 检查市场材料;包含只读 MCP 声明握手,不调用业务工具。 + <检查脚本> <最终ZIP> --market --values '{"python":"ARTEMIS解释器绝对路径"}' --json-output market-check.json +``` + +路径与 JSON 按当前 Shell 引用;密钥不放进 `--values`。`--json-output` 必须在候选目录外,不能覆盖输入 ZIP。ZIP 沿用宿主的安全解压和大小限制,检查不向已有插件库安装。`--market` 先核对材料,再握手,普通开发检查仍保持轻量。 + +市场检查拒绝缓存、`.pyc`、虚拟环境、版本库目录、个人 `.env` 配置、链接和冲突路径。环境样例可保留 `.env.example`。不要为通过检查而清理用户运行目录;从可分发输入构建独立候选。 + +## MARKET.json + +此文件随包提交,版本固定为 1,不增加 `plugin.json` 字段,旧宿主仍可安装符合其能力的插件。草稿由检查脚本生成,包含: + +| 字段 | 要求 | +| --- | --- | +| `plugin_id`、`plugin_version` | 与候选清单完全一致 | +| `content_sha256` | 全部包内文件路径及 SHA256 的确定性摘要,只排除根层 `MARKET.json` 自身 | +| `traits.uses_subprocesses` | 明确 true/false,不能保留 null | +| `traits.file_inputs_for_writes` | 是否消费文件执行写操作,明确 true/false | +| `components` | 分开列原始实现、适配层、第三方依赖/参考资料的许可和来源 | +| `checks` | 下表全部验收项目,保留实际范围和证据 | + +每个组件包含 `name`、`paths`、`license`、`permission`、`license_file`、`source_file`。`paths` 为精确包内相对路径或目录前缀,`.` 表示整个包;全部可分发文件须被覆盖。只有明确的 `permission: "permitted"` 才接受,`local_only` 和 `unknown` 均阻止市场提交。许可和来源文件必须实际在包内;适配层开源许可不会重新许可原始代码、用例、协议或内部资料。可使用合适的 LicenseRef,不强制统一开源,但具体分发授权必须可审查。 + +每条验收记录包含 `status`、`evidence_file`、`evidence_sha256`、`notes`。`passed` 必须指向真实的非空包内文档及其正确摘要,notes 说明验证环境、范围与结果;缺失、失败和未验证均拒绝。不能把历史记录、SDK 自测或模拟设备改写为本次真实业务通过。 + +| 验收项目 | 要记录的内容 | +| --- | --- | +| `installation` | 安装、升级、平台和依赖兼容 | +| `tool_contract` | 实际 MCP Schema、能力和参数限制 | +| `failure_partial` | 错误、部分执行、未知副作用与不盲目重试 | +| `timeout_cancel` | 实际工具入口超时与取消 | +| `process_tree` | 工具创建后代后,正常/超时/取消的回收验证 | +| `file_binding` | 已批准文件被替换、截断、增长时的实际消费字节 | +| `resource_conflicts` | 同设备/桌面资源互斥 | +| `evidence_export` | 导出索引、中文路径、摘要不符与缺失附件 | +| `history_uninstall` | 卸载/升级后历史仍可读 | +| `secret_redaction` | 配置、命令、输出和回执脱敏 | +| `real_task` | 经宿主正常任务的操作、时间线、暂停/恢复、证据和报告 | +| `source_review` | 对本次包的源码审查、来源与分发授权核对 | + +仅在声明无相应行为时,`process_tree`、`file_binding` 可 `not_applicable`;无 MCP 时 `tool_contract`、`timeout_cancel` 可不适用;无声明资源时 `resource_conflicts` 可不适用。仍须在 notes 中给出理由,不能把未测写成不适用。其他项目须有真实通过记录。 + +更新任何文件后,旧材料会失效。先重跑受影响验证,再用 `--fingerprint` 查看新的内容摘要,并更新记录;不要只刷新摘要来沿用过期结论。握手结束后重新核对内容与市场记录,检查期间改动会使检查失败。最终 ZIP 检查结果另包含实际解压检查的同一份字节的 SHA256,不重新打开原路径来记录替换文件;公开发布时仍需核对固定附件、市场索引与无认证下载字节。 + +市场材料检查验证字段、覆盖范围、摘要和证据存在性,不证明声明真实、不替作者确认权利归属,也不自动发布。源码审查和实际业务验收仍由发布方核对;文档、静态检查、握手、SDK 故障自测与真实业务结论分别记录。 diff --git a/skills/artemis-use/scripts/check_plugin.py b/skills/artemis-use/scripts/check_plugin.py index 306fe0e..1faa6e3 100644 --- a/skills/artemis-use/scripts/check_plugin.py +++ b/skills/artemis-use/scripts/check_plugin.py @@ -1,46 +1,6 @@ -"""Validate a plugin package; --live checks MCP declarations without executing actions.""" +"""Shared plugin development and market-materials checker; no tool actions.""" -import argparse -import asyncio -import json -from pathlib import Path - -from artemis.plugins.evidence import validate_tools -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]) -> None: - manifest = PluginManifest.model_validate_json((directory / "plugin.json").read_text("utf-8")) - manifest.check_compatible() - if manifest.skill: - skill = contained_path(directory, manifest.skill) - if not skill.is_file() 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 live and manifest.server: - server = resolve_server(manifest, PluginProfile(values=values), directory) - session = await PluginSession(server, directory).start() - try: - validate_tools(manifest, session.tools) - finally: - await session.aclose() - print( - f"PASS {manifest.id} {manifest.version}: 清单/兼容性/Skill" - + ("/MCP 工具声明" if live and manifest.server else "") - ) - print("此检查不代替实际执行、超时取消、证据导出和视觉能力验收。") - - -def main() -> None: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("directory", type=Path) - parser.add_argument("--live", action="store_true") - parser.add_argument("--values", default="{}", help="普通配置 JSON;不得包含密钥") - args = parser.parse_args() - asyncio.run(check(args.directory.resolve(), args.live, json.loads(args.values))) +from artemis.plugins.validation import check as check, main if __name__ == "__main__": diff --git a/tests/unit/admin_console/test_server_lifecycle.py b/tests/unit/admin_console/test_server_lifecycle.py index 669866d..8681865 100644 --- a/tests/unit/admin_console/test_server_lifecycle.py +++ b/tests/unit/admin_console/test_server_lifecycle.py @@ -202,10 +202,12 @@ async def test_shutdown_marks_only_ui_owned_running_sessions(): "apps.admin_console.server.ipc_service.stop_server", new=AsyncMock(), ), + patch("apps.admin_console.server.DeviceExecutionLock.cleanup_stale_locks") as cleanup_locks, ): await on_shutdown() update_status.assert_called_once_with("ui-running", "interrupted") + cleanup_locks.assert_called_once_with() assert state.queue_items == [] state.active_session_id = None state.is_shutting_down = False diff --git a/tests/unit/test_plugin_market.py b/tests/unit/test_plugin_market.py new file mode 100644 index 0000000..9cdb324 --- /dev/null +++ b/tests/unit/test_plugin_market.py @@ -0,0 +1,326 @@ +"""Market validation uses synthetic packages/evidence, never a real release.""" + +import hashlib +import io +import json +from pathlib import Path +import sys +import zipfile + +import pytest + +from artemis.plugins.market import ( + CHECKS, + REPORT_NAME, + content_fingerprint, + draft_submission, + package_files, + package_directory, + validate_submission, +) +from artemis.plugins.models import PluginManifest +from artemis.plugins.validation import check, main +from artemis.plugins import validation + + +@pytest.fixture +def candidate(tmp_path): + manifest = PluginManifest.model_validate( + { + "schema_version": 2, + "id": "synthetic-market-test", + "name": "Synthetic", + "version": "1.0.0", + "platforms": [sys.platform], + "skill": "SKILL.md", + "license": "Apache-2.0", + "capabilities": {"kind": "file", "tools": {}}, + } + ) + (tmp_path / "plugin.json").write_text(manifest.model_dump_json(), encoding="utf-8") + for name in ("SKILL.md", "LICENSE", "SOURCE.md", "VERIFICATION.md"): + (tmp_path / name).write_text( + "Synthetic test fixture, not a published claim.\n", encoding="utf-8" + ) + return tmp_path, manifest + + +def submitted(candidate): + root, manifest = candidate + files = package_files(root) + report = draft_submission(manifest, files) + report["traits"] = {"uses_subprocesses": False, "file_inputs_for_writes": False} + report["components"] = [ + { + "name": "synthetic fixture", + "paths": ["."], + "license": "Apache-2.0", + "permission": "permitted", + "license_file": "LICENSE", + "source_file": "SOURCE.md", + } + ] + for record in report["checks"].values(): + record.update( + status="passed", + evidence_file="VERIFICATION.md", + evidence_sha256=files["VERIFICATION.md"], + notes="Synthetic validation record for unit testing only.", + ) + write_report(root, report) + return root, manifest, report + + +def write_report(root, report): + (root / REPORT_NAME).write_text(json.dumps(report), encoding="utf-8") + + +def validate(root, manifest): + return validate_submission(root, manifest, package_files(root)) + + +@pytest.mark.asyncio +async def test_local_development_does_not_require_public_distribution(candidate): + root, _ = candidate + result = await check(root, False, {}) + assert result["scope"] == "development" + assert not (root / REPORT_NAME).exists() + with pytest.raises(ValueError, match="MARKET.json"): + await check(root, False, {}, market=True) + + +@pytest.mark.asyncio +async def test_generated_market_draft_is_unverified_and_never_overwrites(candidate): + root, _ = candidate + result = await check(root, False, {}, prepare_market=True) + assert result["status"] == "draft" + report = json.loads((root / REPORT_NAME).read_text("utf-8")) + assert all(record["status"] == "unverified" for record in report["checks"].values()) + assert report["components"][0]["permission"] == "unknown" + assert report["traits"]["uses_subprocesses"] is None + before = (root / REPORT_NAME).read_bytes() + with pytest.raises(FileExistsError): + await check(root, False, {}, prepare_market=True) + assert (root / REPORT_NAME).read_bytes() == before + with pytest.raises(ValueError): + await check(root, False, {}, market=True) + + +@pytest.mark.asyncio +async def test_valid_market_materials_report_and_zip_have_same_content_hash( + candidate, tmp_path_factory +): + root, manifest, report = submitted(candidate) + checked = await check(root, False, {}, market=True) + assert checked["scope"] == "market_materials" + archive = tmp_path_factory.mktemp("market-zip") / "candidate.zip" + with zipfile.ZipFile(archive, "w") as output: + for path in root.iterdir(): + output.write(path, path.name) + zipped = await check(archive, False, {}, market=True) + assert zipped["content_sha256"] == checked["content_sha256"] == report["content_sha256"] + assert zipped["archive_sha256"] == hashlib.sha256(archive.read_bytes()).hexdigest() + + +@pytest.mark.parametrize("permission", ["unknown", "local_only"]) +def test_market_rejects_components_without_public_distribution(candidate, permission): + root, manifest, report = submitted(candidate) + report["components"][0]["permission"] = permission + write_report(root, report) + with pytest.raises(ValueError, match="市场分发"): + validate(root, manifest) + + +def test_market_rejects_changed_payload_and_forged_evidence(candidate): + root, manifest, report = submitted(candidate) + report["checks"]["source_review"]["evidence_sha256"] = "0" * 64 + write_report(root, report) + with pytest.raises(ValueError, match="记录 SHA256"): + validate(root, manifest) + (root / "SKILL.md").write_text("different bytes", encoding="utf-8") + with pytest.raises(ValueError, match="旧的市场验证记录失效"): + validate(root, manifest) + + +@pytest.mark.parametrize( + "change", + [ + "version", + "missing_check", + "unverified", + "no_notes", + "uncovered", + "source_escape", + "license_missing", + "na_required", + "process_na", + "binding_na", + ], +) +def test_market_blocks_incomplete_or_inapplicable_claims(candidate, change): + root, manifest, report = submitted(candidate) + if change == "version": + report["plugin_version"] = "2.0.0" + elif change == "missing_check": + del report["checks"]["real_task"] + elif change == "unverified": + report["checks"]["real_task"]["status"] = "unverified" + elif change == "no_notes": + report["checks"]["source_review"]["notes"] = " " + elif change == "uncovered": + report["components"][0]["paths"] = ["LICENSE"] + elif change == "source_escape": + report["components"][0]["source_file"] = "../outside" + elif change == "license_missing": + report["components"][0]["license_file"] = "missing.txt" + elif change == "na_required": + report["checks"]["real_task"]["status"] = "not_applicable" + elif change == "process_na": + report["traits"]["uses_subprocesses"] = True + report["checks"]["process_tree"]["status"] = "not_applicable" + elif change == "binding_na": + report["traits"]["file_inputs_for_writes"] = True + report["checks"]["file_binding"]["status"] = "not_applicable" + write_report(root, report) + with pytest.raises(ValueError): + validate(root, manifest) + + +@pytest.mark.parametrize( + "name", [".ruff_cache/entry", "__pycache__/x", "module.pyc", ".env", ".env.local"] +) +def test_market_blocks_cache_and_personal_configuration(candidate, name): + root, _ = candidate + path = root / name + path.parent.mkdir(exist_ok=True) + path.write_text("synthetic only", encoding="utf-8") + with pytest.raises(ValueError, match="市场包"): + package_files(root) + + +def test_fingerprint_is_order_independent_but_binds_paths_and_bytes(): + assert content_fingerprint({"b": "2", "a": "1"}) == content_fingerprint({"a": "1", "b": "2"}) + assert content_fingerprint({"a": "1"}) != content_fingerprint({"a": "2"}) + assert content_fingerprint({"a": "1"}) != content_fingerprint({"b": "1"}) + + +@pytest.mark.asyncio +async def test_zip_path_traversal_cannot_escape_inspection(candidate, tmp_path_factory): + root, _ = candidate + archive = tmp_path_factory.mktemp("bad-market-zip") / "bad.zip" + with zipfile.ZipFile(archive, "w") as output: + output.writestr("../outside.txt", "synthetic") + with pytest.raises(ValueError): + await check(archive, False, {}) + assert not (archive.parent / "outside.txt").exists() + + +def test_json_output_must_not_modify_candidate_or_overwrite_zip(candidate, monkeypatch, capsys): + root, _ = candidate + target = root / "report.json" + monkeypatch.setattr(sys, "argv", ["check_plugin", str(root), "--json-output", str(target)]) + with pytest.raises(SystemExit): + main() + assert not target.exists() + archive = root / "original.zip" + archive.write_bytes(b"original immutable zip placeholder") + monkeypatch.setattr(sys, "argv", ["check_plugin", str(archive), "--json-output", str(archive)]) + with pytest.raises(SystemExit): + main() + assert archive.read_bytes() == b"original immutable zip placeholder" + + +def test_invalid_values_are_not_echoed(candidate, monkeypatch, capsys): + root, _ = candidate + private_marker = "SYNTHETIC-DO-NOT-ECHO" + monkeypatch.setattr( + sys, "argv", ["check_plugin", str(root), "--values", '{"key":"' + private_marker] + ) + with pytest.raises(SystemExit): + main() + assert private_marker not in capsys.readouterr().out + + +def add_synthetic_server(candidate): + root, manifest = candidate + data = manifest.model_dump(mode="json") + data["server"] = {"command": sys.executable, "args": ["synthetic-unused-server.py"]} + manifest = PluginManifest.model_validate(data) + (root / "plugin.json").write_text(manifest.model_dump_json(), encoding="utf-8") + return root, manifest + + +def handshake_fixture(monkeypatch, action): + class FakeSession: + tools = [] + + def __init__(self, server, root): + self.root = root + + async def start(self): + action(self.root) + return self + + async def aclose(self): + pass + + monkeypatch.setattr(validation, "PluginSession", FakeSession) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("changed", ["payload", "submission"]) +async def test_market_rejects_changes_during_handshake(candidate, monkeypatch, changed): + root, manifest, report = submitted(add_synthetic_server(candidate)) + + def change(directory): + if changed == "payload": + (directory / "SKILL.md").write_text("changed during handshake", encoding="utf-8") + else: + report["checks"]["real_task"]["notes"] = "A different synthetic claim." + write_report(directory, report) + + handshake_fixture(monkeypatch, change) + with pytest.raises(ValueError, match="发生变化"): + await check(root, False, {}, market=True) + + +@pytest.mark.asyncio +async def test_zip_digest_is_bound_to_bytes_actually_inspected( + candidate, tmp_path_factory, monkeypatch +): + root, _, report = submitted(add_synthetic_server(candidate)) + archive = tmp_path_factory.mktemp("changing-zip") / "candidate.zip" + with zipfile.ZipFile(archive, "w") as output: + for path in root.iterdir(): + output.write(path, path.name) + approved = hashlib.sha256(archive.read_bytes()).hexdigest() + handshake_fixture(monkeypatch, lambda root: archive.write_bytes(b"uninspected replacement")) + with pytest.raises(ValueError, match="ZIP 发生变化"): + await check(archive, False, {}, market=True) + assert approved != hashlib.sha256(archive.read_bytes()).hexdigest() + + +def test_archive_fingerprint_uses_extracted_snapshot(candidate, tmp_path_factory): + root, _ = candidate + archive = tmp_path_factory.mktemp("zip-snapshot") / "candidate.zip" + with zipfile.ZipFile(archive, "w") as output: + for path in root.iterdir(): + output.write(path, path.name) + approved = hashlib.sha256(archive.read_bytes()).hexdigest() + with pytest.raises(ValueError, match="ZIP 发生变化"): + with package_directory(archive) as (extracted, digest): + archive.write_bytes(b"uninspected replacement") + assert (extracted / "plugin.json").read_bytes() == (root / "plugin.json").read_bytes() + assert digest == approved + + +@pytest.mark.asyncio +async def test_incomplete_materials_never_launch_server(candidate, monkeypatch): + root, _ = add_synthetic_server(candidate) + + def never_start(root): + raise AssertionError("Incomplete market materials launched a plugin") + + handshake_fixture(monkeypatch, never_start) + with pytest.raises(ValueError, match="MARKET.json"): + await check(root, False, {}, market=True) diff --git a/tests/unit/test_plugin_sdk.py b/tests/unit/test_plugin_sdk.py new file mode 100644 index 0000000..3b8d792 --- /dev/null +++ b/tests/unit/test_plugin_sdk.py @@ -0,0 +1,274 @@ +"""Real local process fault regressions. No devices, network or user processes.""" + +import asyncio +import hashlib +import importlib.util +import os +from pathlib import Path +import sys +import subprocess +from unittest.mock import AsyncMock + +import pytest +import psutil + +from artemis.plugins import sdk +from artemis.plugins.sdk import ( + FileVerificationError, + ManagedProcessError, + VerifiedFile, + snapshot_file, +) +from artemis.plugins.sdk_checks import run_checks + + +@pytest.mark.asyncio +async def test_standard_sdk_fault_suite(): + result = await run_checks() + assert result["status"] == "passed" + assert "descendants_reclaimed_on_cancel" in result["checks"] + assert "descendants_reclaimed_on_timeout" in result["checks"] + assert result["real_device_actions"] is False + + +@pytest.mark.parametrize("max_bytes", [0, -1, True]) +def test_snapshot_rejects_invalid_limits_before_open(tmp_path, max_bytes): + with pytest.raises(ValueError, match="正整数"): + snapshot_file(tmp_path / "missing", "0" * 64, max_bytes=max_bytes) + + +def test_snapshot_rejects_wrong_digest_oversized_and_special_input(tmp_path): + path = tmp_path / "firmware.bin" + path.write_bytes(b"approved") + digest = hashlib.sha256(path.read_bytes()).hexdigest() + assert snapshot_file(path, digest.upper()).sha256 == digest + with pytest.raises(FileVerificationError, match="批准"): + snapshot_file(path, "f" * 64) + with pytest.raises(FileVerificationError, match="SHA256"): + snapshot_file(path, "not-a-sha256") + with pytest.raises(FileVerificationError, match="大小"): + snapshot_file(path, digest, max_bytes=3) + with pytest.raises(FileVerificationError, match="普通文件"): + snapshot_file(tmp_path, digest) + with pytest.raises(TypeError): + VerifiedFile(bytearray(b"mutable")) + + +def test_snapshot_survives_path_replacement(tmp_path): + path = tmp_path / "source" + path.write_bytes(b"old") + snapshot = snapshot_file(path, hashlib.sha256(b"old").hexdigest()) + replacement = tmp_path / "new" + replacement.write_bytes(b"new") + replacement.replace(path) + assert snapshot.data == b"old" + assert b"".join(snapshot.chunks(1)) == b"old" + with pytest.raises(TypeError): + next(snapshot.chunks())[0] = 9 + with pytest.raises(ValueError): + list(snapshot.chunks(0)) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "updates", + [ + {"argv": "echo hello"}, + {"argv": []}, + {"argv": ["bad\x00program"]}, + {"timeout": 0}, + {"timeout": float("inf")}, + {"timeout": True}, + {"input_data": "not-bytes"}, + {"max_output_bytes": 0}, + ], +) +async def test_invalid_process_inputs_do_not_launch(monkeypatch, tmp_path, updates): + launch = AsyncMock() + monkeypatch.setattr(asyncio, "create_subprocess_exec", launch) + arguments = {"argv": [sys.executable], "cwd": tmp_path, "timeout": 5, **updates} + with pytest.raises(ValueError): + await sdk.run_process(**arguments) + launch.assert_not_called() + + +@pytest.mark.asyncio +async def test_tool_launch_error_does_not_authorize_a_retry(tmp_path): + with pytest.raises(ManagedProcessError) as raised: + await sdk.run_process([str(tmp_path / "nonexistent-program")], cwd=tmp_path, timeout=5) + assert raised.value.error_code == "process_launch_failed" + assert raised.value.may_have_started is False + assert "nonexistent-program" not in str(raised.value) + + +@pytest.mark.asyncio +async def test_combined_stderr_limit_is_enforced(tmp_path): + with pytest.raises(ManagedProcessError, match="process_output_limit"): + await sdk.run_process( + [ + sys.executable, + "-I", + "-B", + "-c", + "import os; os.write(1,b'a'*800); os.write(2,b'b'*800)", + ], + cwd=tmp_path, + timeout=5, + max_output_bytes=1000, + ) + + +@pytest.mark.asyncio +async def test_process_fixed_argv_does_not_invoke_shell_and_has_explicit_environment(tmp_path): + result = await sdk.run_process( + [ + sys.executable, + "-I", + "-B", + "-c", + "import os,sys; print(sys.argv[1]); print(os.environ.get('ARTEMIS_REVIEW_SYNTHETIC'))", + "literal & | ; $(no-command)", + ], + cwd=tmp_path, + timeout=5, + env={**os.environ, "ARTEMIS_REVIEW_SYNTHETIC": "explicit"}, + ) + assert result.returncode == 0 + assert b"literal & | ; $(no-command)" in result.stdout + assert b"explicit" in result.stdout + + +@pytest.mark.asyncio +async def test_cleanup_continues_through_repeated_cancellation(): + entered = asyncio.Event() + release = asyncio.Event() + + async def cleanup(): + entered.set() + await release.wait() + + worker = asyncio.create_task(cleanup()) + owner = asyncio.create_task(sdk._finish_cleanup(worker)) + await entered.wait() + owner.cancel() + await asyncio.sleep(0) + owner.cancel() + await asyncio.sleep(0) + assert not worker.done() + release.set() + with pytest.raises(asyncio.CancelledError): + await owner + assert worker.done() and not worker.cancelled() + + +@pytest.mark.asyncio +@pytest.mark.skipif(os.name != "nt", reason="Windows Job Object ownership barrier") +async def test_ownership_failure_never_starts_tool(monkeypatch, tmp_path): + marker = tmp_path / "must-not-exist" + + def deny_assignment(self, pid): + raise OSError("synthetic assignment failure") + + monkeypatch.setattr(sdk.ProcessJob, "assign", deny_assignment) + with pytest.raises(ManagedProcessError, match="process_ownership_failed") as raised: + await sdk.run_process( + [ + sys.executable, + "-I", + "-B", + "-c", + "from pathlib import Path; import sys; Path(sys.argv[1]).touch()", + str(marker), + ], + cwd=tmp_path, + timeout=5, + ) + assert raised.value.may_have_started is False + assert not marker.exists() + + +@pytest.mark.asyncio +@pytest.mark.skipif(os.name != "nt", reason="Windows owner-death Job Object guarantee") +async def test_forcibly_killing_sdk_owner_reclaims_its_descendants(tmp_path): + from artemis.plugins.sdk_checks import PROBE + + marker = tmp_path / "owned-child.json" + owner_script = tmp_path / "owned-sdk-host.py" + owner_script.write_text( + "import asyncio, sys\nfrom pathlib import Path\n" + "from artemis.plugins.sdk import run_process\n" + "asyncio.run(run_process([sys.executable, '-I', '-B', sys.argv[1], 'tree', " + "sys.argv[2], 'linger'], cwd=Path(sys.argv[2]).parent, timeout=60))\n", + encoding="utf-8", + ) + owner = await asyncio.create_subprocess_exec( + sys.executable, + "-B", + str(owner_script), + str(PROBE), + str(marker), + stdin=asyncio.subprocess.DEVNULL, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + creationflags=subprocess.CREATE_NO_WINDOW, + ) + descendants = [] + try: + async with asyncio.timeout(10): + while not marker.exists(): + assert owner.returncode is None + await asyncio.sleep(0.02) + descendants = psutil.Process(owner.pid).children(recursive=True) + assert len(descendants) >= 3 # bootstrap, synthetic tool, synthetic sleeper + owner.kill() + await owner.wait() + async with asyncio.timeout(3): + while any(p.is_running() for p in descendants): + await asyncio.sleep(0.02) + finally: + if owner.returncode is None: + owner.kill() + await owner.wait() + for process in descendants: + if process.is_running(): + process.kill() + process.wait(timeout=5) + + +@pytest.mark.asyncio +async def test_managed_template_real_mcp_and_host_receipts(): + path = ( + Path(__file__).resolve().parents[2] / "examples/plugins/managed-tool-demo/test_contract.py" + ) + spec = importlib.util.spec_from_file_location("managed_template_contract", path.resolve()) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + result = await module.run_contract() + assert result["mcp_calls"] == 3 + assert result["host_attachments_verified"] == 1 + + +def test_authoring_template_and_checker_copies_stay_identical(): + root = Path(__file__).resolve().parents[2] + for name in ( + "plugin.json", + "server.py", + "SKILL.md", + "README.md", + "test_contract.py", + "LICENSE", + "SOURCE.md", + ): + assert (root / "examples/plugins/managed-tool-demo" / name).read_bytes() == ( + root / "skills/artemis-use/assets/managed-tool-demo" / name + ).read_bytes() + assert (root / "scripts/check_plugin.py").read_bytes() == ( + root / "skills/artemis-use/scripts/check_plugin.py" + ).read_bytes() + for name in ("plugin.json", "server.py", "SKILL.md", "LICENSE", "SOURCE.md"): + assert (root / "examples/plugins/evidence-demo" / name).read_bytes() == ( + root / "skills/artemis-use/assets/evidence-demo" / name + ).read_bytes() + assert (root / "docs/plugin-sdk.md").read_bytes() == ( + root / "skills/artemis-use/references/plugin-sdk.md" + ).read_bytes() diff --git a/uv.lock b/uv.lock index 9bb28f3..84d448a 100644 --- a/uv.lock +++ b/uv.lock @@ -248,7 +248,7 @@ wheels = [ [[package]] name = "artemis" -version = "2.4.4" +version = "2.4.5" source = { editable = "." } dependencies = [ { name = "adbutils" },