mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-11 14:58:39 +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
|
### Agent Plugins v1
|
||||||
|
|
||||||
nanobot also discovers portable [Agent Plugins](https://agent-plugins.org/) placed under
|
nanobot also loads locally installed [Agent Plugins](https://agent-plugins.org/) from
|
||||||
`<workspace>/plugins/<plugin>/`. A supported package has a root `plugin.json` that targets
|
`<workspace>/plugins/<plugin>/`. Package presence in this directory is the installation state;
|
||||||
Agent Plugins v1 and may provide skills, MCP servers, or both:
|
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
|
```text
|
||||||
plugins/
|
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
|
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
|
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
|
plugin setup unless remote package installation was explicitly allowed. Enabling an already set
|
||||||
not define a registry, so package distribution remains separate from discovery and execution.
|
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
|
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
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ class AgentPluginSkill:
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class AgentPlugin:
|
class AgentPlugin:
|
||||||
"""A validated Agent Plugins v1 package installed in the workspace."""
|
"""A validated, locally installed Agent Plugins v1 package."""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
root: Path
|
root: Path
|
||||||
@@ -84,7 +84,7 @@ class AgentPluginState:
|
|||||||
|
|
||||||
|
|
||||||
def discover_agent_plugins(workspace: Path) -> list[AgentPlugin]:
|
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()
|
workspace = workspace.expanduser().resolve()
|
||||||
plugins_root = workspace / "plugins"
|
plugins_root = workspace / "plugins"
|
||||||
if not plugins_root.is_dir():
|
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]:
|
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
|
The portable format does not prescribe acquisition or installation UX.
|
||||||
workspace ``plugins`` directory so packages stay explicit and portable
|
nanobot currently treats package presence in the workspace ``plugins``
|
||||||
with the rest of the agent workspace.
|
directory as installed; activation remains a separate trust decision.
|
||||||
"""
|
"""
|
||||||
skills: list[AgentPluginSkill] = []
|
skills: list[AgentPluginSkill] = []
|
||||||
for plugin in discover_agent_plugins(workspace):
|
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["hot_reload"]["ok"] is True
|
||||||
assert body["restart_required_sections"] == []
|
assert body["restart_required_sections"] == []
|
||||||
|
|
||||||
|
disabled = await _webui_mutate(
|
||||||
|
channel,
|
||||||
|
"settings.mcp.disable",
|
||||||
|
{"name": "browserbase"},
|
||||||
|
)
|
||||||
|
assert disabled.status_code == 200
|
||||||
|
assert preset_queries[-1][0] == "disable"
|
||||||
|
|
||||||
custom = await _webui_mutate(
|
custom = await _webui_mutate(
|
||||||
channel,
|
channel,
|
||||||
"settings.mcp.custom",
|
"settings.mcp.custom",
|
||||||
|
|||||||
@@ -882,11 +882,12 @@ def _agent_plugin_payload(state: AgentPluginState) -> dict[str, Any]:
|
|||||||
"transport": "stdio",
|
"transport": "stdio",
|
||||||
"requires": ", ".join(plugin.permissions),
|
"requires": ", ".join(plugin.permissions),
|
||||||
"note": "",
|
"note": "",
|
||||||
"install_supported": True,
|
"install_supported": False,
|
||||||
"installed": True,
|
"installed": True,
|
||||||
"configured": state.enabled,
|
"configured": not state.setup_required,
|
||||||
|
"enabled": state.enabled,
|
||||||
"available": 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),
|
"logo_url": _plugin_logo_data_url(plugin.logo),
|
||||||
"brand_color": plugin.accent_color,
|
"brand_color": plugin.accent_color,
|
||||||
"required_fields": [],
|
"required_fields": [],
|
||||||
@@ -929,7 +930,7 @@ def mcp_presets_payload(
|
|||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
"presets": [*preset_rows, *custom_rows, *plugin_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),
|
+ sum(int(row["enabled"]) for row in plugin_rows),
|
||||||
}
|
}
|
||||||
if last_action is not None:
|
if last_action is not None:
|
||||||
payload["last_action"] = last_action
|
payload["last_action"] = last_action
|
||||||
@@ -1467,7 +1468,7 @@ async def mcp_presets_settings_action(
|
|||||||
if state.mcp_servers or state.plugin.install_command
|
if state.mcp_servers or state.plugin.install_command
|
||||||
}
|
}
|
||||||
if name not in plugin_config.tools.mcp_servers and plugin_name in installed:
|
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")
|
raise McpPresetError("Agent Plugins support enable and disable actions only")
|
||||||
plugin = await asyncio.to_thread(
|
plugin = await asyncio.to_thread(
|
||||||
set_agent_plugin_enabled,
|
set_agent_plugin_enabled,
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ def _channel_connect_route(path: str) -> tuple[str, str] | None:
|
|||||||
|
|
||||||
_MCP_PRESET_ACTIONS_BY_PATH = {
|
_MCP_PRESET_ACTIONS_BY_PATH = {
|
||||||
"/api/settings/mcp-presets/enable": "enable",
|
"/api/settings/mcp-presets/enable": "enable",
|
||||||
|
"/api/settings/mcp-presets/disable": "disable",
|
||||||
"/api/settings/mcp-presets/remove": "remove",
|
"/api/settings/mcp-presets/remove": "remove",
|
||||||
"/api/settings/mcp-presets/test": "test",
|
"/api/settings/mcp-presets/test": "test",
|
||||||
"/api/settings/mcp-presets/custom": "custom",
|
"/api/settings/mcp-presets/custom": "custom",
|
||||||
@@ -1219,15 +1220,19 @@ class WebUISettingsRouter:
|
|||||||
query = self._parse_mcp_settings_query(request)
|
query = self._parse_mcp_settings_query(request)
|
||||||
name = (_query_first(query, "name") or "").strip()
|
name = (_query_first(query, "name") or "").strip()
|
||||||
if action == "enable" and name.startswith("plugin-"):
|
if action == "enable" and name.startswith("plugin-"):
|
||||||
config = load_config()
|
config = self.settings.config.load()
|
||||||
plugin_names = {
|
plugin_state = next(
|
||||||
f"plugin-{state.plugin.name}"
|
(
|
||||||
for state in discover_agent_plugin_states(config.workspace_path)
|
state
|
||||||
if state.mcp_servers or state.plugin.install_command
|
for state in discover_agent_plugin_states(config.workspace_path)
|
||||||
}
|
if f"plugin-{state.plugin.name}" == name
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
if (
|
if (
|
||||||
name not in config.tools.mcp_servers
|
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)
|
and not self._allow_feature_package_install(connection, request)
|
||||||
):
|
):
|
||||||
return self._error_response(
|
return self._error_response(
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ _WEBUI_MUTATION_PATHS = {
|
|||||||
"settings.pairing.approve": "/api/settings/pairing/approve",
|
"settings.pairing.approve": "/api/settings/pairing/approve",
|
||||||
"settings.pairing.deny": "/api/settings/pairing/deny",
|
"settings.pairing.deny": "/api/settings/pairing/deny",
|
||||||
"settings.mcp.enable": "/api/settings/mcp-presets/enable",
|
"settings.mcp.enable": "/api/settings/mcp-presets/enable",
|
||||||
|
"settings.mcp.disable": "/api/settings/mcp-presets/disable",
|
||||||
"settings.mcp.remove": "/api/settings/mcp-presets/remove",
|
"settings.mcp.remove": "/api/settings/mcp-presets/remove",
|
||||||
"settings.mcp.test": "/api/settings/mcp-presets/test",
|
"settings.mcp.test": "/api/settings/mcp-presets/test",
|
||||||
"settings.mcp.custom": "/api/settings/mcp-presets/custom",
|
"settings.mcp.custom": "/api/settings/mcp-presets/custom",
|
||||||
|
|||||||
@@ -114,8 +114,11 @@ def test_agent_plugin_reuses_mcp_catalog_and_runtime_action(
|
|||||||
assert row["name"] == "plugin-desktop"
|
assert row["name"] == "plugin-desktop"
|
||||||
assert row["display_name"] == "Desktop Control"
|
assert row["display_name"] == "Desktop Control"
|
||||||
assert row["logo_url"] == "data:image/png;base64,iVBORw0KGgpsb2dv"
|
assert row["logo_url"] == "data:image/png;base64,iVBORw0KGgpsb2dv"
|
||||||
|
assert row["install_supported"] is False
|
||||||
assert row["installed"] is True
|
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]:
|
async def reload() -> dict[str, object]:
|
||||||
return {"ok": True, "message": "MCP reloaded.", "requires_restart": False}
|
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")
|
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
|
assert enabled["requires_restart"] is False
|
||||||
|
|
||||||
disabled = asyncio.run(
|
disabled = asyncio.run(
|
||||||
mcp_presets_settings_action(
|
mcp_presets_settings_action(
|
||||||
"remove",
|
"disable",
|
||||||
{"name": ["plugin-desktop"]},
|
{"name": ["plugin-desktop"]},
|
||||||
reload_mcp=reload,
|
reload_mcp=reload,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
disabled_row = next(item for item in disabled["presets"] if item["name"] == "plugin-desktop")
|
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(
|
def test_explicit_mcp_config_wins_over_plugin_catalog_name(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type Dispatch,
|
type Dispatch,
|
||||||
|
type ComponentPropsWithoutRef,
|
||||||
type FormEvent,
|
type FormEvent,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
type SetStateAction,
|
type SetStateAction,
|
||||||
@@ -1987,7 +1988,7 @@ export function SettingsView({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleMcpPresetAction = async (
|
const handleMcpPresetAction = async (
|
||||||
action: "enable" | "remove" | "test",
|
action: "enable" | "disable" | "remove" | "test",
|
||||||
name: string,
|
name: string,
|
||||||
values: Record<string, string> = {},
|
values: Record<string, string> = {},
|
||||||
) => {
|
) => {
|
||||||
@@ -7381,7 +7382,7 @@ function AppsCatalogSettings({
|
|||||||
onQueryChange: (value: string) => void;
|
onQueryChange: (value: string) => void;
|
||||||
onFilterChange: (value: AppsKindFilter) => void;
|
onFilterChange: (value: AppsKindFilter) => void;
|
||||||
onCliAction: (action: "install" | "update" | "uninstall" | "test", name: string) => void;
|
onCliAction: (action: "install" | "update" | "uninstall" | "test", name: string) => void;
|
||||||
onMcpAction: (action: "enable" | "remove" | "test", name: string, values?: Record<string, string>) => void;
|
onMcpAction: (action: "enable" | "disable" | "remove" | "test", name: string, values?: Record<string, string>) => void;
|
||||||
onDismissStatus: () => void;
|
onDismissStatus: () => void;
|
||||||
onBackToChat: () => void;
|
onBackToChat: () => void;
|
||||||
onMcpFieldChange: (presetName: string, fieldName: string, value: string) => void;
|
onMcpFieldChange: (presetName: string, fieldName: string, value: string) => void;
|
||||||
@@ -7686,7 +7687,7 @@ function McpAppsCatalogRow({
|
|||||||
actionKey: string | null;
|
actionKey: string | null;
|
||||||
showBrandLogos: boolean;
|
showBrandLogos: boolean;
|
||||||
onFieldChange: (presetName: string, fieldName: string, value: string) => void;
|
onFieldChange: (presetName: string, fieldName: string, value: string) => void;
|
||||||
onAction: (action: "enable" | "remove" | "test", name: string, values?: Record<string, string>) => void;
|
onAction: (action: "enable" | "disable" | "remove" | "test", name: string, values?: Record<string, string>) => void;
|
||||||
onToolsChange: (name: string, enabledTools: string[]) => void;
|
onToolsChange: (name: string, enabledTools: string[]) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -7694,15 +7695,17 @@ function McpAppsCatalogRow({
|
|||||||
const [setupOpen, setSetupOpen] = useState(false);
|
const [setupOpen, setSetupOpen] = useState(false);
|
||||||
const [toolsOpen, setToolsOpen] = useState(false);
|
const [toolsOpen, setToolsOpen] = useState(false);
|
||||||
const enableBusy = actionKey === `enable:${preset.name}`;
|
const enableBusy = actionKey === `enable:${preset.name}`;
|
||||||
|
const disableBusy = actionKey === `disable:${preset.name}`;
|
||||||
const removeBusy = actionKey === `remove:${preset.name}`;
|
const removeBusy = actionKey === `remove:${preset.name}`;
|
||||||
const testBusy = actionKey === `test:${preset.name}`;
|
const testBusy = actionKey === `test:${preset.name}`;
|
||||||
const toolsBusy = actionKey === `tools:${preset.name}`;
|
const toolsBusy = actionKey === `tools:${preset.name}`;
|
||||||
const busy = enableBusy || removeBusy || testBusy || toolsBusy;
|
const busy = enableBusy || disableBusy || removeBusy || testBusy || toolsBusy;
|
||||||
const agentPlugin = preset.source === "agent-plugin";
|
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;
|
||||||
const readyInstalled = preset.installed && preset.configured;
|
const pluginEnabled = agentPlugin && preset.enabled === true;
|
||||||
|
const readyInstalled = agentPlugin ? pluginEnabled : preset.installed && preset.configured;
|
||||||
const canEnable =
|
const canEnable =
|
||||||
preset.install_supported &&
|
preset.install_supported &&
|
||||||
(missingFields.length === 0 || missingFields.every((field) => Boolean(values[field.name]?.trim())));
|
(missingFields.length === 0 || missingFields.every((field) => Boolean(values[field.name]?.trim())));
|
||||||
@@ -7764,7 +7767,7 @@ function McpAppsCatalogRow({
|
|||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<AppsActionButton
|
<AppsActionButton
|
||||||
ariaLabel={statusLabel}
|
ariaLabel={statusLabel}
|
||||||
busy={testBusy || toolsBusy}
|
busy={testBusy || toolsBusy || disableBusy}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
tone="installed"
|
tone="installed"
|
||||||
>
|
>
|
||||||
@@ -7787,7 +7790,7 @@ function McpAppsCatalogRow({
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
tone={agentPlugin ? undefined : "destructive"}
|
tone={agentPlugin ? undefined : "destructive"}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() => onAction("remove", preset.name)}
|
onClick={() => onAction(agentPlugin ? "disable" : "remove", preset.name)}
|
||||||
>
|
>
|
||||||
{agentPlugin ? <PauseCircle aria-hidden /> : <Trash2 aria-hidden />}
|
{agentPlugin ? <PauseCircle aria-hidden /> : <Trash2 aria-hidden />}
|
||||||
{agentPlugin
|
{agentPlugin
|
||||||
@@ -7808,6 +7811,14 @@ function McpAppsCatalogRow({
|
|||||||
</AppsActionButton>
|
</AppsActionButton>
|
||||||
) : null}
|
) : 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 ? (
|
) : preset.installed && !preset.configured ? (
|
||||||
<AppsActionButton
|
<AppsActionButton
|
||||||
ariaLabel={hasFields ? tx("settings.mcp.configure", "Configure") : tx("settings.mcp.enable", "Enable")}
|
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;
|
ariaLabel: string;
|
||||||
busy?: boolean;
|
busy?: boolean;
|
||||||
disabled?: boolean;
|
|
||||||
tone?: "default" | "installed" | "danger";
|
tone?: "default" | "installed" | "danger";
|
||||||
onClick?: () => void;
|
|
||||||
children: ReactNode;
|
|
||||||
}>(function AppsActionButton({
|
}>(function AppsActionButton({
|
||||||
ariaLabel,
|
ariaLabel,
|
||||||
busy,
|
busy,
|
||||||
disabled,
|
disabled,
|
||||||
tone = "default",
|
tone = "default",
|
||||||
onClick,
|
className,
|
||||||
children,
|
children,
|
||||||
|
...props
|
||||||
}, ref) {
|
}, ref) {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
type="button"
|
type="button"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -7994,12 +8004,12 @@ const AppsActionButton = forwardRef<HTMLButtonElement, {
|
|||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
title={ariaLabel}
|
title={ariaLabel}
|
||||||
disabled={disabled || busy}
|
disabled={disabled || busy}
|
||||||
onClick={onClick}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-9 w-9 rounded-full text-muted-foreground transition-colors",
|
"h-9 w-9 rounded-full text-muted-foreground transition-colors",
|
||||||
tone === "installed" && "bg-transparent hover:bg-muted/70 hover:text-foreground",
|
tone === "installed" && "bg-transparent hover:bg-muted/70 hover:text-foreground",
|
||||||
tone === "danger" && "bg-transparent hover:bg-destructive/10 hover:text-destructive",
|
tone === "danger" && "bg-transparent hover:bg-destructive/10 hover:text-destructive",
|
||||||
tone === "default" && "bg-muted/70 hover:bg-muted hover:text-foreground",
|
tone === "default" && "bg-muted/70 hover:bg-muted hover:text-foreground",
|
||||||
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" aria-hidden /> : children}
|
{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 {
|
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 {
|
function appsSearchText(item: AppsCatalogItem): string {
|
||||||
|
|||||||
@@ -587,6 +587,7 @@
|
|||||||
"mcpLabel": "Integration",
|
"mcpLabel": "Integration",
|
||||||
"pluginLabel": "Plugin",
|
"pluginLabel": "Plugin",
|
||||||
"pluginEnabled": "Plugin enabled",
|
"pluginEnabled": "Plugin enabled",
|
||||||
|
"pluginEnable": "Enable plugin",
|
||||||
"pluginDisable": "Disable",
|
"pluginDisable": "Disable",
|
||||||
"channelLabel": "Channel",
|
"channelLabel": "Channel",
|
||||||
"featureLabel": "Feature",
|
"featureLabel": "Feature",
|
||||||
|
|||||||
@@ -574,6 +574,7 @@
|
|||||||
"mcpLabel": "Integración",
|
"mcpLabel": "Integración",
|
||||||
"pluginLabel": "Plugin",
|
"pluginLabel": "Plugin",
|
||||||
"pluginEnabled": "Plugin activado",
|
"pluginEnabled": "Plugin activado",
|
||||||
|
"pluginEnable": "Activar plugin",
|
||||||
"pluginDisable": "Desactivar",
|
"pluginDisable": "Desactivar",
|
||||||
"channelLabel": "Canal",
|
"channelLabel": "Canal",
|
||||||
"featureLabel": "Función",
|
"featureLabel": "Función",
|
||||||
|
|||||||
@@ -573,6 +573,7 @@
|
|||||||
"mcpLabel": "Intégration",
|
"mcpLabel": "Intégration",
|
||||||
"pluginLabel": "Plugin",
|
"pluginLabel": "Plugin",
|
||||||
"pluginEnabled": "Plugin activé",
|
"pluginEnabled": "Plugin activé",
|
||||||
|
"pluginEnable": "Activer le plugin",
|
||||||
"pluginDisable": "Désactiver",
|
"pluginDisable": "Désactiver",
|
||||||
"channelLabel": "Canal",
|
"channelLabel": "Canal",
|
||||||
"featureLabel": "Fonction",
|
"featureLabel": "Fonction",
|
||||||
|
|||||||
@@ -573,6 +573,7 @@
|
|||||||
"mcpLabel": "Integrasi",
|
"mcpLabel": "Integrasi",
|
||||||
"pluginLabel": "Plugin",
|
"pluginLabel": "Plugin",
|
||||||
"pluginEnabled": "Plugin aktif",
|
"pluginEnabled": "Plugin aktif",
|
||||||
|
"pluginEnable": "Aktifkan plugin",
|
||||||
"pluginDisable": "Nonaktifkan",
|
"pluginDisable": "Nonaktifkan",
|
||||||
"channelLabel": "Kanal",
|
"channelLabel": "Kanal",
|
||||||
"featureLabel": "Fitur",
|
"featureLabel": "Fitur",
|
||||||
|
|||||||
@@ -573,6 +573,7 @@
|
|||||||
"mcpLabel": "連携",
|
"mcpLabel": "連携",
|
||||||
"pluginLabel": "プラグイン",
|
"pluginLabel": "プラグイン",
|
||||||
"pluginEnabled": "プラグインは有効です",
|
"pluginEnabled": "プラグインは有効です",
|
||||||
|
"pluginEnable": "プラグインを有効にする",
|
||||||
"pluginDisable": "無効にする",
|
"pluginDisable": "無効にする",
|
||||||
"channelLabel": "チャンネル",
|
"channelLabel": "チャンネル",
|
||||||
"featureLabel": "機能",
|
"featureLabel": "機能",
|
||||||
|
|||||||
@@ -573,6 +573,7 @@
|
|||||||
"mcpLabel": "연동",
|
"mcpLabel": "연동",
|
||||||
"pluginLabel": "플러그인",
|
"pluginLabel": "플러그인",
|
||||||
"pluginEnabled": "플러그인 활성화됨",
|
"pluginEnabled": "플러그인 활성화됨",
|
||||||
|
"pluginEnable": "플러그인 활성화",
|
||||||
"pluginDisable": "비활성화",
|
"pluginDisable": "비활성화",
|
||||||
"channelLabel": "채널",
|
"channelLabel": "채널",
|
||||||
"featureLabel": "기능",
|
"featureLabel": "기능",
|
||||||
|
|||||||
@@ -587,6 +587,7 @@
|
|||||||
"mcpLabel": "Integração",
|
"mcpLabel": "Integração",
|
||||||
"pluginLabel": "Plugin",
|
"pluginLabel": "Plugin",
|
||||||
"pluginEnabled": "Plugin ativado",
|
"pluginEnabled": "Plugin ativado",
|
||||||
|
"pluginEnable": "Ativar plugin",
|
||||||
"pluginDisable": "Desativar",
|
"pluginDisable": "Desativar",
|
||||||
"channelLabel": "Canal",
|
"channelLabel": "Canal",
|
||||||
"featureLabel": "Recurso",
|
"featureLabel": "Recurso",
|
||||||
|
|||||||
@@ -573,6 +573,7 @@
|
|||||||
"mcpLabel": "Tích hợp",
|
"mcpLabel": "Tích hợp",
|
||||||
"pluginLabel": "Plugin",
|
"pluginLabel": "Plugin",
|
||||||
"pluginEnabled": "Plugin đã bật",
|
"pluginEnabled": "Plugin đã bật",
|
||||||
|
"pluginEnable": "Bật plugin",
|
||||||
"pluginDisable": "Tắt",
|
"pluginDisable": "Tắt",
|
||||||
"channelLabel": "Kênh",
|
"channelLabel": "Kênh",
|
||||||
"featureLabel": "Tính năng",
|
"featureLabel": "Tính năng",
|
||||||
|
|||||||
@@ -587,6 +587,7 @@
|
|||||||
"mcpLabel": "集成",
|
"mcpLabel": "集成",
|
||||||
"pluginLabel": "插件",
|
"pluginLabel": "插件",
|
||||||
"pluginEnabled": "插件已启用",
|
"pluginEnabled": "插件已启用",
|
||||||
|
"pluginEnable": "启用插件",
|
||||||
"pluginDisable": "停用",
|
"pluginDisable": "停用",
|
||||||
"channelLabel": "渠道",
|
"channelLabel": "渠道",
|
||||||
"featureLabel": "能力",
|
"featureLabel": "能力",
|
||||||
|
|||||||
@@ -573,6 +573,7 @@
|
|||||||
"mcpLabel": "整合",
|
"mcpLabel": "整合",
|
||||||
"pluginLabel": "外掛",
|
"pluginLabel": "外掛",
|
||||||
"pluginEnabled": "外掛已啟用",
|
"pluginEnabled": "外掛已啟用",
|
||||||
|
"pluginEnable": "啟用外掛",
|
||||||
"pluginDisable": "停用",
|
"pluginDisable": "停用",
|
||||||
"channelLabel": "通訊管道",
|
"channelLabel": "通訊管道",
|
||||||
"featureLabel": "功能",
|
"featureLabel": "功能",
|
||||||
|
|||||||
@@ -703,7 +703,7 @@ export async function fetchProviderModels(
|
|||||||
|
|
||||||
export async function runMcpPresetAction(
|
export async function runMcpPresetAction(
|
||||||
transport: WebUIMutationTransport,
|
transport: WebUIMutationTransport,
|
||||||
action: "enable" | "remove" | "test",
|
action: "enable" | "disable" | "remove" | "test",
|
||||||
name: string,
|
name: string,
|
||||||
values: Record<string, string> = {},
|
values: Record<string, string> = {},
|
||||||
): Promise<McpPresetsPayload> {
|
): Promise<McpPresetsPayload> {
|
||||||
|
|||||||
@@ -961,6 +961,7 @@ export interface McpPresetInfo {
|
|||||||
install_supported: boolean;
|
install_supported: boolean;
|
||||||
installed: boolean;
|
installed: boolean;
|
||||||
configured: boolean;
|
configured: boolean;
|
||||||
|
enabled?: boolean;
|
||||||
available: boolean;
|
available: boolean;
|
||||||
status: "not_installed" | "configured" | "missing_credentials" | "missing_dependency" | "coming_soon" | string;
|
status: "not_installed" | "configured" | "missing_credentials" | "missing_dependency" | "coming_soon" | string;
|
||||||
logo_url?: string | null;
|
logo_url?: string | null;
|
||||||
|
|||||||
@@ -882,6 +882,13 @@ describe("webui API helpers", () => {
|
|||||||
{ name: "browserbase", browserbase_api_key: "bb_live_test" },
|
{ name: "browserbase", browserbase_api_key: "bb_live_test" },
|
||||||
20_000,
|
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 () => {
|
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();
|
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 = {
|
const plugin = {
|
||||||
name: "plugin-computer-use",
|
name: "plugin-computer-use",
|
||||||
display_name: "Computer Use",
|
display_name: "Computer Use",
|
||||||
@@ -662,11 +662,12 @@ describe("SettingsView Apps catalog", () => {
|
|||||||
transport: "stdio",
|
transport: "stdio",
|
||||||
requires: "screen-recording, accessibility",
|
requires: "screen-recording, accessibility",
|
||||||
note: "",
|
note: "",
|
||||||
install_supported: true,
|
install_supported: false,
|
||||||
installed: true,
|
installed: true,
|
||||||
configured: false,
|
configured: true,
|
||||||
|
enabled: false,
|
||||||
available: false,
|
available: false,
|
||||||
status: "not_installed",
|
status: "disabled",
|
||||||
logo_url: null,
|
logo_url: null,
|
||||||
brand_color: "#ff7a1a",
|
brand_color: "#ff7a1a",
|
||||||
required_fields: [],
|
required_fields: [],
|
||||||
@@ -682,16 +683,20 @@ describe("SettingsView Apps catalog", () => {
|
|||||||
if (url === "/api/settings/mcp-presets") {
|
if (url === "/api/settings/mcp-presets") {
|
||||||
return jsonResponse({ presets: [plugin], installed_count: 0 });
|
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({});
|
return jsonResponse({});
|
||||||
});
|
});
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
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();
|
renderSettingsView();
|
||||||
|
|
||||||
@@ -700,16 +705,30 @@ describe("SettingsView Apps catalog", () => {
|
|||||||
expect(
|
expect(
|
||||||
screen.getByText("Control the desktop with a live preview. · screen-recording, accessibility"),
|
screen.getByText("Control the desktop with a live preview. · screen-recording, accessibility"),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Enable" }));
|
fireEvent.click(screen.getByRole("button", { name: "Enable plugin" }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(fetchMock).toHaveBeenCalledWith(
|
expect(requestMutationMock).toHaveBeenCalledWith(
|
||||||
"/api/settings/mcp-presets/enable?name=plugin-computer-use",
|
"settings.mcp.enable",
|
||||||
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
|
{ name: "plugin-computer-use" },
|
||||||
|
20_000,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
expect(await screen.findByText("Computer Use enabled.")).toBeInTheDocument();
|
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 () => {
|
it("keeps runtime dependencies out of Apps and explains chat mentions", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user