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,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)
|
||||
Reference in new issue
Block a user