feat(plugins): integrate portable Agent Plugins

This commit is contained in:
Xubin Ren
2026-08-11 20:16:24 +09:00
parent 57d81bc1cd
commit d5e0df6963
23 changed files with 1111 additions and 110 deletions
+1 -1
View File
@@ -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) | | 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 | | Tool | Implement a tool under `agent/tools/` or expose a plugin entry point |
| MCP | Add `tools.mcpServers` config | | MCP | Add `tools.mcpServers` config |
| Skill | Add workspace skill files under `<workspace>/skills/` or built-in skills under `nanobot/skills/` | | Skill | Add workspace skills under `<workspace>/skills/`, Agent Plugins v1 under `<workspace>/plugins/`, or built-in skills under `nanobot/skills/` |
Prefer existing registry/discovery patterns over ad hoc wiring. Prefer existing registry/discovery patterns over ad hoc wiring.
+12
View File
@@ -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. | | `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 `<workspace>/plugins/`; a v1 package has `plugin.json` and may add `mcp.json`, `skills/<name>/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 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. 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.
+3 -1
View File
@@ -485,6 +485,8 @@ class AgentLoop:
config, config,
provider_snapshot_loader, provider_snapshot_loader,
) )
from nanobot.agent.plugins import agent_plugin_mcp_servers
return cls( return cls(
bus=bus, bus=bus,
provider=provider, provider=provider,
@@ -499,7 +501,7 @@ class AgentLoop:
provider_retry_mode=defaults.provider_retry_mode, provider_retry_mode=defaults.provider_retry_mode,
tool_hint_max_length=defaults.tool_hint_max_length, tool_hint_max_length=defaults.tool_hint_max_length,
restrict_to_workspace=config.tools.restrict_to_workspace, 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, channels_config=config.channels,
timezone=defaults.timezone, timezone=defaults.timezone,
unified_session=defaults.unified_session, unified_session=defaults.unified_session,
+361
View File
@@ -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 ``<workspace>/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
+66 -30
View File
@@ -17,9 +17,35 @@ _STRIP_SKILL_FRONTMATTER = re.compile(
r"^---\s*\r?\n(.*?)\r?\n---\s*\r?\n?", r"^---\s*\r?\n(.*?)\r?\n---\s*\r?\n?",
re.DOTALL, re.DOTALL,
) )
_SKILL_NAME = re.compile(r"^(?!.*--)[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
_SKILL_REFERENCE = re.compile(r"(?<![\w$])\$([A-Za-z0-9_-]+)") _SKILL_REFERENCE = re.compile(r"(?<![\w$])\$([A-Za-z0-9_-]+)")
def parse_skill_metadata(content: str) -> 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: class SkillsLoader:
""" """
Loader for agent skills. Loader for agent skills.
@@ -34,6 +60,15 @@ class SkillsLoader:
self.builtin_skills = builtin_skills_dir or BUILTIN_SKILLS_DIR self.builtin_skills = builtin_skills_dir or BUILTIN_SKILLS_DIR
self.disabled_skills = disabled_skills or set() 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]]: def _skill_entries_from_dir(self, base: Path, source: str, *, skip_names: set[str] | None = None) -> list[dict[str, str]]:
if not base.exists(): if not base.exists():
return [] return []
@@ -60,15 +95,33 @@ class SkillsLoader:
Returns: Returns:
List of skill info dicts with 'name', 'path', 'source'. 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") 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(): if self.builtin_skills and self.builtin_skills.exists():
skills.extend( 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: 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: if filter_unavailable:
return [skill for skill in skills if self._check_requirements(self._get_skill_meta(skill["name"]))] return [skill for skill in skills if self._check_requirements(self._get_skill_meta(skill["name"]))]
@@ -84,14 +137,11 @@ class SkillsLoader:
Returns: Returns:
Skill content or None if not found. Skill content or None if not found.
""" """
roots = [self.workspace_skills] skills = self.list_skills(filter_unavailable=False)
if self.builtin_skills: available = {skill["name"] for skill in skills}
roots.append(self.builtin_skills) resolved = name if name in available else self._skill_aliases().get(name, name)
for root in roots: entry = next((skill for skill in skills if skill["name"] == resolved), None)
path = root / name / "SKILL.md" return Path(entry["path"]).read_text(encoding="utf-8") if entry else None
if path.exists():
return path.read_text(encoding="utf-8")
return None
def load_skills_for_context(self, skill_names: list[str]) -> str: def load_skills_for_context(self, skill_names: list[str]) -> str:
""" """
@@ -118,9 +168,11 @@ class SkillsLoader:
entry["name"] entry["name"]
for entry in self.list_skills(filter_unavailable=True) for entry in self.list_skills(filter_unavailable=True)
} }
aliases = self._skill_aliases()
invoked: list[str] = [] invoked: list[str] = []
for match in _SKILL_REFERENCE.finditer(text): 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: if name in available and name not in invoked:
invoked.append(name) invoked.append(name)
return invoked return invoked
@@ -145,6 +197,7 @@ class SkillsLoader:
sections: list[str] = [] sections: list[str] = []
groups = ( groups = (
("Workspace skills", "workspace", self.workspace_skills), ("Workspace skills", "workspace", self.workspace_skills),
("Agent Plugin skills", "plugin", self.workspace / "plugins"),
("Built-in skills", "builtin", self.builtin_skills), ("Built-in skills", "builtin", self.builtin_skills),
) )
for label, source, root in groups: for label, source, root in groups:
@@ -278,21 +331,4 @@ class SkillsLoader:
Returns: Returns:
Metadata dict or None. Metadata dict or None.
""" """
content = self.load_skill(name) return parse_skill_metadata(self.load_skill(name) or "")
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
+5 -1
View File
@@ -1340,10 +1340,14 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
"requires_restart": True, "requires_restart": True,
} }
try: try:
from nanobot.agent.plugins import agent_plugin_mcp_servers
from nanobot.config.loader import load_config, resolve_config_env_vars from nanobot.config.loader import load_config, resolve_config_env_vars
config = resolve_config_env_vars(load_config()) 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: except Exception as exc:
logger.warning("MCP hot reload could not read config: {}", exc) logger.warning("MCP hot reload could not read config: {}", exc)
return { return {
+68 -18
View File
@@ -20,6 +20,7 @@ from urllib.parse import urlparse
import httpx import httpx
from loguru import logger 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.apps.protocol import app_manifest, compact_dict
from nanobot.config.paths import get_runtime_subdir from nanobot.config.paths import get_runtime_subdir
from nanobot.security.workspace_policy import is_path_within 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_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_PUBLIC_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/public_registry.json"
CLI_ANYTHING_RAW_BASE = "https://raw.githubusercontent.com/HKUDS/CLI-Anything/main" 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_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" NANOBOT_EXTENSION_RAW_BASE = "https://raw.githubusercontent.com/Re-bin/nanobot-extension/main"
_CATALOG_SOURCES = ( _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 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("-") clean = _SAFE_NAME_RE.sub("-", name.lower()).strip("-")
if not legacy:
clean = clean.replace("_", "-")
return f"cli-app-{clean or 'app'}" 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: def _has_shell_meta(command: str) -> bool:
return any(char in command for char in _SHELL_META_CHARS) 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 registry names explicitly installed through CLI Apps."""
return sorted(str(name) for name in self._load_installed()) 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( def _fetch_registry(
self, self,
url: str, url: str,
@@ -613,7 +641,7 @@ class CliAppManager:
"name": installed_name, "name": installed_name,
"entry_point": entry_point, "entry_point": entry_point,
"source": str(data.get("source") or ""), "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", "tool": "run_cli_app",
} }
) )
@@ -639,9 +667,6 @@ class CliAppManager:
install_cmd = str(app.get("install_cmd") or "") install_cmd = str(app.get("install_cmd") or "")
return not _has_shell_meta(install_cmd) 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( def _app_payload(
self, self,
app: dict[str, Any], app: dict[str, Any],
@@ -677,7 +702,7 @@ class CliAppManager:
"status": status, "status": status,
"logo_url": logo_url, "logo_url": logo_url,
"brand_color": brand_color, "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), "manifest": self._manifest_payload(app, logo_url=logo_url, brand_color=brand_color),
} }
@@ -713,7 +738,8 @@ class CliAppManager:
name = str(app["name"]) name = str(app["name"])
entry_point = str(app.get("entry_point") or "") entry_point = str(app.get("entry_point") or "")
strategy = self._strategy(app) 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 = [ capabilities = [
compact_dict({ compact_dict({
"type": "cli", "type": "cli",
@@ -726,13 +752,13 @@ class CliAppManager:
install = compact_dict({ install = compact_dict({
"supported": install_supported, "supported": install_supported,
"strategy": strategy, "strategy": strategy,
"managed_paths": [skill_path], "managed_paths": [plugin_path],
"verification": ["entry_point_available"] if entry_point else [], "verification": ["entry_point_available"] if entry_point else [],
}) })
remove = compact_dict({ remove = compact_dict({
"supported": strategy != "unsupported", "supported": strategy != "unsupported",
"strategy": strategy, "strategy": strategy,
"managed_paths": [skill_path], "managed_paths": [plugin_path],
"verification": ( "verification": (
["package_manager_ok", "entry_point_absent", "managed_paths_absent"] ["package_manager_ok", "entry_point_absent", "managed_paths_absent"]
if strategy not in {"bundled", "unsupported"} if strategy not in {"bundled", "unsupported"}
@@ -1032,11 +1058,10 @@ class CliAppManager:
name = str(app.get("name") or "unknown") name = str(app.get("name") or "unknown")
display = str(app.get("display_name") or name) display = str(app.get("display_name") or name)
entry = str(app.get("entry_point") or f"cli-anything-{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"""--- return f"""---
name: {_safe_skill_name(name)} name: {_skill_name(name)}
description: >- description: {json.dumps(description, ensure_ascii=False)}
{description}
--- ---
# {display} # {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: 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 = "<!-- nanobot-cli-app-note -->" marker = "<!-- nanobot-cli-app-note -->"
if marker in content: if marker in content:
return content return content
name = str(app.get("name") or "unknown")
note = f"""{marker} note = f"""{marker}
## Nanobot execution ## 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 return note + "\n" + content
def install_skill(self, app: dict[str, Any]) -> Path: 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) path.parent.mkdir(parents=True, exist_ok=True)
content = self._fetch_skill_content(app) or self._fallback_skill(app) content = self._fetch_skill_content(app) or self._fallback_skill(app)
content = self._with_nanobot_skill_note(content, app) content = self._with_nanobot_skill_note(content, app)
path.write_text(content, encoding="utf-8") 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 return path
def remove_skill(self, name: str) -> None: def remove_skill(self, name: str) -> None:
skill_dir = self._skill_path(name).parent plugin_root = (self.workspace / _plugin_skill_relative_path(name)).parents[2]
if skill_dir.is_dir(): if plugin_root.is_dir():
shutil.rmtree(skill_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]: def _record_installed(self, app: dict[str, Any]) -> dict[str, Any]:
from nanobot.agent.plugins import set_agent_plugin_enabled
installed = self._load_installed() installed = self._load_installed()
entry = self._installed_entry(app) entry = self._installed_entry(app)
installed[str(app["name"])] = entry installed[str(app["name"])] = entry
self._save_installed(installed) self._save_installed(installed)
self.install_skill(app) self.install_skill(app)
set_agent_plugin_enabled(self.workspace, _skill_name(str(app["name"])), True)
return entry return entry
def install(self, name: str) -> dict[str, Any]: def install(self, name: str) -> dict[str, Any]:
+3 -1
View File
@@ -20,6 +20,8 @@ def runtime_lines_for_request(
"""Return CLI App annotations from an immutable request snapshot.""" """Return CLI App annotations from an immutable request snapshot."""
structured = metadata.get("cli_apps") if isinstance(metadata, Mapping) else None structured = metadata.get("cli_apps") if isinstance(metadata, Mapping) else None
if isinstance(structured, list): if isinstance(structured, list):
from nanobot.apps.cli.service import cli_app_skill_relative_path
structured_items = cast(list[Any], structured) structured_items = cast(list[Any], structured)
mentions = [ mentions = [
cast(Mapping[str, Any], item) for item in structured_items 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"@{str(item['name']).strip().lower()} "
f"(installed; tool=run_cli_app; " f"(installed; tool=run_cli_app; "
f"entry_point={str(item.get('entry_point') or 'unknown')}; " 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." "Read the skill when useful, then run this app with `run_cli_app`; do not bypass it with shell."
for item in mentions for item in mentions
if str(item.get("name") or "").strip() if str(item.get("name") or "").strip()
@@ -2090,6 +2090,14 @@ async def test_mcp_presets_routes_require_token_and_return_payload(
assert body["hot_reload"]["ok"] is True assert body["hot_reload"]["ok"] is True
assert body["restart_required_sections"] == [] 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( custom = await _webui_mutate(
channel, channel,
"settings.mcp.custom", "settings.mcp.custom",
+61 -3
View File
@@ -16,6 +16,11 @@ from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, Mapping, cast 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 ( from nanobot.agent.tools.mcp_oauth import (
delete_mcp_oauth_credentials, delete_mcp_oauth_credentials,
mcp_oauth_has_credentials, mcp_oauth_has_credentials,
@@ -55,7 +60,6 @@ _MAX_TEST_TOOLS = 16
_DEFAULT_TEST_TIMEOUT = 20 _DEFAULT_TEST_TIMEOUT = 20
_DEFAULT_CUSTOM_TIMEOUT = 30 _DEFAULT_CUSTOM_TIMEOUT = 30
_CUSTOM_ACTIONS = {"custom", "import", "import-cursor", "tools"} _CUSTOM_ACTIONS = {"custom", "import", "import-cursor", "tools"}
McpReload = Callable[[], Awaitable[dict[str, Any]]] 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( def mcp_presets_payload(
*, *,
last_action: dict[str, Any] | None = None, 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()) for name, cfg in sorted(config.tools.mcp_servers.items())
if name not in known 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] = { payload: dict[str, Any] = {
"presets": [*preset_rows, *custom_rows], "presets": [*preset_rows, *custom_rows, *plugin_rows],
"installed_count": len(config.tools.mcp_servers), "installed_count": len(config.tools.mcp_servers)
+ sum(int(row["enabled"]) for row in plugin_rows),
} }
if last_action is not None: if last_action is not None:
payload["last_action"] = last_action 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 config_path = config.path if config is not None else None
if action is None: if action is None:
return mcp_presets_payload(config_path=config_path) 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": if action == "test":
return await mcp_presets_test_action(query, config_path=config_path) return await mcp_presets_test_action(query, config_path=config_path)
if config is not None: if config is not None:
+1
View File
@@ -91,6 +91,7 @@ def _channel_connect_route(path: str) -> tuple[str, str] | None:
_MCP_PRESET_ACTIONS_BY_PATH = { _MCP_PRESET_ACTIONS_BY_PATH = {
"/api/settings/mcp-presets/enable": "enable", "/api/settings/mcp-presets/enable": "enable",
"/api/settings/mcp-presets/disable": "disable",
"/api/settings/mcp-presets/remove": "remove", "/api/settings/mcp-presets/remove": "remove",
"/api/settings/mcp-presets/test": "test", "/api/settings/mcp-presets/test": "test",
"/api/settings/mcp-presets/custom": "custom", "/api/settings/mcp-presets/custom": "custom",
+1
View File
@@ -160,6 +160,7 @@ _WEBUI_MUTATION_PATHS = {
"settings.pairing.approve": "/api/settings/pairing/approve", "settings.pairing.approve": "/api/settings/pairing/approve",
"settings.pairing.deny": "/api/settings/pairing/deny", "settings.pairing.deny": "/api/settings/pairing/deny",
"settings.mcp.enable": "/api/settings/mcp-presets/enable", "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.remove": "/api/settings/mcp-presets/remove",
"settings.mcp.test": "/api/settings/mcp-presets/test", "settings.mcp.test": "/api/settings/mcp-presets/test",
"settings.mcp.custom": "/api/settings/mcp-presets/custom", "settings.mcp.custom": "/api/settings/mcp-presets/custom",
+215
View File
@@ -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) == {}
+1
View File
@@ -266,6 +266,7 @@ def test_disabled_skills_excluded_from_list(tmp_path: Path) -> None:
assert len(entries) == 1 assert len(entries) == 1
assert entries[0]["name"] == "beta" assert entries[0]["name"] == "beta"
assert entries[0]["path"] == str(beta_path) 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: def test_disabled_skills_empty_set_no_effect(tmp_path: Path) -> None:
+66 -6
View File
@@ -9,9 +9,16 @@ from types import SimpleNamespace
import pytest 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 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: def _write_cache(path: Path, registry: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
path.write_text( path.write_text(
@@ -391,6 +398,9 @@ def test_install_dispatches_safe_pip_and_installs_skill(
"_fetch_skill_content", "_fetch_skill_content",
lambda app: "---\nname: cli-anything-gimp\ndescription: GIMP\n---\n# GIMP\n", 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") 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"] assert "state_recorded" in payload["last_action"]["verification"]
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"] installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
assert installed["gimp"]["entry_point"] == "cli-anything-gimp" 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() 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 '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( 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"] assert "entry_point_available" in payload["last_action"]["verification"]
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"] installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
assert installed["feishu"]["entry_point_path"] == str(resolved) 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 skill.is_file()
assert 'run_cli_app` tool with `name="feishu"' in skill.read_text(encoding="utf-8") 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) manager = _manager(tmp_path)
_seed_catalog(manager) _seed_catalog(manager)
manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}}) 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.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text("# GIMP\n", encoding="utf-8") (skill_dir / "SKILL.md").write_text("# GIMP\n", encoding="utf-8")
monkeypatch.setattr( monkeypatch.setattr(
@@ -717,7 +734,7 @@ def test_uninstall_removes_installed_state_and_generated_skill(
assert payload["last_action"]["ok"] is True assert payload["last_action"]["ok"] is True
assert "gimp" not in json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"] 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( 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", "name": "zoom",
"entry_point": "cli-anything-zoom", "entry_point": "cli-anything-zoom",
"source": "public", "source": "public",
"skill": "skills/cli-app-zoom/SKILL.md", "skill": "plugins/cli-app-zoom/skills/cli-app-zoom/SKILL.md",
"tool": "run_cli_app", "tool": "run_cli_app",
}, },
{ {
"name": "gimp", "name": "gimp",
"entry_point": "cli-anything-gimp", "entry_point": "cli-anything-gimp",
"source": "harness", "source": "harness",
"skill": "skills/cli-app-gimp/SKILL.md", "skill": "plugins/cli-app-gimp/skills/cli-app-gimp/SKILL.md",
"tool": "run_cli_app", "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: def test_install_rejects_unknown_and_script_strategy(tmp_path: Path) -> None:
manager = _manager(tmp_path) manager = _manager(tmp_path)
_seed_catalog(manager) _seed_catalog(manager)
+11 -9
View File
@@ -38,24 +38,26 @@ def test_cli_app_mentions_inject_runtime_metadata(tmp_path, monkeypatch):
assert "CLI App Mention: @zoom" in joined assert "CLI App Mention: @zoom" in joined
assert "tool=run_cli_app" in joined assert "tool=run_cli_app" in joined
assert "entry_point=cli-anything-zoom" 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( lines = runtime_lines_for_request(
"please use @zoom tonight", "please use @unimol_tools",
{ {
"cli_apps": [{ "cli_apps": [{
"name": "zoom", "name": "unimol_tools",
"entry_point": "cli-anything-zoom", "entry_point": "cli-anything-unimol-tools",
"display_name": "Zoom",
}], }],
}, },
tmp_path, tmp_path,
) )
joined = "\n".join(lines) 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 "tool=run_cli_app" in joined
assert "entry_point=cli-anything-zoom" in joined assert "entry_point=cli-anything-unimol-tools" in joined
assert "skill=skills/cli-app-zoom/SKILL.md" in joined assert "skill=skills/cli-app-unimol_tools/SKILL.md" in joined
+86 -1
View File
@@ -1,10 +1,14 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json
from functools import partial
from pathlib import Path
import pytest import pytest
from mcp.shared.auth import OAuthToken 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.agent.tools.mcp_oauth import MCPOAuthStorage, mcp_oauth_has_credentials
from nanobot.config.loader import load_config from nanobot.config.loader import load_config
from nanobot.webui.mcp_presets_api import ( from nanobot.webui.mcp_presets_api import (
@@ -12,13 +16,48 @@ from nanobot.webui.mcp_presets_api import (
custom_mcp_action, custom_mcp_action,
mcp_presets_action, mcp_presets_action,
mcp_presets_payload, mcp_presets_payload,
mcp_presets_settings_action,
mcp_presets_test_action, mcp_presets_test_action,
normalize_mcp_preset_mentions, normalize_mcp_preset_mentions,
) )
def _use_config(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: 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: 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" 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 @pytest.mark.asyncio
async def test_oauth_preset_is_one_click_configured_after_token_storage( async def test_oauth_preset_is_one_click_configured_after_token_storage(
tmp_path, tmp_path,
@@ -4,6 +4,7 @@ import {
useId, useId,
useMemo, useMemo,
useState, useState,
type ComponentPropsWithoutRef,
type Dispatch, type Dispatch,
type ReactNode, type ReactNode,
type SetStateAction, type SetStateAction,
@@ -16,6 +17,7 @@ import {
Database, Database,
ExternalLink, ExternalLink,
Loader2, Loader2,
PauseCircle,
PlayCircle, PlayCircle,
Plus, Plus,
RotateCcw, RotateCcw,
@@ -156,7 +158,7 @@ export function AppsCatalogSettings({
onQueryChange: (value: string) => void; onQueryChange: (value: string) => void;
onFilterChange: (value: AppsKindFilter) => void; onFilterChange: (value: AppsKindFilter) => void;
onCliAction: (action: "install" | "update" | "uninstall" | "test", name: string) => void; onCliAction: (action: "install" | "update" | "uninstall" | "test", name: string) => void;
onMcpAction: (action: "enable" | "remove" | "test", name: string, values?: Record<string, string>) => void; onMcpAction: (action: "enable" | "disable" | "remove" | "test", name: string, values?: Record<string, string>) => void;
onMcpOAuthConnect: (name: string) => void; onMcpOAuthConnect: (name: string) => void;
onMcpOAuthCancel: () => void; onMcpOAuthCancel: () => void;
onMcpOAuthOpen: () => void; onMcpOAuthOpen: () => void;
@@ -191,7 +193,11 @@ export function AppsCatalogSettings({
] ]
.filter((item) => { .filter((item) => {
if (normalizedQuery) return appsSearchText(item).includes(normalizedQuery); 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) => { .sort((left, right) => {
const rank = Number(!appsReady(left)) - Number(!appsReady(right)); const rank = Number(!appsReady(left)) - Number(!appsReady(right));
@@ -500,7 +506,7 @@ function McpAppsCatalogRow({
oauthCallbackError: string | null; oauthCallbackError: string | null;
showBrandLogos: boolean; showBrandLogos: boolean;
onFieldChange: (presetName: string, fieldName: string, value: string) => void; onFieldChange: (presetName: string, fieldName: string, value: string) => void;
onAction: (action: "enable" | "remove" | "test", name: string, values?: Record<string, string>) => void; onAction: (action: "enable" | "disable" | "remove" | "test", name: string, values?: Record<string, string>) => void;
onOAuthConnect: (name: string) => void; onOAuthConnect: (name: string) => void;
onOAuthCancel: () => void; onOAuthCancel: () => void;
onOAuthOpen: () => void; onOAuthOpen: () => void;
@@ -513,18 +519,20 @@ function McpAppsCatalogRow({
const [setupOpen, setSetupOpen] = useState(false); const [setupOpen, setSetupOpen] = useState(false);
const [toolsOpen, setToolsOpen] = useState(false); const [toolsOpen, setToolsOpen] = useState(false);
const enableBusy = actionKey === `enable:${preset.name}`; const enableBusy = actionKey === `enable:${preset.name}`;
const disableBusy = actionKey === `disable:${preset.name}`;
const removeBusy = actionKey === `remove:${preset.name}`; const removeBusy = actionKey === `remove:${preset.name}`;
const testBusy = actionKey === `test:${preset.name}`; const testBusy = actionKey === `test:${preset.name}`;
const toolsBusy = actionKey === `tools:${preset.name}`; const toolsBusy = actionKey === `tools:${preset.name}`;
const oauthBusy = actionKey === `oauth:${preset.name}`; const oauthBusy = actionKey === `oauth:${preset.name}`;
const anotherOAuthBusy = Boolean(actionKey?.startsWith("oauth:")) && !oauthBusy; 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 isOAuth = preset.auth === "oauth";
const missingFields = preset.required_fields.filter((field) => field.required && !field.configured); const missingFields = preset.required_fields.filter((field) => field.required && !field.configured);
const hasFields = preset.required_fields.length > 0; const hasFields = preset.required_fields.length > 0;
const needsSetupInput = missingFields.length > 0; const needsSetupInput = missingFields.length > 0;
const readyInstalled = preset.installed && preset.configured; const readyInstalled = preset.enabled ?? (preset.installed && preset.configured);
const statusLabel = mcpPresetStatusLabel(preset.status, tx);
const canEnable = const canEnable =
preset.install_supported && preset.install_supported &&
(missingFields.length === 0 || missingFields.every((field) => Boolean(values[field.name]?.trim()))); (missingFields.length === 0 || missingFields.every((field) => Boolean(values[field.name]?.trim())));
@@ -532,7 +540,13 @@ function McpAppsCatalogRow({
const enabledTools = preset.enabled_tools ?? ["*"]; const enabledTools = preset.enabled_tools ?? ["*"];
const allowAllTools = enabledTools.includes("*"); const allowAllTools = enabledTools.includes("*");
const enabledSet = new Set(allowAllTools ? toolNames : enabledTools); 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 = const manualCallback =
oauthFlow?.completion_input === "callback_url" && Boolean(oauthFlow.authorization_url); oauthFlow?.completion_input === "callback_url" && Boolean(oauthFlow.authorization_url);
const callbackInputId = `mcp-oauth-callback-${preset.name}`; const callbackInputId = `mcp-oauth-callback-${preset.name}`;
@@ -581,9 +595,13 @@ function McpAppsCatalogRow({
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex min-w-0 items-baseline gap-2"> <div className="flex min-w-0 items-baseline gap-2">
<h3 className="truncate text-[14px] font-semibold leading-5 text-foreground">{preset.display_name}</h3> <h3 className="truncate text-[14px] font-semibold leading-5 text-foreground">{preset.display_name}</h3>
<AppsTypeBadge>{tx("settings.apps.mcpLabel", "MCP")}</AppsTypeBadge> <AppsTypeBadge>
{agentPlugin
? tx("settings.apps.filterPlugins", "Plugins")
: tx("settings.apps.mcpLabel", "MCP")}
</AppsTypeBadge>
</div> </div>
<p className="mt-0.5 truncate text-[12.5px] leading-5 text-muted-foreground">{description}</p> <p className="mt-0.5 truncate text-[12.5px] leading-5 text-muted-foreground">{detail}</p>
</div> </div>
<div <div
className={cn( className={cn(
@@ -598,7 +616,7 @@ function McpAppsCatalogRow({
<AppsActionButton <AppsActionButton
ariaLabel={`${preset.display_name}: ${statusLabel}`} ariaLabel={`${preset.display_name}: ${statusLabel}`}
visibleLabel={statusLabel} visibleLabel={statusLabel}
busy={testBusy || toolsBusy} busy={testBusy || toolsBusy || disableBusy}
disabled={busy} disabled={busy}
tone="installed" tone="installed"
> >
@@ -606,26 +624,31 @@ function McpAppsCatalogRow({
</AppsActionButton> </AppsActionButton>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
{!toggleable ? (
<DropdownMenuItem disabled={busy} onClick={() => onAction("test", preset.name)}> <DropdownMenuItem disabled={busy} onClick={() => onAction("test", preset.name)}>
<PlayCircle aria-hidden /> <PlayCircle aria-hidden />
{tx("settings.mcp.test", "Test")} {tx("settings.mcp.test", "Test")}
</DropdownMenuItem> </DropdownMenuItem>
{toolNames.length ? ( ) : null}
{!toggleable && toolNames.length ? (
<DropdownMenuItem disabled={busy} onClick={() => setToolsOpen((open) => !open)}> <DropdownMenuItem disabled={busy} onClick={() => setToolsOpen((open) => !open)}>
<SlidersHorizontal aria-hidden /> <SlidersHorizontal aria-hidden />
{tx("settings.mcp.toolScope", "Tools")} {tx("settings.mcp.toolScope", "Tools")}
</DropdownMenuItem> </DropdownMenuItem>
) : null} ) : null}
<DropdownMenuItem <DropdownMenuItem
tone="destructive" tone={toggleable ? undefined : "destructive"}
disabled={busy} disabled={busy}
onClick={() => onAction("remove", preset.name)} onClick={() => onAction(toggleable ? "disable" : "remove", preset.name)}
> >
<Trash2 aria-hidden /> {toggleable ? <PauseCircle aria-hidden /> : <Trash2 aria-hidden />}
{tx("settings.mcp.remove", "Remove")} {toggleable
? tx("settings.nanobotFeatures.disable", "Disable")
: tx("settings.mcp.remove", "Remove")}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
{!toggleable ? (
<AppsActionButton <AppsActionButton
ariaLabel={tx("settings.mcp.remove", "Remove")} ariaLabel={tx("settings.mcp.remove", "Remove")}
busy={removeBusy} busy={removeBusy}
@@ -635,7 +658,15 @@ function McpAppsCatalogRow({
> >
<Trash2 className="h-4 w-4" aria-hidden /> <Trash2 className="h-4 w-4" aria-hidden />
</AppsActionButton> </AppsActionButton>
) : null}
</> </>
) : preset.enabled === false ? (
<AppsActionButton
ariaLabel={tx("settings.nanobotFeatures.enable", "Enable")}
visibleLabel={tx("settings.nanobotFeatures.enable", "Enable")}
busy={enableBusy}
onClick={() => onAction("enable", preset.name, values)}
/>
) : oauthFlow ? ( ) : oauthFlow ? (
<> <>
<AppsActionButton <AppsActionButton
@@ -935,25 +966,24 @@ function AppsTypeBadge({ children }: { children: ReactNode }) {
); );
} }
export const AppsActionButton = forwardRef<HTMLButtonElement, { export const AppsActionButton = forwardRef<HTMLButtonElement, ComponentPropsWithoutRef<typeof Button> & {
ariaLabel: string; ariaLabel: string;
visibleLabel?: string; visibleLabel?: string;
busy?: boolean; busy?: boolean;
disabled?: boolean;
tone?: "default" | "installed" | "danger"; tone?: "default" | "installed" | "danger";
onClick?: () => void;
children?: ReactNode;
}>(function AppsActionButton({ }>(function AppsActionButton({
ariaLabel, ariaLabel,
visibleLabel, visibleLabel,
busy, busy,
disabled, disabled,
tone = "default", tone = "default",
onClick, className,
children, children,
...props
}, ref) { }, ref) {
return ( return (
<Button <Button
{...props}
ref={ref} ref={ref}
type="button" type="button"
size={visibleLabel ? "sm" : "icon"} size={visibleLabel ? "sm" : "icon"}
@@ -961,7 +991,6 @@ export const AppsActionButton = forwardRef<HTMLButtonElement, {
aria-label={ariaLabel} aria-label={ariaLabel}
title={ariaLabel} title={ariaLabel}
disabled={disabled || busy} disabled={disabled || busy}
onClick={onClick}
className={cn( className={cn(
"rounded-full text-muted-foreground transition-colors", "rounded-full text-muted-foreground transition-colors",
visibleLabel visibleLabel
@@ -970,6 +999,7 @@ export const AppsActionButton = forwardRef<HTMLButtonElement, {
tone === "installed" && "bg-transparent hover:bg-muted/70 hover:text-foreground", tone === "installed" && "bg-transparent hover:bg-muted/70 hover:text-foreground",
tone === "danger" && "bg-transparent hover:bg-destructive/10 hover:text-destructive", tone === "danger" && "bg-transparent hover:bg-destructive/10 hover:text-destructive",
tone === "default" && "bg-muted/70 hover:bg-muted hover:text-foreground", tone === "default" && "bg-muted/70 hover:bg-muted hover:text-foreground",
className,
)} )}
> >
{busy ? <Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" aria-hidden /> : children} {busy ? <Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" aria-hidden /> : children}
@@ -983,7 +1013,8 @@ function appsTitle(item: AppsCatalogItem): string {
} }
function appsReady(item: AppsCatalogItem): boolean { function appsReady(item: AppsCatalogItem): boolean {
return item.kind === "cli" ? item.app.installed : item.preset.installed && item.preset.configured; if (item.kind === "cli") return item.app.installed;
return item.preset.enabled ?? (item.preset.installed && item.preset.configured);
} }
function appsSearchText(item: AppsCatalogItem): string { function appsSearchText(item: AppsCatalogItem): string {
@@ -1374,6 +1405,7 @@ function McpPresetLogo({ preset, showBrandLogos }: { preset: McpPresetInfo; show
const bg = preset.brand_color || "hsl(var(--muted))"; const bg = preset.brand_color || "hsl(var(--muted))";
const logoUrls = useMemo(() => logoFallbackUrls(preset.logo_url), [preset.logo_url]); const logoUrls = useMemo(() => logoFallbackUrls(preset.logo_url), [preset.logo_url]);
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls); const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
const packagedLogo = preset.logo_url?.startsWith("data:image/") === true;
const initials = preset.display_name const initials = preset.display_name
.split(/\s+/) .split(/\s+/)
.filter(Boolean) .filter(Boolean)
@@ -1381,7 +1413,7 @@ function McpPresetLogo({ preset, showBrandLogos }: { preset: McpPresetInfo; show
.map((part) => part[0]?.toUpperCase()) .map((part) => part[0]?.toUpperCase())
.join("") || preset.name.slice(0, 2).toUpperCase(); .join("") || preset.name.slice(0, 2).toUpperCase();
if (showBrandLogos && logoUrl) { if ((showBrandLogos || packagedLogo) && logoUrl) {
return ( return (
<span <span
className="grid h-11 w-11 shrink-0 place-items-center rounded-[8px] border border-border/45 bg-background" className="grid h-11 w-11 shrink-0 place-items-center rounded-[8px] border border-border/45 bg-background"
@@ -521,7 +521,7 @@ export function createSystemSettingsActions({
}; };
const handleMcpPresetAction = async ( const handleMcpPresetAction = async (
action: "enable" | "remove" | "test", action: "enable" | "disable" | "remove" | "test",
name: string, name: string,
values: Record<string, string> = {}, values: Record<string, string> = {},
) => { ) => {
+1 -1
View File
@@ -766,7 +766,7 @@ export async function fetchProviderModels(
export async function runMcpPresetAction( export async function runMcpPresetAction(
transport: WebUIMutationTransport, transport: WebUIMutationTransport,
action: "enable" | "remove" | "test", action: "enable" | "disable" | "remove" | "test",
name: string, name: string,
values: Record<string, string> = {}, values: Record<string, string> = {},
): Promise<McpPresetsPayload> { ): Promise<McpPresetsPayload> {
+4 -1
View File
@@ -9,7 +9,10 @@ export function isMcpPresetsPayload(value: unknown): value is McpPresetsPayload
} }
export function installedMcpPresetsFromPayload(payload: McpPresetsPayload): McpPresetInfo[] { export function installedMcpPresetsFromPayload(payload: McpPresetsPayload): McpPresetInfo[] {
return payload.presets.filter((preset) => preset.installed && preset.configured); return payload.presets.filter(
(preset) => preset.source !== "agent-plugin"
&& (preset.enabled ?? (preset.installed && preset.configured)),
);
} }
export function notifyMcpPresetsChanged(payload: McpPresetsPayload): void { export function notifyMcpPresetsChanged(payload: McpPresetsPayload): void {
+1
View File
@@ -962,6 +962,7 @@ export interface McpPresetInfo {
install_supported: boolean; install_supported: boolean;
installed: boolean; installed: boolean;
configured: boolean; configured: boolean;
enabled?: boolean;
available: boolean; available: boolean;
status: "not_installed" | "configured" | "missing_credentials" | "missing_dependency" | "coming_soon" | string; status: "not_installed" | "configured" | "missing_credentials" | "missing_dependency" | "coming_soon" | string;
logo_url?: string | null; logo_url?: string | null;
+67
View File
@@ -1,5 +1,6 @@
import { act, fireEvent, screen, waitFor } from "@testing-library/react"; import { act, fireEvent, screen, waitFor } from "@testing-library/react";
import { expect, it, vi } from "vitest"; import { expect, it, vi } from "vitest";
import { installedMcpPresetsFromPayload } from "@/lib/mcp-preset-events";
import { requestMutationMock, jsonResponse, settingsPayload, renderSettingsView, installSettingsViewTestHooks } from "@/tests/settings-test-utils"; import { requestMutationMock, jsonResponse, settingsPayload, renderSettingsView, installSettingsViewTestHooks } from "@/tests/settings-test-utils";
@@ -20,9 +21,75 @@ const installedAnyGen = {
skill_installed: true, skill_installed: true,
}; };
const agentPlugin = {
name: "plugin-computer-use",
display_name: "Computer Use",
category: "Plugin",
description: "Control the desktop with a live preview.",
requires: "screen-recording, accessibility",
transport: "stdio",
install_supported: false,
installed: true,
configured: true,
enabled: false,
available: false,
status: "disabled",
required_fields: [],
source: "agent-plugin",
};
describe("Settings system domains", () => { describe("Settings system domains", () => {
installSettingsViewTestHooks(); installSettingsViewTestHooks();
it("keeps enabled Agent Plugins out of MCP composer attachments", () => {
const enabled = { ...agentPlugin, enabled: true, available: true, status: "enabled" };
expect(installedMcpPresetsFromPayload({ presets: [enabled], installed_count: 1 })).toEqual([]);
});
it("enables and disables an installed Agent Plugin explicitly", async () => {
vi.stubGlobal("fetch", 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: [agentPlugin], installed_count: 0 });
}
return jsonResponse({});
}));
requestMutationMock.mockImplementation(async (action: string) => {
const enabled = action.endsWith(".enable");
return {
presets: [{
...agentPlugin,
enabled,
available: enabled,
status: enabled ? "enabled" : "disabled",
}],
installed_count: Number(enabled),
};
});
renderSettingsView();
expect(await screen.findByText("Computer Use")).toBeInTheDocument();
expect(screen.getByText("Plugins")).toBeInTheDocument();
expect(screen.getByText(/Control the desktop.*screen-recording, accessibility/)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Enable" }));
await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith(
"settings.mcp.enable", { name: "plugin-computer-use" }, 20_000,
));
const enabledButton = await screen.findByRole("button", { name: "Computer Use: Enabled" });
await waitFor(() => expect(enabledButton).toBeEnabled());
fireEvent.pointerDown(enabledButton, { button: 0, ctrlKey: false });
fireEvent.click(await screen.findByRole("menuitem", { name: "Disable" }));
await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith(
"settings.mcp.disable", { name: "plugin-computer-use" }, 20_000,
));
expect(await screen.findByRole("button", { name: "Enable" })).toBeInTheDocument();
});
it("does not show the Settings kicker on the standalone Automations surface", async () => { it("does not show the Settings kicker on the standalone Automations surface", async () => {
const onBackToChat = vi.fn(); const onBackToChat = vi.fn();