From 11fe666f7be2a80173155a9ab9c50cb27f0e233e Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Mon, 10 Aug 2026 00:21:05 +0900 Subject: [PATCH] feat(plugins): run portable MCP components --- docs/configuration.md | 20 +- nanobot/agent/agent_plugins.py | 411 ++++++++++++++++-- nanobot/agent/loop.py | 4 +- nanobot/agent/skills.py | 15 +- nanobot/agent/tools/mcp.py | 6 +- nanobot/webui/mcp_presets_api.py | 75 +++- nanobot/webui/settings_routes.py | 25 +- tests/agent/test_agent_plugins.py | 150 ++++++- tests/cli_apps/test_utils.py | 18 +- tests/webui/test_mcp_presets_api.py | 105 ++++- .../src/components/settings/SettingsView.tsx | 64 ++- webui/src/i18n/locales/en/common.json | 3 + webui/src/i18n/locales/es/common.json | 3 + webui/src/i18n/locales/fr/common.json | 3 + webui/src/i18n/locales/id/common.json | 3 + webui/src/i18n/locales/ja/common.json | 3 + webui/src/i18n/locales/ko/common.json | 3 + webui/src/i18n/locales/pt-BR/common.json | 3 + webui/src/i18n/locales/vi/common.json | 3 + webui/src/i18n/locales/zh-CN/common.json | 3 + webui/src/i18n/locales/zh-TW/common.json | 3 + webui/src/tests/settings-view.test.tsx | 60 +++ 22 files changed, 904 insertions(+), 79 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 74136d957..31547ca6e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2306,16 +2306,17 @@ Disabled skills are excluded from the main agent's skill summary, from always-on |--------|---------|-------------| | `agents.defaults.disabledSkills` | `[]` | List of skill directory names to exclude from loading. Applies to both built-in skills and workspace skills. | -### Agent Plugins v1 skills +### Agent Plugins v1 nanobot also discovers portable [Agent Plugins](https://agent-plugins.org/) placed under `/plugins//`. A supported package has a root `plugin.json` that targets -Agent Plugins v1 and one or more direct-child skills: +Agent Plugins v1 and may provide skills, MCP servers, or both: ```text plugins/ └── release-tools/ ├── plugin.json + ├── mcp.json └── skills/ └── release-notes/ └── SKILL.md @@ -2326,10 +2327,17 @@ skills. A workspace skill wins when it has the same name as a plugin skill; plug over built-in skills. Invalid manifests, invalid Agent Skills, nested skill directories, and paths that resolve outside the plugin root are ignored. -This initial compatibility layer loads the portable `skills/` component only. Agent Plugins -`mcp.json` is not started automatically: local MCP servers execute third-party processes and -need an explicit trust and approval flow. Configure a reviewed MCP server through **Apps** or -`tools.mcpServers` for now. +Portable MCP servers declared in `mcp.json` appear in **Apps**, but are never started merely +because a package exists. Enabling a plugin there is the explicit trust decision that activates +its executable components. The host expands `PLUGIN_ROOT` and an isolated `PLUGIN_DATA`, checks +package paths before launch, and hot-reloads MCP connections. Explicit `tools.mcpServers` +configuration wins over a plugin server if their host names collide. The v1 host currently +supports plugin `stdio` servers; unsupported remote transports are skipped independently. + +Plugins may optionally declare a shell-free `extensions.dev.nanobot.installCommand` array. The +local WebUI runs it once per plugin version before first enable; remote WebUI clients cannot run +plugin setup unless remote package installation was explicitly allowed. Agent Plugins v1 does +not define a registry, so package distribution remains separate from discovery and execution. CLI Apps installed from the WebUI use the same package layout. nanobot installs the CLI through its catalog adapter, then writes a skills-only Agent Plugin under `/plugins/`; updates diff --git a/nanobot/agent/agent_plugins.py b/nanobot/agent/agent_plugins.py index 2cdbeb455..3af973958 100644 --- a/nanobot/agent/agent_plugins.py +++ b/nanobot/agent/agent_plugins.py @@ -3,15 +3,22 @@ from __future__ import annotations import json +import os import re +import subprocess from dataclasses import dataclass +from hashlib import sha256 from pathlib import Path from typing import Any, cast import yaml from loguru import logger +from nanobot.config.loader import get_config_path +from nanobot.config.schema import MCPServerConfig + AGENT_PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json" +AGENT_PLUGIN_MCP_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json" _PLUGIN_NAME = re.compile(r"^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$") _SKILL_NAME = re.compile(r"^(?!.*--)[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$") @@ -30,6 +37,10 @@ _MANIFEST_FIELDS = { } _STRING_FIELDS = {"version", "description", "homepage", "repository", "license"} _AUTHOR_FIELDS = {"name", "email", "url"} +_MCP_SERVER_FIELDS = { + "stdio": {"type", "command", "args", "env", "cwd"}, +} +_SETUP_ENV = {"HOME", "LANG", "LC_ALL", "LOGNAME", "PATH", "SHELL", "TMPDIR", "USER"} @dataclass(frozen=True) @@ -41,6 +52,52 @@ class AgentPluginSkill: plugin: str +@dataclass(frozen=True) +class AgentPlugin: + """A validated Agent Plugins v1 package installed in the workspace.""" + + name: str + root: Path + version: str + description: str + repository: str + display_name: str + category: str + accent_color: str | None + permissions: tuple[str, ...] + install_command: tuple[str, ...] + + +def discover_agent_plugins(workspace: Path) -> list[AgentPlugin]: + """Return valid packages from ``/plugins/*``.""" + workspace = workspace.expanduser().resolve() + plugins_root = workspace / "plugins" + if not plugins_root.is_dir(): + return [] + try: + root = plugins_root.resolve(strict=True) + except OSError: + return [] + if not root.is_relative_to(workspace): + logger.warning("Ignoring Agent Plugins directory outside the workspace") + return [] + try: + candidates = sorted(plugins_root.iterdir(), key=lambda path: path.name) + except OSError as exc: + logger.warning("Could not inspect Agent Plugins directory: {}", exc) + return [] + + plugins: list[AgentPlugin] = [] + for candidate in candidates: + plugin_root = _contained_directory(candidate, root) + if plugin_root is None: + continue + plugin = _load_manifest(plugin_root) + if plugin is not None: + plugins.append(plugin) + return plugins + + def discover_agent_plugin_skills(workspace: Path) -> list[AgentPluginSkill]: """Discover direct-child skills under ``/plugins/*``. @@ -48,37 +105,13 @@ def discover_agent_plugin_skills(workspace: Path) -> list[AgentPluginSkill]: workspace ``plugins`` directory so packages stay explicit and portable with the rest of the agent workspace. """ - workspace = workspace.expanduser().resolve() - plugins_root = workspace / "plugins" - if not plugins_root.is_dir(): - return [] - try: - resolved_plugins_root = plugins_root.resolve(strict=True) - except OSError: - return [] - if not resolved_plugins_root.is_relative_to(workspace): - logger.warning("Ignoring Agent Plugins directory outside the workspace") - return [] - - try: - candidates = sorted(plugins_root.iterdir(), key=lambda path: path.name) - except OSError as exc: - logger.warning("Could not inspect Agent Plugins directory: {}", exc) - return [] - skills: list[AgentPluginSkill] = [] - for candidate in candidates: - plugin_root = _contained_directory(candidate, resolved_plugins_root) - if plugin_root is None: - continue - plugin_name = _load_manifest_name(plugin_root) - if plugin_name is None: - continue - skills.extend(_discover_plugin_skills(plugin_name, plugin_root)) + for plugin in discover_agent_plugins(workspace): + skills.extend(_discover_plugin_skills(plugin.name, plugin.root)) return skills -def _load_manifest_name(plugin_root: Path) -> str | None: +def _load_manifest(plugin_root: Path) -> AgentPlugin | None: manifest = _contained_file(plugin_root / "plugin.json", plugin_root) if manifest is None: return None @@ -110,7 +143,95 @@ def _load_manifest_name(plugin_root: Path) -> str | None: logger.warning("Ignoring unknown Agent Plugin manifest field '{}' in '{}'", field, manifest) if "extensions" in payload and not isinstance(payload["extensions"], dict): logger.warning("Ignoring non-object Agent Plugin extensions in '{}'", manifest) - return name + extension = payload.get("extensions") + extension_payload = cast(dict[str, object], extension) if isinstance(extension, dict) else {} + nanobot_value = extension_payload.get("dev.nanobot") + nanobot = cast(dict[str, object], nanobot_value) if isinstance(nanobot_value, dict) else {} + return AgentPlugin( + name=name, + root=plugin_root, + version=_string(payload.get("version")), + description=_string(payload.get("description")), + repository=_string(payload.get("repository")), + display_name=_string(nanobot.get("displayName")) or name, + category=_string(nanobot.get("category")) or "Plugin", + accent_color=_accent_color(nanobot.get("accentColor")), + permissions=_string_tuple(nanobot.get("permissions")), + install_command=_install_command(nanobot.get("installCommand"), plugin_root), + ) + + +def agent_plugin_mcp_servers( + workspace: Path, + configured: dict[str, MCPServerConfig] | None = None, +) -> dict[str, MCPServerConfig]: + """Merge explicitly enabled plugin MCP servers with user configuration. + + User configuration wins on the unlikely event of a namespaced collision. + """ + servers: dict[str, MCPServerConfig] = {} + for plugin in discover_agent_plugins(workspace): + if not _enabled(workspace, plugin.name): + continue + plugin_servers = _plugin_mcp_servers(workspace, plugin) + for name, server in plugin_servers.items(): + host_name = plugin.name if len(plugin_servers) == 1 else f"{plugin.name}-{name}" + servers[host_name] = server + for name, server in (configured or {}).items(): + if name in servers: + logger.warning("Configured MCP server '{}' overrides an Agent Plugin server", name) + servers[name] = server + return servers + + +def agent_plugins_payload(workspace: Path) -> dict[str, Any]: + """Return installed Agent Plugins for the WebUI Apps surface.""" + plugins: list[dict[str, Any]] = [] + enabled_count = 0 + for plugin in discover_agent_plugins(workspace): + mcp_servers = sorted(_plugin_mcp_servers(workspace, plugin)) + if not mcp_servers and not plugin.install_command: + continue + enabled = _enabled(workspace, plugin.name) + enabled_count += int(enabled) + plugins.append( + { + "name": plugin.name, + "display_name": plugin.display_name, + "version": plugin.version, + "description": plugin.description, + "category": plugin.category, + "repository": plugin.repository, + "accent_color": plugin.accent_color, + "permissions": list(plugin.permissions), + "mcp_servers": mcp_servers, + "enabled": enabled, + "setup_required": bool(plugin.install_command) + and _setup_version(workspace, plugin.name) != (plugin.version or "unknown"), + } + ) + return {"plugins": plugins, "enabled_count": enabled_count} + + +def set_agent_plugin_enabled(workspace: Path, name: str, enabled: bool) -> dict[str, Any]: + """Enable or disable one installed plugin's executable MCP components.""" + plugin = next((item for item in discover_agent_plugins(workspace) if item.name == name), None) + if plugin is None: + raise ValueError(f"unknown Agent Plugin '{name}'") + data = _plugin_data_dir(workspace, plugin.name, create=True) + if enabled: + if plugin.install_command and _setup_version(workspace, plugin.name) != (plugin.version or "unknown"): + _run_install(plugin, data) + _write_state(data / "setup-version", plugin.version or "unknown") + _write_state(data / "enabled", "1") + else: + (data / "enabled").unlink(missing_ok=True) + payload = agent_plugins_payload(workspace) + payload["last_action"] = { + "ok": True, + "message": f"{plugin.display_name} {'enabled' if enabled else 'disabled'}.", + } + return payload def _valid_optional_fields(payload: dict[str, Any]) -> bool: @@ -133,6 +254,240 @@ def _valid_optional_fields(payload: dict[str, Any]) -> bool: ) +def _string(value: object) -> str: + return value.strip() if isinstance(value, str) else "" + + +def _string_tuple(value: object) -> tuple[str, ...]: + if not isinstance(value, list): + return () + items = cast(list[object], value) + return tuple(item.strip() for item in items if isinstance(item, str) and item.strip()) + + +def _accent_color(value: object) -> str | None: + return value if isinstance(value, str) and re.fullmatch(r"#[0-9a-fA-F]{6}", value) else None + + +def _install_command(value: object, plugin_root: Path) -> tuple[str, ...]: + """Validate nanobot's optional, shell-free setup command extension.""" + if not isinstance(value, list): + return () + items = cast(list[object], value) + if not 1 <= len(items) <= 32 or not all( + isinstance(item, str) and 0 < len(item) <= 4096 for item in items + ): + return () + command = cast(str, items[0]) + if not command.startswith("./"): + logger.warning("Ignoring non-relative Agent Plugin installCommand in '{}'", plugin_root) + return () + executable = _contained_file(plugin_root / command[2:], plugin_root) + if executable is None: + logger.warning("Ignoring invalid Agent Plugin installCommand in '{}'", plugin_root) + return () + return (str(executable), *(cast(str, item) for item in items[1:])) + + +def _plugin_mcp_servers(workspace: Path, plugin: AgentPlugin) -> dict[str, MCPServerConfig]: + path = _contained_file(plugin.root / "mcp.json", plugin.root) + if path is None: + return {} + try: + value = cast(object, json.loads(path.read_text(encoding="utf-8"))) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + logger.warning("Ignoring invalid MCP component for Agent Plugin '{}': {}", plugin.name, exc) + return {} + if not isinstance(value, dict): + return {} + payload = cast(dict[str, Any], value) + raw_servers = payload.get("mcpServers") + if ( + payload.keys() != {"$schema", "mcpServers"} + or payload.get("$schema") != AGENT_PLUGIN_MCP_SCHEMA + or not isinstance(raw_servers, dict) + ): + logger.warning("Ignoring invalid MCP component for Agent Plugin '{}'", plugin.name) + return {} + + data = _plugin_data_dir(workspace, plugin.name, create=True) + servers: dict[str, MCPServerConfig] = {} + for name, raw in cast(dict[str, object], raw_servers).items(): + if not name or len(name) > 128 or any(ord(char) < 32 for char in name): + logger.warning("Ignoring invalid MCP server name in Agent Plugin '{}'", plugin.name) + continue + server = _plugin_mcp_server(raw, plugin.root, data) + if server is None: + logger.warning("Ignoring invalid MCP server '{}' in Agent Plugin '{}'", name, plugin.name) + continue + servers[name] = server + return servers + + +def _plugin_mcp_server(raw: object, root: Path, data: Path) -> MCPServerConfig | None: + if not isinstance(raw, dict): + return None + payload = cast(dict[str, Any], raw) + transport = payload.get("type") + allowed = _MCP_SERVER_FIELDS.get(transport) if isinstance(transport, str) else None + if allowed is None or payload.keys() - allowed: + return None + if transport == "stdio": + command = _stdio_command(payload.get("command"), root) + args = payload.get("args", []) + env = payload.get("env", {}) + cwd = _stdio_cwd(payload.get("cwd"), root, data) + if ( + command is None + or not isinstance(args, list) + or not all(isinstance(item, str) for item in cast(list[object], args)) + or not isinstance(env, dict) + or cwd is None + ): + return None + env_payload = cast(dict[object, object], env) + if any( + not isinstance(key, str) + or key in {"PLUGIN_ROOT", "PLUGIN_DATA"} + or not isinstance(value, str) + for key, value in env_payload.items() + ): + return None + string_env = cast(dict[str, str], env) + replacements = {"${PLUGIN_ROOT}": str(root), "${PLUGIN_DATA}": str(data)} + return MCPServerConfig( + type="stdio", + command=command, + args=[_expand(item, replacements) for item in cast(list[str], args)], + env={ + **{key: _expand(value, replacements) for key, value in string_env.items()}, + "PLUGIN_ROOT": str(root), + "PLUGIN_DATA": str(data), + }, + cwd=str(cwd), + ) + + return None + + +def _stdio_command(value: object, root: Path) -> str | None: + if not isinstance(value, str) or not value: + return None + if value.startswith("./"): + executable = _contained_file(root / value[2:], root) + return str(executable) if executable is not None else None + if any(char.isspace() for char in value) or "/" in value or "\\" in value: + return None + return value + + +def _stdio_cwd(value: object, root: Path, data: Path) -> Path | None: + if value is None: + return root + if not isinstance(value, str): + return None + if value.startswith("./"): + return _contained_directory(root / value[2:], root) + for placeholder, base in (("${PLUGIN_ROOT}", root), ("${PLUGIN_DATA}", data)): + if value == placeholder or value.startswith(f"{placeholder}/"): + relative = value[len(placeholder):].lstrip("/") + candidate = (base / relative).resolve() + if not candidate.is_relative_to(base): + return None + if base == data: + candidate.mkdir(parents=True, exist_ok=True) + candidate.chmod(0o700) + return candidate if candidate.is_dir() else None + return None + + +def _expand(value: str, replacements: dict[str, str]) -> str: + for token, replacement in replacements.items(): + value = value.replace(token, replacement) + return value + + +def _plugin_data_dir(workspace: Path, name: str, *, create: bool) -> Path: + workspace_id = sha256(str(workspace.expanduser().resolve()).encode()).hexdigest()[:12] + config_root = get_config_path().expanduser().resolve().parent + plugin_data_root = config_root / "plugin-data" + if create: + plugin_data_root.mkdir(parents=True, exist_ok=True) + try: + resolved_plugin_data = plugin_data_root.resolve(strict=create) + except OSError as exc: + raise RuntimeError("Agent Plugin data root is unavailable") from exc + if not resolved_plugin_data.is_relative_to(config_root): + raise RuntimeError("Agent Plugin data root escapes the nanobot config directory") + state_root = resolved_plugin_data / workspace_id + if create: + resolved_plugin_data.chmod(0o700) + state_root.mkdir(parents=True, exist_ok=True) + try: + resolved_state = state_root.resolve(strict=create) + except OSError as exc: + raise RuntimeError("Agent Plugin state directory is unavailable") from exc + if not resolved_state.is_relative_to(config_root): + raise RuntimeError("Agent Plugin state directory escapes the nanobot config directory") + if create: + resolved_state.chmod(0o700) + data = resolved_state / name + if create: + data.mkdir(exist_ok=True) + resolved_data = data.resolve(strict=True) + if not resolved_data.is_relative_to(resolved_state): + raise RuntimeError("Agent Plugin data directory escapes its state directory") + resolved_data.chmod(0o700) + return resolved_data + return data + + +def _enabled(workspace: Path, name: str) -> bool: + return (_plugin_data_dir(workspace, name, create=False) / "enabled").is_file() + + +def _setup_version(workspace: Path, name: str) -> str: + try: + return (_plugin_data_dir(workspace, name, create=False) / "setup-version").read_text( + encoding="utf-8" + ).strip() + except (OSError, UnicodeError): + return "" + + +def _write_state(path: Path, value: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.parent.chmod(0o700) + temporary = path.with_name(f".{path.name}.tmp") + temporary.write_text(value, encoding="utf-8") + temporary.chmod(0o600) + temporary.replace(path) + path.chmod(0o600) + + +def _run_install(plugin: AgentPlugin, data: Path) -> None: + env = { + **{key: value for key in _SETUP_ENV if (value := os.environ.get(key)) is not None}, + "PLUGIN_ROOT": str(plugin.root), + "PLUGIN_DATA": str(data), + } + try: + result = subprocess.run( + plugin.install_command, + cwd=plugin.root, + env=env, + capture_output=True, + text=True, + timeout=600, + check=False, + ) + except subprocess.TimeoutExpired as exc: + raise RuntimeError(f"{plugin.display_name} setup timed out") from exc + if result.returncode: + output = (result.stderr or result.stdout).strip()[-2000:] + raise RuntimeError(output or f"{plugin.display_name} setup failed") + + def _discover_plugin_skills(plugin_name: str, plugin_root: Path) -> list[AgentPluginSkill]: skills_root = plugin_root / "skills" if not skills_root.exists(): diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 9fa12e003..02a8ca55d 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -485,6 +485,8 @@ class AgentLoop: config, provider_snapshot_loader, ) + from nanobot.agent.agent_plugins import agent_plugin_mcp_servers + return cls( bus=bus, provider=provider, @@ -499,7 +501,7 @@ class AgentLoop: provider_retry_mode=defaults.provider_retry_mode, tool_hint_max_length=defaults.tool_hint_max_length, restrict_to_workspace=config.tools.restrict_to_workspace, - mcp_servers=config.tools.mcp_servers, + mcp_servers=agent_plugin_mcp_servers(config.workspace_path, config.tools.mcp_servers), channels_config=config.channels, timezone=defaults.timezone, unified_session=defaults.unified_session, diff --git a/nanobot/agent/skills.py b/nanobot/agent/skills.py index 4693c9a46..84a7c2ee1 100644 --- a/nanobot/agent/skills.py +++ b/nanobot/agent/skills.py @@ -5,11 +5,12 @@ import os import re import shutil from pathlib import Path -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast import yaml -from nanobot.agent.agent_plugins import discover_agent_plugin_skills +if TYPE_CHECKING: + from nanobot.agent.agent_plugins import AgentPluginSkill # Default builtin skills directory (relative to this file) BUILTIN_SKILLS_DIR = Path(__file__).parent.parent / "skills" @@ -35,7 +36,7 @@ class SkillsLoader: self.workspace_skills = workspace / "skills" self.builtin_skills = builtin_skills_dir or BUILTIN_SKILLS_DIR self.disabled_skills = disabled_skills or set() - self.plugin_skills = discover_agent_plugin_skills(workspace) + self.plugin_skills: list[AgentPluginSkill] = [] def _skill_entries_from_dir(self, base: Path, source: str, *, skip_names: set[str] | None = None) -> list[dict[str, str]]: if not base.exists(): @@ -63,6 +64,8 @@ class SkillsLoader: Returns: List of skill info dicts with 'name', 'path', 'source'. """ + from nanobot.agent.agent_plugins import discover_agent_plugin_skills + self.plugin_skills = discover_agent_plugin_skills(self.workspace) skills = self._skill_entries_from_dir(self.workspace_skills, "workspace") seen_names = {entry["name"] for entry in skills} @@ -103,8 +106,12 @@ class SkillsLoader: workspace_path = self.workspace_skills / name / "SKILL.md" if workspace_path.exists(): return workspace_path.read_text(encoding="utf-8") + if not self.plugin_skills: + from nanobot.agent.agent_plugins import discover_agent_plugin_skills + + self.plugin_skills = discover_agent_plugin_skills(self.workspace) for plugin_skill in self.plugin_skills: - if plugin_skill.name == name: + if plugin_skill.name == name and plugin_skill.path.is_file(): return plugin_skill.path.read_text(encoding="utf-8") if self.builtin_skills: builtin_path = self.builtin_skills / name / "SKILL.md" diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py index a4d3aad5d..30f916849 100644 --- a/nanobot/agent/tools/mcp.py +++ b/nanobot/agent/tools/mcp.py @@ -1286,10 +1286,14 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]: "requires_restart": True, } try: + from nanobot.agent.agent_plugins import agent_plugin_mcp_servers from nanobot.config.loader import load_config, resolve_config_env_vars config = resolve_config_env_vars(load_config()) - next_servers = dict(config.tools.mcp_servers) + next_servers = agent_plugin_mcp_servers( + config.workspace_path, + config.tools.mcp_servers, + ) except Exception as exc: logger.warning("MCP hot reload could not read config: {}", exc) return { diff --git a/nanobot/webui/mcp_presets_api.py b/nanobot/webui/mcp_presets_api.py index 162c793fa..aee298e1a 100644 --- a/nanobot/webui/mcp_presets_api.py +++ b/nanobot/webui/mcp_presets_api.py @@ -16,6 +16,7 @@ from datetime import datetime, timezone from pathlib import Path from typing import TYPE_CHECKING, Any, Literal, Mapping, cast +from nanobot.agent.agent_plugins import agent_plugins_payload, set_agent_plugin_enabled from nanobot.agent.tools.registry import ToolRegistry from nanobot.apps.protocol import app_manifest, compact_dict from nanobot.config.loader import load_config, resolve_config_env_vars, save_config @@ -840,6 +841,44 @@ def _custom_payload( } +def _agent_plugin_payload(plugin: Mapping[str, Any]) -> dict[str, Any]: + enabled = bool(plugin.get("enabled")) + permissions = plugin.get("permissions") + mcp_servers = plugin.get("mcp_servers") + permission_names = ( + [str(item) for item in cast(list[object], permissions)] + if isinstance(permissions, list) + else [] + ) + server_names = ( + [str(item) for item in cast(list[object], mcp_servers)] + if isinstance(mcp_servers, list) + else [] + ) + return { + "name": f"plugin-{plugin['name']}", + "display_name": str(plugin.get("display_name") or plugin["name"]), + "category": str(plugin.get("category") or "plugin"), + "description": str(plugin.get("description") or "Agent Plugin"), + "docs_url": str(plugin.get("repository") or ""), + "transport": "stdio", + "requires": ", ".join(permission_names), + "note": "", + "install_supported": True, + "installed": True, + "configured": enabled, + "available": enabled, + "status": "configured" if enabled else "not_installed", + "logo_url": None, + "brand_color": plugin.get("accent_color"), + "required_fields": [], + "connection_summary": ", ".join(server_names), + "enabled_tools": ["*"], + "tool_names": [], + "source": "agent-plugin", + } + + def mcp_presets_payload( *, last_action: dict[str, Any] | None = None, @@ -858,9 +897,17 @@ def mcp_presets_payload( for name, cfg in sorted(config.tools.mcp_servers.items()) if name not in known ] + plugin_state = agent_plugins_payload(config.workspace_path) + existing_names = {str(row["name"]) for row in (*preset_rows, *custom_rows)} + plugin_rows = [ + row + for plugin in plugin_state["plugins"] + if (row := _agent_plugin_payload(plugin))["name"] not in existing_names + ] payload: dict[str, Any] = { - "presets": [*preset_rows, *custom_rows], - "installed_count": len(config.tools.mcp_servers), + "presets": [*preset_rows, *custom_rows, *plugin_rows], + "installed_count": len(config.tools.mcp_servers) + + sum(int(row["configured"]) for row in plugin_rows), } if last_action is not None: payload["last_action"] = last_action @@ -1388,6 +1435,30 @@ async def mcp_presets_settings_action( config_path = config.path if config is not None else None if action is None: return mcp_presets_payload(config_path=config_path) + name = (_query_first(query, "name") or "").strip() + if name.startswith("plugin-"): + plugin_config = load_config(config_path) if config_path is not None else load_config() + plugin_name = name.removeprefix("plugin-") + installed = { + str(plugin["name"]) + for plugin in agent_plugins_payload(plugin_config.workspace_path)["plugins"] + } + if name not in plugin_config.tools.mcp_servers and plugin_name in installed: + if action not in {"enable", "remove"}: + raise McpPresetError("Agent Plugins support enable and disable actions only") + state = await asyncio.to_thread( + set_agent_plugin_enabled, + plugin_config.workspace_path, + plugin_name, + action == "enable", + ) + payload = mcp_presets_payload( + last_action=state.get("last_action"), + config_path=config_path, + ) + if reload_mcp is not None: + payload = attach_mcp_hot_reload_result(payload, await reload_mcp()) + return payload if action == "test": return await mcp_presets_test_action(query, config_path=config_path) if config is not None: diff --git a/nanobot/webui/settings_routes.py b/nanobot/webui/settings_routes.py index bd50124dc..6b2a7b2d5 100644 --- a/nanobot/webui/settings_routes.py +++ b/nanobot/webui/settings_routes.py @@ -17,6 +17,7 @@ from typing import Any, cast from websockets.http11 import Request as WsRequest from websockets.http11 import Response +from nanobot.agent.agent_plugins import agent_plugins_payload from nanobot.agent.tools.image_generation import request_image_generation_reload from nanobot.agent.tools.mcp import request_mcp_reload from nanobot.api.runtime import ApiRuntime, ApiStartOptions, api_runtime_paths @@ -280,12 +281,12 @@ class WebUISettingsRouter: if path == "/api/settings/pairing/deny": return self._handle_settings_pairing_action(request, "deny") if path == "/api/settings/mcp-presets": - return await self._handle_settings_mcp_presets(request) + return await self._handle_settings_mcp_presets(connection, request) if path == "/api/settings/version-check": return await self._handle_settings_version_check(request) mcp_action = _MCP_PRESET_ACTIONS_BY_PATH.get(path) if mcp_action is not None: - return await self._handle_settings_mcp_presets(request, mcp_action) + return await self._handle_settings_mcp_presets(connection, request, mcp_action) return None @staticmethod @@ -1208,15 +1209,33 @@ class WebUISettingsRouter: async def _handle_settings_mcp_presets( self, + connection: Any, request: WsRequest, action: str | None = None, ) -> Response: if not self._authorized(request): return self._unauthorized() try: + query = self._parse_mcp_settings_query(request) + name = (_query_first(query, "name") or "").strip() + if action == "enable" and name.startswith("plugin-"): + config = load_config() + plugin_names = { + f"plugin-{plugin['name']}" + for plugin in agent_plugins_payload(config.workspace_path)["plugins"] + } + if ( + name not in config.tools.mcp_servers + and name in plugin_names + and not self._allow_feature_package_install(connection, request) + ): + return self._error_response( + 403, + "Agent Plugin setup is restricted to the local WebUI", + ) payload = await mcp_presets_settings_action( action, - self._parse_mcp_settings_query(request), + query, reload_mcp=lambda: request_mcp_reload(self.bus), config=self.settings.config, ) diff --git a/tests/agent/test_agent_plugins.py b/tests/agent/test_agent_plugins.py index 9d18cfdb4..d138870dd 100644 --- a/tests/agent/test_agent_plugins.py +++ b/tests/agent/test_agent_plugins.py @@ -1,10 +1,20 @@ import json import shutil +import subprocess from pathlib import Path +from typing import Any, cast import pytest -from nanobot.agent.agent_plugins import AGENT_PLUGIN_SCHEMA, discover_agent_plugin_skills +from nanobot.agent import agent_plugins +from nanobot.agent.agent_plugins import ( + AGENT_PLUGIN_MCP_SCHEMA, + AGENT_PLUGIN_SCHEMA, + agent_plugin_mcp_servers, + agent_plugins_payload, + discover_agent_plugin_skills, + set_agent_plugin_enabled, +) from nanobot.agent.skills import SkillsLoader @@ -170,3 +180,141 @@ def test_plugin_skill_symlink_cannot_escape_plugin_root(tmp_path: Path) -> None: pytest.skip(f"directory symlink unavailable: {exc}") assert discover_agent_plugin_skills(tmp_path) == [] + + +def test_plugin_mcp_requires_explicit_enable(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + agent_plugins, + "get_config_path", + lambda: tmp_path / "config" / "config.json", + ) + plugin = _write_plugin(tmp_path, "desktop") + executable = plugin / "bin" / "server" + executable.parent.mkdir() + executable.write_text("#!/bin/sh\n", encoding="utf-8") + (plugin / "mcp.json").write_text( + json.dumps( + { + "$schema": AGENT_PLUGIN_MCP_SCHEMA, + "mcpServers": { + "desktop": { + "type": "stdio", + "command": "./bin/server", + "args": ["--data", "${PLUGIN_DATA}/state"], + "cwd": "${PLUGIN_ROOT}", + } + }, + } + ), + encoding="utf-8", + ) + + assert agent_plugin_mcp_servers(tmp_path) == {} + set_agent_plugin_enabled(tmp_path, "desktop", True) + + servers = agent_plugin_mcp_servers(tmp_path) + server = servers["desktop"] + assert server.command == str(executable) + assert server.cwd == str(plugin) + assert server.env["PLUGIN_ROOT"] == str(plugin) + assert server.args[0] == "--data" + assert server.args[1].endswith("/state") + + set_agent_plugin_enabled(tmp_path, "desktop", False) + assert agent_plugin_mcp_servers(tmp_path) == {} + + +def test_plugin_setup_command_runs_once_per_version( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + agent_plugins, + "get_config_path", + lambda: tmp_path / "config" / "config.json", + ) + monkeypatch.setenv("NANOBOT_TEST_SECRET", "do-not-inherit") + plugin = _write_plugin( + tmp_path, + "desktop", + manifest={ + "$schema": AGENT_PLUGIN_SCHEMA, + "name": "desktop", + "version": "1.2.3", + "extensions": {"dev.nanobot": {"installCommand": ["./bin/install"]}}, + }, + ) + executable = plugin / "bin" / "install" + executable.parent.mkdir() + executable.write_text("setup", encoding="utf-8") + calls: list[tuple[tuple[str, ...], dict[str, str]]] = [] + + def run(command: tuple[str, ...], **kwargs: Any) -> subprocess.CompletedProcess[str]: + calls.append((command, cast(dict[str, str], kwargs["env"]))) + return subprocess.CompletedProcess(command, 0, "ok", "") + + monkeypatch.setattr(agent_plugins.subprocess, "run", run) + + set_agent_plugin_enabled(tmp_path, "desktop", True) + set_agent_plugin_enabled(tmp_path, "desktop", False) + set_agent_plugin_enabled(tmp_path, "desktop", True) + + assert len(calls) == 1 + assert calls[0][0] == (str(executable),) + assert calls[0][1]["PLUGIN_ROOT"] == str(plugin) + assert "NANOBOT_TEST_SECRET" not in calls[0][1] + assert agent_plugins_payload(tmp_path)["plugins"][0]["setup_required"] is False + + +def test_invalid_plugin_mcp_entries_do_not_block_valid_servers( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + agent_plugins, + "get_config_path", + lambda: tmp_path / "config" / "config.json", + ) + plugin = _write_plugin(tmp_path, "network") + executable = plugin / "bin" / "server" + executable.parent.mkdir() + executable.write_text("#!/bin/sh\n", encoding="utf-8") + (plugin / "mcp.json").write_text( + json.dumps( + { + "$schema": AGENT_PLUGIN_MCP_SCHEMA, + "mcpServers": { + "public-http": {"type": "streamable-http", "url": "http://example.com/mcp"}, + "local": {"type": "stdio", "command": "./bin/server"}, + "escape": {"type": "stdio", "command": "../outside"}, + }, + } + ), + encoding="utf-8", + ) + set_agent_plugin_enabled(tmp_path, "network", True) + + assert list(agent_plugin_mcp_servers(tmp_path)) == ["network"] + + +def test_plugin_state_symlink_cannot_escape_config_root( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config = tmp_path / "config" + outside = tmp_path / "outside" + config.mkdir() + 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", + ) + _write_plugin(tmp_path, "desktop") + + with pytest.raises(RuntimeError, match="escapes the nanobot config directory"): + set_agent_plugin_enabled(tmp_path, "desktop", True) diff --git a/tests/cli_apps/test_utils.py b/tests/cli_apps/test_utils.py index 2aed0c5d1..2b695ba67 100644 --- a/tests/cli_apps/test_utils.py +++ b/tests/cli_apps/test_utils.py @@ -66,16 +66,14 @@ def test_structured_cli_app_attachment_uses_existing_legacy_skill(tmp_path): legacy.parent.mkdir(parents=True) legacy.write_text("# Legacy Uni-Mol\n", encoding="utf-8") - lines = runtime_lines( - SimpleNamespace( - content="please use @unimol_tools", - metadata={ - "cli_apps": [{ - "name": "unimol_tools", - "entry_point": "cli-anything-unimol-tools", - }], - }, - ), + lines = runtime_lines_for_request( + "please use @unimol_tools", + { + "cli_apps": [{ + "name": "unimol_tools", + "entry_point": "cli-anything-unimol-tools", + }], + }, tmp_path, ) diff --git a/tests/webui/test_mcp_presets_api.py b/tests/webui/test_mcp_presets_api.py index e2a2a6683..506e9ba4d 100644 --- a/tests/webui/test_mcp_presets_api.py +++ b/tests/webui/test_mcp_presets_api.py @@ -1,22 +1,66 @@ from __future__ import annotations import asyncio +import json +from pathlib import Path import pytest +from nanobot.agent.agent_plugins import AGENT_PLUGIN_MCP_SCHEMA, AGENT_PLUGIN_SCHEMA from nanobot.config.loader import load_config from nanobot.webui.mcp_presets_api import ( McpPresetError, 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" + command = root / "bin" / "server" + command.parent.mkdir(parents=True, exist_ok=True) + command.write_text("#!/bin/sh\n", encoding="utf-8") + (root / "plugin.json").write_text( + json.dumps( + { + "$schema": AGENT_PLUGIN_SCHEMA, + "name": "desktop", + "description": "Control the local desktop.", + "extensions": { + "dev.nanobot": { + "displayName": "Desktop Control", + "accentColor": "#ff7a1a", + "permissions": ["screen-recording"], + } + }, + } + ), + encoding="utf-8", + ) + (root / "mcp.json").write_text( + json.dumps( + { + "$schema": AGENT_PLUGIN_MCP_SCHEMA, + "mcpServers": { + "desktop": {"type": "stdio", "command": "./bin/server"}, + }, + } + ), + encoding="utf-8", + ) def test_mcp_presets_payload_lists_supported_cards(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: @@ -55,6 +99,65 @@ 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"] == "plugin-desktop" + assert row["display_name"] == "Desktop Control" + assert row["installed"] is True + assert row["configured"] is False + + async def reload() -> dict[str, object]: + return {"ok": True, "message": "MCP reloaded.", "requires_restart": False} + + enabled = asyncio.run( + mcp_presets_settings_action( + "enable", + {"name": ["plugin-desktop"]}, + reload_mcp=reload, + ) + ) + enabled_row = next(item for item in enabled["presets"] if item["name"] == "plugin-desktop") + assert enabled_row["configured"] is True + assert enabled["requires_restart"] is False + + disabled = asyncio.run( + mcp_presets_settings_action( + "remove", + {"name": ["plugin-desktop"]}, + reload_mcp=reload, + ) + ) + disabled_row = next(item for item in disabled["presets"] if item["name"] == "plugin-desktop") + assert disabled_row["configured"] is False + + +def test_explicit_mcp_config_wins_over_plugin_catalog_name( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _use_config(tmp_path, monkeypatch) + 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") + _write_agent_plugin(load_config().workspace_path) + + rows = [ + item for item in mcp_presets_payload()["presets"] if item["name"] == "plugin-desktop" + ] + + assert len(rows) == 1 + assert rows[0]["source"] == "custom" + + def test_enable_browserbase_writes_scrubbed_config_payload( tmp_path, monkeypatch: pytest.MonkeyPatch, diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx index 793bfec9b..3ef0a4caf 100644 --- a/webui/src/components/settings/SettingsView.tsx +++ b/webui/src/components/settings/SettingsView.tsx @@ -7411,7 +7411,11 @@ function AppsCatalogSettings({ ] .filter((item) => { if (normalizedQuery) return appsSearchText(item).includes(normalizedQuery); - return filter === "ready" ? appsReady(item) : item.kind === filter; + if (filter === "ready") return appsReady(item); + if (filter === "cli") { + return item.kind === "cli" || item.preset.source === "agent-plugin"; + } + return item.kind === "mcp" && item.preset.source !== "agent-plugin"; }) .sort((left, right) => { const rank = Number(!appsReady(left)) - Number(!appsReady(right)); @@ -7694,6 +7698,7 @@ function McpAppsCatalogRow({ const testBusy = actionKey === `test:${preset.name}`; const toolsBusy = actionKey === `tools:${preset.name}`; const busy = enableBusy || removeBusy || testBusy || toolsBusy; + const agentPlugin = preset.source === "agent-plugin"; const missingFields = preset.required_fields.filter((field) => field.required && !field.configured); const hasFields = preset.required_fields.length > 0; const needsSetupInput = missingFields.length > 0; @@ -7705,8 +7710,13 @@ function McpAppsCatalogRow({ const enabledTools = preset.enabled_tools ?? ["*"]; const allowAllTools = enabledTools.includes("*"); const enabledSet = new Set(allowAllTools ? toolNames : enabledTools); - const description = preset.description || preset.note || preset.requires || preset.name; - const statusLabel = mcpPresetStatusLabel(preset.status, tx); + const description = preset.description || preset.note || preset.name; + const detail = agentPlugin && preset.requires + ? `${description} · ${preset.requires}` + : description || preset.requires; + const statusLabel = agentPlugin + ? tx("settings.apps.pluginEnabled", "Plugin enabled") + : mcpPresetStatusLabel(preset.status, tx); useEffect(() => { if (preset.configured || !preset.install_supported) setSetupOpen(false); @@ -7739,9 +7749,13 @@ function McpAppsCatalogRow({

