mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-10 22:38:40 +03:00
refactor(plugins): separate installation from activation
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -587,6 +587,7 @@
|
||||
"mcpLabel": "Integration",
|
||||
"pluginLabel": "Plugin",
|
||||
"pluginEnabled": "Plugin enabled",
|
||||
"pluginEnable": "Enable plugin",
|
||||
"pluginDisable": "Disable",
|
||||
"channelLabel": "Channel",
|
||||
"featureLabel": "Feature",
|
||||
|
||||
@@ -574,6 +574,7 @@
|
||||
"mcpLabel": "Integración",
|
||||
"pluginLabel": "Plugin",
|
||||
"pluginEnabled": "Plugin activado",
|
||||
"pluginEnable": "Activar plugin",
|
||||
"pluginDisable": "Desactivar",
|
||||
"channelLabel": "Canal",
|
||||
"featureLabel": "Función",
|
||||
|
||||
@@ -573,6 +573,7 @@
|
||||
"mcpLabel": "Intégration",
|
||||
"pluginLabel": "Plugin",
|
||||
"pluginEnabled": "Plugin activé",
|
||||
"pluginEnable": "Activer le plugin",
|
||||
"pluginDisable": "Désactiver",
|
||||
"channelLabel": "Canal",
|
||||
"featureLabel": "Fonction",
|
||||
|
||||
@@ -573,6 +573,7 @@
|
||||
"mcpLabel": "Integrasi",
|
||||
"pluginLabel": "Plugin",
|
||||
"pluginEnabled": "Plugin aktif",
|
||||
"pluginEnable": "Aktifkan plugin",
|
||||
"pluginDisable": "Nonaktifkan",
|
||||
"channelLabel": "Kanal",
|
||||
"featureLabel": "Fitur",
|
||||
|
||||
@@ -573,6 +573,7 @@
|
||||
"mcpLabel": "連携",
|
||||
"pluginLabel": "プラグイン",
|
||||
"pluginEnabled": "プラグインは有効です",
|
||||
"pluginEnable": "プラグインを有効にする",
|
||||
"pluginDisable": "無効にする",
|
||||
"channelLabel": "チャンネル",
|
||||
"featureLabel": "機能",
|
||||
|
||||
@@ -573,6 +573,7 @@
|
||||
"mcpLabel": "연동",
|
||||
"pluginLabel": "플러그인",
|
||||
"pluginEnabled": "플러그인 활성화됨",
|
||||
"pluginEnable": "플러그인 활성화",
|
||||
"pluginDisable": "비활성화",
|
||||
"channelLabel": "채널",
|
||||
"featureLabel": "기능",
|
||||
|
||||
@@ -587,6 +587,7 @@
|
||||
"mcpLabel": "Integração",
|
||||
"pluginLabel": "Plugin",
|
||||
"pluginEnabled": "Plugin ativado",
|
||||
"pluginEnable": "Ativar plugin",
|
||||
"pluginDisable": "Desativar",
|
||||
"channelLabel": "Canal",
|
||||
"featureLabel": "Recurso",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -587,6 +587,7 @@
|
||||
"mcpLabel": "集成",
|
||||
"pluginLabel": "插件",
|
||||
"pluginEnabled": "插件已启用",
|
||||
"pluginEnable": "启用插件",
|
||||
"pluginDisable": "停用",
|
||||
"channelLabel": "渠道",
|
||||
"featureLabel": "能力",
|
||||
|
||||
@@ -573,6 +573,7 @@
|
||||
"mcpLabel": "整合",
|
||||
"pluginLabel": "外掛",
|
||||
"pluginEnabled": "外掛已啟用",
|
||||
"pluginEnable": "啟用外掛",
|
||||
"pluginDisable": "停用",
|
||||
"channelLabel": "通訊管道",
|
||||
"featureLabel": "功能",
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user