feat(plugins): integrate portable Agent Plugins

This commit is contained in:
Xubin Ren
2026-08-11 20:16:24 +09:00
parent 57d81bc1cd
commit d5e0df6963
23 changed files with 1111 additions and 110 deletions
+215
View File
@@ -0,0 +1,215 @@
import json
import shutil
from pathlib import Path
import pytest
from nanobot.agent import plugins as agent_plugins
from nanobot.agent.plugins import (
AGENT_PLUGIN_MCP_SCHEMA,
AGENT_PLUGIN_SCHEMA,
agent_plugin_mcp_servers,
discover_agent_plugins,
enabled_agent_plugin_skills,
set_agent_plugin_enabled,
)
from nanobot.agent.skills import SkillsLoader
@pytest.fixture(autouse=True)
def _isolate_plugin_state(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
agent_plugins, "get_config_path", lambda: tmp_path / "config" / "config.json"
)
def _write_json(path: Path, value: object) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(value), encoding="utf-8")
def _manifest(name: str, **fields: object) -> dict[str, object]:
return {"$schema": AGENT_PLUGIN_SCHEMA, "name": name, **fields}
def _plugin(workspace: Path, name: str = "demo", **fields: object) -> Path:
root = workspace / "plugins" / name
_write_json(root / "plugin.json", _manifest(name, **fields))
return root
def _skill(root: Path, name: str, frontmatter: str | None = None, body: str = "") -> Path:
path = root / name
path.mkdir(parents=True)
metadata = frontmatter or f"name: {name}\ndescription: Plugin skill."
(path / "SKILL.md").write_text(f"---\n{metadata}\n---\n\n{body}\n", encoding="utf-8")
return path
def _loaded_skills(workspace: Path) -> list[str]:
return [name for name, _ in enabled_agent_plugin_skills(workspace)]
def test_plugin_skill_lifecycle_and_precedence(tmp_path: Path) -> None:
plugin = _plugin(tmp_path)
_skill(
plugin / "skills",
"shared",
"name: shared\ndescription: Plugin version.\nalways: true",
"Plugin body.",
)
_skill(tmp_path / "builtin", "shared", body="Built-in body.")
workspace_skill = _skill(
tmp_path / "skills", "shared", "name: shared\ndescription: Workspace version."
)
loader = SkillsLoader(tmp_path, builtin_skills_dir=tmp_path / "builtin")
assert [entry["source"] for entry in loader.list_skills()] == ["workspace"]
assert "Workspace version" in (loader.load_skill("shared") or "")
set_agent_plugin_enabled(tmp_path, "demo", True)
assert [entry["source"] for entry in loader.list_skills()] == ["workspace"]
shutil.rmtree(workspace_skill)
assert [entry["source"] for entry in loader.list_skills()] == ["plugin"]
assert loader.get_explicitly_invoked_skills("Use $shared") == ["shared"]
assert loader.get_always_skills() == ["shared"]
assert "Plugin body" in (loader.load_skill("shared") or "")
assert "`demo/skills/shared/SKILL.md`" in loader.build_skills_summary()
set_agent_plugin_enabled(tmp_path, "demo", False)
assert [entry["source"] for entry in loader.list_skills()] == ["builtin"]
assert "Built-in body" in (loader.load_skill("shared") or "")
def test_plugin_skills_are_direct_valid_and_contained(tmp_path: Path) -> None:
plugin = _plugin(tmp_path)
skills = plugin / "skills"
_skill(skills, "direct")
_skill(skills / "group", "nested")
for name, frontmatter in (
("wrong-directory", "name: another\ndescription: Mismatch."),
("missing-description", "name: missing-description"),
("Bad-Name", "name: Bad-Name\ndescription: Invalid name."),
):
_skill(skills, name, frontmatter)
outside = _skill(tmp_path / "outside", "escaped")
try:
(skills / "escaped").symlink_to(outside, target_is_directory=True)
except OSError:
pass
set_agent_plugin_enabled(tmp_path, "demo", True)
assert _loaded_skills(tmp_path) == ["direct"]
@pytest.mark.parametrize(
("manifest", "valid"),
[
({"$schema": "https://agent-plugins.org/schemas/2.0.0/plugin.schema.json", "name": "demo"}, False),
(_manifest("Bad-Name"), False),
(_manifest("demo", futureField=True, extensions="invalid but non-fatal"), True),
],
)
def test_plugin_manifest_boundary(tmp_path: Path, manifest: object, valid: bool) -> None:
_write_json(tmp_path / "plugins" / "candidate" / "plugin.json", manifest)
assert bool(discover_agent_plugins(tmp_path)) is valid
def test_plugin_logo_is_validated_and_contained(tmp_path: Path) -> None:
extension = {"extensions": {"dev.nanobot": {"logo": "./assets/icon.png"}}}
plugin = _plugin(tmp_path, "demo", **extension)
icon = plugin / "assets" / "icon.png"
icon.parent.mkdir()
icon.write_bytes(b"\x89PNG\r\n\x1a\nlogo")
escaped = _plugin(tmp_path, "escaped", **extension)
(escaped / "assets").mkdir()
try:
(escaped / "assets" / "icon.png").symlink_to(icon)
except OSError:
pass
assert {plugin.name: plugin.logo for plugin in discover_agent_plugins(tmp_path)} == {
"demo": "data:image/png;base64,iVBORw0KGgpsb2dv",
"escaped": None,
}
def test_plugin_mcp_requires_explicit_enable(tmp_path: Path) -> None:
plugin = _plugin(tmp_path, "desktop")
executable = plugin / "bin" / "server"
executable.parent.mkdir()
executable.write_text("#!/bin/sh\n", encoding="utf-8")
_write_json(
plugin / "mcp.json",
{
"$schema": AGENT_PLUGIN_MCP_SCHEMA,
"mcpServers": {
"desktop": {
"type": "stdio",
"command": "./bin/server",
"args": ["--data", "${PLUGIN_DATA}/state"],
"cwd": "${PLUGIN_ROOT}",
},
"public-http": {"type": "streamable-http", "url": "http://example.com/mcp"},
"escape": {"type": "stdio", "command": "../outside"},
},
},
)
assert agent_plugin_mcp_servers(tmp_path) == {}
set_agent_plugin_enabled(tmp_path, "desktop", True)
server = agent_plugin_mcp_servers(tmp_path)["desktop"]
assert (server.command, server.cwd, server.env["PLUGIN_ROOT"]) == (
str(executable),
str(plugin),
str(plugin),
)
assert server.args[1].endswith("/state")
set_agent_plugin_enabled(tmp_path, "desktop", False)
assert agent_plugin_mcp_servers(tmp_path) == {}
def test_plugin_state_symlink_cannot_escape_config_root(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
config = tmp_path / "config"
config.mkdir()
outside = tmp_path / "outside"
outside.mkdir()
try:
(config / "plugin-data").symlink_to(outside, target_is_directory=True)
except OSError as exc:
pytest.skip(f"directory symlink unavailable: {exc}")
monkeypatch.setattr(agent_plugins, "get_config_path", lambda: config / "config.json")
_plugin(tmp_path, "desktop")
with pytest.raises(RuntimeError, match="escapes its parent"):
set_agent_plugin_enabled(tmp_path, "desktop", True)
def test_plugin_activation_requires_one_stable_package_identity(tmp_path: Path) -> None:
roots = [tmp_path / "plugins" / directory for directory in ("first", "second")]
for root, marker in zip(roots, ("trusted", "replacement"), strict=True):
_write_json(root / "plugin.json", _manifest("duplicate"))
_write_json(
root / "mcp.json",
{
"$schema": AGENT_PLUGIN_MCP_SCHEMA,
"mcpServers": {
"server": {"type": "stdio", "command": "echo", "args": [marker]}
},
},
)
assert discover_agent_plugins(tmp_path) == []
with pytest.raises(ValueError, match="unknown Agent Plugin"):
set_agent_plugin_enabled(tmp_path, "duplicate", True)
shutil.rmtree(roots[1])
set_agent_plugin_enabled(tmp_path, "duplicate", True)
assert discover_agent_plugins(tmp_path)[0].enabled is True
moved = tmp_path / "plugins" / "moved"
roots[0].rename(moved)
assert discover_agent_plugins(tmp_path)[0].enabled is False
assert agent_plugin_mcp_servers(tmp_path) == {}
+1
View File
@@ -266,6 +266,7 @@ def test_disabled_skills_excluded_from_list(tmp_path: Path) -> None:
assert len(entries) == 1
assert entries[0]["name"] == "beta"
assert entries[0]["path"] == str(beta_path)
assert loader.load_skill("alpha") is None
def test_disabled_skills_empty_set_no_effect(tmp_path: Path) -> None:
+66 -6
View File
@@ -9,9 +9,16 @@ from types import SimpleNamespace
import pytest
from nanobot.agent import plugins as agent_plugins
from nanobot.agent.skills import SkillsLoader
from nanobot.apps.cli.service import CliAppError, CliAppManager, CliAppsRuntimeConfig
@pytest.fixture(autouse=True)
def _isolate_plugin_state(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(agent_plugins, "get_config_path", lambda: tmp_path / "config/config.json")
def _write_cache(path: Path, registry: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
@@ -391,6 +398,9 @@ def test_install_dispatches_safe_pip_and_installs_skill(
"_fetch_skill_content",
lambda app: "---\nname: cli-anything-gimp\ndescription: GIMP\n---\n# GIMP\n",
)
legacy = manager.workspace / "skills" / "cli-app-gimp" / "SKILL.md"
legacy.parent.mkdir(parents=True)
legacy.write_text("legacy", encoding="utf-8")
payload = manager.install("gimp")
@@ -400,9 +410,15 @@ def test_install_dispatches_safe_pip_and_installs_skill(
assert "state_recorded" in payload["last_action"]["verification"]
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
assert installed["gimp"]["entry_point"] == "cli-anything-gimp"
skill = manager.workspace / "skills" / "cli-app-gimp" / "SKILL.md"
plugin = manager.workspace / "plugins" / "cli-app-gimp"
skill = plugin / "skills" / "cli-app-gimp" / "SKILL.md"
assert skill.is_file()
manifest = json.loads((plugin / "plugin.json").read_text(encoding="utf-8"))
assert (manifest["name"], manifest["version"]) == ("cli-app-gimp", "1.0.0")
assert "name: cli-app-gimp" in skill.read_text(encoding="utf-8")
assert 'run_cli_app` tool with `name="gimp"' in skill.read_text(encoding="utf-8")
assert SkillsLoader(manager.workspace).load_skill("cli-app-gimp") is not None
assert not legacy.exists()
def test_run_argv_logs_command_exit_and_output(
@@ -487,7 +503,7 @@ def test_install_records_available_cli_without_reinstalling(
assert "entry_point_available" in payload["last_action"]["verification"]
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
assert installed["feishu"]["entry_point_path"] == str(resolved)
skill = manager.workspace / "skills" / "cli-app-feishu" / "SKILL.md"
skill = manager.workspace / "plugins/cli-app-feishu/skills/cli-app-feishu/SKILL.md"
assert skill.is_file()
assert 'run_cli_app` tool with `name="feishu"' in skill.read_text(encoding="utf-8")
@@ -704,7 +720,8 @@ def test_uninstall_removes_installed_state_and_generated_skill(
manager = _manager(tmp_path)
_seed_catalog(manager)
manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}})
skill_dir = manager.workspace / "skills" / "cli-app-gimp"
plugin_dir = manager.workspace / "plugins" / "cli-app-gimp"
skill_dir = plugin_dir / "skills" / "cli-app-gimp"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text("# GIMP\n", encoding="utf-8")
monkeypatch.setattr(
@@ -717,7 +734,7 @@ def test_uninstall_removes_installed_state_and_generated_skill(
assert payload["last_action"]["ok"] is True
assert "gimp" not in json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
assert not skill_dir.exists()
assert not plugin_dir.exists()
def test_uninstall_uses_safe_python_m_pip_uninstall_command(
@@ -845,19 +862,62 @@ def test_mentioned_installed_apps_only_returns_installed_mentions(tmp_path: Path
"name": "zoom",
"entry_point": "cli-anything-zoom",
"source": "public",
"skill": "skills/cli-app-zoom/SKILL.md",
"skill": "plugins/cli-app-zoom/skills/cli-app-zoom/SKILL.md",
"tool": "run_cli_app",
},
{
"name": "gimp",
"entry_point": "cli-anything-gimp",
"source": "harness",
"skill": "skills/cli-app-gimp/SKILL.md",
"skill": "plugins/cli-app-gimp/skills/cli-app-gimp/SKILL.md",
"tool": "run_cli_app",
},
]
def test_remove_skill_cleans_legacy_underscored_name(tmp_path: Path) -> None:
manager = _manager(tmp_path)
legacy = manager.workspace / "skills" / "cli-app-unimol_tools" / "SKILL.md"
legacy.parent.mkdir(parents=True)
legacy.write_text("# Legacy Uni-Mol\n", encoding="utf-8")
manager.remove_skill("unimol_tools")
assert not legacy.exists()
def test_migrated_cli_app_skill_keeps_legacy_identity_alias(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
data_dir = tmp_path / "data"
monkeypatch.setattr(
"nanobot.apps.cli.service.get_runtime_subdir",
lambda _name: data_dir,
)
workspace = tmp_path / "workspace"
workspace.mkdir()
manager = CliAppManager(workspace=workspace)
manager._save_installed({"unimol_tools": {"entry_point": "unimol-tools"}})
manager.install_skill({
"name": "unimol_tools",
"display_name": "Uni-Mol Tools",
"entry_point": "unimol-tools",
})
agent_plugins.set_agent_plugin_enabled(workspace, "cli-app-unimol-tools", True)
loader = SkillsLoader(workspace)
assert loader.get_explicitly_invoked_skills("Use $cli-app-unimol_tools") == [
"cli-app-unimol-tools"
]
assert loader.load_skill("cli-app-unimol_tools") is not None
disabled = SkillsLoader(workspace, disabled_skills={"cli-app-unimol_tools"})
assert "cli-app-unimol-tools" not in {
skill["name"] for skill in disabled.list_skills(filter_unavailable=False)
}
def test_install_rejects_unknown_and_script_strategy(tmp_path: Path) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
+11 -9
View File
@@ -38,24 +38,26 @@ def test_cli_app_mentions_inject_runtime_metadata(tmp_path, monkeypatch):
assert "CLI App Mention: @zoom" in joined
assert "tool=run_cli_app" in joined
assert "entry_point=cli-anything-zoom" in joined
assert "skill=skills/cli-app-zoom/SKILL.md" in joined
assert "skill=plugins/cli-app-zoom/skills/cli-app-zoom/SKILL.md" in joined
def test_structured_cli_app_attachment_injects_runtime_metadata(tmp_path):
def test_structured_cli_app_attachment_uses_existing_legacy_skill(tmp_path):
legacy = tmp_path / "skills" / "cli-app-unimol_tools" / "SKILL.md"
legacy.parent.mkdir(parents=True)
legacy.write_text("# Legacy Uni-Mol\n", encoding="utf-8")
lines = runtime_lines_for_request(
"please use @zoom tonight",
"please use @unimol_tools",
{
"cli_apps": [{
"name": "zoom",
"entry_point": "cli-anything-zoom",
"display_name": "Zoom",
"name": "unimol_tools",
"entry_point": "cli-anything-unimol-tools",
}],
},
tmp_path,
)
joined = "\n".join(lines)
assert "CLI App Attachment: @zoom" in joined
assert "CLI App Attachment: @unimol_tools" in joined
assert "tool=run_cli_app" in joined
assert "entry_point=cli-anything-zoom" in joined
assert "skill=skills/cli-app-zoom/SKILL.md" in joined
assert "entry_point=cli-anything-unimol-tools" in joined
assert "skill=skills/cli-app-unimol_tools/SKILL.md" in joined
+86 -1
View File
@@ -1,10 +1,14 @@
from __future__ import annotations
import asyncio
import json
from functools import partial
from pathlib import Path
import pytest
from mcp.shared.auth import OAuthToken
from nanobot.agent.plugins import AGENT_PLUGIN_MCP_SCHEMA, AGENT_PLUGIN_SCHEMA
from nanobot.agent.tools.mcp_oauth import MCPOAuthStorage, mcp_oauth_has_credentials
from nanobot.config.loader import load_config
from nanobot.webui.mcp_presets_api import (
@@ -12,13 +16,48 @@ from nanobot.webui.mcp_presets_api import (
custom_mcp_action,
mcp_presets_action,
mcp_presets_payload,
mcp_presets_settings_action,
mcp_presets_test_action,
normalize_mcp_preset_mentions,
)
def _use_config(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("nanobot.config.loader._current_config_path", tmp_path / "config.json")
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps({"agents": {"defaults": {"workspace": str(tmp_path / "workspace")}}}),
encoding="utf-8",
)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
def _write_agent_plugin(workspace: Path) -> None:
root = workspace / "plugins" / "desktop"
root.mkdir(parents=True)
for filename, payload in (
(
"plugin.json",
{
"$schema": AGENT_PLUGIN_SCHEMA,
"name": "desktop",
"description": "Control the local desktop.",
"extensions": {
"dev.nanobot": {
"displayName": "Desktop Control",
"permissions": ["screen-recording"],
}
},
},
),
(
"mcp.json",
{
"$schema": AGENT_PLUGIN_MCP_SCHEMA,
"mcpServers": {"desktop": {"type": "stdio", "command": "echo"}},
},
),
):
(root / filename).write_text(json.dumps(payload), encoding="utf-8")
def test_mcp_presets_payload_lists_supported_cards(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
@@ -60,6 +99,52 @@ def test_mcp_presets_payload_lists_supported_cards(tmp_path, monkeypatch: pytest
assert manifest["trust"]["review_status"] == "builtin_preset"
def test_agent_plugin_reuses_mcp_catalog_and_runtime_action(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_use_config(tmp_path, monkeypatch)
_write_agent_plugin(load_config().workspace_path)
row = next(item for item in mcp_presets_payload()["presets"] if item["source"] == "agent-plugin")
assert (row["name"], row["display_name"], row["requires"]) == (
"plugin-desktop", "Desktop Control", "screen-recording"
)
assert row["installed"] and row["configured"] and not row["enabled"]
async def reload() -> dict[str, object]:
return {"ok": True, "message": "MCP reloaded.", "requires_restart": False}
plugin_action = partial(
mcp_presets_settings_action,
query={"name": ["plugin-desktop"]},
)
enabled = asyncio.run(plugin_action("enable", reload_mcp=reload))
enabled_row = next(item for item in enabled["presets"] if item["name"] == "plugin-desktop")
assert (enabled_row["enabled"], enabled_row["status"], enabled["requires_restart"]) == (
True, "enabled", False
)
disabled = asyncio.run(plugin_action("disable", reload_mcp=reload))
disabled_row = next(item for item in disabled["presets"] if item["name"] == "plugin-desktop")
assert (disabled_row["installed"], disabled_row["enabled"], disabled_row["status"]) == (
True, False, "disabled"
)
with pytest.raises(McpPresetError, match="enable and disable"):
asyncio.run(plugin_action("remove"))
(load_config().workspace_path / "plugins" / "desktop" / "mcp.json").unlink()
assert any(item["name"] == "plugin-desktop" for item in mcp_presets_payload()["presets"])
config_path = tmp_path / "config.json"
config = json.loads(config_path.read_text(encoding="utf-8"))
config["tools"] = {"mcpServers": {"plugin-desktop": {"type": "stdio", "command": "echo"}}}
config_path.write_text(json.dumps(config), encoding="utf-8")
rows = [item for item in mcp_presets_payload()["presets"] if item["name"] == "plugin-desktop"]
assert len(rows) == 1 and rows[0]["source"] == "custom"
@pytest.mark.asyncio
async def test_oauth_preset_is_one_click_configured_after_token_storage(
tmp_path,