{preset.display_name}

- {tx("settings.apps.mcpLabel", "Integration")} + + {agentPlugin + ? tx("settings.apps.pluginLabel", "Plugin") + : tx("settings.apps.mcpLabel", "Integration")} +
-

{description}

+

{detail}

{readyInstalled ? ( @@ -7758,35 +7772,41 @@ function McpAppsCatalogRow({ - onAction("test", preset.name)}> - - {tx("settings.mcp.test", "Test")} - - {toolNames.length ? ( + {!agentPlugin ? ( + onAction("test", preset.name)}> + + {tx("settings.mcp.test", "Test")} + + ) : null} + {!agentPlugin && toolNames.length ? ( setToolsOpen((open) => !open)}> {tx("settings.mcp.toolScope", "Tools")} ) : null} onAction("remove", preset.name)} > - - {tx("settings.mcp.remove", "Remove")} + {agentPlugin ? : } + {agentPlugin + ? tx("settings.apps.pluginDisable", "Disable") + : tx("settings.mcp.remove", "Remove")} - onAction("remove", preset.name)} - > - - + {!agentPlugin ? ( + onAction("remove", preset.name)} + > + + + ) : null} ) : preset.installed && !preset.configured ? ( { expect(screen.queryByText("Uninstalled CLI for AnyGen.")).not.toBeInTheDocument(); }); + it("sets up and enables an installed Agent Plugin explicitly", async () => { + const plugin = { + name: "plugin-nanobot-computer-use", + display_name: "Computer Use", + description: "Control the desktop with a live preview.", + category: "Productivity", + docs_url: "https://github.com/nanobot-dev/nanobot-computer-use", + transport: "stdio", + requires: "screen-recording, accessibility", + note: "", + install_supported: true, + installed: true, + configured: false, + available: false, + status: "not_installed", + logo_url: null, + brand_color: "#ff7a1a", + required_fields: [], + connection_summary: "computer-use", + source: "agent-plugin", + }; + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url === "/api/settings") return jsonResponse(settingsPayload()); + if (url === "/api/settings/cli-apps") { + return jsonResponse({ apps: [], installed_count: 0 }); + } + if (url === "/api/settings/mcp-presets") { + return jsonResponse({ presets: [plugin], installed_count: 0 }); + } + if (url === "/api/settings/mcp-presets/enable?name=plugin-nanobot-computer-use") { + return jsonResponse({ + presets: [{ ...plugin, configured: true, available: true, status: "configured" }], + installed_count: 1, + last_action: { ok: true, message: "Computer Use enabled." }, + }); + } + return jsonResponse({}); + }); + vi.stubGlobal("fetch", fetchMock); + + renderSettingsView(); + + expect(await screen.findByText("Computer Use")).toBeInTheDocument(); + expect(screen.getByText("Plugin")).toBeInTheDocument(); + expect( + screen.getByText("Control the desktop with a live preview. · screen-recording, accessibility"), + ).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Enable" })); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith( + "/api/settings/mcp-presets/enable?name=plugin-nanobot-computer-use", + expect.objectContaining({ headers: { Authorization: "Bearer tok" } }), + ); + }); + expect(await screen.findByText("Computer Use enabled.")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Plugin enabled" })).toBeInTheDocument(); + }); + it("keeps runtime dependencies out of Apps and explains chat mentions", async () => { vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { const url = String(input);