From ca78c6d7a67b69a23cb167b82fd2bde8fef72fa4 Mon Sep 17 00:00:00 2001 From: GodsBoy Date: Thu, 6 Aug 2026 22:05:45 +0200 Subject: [PATCH] feat(plugins): load portable agent components --- hermes_cli/agent_plugins.py | 13 +- hermes_cli/mcp_startup.py | 7 +- hermes_cli/plugins.py | 127 +++++++++++++++++- hermes_cli/plugins_cmd.py | 65 +++++++-- hermes_cli/subcommands/plugins.py | 5 +- tests/hermes_cli/test_mcp_startup.py | 20 ++- tests/hermes_cli/test_plugins.py | 88 ++++++++++++ tests/hermes_cli/test_plugins_cmd.py | 27 ++++ tests/test_plugin_skills.py | 37 +++++ tests/tools/test_mcp_tool.py | 30 ++++- tools/mcp_tool.py | 20 ++- tools/skills_tool.py | 92 +++++++++++-- website/docs/developer-guide/plugins/index.md | 58 +++++++- website/docs/user-guide/cli.md | 19 +++ 14 files changed, 576 insertions(+), 32 deletions(-) diff --git a/hermes_cli/agent_plugins.py b/hermes_cli/agent_plugins.py index b110d1c6ce..f59f6d8c39 100644 --- a/hermes_cli/agent_plugins.py +++ b/hermes_cli/agent_plugins.py @@ -9,6 +9,7 @@ Python code. from __future__ import annotations import json +import os import re from dataclasses import dataclass from pathlib import Path @@ -316,7 +317,8 @@ def _translate_stdio( for key, value in env.items() ): raise ValueError("env must map string keys to string values") - if "PLUGIN_ROOT" in env or "PLUGIN_DATA" in env: + env_keys = {key.upper() if os.name == "nt" else key for key in env} + if "PLUGIN_ROOT" in env_keys or "PLUGIN_DATA" in env_keys: raise ValueError("PLUGIN_ROOT and PLUGIN_DATA are reserved") cwd = config.get("cwd") @@ -436,3 +438,12 @@ def load_agent_plugin(plugin_root: Path, data_root: Path) -> AgentPluginPackage: diagnostics=tuple(diagnostics), ) + +def read_agent_plugin_manifest(plugin_root: Path) -> tuple[dict, tuple[AgentPluginDiagnostic, ...]]: + """Validate only root ``plugin.json`` without discovering components.""" + + root = Path(plugin_root).resolve(strict=True) + if not root.is_dir(): + raise AgentPluginError("plugin root must be a directory") + manifest, diagnostics = _validate_manifest(root) + return manifest, tuple(diagnostics) diff --git a/hermes_cli/mcp_startup.py b/hermes_cli/mcp_startup.py index a350f648d6..1b9646b6d2 100644 --- a/hermes_cli/mcp_startup.py +++ b/hermes_cli/mcp_startup.py @@ -17,7 +17,12 @@ def _has_configured_mcp_servers() -> bool: from hermes_cli.config import read_raw_config mcp_servers = (read_raw_config() or {}).get("mcp_servers") - return isinstance(mcp_servers, dict) and len(mcp_servers) > 0 + if isinstance(mcp_servers, dict) and len(mcp_servers) > 0: + return True + from hermes_cli.plugins import discover_plugins, get_plugin_manager + + discover_plugins() + return get_plugin_manager().has_portable_mcp_servers() except Exception: # Be conservative: if config probing fails, try discovery in the # background so startup still can't block. diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 0ae0f7a32a..bc0b76f89b 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -34,6 +34,7 @@ so plugin-defined tools appear alongside the built-in tools. from __future__ import annotations import asyncio +import hashlib import importlib.metadata import importlib.util import inspect @@ -280,6 +281,15 @@ def _get_enabled_plugins() -> Optional[set]: _VALID_PLUGIN_KINDS: Set[str] = {"standalone", "backend", "exclusive", "platform", "model-provider"} +def _portable_skill_namespace(key: str) -> str: + """Return a readable, collision-resistant namespace for a portable plugin.""" + + slug = "".join(ch if ch.isalnum() or ch in "_-" else "-" for ch in key.lower()) + slug = slug.strip("-_") or "plugin" + digest = hashlib.sha256(key.encode("utf-8")).hexdigest()[:8] + return f"agent-plugin-{slug}-{digest}" + + @dataclass class PluginManifest: """Parsed representation of a plugin.yaml manifest.""" @@ -287,7 +297,7 @@ class PluginManifest: name: str version: str = "" description: str = "" - author: str = "" + author: Any = "" requires_env: List[Union[str, Dict[str, Any]]] = field(default_factory=list) provides_tools: List[str] = field(default_factory=list) provides_hooks: List[str] = field(default_factory=list) @@ -315,6 +325,8 @@ class PluginManifest: # category plugin at ``plugins/image_gen/openai/`` the key is # ``image_gen/openai``. When empty, falls back to ``name``. key: str = "" + portable: bool = False + skill_namespace: str = "" @dataclass @@ -1258,10 +1270,13 @@ class PluginContext: if not path.exists(): raise FileNotFoundError(f"SKILL.md not found at {path}") - qualified = f"{self.manifest.name}:{name}" + namespace = self.manifest.skill_namespace or self.manifest.name + qualified = f"{namespace}:{name}" + if qualified in self._manager._plugin_skills: + raise ValueError(f"Plugin skill '{qualified}' is already registered") self._manager._plugin_skills[qualified] = { "path": path, - "plugin": self.manifest.name, + "plugin": namespace, "bare_name": name, "description": description, } @@ -1291,6 +1306,7 @@ class PluginManager: self._cli_ref = None # Set by CLI after plugin discovery # Plugin skill registry: qualified name → metadata dict. self._plugin_skills: Dict[str, Dict[str, Any]] = {} + self._portable_mcp_servers: Dict[str, Dict[str, Any]] = {} # Plugin-registered auxiliary tasks: key → {key, display_name, # description, defaults, plugin}. See PluginContext.register_auxiliary_task. self._aux_tasks: Dict[str, Dict[str, Any]] = {} @@ -1328,6 +1344,7 @@ class PluginManager: self._cli_commands.clear() self._plugin_commands.clear() self._plugin_skills.clear() + self._portable_mcp_servers.clear() self._aux_tasks.clear() self._slack_action_handlers.clear() self._context_engine = None @@ -1570,6 +1587,36 @@ class PluginManager: manifests.append(manifest) continue + portable_file = child / "plugin.json" + if portable_file.exists() or portable_file.is_symlink(): + try: + from hermes_cli.agent_plugins import read_agent_plugin_manifest + + data, diagnostics = read_agent_plugin_manifest(child) + for diagnostic in diagnostics: + logger.warning( + "Agent Plugin '%s': %s", + child, + diagnostic.message, + ) + key = f"{prefix}/{child.name}" if prefix else data["name"] + manifests.append( + PluginManifest( + name=data["name"], + version=data.get("version", ""), + description=data.get("description", ""), + author=data.get("author", ""), + source=source, + path=str(child), + key=key, + portable=True, + skill_namespace=_portable_skill_namespace(key), + ) + ) + except Exception as exc: + logger.warning("Failed to parse %s: %s", portable_file, exc) + continue + # No manifest at this level. If we're still within the depth # cap, treat this directory as a category namespace and recurse # one level in looking for children with manifests. @@ -1783,6 +1830,10 @@ class PluginManager: manifest.key or manifest.name, manifest.source, manifest.kind, manifest.path, ) + if manifest.portable: + self._load_portable_plugin(manifest, loaded) + return + from tools.registry import registry as _registry _plugin_id = manifest.key or manifest.name _slug = _plugin_id.replace("/", "__").replace("-", "_") @@ -1859,6 +1910,53 @@ class PluginManager: ) self._plugins[manifest.key or manifest.name] = loaded + def _load_portable_plugin( + self, manifest: PluginManifest, loaded: LoadedPlugin + ) -> None: + """Load validated portable components without importing Python code.""" + + lookup_key = manifest.key or manifest.name + try: + from hermes_cli.agent_plugins import load_agent_plugin + + package = load_agent_plugin( + Path(manifest.path), + get_hermes_home() / "plugin-data" / manifest.skill_namespace, + ) + ctx = PluginContext(manifest, self) + for diagnostic in package.diagnostics: + logger.warning( + "Agent Plugin '%s' [%s]: %s", + lookup_key, + diagnostic.scope, + diagnostic.message, + ) + for skill in package.skills: + try: + ctx.register_skill(skill.name, skill.skill_md, skill.description) + except Exception as exc: + logger.warning( + "Agent Plugin '%s' skill '%s' skipped: %s", + lookup_key, + skill.name, + exc, + ) + for server_name, config in package.mcp_servers.items(): + internal_name = f"{manifest.skill_namespace}__{server_name}" + if internal_name in self._portable_mcp_servers: + logger.warning( + "Agent Plugin '%s' MCP server collision: %s", + lookup_key, + internal_name, + ) + continue + self._portable_mcp_servers[internal_name] = dict(config) + loaded.enabled = True + except Exception as exc: + loaded.error = str(exc) + logger.warning("Failed to load Agent Plugin '%s': %s", lookup_key, exc) + self._plugins[lookup_key] = loaded + def _load_directory_module(self, manifest: PluginManifest) -> types.ModuleType: """Import a directory-based plugin as ``hermes_plugins.``. @@ -2047,6 +2145,29 @@ class PluginManager: if qn.startswith(prefix) ) + def list_plugin_skill_metadata(self) -> List[Dict[str, str]]: + """Return progressive-disclosure metadata for registered plugin skills.""" + + return [ + { + "name": qualified, + "description": str(entry.get("description", "")), + "category": "plugin", + } + for qualified, entry in sorted(self._plugin_skills.items()) + ] + + def get_portable_mcp_servers(self) -> Dict[str, Dict[str, Any]]: + """Return a defensive copy of enabled portable MCP server configs.""" + + return { + name: dict(config) + for name, config in self._portable_mcp_servers.items() + } + + def has_portable_mcp_servers(self) -> bool: + return bool(self._portable_mcp_servers) + def remove_plugin_skill(self, qualified_name: str) -> None: """Remove a stale registry entry (silently ignores missing keys).""" self._plugin_skills.pop(qualified_name, None) diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index 04b66380d5..bdc50e9339 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -262,10 +262,22 @@ def _repo_name_from_url(url: str) -> str: def _read_manifest(plugin_dir: Path) -> dict: - """Read plugin.yaml and return the parsed dict, or empty dict.""" + """Read a native or portable manifest, preferring native YAML.""" manifest_file = plugin_dir / "plugin.yaml" if not manifest_file.exists(): - return {} + manifest_file = plugin_dir / "plugin.yml" + if not manifest_file.exists(): + portable_file = plugin_dir / "plugin.json" + if not portable_file.exists() and not portable_file.is_symlink(): + return {} + try: + from hermes_cli.agent_plugins import read_agent_plugin_manifest + + manifest, _ = read_agent_plugin_manifest(plugin_dir) + return manifest + except Exception as e: + logger.warning("Failed to read plugin.json in %s: %s", plugin_dir, e) + return {} try: import yaml @@ -497,7 +509,25 @@ def _install_plugin_core(identifier: str, *, force: bool) -> tuple[Path, dict, s else: tmp_target = tmp_clone - manifest = _read_manifest(tmp_target) + has_native_manifest = (tmp_target / "plugin.yaml").exists() or ( + tmp_target / "plugin.yml" + ).exists() + has_portable_manifest = (tmp_target / "plugin.json").exists() or ( + tmp_target / "plugin.json" + ).is_symlink() + if not has_native_manifest and has_portable_manifest: + try: + from hermes_cli.agent_plugins import read_agent_plugin_manifest + + manifest, diagnostics = read_agent_plugin_manifest(tmp_target) + for diagnostic in diagnostics: + logger.warning("Agent Plugin install: %s", diagnostic.message) + except Exception as exc: + raise PluginOperationError( + f"Portable plugin manifest validation failed: {exc}" + ) from exc + else: + manifest = _read_manifest(tmp_target) plugin_name = manifest.get("name") or ( subdir.rstrip("/").rsplit("/", 1)[-1] if subdir else _repo_name_from_url(git_url) ) @@ -536,7 +566,8 @@ def _install_plugin_core(identifier: str, *, force: bool) -> tuple[Path, dict, s shutil.move(str(tmp_target), str(target)) has_yaml = (target / "plugin.yaml").exists() or (target / "plugin.yml").exists() - if not has_yaml and not (target / "__init__.py").exists(): + has_portable = (target / "plugin.json").exists() + if not has_yaml and not has_portable and not (target / "__init__.py").exists(): logger.warning( "%s has no plugin.yaml / __init__.py; may not be a valid plugin", plugin_name, @@ -590,12 +621,12 @@ def cmd_install( console.print(f"[red]Error:[/red] {e}") sys.exit(1) - if not (target / "plugin.yaml").exists() and not (target / "plugin.yml").exists() and not ( + if not (target / "plugin.yaml").exists() and not (target / "plugin.yml").exists() and not (target / "plugin.json").exists() and not ( target / "__init__.py" ).exists(): console.print( - f"[yellow]Warning:[/yellow] {installed_name} doesn't contain plugin.yaml " - f"or __init__.py. It may not be a valid Hermes plugin.", + f"[yellow]Warning:[/yellow] {installed_name} doesn't contain plugin.yaml, " + f"plugin.json, or __init__.py. It may not be a valid Hermes plugin.", ) _prompt_plugin_env_vars(installed_manifest, console) @@ -985,7 +1016,7 @@ def _plugin_exists(name: str) -> bool: def _read_manifest_info(d: Path, prefix: str): - """Read a plugin.yaml manifest and return (name, version, description, key). + """Read a native or portable manifest and return display metadata. Returns None if no manifest file exists. """ @@ -993,7 +1024,23 @@ def _read_manifest_info(d: Path, prefix: str): if not manifest_file.exists(): manifest_file = d / "plugin.yml" if not manifest_file.exists(): - return None + portable_file = d / "plugin.json" + if not portable_file.exists() and not portable_file.is_symlink(): + return None + try: + from hermes_cli.agent_plugins import read_agent_plugin_manifest + + manifest, _ = read_agent_plugin_manifest(d) + name = manifest["name"] + key = f"{prefix}/{d.name}" if prefix else name + return ( + name, + manifest.get("version", ""), + manifest.get("description", ""), + key, + ) + except Exception: + return None try: import yaml except ImportError: diff --git a/hermes_cli/subcommands/plugins.py b/hermes_cli/subcommands/plugins.py index 5355fbec34..57796cf18f 100644 --- a/hermes_cli/subcommands/plugins.py +++ b/hermes_cli/subcommands/plugins.py @@ -14,7 +14,10 @@ def build_plugins_parser(subparsers, *, cmd_plugins: Callable) -> None: plugins_parser = subparsers.add_parser( "plugins", help="Manage plugins — install, update, remove, list", - description="Install plugins from Git repositories, update, remove, or list them.", + description=( + "Install, update, remove, or list native Hermes plugins and " + "portable Agent Plugins v1 packages. Portable packages install disabled." + ), ) plugins_subparsers = plugins_parser.add_subparsers(dest="plugins_action") diff --git a/tests/hermes_cli/test_mcp_startup.py b/tests/hermes_cli/test_mcp_startup.py index 57fb316639..ffcdf14c0a 100644 --- a/tests/hermes_cli/test_mcp_startup.py +++ b/tests/hermes_cli/test_mcp_startup.py @@ -146,6 +146,25 @@ def test_background_mcp_discovery_suppresses_interactive_oauth(monkeypatch): assert state["active"] is False +def test_portable_only_mcp_configuration_opens_startup_gate(monkeypatch): + manager = types.SimpleNamespace(has_portable_mcp_servers=lambda: True) + monkeypatch.setitem( + sys.modules, + "hermes_cli.config", + types.SimpleNamespace(read_raw_config=lambda: {}), + ) + monkeypatch.setitem( + sys.modules, + "hermes_cli.plugins", + types.SimpleNamespace( + discover_plugins=lambda: None, + get_plugin_manager=lambda: manager, + ), + ) + + assert mcp_startup._has_configured_mcp_servers() is True + + @@ -183,4 +202,3 @@ def _install_retry_stubs(monkeypatch, *, connected: bool, calls: dict): - diff --git a/tests/hermes_cli/test_plugins.py b/tests/hermes_cli/test_plugins.py index d4ea437b82..8a7dd84e1a 100644 --- a/tests/hermes_cli/test_plugins.py +++ b/tests/hermes_cli/test_plugins.py @@ -1,6 +1,7 @@ """Tests for the Hermes plugin system (hermes_cli.plugins).""" import logging +import json import sys import types from pathlib import Path @@ -92,6 +93,93 @@ def _make_plugin_dir(base: Path, name: str, *, register_body: str = "pass", class TestPluginDiscovery: """Tests for plugin discovery from directories and entry points.""" + def test_enabled_portable_plugin_registers_components( + self, tmp_path, monkeypatch + ): + from hermes_cli.agent_plugins import MCP_SCHEMA_V1, PLUGIN_SCHEMA_V1 + from hermes_cli import plugins as plugins_mod + + home = tmp_path / "home" + plugin = home / "plugins" / "portable" + skill = plugin / "skills" / "summarize" + skill.mkdir(parents=True) + (plugin / "plugin.json").write_text( + json.dumps({"$schema": PLUGIN_SCHEMA_V1, "name": "portable.test"}) + ) + (skill / "SKILL.md").write_text( + "---\nname: summarize\ndescription: Summarize reports.\n---\nBody.\n" + ) + (plugin / "mcp.json").write_text( + json.dumps( + { + "$schema": MCP_SCHEMA_V1, + "mcpServers": { + "worker": {"type": "stdio", "command": "python"} + }, + } + ) + ) + native = home / "plugins" / "native" + native.mkdir() + (native / "plugin.yaml").write_text( + yaml.safe_dump({"name": "native", "version": "1.0.0"}) + ) + (native / "__init__.py").write_text("def register(ctx):\n pass\n") + home.mkdir(exist_ok=True) + (home / "config.yaml").write_text( + yaml.safe_dump({"plugins": {"enabled": ["portable.test", "native"]}}) + ) + empty_bundled = tmp_path / "bundled" + empty_bundled.mkdir() + monkeypatch.setenv("HOME", str(tmp_path / "os-home")) + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setattr(plugins_mod, "get_bundled_plugins_dir", lambda: empty_bundled) + + manager = PluginManager() + manager.discover_and_load() + + [qualified] = manager.list_plugin_skill_metadata() + assert qualified["name"].endswith(":summarize") + assert set(manager.get_portable_mcp_servers()) == { + qualified["name"].split(":", 1)[0] + "__worker" + } + assert manager._plugins["portable.test"].enabled is True + assert manager._plugins["native"].enabled is True + assert manager._plugins["native"].module is not None + + def test_disabled_portable_plugin_registers_nothing(self, tmp_path, monkeypatch): + from hermes_cli.agent_plugins import PLUGIN_SCHEMA_V1 + from hermes_cli import plugins as plugins_mod + + home = tmp_path / "home" + plugin = home / "plugins" / "portable" + plugin.mkdir(parents=True) + (plugin / "plugin.json").write_text( + json.dumps({"$schema": PLUGIN_SCHEMA_V1, "name": "portable.test"}) + ) + (home / "config.yaml").write_text( + yaml.safe_dump( + { + "plugins": { + "enabled": ["portable.test"], + "disabled": ["portable.test"], + } + } + ) + ) + empty_bundled = tmp_path / "bundled" + empty_bundled.mkdir() + monkeypatch.setenv("HOME", str(tmp_path / "os-home")) + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setattr(plugins_mod, "get_bundled_plugins_dir", lambda: empty_bundled) + + manager = PluginManager() + manager.discover_and_load() + + assert manager._plugins["portable.test"].enabled is False + assert manager.list_plugin_skill_metadata() == [] + assert manager.get_portable_mcp_servers() == {} + def test_plugin_can_register_and_invoke_middleware(self, tmp_path, monkeypatch): plugins_dir = tmp_path / "hermes_test" / "plugins" diff --git a/tests/hermes_cli/test_plugins_cmd.py b/tests/hermes_cli/test_plugins_cmd.py index 1b4e981c30..0e43232875 100644 --- a/tests/hermes_cli/test_plugins_cmd.py +++ b/tests/hermes_cli/test_plugins_cmd.py @@ -616,3 +616,30 @@ class TestSubdirInstallE2E: identifier = f"file://{repo_root}#does-not-exist" with pytest.raises(PluginOperationError, match="does not exist"): pc._install_plugin_core(identifier, force=False) + + +def test_portable_manifest_is_visible_to_plugin_cli(tmp_path): + import json + + from hermes_cli.agent_plugins import PLUGIN_SCHEMA_V1 + from hermes_cli.plugins_cmd import _read_manifest_info + + plugin = tmp_path / "portable" + plugin.mkdir() + (plugin / "plugin.json").write_text( + json.dumps( + { + "$schema": PLUGIN_SCHEMA_V1, + "name": "portable.test", + "version": "1.0.0", + "description": "Portable test plugin", + } + ) + ) + + assert _read_manifest_info(plugin, "") == ( + "portable.test", + "1.0.0", + "Portable test plugin", + "portable.test", + ) diff --git a/tests/test_plugin_skills.py b/tests/test_plugin_skills.py index 19aa53da5a..46cc68f353 100644 --- a/tests/test_plugin_skills.py +++ b/tests/test_plugin_skills.py @@ -139,6 +139,17 @@ class TestPluginContextRegisterSkill: with pytest.raises(FileNotFoundError): ctx.register_skill("foo", tmp_path / "nonexistent.md") + def test_duplicate_qualified_name_is_rejected(self, ctx, tmp_path): + first = tmp_path / "first" / "SKILL.md" + second = tmp_path / "second" / "SKILL.md" + first.parent.mkdir() + second.parent.mkdir() + first.write_text("test") + second.write_text("test") + ctx.register_skill("foo", first) + with pytest.raises(ValueError, match="already registered"): + ctx.register_skill("foo", second) + # ── skill_view qualified name dispatch ──────────────────────────────────── @@ -178,6 +189,32 @@ class TestSkillViewQualifiedName: assert result["name"] == "superpowers:writing-plans" assert "writing-plans body." in result["content"] + def test_reads_supporting_file_with_containment(self, tmp_path): + from tools.skills_tool import skill_view + + md = self._register_skill(tmp_path) + reference = md.parent / "references" / "api.md" + reference.parent.mkdir() + reference.write_text("API details.") + + main = json.loads(skill_view("superpowers:writing-plans")) + assert main["linked_files"] == {"references": ["references/api.md"]} + result = json.loads( + skill_view("superpowers:writing-plans", file_path="references/api.md") + ) + assert result["success"] is True + assert result["content"] == "API details." + + def test_rejects_supporting_file_escape(self, tmp_path): + from tools.skills_tool import skill_view + + self._register_skill(tmp_path) + result = json.loads( + skill_view("superpowers:writing-plans", file_path="../outside.md") + ) + assert result["success"] is False + assert "traversal" in result["error"].lower() + def test_plugin_skill_usage_reports_installed_provenance( self, tmp_path, diff --git a/tests/tools/test_mcp_tool.py b/tests/tools/test_mcp_tool.py index 478693fcaa..6e876b637c 100644 --- a/tests/tools/test_mcp_tool.py +++ b/tests/tools/test_mcp_tool.py @@ -118,6 +118,29 @@ class TestLoadMCPConfig: result = _load_mcp_config() assert result == {} + def test_portable_servers_merge_after_native_interpolation(self): + native = {"native": {"command": "node", "args": ["${PORT}"]}} + portable = { + "agent-plugin-demo__worker": { + "command": "python", + "args": ["${UNKNOWN}"], + "cwd": "/plugin", + } + } + manager = SimpleNamespace(get_portable_mcp_servers=lambda: portable) + with ( + patch("hermes_cli.config.load_config", return_value={"mcp_servers": native}), + patch("hermes_cli.plugins.discover_plugins"), + patch("hermes_cli.plugins.get_plugin_manager", return_value=manager), + patch.dict(os.environ, {"PORT": "3000"}), + ): + from tools.mcp_tool import _load_mcp_config + + result = _load_mcp_config() + + assert result["native"]["args"] == ["3000"] + assert result["agent-plugin-demo__worker"]["args"] == ["${UNKNOWN}"] + class TestMCPParallelSafetyProvenance: def test_parallel_safe_servers_keep_exact_raw_names(self, monkeypatch): @@ -739,14 +762,17 @@ class TestMCPServerTask: p_stdio, p_cs, _, _ = self._mock_stdio_and_session(mock_session) async def _test(): - with patch("tools.mcp_tool.StdioServerParameters"), p_stdio, p_cs: + with patch("tools.mcp_tool.StdioServerParameters") as params, p_stdio, p_cs: server = MCPServerTask("test_srv") - await server.start({"command": "npx", "args": ["-y", "test"]}) + await server.start( + {"command": "npx", "args": ["-y", "test"], "cwd": "/plugin"} + ) assert server.session is mock_session assert len(server._tools) == 1 assert server._tools[0].name == "echo" mock_session.initialize.assert_called_once() + assert params.call_args.kwargs["cwd"] == "/plugin" await server.shutdown() assert server.session is None diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index ad962558a1..e55b36b0a6 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -2524,6 +2524,7 @@ class MCPServerTask: command=command, args=args, env=safe_env if safe_env else None, + cwd=config.get("cwd"), # On Windows, pipe I/O can deliver non-UTF-8 bytes at chunk # boundaries. Use "replace" to substitute undecodable bytes # with U+FFFD instead of crashing with UnicodeDecodeError. @@ -4890,8 +4891,8 @@ def _load_mcp_config() -> Dict[str, dict]: return {} config = load_config() servers = config.get("mcp_servers") - if not servers or not isinstance(servers, dict): - return {} + if not isinstance(servers, dict): + servers = {} # Ensure .env vars are available for interpolation try: from hermes_cli.env_loader import load_hermes_dotenv @@ -4904,6 +4905,21 @@ def _load_mcp_config() -> Dict[str, dict]: if isinstance(interpolated, dict): _warn_hidden_whitespace(name, interpolated) safe_servers[name] = interpolated + try: + from hermes_cli.plugins import discover_plugins, get_plugin_manager + + discover_plugins() + portable = get_plugin_manager().get_portable_mcp_servers() + for name, cfg in _filter_suspicious_mcp_servers(portable).items(): + if name in safe_servers: + logger.warning( + "Portable MCP server '%s' conflicts with native config; skipping", + name, + ) + continue + safe_servers[name] = dict(cfg) + except Exception: + logger.debug("Failed to load portable MCP servers", exc_info=True) return safe_servers except Exception as exc: logger.debug("Failed to load MCP config: %s", exc) diff --git a/tools/skills_tool.py b/tools/skills_tool.py index 12eba2849a..0835d6d7e0 100644 --- a/tools/skills_tool.py +++ b/tools/skills_tool.py @@ -801,18 +801,16 @@ def skills_list(category: str = None, task_id: str = None) -> str: active_skills_dir = _skills_dir() if not active_skills_dir.exists(): active_skills_dir.mkdir(parents=True, exist_ok=True) - return json.dumps( - { - "success": True, - "skills": [], - "categories": [], - "message": f"No skills found. Skills directory created at {display_hermes_home()}/skills/", - }, - ensure_ascii=False, - ) # Find all skills all_skills = _find_all_skills() + try: + from hermes_cli.plugins import discover_plugins, get_plugin_manager + + discover_plugins() + all_skills.extend(get_plugin_manager().list_plugin_skill_metadata()) + except Exception: + logger.debug("Plugin skill listing failed", exc_info=True) if not all_skills: return json.dumps( @@ -859,6 +857,7 @@ def _serve_plugin_skill( skill_md: Path, namespace: str, bare: str, + file_path: str | None = None, *, preprocess: bool = True, session_id: str | None = None, @@ -878,6 +877,59 @@ def _serve_plugin_skill( ensure_ascii=False, ) + if file_path: + from tools.path_security import has_traversal_component, validate_within_dir + + skill_root = skill_md.parent + if has_traversal_component(file_path): + return json.dumps( + {"success": False, "error": "Path traversal ('..') is not allowed."}, + ensure_ascii=False, + ) + target = skill_root / file_path + path_error = validate_within_dir(target, skill_root) + if path_error: + return json.dumps( + {"success": False, "error": path_error}, ensure_ascii=False + ) + if not target.is_file(): + return json.dumps( + { + "success": False, + "error": f"File '{file_path}' not found in skill '{namespace}:{bare}'.", + }, + ensure_ascii=False, + ) + try: + content = target.read_text(encoding="utf-8") + except UnicodeDecodeError: + return json.dumps( + { + "success": True, + "name": f"{namespace}:{bare}", + "file": file_path, + "content": f"[Binary file: {target.name}, size: {target.stat().st_size} bytes]", + "is_binary": True, + }, + ensure_ascii=False, + ) + except Exception as exc: + return json.dumps( + {"success": False, "error": f"Failed to read '{file_path}': {exc}"}, + ensure_ascii=False, + ) + return json.dumps( + { + "success": True, + "name": f"{namespace}:{bare}", + "file": file_path, + "content": content, + "file_type": target.suffix, + "_source_path": str(target), + }, + ensure_ascii=False, + ) + try: content = skill_md.read_text(encoding="utf-8") except Exception as e: @@ -952,13 +1004,32 @@ def _serve_plugin_skill( "name": f"{namespace}:{bare}", "content": f"{banner}{rendered_content}" if banner else rendered_content, "description": description, - "linked_files": None, + "linked_files": _plugin_skill_linked_files(skill_md.parent), "readiness_status": SkillReadinessStatus.AVAILABLE.value, }, ensure_ascii=False, ) +def _plugin_skill_linked_files(skill_root: Path) -> Dict[str, List[str]] | None: + from tools.path_security import validate_within_dir + + linked: Dict[str, List[str]] = {} + for category in ("references", "templates", "assets", "scripts"): + base = skill_root / category + if not base.is_dir(): + continue + files = [ + str(path.relative_to(skill_root)) + for path in sorted(base.rglob("*")) + if path.is_file() + and validate_within_dir(path, skill_root) is None + ] + if files: + linked[category] = files + return linked or None + + def skill_view( name: str, file_path: str = None, @@ -1041,6 +1112,7 @@ def skill_view( plugin_skill_md, namespace, bare, + file_path=file_path, preprocess=preprocess, session_id=task_id, ) diff --git a/website/docs/developer-guide/plugins/index.md b/website/docs/developer-guide/plugins/index.md index d1db3a9a26..480e8b164d 100644 --- a/website/docs/developer-guide/plugins/index.md +++ b/website/docs/developer-guide/plugins/index.md @@ -42,6 +42,60 @@ See the full [Pluggable interfaces table](/user-guide/features/plugins#pluggable Plugins that integrate **someone else's product or project** — observability/metrics backends, vendor SaaS connectors, analytics dashboards, paid-service tie-ins — are built and distributed as **standalone plugin repos**, not merged into `NousResearch/hermes-agent`. Users install them into `~/.hermes/plugins/` or via a pip entry point; everything in this guide works the same way from a standalone repo. This is a coupling-and-maintenance decision (the core moves fast and we don't own your backend), not a quality bar — a plugin can be excellent and still belong in its own repo. Promote it in the Nous Research Discord `#plugins-skills-and-skins` channel. See [CONTRIBUTING.md](https://github.com/NousResearch/hermes-agent/blob/main/CONTRIBUTING.md) for the policy. ::: +## Portable Agent Plugins v1 packages + +Hermes can also install and load directory packages that target the Agent +Plugins v1.0.0 format. This is a compatibility adapter for the portable +components Hermes already owns. It does not replace native `plugin.yaml` plus +`register(ctx)` plugins. + +```text +my-portable-plugin/ +├── plugin.json +├── skills/ +│ └── summarize/ +│ ├── SKILL.md +│ └── references/ +└── mcp.json +``` + +Install and activate a portable package through the normal workflow: + +```bash +hermes plugins install owner/repository --no-enable +hermes plugins list +hermes plugins enable +``` + +Portable packages are disabled after installation unless you explicitly enable +them. An enabled package may provide immediate `skills/*/SKILL.md` directories +and stdio MCP servers from root `mcp.json`. Skills are read-only, namespaced, +and loaded through `skills_list` plus `skill_view`. MCP commands are passed as +one executable token with a separate argument list, never through a shell. + +Hermes validates `plugin.json`, Agent Skills frontmatter, fixed component +locations, `mcp.json`, resolved paths, and symlink containment locally. It does +not fetch JSON schemas while loading a package. A bad skill or MCP entry is +skipped at its own boundary when valid sibling components can still load. +`PLUGIN_ROOT` points to the resolved package root. `PLUGIN_DATA` points to a +profile-scoped writable directory managed by Hermes. +Values declared in portable MCP `env` are visible package data, not a secret +storage mechanism. Do not place credentials in `mcp.json`. + +The current portable subset supports stdio MCP only. Portable Streamable HTTP +and legacy SSE entries are reported and skipped because the native remote +client does not yet prove the v1 configured-header redirect boundary end to +end. Agent Plugins v1 does not define trust, permissions, provenance, or a +sandbox. Enabling a package grants its instructions and local executable the +same full-trust posture as other installed Hermes plugins. + +The [rendered specification](https://agent-plugins.org/specification) currently +labels v1.0.0 a Working Draft, while the +[versioned specification repository](https://github.com/agentplugins/agent-plugins-spec/blob/main/spec/1.0.0.md) +records it as Published. Hermes keys behavior on the canonical v1.0.0 schema +identifiers and normative text, not either mutable status label. This is an +explicit supported subset, not a claim of full Agent Plugins conformance. + ## What you're building A **calculator** plugin with two tools: @@ -353,8 +407,8 @@ hermes logs --level WARNING | grep -i plugin Common reasons a plugin doesn't appear: - **Not enabled in config** — plugins are opt-in. Run `hermes plugins enable ` (the name comes from the `plugins list` output, which can be `/` for nested layouts). -- **Wrong directory layout** — must be `~/.hermes/plugins//plugin.yaml` (flat) or `~/.hermes/plugins///plugin.yaml` (one level of category nesting, max). Anything deeper is ignored. -- **Missing `__init__.py`** — the plugin directory needs both `plugin.yaml` and `__init__.py` with a `register(ctx)` function. +- **Wrong directory layout:** Native packages use `~/.hermes/plugins//plugin.yaml` (flat) or one category level. Portable packages use root `plugin.json` in the same locations. Anything deeper is ignored. +- **Missing `__init__.py`:** Native packages need both `plugin.yaml` and `__init__.py` with a `register(ctx)` function. Portable packages do not import Python and do not require `__init__.py`. - **Wrong `kind`** — gateway adapters need `kind: platform` in their manifest. Memory providers are auto-detected as `kind: exclusive` and routed through the `memory.provider` config instead of `plugins.enabled`. ## Your plugin's final structure diff --git a/website/docs/user-guide/cli.md b/website/docs/user-guide/cli.md index a6b9356c45..2bcde3d2af 100644 --- a/website/docs/user-guide/cli.md +++ b/website/docs/user-guide/cli.md @@ -51,6 +51,25 @@ hermes -w # Interactive mode in worktree hermes -w -z "Fix issue #123" # Single query in worktree ``` +### Plugin management + +The `hermes plugins` commands manage native Hermes plugins and portable Agent +Plugins v1 packages through the same opt-in workflow: + +```bash +hermes plugins install owner/repository --no-enable +hermes plugins list +hermes plugins enable +hermes plugins disable +hermes plugins update +hermes plugins remove +``` + +Portable packages remain disabled until explicitly enabled. Hermes currently +loads portable Agent Skills and stdio MCP entries. See the +[plugin developer guide](/developer-guide/plugins#portable-agent-plugins-v1-packages) +for the exact supported subset and trust boundary. + ## Interface Layout Stylized preview of the Hermes CLI layout showing the banner, conversation area, and fixed input prompt.