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

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

No files matched your search

@@ -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
+326
View File
@@ -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)
+274
View File
@@ -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()