diff --git a/docs/configuration.md b/docs/configuration.md index d3c9ad149..8255baabb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 -`/plugins//`. 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 +`/plugins//`. 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 diff --git a/nanobot/agent/plugins.py b/nanobot/agent/plugins.py index 2efed1616..d05c6f3df 100644 --- a/nanobot/agent/plugins.py +++ b/nanobot/agent/plugins.py @@ -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 ``/plugins/*``.""" + """Return installed packages found under ``/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 ``/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): diff --git a/nanobot/channels/websocket/tests/test_websocket_http_routes.py b/nanobot/channels/websocket/tests/test_websocket_http_routes.py index 023e05367..6740c37d7 100644 --- a/nanobot/channels/websocket/tests/test_websocket_http_routes.py +++ b/nanobot/channels/websocket/tests/test_websocket_http_routes.py @@ -2090,6 +2090,14 @@ async def test_mcp_presets_routes_require_token_and_return_payload( assert body["hot_reload"]["ok"] is True assert body["restart_required_sections"] == [] + disabled = await _webui_mutate( + channel, + "settings.mcp.disable", + {"name": "browserbase"}, + ) + assert disabled.status_code == 200 + assert preset_queries[-1][0] == "disable" + custom = await _webui_mutate( channel, "settings.mcp.custom", diff --git a/nanobot/webui/mcp_presets_api.py b/nanobot/webui/mcp_presets_api.py index 097153643..af8f13d97 100644 --- a/nanobot/webui/mcp_presets_api.py +++ b/nanobot/webui/mcp_presets_api.py @@ -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, diff --git a/nanobot/webui/settings_routes.py b/nanobot/webui/settings_routes.py index 30f57439e..cc04dd127 100644 --- a/nanobot/webui/settings_routes.py +++ b/nanobot/webui/settings_routes.py @@ -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( diff --git a/nanobot/webui/ws_http.py b/nanobot/webui/ws_http.py index a298a979b..0db563684 100644 --- a/nanobot/webui/ws_http.py +++ b/nanobot/webui/ws_http.py @@ -160,6 +160,7 @@ _WEBUI_MUTATION_PATHS = { "settings.pairing.approve": "/api/settings/pairing/approve", "settings.pairing.deny": "/api/settings/pairing/deny", "settings.mcp.enable": "/api/settings/mcp-presets/enable", + "settings.mcp.disable": "/api/settings/mcp-presets/disable", "settings.mcp.remove": "/api/settings/mcp-presets/remove", "settings.mcp.test": "/api/settings/mcp-presets/test", "settings.mcp.custom": "/api/settings/mcp-presets/custom", diff --git a/tests/webui/test_mcp_presets_api.py b/tests/webui/test_mcp_presets_api.py index 891a75d0f..52854eb16 100644 --- a/tests/webui/test_mcp_presets_api.py +++ b/tests/webui/test_mcp_presets_api.py @@ -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( diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx index 733e2d61f..11184c56e 100644 --- a/webui/src/components/settings/SettingsView.tsx +++ b/webui/src/components/settings/SettingsView.tsx @@ -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 = {}, ) => { @@ -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) => void; + onMcpAction: (action: "enable" | "disable" | "remove" | "test", name: string, values?: Record) => 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) => void; + onAction: (action: "enable" | "disable" | "remove" | "test", name: string, values?: Record) => 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({ @@ -7787,7 +7790,7 @@ function McpAppsCatalogRow({ onAction("remove", preset.name)} + onClick={() => onAction(agentPlugin ? "disable" : "remove", preset.name)} > {agentPlugin ? : } {agentPlugin @@ -7808,6 +7811,14 @@ function McpAppsCatalogRow({ ) : null} + ) : agentPlugin && preset.installed ? ( + onAction("enable", preset.name, values)} + > + + ) : preset.installed && !preset.configured ? ( & { 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 (