refactor(plugins): separate installation from activation

This commit is contained in:
Xubin Ren
2026-08-10 23:20:26 +09:00
parent 839c13d46f
commit 3700e1a9f7
22 changed files with 142 additions and 59 deletions
+9 -5
View File
@@ -2308,9 +2308,10 @@ Disabled skills are excluded from the main agent's skill summary, from always-on
### 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:
nanobot also loads locally installed [Agent Plugins](https://agent-plugins.org/) from
`<workspace>/plugins/<plugin>/`. Package presence in this directory is the installation state;
enabling it is a separate trust decision. A supported package has a root `plugin.json` that
targets Agent Plugins v1 and may provide skills, MCP servers, or both:
```text
plugins/
@@ -2340,8 +2341,11 @@ permissions are descriptive; nanobot does not currently enforce them with an OS
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.
plugin setup unless remote package installation was explicitly allowed. Enabling an already set
up package does not install it and is allowed remotely. Agent Plugins v1 deliberately leaves
distribution and installation UX to each host. A future catalog can therefore acquire, verify,
and place a package atomically before handing it to this same runtime; users should still see one
Install action, not separate download and installation steps.
The optional `extensions.dev.nanobot.logo` field points to a packaged PNG, JPEG, or WebP asset
such as `./assets/icon.png`. nanobot only reads contained raster files up to 256 KiB and embeds
+7 -7
View File
@@ -1,4 +1,4 @@
"""Discover portable Agent Plugins from the agent workspace."""
"""Load and activate locally installed Agent Plugin packages."""
from __future__ import annotations
@@ -58,7 +58,7 @@ class AgentPluginSkill:
@dataclass(frozen=True)
class AgentPlugin:
"""A validated Agent Plugins v1 package installed in the workspace."""
"""A validated, locally installed Agent Plugins v1 package."""
name: str
root: Path
@@ -84,7 +84,7 @@ class AgentPluginState:
def discover_agent_plugins(workspace: Path) -> list[AgentPlugin]:
"""Return valid packages from ``<workspace>/plugins/*``."""
"""Return installed packages found under ``<workspace>/plugins/*``."""
workspace = workspace.expanduser().resolve()
plugins_root = workspace / "plugins"
if not plugins_root.is_dir():
@@ -114,11 +114,11 @@ def discover_agent_plugins(workspace: Path) -> list[AgentPlugin]:
def discover_agent_plugin_skills(workspace: Path) -> list[AgentPluginSkill]:
"""Discover direct-child skills under ``<workspace>/plugins/*``.
"""Return skills supplied by locally installed plugin packages.
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.
The portable format does not prescribe acquisition or installation UX.
nanobot currently treats package presence in the workspace ``plugins``
directory as installed; activation remains a separate trust decision.
"""
skills: list[AgentPluginSkill] = []
for plugin in discover_agent_plugins(workspace):
@@ -2090,6 +2090,14 @@ async def test_mcp_presets_routes_require_token_and_return_payload(
assert body["hot_reload"]["ok"] is True
assert body["restart_required_sections"] == []
disabled = await _webui_mutate(
channel,
"settings.mcp.disable",
{"name": "browserbase"},
)
assert disabled.status_code == 200
assert preset_queries[-1][0] == "disable"
custom = await _webui_mutate(
channel,
"settings.mcp.custom",
+6 -5
View File
@@ -882,11 +882,12 @@ def _agent_plugin_payload(state: AgentPluginState) -> dict[str, Any]:
"transport": "stdio",
"requires": ", ".join(plugin.permissions),
"note": "",
"install_supported": True,
"install_supported": False,
"installed": True,
"configured": state.enabled,
"configured": not state.setup_required,
"enabled": state.enabled,
"available": state.enabled,
"status": "configured" if state.enabled else "not_installed",
"status": "enabled" if state.enabled else "disabled",
"logo_url": _plugin_logo_data_url(plugin.logo),
"brand_color": plugin.accent_color,
"required_fields": [],
@@ -929,7 +930,7 @@ def mcp_presets_payload(
payload: dict[str, Any] = {
"presets": [*preset_rows, *custom_rows, *plugin_rows],
"installed_count": len(config.tools.mcp_servers)
+ sum(int(row["configured"]) for row in plugin_rows),
+ sum(int(row["enabled"]) for row in plugin_rows),
}
if last_action is not None:
payload["last_action"] = last_action
@@ -1467,7 +1468,7 @@ async def mcp_presets_settings_action(
if state.mcp_servers or state.plugin.install_command
}
if name not in plugin_config.tools.mcp_servers and plugin_name in installed:
if action not in {"enable", "remove"}:
if action not in {"enable", "disable"}:
raise McpPresetError("Agent Plugins support enable and disable actions only")
plugin = await asyncio.to_thread(
set_agent_plugin_enabled,
+12 -7
View File
@@ -95,6 +95,7 @@ def _channel_connect_route(path: str) -> tuple[str, str] | None:
_MCP_PRESET_ACTIONS_BY_PATH = {
"/api/settings/mcp-presets/enable": "enable",
"/api/settings/mcp-presets/disable": "disable",
"/api/settings/mcp-presets/remove": "remove",
"/api/settings/mcp-presets/test": "test",
"/api/settings/mcp-presets/custom": "custom",
@@ -1219,15 +1220,19 @@ class WebUISettingsRouter:
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-{state.plugin.name}"
for state in discover_agent_plugin_states(config.workspace_path)
if state.mcp_servers or state.plugin.install_command
}
config = self.settings.config.load()
plugin_state = next(
(
state
for state in discover_agent_plugin_states(config.workspace_path)
if f"plugin-{state.plugin.name}" == name
),
None,
)
if (
name not in config.tools.mcp_servers
and name in plugin_names
and plugin_state is not None
and plugin_state.setup_required
and not self._allow_feature_package_install(connection, request)
):
return self._error_response(
+1
View File
@@ -160,6 +160,7 @@ _WEBUI_MUTATION_PATHS = {
"settings.pairing.approve": "/api/settings/pairing/approve",
"settings.pairing.deny": "/api/settings/pairing/deny",
"settings.mcp.enable": "/api/settings/mcp-presets/enable",
"settings.mcp.disable": "/api/settings/mcp-presets/disable",
"settings.mcp.remove": "/api/settings/mcp-presets/remove",
"settings.mcp.test": "/api/settings/mcp-presets/test",
"settings.mcp.custom": "/api/settings/mcp-presets/custom",
+19 -4
View File
@@ -114,8 +114,11 @@ def test_agent_plugin_reuses_mcp_catalog_and_runtime_action(
assert row["name"] == "plugin-desktop"
assert row["display_name"] == "Desktop Control"
assert row["logo_url"] == "data:image/png;base64,iVBORw0KGgpsb2dv"
assert row["install_supported"] is False
assert row["installed"] is True
assert row["configured"] is False
assert row["configured"] is True
assert row["enabled"] is False
assert row["status"] == "disabled"
async def reload() -> dict[str, object]:
return {"ok": True, "message": "MCP reloaded.", "requires_restart": False}
@@ -128,18 +131,30 @@ def test_agent_plugin_reuses_mcp_catalog_and_runtime_action(
)
)
enabled_row = next(item for item in enabled["presets"] if item["name"] == "plugin-desktop")
assert enabled_row["configured"] is True
assert enabled_row["enabled"] is True
assert enabled_row["status"] == "enabled"
assert enabled["requires_restart"] is False
disabled = asyncio.run(
mcp_presets_settings_action(
"remove",
"disable",
{"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
assert disabled_row["installed"] is True
assert disabled_row["configured"] is True
assert disabled_row["enabled"] is False
assert disabled_row["status"] == "disabled"
with pytest.raises(McpPresetError, match="enable and disable"):
asyncio.run(
mcp_presets_settings_action(
"remove",
{"name": ["plugin-desktop"]},
)
)
def test_explicit_mcp_config_wins_over_plugin_catalog_name(
+26 -14
View File
@@ -6,6 +6,7 @@ import {
useRef,
useState,
type Dispatch,
type ComponentPropsWithoutRef,
type FormEvent,
type ReactNode,
type SetStateAction,
@@ -1987,7 +1988,7 @@ export function SettingsView({
};
const handleMcpPresetAction = async (
action: "enable" | "remove" | "test",
action: "enable" | "disable" | "remove" | "test",
name: string,
values: Record<string, string> = {},
) => {
@@ -7381,7 +7382,7 @@ function AppsCatalogSettings({
onQueryChange: (value: string) => void;
onFilterChange: (value: AppsKindFilter) => void;
onCliAction: (action: "install" | "update" | "uninstall" | "test", name: string) => void;
onMcpAction: (action: "enable" | "remove" | "test", name: string, values?: Record<string, string>) => void;
onMcpAction: (action: "enable" | "disable" | "remove" | "test", name: string, values?: Record<string, string>) => void;
onDismissStatus: () => void;
onBackToChat: () => void;
onMcpFieldChange: (presetName: string, fieldName: string, value: string) => void;
@@ -7686,7 +7687,7 @@ function McpAppsCatalogRow({
actionKey: string | null;
showBrandLogos: boolean;
onFieldChange: (presetName: string, fieldName: string, value: string) => void;
onAction: (action: "enable" | "remove" | "test", name: string, values?: Record<string, string>) => void;
onAction: (action: "enable" | "disable" | "remove" | "test", name: string, values?: Record<string, string>) => void;
onToolsChange: (name: string, enabledTools: string[]) => void;
}) {
const { t } = useTranslation();
@@ -7694,15 +7695,17 @@ function McpAppsCatalogRow({
const [setupOpen, setSetupOpen] = useState(false);
const [toolsOpen, setToolsOpen] = useState(false);
const enableBusy = actionKey === `enable:${preset.name}`;
const disableBusy = actionKey === `disable:${preset.name}`;
const removeBusy = actionKey === `remove:${preset.name}`;
const testBusy = actionKey === `test:${preset.name}`;
const toolsBusy = actionKey === `tools:${preset.name}`;
const busy = enableBusy || removeBusy || testBusy || toolsBusy;
const busy = enableBusy || disableBusy || removeBusy || testBusy || toolsBusy;
const agentPlugin = preset.source === "agent-plugin";
const missingFields = preset.required_fields.filter((field) => field.required && !field.configured);
const hasFields = preset.required_fields.length > 0;
const needsSetupInput = missingFields.length > 0;
const readyInstalled = preset.installed && preset.configured;
const pluginEnabled = agentPlugin && preset.enabled === true;
const readyInstalled = agentPlugin ? pluginEnabled : preset.installed && preset.configured;
const canEnable =
preset.install_supported &&
(missingFields.length === 0 || missingFields.every((field) => Boolean(values[field.name]?.trim())));
@@ -7764,7 +7767,7 @@ function McpAppsCatalogRow({
<DropdownMenuTrigger asChild>
<AppsActionButton
ariaLabel={statusLabel}
busy={testBusy || toolsBusy}
busy={testBusy || toolsBusy || disableBusy}
disabled={busy}
tone="installed"
>
@@ -7787,7 +7790,7 @@ function McpAppsCatalogRow({
<DropdownMenuItem
tone={agentPlugin ? undefined : "destructive"}
disabled={busy}
onClick={() => onAction("remove", preset.name)}
onClick={() => onAction(agentPlugin ? "disable" : "remove", preset.name)}
>
{agentPlugin ? <PauseCircle aria-hidden /> : <Trash2 aria-hidden />}
{agentPlugin
@@ -7808,6 +7811,14 @@ function McpAppsCatalogRow({
</AppsActionButton>
) : null}
</>
) : agentPlugin && preset.installed ? (
<AppsActionButton
ariaLabel={tx("settings.apps.pluginEnable", "Enable plugin")}
busy={enableBusy}
onClick={() => onAction("enable", preset.name, values)}
>
<PlayCircle className="h-4 w-4" aria-hidden />
</AppsActionButton>
) : preset.installed && !preset.configured ? (
<AppsActionButton
ariaLabel={hasFields ? tx("settings.mcp.configure", "Configure") : tx("settings.mcp.enable", "Enable")}
@@ -7970,23 +7981,22 @@ function AppsTypeBadge({ children }: { children: ReactNode }) {
);
}
const AppsActionButton = forwardRef<HTMLButtonElement, {
const AppsActionButton = forwardRef<HTMLButtonElement, ComponentPropsWithoutRef<typeof Button> & {
ariaLabel: string;
busy?: boolean;
disabled?: boolean;
tone?: "default" | "installed" | "danger";
onClick?: () => void;
children: ReactNode;
}>(function AppsActionButton({
ariaLabel,
busy,
disabled,
tone = "default",
onClick,
className,
children,
...props
}, ref) {
return (
<Button
{...props}
ref={ref}
type="button"
size="icon"
@@ -7994,12 +8004,12 @@ const AppsActionButton = forwardRef<HTMLButtonElement, {
aria-label={ariaLabel}
title={ariaLabel}
disabled={disabled || busy}
onClick={onClick}
className={cn(
"h-9 w-9 rounded-full text-muted-foreground transition-colors",
tone === "installed" && "bg-transparent hover:bg-muted/70 hover:text-foreground",
tone === "danger" && "bg-transparent hover:bg-destructive/10 hover:text-destructive",
tone === "default" && "bg-muted/70 hover:bg-muted hover:text-foreground",
className,
)}
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden /> : children}
@@ -8012,7 +8022,9 @@ function appsTitle(item: AppsCatalogItem): string {
}
function appsReady(item: AppsCatalogItem): boolean {
return item.kind === "cli" ? item.app.installed : item.preset.installed && item.preset.configured;
if (item.kind === "cli") return item.app.installed;
if (item.preset.source === "agent-plugin") return item.preset.enabled === true;
return item.preset.installed && item.preset.configured;
}
function appsSearchText(item: AppsCatalogItem): string {
+1
View File
@@ -587,6 +587,7 @@
"mcpLabel": "Integration",
"pluginLabel": "Plugin",
"pluginEnabled": "Plugin enabled",
"pluginEnable": "Enable plugin",
"pluginDisable": "Disable",
"channelLabel": "Channel",
"featureLabel": "Feature",
+1
View File
@@ -574,6 +574,7 @@
"mcpLabel": "Integración",
"pluginLabel": "Plugin",
"pluginEnabled": "Plugin activado",
"pluginEnable": "Activar plugin",
"pluginDisable": "Desactivar",
"channelLabel": "Canal",
"featureLabel": "Función",
+1
View File
@@ -573,6 +573,7 @@
"mcpLabel": "Intégration",
"pluginLabel": "Plugin",
"pluginEnabled": "Plugin activé",
"pluginEnable": "Activer le plugin",
"pluginDisable": "Désactiver",
"channelLabel": "Canal",
"featureLabel": "Fonction",
+1
View File
@@ -573,6 +573,7 @@
"mcpLabel": "Integrasi",
"pluginLabel": "Plugin",
"pluginEnabled": "Plugin aktif",
"pluginEnable": "Aktifkan plugin",
"pluginDisable": "Nonaktifkan",
"channelLabel": "Kanal",
"featureLabel": "Fitur",
+1
View File
@@ -573,6 +573,7 @@
"mcpLabel": "連携",
"pluginLabel": "プラグイン",
"pluginEnabled": "プラグインは有効です",
"pluginEnable": "プラグインを有効にする",
"pluginDisable": "無効にする",
"channelLabel": "チャンネル",
"featureLabel": "機能",
+1
View File
@@ -573,6 +573,7 @@
"mcpLabel": "연동",
"pluginLabel": "플러그인",
"pluginEnabled": "플러그인 활성화됨",
"pluginEnable": "플러그인 활성화",
"pluginDisable": "비활성화",
"channelLabel": "채널",
"featureLabel": "기능",
+1
View File
@@ -587,6 +587,7 @@
"mcpLabel": "Integração",
"pluginLabel": "Plugin",
"pluginEnabled": "Plugin ativado",
"pluginEnable": "Ativar plugin",
"pluginDisable": "Desativar",
"channelLabel": "Canal",
"featureLabel": "Recurso",
+1
View File
@@ -573,6 +573,7 @@
"mcpLabel": "Tích hợp",
"pluginLabel": "Plugin",
"pluginEnabled": "Plugin đã bật",
"pluginEnable": "Bật plugin",
"pluginDisable": "Tắt",
"channelLabel": "Kênh",
"featureLabel": "Tính năng",
+1
View File
@@ -587,6 +587,7 @@
"mcpLabel": "集成",
"pluginLabel": "插件",
"pluginEnabled": "插件已启用",
"pluginEnable": "启用插件",
"pluginDisable": "停用",
"channelLabel": "渠道",
"featureLabel": "能力",
+1
View File
@@ -573,6 +573,7 @@
"mcpLabel": "整合",
"pluginLabel": "外掛",
"pluginEnabled": "外掛已啟用",
"pluginEnable": "啟用外掛",
"pluginDisable": "停用",
"channelLabel": "通訊管道",
"featureLabel": "功能",
+1 -1
View File
@@ -703,7 +703,7 @@ export async function fetchProviderModels(
export async function runMcpPresetAction(
transport: WebUIMutationTransport,
action: "enable" | "remove" | "test",
action: "enable" | "disable" | "remove" | "test",
name: string,
values: Record<string, string> = {},
): Promise<McpPresetsPayload> {
+1
View File
@@ -961,6 +961,7 @@ export interface McpPresetInfo {
install_supported: boolean;
installed: boolean;
configured: boolean;
enabled?: boolean;
available: boolean;
status: "not_installed" | "configured" | "missing_credentials" | "missing_dependency" | "coming_soon" | string;
logo_url?: string | null;
+7
View File
@@ -882,6 +882,13 @@ describe("webui API helpers", () => {
{ name: "browserbase", browserbase_api_key: "bb_live_test" },
20_000,
);
await runMcpPresetAction(mutationTransport, "disable", "plugin-desktop");
expect(requestMutation).toHaveBeenCalledWith(
"settings.mcp.disable",
{ name: "plugin-desktop" },
20_000,
);
});
it("serializes custom MCP, mcp.json import, and tool allowlist actions", async () => {
+35 -16
View File
@@ -652,7 +652,7 @@ describe("SettingsView Apps catalog", () => {
expect(screen.queryByText("Uninstalled CLI for AnyGen.")).not.toBeInTheDocument();
});
it("sets up and enables an installed Agent Plugin explicitly", async () => {
it("enables and disables an installed Agent Plugin explicitly", async () => {
const plugin = {
name: "plugin-computer-use",
display_name: "Computer Use",
@@ -662,11 +662,12 @@ describe("SettingsView Apps catalog", () => {
transport: "stdio",
requires: "screen-recording, accessibility",
note: "",
install_supported: true,
install_supported: false,
installed: true,
configured: false,
configured: true,
enabled: false,
available: false,
status: "not_installed",
status: "disabled",
logo_url: null,
brand_color: "#ff7a1a",
required_fields: [],
@@ -682,16 +683,20 @@ describe("SettingsView Apps catalog", () => {
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);
requestMutationMock
.mockResolvedValueOnce({
presets: [{ ...plugin, enabled: true, available: true, status: "enabled" }],
installed_count: 1,
last_action: { ok: true, message: "Computer Use enabled." },
})
.mockResolvedValueOnce({
presets: [{ ...plugin, enabled: false, available: false, status: "disabled" }],
installed_count: 0,
last_action: { ok: true, message: "Computer Use disabled." },
});
renderSettingsView();
@@ -700,16 +705,30 @@ describe("SettingsView Apps catalog", () => {
expect(
screen.getByText("Control the desktop with a live preview. · screen-recording, accessibility"),
).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Enable" }));
fireEvent.click(screen.getByRole("button", { name: "Enable plugin" }));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(
"/api/settings/mcp-presets/enable?name=plugin-computer-use",
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
expect(requestMutationMock).toHaveBeenCalledWith(
"settings.mcp.enable",
{ name: "plugin-computer-use" },
20_000,
);
});
expect(await screen.findByText("Computer Use enabled.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Plugin enabled" })).toBeInTheDocument();
const enabledButton = screen.getByRole("button", { name: "Plugin enabled" });
await waitFor(() => expect(enabledButton).toBeEnabled());
fireEvent.pointerDown(enabledButton, { button: 0, ctrlKey: false });
fireEvent.click(await screen.findByRole("menuitem", { name: "Disable" }));
await waitFor(() => {
expect(requestMutationMock).toHaveBeenCalledWith(
"settings.mcp.disable",
{ name: "plugin-computer-use" },
20_000,
);
});
expect(await screen.findByText("Computer Use disabled.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Enable plugin" })).toBeInTheDocument();
});
it("keeps runtime dependencies out of Apps and explains chat mentions", async () => {