1138 lines
42 KiB
Python
1138 lines
42 KiB
Python
"""Plugin transport/installation tests use local deterministic MCP servers, never devices."""
|
|
|
|
import asyncio
|
|
import base64
|
|
import io
|
|
import json
|
|
from pathlib import Path
|
|
import sys
|
|
import zipfile
|
|
|
|
from mcp.types import CallToolResult, ImageContent, TextContent
|
|
import pytest
|
|
|
|
from artemis.plugins.installer import install_archive
|
|
from artemis.plugins.models import CatalogEntry, PluginManifest, PluginProfile, PluginSelection
|
|
from artemis.plugins.runtime import PluginRuntime
|
|
from artemis.plugins.session import resolve_server
|
|
from artemis.plugins.store import PluginStore
|
|
from artemis.runtime.device_lock import DeviceBusyError, DeviceExecutionLock
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("configured", "expected"),
|
|
[
|
|
(
|
|
"https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/" + name,
|
|
"https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/catalog-v4.json",
|
|
)
|
|
for name in ("catalog.json", "catalog-v3.json", "catalog-v4.json")
|
|
]
|
|
+ [
|
|
("https://example.test/catalog.json", "https://example.test/catalog.json"),
|
|
("https://example.test/catalog-v3.json", "https://example.test/catalog-v3.json"),
|
|
],
|
|
)
|
|
def test_workspace_catalog_routes_only_the_official_legacy_address(
|
|
monkeypatch, configured, expected
|
|
):
|
|
from artemis.plugins import installer
|
|
|
|
requested = []
|
|
monkeypatch.setattr(installer.settings, "ARTEMIS_PLUGIN_MARKETPLACE_URL", configured)
|
|
|
|
def download(url, progress):
|
|
requested.append(url)
|
|
return b'{"schema_version":1,"plugins":[]}'
|
|
|
|
monkeypatch.setattr(installer, "download", download)
|
|
assert installer.load_catalog().plugins == []
|
|
assert requested == [expected]
|
|
assert installer.settings.ARTEMIS_PLUGIN_MARKETPLACE_URL == configured
|
|
|
|
|
|
def manifest(**updates):
|
|
return PluginManifest.model_validate(
|
|
{
|
|
"id": "test-plugin",
|
|
"name": "测试插件",
|
|
"version": "1.0.0",
|
|
"platforms": [sys.platform],
|
|
"skill": "SKILL.md",
|
|
"resources": ["desktop:local"],
|
|
"config": {"token": {"label": "密钥", "secret": True, "required": True}},
|
|
**updates,
|
|
}
|
|
)
|
|
|
|
|
|
def archive(plugin, extra=None):
|
|
buffer = io.BytesIO()
|
|
with zipfile.ZipFile(buffer, "w") as output:
|
|
output.writestr("plugin.json", plugin.model_dump_json())
|
|
output.writestr("SKILL.md", "先观察,再操作;报告真实证据。")
|
|
for name, value in (extra or {}).items():
|
|
output.writestr(name, value)
|
|
return buffer.getvalue()
|
|
|
|
|
|
@pytest.fixture
|
|
def store(tmp_path):
|
|
return PluginStore(tmp_path / "plugins")
|
|
|
|
|
|
def enable(store, plugin, **profile):
|
|
install_archive(store, archive(plugin))
|
|
store.save_profile(
|
|
plugin.id, PluginProfile(secrets={"token": "private-value"}, authorized=True, **profile)
|
|
)
|
|
store.set_enabled(plugin.id, True)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_android_task_discovers_cross_device_plugins_and_loads_only_on_demand(store):
|
|
plugin = manifest(targets=["computer", "mixed"])
|
|
enable(store, plugin)
|
|
runtime = PluginRuntime(store=store, target="android")
|
|
try:
|
|
assert list(runtime.packages) == [plugin.id]
|
|
assert runtime.loaded == set()
|
|
assert runtime.sessions == {}
|
|
assert plugin.id in runtime.prompt()
|
|
assert (await runtime.call("plugin_load", {"plugin_id": plugin.id})).status == "success"
|
|
assert runtime.loaded == {plugin.id}
|
|
finally:
|
|
await runtime.aclose()
|
|
|
|
|
|
def test_android_auto_selection_respects_enablement_authorization_and_explicit_opt_out(store):
|
|
enable(store, manifest(targets=["mixed"]))
|
|
assert len(store.pin(None, "android")) == 1
|
|
assert store.pin([], "android") == []
|
|
store.set_enabled("test-plugin", False)
|
|
assert store.pin(None, "android") == []
|
|
with pytest.raises(ValueError, match="未启用"):
|
|
store.pin([PluginSelection(id="test-plugin")], "android")
|
|
store.set_enabled("test-plugin", True)
|
|
store.save_profile("test-plugin", PluginProfile(authorized=False))
|
|
assert store.pin(None, "android") == []
|
|
with pytest.raises(ValueError, match="授权"):
|
|
store.pin([PluginSelection(id="test-plugin")], "android")
|
|
|
|
|
|
def test_android_task_uses_authorized_default_and_allows_explicit_profile(store):
|
|
enable(store, manifest(targets=["mixed"]))
|
|
store.save_profile(
|
|
"test-plugin",
|
|
PluginProfile(name="lab", is_default=True, authorized=True, secrets={"token": "lab"}),
|
|
)
|
|
assert store.pin(None, "android")[0].profile == "lab"
|
|
assert store.pin([PluginSelection(id="test-plugin")], "android")[0].profile == "default"
|
|
|
|
|
|
def test_computer_only_plugin_is_not_automatically_offered_to_android(store):
|
|
enable(store, manifest(targets=["computer"]))
|
|
assert store.pin(None, "android") == []
|
|
with pytest.raises(ValueError, match="不支持"):
|
|
store.pin([PluginSelection(id="test-plugin")], "android")
|
|
|
|
|
|
def test_profile_secrets_are_write_only_and_blank_preserves(store):
|
|
enable(store, manifest())
|
|
assert "private-value" not in json.dumps(store.list())
|
|
assert store.list()[0]["profiles"][0]["configured_secrets"] == ["token"]
|
|
store.save_profile("test-plugin", PluginProfile(secrets={"token": ""}, authorized=True))
|
|
_, profile, _ = store.resolve(PluginSelection(id="test-plugin"))
|
|
assert profile.secrets["token"].get_secret_value() == "private-value"
|
|
|
|
|
|
def test_untrusted_config_cannot_hide_secrets_in_ordinary_fields(store):
|
|
install_archive(store, archive(manifest()))
|
|
with pytest.raises(ValueError, match="普通配置"):
|
|
store.save_profile("test-plugin", PluginProfile(values={"token": "secret"}))
|
|
with pytest.raises(ValueError, match="授权"):
|
|
store.set_enabled("test-plugin", True)
|
|
|
|
|
|
def test_package_versions_immutable_and_old_pin_survives_upgrade(store):
|
|
enable(store, manifest())
|
|
with pytest.raises(ValueError, match="覆盖"):
|
|
install_archive(store, archive(manifest()))
|
|
install_archive(store, archive(manifest(version="1.1.0")))
|
|
assert store.get("test-plugin")["enabled"] is False
|
|
store.set_enabled("test-plugin", True)
|
|
old, _, _ = store.resolve(PluginSelection(id="test-plugin", version="1.0.0"))
|
|
assert old.version == "1.0.0"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"path",
|
|
["../../escape.txt", "C:/escape.txt", "/escape.txt", "dir/../../../escape", "x/../escape"],
|
|
)
|
|
def test_archive_rejects_traversal(store, path):
|
|
with pytest.raises(ValueError):
|
|
install_archive(store, archive(manifest(), {path: "bad"}))
|
|
assert store.list() == []
|
|
|
|
|
|
def test_checksum_failure_keeps_previous_version(store):
|
|
enable(store, manifest())
|
|
entry = CatalogEntry(
|
|
manifest=manifest(version="1.1.0"),
|
|
archive_url="https://example.com/package.zip",
|
|
sha256="0" * 64,
|
|
verification="本地确定性验证",
|
|
)
|
|
with pytest.raises(ValueError, match="SHA-256"):
|
|
install_archive(store, archive(entry.manifest), entry=entry)
|
|
assert store.get("test-plugin")["manifest"]["version"] == "1.0.0"
|
|
assert store.get("test-plugin")["enabled"] is True
|
|
|
|
|
|
def test_server_substitutions_do_not_use_shell_or_expand_unknown_values(tmp_path):
|
|
plugin = manifest(
|
|
server={
|
|
"command": "${plugin_dir}/server.exe",
|
|
"args": ["${config.target}"],
|
|
"env": {"TOKEN": "${secret.token}"},
|
|
},
|
|
config={"token": {"label": "密钥", "secret": True}, "target": {"label": "目标"}},
|
|
)
|
|
profile = PluginProfile(
|
|
values={"target": "a b;$(echo forbidden)"}, secrets={"token": "private-value"}
|
|
)
|
|
config = resolve_server(plugin, profile, tmp_path)
|
|
assert config.args == ["a b;$(echo forbidden)"]
|
|
assert config.env == {"TOKEN": "private-value"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skill_load_and_reference_cannot_read_outside_package(store):
|
|
enable(store, manifest())
|
|
runtime = PluginRuntime(store=store)
|
|
assert {t.name for t in runtime.declarations()} == {
|
|
"plugin_load",
|
|
"plugin_read_file",
|
|
"plugin_read_result",
|
|
}
|
|
result = await runtime.call("plugin_load", {"plugin_id": "test-plugin"})
|
|
assert result.status == "success" and "先观察" in result.text()
|
|
assert (
|
|
await runtime.call(
|
|
"plugin_read_file", {"plugin_id": "test-plugin", "path": "../../../registry.sqlite3"}
|
|
)
|
|
).status == "error"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_real_stdio_mcp_errors_images_secrets_and_langchain(store, tmp_path):
|
|
server = tmp_path / "server.py"
|
|
server.write_text(
|
|
"""
|
|
import os
|
|
from mcp.server.fastmcp import FastMCP
|
|
from mcp.types import CallToolResult, TextContent, ImageContent
|
|
server = FastMCP("test")
|
|
@server.tool()
|
|
def observe(app: str) -> CallToolResult:
|
|
return CallToolResult(content=[TextContent(type="text", text=os.environ["TOKEN"]),
|
|
ImageContent(type="image", mimeType="image/png", data="aGVsbG8=")],
|
|
structuredContent={"app": app}, isError=app == "failed")
|
|
server.run()
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
plugin = manifest(
|
|
server={
|
|
"command": sys.executable,
|
|
"args": [str(server)],
|
|
"env": {"TOKEN": "${secret.token}"},
|
|
}
|
|
)
|
|
enable(store, plugin, argument_constraints={"observe": {"app": ["ok", "failed"]}})
|
|
runtime = PluginRuntime(store=store)
|
|
try:
|
|
loaded = await runtime.load(plugin.id)
|
|
assert loaded.status == "success"
|
|
tool_name = next(iter(runtime.bindings))
|
|
result = await runtime.execute(tool_name, {"app": "ok"}, "tc-1")
|
|
assert result.status == "success"
|
|
assert json.loads(json.dumps(result.metadata))["resource_ids"] == ["desktop:local"]
|
|
assert result.screenshot_bytes == b"hello"
|
|
assert "private-value" not in result.text_summary
|
|
assert "[已隐藏密钥]" in result.text_summary
|
|
assert any(b["type"] == "image_url" for b in result.raw_result)
|
|
failed = await runtime.call(tool_name, {"app": "failed"})
|
|
assert failed.status == "error"
|
|
assert failed.structured_content == {"app": "failed"}
|
|
blocked = await runtime.call(tool_name, {"app": "not-authorized"})
|
|
assert blocked.status == "error"
|
|
wrapper = next(t for t in runtime.langchain_tools() if t.name == tool_name)
|
|
assert (await wrapper.ainvoke({"app": "failed"})).status == "error"
|
|
finally:
|
|
await runtime.aclose()
|
|
assert runtime.sessions == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stdio_timeout_is_bounded_and_never_replays(store, tmp_path, monkeypatch):
|
|
from artemis.config.settings import settings
|
|
|
|
server = tmp_path / "slow.py"
|
|
count = tmp_path / "calls.txt"
|
|
server.write_text(
|
|
f"""
|
|
import asyncio
|
|
from pathlib import Path
|
|
from mcp.server.fastmcp import FastMCP
|
|
server = FastMCP("slow")
|
|
@server.tool()
|
|
async def write_once() -> str:
|
|
with Path({str(count)!r}).open("a") as f: f.write("called\\n")
|
|
await asyncio.sleep(60)
|
|
return "done"
|
|
server.run()
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
enable(store, manifest(server={"command": sys.executable, "args": [str(server)]}))
|
|
runtime = PluginRuntime(store=store)
|
|
await runtime.load("test-plugin")
|
|
monkeypatch.setattr(settings, "ARTEMIS_PLUGIN_TOOL_TIMEOUT_SECONDS", 0.2)
|
|
try:
|
|
name = next(iter(runtime.bindings))
|
|
result = await asyncio.wait_for(runtime.call(name, {}), timeout=10)
|
|
assert result.status == "uncertain"
|
|
assert count.read_text().splitlines() == ["called"]
|
|
again = await runtime.call(name, {})
|
|
assert again.status == "uncertain"
|
|
assert count.read_text().splitlines() == ["called"]
|
|
finally:
|
|
await runtime.aclose()
|
|
|
|
|
|
@pytest.mark.parametrize("scope", ["", "test-adb-endpoint"])
|
|
def test_serial_resource_locks_across_devices_without_holding_partial_leases(
|
|
tmp_path, monkeypatch, scope
|
|
):
|
|
import artemis.runtime.device_lock as locks
|
|
|
|
monkeypatch.setattr(locks, "get_temp_dir", lambda *_: tmp_path)
|
|
monkeypatch.delenv(DeviceExecutionLock.QUEUE_TICKET_ENV, raising=False)
|
|
monkeypatch.setenv(DeviceExecutionLock.LOCK_SCOPE_ENV, scope)
|
|
first = DeviceExecutionLock(
|
|
"phone-a", session_id="first", resource_ids=["desktop:local"], concurrency_mode="per_device"
|
|
)
|
|
second = DeviceExecutionLock(
|
|
"phone-b",
|
|
session_id="second",
|
|
resource_ids=["desktop:local", "router:lab"],
|
|
concurrency_mode="per_device",
|
|
)
|
|
other = DeviceExecutionLock(
|
|
"phone-c", session_id="other", resource_ids=["router:lab"], concurrency_mode="per_device"
|
|
)
|
|
first.acquire(blocking=False)
|
|
try:
|
|
with pytest.raises(DeviceBusyError):
|
|
second.acquire(blocking=False)
|
|
with pytest.raises(DeviceBusyError):
|
|
other.acquire(blocking=False)
|
|
assert len(DeviceExecutionLock.get_active_owners()) == 1
|
|
resources = (DeviceExecutionLock.EXECUTION_RESOURCE, "desktop:local")
|
|
assert DeviceExecutionLock.get_active_owners()["phone-a"].resource_ids == resources
|
|
assert DeviceExecutionLock.annotate_active_owner(
|
|
session_id="first", device_id="phone-a", ingress="test"
|
|
)
|
|
assert DeviceExecutionLock.get_active_owners()["phone-a"].resource_ids == resources
|
|
finally:
|
|
other.release()
|
|
first.release()
|
|
other.acquire(blocking=False)
|
|
other.release()
|
|
second.acquire(blocking=False)
|
|
second.release()
|
|
assert not DeviceExecutionLock.get_active_owners()
|
|
|
|
|
|
def test_task_reservation_blocks_mutations_and_removes_files_after_release(store):
|
|
enable(store, manifest())
|
|
selection = PluginSelection(id="test-plugin", version="1.0.0")
|
|
assert store.reserve("queued-task", [selection], "mixed") == ["desktop:local"]
|
|
for change in (
|
|
lambda: store.save_profile("test-plugin", PluginProfile()),
|
|
lambda: store.set_enabled("test-plugin", False),
|
|
lambda: store.uninstall("test-plugin"),
|
|
lambda: install_archive(store, archive(manifest(version="1.1.0"))),
|
|
):
|
|
with pytest.raises(ValueError, match="任务"):
|
|
change()
|
|
assert not store.package_dir("test-plugin", "1.1.0").exists()
|
|
store.release("queued-task")
|
|
store.uninstall("test-plugin")
|
|
assert store.list() == []
|
|
assert not store.package_dir("test-plugin", "1.0.0").exists()
|
|
|
|
|
|
@pytest.mark.parametrize("stop_all", [False, True])
|
|
def test_cancelled_queue_entries_release_plugin_pin_but_live_workers_keep_it(
|
|
store, monkeypatch, stop_all
|
|
):
|
|
from types import SimpleNamespace
|
|
from apps.admin_console.services import task_queue_service as queue_module
|
|
from apps.admin_console.services import automation_store
|
|
|
|
enable(store, manifest())
|
|
selection = PluginSelection(id="test-plugin", version="1.0.0")
|
|
store.reserve("pending", [selection], "mixed")
|
|
store.reserve("active", [selection], "mixed")
|
|
pending = {"session_id": "pending", "status": "pending"}
|
|
active = {"session_id": "active", "status": "running"}
|
|
state = SimpleNamespace(
|
|
queue_items=[pending, active],
|
|
active_connections={},
|
|
active_session_id=None,
|
|
current_goal=None,
|
|
current_profile=None,
|
|
wake_event=SimpleNamespace(set=lambda: None),
|
|
)
|
|
state.clear_queue = lambda: state.queue_items.clear()
|
|
monkeypatch.setattr(queue_module, "state", state)
|
|
monkeypatch.setattr("artemis.plugins.store.PluginStore", lambda: store)
|
|
monkeypatch.setattr(DeviceExecutionLock, "cancel_reservation", lambda *_: None)
|
|
monkeypatch.setattr(
|
|
automation_store, "store", SimpleNamespace(records=lambda *_: [], job=lambda *_: None)
|
|
)
|
|
service = queue_module.TaskQueueService
|
|
for name in (
|
|
"_terminate_all_device_owners",
|
|
"_kill_all_local_runs",
|
|
"_clear_pause_file",
|
|
"ensure_worker_running",
|
|
):
|
|
monkeypatch.setattr(service, name, lambda: None)
|
|
if stop_all:
|
|
service._stop_all_tasks()
|
|
else:
|
|
service._remove_stopped_queue_item("pending", False)
|
|
service._remove_stopped_queue_item("active", False)
|
|
with store.connect() as db:
|
|
assert [row[0] for row in db.execute("SELECT task_id FROM task_pins")] == ["active"]
|
|
# Even after stop removes the queue row, the worker finalizer must drop its reservation.
|
|
service._remove_task("active")
|
|
store.assert_idle("test-plugin")
|
|
|
|
|
|
def test_registry_failure_rolls_back_package_files(store, monkeypatch):
|
|
def failed(*_):
|
|
raise OSError("disk failure")
|
|
|
|
monkeypatch.setattr(store, "register", failed)
|
|
with pytest.raises(OSError):
|
|
install_archive(store, archive(manifest()))
|
|
assert not store.package_dir("test-plugin", "1.0.0").exists()
|
|
assert store.list() == []
|
|
|
|
|
|
@pytest.mark.parametrize("path", ["NUL", "dir/COM1.txt", "aux.md", "LPT9/data"])
|
|
def test_archive_rejects_windows_device_names(store, path):
|
|
with pytest.raises(ValueError, match="路径"):
|
|
install_archive(store, archive(manifest(), {path: "bad"}))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runtime_keeps_version_and_profile_reserved_while_paused(store):
|
|
enable(store, manifest())
|
|
runtime = PluginRuntime(store=store)
|
|
await runtime.aclose(release=False)
|
|
with pytest.raises(ValueError, match="任务"):
|
|
store.save_profile("test-plugin", PluginProfile())
|
|
await runtime.aclose()
|
|
store.save_profile("test-plugin", PluginProfile())
|
|
|
|
|
|
def test_plugin_http_api_redacts_invalid_payload_and_requires_resource_confirmation(
|
|
store, monkeypatch
|
|
):
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
from apps.admin_console.routers import plugins
|
|
|
|
enable(store, manifest())
|
|
monkeypatch.setattr(plugins, "get_store", lambda: store)
|
|
monkeypatch.setattr(DeviceExecutionLock, "get_active_owners", lambda: {})
|
|
app = FastAPI()
|
|
app.include_router(plugins.router)
|
|
with TestClient(app) as client:
|
|
response = client.put(
|
|
"/api/plugins/test-plugin/profiles",
|
|
json={
|
|
"name": "default",
|
|
"secrets": {"token": {"invalid": "DO-NOT-LEAK"}},
|
|
},
|
|
)
|
|
assert response.status_code == 422
|
|
assert "DO-NOT-LEAK" not in response.text
|
|
assert "private-value" not in client.get("/api/plugins").text
|
|
store.block_resources(["desktop:local"], "动作未确认停止")
|
|
assert client.get("/api/plugins").json()["blocked_resources"]
|
|
assert (
|
|
client.post(
|
|
"/api/plugins/resources/confirm-stopped", json={"resource": "desktop:local"}
|
|
).status_code
|
|
== 422
|
|
)
|
|
assert store.blocked_resources()
|
|
assert (
|
|
client.post(
|
|
"/api/plugins/resources/confirm-stopped",
|
|
json={"resource": "desktop:local", "confirmed_stopped": True},
|
|
).status_code
|
|
== 200
|
|
)
|
|
assert store.blocked_resources() == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pause_cleanup_error_does_not_release_resource(tmp_path, monkeypatch):
|
|
from unittest.mock import Mock
|
|
from artemis.runtime import task_control
|
|
|
|
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path)
|
|
lock = Mock()
|
|
control = task_control.TaskControl("cleanup-failed", lock)
|
|
entered = asyncio.Event()
|
|
|
|
async def operation():
|
|
entered.set()
|
|
try:
|
|
await asyncio.sleep(30)
|
|
except asyncio.CancelledError:
|
|
raise RuntimeError("still running remotely") from None
|
|
|
|
future = asyncio.create_task(control.run_interruptibly(operation()))
|
|
await entered.wait()
|
|
task_control.request_pause(control.session_id)
|
|
with pytest.raises(RuntimeError, match="still running"):
|
|
await future
|
|
lock.release.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stdio_pause_yields_resources_and_resume_waits_for_other_task(
|
|
store, tmp_path, monkeypatch
|
|
):
|
|
from artemis.runtime import task_control
|
|
import artemis.runtime.device_lock as locks
|
|
|
|
monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path / "traces")
|
|
monkeypatch.setattr(locks, "get_temp_dir", lambda *_: tmp_path)
|
|
monkeypatch.delenv(DeviceExecutionLock.QUEUE_TICKET_ENV, raising=False)
|
|
started = tmp_path / "started.txt"
|
|
server = tmp_path / "pause_server.py"
|
|
server.write_text(
|
|
f"""
|
|
import asyncio
|
|
from pathlib import Path
|
|
from mcp.server.fastmcp import FastMCP
|
|
server = FastMCP("pause")
|
|
@server.tool()
|
|
async def observe(slow: bool = False) -> str:
|
|
if slow:
|
|
Path({str(started)!r}).write_text("started")
|
|
await asyncio.sleep(60)
|
|
return "fresh observation"
|
|
server.run()
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
enable(store, manifest(server={"command": sys.executable, "args": [str(server)]}))
|
|
runtime = PluginRuntime(store=store)
|
|
first = DeviceExecutionLock(
|
|
"computer-a",
|
|
session_id="paused-plugin",
|
|
resource_ids=runtime.resources,
|
|
concurrency_mode="per_device",
|
|
)
|
|
other = DeviceExecutionLock(
|
|
"computer-b",
|
|
session_id="other-task",
|
|
resource_ids=runtime.resources,
|
|
concurrency_mode="per_device",
|
|
)
|
|
first.acquire(blocking=False)
|
|
control = task_control.TaskControl("paused-plugin", first)
|
|
future = None
|
|
|
|
async def until(predicate):
|
|
async with asyncio.timeout(12):
|
|
while not predicate():
|
|
await asyncio.sleep(0.05)
|
|
|
|
async def park():
|
|
await runtime.aclose(release=False)
|
|
|
|
async def resume():
|
|
await runtime.load("test-plugin")
|
|
|
|
try:
|
|
await runtime.load("test-plugin")
|
|
name = next(iter(runtime.bindings))
|
|
future = asyncio.create_task(
|
|
control.run_interruptibly(
|
|
runtime.call(name, {"slow": True}), on_pause=park, on_resume=resume
|
|
)
|
|
)
|
|
await until(started.exists)
|
|
task_control.request_pause(control.session_id)
|
|
await until(
|
|
lambda: task_control.read_state(control.session_id).get("status") == "task_paused"
|
|
)
|
|
assert not runtime.sessions
|
|
with pytest.raises(ValueError, match="任务"):
|
|
store.assert_idle("test-plugin")
|
|
other.acquire(blocking=False)
|
|
task_control.request_resume(control.session_id)
|
|
await until(lambda: task_control.read_state(control.session_id).get("status") == "resuming")
|
|
assert not future.done()
|
|
other.release()
|
|
assert await asyncio.wait_for(future, timeout=15) == (True, None)
|
|
assert DeviceExecutionLock.get_active_owners()["computer-a"].resource_ids == (
|
|
DeviceExecutionLock.EXECUTION_RESOURCE,
|
|
"desktop:local",
|
|
)
|
|
result = await runtime.call(next(iter(runtime.bindings)), {})
|
|
assert result.status == "success"
|
|
assert result.structured_content == {"result": "fresh observation"}
|
|
finally:
|
|
if future and not future.done():
|
|
future.cancel()
|
|
await asyncio.gather(future, return_exceptions=True)
|
|
await runtime.aclose()
|
|
control.release()
|
|
other.release()
|
|
store.assert_idle("test-plugin")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_computer_observation_and_both_action_pipelines_without_adb(
|
|
store, tmp_path, monkeypatch
|
|
):
|
|
from types import SimpleNamespace
|
|
from unittest.mock import Mock
|
|
from PIL import Image
|
|
from artemis.context import ArtemisContext, DeviceContext, DevicePlatform
|
|
from artemis.drivers.factory import create_driver
|
|
from artemis.mcp.action_executor import McpActionExecutor
|
|
from artemis.agents.validator.execution_loop import _process_action
|
|
from artemis.agents.checker.checker import build_checker_tools
|
|
|
|
picture = io.BytesIO()
|
|
Image.new("RGB", (80, 60), "white").save(picture, format="PNG")
|
|
encoded = base64.b64encode(picture.getvalue()).decode()
|
|
server = tmp_path / "desktop.py"
|
|
server.write_text(
|
|
f"""
|
|
from mcp.server.fastmcp import FastMCP
|
|
from mcp.types import CallToolResult, TextContent, ImageContent
|
|
server = FastMCP("desktop")
|
|
value = "initial"
|
|
@server.tool()
|
|
def list_apps() -> str:
|
|
return "fixture-window"
|
|
@server.tool()
|
|
def get_app_state(app: str) -> CallToolResult:
|
|
return CallToolResult(content=[TextContent(type="text", text=app + ":" + value), ImageContent(type="image", mimeType="image/png", data={encoded!r})])
|
|
@server.tool()
|
|
def set_value(text: str) -> str:
|
|
global value
|
|
value = text
|
|
return value
|
|
server.run()
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
enable(
|
|
store,
|
|
manifest(
|
|
targets=["computer", "mixed"],
|
|
observation={"tool": "list_apps", "refresh_tools": ["get_app_state"]},
|
|
server={"command": sys.executable, "args": [str(server)]},
|
|
),
|
|
)
|
|
ctx = ArtemisContext(
|
|
device=DeviceContext(mobile_platform=DevicePlatform.COMPUTER, device_id="computer:local"),
|
|
execution_target="computer",
|
|
)
|
|
ctx.plugins = PluginRuntime(store=store, ctx=ctx)
|
|
adb = Mock(side_effect=AssertionError("computer tasks cannot create an ADB client"))
|
|
monkeypatch.setattr("artemis.drivers.factory.AdbClient", adb)
|
|
try:
|
|
driver = create_driver(ctx)
|
|
first = await driver.get_screen_data()
|
|
assert first.screenshot_bytes == b"" and "fixture-window" in first.observation_text
|
|
names = {tool.name: name for name, (_, tool) in ctx.plugins.bindings.items()}
|
|
await ctx.plugins.call(names["get_app_state"], {"app": "fixture-window"})
|
|
executor = McpActionExecutor(ctx)
|
|
result = await executor.execute(
|
|
names["set_value"], {"text": "flash-value"}, "flash-call", None
|
|
)
|
|
assert result.status == "success" and result.metadata["plugin_id"] == "test-plugin"
|
|
observed = await driver.get_screen_data()
|
|
assert observed.width == 80 and "flash-value" in observed.observation_text
|
|
node = SimpleNamespace(ctx=ctx)
|
|
outcome = await _process_action(
|
|
node,
|
|
None,
|
|
None,
|
|
{"action": names["set_value"], "args": {"text": "pro-value"}},
|
|
names["set_value"],
|
|
"",
|
|
burst=False,
|
|
)
|
|
assert outcome.success and outcome.action_item["plugin_version"] == "1.0.0"
|
|
observed = await driver.get_screen_data()
|
|
assert "pro-value" in observed.observation_text
|
|
assert "probe_device" not in {tool.name for tool in build_checker_tools(ctx, "final")}
|
|
adb.assert_not_called()
|
|
finally:
|
|
await ctx.plugins.aclose()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("target", ["computer", "mixed", "android"])
|
|
async def test_flash_plugin_images_never_replace_the_mixed_phone_observation(monkeypatch, target):
|
|
from types import SimpleNamespace
|
|
from unittest.mock import Mock, AsyncMock
|
|
from artemis.agents.flash import runner as flash
|
|
from artemis.agents.validator.tool_declarations import ToolExecutionResult
|
|
|
|
name = "plug_test_observe"
|
|
plugins = SimpleNamespace(
|
|
bindings={name: ("test-plugin", None)},
|
|
tool_names={name},
|
|
last_images={"test-plugin": b"desktop-before"},
|
|
previous_image=lambda name, args: b"desktop-before",
|
|
)
|
|
monkeypatch.setattr("artemis.plugins.runtime.get_plugin_runtime", lambda ctx: plugins)
|
|
capture = AsyncMock(return_value=("phone-path", b"phone-after", "phone-tree-after"))
|
|
monkeypatch.setattr(flash, "capture_screenshot_and_parse_ui", capture)
|
|
runner = flash.FlashRunner.__new__(flash.FlashRunner)
|
|
runner.ctx = SimpleNamespace(execution_target=target, data_engine=Mock(), task_control=None)
|
|
runner.controller, runner.summarizer, runner._llm = Mock(), None, None
|
|
runner.executor = SimpleNamespace(
|
|
action_tool_names={name},
|
|
execute=AsyncMock(
|
|
return_value=ToolExecutionResult(
|
|
tool_name=name,
|
|
tool_call_id="tc",
|
|
status="success",
|
|
screenshot_bytes=b"desktop-after",
|
|
ui_elements_text="desktop-tree",
|
|
text_summary="Desktop observation",
|
|
raw_result=[
|
|
{"type": "text", "text": "Desktop observation"},
|
|
{
|
|
"type": "image_url",
|
|
"image_url": {
|
|
"url": "data:image/jpeg;base64,"
|
|
+ base64.b64encode(b"desktop-after").decode()
|
|
},
|
|
},
|
|
],
|
|
metadata={"plugin_id": "test-plugin"},
|
|
)
|
|
),
|
|
)
|
|
runner._build_action_record = Mock(return_value={"action": name})
|
|
runner._record_action_step = Mock(return_value="step")
|
|
state = SimpleNamespace(latest_screenshot="old-phone-path")
|
|
messages = []
|
|
result = await runner._execute_and_record_action(
|
|
name,
|
|
{},
|
|
"tc",
|
|
state,
|
|
messages,
|
|
"",
|
|
{},
|
|
b"phone-before",
|
|
"phone-tree-before",
|
|
0,
|
|
flash._TurnRecord(),
|
|
)
|
|
assert runner._record_action_step.call_args.args[5:8] == (
|
|
b"desktop-before",
|
|
"",
|
|
b"desktop-after",
|
|
)
|
|
assert messages[0].tool_call_id == "tc"
|
|
assert "Desktop observation" in str(messages[0].content)
|
|
assert base64.b64encode(b"desktop-after").decode() in str(messages)
|
|
if target != "computer":
|
|
assert result[0] is None
|
|
assert "安卓旧截图、元素编号和坐标已失效" in result[1]
|
|
assert state.latest_screenshot is None
|
|
assert state.indexed_elements == []
|
|
assert state.indexed_points == []
|
|
assert runner._android_observation_stale
|
|
capture.assert_not_awaited()
|
|
else:
|
|
assert result[:2] == (b"desktop-after", "desktop-tree")
|
|
capture.assert_not_awaited()
|
|
|
|
|
|
def test_named_default_profile_is_used_only_when_authorized(store):
|
|
enable(store, manifest())
|
|
store.save_profile(
|
|
"test-plugin",
|
|
PluginProfile(
|
|
name="lab", is_default=True, authorized=True, secrets={"token": "lab-secret"}
|
|
),
|
|
)
|
|
assert store.selections()[0].profile == "lab"
|
|
store.save_profile("test-plugin", PluginProfile(name="lab", is_default=True, authorized=False))
|
|
assert store.selections() == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_streamable_http_timeout_protects_resources_and_never_replays(
|
|
store, tmp_path, monkeypatch
|
|
):
|
|
import socket
|
|
import subprocess
|
|
from artemis.config.settings import settings
|
|
|
|
with socket.socket() as available:
|
|
available.bind(("127.0.0.1", 0))
|
|
port = available.getsockname()[1]
|
|
counter = tmp_path / "writes.txt"
|
|
script = tmp_path / "http_mcp.py"
|
|
script.write_text(
|
|
f"""
|
|
import asyncio
|
|
from pathlib import Path
|
|
from mcp.server.fastmcp import FastMCP
|
|
server = FastMCP("http-test", host="127.0.0.1", port={port})
|
|
@server.tool()
|
|
async def write_once() -> str:
|
|
with Path({str(counter)!r}).open("a") as output: output.write("write\\n")
|
|
await asyncio.sleep(30)
|
|
return "written"
|
|
server.run(transport="streamable-http")
|
|
""",
|
|
encoding="utf-8",
|
|
)
|
|
process = subprocess.Popen(
|
|
[sys.executable, str(script)],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
|
)
|
|
runtime = None
|
|
try:
|
|
for _ in range(100):
|
|
with socket.socket() as probe:
|
|
if probe.connect_ex(("127.0.0.1", port)) == 0:
|
|
break
|
|
assert process.poll() is None
|
|
await asyncio.sleep(0.05)
|
|
else:
|
|
pytest.fail("local MCP fixture did not start")
|
|
enable(
|
|
store,
|
|
manifest(
|
|
server={"transport": "streamable_http", "url": f"http://127.0.0.1:{port}/mcp"}
|
|
),
|
|
)
|
|
runtime = PluginRuntime(store=store)
|
|
await runtime.load("test-plugin")
|
|
monkeypatch.setattr(settings, "ARTEMIS_PLUGIN_TOOL_TIMEOUT_SECONDS", 0.2)
|
|
name = next(iter(runtime.bindings))
|
|
result = await asyncio.wait_for(runtime.call(name, {}), timeout=10)
|
|
assert result.status == "uncertain"
|
|
assert "desktop:local" in store.blocked_resources()
|
|
assert counter.read_text().splitlines() == ["write"]
|
|
with pytest.raises(RuntimeError, match="资源"):
|
|
await runtime.load("test-plugin")
|
|
assert counter.read_text().splitlines() == ["write"]
|
|
finally:
|
|
if runtime:
|
|
await runtime.aclose()
|
|
process.terminate()
|
|
process.wait(timeout=10)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_computer_only_plugin_diagnosis_uses_its_declared_target(store, monkeypatch):
|
|
from apps.admin_console.routers import plugins
|
|
|
|
enable(store, manifest(targets=["computer"], observation={"tool": "observe"}))
|
|
monkeypatch.setattr(plugins, "get_store", lambda: store)
|
|
result = await plugins.diagnose_plugin("test-plugin", plugins.DiagnoseRequest())
|
|
assert result["ok"] is True
|
|
store.assert_idle("test-plugin")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_workspace_computer_discovery_never_queries_adb(monkeypatch):
|
|
from mcp_server.tools import automation
|
|
|
|
routes = []
|
|
|
|
async def request(method, route):
|
|
routes.append(route)
|
|
assert route != "/api/devices"
|
|
return {"plugins": [{"id": "computer"}]} if route == "/api/plugins" else {}
|
|
|
|
monkeypatch.setattr(automation, "_request", request)
|
|
result = await automation.mobile_workspace(include_devices=False)
|
|
assert result["devices"] == []
|
|
assert result["plugins"] == [{"id": "computer"}]
|
|
assert set(routes) == {"/api/status", "/api/plugins"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_computer_task_uses_the_actual_host_platform():
|
|
from artemis.platform import platform
|
|
from artemis.sdk.agent import Agent
|
|
|
|
agent = Agent()
|
|
await agent.init(execution_target="computer")
|
|
assert agent._device_context.host_platform == platform.os_type.name
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sdk_setup_failure_releases_plugin_version_pin(store, monkeypatch):
|
|
from artemis.sdk.agent import Agent
|
|
import artemis.plugins.runtime as runtime_module
|
|
|
|
enable(store, manifest(targets=["computer"], observation={"tool": "observe"}))
|
|
monkeypatch.setattr(runtime_module, "PluginStore", lambda: store)
|
|
monkeypatch.setattr(DeviceExecutionLock, "get_active_owner", lambda _: None)
|
|
|
|
def fail_before_tracing(*args, **kwargs):
|
|
raise ValueError("cannot initialize lock")
|
|
|
|
monkeypatch.setattr("artemis.sdk.agent.DeviceExecutionLock", fail_before_tracing)
|
|
agent = Agent()
|
|
await agent.init(execution_target="computer")
|
|
request = agent.new_task("fixture").with_plugins([PluginSelection(id="test-plugin")]).build()
|
|
# Fail while constructing the lock, before device/trace initialization.
|
|
fail_before_tracing.get_active_owner = lambda _: None
|
|
with pytest.raises(ValueError, match="initialize lock"):
|
|
await agent.run_task(request=request)
|
|
store.assert_idle("test-plugin")
|
|
|
|
|
|
def test_plugin_screenshots_export_with_source_and_without_phone_substitution(
|
|
tmp_path, monkeypatch
|
|
):
|
|
from apps.admin_console.services import report_export
|
|
|
|
phone, desktop = tmp_path / "phone.png", tmp_path / "desktop.png"
|
|
phone.write_bytes(b"phone")
|
|
desktop.write_bytes(b"desktop")
|
|
monkeypatch.setattr(
|
|
report_export, "image_path", lambda name: {"phone": phone, "desktop": desktop}.get(name)
|
|
)
|
|
evidence = {"plugin_id": "desktop-control", "image_names": ["desktop"]}
|
|
report = {
|
|
"steps": [
|
|
{
|
|
"step_number": 1,
|
|
"pre_image_name": "phone",
|
|
"generic_tools": [{"payload": evidence}, {"payload": json.dumps(evidence)}],
|
|
}
|
|
]
|
|
}
|
|
images = report_export.session_screenshots(report)
|
|
assert [path for path, _ in images] == [phone, desktop]
|
|
assert "插件_desktop-control" in images[1][1]
|
|
|
|
|
|
def test_plugin_images_live_until_the_last_referencing_task_is_deleted(tmp_path):
|
|
from uuid import uuid4
|
|
from artemis.data_engine.storage import StorageManager
|
|
|
|
storage = StorageManager(tmp_path / "history.db", tmp_path)
|
|
name = "f" * 64
|
|
image = tmp_path / "images" / f"{name}.jpg"
|
|
image.parent.mkdir()
|
|
image.write_bytes(b"plugin image")
|
|
first, second = uuid4(), uuid4()
|
|
storage.reference_plugin_image(first, name)
|
|
storage.reference_plugin_image(second, name)
|
|
storage.delete_session(first)
|
|
assert image.read_bytes() == b"plugin image"
|
|
storage.delete_session(second)
|
|
assert not image.exists()
|
|
|
|
|
|
def test_plugin_operation_export_retains_source_and_failure_evidence():
|
|
from apps.admin_console.services.report_export import plugin_operations
|
|
from apps.admin_console.database.repositories.step_repository import StepRepository
|
|
|
|
operations = plugin_operations(
|
|
[
|
|
{
|
|
"step_number": 3,
|
|
"generic_tools": [
|
|
{
|
|
"name": "plug_click",
|
|
"status": "failed",
|
|
"payload": {
|
|
"plugin_id": "desktop",
|
|
"plugin_version": "1.0.0",
|
|
"resource_ids": ["desktop:local"],
|
|
"error": "窗口已关闭",
|
|
"post_image_name": "evidence",
|
|
},
|
|
},
|
|
],
|
|
}
|
|
]
|
|
)
|
|
trace = StepRepository()._normalize_display_trace(
|
|
{
|
|
"name": "plug_click",
|
|
"type": "tool",
|
|
"status": "failed",
|
|
"payload": {
|
|
"plugin_id": "desktop",
|
|
"plugin_version": "1.0.0",
|
|
"resource_ids": ["desktop:local"],
|
|
"post_image_name": "evidence",
|
|
"image_names": ["evidence"],
|
|
"error": "窗口已关闭",
|
|
},
|
|
}
|
|
)
|
|
assert trace["payload"]["post_image_name"] == "evidence"
|
|
assert plugin_operations([{"step_number": 3, "generic_tools": [trace]}]) == operations
|
|
assert operations == [
|
|
{
|
|
"step": 3,
|
|
"plugin": "desktop",
|
|
"version": "1.0.0",
|
|
"tool": "plug_click",
|
|
"resources": "desktop:local",
|
|
"status": "failed",
|
|
"content": "窗口已关闭",
|
|
}
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pro_text_only_observation_never_sends_an_empty_image():
|
|
from artemis.agents.operator.prompts import ObservationPromptComponent, PromptBuilder
|
|
|
|
builder = PromptBuilder()
|
|
await ObservationPromptComponent()(
|
|
builder, None, None, latest_screenshot_b64="", minimal_list="fixture window"
|
|
)
|
|
assert all(
|
|
not isinstance(part, dict) or part.get("type") != "image_url"
|
|
for part in builder.human_parts
|
|
)
|
|
assert any("fixture window" in part for part in builder.human_parts if isinstance(part, str))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_plugin_image_text_stays_with_its_own_observation(store, tmp_path, monkeypatch):
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock
|
|
from PIL import Image
|
|
from artemis.context import ArtemisContext
|
|
from artemis.data_engine.engine import DataEngine
|
|
from artemis.plugins.models import PluginCallResult
|
|
|
|
monkeypatch.setattr(DataEngine, "_ipc_port_candidates", staticmethod(lambda: []))
|
|
monkeypatch.setattr("artemis.data_engine.engine.PAUSE_FILE", tmp_path / "pause")
|
|
ctx = MagicMock(spec=ArtemisContext)
|
|
ctx.execution_setup = SimpleNamespace(traces_path=str(tmp_path / "traces"))
|
|
ctx.device = None
|
|
engine = DataEngine(ctx)
|
|
engine.start_session("plugin image provenance")
|
|
runtime = PluginRuntime([], store=store, ctx=SimpleNamespace(data_engine=engine))
|
|
images = []
|
|
names = []
|
|
try:
|
|
for text, color in (("baseline", "white"), ("changed", "green")):
|
|
buffer = io.BytesIO()
|
|
Image.new("RGB", (40, 40), color).save(buffer, format="PNG")
|
|
images.append(buffer.getvalue())
|
|
outcome = PluginCallResult(
|
|
status="success",
|
|
content=[
|
|
{"type": "text", "text": text},
|
|
{"type": "image", "data": base64.b64encode(images[-1]).decode()},
|
|
],
|
|
)
|
|
runtime._persist_images(outcome)
|
|
names.extend(outcome.image_names)
|
|
engine.record_step(
|
|
pre_screenshot_bytes=images[0],
|
|
post_screenshot_bytes=images[1],
|
|
ui_tree="changed",
|
|
)
|
|
await engine.shutdown()
|
|
assert engine.storage.get_image(names[0]).ui_tree == "baseline"
|
|
assert engine.storage.get_image(names[1]).ui_tree == "changed"
|
|
finally:
|
|
await runtime.aclose()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_long_plugin_result_roundtrips_through_task_scoped_reader(store, tmp_path):
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
from mcp.types import Tool, ToolAnnotations
|
|
from artemis.config.constants import PLUGIN_PROMPT_TEXT_CHARS
|
|
|
|
plugin = manifest()
|
|
enable(store, plugin)
|
|
engine = MagicMock(base_dir=tmp_path / "task-a")
|
|
ctx = SimpleNamespace(data_engine=engine)
|
|
runtime = PluginRuntime(store=store, ctx=ctx)
|
|
tool = Tool(
|
|
name="observe",
|
|
inputSchema={"type": "object"},
|
|
annotations=ToolAnnotations(readOnlyHint=True),
|
|
)
|
|
original = "private-value\n" + "node 中文\n" * 9000 + "END"
|
|
runtime.bindings["plug_test_observe"] = (plugin.id, tool)
|
|
session = SimpleNamespace(
|
|
call=AsyncMock(
|
|
return_value=CallToolResult(content=[TextContent(type="text", text=original)])
|
|
)
|
|
)
|
|
runtime.sessions[plugin.id] = session
|
|
try:
|
|
outcome = await runtime.call("plug_test_observe", {})
|
|
assert outcome.status == "success"
|
|
assert outcome.text_ref == outcome.call_id
|
|
assert runtime.is_read_only("plug_test_observe")
|
|
shown = runtime.content_blocks(outcome)[0]["text"]
|
|
assert len(shown) <= PLUGIN_PROMPT_TEXT_CHARS
|
|
assert outcome.text_ref in shown
|
|
text = outcome.text()
|
|
pages = []
|
|
for offset in range(0, len(text), PLUGIN_PROMPT_TEXT_CHARS):
|
|
page = await runtime.call(
|
|
"plugin_read_result", {"call_id": outcome.text_ref, "offset": offset}
|
|
)
|
|
assert page.status == "success"
|
|
delivered = runtime.content_blocks(page)[0]["text"]
|
|
pages.append(delivered.split("\n", 1)[1])
|
|
assert "".join(pages) == text
|
|
assert "private-value" not in text and "已隐藏密钥" in text
|
|
assert text.endswith("END")
|
|
engine.base_dir = tmp_path / "task-b"
|
|
inaccessible = await runtime.call("plugin_read_result", {"call_id": outcome.text_ref})
|
|
assert inaccessible.status == "error"
|
|
session.call.assert_awaited_once()
|
|
finally:
|
|
runtime.sessions.clear()
|
|
await runtime.aclose()
|