feat(plugins): add managed runtime SDK and release 2.4.5
This commit is contained in:
1 parent
506e3bf1c5
commit
2a3bbaa3fb
45 files changed
+3348
-98
No files matched your search
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()))
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user