Files
Artemis-Plugins/scripts/package_rust3_compat.py
T

628 lines
22 KiB
Python

"""Assemble immutable Rust 3 plugin candidates from reviewed local inputs.
Only the small, dependency-free Go adapter is rebuilt. This script never runs
Cargo, npm, a Cua session, a device tool, an installer, or a network download.
"""
import argparse
import hashlib
import io
import json
import os
import platform
import re
import stat
import struct
import subprocess
import sys
import zipfile
from pathlib import Path, PurePosixPath
from urllib.parse import quote
ROOT = Path(__file__).resolve().parents[1]
CUA = ROOT / "plugins/cua-driver"
REVIEW = ROOT / "plugins/task-review"
DEVICE_TEST = ROOT / "plugins/artemis-device-test"
BASE_VERSION = "0.38.0+artemis.1"
CUA_VERSION = "0.38.0+artemis.2"
REVIEW_VERSION = "1.1.1"
DEVICE_TEST_VERSION = "0.5.0+artemis.7"
DEFAULT_RELEASE_TAG = "rust3-plugin-preview-20261101"
HOST_RANGE = ">=3.0.0,<4.0.0"
BASE_SHA256 = "c007da16d58c4165a1a344113e650e68bd92a69ad3ce4ca8285818c5426dc0f4"
RELEASES = "https://git.playones.com/chenmingxuan/Artemis-Plugins/releases/download"
BASE_URL = (
f"{RELEASES}/cua-driver-v{BASE_VERSION}/cua-driver-{BASE_VERSION}-windows-amd64.zip"
)
RUNTIME = (
"cua-driver.exe",
"cua-driver-uia.exe",
"cua_driver_sdk.dll",
"cua-cursor-theme.exe",
)
ZIP_DATE = (2026, 11, 1, 0, 0, 0)
def sha256(data):
return hashlib.sha256(data).hexdigest()
def encoded(value):
return (json.dumps(value, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
def read_json(path):
return json.loads(path.read_text(encoding="utf-8-sig"))
def require(condition, message):
if not condition:
raise ValueError(message)
def file_bytes(path):
require(
path.is_file() and not path.is_symlink(),
f"Missing or linked input: {path.name}",
)
return path.read_bytes()
def x64(data, name):
require(len(data) >= 64 and data[:2] == b"MZ", f"Not a PE binary: {name}")
offset = struct.unpack_from("<I", data, 0x3C)[0]
require(
offset + 6 <= len(data) and data[offset : offset + 4] == b"PE\0\0",
f"Invalid PE: {name}",
)
require(
struct.unpack_from("<H", data, offset + 4)[0] == 0x8664,
f"Not Windows x64: {name}",
)
def exclusive(path, data):
with path.open("xb") as destination:
destination.write(data)
def write_zip(path, entries):
require(not path.exists(), f"Refusing to overwrite an archive: {path.name}")
with zipfile.ZipFile(path, "x", compression=zipfile.ZIP_DEFLATED) as archive:
for name, data in sorted(entries.items()):
parts = PurePosixPath(name)
require(
not parts.is_absolute()
and ".." not in parts.parts
and "\\" not in name,
"Unsafe archive entry",
)
info = zipfile.ZipInfo(name, date_time=ZIP_DATE)
info.create_system = 3
info.compress_type = zipfile.ZIP_DEFLATED
info.external_attr = 0o100644 << 16
archive.writestr(info, data)
with zipfile.ZipFile(path) as archive:
require(
archive.testzip() is None and set(archive.namelist()) == set(entries),
"Archive readback failed",
)
for name, data in entries.items():
require(
sha256(archive.read(name)) == sha256(data),
f"Archive input differs: {name}",
)
return sha256(path.read_bytes())
def source_files(directory):
files = sorted(directory.glob("*.go"))
files += [directory / "go.mod", directory / "tools.json"]
files += sorted((directory / "patches").glob("*.patch"))
return {path.relative_to(directory).as_posix(): file_bytes(path) for path in files}
def source_digest(files):
return sha256(encoded({name: sha256(data) for name, data in sorted(files.items())}))
def release_url(tag, filename):
require(
isinstance(tag, str)
and re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._+-]{0,127}", tag),
"Release tag must be a single safe tag name",
)
require(PurePosixPath(filename).name == filename, "Invalid release filename")
return f"{RELEASES}/{quote(tag, safe='')}/{quote(filename, safe='-._+')}"
def device_test_files(directory):
"""Freeze distributable bytes; the root SDK preflight owns market admission."""
require(
not directory.is_symlink() and not directory.is_junction(), "Linked plugin root"
)
directory = directory.resolve(strict=True)
require(directory.is_dir(), "Device-test source must be a directory")
entries, seen, total = {}, set(), 0
forbidden = {
"__pycache__",
".git",
"node_modules",
".venv",
"venv",
".pytest_cache",
".ruff_cache",
}
for path in sorted(directory.rglob("*")):
name = path.relative_to(directory).as_posix()
require(
not path.is_symlink()
and not path.is_junction()
and path.resolve().is_relative_to(directory),
f"Linked device-test input: {name}",
)
require(name.casefold() not in seen, "Case-colliding device-test inputs")
seen.add(name.casefold())
require(len(seen) <= 20000, "Too many device-test inputs")
require(
not any(
part.casefold() in forbidden
for part in path.relative_to(directory).parts
),
f"Non-distributable device-test input: {name}",
)
if path.is_dir():
continue
lower = path.name.casefold()
require(
stat.S_ISREG(path.stat().st_mode)
and not lower.endswith((".pyc", ".pyo"))
and lower != ".env"
and not (
lower.startswith(".env.")
and not lower.endswith((".example", ".sample", ".template"))
),
f"Non-distributable device-test file: {name}",
)
data = file_bytes(path)
total += len(data)
require(total <= 512 * 1024 * 1024, "Device-test source exceeds size limit")
entries[name] = data
for name in (
"plugin.json",
"LICENSE",
"SOURCE.md",
"MARKET.json",
"VERIFICATION.md",
):
require(
bool(entries.get(name)),
f"Missing device-test distribution material: {name}",
)
manifest = json.loads(entries["plugin.json"])
require(
manifest["id"] == "artemis-device-test"
and manifest["version"] == DEVICE_TEST_VERSION
and manifest["artemis_version"] == HOST_RANGE,
"Unexpected device-test identity or compatibility",
)
require(bool(manifest.get("license", "").strip()), "Missing device-test license")
market = json.loads(entries["MARKET.json"])
hashes = {
name: sha256(data) for name, data in entries.items() if name != "MARKET.json"
}
fingerprint = sha256(
json.dumps(
hashes, ensure_ascii=False, sort_keys=True, separators=(",", ":")
).encode("utf-8")
)
require(
market["plugin_id"] == manifest["id"]
and market["plugin_version"] == manifest["version"]
and market["content_sha256"] == fingerprint,
"Device-test MARKET identity or content hash differs; rerun SDK preflight",
)
return manifest, entries
def bundle_map(entries, filenames, catalog):
return {
"schema_version": 1,
"catalog": "catalog-v5.json",
"catalog_sha256": sha256(catalog),
"packages": [
{
"id": entry["manifest"]["id"],
"version": entry["manifest"]["version"],
"file": filename,
"archive_url": entry["archive_url"],
"sha256": entry["sha256"],
}
for entry, filename in zip(entries, filenames, strict=True)
],
}
def check_contract(cua, review, sources):
require(
cua["id"] == "cua-driver" and cua["version"] == CUA_VERSION,
"Unexpected Cua version",
)
require(
review["id"] == "task-review" and review["version"] == REVIEW_VERSION,
"Unexpected review version",
)
require(
cua["artemis_version"] == review["artemis_version"] == HOST_RANGE,
"Host compatibility differs",
)
require(
cua["server"]
== {
"transport": "stdio",
"command": "${plugin_dir}/bin/artemis-cua-adapter.exe",
},
"Unexpected Cua startup",
)
require(
cua["resources"] == ["desktop:local"]
and cua["targets"] == ["computer", "mixed"],
"Cua resource scope differs",
)
require(
re.search(
rb'const adapterVersion = "' + re.escape(CUA_VERSION.encode()) + rb'"',
sources["main.go"],
),
"MCP handshake version differs",
)
tools = json.loads(sources["tools.json"])
actual = {tool["name"]: tool for tool in tools}
declared = cua["capabilities"]["tools"]
require(
len(actual) == len(tools) and set(actual) == set(declared),
"Tool manifest differs from embedded catalog",
)
for name, spec in declared.items():
tool = actual[name]
require(
not spec.get("observation")
or tool.get("annotations", {}).get("readOnlyHint") is True,
"Observation is not read-only",
)
target = spec.get("target_argument")
require(
not target or target in tool["inputSchema"].get("properties", {}),
"Target argument is absent",
)
require(
review["schema_version"] == 3 and review["targets"] == [],
"Review must remain declarative",
)
require(
not review.get("server")
and not review.get("skill")
and not review["capabilities"]["tools"],
"Review must not start a process",
)
require(
{p["kind"] for p in review["workspace"]["panels"]} == {"original_case", "form"},
"Review panels differ",
)
require(
[f["id"] for f in review["workspace"]["fields"]]
== [
"execution_accuracy",
"execution_note",
"result_correctness",
"result_note",
],
"Review field IDs differ",
)
def base_runtime(path, sources):
data = file_bytes(path)
require(sha256(data) == BASE_SHA256, "Published Cua base archive hash mismatch")
with zipfile.ZipFile(io.BytesIO(data)) as base:
names = base.namelist()
require(len(names) == len(set(names)), "Duplicate base archive entries")
require(
all(
not PurePosixPath(n).is_absolute()
and ".." not in PurePosixPath(n).parts
and "\\" not in n
for n in names
),
"Unsafe base archive path",
)
manifest = json.loads(base.read("plugin.json"))
require(
manifest["version"] == BASE_VERSION and manifest["id"] == "cua-driver",
"Wrong published base version",
)
for name, content in sources.items():
previous = base.read("source/" + name).replace(b"\r\n", b"\n")
expected = content.replace(b"\r\n", b"\n")
if name == "main.go":
expected = expected.replace(CUA_VERSION.encode(), BASE_VERSION.encode())
require(
previous == expected,
f"Go behavior differs from the reviewed base: {name}",
)
runtime = {"bin/" + name: base.read("bin/" + name) for name in RUNTIME}
provenance = json.loads(base.read("provenance.json"))
require(
provenance["plugin_version"] == BASE_VERSION,
"Published provenance version differs",
)
for name, expected in provenance["compatibility_patch"][
"rebuilt_binaries"
].items():
require(
sha256(runtime["bin/" + name]) == expected,
"Published runtime differs from provenance",
)
license_text = base.read("LICENSE.upstream")
require(
license_text == file_bytes(CUA / "LICENSE.upstream"),
"Upstream license changed",
)
for name, binary in runtime.items():
x64(binary, name)
return runtime, provenance
def build_adapter(go, output):
build = output / ".build"
env = {
**os.environ,
"CGO_ENABLED": "0",
"GOOS": "windows",
"GOARCH": "amd64",
"GOTOOLCHAIN": "local",
"GOENV": "off",
"GOPROXY": "off",
"GOSUMDB": "off",
"GOTELEMETRY": "off",
"GOFLAGS": "-mod=readonly",
}
for key, name in (
("GOCACHE", "cache"),
("GOMODCACHE", "modules"),
("GOPATH", "path"),
("GOTMPDIR", "temp"),
):
folder = build / name
folder.mkdir(parents=True, exist_ok=True)
env[key] = str(folder)
version = subprocess.check_output(
[str(go), "version"], env=env, text=True, timeout=30
).strip()
require(
re.fullmatch(r"go version go[0-9.]+ windows/amd64", version),
"Expected native, stable Windows amd64 Go",
)
commands = (
["test", "-count=1", "./..."],
["vet", "./..."],
[
"build",
"-trimpath",
"-buildvcs=false",
"-ldflags=-s -w",
"-o",
str(build / "artemis-cua-adapter.exe"),
".",
],
)
for command in commands:
subprocess.run([str(go), *command], cwd=CUA, env=env, check=True, timeout=300)
binary = file_bytes(build / "artemis-cua-adapter.exe")
x64(binary, "artemis-cua-adapter.exe")
go_license = file_bytes(go.parent.parent / "LICENSE")
return binary, go_license, version
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--cua-base-archive", type=Path, required=True)
parser.add_argument("--go", type=Path, required=True)
parser.add_argument(
"--output",
type=Path,
required=True,
help="New empty candidate directory; never overwrite an earlier candidate",
)
parser.add_argument("--catalog-output", type=Path, default=ROOT / "catalog-v5.json")
parser.add_argument("--device-test-source", type=Path, default=DEVICE_TEST)
parser.add_argument("--release-tag", default=DEFAULT_RELEASE_TAG)
args = parser.parse_args(argv)
release_url(args.release_tag, "preflight.zip")
output = args.output.resolve()
require(
not output.exists() or not any(output.iterdir()),
"Candidate output must be new or empty",
)
require(
not args.catalog_output.exists(),
"Refusing to overwrite catalog-v5; use a new --catalog-output for a later candidate",
)
cua, review = read_json(CUA / "plugin.json"), read_json(REVIEW / "plugin.json")
sources = source_files(CUA)
check_contract(cua, review, sources)
device_test, device_entries = device_test_files(args.device_test_source)
runtime, previous = base_runtime(args.cua_base_archive.resolve(), sources)
output.mkdir(parents=True, exist_ok=True)
adapter, go_license, go_version = build_adapter(args.go.resolve(), output)
require(source_files(CUA) == sources, "Adapter source changed during build")
require(
device_test_files(args.device_test_source) == (device_test, device_entries),
"Device-test source changed during build",
)
head = subprocess.check_output(
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
).strip()
dirty = bool(
subprocess.check_output(
["git", "status", "--porcelain=v1", "--untracked-files=normal"], cwd=ROOT
)
)
provenance = dict(previous)
provenance.update(
{
"plugin_version": CUA_VERSION,
"adapter_revision": 2,
"host_compatibility": HOST_RANGE,
"runtime_reuse": {
"plugin_version": BASE_VERSION,
"archive_url": BASE_URL,
"archive_sha256": BASE_SHA256,
"binaries": {name: sha256(data) for name, data in runtime.items()},
},
"adapter_build": {
"go_version": go_version,
"cgo_enabled": False,
"sha256": sha256(adapter),
"source_files_sha256": source_digest(sources),
"checkout_commit_at_build": head,
"checkout_dirty": dirty,
"fixed_source_commit_claimed": False,
},
"verification_scope": "Go test/vet/build and archive contracts only. No Rust host handshake, UI, recording, device, business or performance validation in this build.",
}
)
cua_entries = dict(runtime)
cua_entries.update(
{
"bin/artemis-cua-adapter.exe": adapter,
"LICENSE.Go": go_license,
"provenance.json": encoded(provenance),
}
)
for name in (
"plugin.json",
"SKILL.md",
"LICENSE.upstream",
"RELEASE.md",
"VERIFICATION.md",
"BUILD.md",
):
cua_entries[name] = file_bytes(CUA / name)
cua_entries.update({"source/" + name: data for name, data in sources.items()})
review_entries = {
name: file_bytes(REVIEW / name)
for name in (
"plugin.json",
"README.md",
"LICENSE",
"VERIFICATION.md",
"RELEASE.md",
)
}
require(
json.loads(cua_entries["plugin.json"]) == cua
and json.loads(review_entries["plugin.json"]) == review,
"Manifest changed during build",
)
cua_name = f"cua-driver-{CUA_VERSION}-windows-amd64.zip"
review_name = f"task-review-{REVIEW_VERSION}.zip"
device_name = f"artemis-device-test-{DEVICE_TEST_VERSION}-windows-amd64.zip"
cua_sha = write_zip(output / cua_name, cua_entries)
review_sha = write_zip(output / review_name, review_entries)
device_sha = write_zip(output / device_name, device_entries)
sums = f"{cua_sha} {cua_name}\n{review_sha} {review_name}\n{device_sha} {device_name}\n".encode(
"ascii"
)
exclusive(output / "SHA256SUMS.txt", sums)
entries = [
{
"manifest": cua,
"release_stage": "preview",
"archive_url": release_url(args.release_tag, cua_name),
"sha256": cua_sha,
"verification": "Windows x64 / ARTEMIS 3.x 候选。Go 确定性回归、vet、桥接构建和最终 ZIP 输入/回读校验通过;Driver/UIA/SDK/游标工具原样复用已发布 0.38.0+artemis.1 的固定文件与来源。3.0 实际握手、窗口发现、截图、动作、录像及业务任务待宿主统一验收;未进行性能测试。",
},
{
"manifest": review,
"release_stage": "preview",
"archive_url": release_url(args.release_tag, review_name),
"sha256": review_sha,
"verification": "ARTEMIS 3.x 声明式工作区候选,无运行进程或额外依赖。清单字段/面板及最终 ZIP 回读校验通过;实际界面、自动保存、导航与报告导出待 3.0 宿主统一验收,不沿用 2.4 的历史测试结论。",
},
{
"manifest": device_test,
"release_stage": "preview",
"archive_url": release_url(args.release_tag, device_name),
"sha256": device_sha,
"verification": "ARTEMIS 3.x Windows x64 候选;独立 Python/MCP/dotenv 与外部工具由使用者准备。固定源码、MARKET 内容摘要和最终 ZIP 回读校验;完整许可及证据准入由 SDK 独立预检。Rust 安装、握手、取消、设备与业务验收范围以包内验证记录为准,不沿用旧宿主结论。",
},
]
catalog = encoded({"schema_version": 1, "plugins": entries})
exclusive(output / "catalog-v5.candidate.json", catalog)
exclusive(output / "catalog-v5.json", catalog)
mapping = encoded(
bundle_map(entries, [cua_name, review_name, device_name], catalog)
)
exclusive(output / "bundle-map.json", mapping)
exclusive(args.catalog_output.resolve(), catalog)
receipt = {
"schema_version": 1,
"candidate_only": True,
"published": False,
"release_tag": args.release_tag,
"python_version": platform.python_version(),
"go_version": go_version,
"source_commit_at_build": head,
"source_dirty": dirty,
"adapter_source_sha256": source_digest(sources),
"device_test_source_sha256": source_digest(device_entries),
"base_archive_sha256": BASE_SHA256,
"archives": [
{"file": cua_name, "sha256": cua_sha},
{"file": review_name, "sha256": review_sha},
{"file": device_name, "sha256": device_sha},
],
"catalog_sha256": sha256(catalog),
"bundle_map_sha256": sha256(mapping),
"checks": [
"go test -count=1 ./...",
"go vet ./...",
"go build",
"manifest/tools/source/runtime contract",
"final ZIP readback",
"device-test MARKET identity/content hash (SDK admission is separate)",
],
"not_run": [
"Cargo",
"npm",
"Rust host handshake",
"device/UI/recording/business/performance tests",
],
}
exclusive(output / "build-receipt.json", encoded(receipt))
print(
json.dumps(
{
"candidate_directory": str(output),
"archives": receipt["archives"],
"catalog": str(args.catalog_output.resolve()),
"published": False,
},
ensure_ascii=False,
)
)
if __name__ == "__main__":
try:
main()
except (
ValueError,
OSError,
subprocess.SubprocessError,
zipfile.BadZipFile,
KeyError,
) as error:
sys.exit(f"Candidate assembly failed: {error}")