From d5e0df69635184b6530359f27a344b394548da51 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:02:21 +0900 Subject: [PATCH] feat(plugins): integrate portable Agent Plugins --- docs/architecture.md | 2 +- docs/configuration.md | 12 + nanobot/agent/loop.py | 4 +- nanobot/agent/plugins.py | 361 ++++++++++++++++++ nanobot/agent/skills.py | 96 +++-- nanobot/agent/tools/mcp.py | 6 +- nanobot/apps/cli/service.py | 86 ++++- nanobot/apps/cli/utils.py | 4 +- .../tests/test_websocket_http_routes.py | 8 + nanobot/webui/mcp_presets_api.py | 64 +++- nanobot/webui/settings_routes.py | 1 + nanobot/webui/ws_http.py | 1 + tests/agent/test_agent_plugins.py | 215 +++++++++++ tests/agent/test_skills_loader.py | 1 + tests/cli_apps/test_service.py | 72 +++- tests/cli_apps/test_utils.py | 20 +- tests/webui/test_mcp_presets_api.py | 87 ++++- .../settings/system/AppsSettings.tsx | 104 +++-- .../system/createSystemSettingsActions.ts | 2 +- webui/src/lib/api.ts | 2 +- webui/src/lib/mcp-preset-events.ts | 5 +- webui/src/lib/types.ts | 1 + webui/src/tests/settings-system.test.tsx | 67 ++++ 23 files changed, 1111 insertions(+), 110 deletions(-) create mode 100644 nanobot/agent/plugins.py create mode 100644 tests/agent/test_agent_plugins.py diff --git a/docs/architecture.md b/docs/architecture.md index ddd6deeb2..c7fea8ab2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -202,7 +202,7 @@ When changing tools, channels, file access, WebUI workspace behavior, or network | Channel | Export a `ChannelPlugin` descriptor, keep its runtime and optional setup surfaces in one package, and follow [`channel-package-guide.md`](./channel-package-guide.md) | | Tool | Implement a tool under `agent/tools/` or expose a plugin entry point | | MCP | Add `tools.mcpServers` config | -| Skill | Add workspace skill files under `/skills/` or built-in skills under `nanobot/skills/` | +| Skill | Add workspace skills under `/skills/`, Agent Plugins v1 under `/plugins/`, or built-in skills under `nanobot/skills/` | Prefer existing registry/discovery patterns over ad hoc wiring. diff --git a/docs/configuration.md b/docs/configuration.md index c1c371b4f..48ec4b8f2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2347,6 +2347,18 @@ 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 + +nanobot discovers [Agent Plugins](https://agent-plugins.org/) under `/plugins/`; a v1 package has `plugin.json` and may add `mcp.json`, `skills//SKILL.md`, or both. + +Directory presence means installed; activation is explicit in **Apps**. Skills use progressive loading and `$skill-name` invocation, with workspace > plugin > built-in precedence. +Enabled `stdio` servers receive contained `PLUGIN_ROOT` and isolated `PLUGIN_DATA` paths; explicit +`tools.mcpServers` entries win collisions. Invalid or escaping components are ignored. + +Enabled plugins run as the nanobot user; permissions are descriptive, not an OS sandbox. The optional `extensions.dev.nanobot.logo` accepts a contained PNG, JPEG, or WebP up to 256 KiB. + +CLI Apps use the same skills-only package layout while their installer manages executables, updates, and removal. Future catalogs can place packages before using this activation path. + ## Tool Hint Max Length Tool hints are the short progress messages shown when the agent calls tools (e.g. `$ cd …/project && npm test`). By default, these are truncated at 40 characters, which can make long commands hard to read. diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 9fa12e003..23c555cf4 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -485,6 +485,8 @@ class AgentLoop: config, provider_snapshot_loader, ) + from nanobot.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/plugins.py b/nanobot/agent/plugins.py new file mode 100644 index 000000000..12a1225eb --- /dev/null +++ b/nanobot/agent/plugins.py @@ -0,0 +1,361 @@ +"""Load and activate locally installed Agent Plugin packages.""" + +from __future__ import annotations + +import base64 +import json +import re +from dataclasses import dataclass, replace +from hashlib import sha256 +from pathlib import Path +from typing import cast + +from loguru import logger +from pydantic import ValidationError + +from nanobot.agent.skills import parse_skill_metadata, valid_skill_metadata +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])?$") +_MCP_SERVER_FIELDS = {"type", "command", "args", "env", "cwd"} +_MAX_LOGO_BYTES = 256 * 1024 + + +@dataclass(frozen=True) +class AgentPlugin: + """A validated, locally installed Agent Plugins v1 package.""" + + name: str + root: Path + description: str + repository: str + display_name: str + category: str + accent_color: str | None + logo: str | None + permissions: tuple[str, ...] + mcp_servers: tuple[str, ...] = () + enabled: bool = False + + +def _installed_plugins(workspace: Path) -> list[AgentPlugin]: + """Return installed packages found under ``/plugins/*``.""" + workspace = workspace.expanduser().resolve() + root = _contained(workspace / "plugins", workspace, directory=True) + if root is None: + return [] + plugins: dict[str, AgentPlugin | None] = {} + for candidate in _children(root, "Agent Plugins directory"): + plugin_root = _contained(candidate, root, directory=True) + if plugin_root is None: + continue + plugin = _load_manifest(plugin_root) + if plugin is not None: + if plugin.name in plugins: + logger.warning("Ignoring duplicate Agent Plugin identity '{}'", plugin.name) + plugins[plugin.name] = None + else: + plugins[plugin.name] = plugin + return [plugin for plugin in plugins.values() if plugin is not None] + + +def enabled_agent_plugin_skills(workspace: Path) -> list[tuple[str, Path]]: + """Return skills from plugins the user has explicitly enabled.""" + return [ + skill + for plugin in _installed_plugins(workspace) + if _enabled(workspace, plugin) + for skill in _discover_plugin_skills(plugin.name, plugin.root) + ] + + +def _load_manifest(plugin_root: Path) -> AgentPlugin | None: + payload = _read_object(plugin_root / "plugin.json", plugin_root) + if payload is None: + return None + if payload.get("$schema") != AGENT_PLUGIN_SCHEMA: + return None + name = payload.get("name") + if ( + not isinstance(name, str) + or len(name) > 64 + or _PLUGIN_NAME.fullmatch(name) is None + ): + logger.warning("Ignoring Agent Plugin manifest in '{}': invalid name", plugin_root) + return None + 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, + 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")), + logo=_plugin_logo(nanobot.get("logo"), plugin_root), + permissions=_string_tuple(nanobot.get("permissions")), + ) + + +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 _installed_plugins(workspace): + if not _enabled(workspace, plugin): + 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 + configured = configured or {} + if collisions := servers.keys() & configured.keys(): + logger.warning("Configured MCP servers override Agent Plugins: {}", ", ".join(sorted(collisions))) + return servers | configured + + +def discover_agent_plugins(workspace: Path) -> list[AgentPlugin]: + """Return component and lifecycle state for discovered plugins.""" + return [ + replace( + plugin, + mcp_servers=tuple(sorted(_plugin_mcp_servers(workspace, plugin))), + enabled=_enabled(workspace, plugin), + ) + for plugin in _installed_plugins(workspace) + ] + + +def set_agent_plugin_enabled(workspace: Path, name: str, enabled: bool) -> None: + """Enable or disable one installed plugin.""" + plugin = next((item for item in _installed_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) + marker = data / "enabled" + if enabled: + marker.write_text(str(plugin.root), encoding="utf-8") + marker.chmod(0o600) + else: + marker.unlink(missing_ok=True) + + +def _string(value: object) -> str: + return value.strip() if isinstance(value, str) else "" + + +def _string_tuple(value: object) -> tuple[str, ...]: + items = cast(list[object], value) if isinstance(value, list) else [] + 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 _plugin_logo(value: object, plugin_root: Path) -> str | None: + """Resolve nanobot's optional packaged logo extension.""" + if value is None: + return None + if not isinstance(value, str) or not value.startswith("./"): + logger.warning("Ignoring invalid Agent Plugin logo in '{}'", plugin_root) + return None + logo = _contained(plugin_root / value[2:], plugin_root) + try: + data = logo.read_bytes() if logo is not None else b"" + suffix = logo.suffix.lower() if logo is not None else "" + if len(data) <= _MAX_LOGO_BYTES and ( + suffix == ".png" and data.startswith(b"\x89PNG\r\n\x1a\n") + or suffix in {".jpg", ".jpeg"} and data.startswith(b"\xff\xd8\xff") + or suffix == ".webp" and data.startswith(b"RIFF") and data[8:12] == b"WEBP" + ): + mime = "jpeg" if suffix in {".jpg", ".jpeg"} else suffix[1:] + return f"data:image/{mime};base64,{base64.b64encode(data).decode('ascii')}" + except OSError: + pass + logger.warning("Ignoring invalid Agent Plugin logo in '{}'", plugin_root) + return None + + +def _plugin_mcp_servers(workspace: Path, plugin: AgentPlugin) -> dict[str, MCPServerConfig]: + payload = _read_object(plugin.root / "mcp.json", plugin.root) + if payload is None: + return {} + 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, object], raw) + if payload.keys() - _MCP_SERVER_FIELDS: + return None + try: + server = MCPServerConfig.model_validate(payload) + except ValidationError: + return None + command = _stdio_command(server.command, root) + cwd = _stdio_cwd(payload.get("cwd"), root, data) + if server.type != "stdio" or command is None or cwd is None: + return None + if {"PLUGIN_ROOT", "PLUGIN_DATA"} & server.env.keys(): + return None + return server.model_copy( + update={ + "command": command, + "args": [_expand(item, root, data) for item in server.args], + "env": { + **{key: _expand(value, root, data) for key, value in server.env.items()}, + "PLUGIN_ROOT": str(root), + "PLUGIN_DATA": str(data), + }, + "cwd": str(cwd), + } + ) + + +def _stdio_command(value: object, root: Path) -> str | None: + if not isinstance(value, str) or not value: + return None + if value.startswith("./"): + executable = _contained(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(root / value[2:], root, directory=True) + 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, root: Path, data: Path) -> str: + return value.replace("${PLUGIN_ROOT}", str(root)).replace("${PLUGIN_DATA}", str(data)) + + +def _plugin_data_dir(workspace: Path, name: str, *, create: bool) -> Path: + workspace_id = sha256(str(workspace.expanduser().resolve()).encode()).hexdigest()[:12] + current = get_config_path().expanduser().resolve().parent + for segment in ("plugin-data", workspace_id, name): + path = current / segment + if create: + path.mkdir(parents=True, exist_ok=True) + try: + resolved = path.resolve(strict=create) + except OSError as exc: + raise RuntimeError("Agent Plugin data directory is unavailable") from exc + if not resolved.is_relative_to(current): + raise RuntimeError("Agent Plugin data directory escapes its parent") + if create: + resolved.chmod(0o700) + current = resolved + return current + + +def _enabled(workspace: Path, plugin: AgentPlugin) -> bool: + marker = _plugin_data_dir(workspace, plugin.name, create=False) / "enabled" + try: + return marker.is_file() and marker.read_text(encoding="utf-8") == str(plugin.root) + except OSError: + return False + + +def _discover_plugin_skills(plugin_name: str, plugin_root: Path) -> list[tuple[str, Path]]: + skills_root = _contained(plugin_root / "skills", plugin_root, directory=True) + if skills_root is None: + return [] + + skills: list[tuple[str, Path]] = [] + for candidate in _children(skills_root, f"Agent Plugin '{plugin_name}' skills"): + skill_root = _contained(candidate, skills_root, directory=True) + if skill_root is None: + continue + skill_file = _contained(skill_root / "SKILL.md", plugin_root) + if skill_file is None: + continue + try: + metadata = parse_skill_metadata(skill_file.read_text(encoding="utf-8")) + except (OSError, UnicodeError): + metadata = None + if metadata is None or not valid_skill_metadata(metadata, candidate.name): + logger.warning("Ignoring Agent Plugin '{}' skill '{}': invalid metadata", plugin_name, candidate.name) + continue + skills.append((candidate.name, skill_file)) + return skills + + +def _children(root: Path, label: str) -> list[Path]: + try: + return sorted(root.iterdir(), key=lambda path: path.name) + except OSError as exc: + logger.warning("Could not inspect {}: {}", label, exc) + return [] + + +def _contained(path: Path, root: Path, *, directory: bool = False) -> Path | None: + try: + resolved = path.resolve(strict=True) + except OSError: + return None + expected_kind = resolved.is_dir() if directory else resolved.is_file() + return resolved if expected_kind and resolved.is_relative_to(root) else None + + +def _read_object(path: Path, root: Path) -> dict[str, object] | None: + contained = _contained(path, root) + if contained is None: + return None + try: + value = cast(object, json.loads(contained.read_text(encoding="utf-8"))) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + logger.warning("Ignoring invalid Agent Plugin component '{}': {}", contained, exc) + return None + return cast(dict[str, object], value) if isinstance(value, dict) else None diff --git a/nanobot/agent/skills.py b/nanobot/agent/skills.py index fa20a98aa..f1a2e513f 100644 --- a/nanobot/agent/skills.py +++ b/nanobot/agent/skills.py @@ -17,9 +17,35 @@ _STRIP_SKILL_FRONTMATTER = re.compile( r"^---\s*\r?\n(.*?)\r?\n---\s*\r?\n?", re.DOTALL, ) +_SKILL_NAME = re.compile(r"^(?!.*--)[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$") _SKILL_REFERENCE = re.compile(r"(? dict[str, object] | None: + """Parse a skill document's YAML frontmatter.""" + if not (match := _STRIP_SKILL_FRONTMATTER.match(content)): + return None + try: + parsed = yaml.safe_load(match.group(1)) + except yaml.YAMLError: + return None + if not isinstance(parsed, dict): + return None + return {str(key): value for key, value in cast(dict[object, object], parsed).items()} + + +def valid_skill_metadata(metadata: dict[str, object], name: str) -> bool: + """Return whether metadata satisfies the Agent Skills identity contract.""" + description = metadata.get("description") + return ( + metadata.get("name") == name + and len(name) <= 64 + and _SKILL_NAME.fullmatch(name) is not None + and isinstance(description, str) + and 1 <= len(description.strip()) <= 1024 + ) + + class SkillsLoader: """ Loader for agent skills. @@ -34,6 +60,15 @@ class SkillsLoader: self.builtin_skills = builtin_skills_dir or BUILTIN_SKILLS_DIR self.disabled_skills = disabled_skills or set() + def _skill_aliases(self) -> dict[str, str]: + """Return compatibility aliases owned by installed CLI Apps.""" + from nanobot.apps.cli import CliAppManager + + try: + return CliAppManager(workspace=self.workspace).installed_skill_aliases() + except OSError: + return {} + def _skill_entries_from_dir(self, base: Path, source: str, *, skip_names: set[str] | None = None) -> list[dict[str, str]]: if not base.exists(): return [] @@ -60,15 +95,33 @@ class SkillsLoader: Returns: List of skill info dicts with 'name', 'path', 'source'. """ + from nanobot.agent.plugins import enabled_agent_plugin_skills + + plugin_skills = enabled_agent_plugin_skills(self.workspace) skills = self._skill_entries_from_dir(self.workspace_skills, "workspace") - workspace_names = {entry["name"] for entry in skills} + seen_names = {entry["name"] for entry in skills} + for name, path in plugin_skills: + if name in seen_names: + continue + skills.append( + { + "name": name, + "path": str(path), + "source": "plugin", + } + ) + seen_names.add(name) if self.builtin_skills and self.builtin_skills.exists(): skills.extend( - self._skill_entries_from_dir(self.builtin_skills, "builtin", skip_names=workspace_names) + self._skill_entries_from_dir(self.builtin_skills, "builtin", skip_names=seen_names) ) if self.disabled_skills: - skills = [s for s in skills if s["name"] not in self.disabled_skills] + disabled = set(self.disabled_skills) + for legacy, canonical in self._skill_aliases().items(): + if legacy in disabled or canonical in disabled: + disabled.update((legacy, canonical)) + skills = [s for s in skills if s["name"] not in disabled] if filter_unavailable: return [skill for skill in skills if self._check_requirements(self._get_skill_meta(skill["name"]))] @@ -84,14 +137,11 @@ class SkillsLoader: Returns: Skill content or None if not found. """ - roots = [self.workspace_skills] - if self.builtin_skills: - roots.append(self.builtin_skills) - for root in roots: - path = root / name / "SKILL.md" - if path.exists(): - return path.read_text(encoding="utf-8") - return None + skills = self.list_skills(filter_unavailable=False) + available = {skill["name"] for skill in skills} + resolved = name if name in available else self._skill_aliases().get(name, name) + entry = next((skill for skill in skills if skill["name"] == resolved), None) + return Path(entry["path"]).read_text(encoding="utf-8") if entry else None def load_skills_for_context(self, skill_names: list[str]) -> str: """ @@ -118,9 +168,11 @@ class SkillsLoader: entry["name"] for entry in self.list_skills(filter_unavailable=True) } + aliases = self._skill_aliases() invoked: list[str] = [] for match in _SKILL_REFERENCE.finditer(text): - name = match.group(1) + requested = match.group(1) + name = requested if requested in available else aliases.get(requested, requested) if name in available and name not in invoked: invoked.append(name) return invoked @@ -145,6 +197,7 @@ class SkillsLoader: sections: list[str] = [] groups = ( ("Workspace skills", "workspace", self.workspace_skills), + ("Agent Plugin skills", "plugin", self.workspace / "plugins"), ("Built-in skills", "builtin", self.builtin_skills), ) for label, source, root in groups: @@ -278,21 +331,4 @@ class SkillsLoader: Returns: Metadata dict or None. """ - content = self.load_skill(name) - if not content or not content.startswith("---"): - return None - match = _STRIP_SKILL_FRONTMATTER.match(content) - if not match: - return None - try: - parsed = yaml.safe_load(match.group(1)) - except yaml.YAMLError: - return None - if not isinstance(parsed, dict): - return None - # yaml.safe_load returns native types (int, bool, list, etc.); - # keep values as-is so downstream consumers get correct types. - metadata: dict[str, object] = {} - for key, value in cast(dict[object, object], parsed).items(): - metadata[str(key)] = value - return metadata + return parse_skill_metadata(self.load_skill(name) or "") diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py index b6f6ba3cc..1fe4a6bab 100644 --- a/nanobot/agent/tools/mcp.py +++ b/nanobot/agent/tools/mcp.py @@ -1340,10 +1340,14 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]: "requires_restart": True, } try: + from nanobot.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/apps/cli/service.py b/nanobot/apps/cli/service.py index 413b449bd..0407ba1d2 100644 --- a/nanobot/apps/cli/service.py +++ b/nanobot/apps/cli/service.py @@ -20,6 +20,7 @@ from urllib.parse import urlparse import httpx from loguru import logger +from nanobot.agent.skills import parse_skill_metadata, valid_skill_metadata from nanobot.apps.protocol import app_manifest, compact_dict from nanobot.config.paths import get_runtime_subdir from nanobot.security.workspace_policy import is_path_within @@ -27,6 +28,7 @@ from nanobot.security.workspace_policy import is_path_within CLI_ANYTHING_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/registry.json" CLI_ANYTHING_PUBLIC_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/public_registry.json" CLI_ANYTHING_RAW_BASE = "https://raw.githubusercontent.com/HKUDS/CLI-Anything/main" +AGENT_PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json" NANOBOT_EXTENSION_REGISTRY_URL = "https://raw.githubusercontent.com/Re-bin/nanobot-extension/main/registry.json" NANOBOT_EXTENSION_RAW_BASE = "https://raw.githubusercontent.com/Re-bin/nanobot-extension/main" _CATALOG_SOURCES = ( @@ -210,11 +212,27 @@ def _as_object_dict(value: object) -> dict[str, Any] | None: return cast(dict[str, Any], value) if isinstance(value, dict) else None -def _safe_skill_name(name: str) -> str: +def _skill_name(name: str, *, legacy: bool = False) -> str: clean = _SAFE_NAME_RE.sub("-", name.lower()).strip("-") + if not legacy: + clean = clean.replace("_", "-") return f"cli-app-{clean or 'app'}" +def _plugin_skill_relative_path(name: str) -> str: + skill_name = _skill_name(name) + return f"plugins/{skill_name}/skills/{skill_name}/SKILL.md" + + +def cli_app_skill_relative_path(workspace: Path, name: str) -> str: + """Return a CLI App's skill path, including the legacy location.""" + canonical = _plugin_skill_relative_path(name) + legacy = f"skills/{_skill_name(name, legacy=True)}/SKILL.md" + if not (workspace / canonical).is_file() and (workspace / legacy).is_file(): + return legacy + return canonical + + def _has_shell_meta(command: str) -> bool: return any(char in command for char in _SHELL_META_CHARS) @@ -442,6 +460,16 @@ class CliAppManager: """Return registry names explicitly installed through CLI Apps.""" return sorted(str(name) for name in self._load_installed()) + def installed_skill_aliases(self) -> dict[str, str]: + """Map pre-plugin CLI App skill names to their portable identities.""" + aliases: dict[str, str] = {} + for name in self.installed_names(): + legacy = _skill_name(name, legacy=True) + canonical = _skill_name(name) + if legacy != canonical: + aliases[legacy] = canonical + return aliases + def _fetch_registry( self, url: str, @@ -613,7 +641,7 @@ class CliAppManager: "name": installed_name, "entry_point": entry_point, "source": str(data.get("source") or ""), - "skill": f"skills/{_safe_skill_name(installed_name)}/SKILL.md", + "skill": cli_app_skill_relative_path(self.workspace, installed_name), "tool": "run_cli_app", } ) @@ -639,9 +667,6 @@ class CliAppManager: install_cmd = str(app.get("install_cmd") or "") return not _has_shell_meta(install_cmd) - def _skill_path(self, name: str) -> Path: - return self.workspace / "skills" / _safe_skill_name(name) / "SKILL.md" - def _app_payload( self, app: dict[str, Any], @@ -677,7 +702,7 @@ class CliAppManager: "status": status, "logo_url": logo_url, "brand_color": brand_color, - "skill_installed": self._skill_path(name).is_file(), + "skill_installed": (self.workspace / cli_app_skill_relative_path(self.workspace, name)).is_file(), "manifest": self._manifest_payload(app, logo_url=logo_url, brand_color=brand_color), } @@ -713,7 +738,8 @@ class CliAppManager: name = str(app["name"]) entry_point = str(app.get("entry_point") or "") strategy = self._strategy(app) - skill_path = f"skills/{_safe_skill_name(name)}/SKILL.md" + skill_path = _plugin_skill_relative_path(name) + plugin_path = f"plugins/{_skill_name(name)}" capabilities = [ compact_dict({ "type": "cli", @@ -726,13 +752,13 @@ class CliAppManager: install = compact_dict({ "supported": install_supported, "strategy": strategy, - "managed_paths": [skill_path], + "managed_paths": [plugin_path], "verification": ["entry_point_available"] if entry_point else [], }) remove = compact_dict({ "supported": strategy != "unsupported", "strategy": strategy, - "managed_paths": [skill_path], + "managed_paths": [plugin_path], "verification": ( ["package_manager_ok", "entry_point_absent", "managed_paths_absent"] if strategy not in {"bundled", "unsupported"} @@ -1032,11 +1058,10 @@ class CliAppManager: name = str(app.get("name") or "unknown") display = str(app.get("display_name") or name) entry = str(app.get("entry_point") or f"cli-anything-{name}") - description = _catalog_description(app) or f"Use {display} from nanobot." + description = (_catalog_description(app) or f"Use {display} from nanobot.")[:1024] return f"""--- -name: {_safe_skill_name(name)} -description: >- - {description} +name: {_skill_name(name)} +description: {json.dumps(description, ensure_ascii=False)} --- # {display} @@ -1056,10 +1081,17 @@ Prefer machine-readable output when the CLI supports `--json`. """ def _with_nanobot_skill_note(self, content: str, app: dict[str, Any]) -> str: + name = str(app.get("name") or "unknown") + skill_name = _skill_name(name) + metadata = parse_skill_metadata(content) + if metadata is None or not valid_skill_metadata(metadata | {"name": skill_name}, skill_name): + content = self._fallback_skill(app) + content, replaced = re.subn(r"(?m)^name\s*:.*$", f"name: {skill_name}", content, count=1) + if not replaced: + content = content.replace("---\n", f"---\nname: {skill_name}\n", 1) marker = "" if marker in content: return content - name = str(app.get("name") or "unknown") note = f"""{marker} ## Nanobot execution @@ -1073,24 +1105,42 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in return note + "\n" + content def install_skill(self, app: dict[str, Any]) -> Path: - path = self._skill_path(str(app["name"])) + name = str(app["name"]) + path = self.workspace / _plugin_skill_relative_path(name) path.parent.mkdir(parents=True, exist_ok=True) content = self._fetch_skill_content(app) or self._fallback_skill(app) content = self._with_nanobot_skill_note(content, app) path.write_text(content, encoding="utf-8") + plugin_root = path.parents[2] + manifest = compact_dict({ + "$schema": AGENT_PLUGIN_SCHEMA, + "name": _skill_name(str(app["name"])), + "version": str(app.get("version") or ""), + "description": _catalog_description(app), + }) + _write_json(plugin_root / "plugin.json", manifest) + legacy_dir = self.workspace / "skills" / _skill_name(str(app["name"]), legacy=True) + if legacy_dir.is_dir(): + shutil.rmtree(legacy_dir) return path def remove_skill(self, name: str) -> None: - skill_dir = self._skill_path(name).parent - if skill_dir.is_dir(): - shutil.rmtree(skill_dir) + plugin_root = (self.workspace / _plugin_skill_relative_path(name)).parents[2] + if plugin_root.is_dir(): + shutil.rmtree(plugin_root) + legacy_dir = self.workspace / "skills" / _skill_name(name, legacy=True) + if legacy_dir.is_dir(): + shutil.rmtree(legacy_dir) def _record_installed(self, app: dict[str, Any]) -> dict[str, Any]: + from nanobot.agent.plugins import set_agent_plugin_enabled + installed = self._load_installed() entry = self._installed_entry(app) installed[str(app["name"])] = entry self._save_installed(installed) self.install_skill(app) + set_agent_plugin_enabled(self.workspace, _skill_name(str(app["name"])), True) return entry def install(self, name: str) -> dict[str, Any]: diff --git a/nanobot/apps/cli/utils.py b/nanobot/apps/cli/utils.py index c48bb56b5..668dea161 100644 --- a/nanobot/apps/cli/utils.py +++ b/nanobot/apps/cli/utils.py @@ -20,6 +20,8 @@ def runtime_lines_for_request( """Return CLI App annotations from an immutable request snapshot.""" structured = metadata.get("cli_apps") if isinstance(metadata, Mapping) else None if isinstance(structured, list): + from nanobot.apps.cli.service import cli_app_skill_relative_path + structured_items = cast(list[Any], structured) mentions = [ cast(Mapping[str, Any], item) for item in structured_items @@ -32,7 +34,7 @@ def runtime_lines_for_request( f"@{str(item['name']).strip().lower()} " f"(installed; tool=run_cli_app; " f"entry_point={str(item.get('entry_point') or 'unknown')}; " - f"skill=skills/cli-app-{str(item['name']).strip().lower()}/SKILL.md). " + f"skill={cli_app_skill_relative_path(workspace, str(item['name']))}). " "Read the skill when useful, then run this app with `run_cli_app`; do not bypass it with shell." for item in mentions if str(item.get("name") or "").strip() diff --git a/nanobot/channels/websocket/tests/test_websocket_http_routes.py b/nanobot/channels/websocket/tests/test_websocket_http_routes.py index 023e05367..6740c37d7 100644 --- a/nanobot/channels/websocket/tests/test_websocket_http_routes.py +++ b/nanobot/channels/websocket/tests/test_websocket_http_routes.py @@ -2090,6 +2090,14 @@ async def test_mcp_presets_routes_require_token_and_return_payload( assert body["hot_reload"]["ok"] is True assert body["restart_required_sections"] == [] + disabled = await _webui_mutate( + channel, + "settings.mcp.disable", + {"name": "browserbase"}, + ) + assert disabled.status_code == 200 + assert preset_queries[-1][0] == "disable" + custom = await _webui_mutate( channel, "settings.mcp.custom", diff --git a/nanobot/webui/mcp_presets_api.py b/nanobot/webui/mcp_presets_api.py index 86413f3a6..581cf8023 100644 --- a/nanobot/webui/mcp_presets_api.py +++ b/nanobot/webui/mcp_presets_api.py @@ -16,6 +16,11 @@ from datetime import datetime, timezone from pathlib import Path from typing import TYPE_CHECKING, Any, Literal, Mapping, cast +from nanobot.agent.plugins import ( + AgentPlugin, + discover_agent_plugins, + set_agent_plugin_enabled, +) from nanobot.agent.tools.mcp_oauth import ( delete_mcp_oauth_credentials, mcp_oauth_has_credentials, @@ -55,7 +60,6 @@ _MAX_TEST_TOOLS = 16 _DEFAULT_TEST_TIMEOUT = 20 _DEFAULT_CUSTOM_TIMEOUT = 30 _CUSTOM_ACTIONS = {"custom", "import", "import-cursor", "tools"} - McpReload = Callable[[], Awaitable[dict[str, Any]]] @@ -911,6 +915,30 @@ def _custom_payload( } +def _agent_plugin_payload(plugin: AgentPlugin) -> dict[str, Any]: + return { + "name": f"plugin-{plugin.name}", + "display_name": plugin.display_name, + "category": plugin.category, + "description": plugin.description or "Agent Plugin", + "docs_url": plugin.repository, + "transport": "stdio", + "requires": ", ".join(plugin.permissions), + "note": "", + "install_supported": False, + "installed": True, + "configured": True, + "enabled": plugin.enabled, + "available": plugin.enabled, + "status": "enabled" if plugin.enabled else "disabled", + "logo_url": plugin.logo, + "brand_color": plugin.accent_color, + "required_fields": [], + "connection_summary": ", ".join(plugin.mcp_servers), + "source": "agent-plugin", + } + + def mcp_presets_payload( *, last_action: dict[str, Any] | None = None, @@ -929,9 +957,16 @@ def mcp_presets_payload( for name, cfg in sorted(config.tools.mcp_servers.items()) if name not in known ] + existing_names = {str(row["name"]) for row in (*preset_rows, *custom_rows)} + plugin_rows = [ + _agent_plugin_payload(plugin) + for plugin in discover_agent_plugins(config.workspace_path) + if f"plugin-{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["enabled"]) for row in plugin_rows), } if last_action is not None: payload["last_action"] = last_action @@ -1542,6 +1577,29 @@ 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-") + plugins = discover_agent_plugins(plugin_config.workspace_path) + plugin = next((item for item in plugins if item.name == plugin_name), None) + if name not in plugin_config.tools.mcp_servers and plugin is not None: + if action not in {"enable", "disable"}: + raise McpPresetError("Agent Plugins support enable and disable actions only") + await asyncio.to_thread( + set_agent_plugin_enabled, + plugin_config.workspace_path, + plugin_name, + action == "enable", + ) + verb = "enabled" if action == "enable" else "disabled" + payload = mcp_presets_payload( + last_action={"ok": True, "message": f"{plugin.display_name} {verb}."}, + 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 104fd0feb..66b906a18 100644 --- a/nanobot/webui/settings_routes.py +++ b/nanobot/webui/settings_routes.py @@ -91,6 +91,7 @@ def _channel_connect_route(path: str) -> tuple[str, str] | None: _MCP_PRESET_ACTIONS_BY_PATH = { "/api/settings/mcp-presets/enable": "enable", + "/api/settings/mcp-presets/disable": "disable", "/api/settings/mcp-presets/remove": "remove", "/api/settings/mcp-presets/test": "test", "/api/settings/mcp-presets/custom": "custom", diff --git a/nanobot/webui/ws_http.py b/nanobot/webui/ws_http.py index 05b7769fb..4942bc7d3 100644 --- a/nanobot/webui/ws_http.py +++ b/nanobot/webui/ws_http.py @@ -160,6 +160,7 @@ _WEBUI_MUTATION_PATHS = { "settings.pairing.approve": "/api/settings/pairing/approve", "settings.pairing.deny": "/api/settings/pairing/deny", "settings.mcp.enable": "/api/settings/mcp-presets/enable", + "settings.mcp.disable": "/api/settings/mcp-presets/disable", "settings.mcp.remove": "/api/settings/mcp-presets/remove", "settings.mcp.test": "/api/settings/mcp-presets/test", "settings.mcp.custom": "/api/settings/mcp-presets/custom", diff --git a/tests/agent/test_agent_plugins.py b/tests/agent/test_agent_plugins.py new file mode 100644 index 000000000..5daafb823 --- /dev/null +++ b/tests/agent/test_agent_plugins.py @@ -0,0 +1,215 @@ +import json +import shutil +from pathlib import Path + +import pytest + +from nanobot.agent import plugins as agent_plugins +from nanobot.agent.plugins import ( + AGENT_PLUGIN_MCP_SCHEMA, + AGENT_PLUGIN_SCHEMA, + agent_plugin_mcp_servers, + discover_agent_plugins, + enabled_agent_plugin_skills, + set_agent_plugin_enabled, +) +from nanobot.agent.skills import SkillsLoader + + +@pytest.fixture(autouse=True) +def _isolate_plugin_state(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + agent_plugins, "get_config_path", lambda: tmp_path / "config" / "config.json" + ) + + +def _write_json(path: Path, value: object) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value), encoding="utf-8") + + +def _manifest(name: str, **fields: object) -> dict[str, object]: + return {"$schema": AGENT_PLUGIN_SCHEMA, "name": name, **fields} + + +def _plugin(workspace: Path, name: str = "demo", **fields: object) -> Path: + root = workspace / "plugins" / name + _write_json(root / "plugin.json", _manifest(name, **fields)) + return root + + +def _skill(root: Path, name: str, frontmatter: str | None = None, body: str = "") -> Path: + path = root / name + path.mkdir(parents=True) + metadata = frontmatter or f"name: {name}\ndescription: Plugin skill." + (path / "SKILL.md").write_text(f"---\n{metadata}\n---\n\n{body}\n", encoding="utf-8") + return path + + +def _loaded_skills(workspace: Path) -> list[str]: + return [name for name, _ in enabled_agent_plugin_skills(workspace)] + + +def test_plugin_skill_lifecycle_and_precedence(tmp_path: Path) -> None: + plugin = _plugin(tmp_path) + _skill( + plugin / "skills", + "shared", + "name: shared\ndescription: Plugin version.\nalways: true", + "Plugin body.", + ) + _skill(tmp_path / "builtin", "shared", body="Built-in body.") + workspace_skill = _skill( + tmp_path / "skills", "shared", "name: shared\ndescription: Workspace version." + ) + loader = SkillsLoader(tmp_path, builtin_skills_dir=tmp_path / "builtin") + + assert [entry["source"] for entry in loader.list_skills()] == ["workspace"] + assert "Workspace version" in (loader.load_skill("shared") or "") + set_agent_plugin_enabled(tmp_path, "demo", True) + assert [entry["source"] for entry in loader.list_skills()] == ["workspace"] + + shutil.rmtree(workspace_skill) + assert [entry["source"] for entry in loader.list_skills()] == ["plugin"] + assert loader.get_explicitly_invoked_skills("Use $shared") == ["shared"] + assert loader.get_always_skills() == ["shared"] + assert "Plugin body" in (loader.load_skill("shared") or "") + assert "`demo/skills/shared/SKILL.md`" in loader.build_skills_summary() + + set_agent_plugin_enabled(tmp_path, "demo", False) + assert [entry["source"] for entry in loader.list_skills()] == ["builtin"] + assert "Built-in body" in (loader.load_skill("shared") or "") + + +def test_plugin_skills_are_direct_valid_and_contained(tmp_path: Path) -> None: + plugin = _plugin(tmp_path) + skills = plugin / "skills" + _skill(skills, "direct") + _skill(skills / "group", "nested") + for name, frontmatter in ( + ("wrong-directory", "name: another\ndescription: Mismatch."), + ("missing-description", "name: missing-description"), + ("Bad-Name", "name: Bad-Name\ndescription: Invalid name."), + ): + _skill(skills, name, frontmatter) + outside = _skill(tmp_path / "outside", "escaped") + try: + (skills / "escaped").symlink_to(outside, target_is_directory=True) + except OSError: + pass + + set_agent_plugin_enabled(tmp_path, "demo", True) + assert _loaded_skills(tmp_path) == ["direct"] + + +@pytest.mark.parametrize( + ("manifest", "valid"), + [ + ({"$schema": "https://agent-plugins.org/schemas/2.0.0/plugin.schema.json", "name": "demo"}, False), + (_manifest("Bad-Name"), False), + (_manifest("demo", futureField=True, extensions="invalid but non-fatal"), True), + ], +) +def test_plugin_manifest_boundary(tmp_path: Path, manifest: object, valid: bool) -> None: + _write_json(tmp_path / "plugins" / "candidate" / "plugin.json", manifest) + assert bool(discover_agent_plugins(tmp_path)) is valid + + +def test_plugin_logo_is_validated_and_contained(tmp_path: Path) -> None: + extension = {"extensions": {"dev.nanobot": {"logo": "./assets/icon.png"}}} + plugin = _plugin(tmp_path, "demo", **extension) + icon = plugin / "assets" / "icon.png" + icon.parent.mkdir() + icon.write_bytes(b"\x89PNG\r\n\x1a\nlogo") + escaped = _plugin(tmp_path, "escaped", **extension) + (escaped / "assets").mkdir() + try: + (escaped / "assets" / "icon.png").symlink_to(icon) + except OSError: + pass + + assert {plugin.name: plugin.logo for plugin in discover_agent_plugins(tmp_path)} == { + "demo": "data:image/png;base64,iVBORw0KGgpsb2dv", + "escaped": None, + } + + +def test_plugin_mcp_requires_explicit_enable(tmp_path: Path) -> None: + plugin = _plugin(tmp_path, "desktop") + executable = plugin / "bin" / "server" + executable.parent.mkdir() + executable.write_text("#!/bin/sh\n", encoding="utf-8") + _write_json( + plugin / "mcp.json", + { + "$schema": AGENT_PLUGIN_MCP_SCHEMA, + "mcpServers": { + "desktop": { + "type": "stdio", + "command": "./bin/server", + "args": ["--data", "${PLUGIN_DATA}/state"], + "cwd": "${PLUGIN_ROOT}", + }, + "public-http": {"type": "streamable-http", "url": "http://example.com/mcp"}, + "escape": {"type": "stdio", "command": "../outside"}, + }, + }, + ) + + assert agent_plugin_mcp_servers(tmp_path) == {} + set_agent_plugin_enabled(tmp_path, "desktop", True) + server = agent_plugin_mcp_servers(tmp_path)["desktop"] + assert (server.command, server.cwd, server.env["PLUGIN_ROOT"]) == ( + str(executable), + str(plugin), + str(plugin), + ) + assert server.args[1].endswith("/state") + set_agent_plugin_enabled(tmp_path, "desktop", False) + assert agent_plugin_mcp_servers(tmp_path) == {} + + +def test_plugin_state_symlink_cannot_escape_config_root( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + config = tmp_path / "config" + config.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + try: + (config / "plugin-data").symlink_to(outside, target_is_directory=True) + except OSError as exc: + pytest.skip(f"directory symlink unavailable: {exc}") + monkeypatch.setattr(agent_plugins, "get_config_path", lambda: config / "config.json") + _plugin(tmp_path, "desktop") + + with pytest.raises(RuntimeError, match="escapes its parent"): + set_agent_plugin_enabled(tmp_path, "desktop", True) + + +def test_plugin_activation_requires_one_stable_package_identity(tmp_path: Path) -> None: + roots = [tmp_path / "plugins" / directory for directory in ("first", "second")] + for root, marker in zip(roots, ("trusted", "replacement"), strict=True): + _write_json(root / "plugin.json", _manifest("duplicate")) + _write_json( + root / "mcp.json", + { + "$schema": AGENT_PLUGIN_MCP_SCHEMA, + "mcpServers": { + "server": {"type": "stdio", "command": "echo", "args": [marker]} + }, + }, + ) + + assert discover_agent_plugins(tmp_path) == [] + with pytest.raises(ValueError, match="unknown Agent Plugin"): + set_agent_plugin_enabled(tmp_path, "duplicate", True) + + shutil.rmtree(roots[1]) + set_agent_plugin_enabled(tmp_path, "duplicate", True) + assert discover_agent_plugins(tmp_path)[0].enabled is True + + moved = tmp_path / "plugins" / "moved" + roots[0].rename(moved) + assert discover_agent_plugins(tmp_path)[0].enabled is False + assert agent_plugin_mcp_servers(tmp_path) == {} diff --git a/tests/agent/test_skills_loader.py b/tests/agent/test_skills_loader.py index 88a1a0eaf..2c1749eba 100644 --- a/tests/agent/test_skills_loader.py +++ b/tests/agent/test_skills_loader.py @@ -266,6 +266,7 @@ def test_disabled_skills_excluded_from_list(tmp_path: Path) -> None: assert len(entries) == 1 assert entries[0]["name"] == "beta" assert entries[0]["path"] == str(beta_path) + assert loader.load_skill("alpha") is None def test_disabled_skills_empty_set_no_effect(tmp_path: Path) -> None: diff --git a/tests/cli_apps/test_service.py b/tests/cli_apps/test_service.py index 7b0a312a0..5fd532898 100644 --- a/tests/cli_apps/test_service.py +++ b/tests/cli_apps/test_service.py @@ -9,9 +9,16 @@ from types import SimpleNamespace import pytest +from nanobot.agent import plugins as agent_plugins +from nanobot.agent.skills import SkillsLoader from nanobot.apps.cli.service import CliAppError, CliAppManager, CliAppsRuntimeConfig +@pytest.fixture(autouse=True) +def _isolate_plugin_state(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(agent_plugins, "get_config_path", lambda: tmp_path / "config/config.json") + + def _write_cache(path: Path, registry: dict) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text( @@ -391,6 +398,9 @@ def test_install_dispatches_safe_pip_and_installs_skill( "_fetch_skill_content", lambda app: "---\nname: cli-anything-gimp\ndescription: GIMP\n---\n# GIMP\n", ) + legacy = manager.workspace / "skills" / "cli-app-gimp" / "SKILL.md" + legacy.parent.mkdir(parents=True) + legacy.write_text("legacy", encoding="utf-8") payload = manager.install("gimp") @@ -400,9 +410,15 @@ def test_install_dispatches_safe_pip_and_installs_skill( assert "state_recorded" in payload["last_action"]["verification"] installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"] assert installed["gimp"]["entry_point"] == "cli-anything-gimp" - skill = manager.workspace / "skills" / "cli-app-gimp" / "SKILL.md" + plugin = manager.workspace / "plugins" / "cli-app-gimp" + skill = plugin / "skills" / "cli-app-gimp" / "SKILL.md" assert skill.is_file() + manifest = json.loads((plugin / "plugin.json").read_text(encoding="utf-8")) + assert (manifest["name"], manifest["version"]) == ("cli-app-gimp", "1.0.0") + assert "name: cli-app-gimp" in skill.read_text(encoding="utf-8") assert 'run_cli_app` tool with `name="gimp"' in skill.read_text(encoding="utf-8") + assert SkillsLoader(manager.workspace).load_skill("cli-app-gimp") is not None + assert not legacy.exists() def test_run_argv_logs_command_exit_and_output( @@ -487,7 +503,7 @@ def test_install_records_available_cli_without_reinstalling( assert "entry_point_available" in payload["last_action"]["verification"] installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"] assert installed["feishu"]["entry_point_path"] == str(resolved) - skill = manager.workspace / "skills" / "cli-app-feishu" / "SKILL.md" + skill = manager.workspace / "plugins/cli-app-feishu/skills/cli-app-feishu/SKILL.md" assert skill.is_file() assert 'run_cli_app` tool with `name="feishu"' in skill.read_text(encoding="utf-8") @@ -704,7 +720,8 @@ def test_uninstall_removes_installed_state_and_generated_skill( manager = _manager(tmp_path) _seed_catalog(manager) manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}}) - skill_dir = manager.workspace / "skills" / "cli-app-gimp" + plugin_dir = manager.workspace / "plugins" / "cli-app-gimp" + skill_dir = plugin_dir / "skills" / "cli-app-gimp" skill_dir.mkdir(parents=True) (skill_dir / "SKILL.md").write_text("# GIMP\n", encoding="utf-8") monkeypatch.setattr( @@ -717,7 +734,7 @@ def test_uninstall_removes_installed_state_and_generated_skill( assert payload["last_action"]["ok"] is True assert "gimp" not in json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"] - assert not skill_dir.exists() + assert not plugin_dir.exists() def test_uninstall_uses_safe_python_m_pip_uninstall_command( @@ -845,19 +862,62 @@ def test_mentioned_installed_apps_only_returns_installed_mentions(tmp_path: Path "name": "zoom", "entry_point": "cli-anything-zoom", "source": "public", - "skill": "skills/cli-app-zoom/SKILL.md", + "skill": "plugins/cli-app-zoom/skills/cli-app-zoom/SKILL.md", "tool": "run_cli_app", }, { "name": "gimp", "entry_point": "cli-anything-gimp", "source": "harness", - "skill": "skills/cli-app-gimp/SKILL.md", + "skill": "plugins/cli-app-gimp/skills/cli-app-gimp/SKILL.md", "tool": "run_cli_app", }, ] +def test_remove_skill_cleans_legacy_underscored_name(tmp_path: Path) -> None: + manager = _manager(tmp_path) + legacy = manager.workspace / "skills" / "cli-app-unimol_tools" / "SKILL.md" + legacy.parent.mkdir(parents=True) + legacy.write_text("# Legacy Uni-Mol\n", encoding="utf-8") + + manager.remove_skill("unimol_tools") + + assert not legacy.exists() + + +def test_migrated_cli_app_skill_keeps_legacy_identity_alias( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + data_dir = tmp_path / "data" + monkeypatch.setattr( + "nanobot.apps.cli.service.get_runtime_subdir", + lambda _name: data_dir, + ) + workspace = tmp_path / "workspace" + workspace.mkdir() + manager = CliAppManager(workspace=workspace) + manager._save_installed({"unimol_tools": {"entry_point": "unimol-tools"}}) + manager.install_skill({ + "name": "unimol_tools", + "display_name": "Uni-Mol Tools", + "entry_point": "unimol-tools", + }) + agent_plugins.set_agent_plugin_enabled(workspace, "cli-app-unimol-tools", True) + + loader = SkillsLoader(workspace) + assert loader.get_explicitly_invoked_skills("Use $cli-app-unimol_tools") == [ + "cli-app-unimol-tools" + ] + assert loader.load_skill("cli-app-unimol_tools") is not None + + disabled = SkillsLoader(workspace, disabled_skills={"cli-app-unimol_tools"}) + assert "cli-app-unimol-tools" not in { + skill["name"] for skill in disabled.list_skills(filter_unavailable=False) + } + + def test_install_rejects_unknown_and_script_strategy(tmp_path: Path) -> None: manager = _manager(tmp_path) _seed_catalog(manager) diff --git a/tests/cli_apps/test_utils.py b/tests/cli_apps/test_utils.py index 4c5d7d64e..681e31cb4 100644 --- a/tests/cli_apps/test_utils.py +++ b/tests/cli_apps/test_utils.py @@ -38,24 +38,26 @@ def test_cli_app_mentions_inject_runtime_metadata(tmp_path, monkeypatch): assert "CLI App Mention: @zoom" in joined assert "tool=run_cli_app" in joined assert "entry_point=cli-anything-zoom" in joined - assert "skill=skills/cli-app-zoom/SKILL.md" in joined + assert "skill=plugins/cli-app-zoom/skills/cli-app-zoom/SKILL.md" in joined -def test_structured_cli_app_attachment_injects_runtime_metadata(tmp_path): +def test_structured_cli_app_attachment_uses_existing_legacy_skill(tmp_path): + legacy = tmp_path / "skills" / "cli-app-unimol_tools" / "SKILL.md" + legacy.parent.mkdir(parents=True) + legacy.write_text("# Legacy Uni-Mol\n", encoding="utf-8") lines = runtime_lines_for_request( - "please use @zoom tonight", + "please use @unimol_tools", { "cli_apps": [{ - "name": "zoom", - "entry_point": "cli-anything-zoom", - "display_name": "Zoom", + "name": "unimol_tools", + "entry_point": "cli-anything-unimol-tools", }], }, tmp_path, ) joined = "\n".join(lines) - assert "CLI App Attachment: @zoom" in joined + assert "CLI App Attachment: @unimol_tools" in joined assert "tool=run_cli_app" in joined - assert "entry_point=cli-anything-zoom" in joined - assert "skill=skills/cli-app-zoom/SKILL.md" in joined + assert "entry_point=cli-anything-unimol-tools" in joined + assert "skill=skills/cli-app-unimol_tools/SKILL.md" in joined diff --git a/tests/webui/test_mcp_presets_api.py b/tests/webui/test_mcp_presets_api.py index 7f74d00f4..65677451d 100644 --- a/tests/webui/test_mcp_presets_api.py +++ b/tests/webui/test_mcp_presets_api.py @@ -1,10 +1,14 @@ from __future__ import annotations import asyncio +import json +from functools import partial +from pathlib import Path import pytest from mcp.shared.auth import OAuthToken +from nanobot.agent.plugins import AGENT_PLUGIN_MCP_SCHEMA, AGENT_PLUGIN_SCHEMA from nanobot.agent.tools.mcp_oauth import MCPOAuthStorage, mcp_oauth_has_credentials from nanobot.config.loader import load_config from nanobot.webui.mcp_presets_api import ( @@ -12,13 +16,48 @@ from nanobot.webui.mcp_presets_api import ( custom_mcp_action, mcp_presets_action, mcp_presets_payload, + mcp_presets_settings_action, mcp_presets_test_action, normalize_mcp_preset_mentions, ) def _use_config(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr("nanobot.config.loader._current_config_path", tmp_path / "config.json") + config_path = tmp_path / "config.json" + config_path.write_text( + json.dumps({"agents": {"defaults": {"workspace": str(tmp_path / "workspace")}}}), + encoding="utf-8", + ) + monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) + + +def _write_agent_plugin(workspace: Path) -> None: + root = workspace / "plugins" / "desktop" + root.mkdir(parents=True) + for filename, payload in ( + ( + "plugin.json", + { + "$schema": AGENT_PLUGIN_SCHEMA, + "name": "desktop", + "description": "Control the local desktop.", + "extensions": { + "dev.nanobot": { + "displayName": "Desktop Control", + "permissions": ["screen-recording"], + } + }, + }, + ), + ( + "mcp.json", + { + "$schema": AGENT_PLUGIN_MCP_SCHEMA, + "mcpServers": {"desktop": {"type": "stdio", "command": "echo"}}, + }, + ), + ): + (root / filename).write_text(json.dumps(payload), encoding="utf-8") def test_mcp_presets_payload_lists_supported_cards(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: @@ -60,6 +99,52 @@ def test_mcp_presets_payload_lists_supported_cards(tmp_path, monkeypatch: pytest assert manifest["trust"]["review_status"] == "builtin_preset" +def test_agent_plugin_reuses_mcp_catalog_and_runtime_action( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _use_config(tmp_path, monkeypatch) + _write_agent_plugin(load_config().workspace_path) + + row = next(item for item in mcp_presets_payload()["presets"] if item["source"] == "agent-plugin") + assert (row["name"], row["display_name"], row["requires"]) == ( + "plugin-desktop", "Desktop Control", "screen-recording" + ) + assert row["installed"] and row["configured"] and not row["enabled"] + + async def reload() -> dict[str, object]: + return {"ok": True, "message": "MCP reloaded.", "requires_restart": False} + + plugin_action = partial( + mcp_presets_settings_action, + query={"name": ["plugin-desktop"]}, + ) + enabled = asyncio.run(plugin_action("enable", reload_mcp=reload)) + enabled_row = next(item for item in enabled["presets"] if item["name"] == "plugin-desktop") + assert (enabled_row["enabled"], enabled_row["status"], enabled["requires_restart"]) == ( + True, "enabled", False + ) + + disabled = asyncio.run(plugin_action("disable", reload_mcp=reload)) + disabled_row = next(item for item in disabled["presets"] if item["name"] == "plugin-desktop") + assert (disabled_row["installed"], disabled_row["enabled"], disabled_row["status"]) == ( + True, False, "disabled" + ) + + with pytest.raises(McpPresetError, match="enable and disable"): + asyncio.run(plugin_action("remove")) + + (load_config().workspace_path / "plugins" / "desktop" / "mcp.json").unlink() + assert any(item["name"] == "plugin-desktop" for item in mcp_presets_payload()["presets"]) + + config_path = tmp_path / "config.json" + config = json.loads(config_path.read_text(encoding="utf-8")) + config["tools"] = {"mcpServers": {"plugin-desktop": {"type": "stdio", "command": "echo"}}} + config_path.write_text(json.dumps(config), encoding="utf-8") + rows = [item for item in mcp_presets_payload()["presets"] if item["name"] == "plugin-desktop"] + assert len(rows) == 1 and rows[0]["source"] == "custom" + + @pytest.mark.asyncio async def test_oauth_preset_is_one_click_configured_after_token_storage( tmp_path, diff --git a/webui/src/components/settings/system/AppsSettings.tsx b/webui/src/components/settings/system/AppsSettings.tsx index 3f6aed783..14354491c 100644 --- a/webui/src/components/settings/system/AppsSettings.tsx +++ b/webui/src/components/settings/system/AppsSettings.tsx @@ -4,6 +4,7 @@ import { useId, useMemo, useState, + type ComponentPropsWithoutRef, type Dispatch, type ReactNode, type SetStateAction, @@ -16,6 +17,7 @@ import { Database, ExternalLink, Loader2, + PauseCircle, PlayCircle, Plus, RotateCcw, @@ -156,7 +158,7 @@ export function AppsCatalogSettings({ onQueryChange: (value: string) => void; onFilterChange: (value: AppsKindFilter) => void; onCliAction: (action: "install" | "update" | "uninstall" | "test", name: string) => void; - onMcpAction: (action: "enable" | "remove" | "test", name: string, values?: Record) => void; + onMcpAction: (action: "enable" | "disable" | "remove" | "test", name: string, values?: Record) => void; onMcpOAuthConnect: (name: string) => void; onMcpOAuthCancel: () => void; onMcpOAuthOpen: () => void; @@ -191,7 +193,11 @@ export 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)); @@ -500,7 +506,7 @@ function McpAppsCatalogRow({ oauthCallbackError: string | null; showBrandLogos: boolean; onFieldChange: (presetName: string, fieldName: string, value: string) => void; - onAction: (action: "enable" | "remove" | "test", name: string, values?: Record) => void; + onAction: (action: "enable" | "disable" | "remove" | "test", name: string, values?: Record) => void; onOAuthConnect: (name: string) => void; onOAuthCancel: () => void; onOAuthOpen: () => void; @@ -513,18 +519,20 @@ function McpAppsCatalogRow({ const [setupOpen, setSetupOpen] = useState(false); const [toolsOpen, setToolsOpen] = useState(false); const enableBusy = actionKey === `enable:${preset.name}`; + const disableBusy = actionKey === `disable:${preset.name}`; const removeBusy = actionKey === `remove:${preset.name}`; const testBusy = actionKey === `test:${preset.name}`; const toolsBusy = actionKey === `tools:${preset.name}`; const oauthBusy = actionKey === `oauth:${preset.name}`; const anotherOAuthBusy = Boolean(actionKey?.startsWith("oauth:")) && !oauthBusy; - const busy = enableBusy || removeBusy || testBusy || toolsBusy || oauthBusy; + const busy = enableBusy || disableBusy || removeBusy || testBusy || toolsBusy || oauthBusy; + const agentPlugin = preset.source === "agent-plugin"; + const toggleable = preset.enabled !== undefined; const isOAuth = preset.auth === "oauth"; const missingFields = preset.required_fields.filter((field) => field.required && !field.configured); const hasFields = preset.required_fields.length > 0; const needsSetupInput = missingFields.length > 0; - const readyInstalled = preset.installed && preset.configured; - const statusLabel = mcpPresetStatusLabel(preset.status, tx); + const readyInstalled = preset.enabled ?? (preset.installed && preset.configured); const canEnable = preset.install_supported && (missingFields.length === 0 || missingFields.every((field) => Boolean(values[field.name]?.trim()))); @@ -532,7 +540,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 description = preset.description || preset.note || preset.name; + const detail = agentPlugin && preset.requires + ? `${description} · ${preset.requires}` + : description || preset.requires; + const statusLabel = toggleable + ? tx("settings.nanobotFeatures.enabled", "Enabled") + : mcpPresetStatusLabel(preset.status, tx); const manualCallback = oauthFlow?.completion_input === "callback_url" && Boolean(oauthFlow.authorization_url); const callbackInputId = `mcp-oauth-callback-${preset.name}`; @@ -581,9 +595,13 @@ function McpAppsCatalogRow({

{preset.display_name}

- {tx("settings.apps.mcpLabel", "MCP")} + + {agentPlugin + ? tx("settings.apps.filterPlugins", "Plugins") + : tx("settings.apps.mcpLabel", "MCP")} +
-

{description}

+

{detail}

@@ -606,36 +624,49 @@ function McpAppsCatalogRow({ - onAction("test", preset.name)}> - - {tx("settings.mcp.test", "Test")} - - {toolNames.length ? ( + {!toggleable ? ( + onAction("test", preset.name)}> + + {tx("settings.mcp.test", "Test")} + + ) : null} + {!toggleable && toolNames.length ? ( setToolsOpen((open) => !open)}> {tx("settings.mcp.toolScope", "Tools")} ) : null} onAction("remove", preset.name)} + onClick={() => onAction(toggleable ? "disable" : "remove", preset.name)} > - - {tx("settings.mcp.remove", "Remove")} + {toggleable ? : } + {toggleable + ? tx("settings.nanobotFeatures.disable", "Disable") + : tx("settings.mcp.remove", "Remove")} - onAction("remove", preset.name)} - > - - + {!toggleable ? ( + onAction("remove", preset.name)} + > + + + ) : null} + ) : preset.enabled === false ? ( + onAction("enable", preset.name, values)} + /> ) : oauthFlow ? ( <> & { ariaLabel: string; visibleLabel?: string; busy?: boolean; - disabled?: boolean; tone?: "default" | "installed" | "danger"; - onClick?: () => void; - children?: ReactNode; }>(function AppsActionButton({ ariaLabel, visibleLabel, busy, disabled, tone = "default", - onClick, + className, children, + ...props }, ref) { return (