c0c0156468
Introduces a standalone plugin system where every integration lives as an independent Python script in ~/.pyra/plugins/, not hardcoded in core. Plugin framework (src/pyra/plugins/): - base.py: Tool dataclass, PyraPlugin Protocol, BasePlugin helper - loader.py: importlib-based discovery; one bad plugin never crashes pyra - registry.py: singleton aggregating tools, slash commands, system prompts - executor.py: approval gate — scans args, prompts y/N, scans result, logs - install.py: copies bundled_plugins/ to ~/.pyra/plugins/ on install Chat integration: - AI tool-use loop (litellm function calling, up to 10 iterations) - Plugin system prompt additions injected per session - Plugin slash commands merged with static commands CLI additions: - pyra plugin list/install/enable/disable/setup - pyra daemon start/stop/status/restart/install/uninstall (stubs for 2.4) Config: PluginConfig + DaemonConfig added to PyraConfig (backwards-compatible) Bootstrap: ~/.pyra/plugins/ and ~/.pyra/logs/ created on startup Security: tool args and results always injection-scanned; plugin dirs validated with assert_safe_path() before loading (symlink protection) Tests: 37 new tests (loader, registry, executor, plugin isolation security) 161 total, all passing. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
167 lines
5.2 KiB
Python
167 lines
5.2 KiB
Python
"""Tests for plugin discovery and loading."""
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from pyra.plugins.loader import load_plugin_by_name, load_plugins
|
|
|
|
|
|
def _make_plugin(plugins_dir: Path, name: str, plugin_code: str, manifest: dict | None = None) -> Path:
|
|
"""Helper: create a minimal plugin directory."""
|
|
plugin_dir = plugins_dir / name
|
|
plugin_dir.mkdir(parents=True)
|
|
if manifest is None:
|
|
manifest = {"name": name, "version": "0.1.0", "description": "Test plugin"}
|
|
(plugin_dir / "manifest.json").write_text(json.dumps(manifest))
|
|
(plugin_dir / "plugin.py").write_text(plugin_code)
|
|
return plugin_dir
|
|
|
|
|
|
_MINIMAL_PLUGIN = """\
|
|
from pyra.plugins.base import BasePlugin
|
|
|
|
class _Plugin(BasePlugin):
|
|
name = "test_plugin"
|
|
description = "A test plugin"
|
|
version = "0.1.0"
|
|
|
|
def get_plugin():
|
|
return _Plugin()
|
|
"""
|
|
|
|
_TOOL_PLUGIN = """\
|
|
from pyra.plugins.base import BasePlugin, Tool
|
|
|
|
class _Plugin(BasePlugin):
|
|
name = "tool_plugin"
|
|
description = "Plugin with a tool"
|
|
version = "0.1.0"
|
|
|
|
def tools(self):
|
|
return [
|
|
Tool(
|
|
name="say_hello",
|
|
description="Says hello",
|
|
parameters={"type": "object", "properties": {}},
|
|
handler=lambda: "hello",
|
|
requires_approval=False,
|
|
)
|
|
]
|
|
|
|
def get_plugin():
|
|
return _Plugin()
|
|
"""
|
|
|
|
|
|
def test_load_valid_plugin(tmp_pyra_home, tmp_path):
|
|
plugins_dir = tmp_path / "plugins"
|
|
plugins_dir.mkdir()
|
|
_make_plugin(plugins_dir, "test_plugin", _MINIMAL_PLUGIN)
|
|
plugin = load_plugin_by_name("test_plugin", plugins_dir)
|
|
assert plugin is not None
|
|
assert plugin.name == "test_plugin"
|
|
|
|
|
|
def test_load_plugins_discovers_all(tmp_pyra_home, tmp_path):
|
|
plugins_dir = tmp_path / "plugins"
|
|
plugins_dir.mkdir()
|
|
_make_plugin(plugins_dir, "plugin_a", _MINIMAL_PLUGIN.replace("test_plugin", "plugin_a"))
|
|
_make_plugin(plugins_dir, "plugin_b", _MINIMAL_PLUGIN.replace("test_plugin", "plugin_b"))
|
|
plugins = load_plugins(plugins_dir)
|
|
names = {p.name for p in plugins}
|
|
assert "plugin_a" in names
|
|
assert "plugin_b" in names
|
|
|
|
|
|
def test_load_plugins_empty_dir(tmp_path):
|
|
plugins_dir = tmp_path / "plugins"
|
|
plugins_dir.mkdir()
|
|
assert load_plugins(plugins_dir) == []
|
|
|
|
|
|
def test_load_plugins_missing_dir(tmp_path):
|
|
assert load_plugins(tmp_path / "nonexistent") == []
|
|
|
|
|
|
def test_missing_manifest_returns_none(tmp_pyra_home, tmp_path):
|
|
plugins_dir = tmp_path / "plugins"
|
|
plugins_dir.mkdir()
|
|
plugin_dir = plugins_dir / "bad_plugin"
|
|
plugin_dir.mkdir()
|
|
(plugin_dir / "plugin.py").write_text(_MINIMAL_PLUGIN)
|
|
# No manifest.json
|
|
result = load_plugin_by_name("bad_plugin", plugins_dir)
|
|
assert result is None
|
|
|
|
|
|
def test_missing_plugin_py_returns_none(tmp_pyra_home, tmp_path):
|
|
plugins_dir = tmp_path / "plugins"
|
|
plugins_dir.mkdir()
|
|
plugin_dir = plugins_dir / "bad_plugin"
|
|
plugin_dir.mkdir()
|
|
(plugin_dir / "manifest.json").write_text(json.dumps({"name": "bad_plugin", "version": "1.0.0"}))
|
|
# No plugin.py
|
|
result = load_plugin_by_name("bad_plugin", plugins_dir)
|
|
assert result is None
|
|
|
|
|
|
def test_invalid_manifest_returns_none(tmp_pyra_home, tmp_path):
|
|
plugins_dir = tmp_path / "plugins"
|
|
plugins_dir.mkdir()
|
|
plugin_dir = plugins_dir / "bad_plugin"
|
|
plugin_dir.mkdir()
|
|
(plugin_dir / "manifest.json").write_text('{"name": "bad_plugin"}') # missing version
|
|
(plugin_dir / "plugin.py").write_text(_MINIMAL_PLUGIN)
|
|
result = load_plugin_by_name("bad_plugin", plugins_dir)
|
|
assert result is None
|
|
|
|
|
|
def test_no_get_plugin_returns_none(tmp_pyra_home, tmp_path):
|
|
plugins_dir = tmp_path / "plugins"
|
|
plugins_dir.mkdir()
|
|
code = "# No get_plugin function here\nclass Foo: pass"
|
|
_make_plugin(plugins_dir, "no_factory", code)
|
|
result = load_plugin_by_name("no_factory", plugins_dir)
|
|
assert result is None
|
|
|
|
|
|
def test_plugin_with_syntax_error_returns_none(tmp_pyra_home, tmp_path):
|
|
plugins_dir = tmp_path / "plugins"
|
|
plugins_dir.mkdir()
|
|
_make_plugin(plugins_dir, "broken", "def get_plugin(: INVALID SYNTAX")
|
|
result = load_plugin_by_name("broken", plugins_dir)
|
|
assert result is None
|
|
|
|
|
|
def test_one_bad_plugin_does_not_prevent_others(tmp_pyra_home, tmp_path):
|
|
plugins_dir = tmp_path / "plugins"
|
|
plugins_dir.mkdir()
|
|
_make_plugin(plugins_dir, "good", _MINIMAL_PLUGIN.replace("test_plugin", "good"))
|
|
_make_plugin(plugins_dir, "bad", "SYNTAX ERROR !!!")
|
|
plugins = load_plugins(plugins_dir)
|
|
names = [p.name for p in plugins]
|
|
assert "good" in names
|
|
assert len(names) == 1
|
|
|
|
|
|
def test_plugin_errors_logged(tmp_pyra_home, tmp_path):
|
|
plugins_dir = tmp_path / "plugins"
|
|
plugins_dir.mkdir()
|
|
_make_plugin(plugins_dir, "bad", "SYNTAX ERROR")
|
|
load_plugin_by_name("bad", plugins_dir)
|
|
log_file = tmp_pyra_home / "logs" / "plugin_errors.log"
|
|
assert log_file.exists()
|
|
assert "bad" in log_file.read_text()
|
|
|
|
|
|
def test_plugin_tools_accessible(tmp_pyra_home, tmp_path):
|
|
plugins_dir = tmp_path / "plugins"
|
|
plugins_dir.mkdir()
|
|
_make_plugin(plugins_dir, "tool_plugin", _TOOL_PLUGIN)
|
|
plugin = load_plugin_by_name("tool_plugin", plugins_dir)
|
|
assert plugin is not None
|
|
tools = plugin.tools()
|
|
assert len(tools) == 1
|
|
assert tools[0].name == "say_hello"
|