mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-12 23:29:16 +03:00
371 lines
13 KiB
Python
371 lines
13 KiB
Python
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_skill_dirs,
|
|
enabled_agent_plugin_skills,
|
|
set_agent_plugin_enabled,
|
|
)
|
|
from nanobot.agent.skills import SkillsLoader
|
|
from nanobot.agent.tools.context import ToolContext
|
|
from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool
|
|
from nanobot.config.schema import ToolsConfig
|
|
from nanobot.security.workspace_access import (
|
|
bind_workspace_scope,
|
|
reset_workspace_scope,
|
|
validate_workspace_scope_payload,
|
|
)
|
|
|
|
|
|
@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_mcp_namespaces_cannot_shadow_plugin_identities(tmp_path: Path) -> None:
|
|
single = _plugin(tmp_path, "foo-bar")
|
|
multi = _plugin(tmp_path, "foo")
|
|
for root, servers in (
|
|
(single, {"main": {"type": "stdio", "command": "echo", "args": ["single"]}}),
|
|
(
|
|
multi,
|
|
{
|
|
"bar": {"type": "stdio", "command": "echo", "args": ["multi"]},
|
|
"other": {"type": "stdio", "command": "echo"},
|
|
},
|
|
),
|
|
):
|
|
_write_json(
|
|
root / "mcp.json",
|
|
{"$schema": AGENT_PLUGIN_MCP_SCHEMA, "mcpServers": servers},
|
|
)
|
|
set_agent_plugin_enabled(tmp_path, "foo-bar", True)
|
|
set_agent_plugin_enabled(tmp_path, "foo", True)
|
|
|
|
servers = agent_plugin_mcp_servers(tmp_path)
|
|
|
|
assert set(servers) == {"foo-bar", "foo--bar", "foo--other"}
|
|
assert servers["foo-bar"].args == ["single"]
|
|
assert servers["foo--bar"].args == ["multi"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_restricted_project_can_read_only_enabled_plugin_skill(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
agent_workspace = tmp_path / "agent"
|
|
project = tmp_path / "project"
|
|
project.mkdir()
|
|
plugin = _plugin(agent_workspace)
|
|
skill = _skill(plugin / "skills", "demo-skill")
|
|
resource = skill / "reference.md"
|
|
resource.write_text("plugin reference", encoding="utf-8")
|
|
ctx = ToolContext(
|
|
config=ToolsConfig(restrict_to_workspace=True),
|
|
workspace=str(agent_workspace),
|
|
)
|
|
read_tool = ReadFileTool.create(ctx)
|
|
write_tool = WriteFileTool.create(ctx)
|
|
set_agent_plugin_enabled(agent_workspace, "demo", True)
|
|
activation_checks = 0
|
|
activation_marker = agent_plugins._activation_marker
|
|
|
|
def count_activation_checks(plugin: agent_plugins.AgentPlugin) -> str | None:
|
|
nonlocal activation_checks
|
|
activation_checks += 1
|
|
return activation_marker(plugin)
|
|
|
|
monkeypatch.setattr(agent_plugins, "_activation_marker", count_activation_checks)
|
|
scope = validate_workspace_scope_payload(
|
|
{"project_path": str(project), "access_mode": "restricted"},
|
|
default_workspace=agent_workspace,
|
|
default_restrict_to_workspace=True,
|
|
)
|
|
|
|
token = bind_workspace_scope(scope)
|
|
try:
|
|
read_result = await read_tool.execute(path=str(resource))
|
|
repeated_read_result = await read_tool.execute(path=str(resource))
|
|
write_result = await write_tool.execute(path=str(resource), content="changed")
|
|
set_agent_plugin_enabled(agent_workspace, "demo", False)
|
|
disabled_result = await read_tool.execute(path=str(resource))
|
|
finally:
|
|
reset_workspace_scope(token)
|
|
|
|
assert "plugin reference" in read_result
|
|
assert "File unchanged since last read" in repeated_read_result
|
|
assert activation_checks == 1
|
|
assert "outside allowed directory" in write_result
|
|
assert "outside allowed directory" in disabled_result
|
|
assert resource.read_text(encoding="utf-8") == "plugin reference"
|
|
|
|
|
|
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) == {}
|
|
|
|
|
|
def test_legacy_path_activation_is_upgraded_to_package_fingerprint(tmp_path: Path) -> None:
|
|
plugin = _plugin(tmp_path)
|
|
set_agent_plugin_enabled(tmp_path, "demo", True)
|
|
marker = next((tmp_path / "config" / "plugin-data").glob("*/demo/enabled"))
|
|
marker.write_text(str(plugin), encoding="utf-8")
|
|
|
|
assert discover_agent_plugins(tmp_path)[0].enabled is True
|
|
assert marker.read_text(encoding="utf-8").startswith('{"fingerprint":')
|
|
|
|
|
|
def test_plugin_activation_does_not_survive_in_place_contract_replacement(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
plugin = _plugin(tmp_path, "desktop")
|
|
mcp = plugin / "mcp.json"
|
|
|
|
def write_server(marker: str) -> None:
|
|
_write_json(
|
|
mcp,
|
|
{
|
|
"$schema": AGENT_PLUGIN_MCP_SCHEMA,
|
|
"mcpServers": {
|
|
"server": {"type": "stdio", "command": "echo", "args": [marker]}
|
|
},
|
|
},
|
|
)
|
|
|
|
write_server("trusted")
|
|
set_agent_plugin_enabled(tmp_path, "desktop", True)
|
|
assert agent_plugin_mcp_servers(tmp_path)["desktop"].args == ["trusted"]
|
|
|
|
write_server("replacement")
|
|
|
|
assert discover_agent_plugins(tmp_path)[0].enabled is False
|
|
assert agent_plugin_mcp_servers(tmp_path) == {}
|
|
|
|
|
|
def test_plugin_activation_does_not_survive_in_place_code_replacement(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
plugin = _plugin(tmp_path, "desktop")
|
|
_skill(plugin / "skills", "demo")
|
|
executable = plugin / "server.py"
|
|
executable.write_text("print('trusted')\n", encoding="utf-8")
|
|
_write_json(
|
|
plugin / "mcp.json",
|
|
{
|
|
"$schema": AGENT_PLUGIN_MCP_SCHEMA,
|
|
"mcpServers": {
|
|
"server": {
|
|
"type": "stdio",
|
|
"command": "python",
|
|
"args": ["${PLUGIN_ROOT}/server.py"],
|
|
}
|
|
},
|
|
},
|
|
)
|
|
set_agent_plugin_enabled(tmp_path, "desktop", True)
|
|
assert discover_agent_plugins(tmp_path)[0].enabled is True
|
|
assert enabled_agent_plugin_skill_dirs(tmp_path) == (plugin / "skills" / "demo",)
|
|
|
|
executable.write_text("print('replacement')\n", encoding="utf-8")
|
|
|
|
assert discover_agent_plugins(tmp_path)[0].enabled is False
|
|
assert enabled_agent_plugin_skill_dirs(tmp_path) == ()
|
|
assert agent_plugin_mcp_servers(tmp_path) == {}
|