mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-09 22:08:38 +03:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6286692693 | ||
|
|
ff78a70fc0 | ||
|
|
e02c04f2ae | ||
|
|
570ce5cc0a | ||
|
|
6bcdbe37e3 |
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -2306,6 +2306,44 @@ 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 also discovers portable [Agent Plugins](https://agent-plugins.org/) placed under
|
||||||
|
`<workspace>/plugins/<plugin>/`. A supported package has a root `plugin.json` that targets
|
||||||
|
Agent Plugins v1 and may provide skills, MCP servers, or both:
|
||||||
|
|
||||||
|
```text
|
||||||
|
plugins/
|
||||||
|
└── release-tools/
|
||||||
|
├── plugin.json
|
||||||
|
├── mcp.json
|
||||||
|
└── skills/
|
||||||
|
└── release-notes/
|
||||||
|
└── SKILL.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Plugin skills use the same progressive loading and `$skill-name` invocation as workspace
|
||||||
|
skills. A workspace skill wins when it has the same name as a plugin skill; plugin skills win
|
||||||
|
over built-in skills. Invalid manifests, invalid Agent Skills, nested skill directories, and
|
||||||
|
paths that resolve outside the plugin root are ignored.
|
||||||
|
|
||||||
|
Portable MCP servers declared in `mcp.json` appear in **Apps**, but are never started merely
|
||||||
|
because a package exists. Enabling a plugin there is the explicit trust decision that activates
|
||||||
|
its executable components. The host expands `PLUGIN_ROOT` and an isolated `PLUGIN_DATA`, checks
|
||||||
|
package paths before launch, and hot-reloads MCP connections. Explicit `tools.mcpServers`
|
||||||
|
configuration wins over a plugin server if their host names collide. The v1 host currently
|
||||||
|
supports plugin `stdio` servers; unsupported remote transports are skipped independently.
|
||||||
|
|
||||||
|
Plugins may optionally declare a shell-free `extensions.dev.nanobot.installCommand` array. The
|
||||||
|
local WebUI runs it once per plugin version before first enable; remote WebUI clients cannot run
|
||||||
|
plugin setup unless remote package installation was explicitly allowed. Agent Plugins v1 does
|
||||||
|
not define a registry, so package distribution remains separate from discovery and execution.
|
||||||
|
|
||||||
|
CLI Apps installed from the WebUI use the same package layout. nanobot installs the CLI through
|
||||||
|
its catalog adapter, then writes a skills-only Agent Plugin under `<workspace>/plugins/`; updates
|
||||||
|
refresh that package and uninstall removes it. The external executable remains managed by the
|
||||||
|
CLI Apps installer rather than by the Agent Plugins manifest.
|
||||||
|
|
||||||
## 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.
|
||||||
|
|||||||
@@ -0,0 +1,565 @@
|
|||||||
|
"""Discover portable Agent Plugins from the agent workspace."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from hashlib import sha256
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from nanobot.config.loader import get_config_path
|
||||||
|
from nanobot.config.schema import MCPServerConfig
|
||||||
|
|
||||||
|
AGENT_PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"
|
||||||
|
AGENT_PLUGIN_MCP_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json"
|
||||||
|
|
||||||
|
_PLUGIN_NAME = re.compile(r"^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$")
|
||||||
|
_SKILL_NAME = re.compile(r"^(?!.*--)[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
|
||||||
|
_SKILL_FRONTMATTER = re.compile(r"^---\s*\r?\n(.*?)\r?\n---\s*\r?\n?", re.DOTALL)
|
||||||
|
_MANIFEST_FIELDS = {
|
||||||
|
"$schema",
|
||||||
|
"name",
|
||||||
|
"version",
|
||||||
|
"description",
|
||||||
|
"author",
|
||||||
|
"homepage",
|
||||||
|
"repository",
|
||||||
|
"license",
|
||||||
|
"keywords",
|
||||||
|
"extensions",
|
||||||
|
}
|
||||||
|
_STRING_FIELDS = {"version", "description", "homepage", "repository", "license"}
|
||||||
|
_AUTHOR_FIELDS = {"name", "email", "url"}
|
||||||
|
_MCP_SERVER_FIELDS = {
|
||||||
|
"stdio": {"type", "command", "args", "env", "cwd"},
|
||||||
|
}
|
||||||
|
_SETUP_ENV = {"HOME", "LANG", "LC_ALL", "LOGNAME", "PATH", "SHELL", "TMPDIR", "USER"}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AgentPluginSkill:
|
||||||
|
"""One skill supplied by a valid Agent Plugins v1 package."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
path: Path
|
||||||
|
plugin: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AgentPlugin:
|
||||||
|
"""A validated Agent Plugins v1 package installed in the workspace."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
root: Path
|
||||||
|
version: str
|
||||||
|
description: str
|
||||||
|
repository: str
|
||||||
|
display_name: str
|
||||||
|
category: str
|
||||||
|
accent_color: str | None
|
||||||
|
permissions: tuple[str, ...]
|
||||||
|
install_command: tuple[str, ...]
|
||||||
|
|
||||||
|
|
||||||
|
def discover_agent_plugins(workspace: Path) -> list[AgentPlugin]:
|
||||||
|
"""Return valid packages from ``<workspace>/plugins/*``."""
|
||||||
|
workspace = workspace.expanduser().resolve()
|
||||||
|
plugins_root = workspace / "plugins"
|
||||||
|
if not plugins_root.is_dir():
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
root = plugins_root.resolve(strict=True)
|
||||||
|
except OSError:
|
||||||
|
return []
|
||||||
|
if not root.is_relative_to(workspace):
|
||||||
|
logger.warning("Ignoring Agent Plugins directory outside the workspace")
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
candidates = sorted(plugins_root.iterdir(), key=lambda path: path.name)
|
||||||
|
except OSError as exc:
|
||||||
|
logger.warning("Could not inspect Agent Plugins directory: {}", exc)
|
||||||
|
return []
|
||||||
|
|
||||||
|
plugins: list[AgentPlugin] = []
|
||||||
|
for candidate in candidates:
|
||||||
|
plugin_root = _contained_directory(candidate, root)
|
||||||
|
if plugin_root is None:
|
||||||
|
continue
|
||||||
|
plugin = _load_manifest(plugin_root)
|
||||||
|
if plugin is not None:
|
||||||
|
plugins.append(plugin)
|
||||||
|
return plugins
|
||||||
|
|
||||||
|
|
||||||
|
def discover_agent_plugin_skills(workspace: Path) -> list[AgentPluginSkill]:
|
||||||
|
"""Discover direct-child skills under ``<workspace>/plugins/*``.
|
||||||
|
|
||||||
|
Agent Plugins does not prescribe an install location. nanobot uses the
|
||||||
|
workspace ``plugins`` directory so packages stay explicit and portable
|
||||||
|
with the rest of the agent workspace.
|
||||||
|
"""
|
||||||
|
skills: list[AgentPluginSkill] = []
|
||||||
|
for plugin in discover_agent_plugins(workspace):
|
||||||
|
skills.extend(_discover_plugin_skills(plugin.name, plugin.root))
|
||||||
|
return skills
|
||||||
|
|
||||||
|
|
||||||
|
def _load_manifest(plugin_root: Path) -> AgentPlugin | None:
|
||||||
|
manifest = _contained_file(plugin_root / "plugin.json", plugin_root)
|
||||||
|
if manifest is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
value = cast(object, json.loads(manifest.read_text(encoding="utf-8")))
|
||||||
|
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||||
|
logger.warning("Ignoring invalid Agent Plugin manifest '{}': {}", manifest, exc)
|
||||||
|
return None
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
logger.warning("Ignoring Agent Plugin manifest '{}': expected a JSON object", manifest)
|
||||||
|
return None
|
||||||
|
|
||||||
|
payload = cast(dict[str, Any], value)
|
||||||
|
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 '{}': invalid name", manifest)
|
||||||
|
return None
|
||||||
|
if not _valid_optional_fields(payload):
|
||||||
|
logger.warning("Ignoring Agent Plugin manifest '{}': invalid metadata", manifest)
|
||||||
|
return None
|
||||||
|
|
||||||
|
for field in payload.keys() - _MANIFEST_FIELDS:
|
||||||
|
logger.warning("Ignoring unknown Agent Plugin manifest field '{}' in '{}'", field, manifest)
|
||||||
|
if "extensions" in payload and not isinstance(payload["extensions"], dict):
|
||||||
|
logger.warning("Ignoring non-object Agent Plugin extensions in '{}'", manifest)
|
||||||
|
extension = payload.get("extensions")
|
||||||
|
extension_payload = cast(dict[str, object], extension) if isinstance(extension, dict) else {}
|
||||||
|
nanobot_value = extension_payload.get("dev.nanobot")
|
||||||
|
nanobot = cast(dict[str, object], nanobot_value) if isinstance(nanobot_value, dict) else {}
|
||||||
|
return AgentPlugin(
|
||||||
|
name=name,
|
||||||
|
root=plugin_root,
|
||||||
|
version=_string(payload.get("version")),
|
||||||
|
description=_string(payload.get("description")),
|
||||||
|
repository=_string(payload.get("repository")),
|
||||||
|
display_name=_string(nanobot.get("displayName")) or name,
|
||||||
|
category=_string(nanobot.get("category")) or "Plugin",
|
||||||
|
accent_color=_accent_color(nanobot.get("accentColor")),
|
||||||
|
permissions=_string_tuple(nanobot.get("permissions")),
|
||||||
|
install_command=_install_command(nanobot.get("installCommand"), plugin_root),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def agent_plugin_mcp_servers(
|
||||||
|
workspace: Path,
|
||||||
|
configured: dict[str, MCPServerConfig] | None = None,
|
||||||
|
) -> dict[str, MCPServerConfig]:
|
||||||
|
"""Merge explicitly enabled plugin MCP servers with user configuration.
|
||||||
|
|
||||||
|
User configuration wins on the unlikely event of a namespaced collision.
|
||||||
|
"""
|
||||||
|
servers: dict[str, MCPServerConfig] = {}
|
||||||
|
for plugin in discover_agent_plugins(workspace):
|
||||||
|
if not _enabled(workspace, plugin.name):
|
||||||
|
continue
|
||||||
|
plugin_servers = _plugin_mcp_servers(workspace, plugin)
|
||||||
|
for name, server in plugin_servers.items():
|
||||||
|
host_name = plugin.name if len(plugin_servers) == 1 else f"{plugin.name}-{name}"
|
||||||
|
servers[host_name] = server
|
||||||
|
for name, server in (configured or {}).items():
|
||||||
|
if name in servers:
|
||||||
|
logger.warning("Configured MCP server '{}' overrides an Agent Plugin server", name)
|
||||||
|
servers[name] = server
|
||||||
|
return servers
|
||||||
|
|
||||||
|
|
||||||
|
def agent_plugins_payload(workspace: Path) -> dict[str, Any]:
|
||||||
|
"""Return installed Agent Plugins for the WebUI Apps surface."""
|
||||||
|
plugins: list[dict[str, Any]] = []
|
||||||
|
enabled_count = 0
|
||||||
|
for plugin in discover_agent_plugins(workspace):
|
||||||
|
mcp_servers = sorted(_plugin_mcp_servers(workspace, plugin))
|
||||||
|
if not mcp_servers and not plugin.install_command:
|
||||||
|
continue
|
||||||
|
enabled = _enabled(workspace, plugin.name)
|
||||||
|
enabled_count += int(enabled)
|
||||||
|
plugins.append(
|
||||||
|
{
|
||||||
|
"name": plugin.name,
|
||||||
|
"display_name": plugin.display_name,
|
||||||
|
"version": plugin.version,
|
||||||
|
"description": plugin.description,
|
||||||
|
"category": plugin.category,
|
||||||
|
"repository": plugin.repository,
|
||||||
|
"accent_color": plugin.accent_color,
|
||||||
|
"permissions": list(plugin.permissions),
|
||||||
|
"mcp_servers": mcp_servers,
|
||||||
|
"enabled": enabled,
|
||||||
|
"setup_required": bool(plugin.install_command)
|
||||||
|
and _setup_version(workspace, plugin.name) != (plugin.version or "unknown"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"plugins": plugins, "enabled_count": enabled_count}
|
||||||
|
|
||||||
|
|
||||||
|
def set_agent_plugin_enabled(workspace: Path, name: str, enabled: bool) -> dict[str, Any]:
|
||||||
|
"""Enable or disable one installed plugin's executable MCP components."""
|
||||||
|
plugin = next((item for item in discover_agent_plugins(workspace) if item.name == name), None)
|
||||||
|
if plugin is None:
|
||||||
|
raise ValueError(f"unknown Agent Plugin '{name}'")
|
||||||
|
data = _plugin_data_dir(workspace, plugin.name, create=True)
|
||||||
|
if enabled:
|
||||||
|
if plugin.install_command and _setup_version(workspace, plugin.name) != (plugin.version or "unknown"):
|
||||||
|
_run_install(plugin, data)
|
||||||
|
_write_state(data / "setup-version", plugin.version or "unknown")
|
||||||
|
_write_state(data / "enabled", "1")
|
||||||
|
else:
|
||||||
|
(data / "enabled").unlink(missing_ok=True)
|
||||||
|
payload = agent_plugins_payload(workspace)
|
||||||
|
payload["last_action"] = {
|
||||||
|
"ok": True,
|
||||||
|
"message": f"{plugin.display_name} {'enabled' if enabled else 'disabled'}.",
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_optional_fields(payload: dict[str, Any]) -> bool:
|
||||||
|
if any(field in payload and not isinstance(payload[field], str) for field in _STRING_FIELDS):
|
||||||
|
return False
|
||||||
|
keywords = payload.get("keywords")
|
||||||
|
if "keywords" in payload and (
|
||||||
|
not isinstance(keywords, list)
|
||||||
|
or not all(isinstance(keyword, str) for keyword in cast(list[object], keywords))
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
author = payload.get("author")
|
||||||
|
if "author" not in payload:
|
||||||
|
return True
|
||||||
|
if not isinstance(author, dict):
|
||||||
|
return False
|
||||||
|
author_payload = cast(dict[str, object], author)
|
||||||
|
return not (author_payload.keys() - _AUTHOR_FIELDS) and all(
|
||||||
|
isinstance(value, str) for value in author_payload.values()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _string(value: object) -> str:
|
||||||
|
return value.strip() if isinstance(value, str) else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _string_tuple(value: object) -> tuple[str, ...]:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return ()
|
||||||
|
items = cast(list[object], value)
|
||||||
|
return tuple(item.strip() for item in items if isinstance(item, str) and item.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def _accent_color(value: object) -> str | None:
|
||||||
|
return value if isinstance(value, str) and re.fullmatch(r"#[0-9a-fA-F]{6}", value) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _install_command(value: object, plugin_root: Path) -> tuple[str, ...]:
|
||||||
|
"""Validate nanobot's optional, shell-free setup command extension."""
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return ()
|
||||||
|
items = cast(list[object], value)
|
||||||
|
if not 1 <= len(items) <= 32 or not all(
|
||||||
|
isinstance(item, str) and 0 < len(item) <= 4096 for item in items
|
||||||
|
):
|
||||||
|
return ()
|
||||||
|
command = cast(str, items[0])
|
||||||
|
if not command.startswith("./"):
|
||||||
|
logger.warning("Ignoring non-relative Agent Plugin installCommand in '{}'", plugin_root)
|
||||||
|
return ()
|
||||||
|
executable = _contained_file(plugin_root / command[2:], plugin_root)
|
||||||
|
if executable is None:
|
||||||
|
logger.warning("Ignoring invalid Agent Plugin installCommand in '{}'", plugin_root)
|
||||||
|
return ()
|
||||||
|
return (str(executable), *(cast(str, item) for item in items[1:]))
|
||||||
|
|
||||||
|
|
||||||
|
def _plugin_mcp_servers(workspace: Path, plugin: AgentPlugin) -> dict[str, MCPServerConfig]:
|
||||||
|
path = _contained_file(plugin.root / "mcp.json", plugin.root)
|
||||||
|
if path is None:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
value = cast(object, json.loads(path.read_text(encoding="utf-8")))
|
||||||
|
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||||
|
logger.warning("Ignoring invalid MCP component for Agent Plugin '{}': {}", plugin.name, exc)
|
||||||
|
return {}
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return {}
|
||||||
|
payload = cast(dict[str, Any], value)
|
||||||
|
raw_servers = payload.get("mcpServers")
|
||||||
|
if (
|
||||||
|
payload.keys() != {"$schema", "mcpServers"}
|
||||||
|
or payload.get("$schema") != AGENT_PLUGIN_MCP_SCHEMA
|
||||||
|
or not isinstance(raw_servers, dict)
|
||||||
|
):
|
||||||
|
logger.warning("Ignoring invalid MCP component for Agent Plugin '{}'", plugin.name)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
data = _plugin_data_dir(workspace, plugin.name, create=True)
|
||||||
|
servers: dict[str, MCPServerConfig] = {}
|
||||||
|
for name, raw in cast(dict[str, object], raw_servers).items():
|
||||||
|
if not name or len(name) > 128 or any(ord(char) < 32 for char in name):
|
||||||
|
logger.warning("Ignoring invalid MCP server name in Agent Plugin '{}'", plugin.name)
|
||||||
|
continue
|
||||||
|
server = _plugin_mcp_server(raw, plugin.root, data)
|
||||||
|
if server is None:
|
||||||
|
logger.warning("Ignoring invalid MCP server '{}' in Agent Plugin '{}'", name, plugin.name)
|
||||||
|
continue
|
||||||
|
servers[name] = server
|
||||||
|
return servers
|
||||||
|
|
||||||
|
|
||||||
|
def _plugin_mcp_server(raw: object, root: Path, data: Path) -> MCPServerConfig | None:
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return None
|
||||||
|
payload = cast(dict[str, Any], raw)
|
||||||
|
transport = payload.get("type")
|
||||||
|
allowed = _MCP_SERVER_FIELDS.get(transport) if isinstance(transport, str) else None
|
||||||
|
if allowed is None or payload.keys() - allowed:
|
||||||
|
return None
|
||||||
|
if transport == "stdio":
|
||||||
|
command = _stdio_command(payload.get("command"), root)
|
||||||
|
args = payload.get("args", [])
|
||||||
|
env = payload.get("env", {})
|
||||||
|
cwd = _stdio_cwd(payload.get("cwd"), root, data)
|
||||||
|
if (
|
||||||
|
command is None
|
||||||
|
or not isinstance(args, list)
|
||||||
|
or not all(isinstance(item, str) for item in cast(list[object], args))
|
||||||
|
or not isinstance(env, dict)
|
||||||
|
or cwd is None
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
env_payload = cast(dict[object, object], env)
|
||||||
|
if any(
|
||||||
|
not isinstance(key, str)
|
||||||
|
or key in {"PLUGIN_ROOT", "PLUGIN_DATA"}
|
||||||
|
or not isinstance(value, str)
|
||||||
|
for key, value in env_payload.items()
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
string_env = cast(dict[str, str], env)
|
||||||
|
replacements = {"${PLUGIN_ROOT}": str(root), "${PLUGIN_DATA}": str(data)}
|
||||||
|
return MCPServerConfig(
|
||||||
|
type="stdio",
|
||||||
|
command=command,
|
||||||
|
args=[_expand(item, replacements) for item in cast(list[str], args)],
|
||||||
|
env={
|
||||||
|
**{key: _expand(value, replacements) for key, value in string_env.items()},
|
||||||
|
"PLUGIN_ROOT": str(root),
|
||||||
|
"PLUGIN_DATA": str(data),
|
||||||
|
},
|
||||||
|
cwd=str(cwd),
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _stdio_command(value: object, root: Path) -> str | None:
|
||||||
|
if not isinstance(value, str) or not value:
|
||||||
|
return None
|
||||||
|
if value.startswith("./"):
|
||||||
|
executable = _contained_file(root / value[2:], root)
|
||||||
|
return str(executable) if executable is not None else None
|
||||||
|
if any(char.isspace() for char in value) or "/" in value or "\\" in value:
|
||||||
|
return None
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _stdio_cwd(value: object, root: Path, data: Path) -> Path | None:
|
||||||
|
if value is None:
|
||||||
|
return root
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
if value.startswith("./"):
|
||||||
|
return _contained_directory(root / value[2:], root)
|
||||||
|
for placeholder, base in (("${PLUGIN_ROOT}", root), ("${PLUGIN_DATA}", data)):
|
||||||
|
if value == placeholder or value.startswith(f"{placeholder}/"):
|
||||||
|
relative = value[len(placeholder):].lstrip("/")
|
||||||
|
candidate = (base / relative).resolve()
|
||||||
|
if not candidate.is_relative_to(base):
|
||||||
|
return None
|
||||||
|
if base == data:
|
||||||
|
candidate.mkdir(parents=True, exist_ok=True)
|
||||||
|
candidate.chmod(0o700)
|
||||||
|
return candidate if candidate.is_dir() else None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _expand(value: str, replacements: dict[str, str]) -> str:
|
||||||
|
for token, replacement in replacements.items():
|
||||||
|
value = value.replace(token, replacement)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _plugin_data_dir(workspace: Path, name: str, *, create: bool) -> Path:
|
||||||
|
workspace_id = sha256(str(workspace.expanduser().resolve()).encode()).hexdigest()[:12]
|
||||||
|
config_root = get_config_path().expanduser().resolve().parent
|
||||||
|
plugin_data_root = config_root / "plugin-data"
|
||||||
|
if create:
|
||||||
|
plugin_data_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
try:
|
||||||
|
resolved_plugin_data = plugin_data_root.resolve(strict=create)
|
||||||
|
except OSError as exc:
|
||||||
|
raise RuntimeError("Agent Plugin data root is unavailable") from exc
|
||||||
|
if not resolved_plugin_data.is_relative_to(config_root):
|
||||||
|
raise RuntimeError("Agent Plugin data root escapes the nanobot config directory")
|
||||||
|
state_root = resolved_plugin_data / workspace_id
|
||||||
|
if create:
|
||||||
|
resolved_plugin_data.chmod(0o700)
|
||||||
|
state_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
try:
|
||||||
|
resolved_state = state_root.resolve(strict=create)
|
||||||
|
except OSError as exc:
|
||||||
|
raise RuntimeError("Agent Plugin state directory is unavailable") from exc
|
||||||
|
if not resolved_state.is_relative_to(config_root):
|
||||||
|
raise RuntimeError("Agent Plugin state directory escapes the nanobot config directory")
|
||||||
|
if create:
|
||||||
|
resolved_state.chmod(0o700)
|
||||||
|
data = resolved_state / name
|
||||||
|
if create:
|
||||||
|
data.mkdir(exist_ok=True)
|
||||||
|
resolved_data = data.resolve(strict=True)
|
||||||
|
if not resolved_data.is_relative_to(resolved_state):
|
||||||
|
raise RuntimeError("Agent Plugin data directory escapes its state directory")
|
||||||
|
resolved_data.chmod(0o700)
|
||||||
|
return resolved_data
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _enabled(workspace: Path, name: str) -> bool:
|
||||||
|
return (_plugin_data_dir(workspace, name, create=False) / "enabled").is_file()
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_version(workspace: Path, name: str) -> str:
|
||||||
|
try:
|
||||||
|
return (_plugin_data_dir(workspace, name, create=False) / "setup-version").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
).strip()
|
||||||
|
except (OSError, UnicodeError):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _write_state(path: Path, value: str) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.parent.chmod(0o700)
|
||||||
|
temporary = path.with_name(f".{path.name}.tmp")
|
||||||
|
temporary.write_text(value, encoding="utf-8")
|
||||||
|
temporary.chmod(0o600)
|
||||||
|
temporary.replace(path)
|
||||||
|
path.chmod(0o600)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_install(plugin: AgentPlugin, data: Path) -> None:
|
||||||
|
env = {
|
||||||
|
**{key: value for key in _SETUP_ENV if (value := os.environ.get(key)) is not None},
|
||||||
|
"PLUGIN_ROOT": str(plugin.root),
|
||||||
|
"PLUGIN_DATA": str(data),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
plugin.install_command,
|
||||||
|
cwd=plugin.root,
|
||||||
|
env=env,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=600,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired as exc:
|
||||||
|
raise RuntimeError(f"{plugin.display_name} setup timed out") from exc
|
||||||
|
if result.returncode:
|
||||||
|
output = (result.stderr or result.stdout).strip()[-2000:]
|
||||||
|
raise RuntimeError(output or f"{plugin.display_name} setup failed")
|
||||||
|
|
||||||
|
|
||||||
|
def _discover_plugin_skills(plugin_name: str, plugin_root: Path) -> list[AgentPluginSkill]:
|
||||||
|
skills_root = plugin_root / "skills"
|
||||||
|
if not skills_root.exists():
|
||||||
|
return []
|
||||||
|
resolved_skills_root = _contained_directory(skills_root, plugin_root)
|
||||||
|
if resolved_skills_root is None:
|
||||||
|
logger.warning("Ignoring invalid skills component in Agent Plugin '{}'", plugin_name)
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
candidates = sorted(skills_root.iterdir(), key=lambda path: path.name)
|
||||||
|
except OSError as exc:
|
||||||
|
logger.warning("Could not inspect Agent Plugin '{}' skills: {}", plugin_name, exc)
|
||||||
|
return []
|
||||||
|
|
||||||
|
skills: list[AgentPluginSkill] = []
|
||||||
|
for candidate in candidates:
|
||||||
|
skill_root = _contained_directory(candidate, resolved_skills_root)
|
||||||
|
if skill_root is None:
|
||||||
|
continue
|
||||||
|
skill_file = _contained_file(skill_root / "SKILL.md", plugin_root)
|
||||||
|
if skill_file is None or not _valid_skill(skill_file, candidate.name, plugin_name):
|
||||||
|
continue
|
||||||
|
skills.append(
|
||||||
|
AgentPluginSkill(name=candidate.name, path=skill_file, plugin=plugin_name)
|
||||||
|
)
|
||||||
|
return skills
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_skill(path: Path, directory_name: str, plugin_name: str) -> bool:
|
||||||
|
try:
|
||||||
|
content = path.read_text(encoding="utf-8")
|
||||||
|
except (OSError, UnicodeError):
|
||||||
|
return False
|
||||||
|
match = _SKILL_FRONTMATTER.match(content)
|
||||||
|
if match is None:
|
||||||
|
logger.warning("Ignoring Agent Plugin '{}' skill '{}': invalid frontmatter", plugin_name, directory_name)
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
metadata = cast(object, yaml.safe_load(match.group(1)))
|
||||||
|
except yaml.YAMLError:
|
||||||
|
metadata = None
|
||||||
|
if not isinstance(metadata, dict):
|
||||||
|
logger.warning("Ignoring Agent Plugin '{}' skill '{}': invalid frontmatter", plugin_name, directory_name)
|
||||||
|
return False
|
||||||
|
payload = cast(dict[object, object], metadata)
|
||||||
|
name = payload.get("name")
|
||||||
|
description = payload.get("description")
|
||||||
|
valid = (
|
||||||
|
name == directory_name
|
||||||
|
and isinstance(name, str)
|
||||||
|
and len(name) <= 64
|
||||||
|
and _SKILL_NAME.fullmatch(name) is not None
|
||||||
|
and isinstance(description, str)
|
||||||
|
and 1 <= len(description.strip()) <= 1024
|
||||||
|
)
|
||||||
|
if not valid:
|
||||||
|
logger.warning("Ignoring Agent Plugin '{}' skill '{}': invalid metadata", plugin_name, directory_name)
|
||||||
|
return valid
|
||||||
|
|
||||||
|
|
||||||
|
def _contained_directory(path: Path, root: Path) -> Path | None:
|
||||||
|
try:
|
||||||
|
resolved = path.resolve(strict=True)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return resolved if resolved.is_dir() and resolved.is_relative_to(root) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _contained_file(path: Path, root: Path) -> Path | None:
|
||||||
|
try:
|
||||||
|
resolved = path.resolve(strict=True)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return resolved if resolved.is_file() and resolved.is_relative_to(root) else None
|
||||||
@@ -480,6 +480,8 @@ class AgentLoop:
|
|||||||
config,
|
config,
|
||||||
provider_snapshot_loader,
|
provider_snapshot_loader,
|
||||||
)
|
)
|
||||||
|
from nanobot.agent.agent_plugins import agent_plugin_mcp_servers
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
bus=bus,
|
bus=bus,
|
||||||
provider=provider,
|
provider=provider,
|
||||||
@@ -494,7 +496,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,
|
||||||
|
|||||||
+36
-9
@@ -5,10 +5,13 @@ import os
|
|||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, cast
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from nanobot.agent.agent_plugins import AgentPluginSkill
|
||||||
|
|
||||||
# Default builtin skills directory (relative to this file)
|
# Default builtin skills directory (relative to this file)
|
||||||
BUILTIN_SKILLS_DIR = Path(__file__).parent.parent / "skills"
|
BUILTIN_SKILLS_DIR = Path(__file__).parent.parent / "skills"
|
||||||
|
|
||||||
@@ -33,6 +36,7 @@ class SkillsLoader:
|
|||||||
self.workspace_skills = workspace / "skills"
|
self.workspace_skills = workspace / "skills"
|
||||||
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()
|
||||||
|
self.plugin_skills: list[AgentPluginSkill] = []
|
||||||
|
|
||||||
def _skill_entries_from_dir(self, base: Path, source: str, *, skip_names: set[str] | None = None) -> list[dict[str, str]]:
|
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():
|
||||||
@@ -60,11 +64,26 @@ 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.agent_plugins import discover_agent_plugin_skills
|
||||||
|
|
||||||
|
self.plugin_skills = discover_agent_plugin_skills(self.workspace)
|
||||||
skills = self._skill_entries_from_dir(self.workspace_skills, "workspace")
|
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 plugin_skill in self.plugin_skills:
|
||||||
|
if plugin_skill.name in seen_names:
|
||||||
|
continue
|
||||||
|
skills.append(
|
||||||
|
{
|
||||||
|
"name": plugin_skill.name,
|
||||||
|
"path": str(plugin_skill.path),
|
||||||
|
"source": "plugin",
|
||||||
|
"plugin": plugin_skill.plugin,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
seen_names.add(plugin_skill.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:
|
||||||
@@ -84,13 +103,20 @@ class SkillsLoader:
|
|||||||
Returns:
|
Returns:
|
||||||
Skill content or None if not found.
|
Skill content or None if not found.
|
||||||
"""
|
"""
|
||||||
roots = [self.workspace_skills]
|
workspace_path = self.workspace_skills / name / "SKILL.md"
|
||||||
|
if workspace_path.exists():
|
||||||
|
return workspace_path.read_text(encoding="utf-8")
|
||||||
|
if not self.plugin_skills:
|
||||||
|
from nanobot.agent.agent_plugins import discover_agent_plugin_skills
|
||||||
|
|
||||||
|
self.plugin_skills = discover_agent_plugin_skills(self.workspace)
|
||||||
|
for plugin_skill in self.plugin_skills:
|
||||||
|
if plugin_skill.name == name and plugin_skill.path.is_file():
|
||||||
|
return plugin_skill.path.read_text(encoding="utf-8")
|
||||||
if self.builtin_skills:
|
if self.builtin_skills:
|
||||||
roots.append(self.builtin_skills)
|
builtin_path = self.builtin_skills / name / "SKILL.md"
|
||||||
for root in roots:
|
if builtin_path.exists():
|
||||||
path = root / name / "SKILL.md"
|
return builtin_path.read_text(encoding="utf-8")
|
||||||
if path.exists():
|
|
||||||
return path.read_text(encoding="utf-8")
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def load_skills_for_context(self, skill_names: list[str]) -> str:
|
def load_skills_for_context(self, skill_names: list[str]) -> str:
|
||||||
@@ -145,6 +171,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:
|
||||||
|
|||||||
@@ -1296,10 +1296,14 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
|
|||||||
"requires_restart": True,
|
"requires_restart": True,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
|
from nanobot.agent.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 {
|
||||||
|
|||||||
+75
-12
@@ -18,6 +18,7 @@ from typing import Any, cast
|
|||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
import yaml
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.apps.protocol import app_manifest, compact_dict
|
from nanobot.apps.protocol import app_manifest, compact_dict
|
||||||
@@ -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 = (
|
||||||
@@ -41,6 +43,8 @@ _MAX_ARTIFACT_REPORT = 12
|
|||||||
_SAFE_NAME_RE = re.compile(r"[^a-z0-9_-]+")
|
_SAFE_NAME_RE = re.compile(r"[^a-z0-9_-]+")
|
||||||
_SAFE_NPM_DIR_RE = re.compile(r"^[a-z0-9._-]+$", re.IGNORECASE)
|
_SAFE_NPM_DIR_RE = re.compile(r"^[a-z0-9._-]+$", re.IGNORECASE)
|
||||||
_MENTION_RE = re.compile(r"(^|[\s([{])@([a-z0-9_-]+)\b", re.IGNORECASE)
|
_MENTION_RE = re.compile(r"(^|[\s([{])@([a-z0-9_-]+)\b", re.IGNORECASE)
|
||||||
|
_SKILL_FRONTMATTER_RE = re.compile(r"^---\s*\r?\n(.*?)\r?\n---\s*\r?\n?", re.DOTALL)
|
||||||
|
_SKILL_NAME_LINE_RE = re.compile(r"^name\s*:.*$", re.MULTILINE)
|
||||||
_SHELL_META_CHARS = ("|", "&&", "||", ";", "$(", "`", ">", "<")
|
_SHELL_META_CHARS = ("|", "&&", "||", ";", "$(", "`", ">", "<")
|
||||||
_ENDORSEMENT_WORD_RE = re.compile(r"\bofficial\s+", re.IGNORECASE)
|
_ENDORSEMENT_WORD_RE = re.compile(r"\bofficial\s+", re.IGNORECASE)
|
||||||
_ARTIFACT_EXTENSIONS = frozenset({
|
_ARTIFACT_EXTENSIONS = frozenset({
|
||||||
@@ -211,10 +215,21 @@ def _as_object_dict(value: object) -> dict[str, Any] | None:
|
|||||||
|
|
||||||
|
|
||||||
def _safe_skill_name(name: str) -> str:
|
def _safe_skill_name(name: str) -> str:
|
||||||
|
clean = _SAFE_NAME_RE.sub("-", name.lower()).replace("_", "-").strip("-")
|
||||||
|
return f"cli-app-{clean or 'app'}"
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_skill_name(name: str) -> str:
|
||||||
|
"""Return the workspace skill name emitted before Agent Plugins support."""
|
||||||
clean = _SAFE_NAME_RE.sub("-", name.lower()).strip("-")
|
clean = _SAFE_NAME_RE.sub("-", name.lower()).strip("-")
|
||||||
return f"cli-app-{clean or 'app'}"
|
return f"cli-app-{clean or 'app'}"
|
||||||
|
|
||||||
|
|
||||||
|
def _plugin_skill_relative_path(name: str) -> str:
|
||||||
|
skill_name = _safe_skill_name(name)
|
||||||
|
return f"plugins/{skill_name}/skills/{skill_name}/SKILL.md"
|
||||||
|
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
@@ -613,7 +628,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": self.skill_relative_path(installed_name),
|
||||||
"tool": "run_cli_app",
|
"tool": "run_cli_app",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -640,7 +655,20 @@ class CliAppManager:
|
|||||||
return not _has_shell_meta(install_cmd)
|
return not _has_shell_meta(install_cmd)
|
||||||
|
|
||||||
def _skill_path(self, name: str) -> Path:
|
def _skill_path(self, name: str) -> Path:
|
||||||
return self.workspace / "skills" / _safe_skill_name(name) / "SKILL.md"
|
skill_name = _safe_skill_name(name)
|
||||||
|
return self.workspace / "plugins" / skill_name / "skills" / skill_name / "SKILL.md"
|
||||||
|
|
||||||
|
def _legacy_skill_path(self, name: str) -> Path:
|
||||||
|
return self.workspace / "skills" / _legacy_skill_name(name) / "SKILL.md"
|
||||||
|
|
||||||
|
def _installed_skill_path(self, name: str) -> Path:
|
||||||
|
path = self._skill_path(name)
|
||||||
|
legacy_path = self._legacy_skill_path(name)
|
||||||
|
return legacy_path if not path.is_file() and legacy_path.is_file() else path
|
||||||
|
|
||||||
|
def skill_relative_path(self, name: str) -> str:
|
||||||
|
"""Return the existing skill path, falling back to the canonical plugin path."""
|
||||||
|
return self._installed_skill_path(name).relative_to(self.workspace).as_posix()
|
||||||
|
|
||||||
def _app_payload(
|
def _app_payload(
|
||||||
self,
|
self,
|
||||||
@@ -677,7 +705,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._installed_skill_path(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 +741,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/{_safe_skill_name(name)}"
|
||||||
capabilities = [
|
capabilities = [
|
||||||
compact_dict({
|
compact_dict({
|
||||||
"type": "cli",
|
"type": "cli",
|
||||||
@@ -726,13 +755,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 +1061,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: {_safe_skill_name(name)}
|
||||||
description: >-
|
description: {json.dumps(description, ensure_ascii=False)}
|
||||||
{description}
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# {display}
|
# {display}
|
||||||
@@ -1072,18 +1100,53 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in
|
|||||||
return "".join(lines[: index + 1]) + "\n" + note + "\n" + "".join(lines[index + 1 :])
|
return "".join(lines[: index + 1]) + "\n" + note + "\n" + "".join(lines[index + 1 :])
|
||||||
return note + "\n" + content
|
return note + "\n" + content
|
||||||
|
|
||||||
|
def _normalise_skill(self, content: str, app: dict[str, Any]) -> str:
|
||||||
|
"""Give a catalog skill the identity required by its plugin directory."""
|
||||||
|
match = _SKILL_FRONTMATTER_RE.match(content)
|
||||||
|
if match is None:
|
||||||
|
return self._fallback_skill(app)
|
||||||
|
try:
|
||||||
|
metadata = _as_object_dict(cast(object, yaml.safe_load(match.group(1))))
|
||||||
|
except yaml.YAMLError:
|
||||||
|
return self._fallback_skill(app)
|
||||||
|
description = metadata.get("description") if metadata is not None else None
|
||||||
|
if not isinstance(description, str) or not 1 <= len(description.strip()) <= 1024:
|
||||||
|
return self._fallback_skill(app)
|
||||||
|
|
||||||
|
name = _safe_skill_name(str(app["name"]))
|
||||||
|
frontmatter, replaced = _SKILL_NAME_LINE_RE.subn(f"name: {name}", match.group(1), count=1)
|
||||||
|
if not replaced:
|
||||||
|
frontmatter = f"name: {name}\n{frontmatter}"
|
||||||
|
body = content[match.end():].lstrip()
|
||||||
|
return f"---\n{frontmatter.strip()}\n---\n\n{body}"
|
||||||
|
|
||||||
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"]))
|
path = self._skill_path(str(app["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._normalise_skill(content, 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": _safe_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._legacy_skill_path(str(app["name"])).parent
|
||||||
|
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._skill_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._legacy_skill_path(name).parent
|
||||||
|
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]:
|
||||||
installed = self._load_installed()
|
installed = self._load_installed()
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ 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 import CliAppManager
|
||||||
|
|
||||||
|
manager = CliAppManager(workspace=workspace)
|
||||||
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 +35,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={manager.skill_relative_path(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()
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from datetime import datetime, timezone
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Literal, Mapping, cast
|
from typing import Any, Literal, Mapping, cast
|
||||||
|
|
||||||
|
from nanobot.agent.agent_plugins import agent_plugins_payload, set_agent_plugin_enabled
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
from nanobot.apps.protocol import app_manifest, compact_dict
|
from nanobot.apps.protocol import app_manifest, compact_dict
|
||||||
from nanobot.config.loader import load_config, resolve_config_env_vars, save_config
|
from nanobot.config.loader import load_config, resolve_config_env_vars, save_config
|
||||||
@@ -837,6 +838,44 @@ def _custom_payload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _agent_plugin_payload(plugin: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
|
enabled = bool(plugin.get("enabled"))
|
||||||
|
permissions = plugin.get("permissions")
|
||||||
|
mcp_servers = plugin.get("mcp_servers")
|
||||||
|
permission_names = (
|
||||||
|
[str(item) for item in cast(list[object], permissions)]
|
||||||
|
if isinstance(permissions, list)
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
server_names = (
|
||||||
|
[str(item) for item in cast(list[object], mcp_servers)]
|
||||||
|
if isinstance(mcp_servers, list)
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"name": f"plugin-{plugin['name']}",
|
||||||
|
"display_name": str(plugin.get("display_name") or plugin["name"]),
|
||||||
|
"category": str(plugin.get("category") or "plugin"),
|
||||||
|
"description": str(plugin.get("description") or "Agent Plugin"),
|
||||||
|
"docs_url": str(plugin.get("repository") or ""),
|
||||||
|
"transport": "stdio",
|
||||||
|
"requires": ", ".join(permission_names),
|
||||||
|
"note": "",
|
||||||
|
"install_supported": True,
|
||||||
|
"installed": True,
|
||||||
|
"configured": enabled,
|
||||||
|
"available": enabled,
|
||||||
|
"status": "configured" if enabled else "not_installed",
|
||||||
|
"logo_url": None,
|
||||||
|
"brand_color": plugin.get("accent_color"),
|
||||||
|
"required_fields": [],
|
||||||
|
"connection_summary": ", ".join(server_names),
|
||||||
|
"enabled_tools": ["*"],
|
||||||
|
"tool_names": [],
|
||||||
|
"source": "agent-plugin",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def mcp_presets_payload(
|
def mcp_presets_payload(
|
||||||
*,
|
*,
|
||||||
last_action: dict[str, Any] | None = None,
|
last_action: dict[str, Any] | None = None,
|
||||||
@@ -854,9 +893,17 @@ 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
|
||||||
]
|
]
|
||||||
|
plugin_state = agent_plugins_payload(config.workspace_path)
|
||||||
|
existing_names = {str(row["name"]) for row in (*preset_rows, *custom_rows)}
|
||||||
|
plugin_rows = [
|
||||||
|
row
|
||||||
|
for plugin in plugin_state["plugins"]
|
||||||
|
if (row := _agent_plugin_payload(plugin))["name"] not in existing_names
|
||||||
|
]
|
||||||
payload: dict[str, Any] = {
|
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["configured"]) 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
|
||||||
@@ -1343,6 +1390,27 @@ async def mcp_presets_settings_action(
|
|||||||
"""Run a WebUI MCP preset action and hot-reload the agent when config changes."""
|
"""Run a WebUI MCP preset action and hot-reload the agent when config changes."""
|
||||||
if action is None:
|
if action is None:
|
||||||
return mcp_presets_payload()
|
return mcp_presets_payload()
|
||||||
|
name = (_query_first(query, "name") or "").strip()
|
||||||
|
if name.startswith("plugin-"):
|
||||||
|
config = load_config()
|
||||||
|
plugin_name = name.removeprefix("plugin-")
|
||||||
|
installed = {
|
||||||
|
str(plugin["name"])
|
||||||
|
for plugin in agent_plugins_payload(config.workspace_path)["plugins"]
|
||||||
|
}
|
||||||
|
if name not in config.tools.mcp_servers and plugin_name in installed:
|
||||||
|
if action not in {"enable", "remove"}:
|
||||||
|
raise McpPresetError("Agent Plugins support enable and disable actions only")
|
||||||
|
state = await asyncio.to_thread(
|
||||||
|
set_agent_plugin_enabled,
|
||||||
|
config.workspace_path,
|
||||||
|
plugin_name,
|
||||||
|
action == "enable",
|
||||||
|
)
|
||||||
|
payload = mcp_presets_payload(last_action=state.get("last_action"))
|
||||||
|
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)
|
return await mcp_presets_test_action(query)
|
||||||
if action in _CUSTOM_ACTIONS:
|
if action in _CUSTOM_ACTIONS:
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from urllib.parse import unquote
|
|||||||
from websockets.http11 import Request as WsRequest
|
from websockets.http11 import Request as WsRequest
|
||||||
from websockets.http11 import Response
|
from websockets.http11 import Response
|
||||||
|
|
||||||
|
from nanobot.agent.agent_plugins import agent_plugins_payload
|
||||||
from nanobot.agent.tools.image_generation import request_image_generation_reload
|
from nanobot.agent.tools.image_generation import request_image_generation_reload
|
||||||
from nanobot.agent.tools.mcp import request_mcp_reload
|
from nanobot.agent.tools.mcp import request_mcp_reload
|
||||||
from nanobot.api.runtime import ApiRuntime, ApiStartOptions, api_runtime_paths
|
from nanobot.api.runtime import ApiRuntime, ApiStartOptions, api_runtime_paths
|
||||||
@@ -222,12 +223,12 @@ class WebUISettingsRouter:
|
|||||||
if path == "/api/settings/pairing/deny":
|
if path == "/api/settings/pairing/deny":
|
||||||
return self._handle_settings_pairing_action(request, "deny")
|
return self._handle_settings_pairing_action(request, "deny")
|
||||||
if path == "/api/settings/mcp-presets":
|
if path == "/api/settings/mcp-presets":
|
||||||
return await self._handle_settings_mcp_presets(request)
|
return await self._handle_settings_mcp_presets(connection, request)
|
||||||
if path == "/api/settings/version-check":
|
if path == "/api/settings/version-check":
|
||||||
return await self._handle_settings_version_check(request)
|
return await self._handle_settings_version_check(request)
|
||||||
mcp_action = _MCP_PRESET_ACTIONS_BY_PATH.get(path)
|
mcp_action = _MCP_PRESET_ACTIONS_BY_PATH.get(path)
|
||||||
if mcp_action is not None:
|
if mcp_action is not None:
|
||||||
return await self._handle_settings_mcp_presets(request, mcp_action)
|
return await self._handle_settings_mcp_presets(connection, request, mcp_action)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _query(self, request: WsRequest) -> QueryParams:
|
def _query(self, request: WsRequest) -> QueryParams:
|
||||||
@@ -1144,15 +1145,33 @@ class WebUISettingsRouter:
|
|||||||
|
|
||||||
async def _handle_settings_mcp_presets(
|
async def _handle_settings_mcp_presets(
|
||||||
self,
|
self,
|
||||||
|
connection: Any,
|
||||||
request: WsRequest,
|
request: WsRequest,
|
||||||
action: str | None = None,
|
action: str | None = None,
|
||||||
) -> Response:
|
) -> Response:
|
||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
|
query = self._parse_mcp_settings_query(request)
|
||||||
|
name = (_query_first(query, "name") or "").strip()
|
||||||
|
if action == "enable" and name.startswith("plugin-"):
|
||||||
|
config = load_config()
|
||||||
|
plugin_names = {
|
||||||
|
f"plugin-{plugin['name']}"
|
||||||
|
for plugin in agent_plugins_payload(config.workspace_path)["plugins"]
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
name not in config.tools.mcp_servers
|
||||||
|
and name in plugin_names
|
||||||
|
and not self._allow_feature_package_install(connection, request)
|
||||||
|
):
|
||||||
|
return self._error_response(
|
||||||
|
403,
|
||||||
|
"Agent Plugin setup is restricted to the local WebUI",
|
||||||
|
)
|
||||||
payload = await mcp_presets_settings_action(
|
payload = await mcp_presets_settings_action(
|
||||||
action,
|
action,
|
||||||
self._parse_mcp_settings_query(request),
|
query,
|
||||||
reload_mcp=lambda: request_mcp_reload(self.bus),
|
reload_mcp=lambda: request_mcp_reload(self.bus),
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.agent import agent_plugins
|
||||||
|
from nanobot.agent.agent_plugins import (
|
||||||
|
AGENT_PLUGIN_MCP_SCHEMA,
|
||||||
|
AGENT_PLUGIN_SCHEMA,
|
||||||
|
agent_plugin_mcp_servers,
|
||||||
|
agent_plugins_payload,
|
||||||
|
discover_agent_plugin_skills,
|
||||||
|
set_agent_plugin_enabled,
|
||||||
|
)
|
||||||
|
from nanobot.agent.skills import SkillsLoader
|
||||||
|
|
||||||
|
|
||||||
|
def _write_skill(root: Path, name: str, *, description: str = "Plugin skill.") -> Path:
|
||||||
|
skill = root / "skills" / name
|
||||||
|
skill.mkdir(parents=True)
|
||||||
|
(skill / "SKILL.md").write_text(
|
||||||
|
f"---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return skill
|
||||||
|
|
||||||
|
|
||||||
|
def _write_plugin(
|
||||||
|
workspace: Path,
|
||||||
|
directory: str,
|
||||||
|
*,
|
||||||
|
name: str | None = None,
|
||||||
|
manifest: dict[str, object] | None = None,
|
||||||
|
) -> Path:
|
||||||
|
root = workspace / "plugins" / directory
|
||||||
|
root.mkdir(parents=True)
|
||||||
|
payload = manifest or {
|
||||||
|
"$schema": AGENT_PLUGIN_SCHEMA,
|
||||||
|
"name": name or directory,
|
||||||
|
}
|
||||||
|
(root / "plugin.json").write_text(json.dumps(payload), encoding="utf-8")
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def test_skills_loader_discovers_agent_plugin_skill(tmp_path: Path) -> None:
|
||||||
|
plugin = _write_plugin(tmp_path, "acme-tools")
|
||||||
|
_write_skill(plugin, "release-notes", description="Draft release notes from changes.")
|
||||||
|
|
||||||
|
loader = SkillsLoader(tmp_path, builtin_skills_dir=tmp_path / "builtin")
|
||||||
|
|
||||||
|
assert loader.list_skills() == [
|
||||||
|
{
|
||||||
|
"name": "release-notes",
|
||||||
|
"path": str(plugin / "skills" / "release-notes" / "SKILL.md"),
|
||||||
|
"source": "plugin",
|
||||||
|
"plugin": "acme-tools",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
assert loader.get_explicitly_invoked_skills("Use $release-notes") == ["release-notes"]
|
||||||
|
assert "Draft release notes" in (loader.load_skill("release-notes") or "")
|
||||||
|
assert "### Agent Plugin skills" in loader.build_skills_summary()
|
||||||
|
assert "`acme-tools/skills/release-notes/SKILL.md`" in loader.build_skills_summary()
|
||||||
|
|
||||||
|
|
||||||
|
def test_skills_loader_sees_plugin_installed_after_startup(tmp_path: Path) -> None:
|
||||||
|
loader = SkillsLoader(tmp_path, builtin_skills_dir=tmp_path / "builtin")
|
||||||
|
assert loader.list_skills() == []
|
||||||
|
|
||||||
|
plugin = _write_plugin(tmp_path, "acme-tools")
|
||||||
|
_write_skill(plugin, "release-notes")
|
||||||
|
|
||||||
|
assert [entry["name"] for entry in loader.list_skills()] == ["release-notes"]
|
||||||
|
|
||||||
|
shutil.rmtree(plugin)
|
||||||
|
|
||||||
|
assert loader.list_skills() == []
|
||||||
|
assert loader.build_skills_summary() == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_plugin_skills_are_direct_children_only(tmp_path: Path) -> None:
|
||||||
|
plugin = _write_plugin(tmp_path, "acme-tools")
|
||||||
|
_write_skill(plugin, "direct")
|
||||||
|
nested = plugin / "skills" / "group" / "nested"
|
||||||
|
nested.mkdir(parents=True)
|
||||||
|
(nested / "SKILL.md").write_text(
|
||||||
|
"---\nname: nested\ndescription: Nested skill.\n---\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [skill.name for skill in discover_agent_plugin_skills(tmp_path)] == ["direct"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"manifest",
|
||||||
|
[
|
||||||
|
{"$schema": "https://agent-plugins.org/schemas/2.0.0/plugin.schema.json", "name": "demo"},
|
||||||
|
{"$schema": AGENT_PLUGIN_SCHEMA, "name": "Bad-Name"},
|
||||||
|
{"$schema": AGENT_PLUGIN_SCHEMA, "name": "demo", "author": None},
|
||||||
|
{"$schema": AGENT_PLUGIN_SCHEMA, "name": "demo", "keywords": None},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_invalid_agent_plugin_manifest_is_skipped(
|
||||||
|
tmp_path: Path,
|
||||||
|
manifest: dict[str, object],
|
||||||
|
) -> None:
|
||||||
|
plugin = _write_plugin(tmp_path, "demo", manifest=manifest)
|
||||||
|
_write_skill(plugin, "example")
|
||||||
|
|
||||||
|
assert discover_agent_plugin_skills(tmp_path) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_manifest_fields_and_non_object_extensions_are_ignored(tmp_path: Path) -> None:
|
||||||
|
plugin = _write_plugin(
|
||||||
|
tmp_path,
|
||||||
|
"demo",
|
||||||
|
manifest={
|
||||||
|
"$schema": AGENT_PLUGIN_SCHEMA,
|
||||||
|
"name": "demo",
|
||||||
|
"futureField": True,
|
||||||
|
"extensions": "invalid but non-fatal",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
_write_skill(plugin, "example")
|
||||||
|
|
||||||
|
assert [skill.name for skill in discover_agent_plugin_skills(tmp_path)] == ["example"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("skill_name", "frontmatter"),
|
||||||
|
[
|
||||||
|
("wrong-directory", "name: another\ndescription: Mismatch."),
|
||||||
|
("missing-description", "name: missing-description"),
|
||||||
|
("Bad-Name", "name: Bad-Name\ndescription: Invalid name."),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_invalid_agent_skill_is_skipped(
|
||||||
|
tmp_path: Path,
|
||||||
|
skill_name: str,
|
||||||
|
frontmatter: str,
|
||||||
|
) -> None:
|
||||||
|
plugin = _write_plugin(tmp_path, "demo")
|
||||||
|
skill = plugin / "skills" / skill_name
|
||||||
|
skill.mkdir(parents=True)
|
||||||
|
(skill / "SKILL.md").write_text(f"---\n{frontmatter}\n---\n", encoding="utf-8")
|
||||||
|
|
||||||
|
assert discover_agent_plugin_skills(tmp_path) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_workspace_skill_overrides_plugin_skill(tmp_path: Path) -> None:
|
||||||
|
plugin = _write_plugin(tmp_path, "demo")
|
||||||
|
_write_skill(plugin, "shared", description="Plugin version.")
|
||||||
|
workspace_skill = tmp_path / "skills" / "shared"
|
||||||
|
workspace_skill.mkdir(parents=True)
|
||||||
|
(workspace_skill / "SKILL.md").write_text(
|
||||||
|
"---\nname: shared\ndescription: Workspace version.\n---\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
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 "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_skill_symlink_cannot_escape_plugin_root(tmp_path: Path) -> None:
|
||||||
|
plugin = _write_plugin(tmp_path, "demo")
|
||||||
|
outside = tmp_path / "outside"
|
||||||
|
_write_skill(outside, "escaped")
|
||||||
|
skills_root = plugin / "skills"
|
||||||
|
skills_root.mkdir()
|
||||||
|
try:
|
||||||
|
(skills_root / "escaped").symlink_to(
|
||||||
|
outside / "skills" / "escaped",
|
||||||
|
target_is_directory=True,
|
||||||
|
)
|
||||||
|
except OSError as exc:
|
||||||
|
pytest.skip(f"directory symlink unavailable: {exc}")
|
||||||
|
|
||||||
|
assert discover_agent_plugin_skills(tmp_path) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_mcp_requires_explicit_enable(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
agent_plugins,
|
||||||
|
"get_config_path",
|
||||||
|
lambda: tmp_path / "config" / "config.json",
|
||||||
|
)
|
||||||
|
plugin = _write_plugin(tmp_path, "desktop")
|
||||||
|
executable = plugin / "bin" / "server"
|
||||||
|
executable.parent.mkdir()
|
||||||
|
executable.write_text("#!/bin/sh\n", encoding="utf-8")
|
||||||
|
(plugin / "mcp.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"$schema": AGENT_PLUGIN_MCP_SCHEMA,
|
||||||
|
"mcpServers": {
|
||||||
|
"desktop": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "./bin/server",
|
||||||
|
"args": ["--data", "${PLUGIN_DATA}/state"],
|
||||||
|
"cwd": "${PLUGIN_ROOT}",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert agent_plugin_mcp_servers(tmp_path) == {}
|
||||||
|
set_agent_plugin_enabled(tmp_path, "desktop", True)
|
||||||
|
|
||||||
|
servers = agent_plugin_mcp_servers(tmp_path)
|
||||||
|
server = servers["desktop"]
|
||||||
|
assert server.command == str(executable)
|
||||||
|
assert server.cwd == str(plugin)
|
||||||
|
assert server.env["PLUGIN_ROOT"] == str(plugin)
|
||||||
|
assert server.args[0] == "--data"
|
||||||
|
assert server.args[1].endswith("/state")
|
||||||
|
|
||||||
|
set_agent_plugin_enabled(tmp_path, "desktop", False)
|
||||||
|
assert agent_plugin_mcp_servers(tmp_path) == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_setup_command_runs_once_per_version(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
agent_plugins,
|
||||||
|
"get_config_path",
|
||||||
|
lambda: tmp_path / "config" / "config.json",
|
||||||
|
)
|
||||||
|
monkeypatch.setenv("NANOBOT_TEST_SECRET", "do-not-inherit")
|
||||||
|
plugin = _write_plugin(
|
||||||
|
tmp_path,
|
||||||
|
"desktop",
|
||||||
|
manifest={
|
||||||
|
"$schema": AGENT_PLUGIN_SCHEMA,
|
||||||
|
"name": "desktop",
|
||||||
|
"version": "1.2.3",
|
||||||
|
"extensions": {"dev.nanobot": {"installCommand": ["./bin/install"]}},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
executable = plugin / "bin" / "install"
|
||||||
|
executable.parent.mkdir()
|
||||||
|
executable.write_text("setup", encoding="utf-8")
|
||||||
|
calls: list[tuple[tuple[str, ...], dict[str, str]]] = []
|
||||||
|
|
||||||
|
def run(command: tuple[str, ...], **kwargs: Any) -> subprocess.CompletedProcess[str]:
|
||||||
|
calls.append((command, cast(dict[str, str], kwargs["env"])))
|
||||||
|
return subprocess.CompletedProcess(command, 0, "ok", "")
|
||||||
|
|
||||||
|
monkeypatch.setattr(agent_plugins.subprocess, "run", run)
|
||||||
|
|
||||||
|
set_agent_plugin_enabled(tmp_path, "desktop", True)
|
||||||
|
set_agent_plugin_enabled(tmp_path, "desktop", False)
|
||||||
|
set_agent_plugin_enabled(tmp_path, "desktop", True)
|
||||||
|
|
||||||
|
assert len(calls) == 1
|
||||||
|
assert calls[0][0] == (str(executable),)
|
||||||
|
assert calls[0][1]["PLUGIN_ROOT"] == str(plugin)
|
||||||
|
assert "NANOBOT_TEST_SECRET" not in calls[0][1]
|
||||||
|
assert agent_plugins_payload(tmp_path)["plugins"][0]["setup_required"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_plugin_mcp_entries_do_not_block_valid_servers(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
agent_plugins,
|
||||||
|
"get_config_path",
|
||||||
|
lambda: tmp_path / "config" / "config.json",
|
||||||
|
)
|
||||||
|
plugin = _write_plugin(tmp_path, "network")
|
||||||
|
executable = plugin / "bin" / "server"
|
||||||
|
executable.parent.mkdir()
|
||||||
|
executable.write_text("#!/bin/sh\n", encoding="utf-8")
|
||||||
|
(plugin / "mcp.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"$schema": AGENT_PLUGIN_MCP_SCHEMA,
|
||||||
|
"mcpServers": {
|
||||||
|
"public-http": {"type": "streamable-http", "url": "http://example.com/mcp"},
|
||||||
|
"local": {"type": "stdio", "command": "./bin/server"},
|
||||||
|
"escape": {"type": "stdio", "command": "../outside"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
set_agent_plugin_enabled(tmp_path, "network", True)
|
||||||
|
|
||||||
|
assert list(agent_plugin_mcp_servers(tmp_path)) == ["network"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_state_symlink_cannot_escape_config_root(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
config = tmp_path / "config"
|
||||||
|
outside = tmp_path / "outside"
|
||||||
|
config.mkdir()
|
||||||
|
outside.mkdir()
|
||||||
|
try:
|
||||||
|
(config / "plugin-data").symlink_to(outside, target_is_directory=True)
|
||||||
|
except OSError as exc:
|
||||||
|
pytest.skip(f"directory symlink unavailable: {exc}")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
agent_plugins,
|
||||||
|
"get_config_path",
|
||||||
|
lambda: config / "config.json",
|
||||||
|
)
|
||||||
|
_write_plugin(tmp_path, "desktop")
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="escapes the nanobot config directory"):
|
||||||
|
set_agent_plugin_enabled(tmp_path, "desktop", True)
|
||||||
@@ -9,6 +9,7 @@ from types import SimpleNamespace
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.agent.agent_plugins import discover_agent_plugin_skills
|
||||||
from nanobot.apps.cli.service import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
from nanobot.apps.cli.service import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
||||||
|
|
||||||
|
|
||||||
@@ -391,6 +392,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 +404,21 @@ 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()
|
||||||
|
assert json.loads((plugin / "plugin.json").read_text(encoding="utf-8")) == {
|
||||||
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
||||||
|
"name": "cli-app-gimp",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Public duplicate entry",
|
||||||
|
}
|
||||||
|
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 [item.name for item in discover_agent_plugin_skills(manager.workspace)] == [
|
||||||
|
"cli-app-gimp"
|
||||||
|
]
|
||||||
|
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,14 @@ 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 +727,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 +741,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 +869,47 @@ 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_legacy_underscored_skill_remains_visible_and_removable(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(
|
||||||
|
"---\nname: cli-app-unimol_tools\ndescription: Legacy Uni-Mol app.\n---\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
manager._save_installed(
|
||||||
|
{"unimol_tools": {"entry_point": "cli-anything-unimol-tools", "source": "harness"}}
|
||||||
|
)
|
||||||
|
|
||||||
|
app = {
|
||||||
|
"name": "unimol_tools",
|
||||||
|
"entry_point": "cli-anything-unimol-tools",
|
||||||
|
"install_cmd": "pip install cli-anything-unimol-tools",
|
||||||
|
}
|
||||||
|
|
||||||
|
assert manager._app_payload(app, manager._load_installed())["skill_installed"] is True
|
||||||
|
assert manager.mentioned_installed_apps("use @unimol_tools")[0]["skill"] == (
|
||||||
|
"skills/cli-app-unimol_tools/SKILL.md"
|
||||||
|
)
|
||||||
|
|
||||||
|
manager.remove_skill("unimol_tools")
|
||||||
|
|
||||||
|
assert not legacy.exists()
|
||||||
|
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ 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_injects_runtime_metadata(tmp_path):
|
||||||
@@ -58,4 +58,23 @@ def test_structured_cli_app_attachment_injects_runtime_metadata(tmp_path):
|
|||||||
assert "CLI App Attachment: @zoom" in joined
|
assert "CLI App Attachment: @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_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 @unimol_tools",
|
||||||
|
{
|
||||||
|
"cli_apps": [{
|
||||||
|
"name": "unimol_tools",
|
||||||
|
"entry_point": "cli-anything-unimol-tools",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
tmp_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "skill=skills/cli-app-unimol_tools/SKILL.md" in "\n".join(lines)
|
||||||
|
|||||||
@@ -1,22 +1,66 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.agent.agent_plugins import AGENT_PLUGIN_MCP_SCHEMA, AGENT_PLUGIN_SCHEMA
|
||||||
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 (
|
||||||
McpPresetError,
|
McpPresetError,
|
||||||
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"
|
||||||
|
command = root / "bin" / "server"
|
||||||
|
command.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
command.write_text("#!/bin/sh\n", encoding="utf-8")
|
||||||
|
(root / "plugin.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"$schema": AGENT_PLUGIN_SCHEMA,
|
||||||
|
"name": "desktop",
|
||||||
|
"description": "Control the local desktop.",
|
||||||
|
"extensions": {
|
||||||
|
"dev.nanobot": {
|
||||||
|
"displayName": "Desktop Control",
|
||||||
|
"accentColor": "#ff7a1a",
|
||||||
|
"permissions": ["screen-recording"],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(root / "mcp.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"$schema": AGENT_PLUGIN_MCP_SCHEMA,
|
||||||
|
"mcpServers": {
|
||||||
|
"desktop": {"type": "stdio", "command": "./bin/server"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_mcp_presets_payload_lists_supported_cards(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_mcp_presets_payload_lists_supported_cards(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
@@ -55,6 +99,65 @@ def test_mcp_presets_payload_lists_supported_cards(tmp_path, monkeypatch: pytest
|
|||||||
assert manifest["trust"]["review_status"] == "builtin_preset"
|
assert manifest["trust"]["review_status"] == "builtin_preset"
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_plugin_reuses_mcp_catalog_and_runtime_action(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
_use_config(tmp_path, monkeypatch)
|
||||||
|
_write_agent_plugin(load_config().workspace_path)
|
||||||
|
|
||||||
|
row = next(item for item in mcp_presets_payload()["presets"] if item["source"] == "agent-plugin")
|
||||||
|
assert row["name"] == "plugin-desktop"
|
||||||
|
assert row["display_name"] == "Desktop Control"
|
||||||
|
assert row["installed"] is True
|
||||||
|
assert row["configured"] is False
|
||||||
|
|
||||||
|
async def reload() -> dict[str, object]:
|
||||||
|
return {"ok": True, "message": "MCP reloaded.", "requires_restart": False}
|
||||||
|
|
||||||
|
enabled = asyncio.run(
|
||||||
|
mcp_presets_settings_action(
|
||||||
|
"enable",
|
||||||
|
{"name": ["plugin-desktop"]},
|
||||||
|
reload_mcp=reload,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
enabled_row = next(item for item in enabled["presets"] if item["name"] == "plugin-desktop")
|
||||||
|
assert enabled_row["configured"] is True
|
||||||
|
assert enabled["requires_restart"] is False
|
||||||
|
|
||||||
|
disabled = asyncio.run(
|
||||||
|
mcp_presets_settings_action(
|
||||||
|
"remove",
|
||||||
|
{"name": ["plugin-desktop"]},
|
||||||
|
reload_mcp=reload,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
disabled_row = next(item for item in disabled["presets"] if item["name"] == "plugin-desktop")
|
||||||
|
assert disabled_row["configured"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_mcp_config_wins_over_plugin_catalog_name(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
_use_config(tmp_path, monkeypatch)
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
config = json.loads(config_path.read_text(encoding="utf-8"))
|
||||||
|
config["tools"] = {
|
||||||
|
"mcpServers": {"plugin-desktop": {"type": "stdio", "command": "echo"}}
|
||||||
|
}
|
||||||
|
config_path.write_text(json.dumps(config), encoding="utf-8")
|
||||||
|
_write_agent_plugin(load_config().workspace_path)
|
||||||
|
|
||||||
|
rows = [
|
||||||
|
item for item in mcp_presets_payload()["presets"] if item["name"] == "plugin-desktop"
|
||||||
|
]
|
||||||
|
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0]["source"] == "custom"
|
||||||
|
|
||||||
|
|
||||||
def test_enable_browserbase_writes_scrubbed_config_payload(
|
def test_enable_browserbase_writes_scrubbed_config_payload(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
|||||||
@@ -7383,7 +7383,11 @@ 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));
|
||||||
@@ -7623,6 +7627,7 @@ function McpAppsCatalogRow({
|
|||||||
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 busy = enableBusy || removeBusy || testBusy || toolsBusy;
|
const busy = enableBusy || removeBusy || testBusy || toolsBusy;
|
||||||
|
const agentPlugin = preset.source === "agent-plugin";
|
||||||
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;
|
||||||
@@ -7634,8 +7639,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 statusLabel = mcpPresetStatusLabel(preset.status, tx);
|
const detail = agentPlugin && preset.requires
|
||||||
|
? `${description} · ${preset.requires}`
|
||||||
|
: description || preset.requires;
|
||||||
|
const statusLabel = agentPlugin
|
||||||
|
? tx("settings.apps.pluginEnabled", "Plugin enabled")
|
||||||
|
: mcpPresetStatusLabel(preset.status, tx);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (preset.configured || !preset.install_supported) setSetupOpen(false);
|
if (preset.configured || !preset.install_supported) setSetupOpen(false);
|
||||||
@@ -7668,9 +7678,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", "Integration")}</AppsTypeBadge>
|
<AppsTypeBadge>
|
||||||
|
{agentPlugin
|
||||||
|
? tx("settings.apps.pluginLabel", "Plugin")
|
||||||
|
: tx("settings.apps.mcpLabel", "Integration")}
|
||||||
|
</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 className="flex shrink-0 items-center gap-1">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
{readyInstalled ? (
|
{readyInstalled ? (
|
||||||
@@ -7687,35 +7701,41 @@ function McpAppsCatalogRow({
|
|||||||
</AppsActionButton>
|
</AppsActionButton>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem disabled={busy} onClick={() => onAction("test", preset.name)}>
|
{!agentPlugin ? (
|
||||||
<PlayCircle aria-hidden />
|
<DropdownMenuItem disabled={busy} onClick={() => onAction("test", preset.name)}>
|
||||||
{tx("settings.mcp.test", "Test")}
|
<PlayCircle aria-hidden />
|
||||||
</DropdownMenuItem>
|
{tx("settings.mcp.test", "Test")}
|
||||||
{toolNames.length ? (
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
{!agentPlugin && 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={agentPlugin ? undefined : "destructive"}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() => onAction("remove", preset.name)}
|
onClick={() => onAction("remove", preset.name)}
|
||||||
>
|
>
|
||||||
<Trash2 aria-hidden />
|
{agentPlugin ? <PauseCircle aria-hidden /> : <Trash2 aria-hidden />}
|
||||||
{tx("settings.mcp.remove", "Remove")}
|
{agentPlugin
|
||||||
|
? tx("settings.apps.pluginDisable", "Disable")
|
||||||
|
: tx("settings.mcp.remove", "Remove")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<AppsActionButton
|
{!agentPlugin ? (
|
||||||
ariaLabel={tx("settings.mcp.remove", "Remove")}
|
<AppsActionButton
|
||||||
busy={removeBusy}
|
ariaLabel={tx("settings.mcp.remove", "Remove")}
|
||||||
disabled={busy && !removeBusy}
|
busy={removeBusy}
|
||||||
tone="danger"
|
disabled={busy && !removeBusy}
|
||||||
onClick={() => onAction("remove", preset.name)}
|
tone="danger"
|
||||||
>
|
onClick={() => onAction("remove", preset.name)}
|
||||||
<Trash2 className="h-4 w-4" aria-hidden />
|
>
|
||||||
</AppsActionButton>
|
<Trash2 className="h-4 w-4" aria-hidden />
|
||||||
|
</AppsActionButton>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
) : preset.installed && !preset.configured ? (
|
) : preset.installed && !preset.configured ? (
|
||||||
<AppsActionButton
|
<AppsActionButton
|
||||||
|
|||||||
@@ -584,6 +584,9 @@
|
|||||||
"description": "Add tools to nanobot, then @ them in chat.",
|
"description": "Add tools to nanobot, then @ them in chat.",
|
||||||
"cliLabel": "App",
|
"cliLabel": "App",
|
||||||
"mcpLabel": "Integration",
|
"mcpLabel": "Integration",
|
||||||
|
"pluginLabel": "Plugin",
|
||||||
|
"pluginEnabled": "Plugin enabled",
|
||||||
|
"pluginDisable": "Disable",
|
||||||
"channelLabel": "Channel",
|
"channelLabel": "Channel",
|
||||||
"featureLabel": "Feature",
|
"featureLabel": "Feature",
|
||||||
"filterAll": "Ready",
|
"filterAll": "Ready",
|
||||||
|
|||||||
@@ -571,6 +571,9 @@
|
|||||||
"description": "Añade herramientas a nanobot y luego úsalas con @ en el chat.",
|
"description": "Añade herramientas a nanobot y luego úsalas con @ en el chat.",
|
||||||
"cliLabel": "Aplicación",
|
"cliLabel": "Aplicación",
|
||||||
"mcpLabel": "Integración",
|
"mcpLabel": "Integración",
|
||||||
|
"pluginLabel": "Plugin",
|
||||||
|
"pluginEnabled": "Plugin activado",
|
||||||
|
"pluginDisable": "Desactivar",
|
||||||
"channelLabel": "Canal",
|
"channelLabel": "Canal",
|
||||||
"featureLabel": "Función",
|
"featureLabel": "Función",
|
||||||
"filterAll": "Listo",
|
"filterAll": "Listo",
|
||||||
|
|||||||
@@ -570,6 +570,9 @@
|
|||||||
"description": "Ajoutez des outils à nanobot, puis utilisez-les avec @ dans le chat.",
|
"description": "Ajoutez des outils à nanobot, puis utilisez-les avec @ dans le chat.",
|
||||||
"cliLabel": "Application",
|
"cliLabel": "Application",
|
||||||
"mcpLabel": "Intégration",
|
"mcpLabel": "Intégration",
|
||||||
|
"pluginLabel": "Plugin",
|
||||||
|
"pluginEnabled": "Plugin activé",
|
||||||
|
"pluginDisable": "Désactiver",
|
||||||
"channelLabel": "Canal",
|
"channelLabel": "Canal",
|
||||||
"featureLabel": "Fonction",
|
"featureLabel": "Fonction",
|
||||||
"filterAll": "Prêts",
|
"filterAll": "Prêts",
|
||||||
|
|||||||
@@ -570,6 +570,9 @@
|
|||||||
"description": "Tambahkan alat ke nanobot, lalu gunakan dengan @ di chat.",
|
"description": "Tambahkan alat ke nanobot, lalu gunakan dengan @ di chat.",
|
||||||
"cliLabel": "Aplikasi",
|
"cliLabel": "Aplikasi",
|
||||||
"mcpLabel": "Integrasi",
|
"mcpLabel": "Integrasi",
|
||||||
|
"pluginLabel": "Plugin",
|
||||||
|
"pluginEnabled": "Plugin aktif",
|
||||||
|
"pluginDisable": "Nonaktifkan",
|
||||||
"channelLabel": "Kanal",
|
"channelLabel": "Kanal",
|
||||||
"featureLabel": "Fitur",
|
"featureLabel": "Fitur",
|
||||||
"filterAll": "Siap",
|
"filterAll": "Siap",
|
||||||
|
|||||||
@@ -570,6 +570,9 @@
|
|||||||
"description": "nanobot にツールを追加し、チャットで @ を付けて使用できます。",
|
"description": "nanobot にツールを追加し、チャットで @ を付けて使用できます。",
|
||||||
"cliLabel": "アプリ",
|
"cliLabel": "アプリ",
|
||||||
"mcpLabel": "連携",
|
"mcpLabel": "連携",
|
||||||
|
"pluginLabel": "プラグイン",
|
||||||
|
"pluginEnabled": "プラグインは有効です",
|
||||||
|
"pluginDisable": "無効にする",
|
||||||
"channelLabel": "チャンネル",
|
"channelLabel": "チャンネル",
|
||||||
"featureLabel": "機能",
|
"featureLabel": "機能",
|
||||||
"filterAll": "使用可能",
|
"filterAll": "使用可能",
|
||||||
|
|||||||
@@ -570,6 +570,9 @@
|
|||||||
"description": "nanobot에 도구를 추가한 뒤 채팅에서 @로 사용하세요.",
|
"description": "nanobot에 도구를 추가한 뒤 채팅에서 @로 사용하세요.",
|
||||||
"cliLabel": "앱",
|
"cliLabel": "앱",
|
||||||
"mcpLabel": "연동",
|
"mcpLabel": "연동",
|
||||||
|
"pluginLabel": "플러그인",
|
||||||
|
"pluginEnabled": "플러그인 활성화됨",
|
||||||
|
"pluginDisable": "비활성화",
|
||||||
"channelLabel": "채널",
|
"channelLabel": "채널",
|
||||||
"featureLabel": "기능",
|
"featureLabel": "기능",
|
||||||
"filterAll": "사용 가능",
|
"filterAll": "사용 가능",
|
||||||
|
|||||||
@@ -584,6 +584,9 @@
|
|||||||
"description": "Adicione ferramentas ao nanobot e mencione-as com @ na conversa.",
|
"description": "Adicione ferramentas ao nanobot e mencione-as com @ na conversa.",
|
||||||
"cliLabel": "Aplicativo",
|
"cliLabel": "Aplicativo",
|
||||||
"mcpLabel": "Integração",
|
"mcpLabel": "Integração",
|
||||||
|
"pluginLabel": "Plugin",
|
||||||
|
"pluginEnabled": "Plugin ativado",
|
||||||
|
"pluginDisable": "Desativar",
|
||||||
"channelLabel": "Canal",
|
"channelLabel": "Canal",
|
||||||
"featureLabel": "Recurso",
|
"featureLabel": "Recurso",
|
||||||
"filterAll": "Prontos",
|
"filterAll": "Prontos",
|
||||||
|
|||||||
@@ -570,6 +570,9 @@
|
|||||||
"description": "Thêm công cụ vào nanobot, sau đó dùng @ trong cuộc trò chuyện.",
|
"description": "Thêm công cụ vào nanobot, sau đó dùng @ trong cuộc trò chuyện.",
|
||||||
"cliLabel": "Ứng dụng",
|
"cliLabel": "Ứng dụng",
|
||||||
"mcpLabel": "Tích hợp",
|
"mcpLabel": "Tích hợp",
|
||||||
|
"pluginLabel": "Plugin",
|
||||||
|
"pluginEnabled": "Plugin đã bật",
|
||||||
|
"pluginDisable": "Tắt",
|
||||||
"channelLabel": "Kênh",
|
"channelLabel": "Kênh",
|
||||||
"featureLabel": "Tính năng",
|
"featureLabel": "Tính năng",
|
||||||
"filterAll": "Sẵn sàng",
|
"filterAll": "Sẵn sàng",
|
||||||
|
|||||||
@@ -584,6 +584,9 @@
|
|||||||
"description": "将工具接入 nanobot,然后在对话中通过 @ 调用。",
|
"description": "将工具接入 nanobot,然后在对话中通过 @ 调用。",
|
||||||
"cliLabel": "应用",
|
"cliLabel": "应用",
|
||||||
"mcpLabel": "集成",
|
"mcpLabel": "集成",
|
||||||
|
"pluginLabel": "插件",
|
||||||
|
"pluginEnabled": "插件已启用",
|
||||||
|
"pluginDisable": "停用",
|
||||||
"channelLabel": "渠道",
|
"channelLabel": "渠道",
|
||||||
"featureLabel": "能力",
|
"featureLabel": "能力",
|
||||||
"filterAll": "可用",
|
"filterAll": "可用",
|
||||||
|
|||||||
@@ -570,6 +570,9 @@
|
|||||||
"description": "將工具新增至 nanobot,再於聊天中使用 @ 指定工具。",
|
"description": "將工具新增至 nanobot,再於聊天中使用 @ 指定工具。",
|
||||||
"cliLabel": "應用程式",
|
"cliLabel": "應用程式",
|
||||||
"mcpLabel": "整合",
|
"mcpLabel": "整合",
|
||||||
|
"pluginLabel": "外掛",
|
||||||
|
"pluginEnabled": "外掛已啟用",
|
||||||
|
"pluginDisable": "停用",
|
||||||
"channelLabel": "通訊管道",
|
"channelLabel": "通訊管道",
|
||||||
"featureLabel": "功能",
|
"featureLabel": "功能",
|
||||||
"filterAll": "就緒",
|
"filterAll": "就緒",
|
||||||
|
|||||||
@@ -579,6 +579,66 @@ describe("SettingsView Apps catalog", () => {
|
|||||||
expect(screen.queryByText("Uninstalled CLI for AnyGen.")).not.toBeInTheDocument();
|
expect(screen.queryByText("Uninstalled CLI for AnyGen.")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sets up and enables an installed Agent Plugin explicitly", async () => {
|
||||||
|
const plugin = {
|
||||||
|
name: "plugin-computer-use",
|
||||||
|
display_name: "Computer Use",
|
||||||
|
description: "Control the desktop with a live preview.",
|
||||||
|
category: "Productivity",
|
||||||
|
docs_url: "https://github.com/nanobot-dev/computer-use",
|
||||||
|
transport: "stdio",
|
||||||
|
requires: "screen-recording, accessibility",
|
||||||
|
note: "",
|
||||||
|
install_supported: true,
|
||||||
|
installed: true,
|
||||||
|
configured: false,
|
||||||
|
available: false,
|
||||||
|
status: "not_installed",
|
||||||
|
logo_url: null,
|
||||||
|
brand_color: "#ff7a1a",
|
||||||
|
required_fields: [],
|
||||||
|
connection_summary: "computer-use",
|
||||||
|
source: "agent-plugin",
|
||||||
|
};
|
||||||
|
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url === "/api/settings") return jsonResponse(settingsPayload());
|
||||||
|
if (url === "/api/settings/cli-apps") {
|
||||||
|
return jsonResponse({ apps: [], installed_count: 0 });
|
||||||
|
}
|
||||||
|
if (url === "/api/settings/mcp-presets") {
|
||||||
|
return jsonResponse({ presets: [plugin], installed_count: 0 });
|
||||||
|
}
|
||||||
|
if (url === "/api/settings/mcp-presets/enable?name=plugin-computer-use") {
|
||||||
|
return jsonResponse({
|
||||||
|
presets: [{ ...plugin, configured: true, available: true, status: "configured" }],
|
||||||
|
installed_count: 1,
|
||||||
|
last_action: { ok: true, message: "Computer Use enabled." },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return jsonResponse({});
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
renderSettingsView();
|
||||||
|
|
||||||
|
expect(await screen.findByText("Computer Use")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Plugin")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText("Control the desktop with a live preview. · screen-recording, accessibility"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Enable" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
"/api/settings/mcp-presets/enable?name=plugin-computer-use",
|
||||||
|
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(await screen.findByText("Computer Use enabled.")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Plugin enabled" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps runtime dependencies out of Apps and explains chat mentions", async () => {
|
it("keeps runtime dependencies out of Apps and explains chat mentions", async () => {
|
||||||
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
|
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
|
||||||
const url = String(input);
|
const url = String(input);
|
||||||
|
|||||||
Reference in New Issue
Block a user