diff --git a/.changeset/fn-7629-builtin-runtime-disable.md b/.changeset/fn-7629-builtin-runtime-disable.md new file mode 100644 index 0000000000..31c2022a32 --- /dev/null +++ b/.changeset/fn-7629-builtin-runtime-disable.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Built-in runtime plugins (Hermes, Paperclip, OpenClaw, Droid) can now be disabled and stay disabled across restarts. +category: feature +dev: renderBuiltinPluginSection now renders a durable enable/disable toggle for runtime built-ins independent of installed status, replacing the dead-end "Built-in metadata only" CTA for the not-installed / activated-without-record case. Chosen persistence path: on disable, a not-yet-installed built-in runtime is first registered via the existing installPlugin path (mirroring the CLI's ensureBundledPluginInstalled lazy-install), then disablePlugin is called immediately so a plugin_installs row + project state exists with enabled=false — no new persistence primitive needed since loadAllPlugins/loadPlugin already skip disabled plugins and recordActivationEvent only fires on actual load, so a disabled runtime is never re-activated on restart. HermesRuntimeCard/OpenClawRuntimeCard/PaperclipRuntimeCard now reflect the Plugin Manager disabled state ("Disabled in Plugin Manager") instead of showing a stale detected/connected status. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index f930b1cb0d..57f5033a68 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -1157,6 +1157,7 @@ Features: - Enable/disable plugins, reload active plugins, and uninstall plugins - Inspect plugin runtime state and transition feedback - Edit and save plugin-defined settings schemas from the same panel +- Built-in runtime plugins (Hermes, Paperclip, OpenClaw, Droid) always expose an interactive enable/disable toggle in the Built-in Plugins list, even before install. Disabling one registers it and disables it in the same action, and the decision survives restarts — a disabled runtime is not re-activated on the next startup. The Runtimes settings cards mirror this state instead of showing a stale detected/connected status. For full plugin lifecycle workflows (discovery, install, enable/disable, configure, update, uninstall, troubleshooting), see [Plugin Management](./plugin-management.md). For plugin-related settings and experimental toggles, see [Settings reference](./settings-reference.md). diff --git a/docs/plugin-management.md b/docs/plugin-management.md index 3b86d58c77..4c1eb75053 100644 --- a/docs/plugin-management.md +++ b/docs/plugin-management.md @@ -133,6 +133,10 @@ fn plugin disable Expected outcome: Plugin is enabled or disabled by ID. +### Built-in runtime plugins (Hermes, Paperclip, OpenClaw, Droid) + +Built-in runtime plugins always expose an interactive enable/disable toggle in **Built-in Plugins**, even before the plugin has been explicitly installed (no `plugin_installs` record yet). Disabling a not-yet-installed runtime registers it (mirroring the same lazy-install path used elsewhere) and then disables it in one step, so the decision persists. A user-disabled built-in runtime is never silently re-enabled or re-activated by Fusion on the next restart — startup auto-activation only loads plugins whose project state is enabled. The **Runtimes** settings cards (Hermes/OpenClaw/Paperclip) reflect this state and show "Disabled in Plugin Manager" instead of a stale detected/connected status when the runtime has been turned off. + ## 5) Configure plugin settings 1. Go to **Settings → Plugins → Fusion Plugins**. diff --git a/packages/core/src/__tests__/plugin-loader.test.ts b/packages/core/src/__tests__/plugin-loader.test.ts index 65acd2e68e..02b5b0feb3 100644 --- a/packages/core/src/__tests__/plugin-loader.test.ts +++ b/packages/core/src/__tests__/plugin-loader.test.ts @@ -923,6 +923,52 @@ export default plugin; expect(loader.isPluginLoaded("disabled-plugin")).toBe(false); }); + /* + * FNXC:PluginLoader 2026-07-07-00:00: + * FN-7629 — regression coverage for the built-in runtime disable durability invariant. + * Symptom: with no plugin_installs row, a built-in runtime (e.g. Hermes) could not be + * disabled from the dashboard, and once registered+enabled it re-activated on every restart. + * The register-then-disable UI path (installPlugin + disablePlugin) relies on this store/loader + * contract: once a plugin's project state is enabled=false, loadAllPlugins must skip it on + * every subsequent load pass (i.e. every process restart) and must never record an activation + * event for it, even though the plugin_installs row itself persists untouched. + */ + it("keeps a user-disabled runtime built-in skipped and unactivated across repeated loadAllPlugins passes (restart simulation)", async () => { + await pluginStore.init(); + + const pluginDir = join(rootDir, "plugins"); + const runtimePlugin = makePlugin(makeManifest({ id: "fusion-plugin-fake-runtime" })); + const runtimePath = await writePluginModule(pluginDir, "fake-runtime.js", runtimePlugin); + + // Mirrors the UI's durable-disable path: register (defaults to enabled=true, + // same as installPlugin/registerPlugin), then immediately disable. + await pluginStore.registerPlugin({ manifest: runtimePlugin.manifest, path: runtimePath }); + await pluginStore.disablePlugin("fusion-plugin-fake-runtime"); + + const firstLoader = new PluginLoader({ pluginStore, taskStore: mockTaskStore }); + const firstResult = await firstLoader.loadAllPlugins(); + + expect(firstResult).toEqual({ loaded: 0, errors: 0 }); + expect(firstLoader.isPluginLoaded("fusion-plugin-fake-runtime")).toBe(false); + expect(mockTaskStore.recordPluginActivation).not.toHaveBeenCalledWith( + expect.objectContaining({ pluginId: "fusion-plugin-fake-runtime" }), + ); + + // Simulate a second restart with a brand-new PluginLoader instance against the + // same persisted store — the disabled decision must still be honored. + mockTaskStore.recordPluginActivation.mockClear(); + const secondLoader = new PluginLoader({ pluginStore, taskStore: mockTaskStore }); + const secondResult = await secondLoader.loadAllPlugins(); + + expect(secondResult).toEqual({ loaded: 0, errors: 0 }); + expect(secondLoader.isPluginLoaded("fusion-plugin-fake-runtime")).toBe(false); + expect(mockTaskStore.recordPluginActivation).not.toHaveBeenCalled(); + + // The install row itself must still exist and remain disabled (durable, not deleted). + const stored = await pluginStore.getPlugin("fusion-plugin-fake-runtime"); + expect(stored.enabled).toBe(false); + }); + it("returns error count for failed plugins", async () => { await pluginStore.init(); diff --git a/packages/dashboard/app/components/HermesRuntimeCard.tsx b/packages/dashboard/app/components/HermesRuntimeCard.tsx index bc13a0bb85..8804d52480 100644 --- a/packages/dashboard/app/components/HermesRuntimeCard.tsx +++ b/packages/dashboard/app/components/HermesRuntimeCard.tsx @@ -4,6 +4,7 @@ import { fetchHermesProfiles, fetchHermesStatus, fetchPluginSettings, + fetchPlugins, updatePluginSettings, type HermesProfileSummary, type HermesProviderStatus, @@ -72,6 +73,14 @@ export function HermesRuntimeCard() { const [profiles, setProfiles] = useState([]); const [busy, setBusy] = useState<"loading" | "saving" | "testing" | "save-test" | null>(null); const [toast, setToast] = useState<{ kind: "ok" | "err"; message: string } | null>(null); + /* + * FNXC:PluginManager 2026-07-07-00:00: + * FN-7629 — Plugin Manager is the source of truth for the runtime's enable/disable decision. + * This card must not claim Hermes is active/detected when the user has disabled it there, so + * mirror the installed project-state (not the local settings form) and override the status + * badge when disabled. + */ + const [runtimeDisabled, setRuntimeDisabled] = useState(false); const mountedRef = useRef(true); useEffect(() => { @@ -81,6 +90,20 @@ export function HermesRuntimeCard() { }; }, []); + useEffect(() => { + let cancelled = false; + fetchPlugins() + .then((list) => { + if (cancelled) return; + const installed = list.find((p) => p.id === PLUGIN_ID); + setRuntimeDisabled(installed ? !installed.enabled : false); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, []); + useEffect(() => { setBusy("loading"); fetchPluginSettings(PLUGIN_ID) @@ -198,13 +221,16 @@ export function HermesRuntimeCard() { }, [buildPayload, probe, t]); const binary = status?.binary; - const statusKind = status === null - ? "loading" - : binary?.available - ? "ok" - : "err"; - const statusText = - status === null + const statusKind = runtimeDisabled + ? "neutral" + : status === null + ? "loading" + : binary?.available + ? "ok" + : "err"; + const statusText = runtimeDisabled + ? t("hermes.statusDisabledInPluginManager", "Disabled in Plugin Manager") + : status === null ? t("hermes.probing", "Probing local hermes binary…") : binary?.available ? t("hermes.statusDetected", "✓ Detected{{version}}{{path}}", { version: binary.version ? ` ${binary.version}` : "", path: binary.binaryPath ? ` · ${binary.binaryPath}` : "" }) diff --git a/packages/dashboard/app/components/OpenClawRuntimeCard.tsx b/packages/dashboard/app/components/OpenClawRuntimeCard.tsx index 1180db553d..3e366913ca 100644 --- a/packages/dashboard/app/components/OpenClawRuntimeCard.tsx +++ b/packages/dashboard/app/components/OpenClawRuntimeCard.tsx @@ -3,6 +3,7 @@ import { useTranslation, Trans } from "react-i18next"; import { fetchOpenClawStatus, fetchPluginSettings, + fetchPlugins, updatePluginSettings, type OpenClawProviderStatus, } from "../api"; @@ -83,6 +84,13 @@ export function OpenClawRuntimeCard() { const [status, setStatus] = useState(null); const [busy, setBusy] = useState<"loading" | "saving" | "testing" | "save-test" | null>(null); const [toast, setToast] = useState<{ kind: "ok" | "err"; message: string } | null>(null); + /* + * FNXC:PluginManager 2026-07-07-00:00: + * FN-7629 — Plugin Manager is the source of truth for the runtime's enable/disable decision. + * Mirror the installed project-state so this card never claims OpenClaw is active/detected when + * the user has disabled it there. + */ + const [runtimeDisabled, setRuntimeDisabled] = useState(false); const mountedRef = useRef(true); useEffect(() => { @@ -92,6 +100,20 @@ export function OpenClawRuntimeCard() { }; }, []); + useEffect(() => { + let cancelled = false; + fetchPlugins() + .then((list) => { + if (cancelled) return; + const installed = list.find((p) => p.id === PLUGIN_ID); + setRuntimeDisabled(installed ? !installed.enabled : false); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, []); + // Load saved settings on mount. useEffect(() => { setBusy("loading"); @@ -201,10 +223,12 @@ export function OpenClawRuntimeCard() { }, [buildPayload, probe, t]); const binary = status?.binary; - const statusKind = - status === null ? "loading" : binary?.available ? "ok" : "err"; - const statusText = - status === null + const statusKind = runtimeDisabled + ? "neutral" + : status === null ? "loading" : binary?.available ? "ok" : "err"; + const statusText = runtimeDisabled + ? t("openclaw.statusDisabledInPluginManager", "Disabled in Plugin Manager") + : status === null ? t("openclaw.probing", "Probing local openclaw binary…") : binary?.available ? t("openclaw.statusDetected", `✓ Detected{{version}}{{path}}`, { version: binary.version ? ` ${binary.version}` : "", path: binary.binaryPath ? ` · ${binary.binaryPath}` : "" }) diff --git a/packages/dashboard/app/components/PaperclipRuntimeCard.tsx b/packages/dashboard/app/components/PaperclipRuntimeCard.tsx index d523c71f51..1d673ad088 100644 --- a/packages/dashboard/app/components/PaperclipRuntimeCard.tsx +++ b/packages/dashboard/app/components/PaperclipRuntimeCard.tsx @@ -23,6 +23,7 @@ import { fetchPaperclipCompanies, fetchPaperclipStatus, fetchPluginSettings, + fetchPlugins, mintPaperclipApiKey, updatePluginSettings, type PaperclipAgentSummary, @@ -132,6 +133,13 @@ export function PaperclipRuntimeCard() { >(null); const [toast, setToast] = useState<{ kind: "ok" | "err"; message: string } | null>(null); const [apiKeyDirty, setApiKeyDirty] = useState(false); + /* + * FNXC:PluginManager 2026-07-07-00:00: + * FN-7629 — Plugin Manager is the source of truth for the runtime's enable/disable decision. + * Mirror the installed project-state so this card never claims Paperclip is connected/active + * when the user has disabled it there. + */ + const [runtimeDisabled, setRuntimeDisabled] = useState(false); const mountedRef = useRef(true); useEffect(() => { @@ -141,6 +149,20 @@ export function PaperclipRuntimeCard() { }; }, []); + useEffect(() => { + let cancelled = false; + fetchPlugins() + .then((list) => { + if (cancelled) return; + const installed = list.find((p) => p.id === PLUGIN_ID); + setRuntimeDisabled(installed ? !installed.enabled : false); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, []); + // The "effective" apiUrl/apiKey used for status + dropdowns. // In CLI mode we prefer whatever cliDiscovery returned, falling back to the // typed apiUrl if discovery hasn't completed yet. @@ -411,15 +433,17 @@ export function PaperclipRuntimeCard() { const identity = status?.connection.identity; const cliOk = cliDiscovery?.ok === true; - const statusKind = - status === null + const statusKind = runtimeDisabled + ? "neutral" + : status === null ? "loading" : connected ? "ok" : "err"; - const statusText = - status === null + const statusText = runtimeDisabled + ? t("paperclip.statusDisabledInPluginManager", "Disabled in Plugin Manager") + : status === null ? settings.transport === "cli" && cliDiscovery && !cliOk ? t("paperclip.statusCliDiscoveryFailed", "✗ CLI discovery failed: {{reason}}", { reason: cliDiscovery.reason }) : t("paperclip.statusProbing", "Probing Paperclip server…") diff --git a/packages/dashboard/app/components/PluginManager.css b/packages/dashboard/app/components/PluginManager.css index 7bf8ee3824..a4cbdbde8a 100644 --- a/packages/dashboard/app/components/PluginManager.css +++ b/packages/dashboard/app/components/PluginManager.css @@ -480,6 +480,20 @@ text-transform: uppercase; } +/* + * FNXC:PluginManager 2026-07-07-00:00: + * FN-7629 — runtime built-ins (Hermes/Paperclip/OpenClaw/Droid) must always expose a durable + * enable/disable control alongside the existing Install/Manage affordance, on both desktop and + * mobile, and never dead-end at a static label. Wrap the toggle + action button so both share the + * same alignment as the rest of the built-in row. + */ +.plugin-builtins-actions { + display: flex; + align-items: center; + gap: var(--space-sm); + flex-shrink: 0; +} + .plugin-registry-section { display: flex; flex-direction: column; @@ -831,6 +845,26 @@ align-items: stretch; } + /* + * FNXC:PluginManager 2026-07-07-00:00: + * FN-7629 — keep the built-in runtime toggle at a 36x36 tap target on mobile, matching the + * installed-list `.plugin-actions .toggle-switch` convention, and let the actions row wrap + * below the metadata instead of squeezing the toggle + button onto one line. + */ + .plugin-builtins-actions { + width: 100%; + justify-content: flex-end; + flex-wrap: wrap; + } + + .plugin-builtins-actions .toggle-switch { + min-width: 36px; + min-height: 36px; + display: inline-flex; + align-items: center; + justify-content: center; + } + .plugin-registry-header { flex-direction: column; align-items: stretch; diff --git a/packages/dashboard/app/components/PluginManager.tsx b/packages/dashboard/app/components/PluginManager.tsx index 5061d0fffa..ae25328ec3 100644 --- a/packages/dashboard/app/components/PluginManager.tsx +++ b/packages/dashboard/app/components/PluginManager.tsx @@ -274,6 +274,14 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) { const [builtinSetupStatusById, setBuiltinSetupStatusById] = useState>({}); const [loadingBuiltinSetupId, setLoadingBuiltinSetupId] = useState(null); const [installingBuiltinSetupId, setInstallingBuiltinSetupId] = useState(null); + /* + * FNXC:PluginManager 2026-07-07-00:00: + * FN-7629 — built-in runtime plugins (Hermes/Paperclip/OpenClaw/Droid) must expose a durable + * enable/disable control even when no plugin_installs row exists yet ("activated-without-record"). + * Track in-flight toggles separately from install/setup so the toggle-switch can show a busy + * state without blocking the Install/Manage button. + */ + const [togglingBuiltinRuntimeId, setTogglingBuiltinRuntimeId] = useState(null); const { confirm } = useConfirm(); const loadPlugins = useCallback(async () => { @@ -565,6 +573,48 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) { } }; + /* + * FNXC:PluginManager 2026-07-07-00:00: + * FN-7629 — durable built-in runtime disable path. A built-in runtime + * (Hermes/Paperclip/OpenClaw/Droid) that has never been explicitly installed + * has no plugin_installs row, so enablePlugin/disablePlugin (which both call + * getPlugin -> ENOENT) cannot persist a decision for it. When the user wants + * to disable such a runtime, first register it via the existing install path + * (mirrors the CLI's ensureBundledPluginInstalled lazy-install) so a + * plugin_installs row + project state exists, then disable it immediately so + * the decision is durable: loadAllPlugins() only loads plugins where + * enabled=true, so a disabled runtime is never re-activated on restart. + * Already-installed runtimes just toggle through the normal enable/disable + * handlers, same as the installed-plugin list row. + */ + const handleToggleBuiltinRuntime = async (builtinPlugin: BuiltinPlugin, installedPlugin?: PluginInstallation) => { + if (installedPlugin) { + if (installedPlugin.enabled) { + await handleDisable(installedPlugin); + } else { + await handleEnable(installedPlugin); + } + return; + } + + if (!builtinPlugin.path) { + addToast(t("plugins.builtinNoPackage", "{{name}} is built in and does not have an installable package yet", { name: builtinPlugin.name }), "warning"); + return; + } + + try { + setTogglingBuiltinRuntimeId(builtinPlugin.id); + const registered = await installPlugin({ path: builtinPlugin.path }, projectId); + await disablePlugin(registered.id, projectId); + addToast(t("plugins.disabledForProject", "{{name}} disabled for this project", { name: builtinPlugin.name }), "success"); + await loadPlugins(); + } catch (err) { + addToast(t("plugins.disablePluginFailed", "Failed to disable plugin: {{error}}", { error: err instanceof Error ? err.message : String(err) }), "error"); + } finally { + setTogglingBuiltinRuntimeId(null); + } + }; + const handleReload = async (plugin: PluginInstallation) => { try { setReloadingPluginId(plugin.id); @@ -1073,6 +1123,16 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) { const setupReady = isInstalled && setupStatus?.hasSetup && pluginSetupState === "installed"; const setupCheckInFlight = loadingBuiltinSetupId === builtinPlugin.id; const metadataOnly = !builtinPlugin.path; + /* + * FNXC:PluginManager 2026-07-07-00:00: + * FN-7629 — runtime built-ins (Hermes/Paperclip/OpenClaw/Droid) must always expose an + * interactive enable/disable control, independent of install status, and must never + * dead-end at the static "Built-in metadata only" label. Non-runtime built-ins (e.g. + * Agent Browser) keep the existing metadata-only/install/manage affordances unchanged. + */ + const isRuntimeBuiltin = builtinPlugin.category === "runtime"; + const runtimeEnabled = installedPlugin ? installedPlugin.enabled : true; + const isTogglingRuntime = togglingBuiltinRuntimeId === builtinPlugin.id; return (
@@ -1083,6 +1143,9 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) { {isInstalled ? t("plugins.statusInstalled", "Installed") : metadataOnly ? t("plugins.statusBuiltIn", "Built in") : t("plugins.statusNotInstalled", "Not installed")} + {isRuntimeBuiltin && !runtimeEnabled && ( + {t("plugins.builtinDisabled", "Disabled")} + )} {requiresSetupAction && ( {t("plugins.setupRequired", "Setup required")} )} @@ -1097,53 +1160,69 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) { )} {builtinPlugin.description}
- {metadataOnly ? ( - isInstalled && requiresSetupAction ? ( - - ) : isInstalled && installedPlugin ? ( - +
+ {isRuntimeBuiltin && ( + + )} + {metadataOnly ? ( + isInstalled && requiresSetupAction ? ( + + ) : isInstalled && installedPlugin ? ( + + ) : isRuntimeBuiltin ? null : ( + {t("plugins.builtinMetadataOnly", "Built-in metadata only")} + ) ) : ( - {t("plugins.builtinMetadataOnly", "Built-in metadata only")} - ) - ) : ( - - )} + > + {!isInstalled + ? (installingBuiltinPluginId === builtinPlugin.id ? t("plugins.installing", "Installing...") : t("plugins.installNamed", "Install {{name}}", { name: builtinPlugin.name })) + : requiresSetupAction + ? (installingBuiltinSetupId === builtinPlugin.id ? t("plugins.settingUp", "Setting up...") : t("plugins.installSetup", "Install Setup")) + : t("plugins.manage", "Manage")} + + )} +
); })} diff --git a/packages/dashboard/app/components/__tests__/PluginManager.test.tsx b/packages/dashboard/app/components/__tests__/PluginManager.test.tsx index 2d274398d2..1274dc36bc 100644 --- a/packages/dashboard/app/components/__tests__/PluginManager.test.tsx +++ b/packages/dashboard/app/components/__tests__/PluginManager.test.tsx @@ -767,7 +767,7 @@ describe("PluginManager", () => { expect(screen.getByText("Test Plugin A")).toBeTruthy(); }); - const toggle = screen.getByRole("checkbox"); + const toggle = screen.getByRole("checkbox", { name: /Test Plugin A/ }); expect(toggle).toBeTruthy(); expect(toggle).not.toBeChecked(); @@ -796,7 +796,7 @@ describe("PluginManager", () => { expect(screen.getByText("Test Plugin A")).toBeTruthy(); }); - await userEvent.click(screen.getByRole("checkbox")); + await userEvent.click(screen.getByRole("checkbox", { name: /Test Plugin A/ })); await waitFor(() => { expect(addToast).toHaveBeenCalledWith( @@ -818,7 +818,7 @@ describe("PluginManager", () => { expect(screen.getByText("Test Plugin A")).toBeTruthy(); }); - await userEvent.click(screen.getByRole("checkbox")); + await userEvent.click(screen.getByRole("checkbox", { name: /Test Plugin A/ })); await waitFor(() => { expect(addToast).toHaveBeenCalledWith("Failed to enable plugin: network", "error"); @@ -835,7 +835,7 @@ describe("PluginManager", () => { expect(screen.getByText("Test Plugin A")).toBeTruthy(); }); - const toggle = screen.getByRole("checkbox"); + const toggle = screen.getByRole("checkbox", { name: /Test Plugin A/ }); expect(toggle).toBeTruthy(); expect(toggle).toBeChecked(); @@ -1150,7 +1150,7 @@ describe("PluginManager", () => { }); await waitFor(() => { - const toggle = screen.getByRole("checkbox"); + const toggle = screen.getByRole("checkbox", { name: /Test Plugin A/ }); expect(toggle).toBeChecked(); }); }); @@ -1183,7 +1183,7 @@ describe("PluginManager", () => { }); await waitFor(() => { - const toggle = screen.getByRole("checkbox"); + const toggle = screen.getByRole("checkbox", { name: /Test Plugin A/ }); expect(toggle).not.toBeChecked(); }); }); diff --git a/packages/dashboard/app/components/__tests__/PluginManager.toggle.test.tsx b/packages/dashboard/app/components/__tests__/PluginManager.toggle.test.tsx index 7888f3dc1f..25d4854f51 100644 --- a/packages/dashboard/app/components/__tests__/PluginManager.toggle.test.tsx +++ b/packages/dashboard/app/components/__tests__/PluginManager.toggle.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, waitFor, cleanup } from "@testing-library/react"; +import { render, screen, waitFor, cleanup, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { PluginManager } from "../PluginManager"; import { loadAllAppCss } from "../../test/cssFixture"; @@ -21,7 +21,8 @@ vi.mock("../../api", () => ({ browseDirectory: vi.fn(() => Promise.resolve({ currentPath: "/", parentPath: null, entries: [] })), })); -import { fetchPlugins, disablePlugin } from "../../api"; +import { fetchPlugins, disablePlugin, enablePlugin, installPlugin } from "../../api"; +import { BUILTIN_PLUGINS } from "../PluginManager"; const addToast = vi.fn(); @@ -71,6 +72,22 @@ afterEach(() => { vi.restoreAllMocks(); }); +function builtinPlugin(id: string, enabled: boolean) { + const builtin = BUILTIN_PLUGINS.find((p) => p.id === id)!; + return { + id: builtin.id, + name: builtin.name, + version: "1.0.0", + state: "started" as const, + enabled, + path: builtin.path ?? "/plugins/unknown", + settings: {}, + settingsSchema: {}, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }; +} + describe("PluginManager toggle switch", () => { it("keeps checkbox focusable but visually hidden", async () => { vi.mocked(fetchPlugins).mockResolvedValue([plugin(true)]); @@ -117,3 +134,86 @@ describe("PluginManager toggle switch", () => { expect(disabled.nextElementSibling).toHaveClass("toggle-slider"); }); }); + +/* + * FNXC:PluginManager 2026-07-07-00:00: + * FN-7629 — regression coverage for the durable built-in runtime enable/disable control. + * Symptom: with no plugin_installs row for Hermes/Paperclip/OpenClaw/Droid (fetchPlugins -> []), + * the built-in runtime rows dead-ended at a static "Built-in metadata only"/install-only CTA and + * offered no way to disable the runtime. Assert every runtime built-in exposes an interactive + * toggle in every data state, and clicking disable on a not-installed runtime persists via + * installPlugin + disablePlugin (the durable register-then-disable path). + */ +describe("PluginManager built-in runtime enable/disable toggle (FN-7629)", () => { + const RUNTIME_BUILTINS = BUILTIN_PLUGINS.filter((p) => p.category === "runtime"); + + async function builtinSection() { + return within(await screen.findByLabelText("Built-in plugin recommendations")); + } + + it("renders an interactive, checked toggle for every runtime built-in when not installed", async () => { + vi.mocked(fetchPlugins).mockResolvedValue([]); + + render(); + const section = await builtinSection(); + + for (const runtime of RUNTIME_BUILTINS) { + const toggle = await section.findByRole("checkbox", { name: `Disable ${runtime.name}` }); + expect(toggle).toBeChecked(); + expect(toggle.closest("label.toggle-switch")).not.toBeNull(); + } + + // No dead-end "Built-in metadata only" label for any runtime built-in row + // (the metadata-only dead-end may still legitimately appear for non-runtime + // built-ins like Agent Browser, which this task does not change). + for (const runtime of RUNTIME_BUILTINS) { + const row = section.getByText(runtime.name).closest(".plugin-builtins-item") as HTMLElement; + expect(within(row).queryByText("Built-in metadata only")).not.toBeInTheDocument(); + } + }); + + it("disabling a not-installed runtime built-in registers it then disables it (durable path)", async () => { + vi.mocked(fetchPlugins).mockResolvedValue([]); + vi.mocked(installPlugin).mockResolvedValue(builtinPlugin("fusion-plugin-hermes-runtime", true)); + + render(); + + const toggle = await (await builtinSection()).findByRole("checkbox", { name: "Disable Hermes Runtime" }); + await userEvent.click(toggle.closest("label.toggle-switch") as HTMLLabelElement); + + await waitFor(() => { + expect(installPlugin).toHaveBeenCalledWith({ path: "./plugins/fusion-plugin-hermes-runtime" }, undefined); + }); + await waitFor(() => { + expect(disablePlugin).toHaveBeenCalledWith("fusion-plugin-hermes-runtime", undefined); + }); + }); + + it("toggles an installed, enabled runtime built-in via the standard disable path (no re-install)", async () => { + vi.mocked(fetchPlugins).mockResolvedValue([builtinPlugin("fusion-plugin-paperclip-runtime", true)]); + + render(); + + const toggle = await (await builtinSection()).findByRole("checkbox", { name: "Disable Paperclip Runtime" }); + await userEvent.click(toggle.closest("label.toggle-switch") as HTMLLabelElement); + + await waitFor(() => { + expect(disablePlugin).toHaveBeenCalledWith("fusion-plugin-paperclip-runtime", undefined); + }); + expect(installPlugin).not.toHaveBeenCalled(); + }); + + it("toggles an installed, disabled runtime built-in back on via the standard enable path", async () => { + vi.mocked(fetchPlugins).mockResolvedValue([builtinPlugin("fusion-plugin-openclaw-runtime", false)]); + + render(); + + const toggle = await (await builtinSection()).findByRole("checkbox", { name: "Enable OpenClaw Runtime" }); + expect(toggle).not.toBeChecked(); + await userEvent.click(toggle.closest("label.toggle-switch") as HTMLLabelElement); + + await waitFor(() => { + expect(enablePlugin).toHaveBeenCalledWith("fusion-plugin-openclaw-runtime", undefined); + }); + }); +}); diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index 8edc2e5be9..440fadba5c 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -3039,6 +3039,7 @@ "savedProbeFailed": "Saved, but probe failed.", "settingsSaved": "Settings saved.", "statusDetected": "✓ Detected{{version}}{{path}}", + "statusDisabledInPluginManager": "Disabled in Plugin Manager", "statusNotDetected": "✗ {{reason}}", "subname": "by Nous Research", "testFailed": "Test failed — see status above.", @@ -4401,6 +4402,7 @@ "savedNotFound": "Saved · ✗ openclaw not found", "savedProbeFailed": "Saved, but probe failed.", "statusDetected": "✓ Detected{{version}}{{path}}", + "statusDisabledInPluginManager": "Disabled in Plugin Manager", "statusNotFound": "✗ not detected on PATH", "subprocess": "CLI subprocess timeout (ms)", "subprocessHint": "Fusion-side hard cap before the CLI subprocess is killed (default: {{default}}s).", @@ -4464,6 +4466,7 @@ "statusCliDiscoveryFailed": "CLI discovery failed: {{reason}}", "statusConnected": "Connected", "statusConnectedAs": "Connected as {{agentName}}{{roleInfo}}{{companyInfo}}", + "statusDisabledInPluginManager": "Disabled in Plugin Manager", "statusProbing": "Probing Paperclip server…", "statusUnreachable": "{{reason}}", "tabApi": "API (URL + token)", @@ -4660,6 +4663,7 @@ "author": "Author:", "backToList": "Back to plugin list", "browseRegistry": "Browse Registry", + "builtinDisabled": "Disabled", "builtinInstalledGlobally": "{{name}} installed globally", "builtinInstallFailed": "Failed to install {{name}}: {{error}}", "builtinMetadataOnly": "Built-in metadata only", diff --git a/packages/i18n/locales/es/app.json b/packages/i18n/locales/es/app.json index 0d267e2222..ec872b4933 100644 --- a/packages/i18n/locales/es/app.json +++ b/packages/i18n/locales/es/app.json @@ -3029,6 +3029,7 @@ "savedProbeFailed": "Guardado, pero la prueba falló.", "settingsSaved": "Configuración guardada.", "statusDetected": "✓ Detectado{{version}}{{path}}", + "statusDisabledInPluginManager": "Deshabilitado en el gestor de complementos", "statusNotDetected": "✗ {{reason}}", "subname": "por Nous Research", "testFailed": "La prueba falló — consulte el estado anterior.", @@ -4391,6 +4392,7 @@ "savedNotFound": "Guardado · ✗ openclaw no encontrado", "savedProbeFailed": "Guardado, pero la prueba falló.", "statusDetected": "✓ Detectado{{version}}{{path}}", + "statusDisabledInPluginManager": "Deshabilitado en el gestor de complementos", "statusNotFound": "✗ no detectado en PATH", "subprocess": "Tiempo de espera del subproceso CLI (ms)", "subprocessHint": "Límite de Fusion antes de que se mate el subproceso CLI (predeterminado: {{default}}s).", @@ -4454,6 +4456,7 @@ "statusCliDiscoveryFailed": "Error al descubrir CLI: {{reason}}", "statusConnected": "Conectado", "statusConnectedAs": "Conectado como {{agentName}}{{roleInfo}}{{companyInfo}}", + "statusDisabledInPluginManager": "Deshabilitado en el gestor de complementos", "statusProbing": "Sondeando servidor de Paperclip…", "statusUnreachable": "{{reason}}", "tabApi": "API (URL + token)", @@ -4650,6 +4653,7 @@ "author": "Autor:", "backToList": "Volver a la lista de plugins", "browseRegistry": "", + "builtinDisabled": "Deshabilitado", "builtinInstalledGlobally": "{{name}} instalado globalmente", "builtinInstallFailed": "Error al instalar {{name}}: {{error}}", "builtinMetadataOnly": "Solo metadatos integrados", diff --git a/packages/i18n/locales/fr/app.json b/packages/i18n/locales/fr/app.json index fb79ee6e70..d942a960f1 100644 --- a/packages/i18n/locales/fr/app.json +++ b/packages/i18n/locales/fr/app.json @@ -3029,6 +3029,7 @@ "savedProbeFailed": "Enregistré, mais la sonde a échoué.", "settingsSaved": "Paramètres enregistrés.", "statusDetected": "✓ Détecté{{version}}{{path}}", + "statusDisabledInPluginManager": "Désactivé dans le gestionnaire de plugins", "statusNotDetected": "✗ {{reason}}", "subname": "par Nous Research", "testFailed": "Le test a échoué — voir le statut ci-dessus.", @@ -4391,6 +4392,7 @@ "savedNotFound": "Enregistré · ✗ openclaw non trouvé", "savedProbeFailed": "Enregistré, mais la sonde a échoué.", "statusDetected": "✓ Détecté{{version}}{{path}}", + "statusDisabledInPluginManager": "Désactivé dans le gestionnaire de plugins", "statusNotFound": "✗ non détecté sur PATH", "subprocess": "Délai d'expiration du sous-processus CLI (ms)", "subprocessHint": "Limite côté Fusion avant la suppression du sous-processus CLI (par défaut: {{default}}s).", @@ -4454,6 +4456,7 @@ "statusCliDiscoveryFailed": "Échec de la découverte CLI : {{reason}}", "statusConnected": "Connecté", "statusConnectedAs": "Connecté en tant que {{agentName}}{{roleInfo}}{{companyInfo}}", + "statusDisabledInPluginManager": "Désactivé dans le gestionnaire de plugins", "statusProbing": "Sondage du serveur Paperclip…", "statusUnreachable": "{{reason}}", "tabApi": "API (URL + jeton)", @@ -4650,6 +4653,7 @@ "author": "Auteur :", "backToList": "Retour à la liste des plugins", "browseRegistry": "", + "builtinDisabled": "Désactivé", "builtinInstalledGlobally": "{{name}} installé globalement", "builtinInstallFailed": "Échec de l'installation de {{name}} : {{error}}", "builtinMetadataOnly": "Métadonnées intégrées uniquement", diff --git a/packages/i18n/locales/ko/app.json b/packages/i18n/locales/ko/app.json index 476df6d1e0..d24159fb82 100644 --- a/packages/i18n/locales/ko/app.json +++ b/packages/i18n/locales/ko/app.json @@ -3029,6 +3029,7 @@ "savedProbeFailed": "저장됨, 하지만 탐색에 실패했습니다.", "settingsSaved": "설정이 저장되었습니다.", "statusDetected": "✓ 감지됨{{version}}{{path}}", + "statusDisabledInPluginManager": "플러그인 관리자에서 비활성화됨", "statusNotDetected": "✗ {{reason}}", "subname": "by Nous Research", "testFailed": "테스트 실패 — 위의 상태를 확인하세요.", @@ -4391,6 +4392,7 @@ "savedNotFound": "저장됨 · ✗ openclaw를 찾을 수 없음", "savedProbeFailed": "저장되었지만, 확인에 실패했습니다.", "statusDetected": "✓ 감지됨{{version}}{{path}}", + "statusDisabledInPluginManager": "플러그인 관리자에서 비활성화됨", "statusNotFound": "✗ PATH에서 감지되지 않음", "subprocess": "CLI 서브프로세스 타임아웃 (ms)", "subprocessHint": "CLI 서브프로세스가 종료되기 전 Fusion 측 최대 대기 시간 (기본값: {{default}}s).", @@ -4454,6 +4456,7 @@ "statusCliDiscoveryFailed": "CLI 검색에 실패했습니다: {{reason}}", "statusConnected": "연결됨", "statusConnectedAs": "{{agentName}}{{roleInfo}}{{companyInfo}}(으)로 연결됨", + "statusDisabledInPluginManager": "플러그인 관리자에서 비활성화됨", "statusProbing": "Paperclip 서버 확인 중…", "statusUnreachable": "{{reason}}", "tabApi": "API (URL + 토큰)", @@ -4650,6 +4653,7 @@ "author": "작성자:", "backToList": "플러그인 목록으로 돌아가기", "browseRegistry": "", + "builtinDisabled": "비활성화됨", "builtinInstalledGlobally": "{{name}} 전역 설치됨", "builtinInstallFailed": "{{name}} 설치 실패: {{error}}", "builtinMetadataOnly": "기본 제공 메타데이터만", diff --git a/packages/i18n/locales/zh-CN/app.json b/packages/i18n/locales/zh-CN/app.json index 2f20fc338f..a3caa46a41 100644 --- a/packages/i18n/locales/zh-CN/app.json +++ b/packages/i18n/locales/zh-CN/app.json @@ -3029,6 +3029,7 @@ "savedProbeFailed": "已保存,但探测失败。", "settingsSaved": "设置已保存。", "statusDetected": "✓ 已检测{{version}}{{path}}", + "statusDisabledInPluginManager": "已在插件管理器中禁用", "statusNotDetected": "✗ {{reason}}", "subname": "由 Nous Research 提供", "testFailed": "测试失败 — 见上方状态。", @@ -4391,6 +4392,7 @@ "savedNotFound": "已保存 · ✗ openclaw 未找到", "savedProbeFailed": "已保存,但探测失败。", "statusDetected": "✓ 已检测到{{version}}{{path}}", + "statusDisabledInPluginManager": "已在插件管理器中禁用", "statusNotFound": "✗ 在 PATH 上未检测到", "subprocess": "CLI 子进程超时(毫秒)", "subprocessHint": "Fusion 端限制,超时后 CLI 子进程被终止(默认:{{default}}s)。", @@ -4454,6 +4456,7 @@ "statusCliDiscoveryFailed": "CLI 发现失败:{{reason}}", "statusConnected": "已连接", "statusConnectedAs": "已连接为 {{agentName}}{{roleInfo}}{{companyInfo}}", + "statusDisabledInPluginManager": "已在插件管理器中禁用", "statusProbing": "正在探测 Paperclip 服务器…", "statusUnreachable": "{{reason}}", "tabApi": "API(URL + 令牌)", @@ -4650,6 +4653,7 @@ "author": "作者:", "backToList": "返回插件列表", "browseRegistry": "", + "builtinDisabled": "已禁用", "builtinInstalledGlobally": "{{name}} 已全局安装", "builtinInstallFailed": "安装 {{name}} 失败:{{error}}", "builtinMetadataOnly": "仅内置元数据", diff --git a/packages/i18n/locales/zh-TW/app.json b/packages/i18n/locales/zh-TW/app.json index 3f1ed69a98..5115139fba 100644 --- a/packages/i18n/locales/zh-TW/app.json +++ b/packages/i18n/locales/zh-TW/app.json @@ -3029,6 +3029,7 @@ "savedProbeFailed": "已保存,但探測失敗。", "settingsSaved": "設置已保存。", "statusDetected": "✓ 已偵測{{version}}{{path}}", + "statusDisabledInPluginManager": "已在外掛程式管理器中停用", "statusNotDetected": "✗ {{reason}}", "subname": "由 Nous Research 提供", "testFailed": "測試失敗 — 見上方狀態。", @@ -4391,6 +4392,7 @@ "savedNotFound": "已保存 · ✗ openclaw 未找到", "savedProbeFailed": "已保存,但探測失敗。", "statusDetected": "✓ 已偵測到{{version}}{{path}}", + "statusDisabledInPluginManager": "已在外掛程式管理器中停用", "statusNotFound": "✗ 在 PATH 上未偵測到", "subprocess": "CLI 子進程逾時(毫秒)", "subprocessHint": "Fusion 端限制,逾時後 CLI 子進程被終止(預設:{{default}}s)。", @@ -4454,6 +4456,7 @@ "statusCliDiscoveryFailed": "CLI 發現失敗:{{reason}}", "statusConnected": "已連線", "statusConnectedAs": "已連線為 {{agentName}}{{roleInfo}}{{companyInfo}}", + "statusDisabledInPluginManager": "已在外掛程式管理器中停用", "statusProbing": "正在探測 Paperclip 伺服器…", "statusUnreachable": "{{reason}}", "tabApi": "API(URL + 令牌)", @@ -4650,6 +4653,7 @@ "author": "作者:", "backToList": "返回插件列表", "browseRegistry": "", + "builtinDisabled": "已停用", "builtinInstalledGlobally": "{{name}} 已全域安裝", "builtinInstallFailed": "安裝 {{name}} 失敗:{{error}}", "builtinMetadataOnly": "僅內建中繼資料", diff --git a/packages/i18n/src/resources.d.ts b/packages/i18n/src/resources.d.ts index d2b638fc01..ece06164a6 100644 --- a/packages/i18n/src/resources.d.ts +++ b/packages/i18n/src/resources.d.ts @@ -3040,6 +3040,7 @@ export default interface Resources { "savedProbeFailed": "Saved, but probe failed.", "settingsSaved": "Settings saved.", "statusDetected": "✓ Detected{{version}}{{path}}", + "statusDisabledInPluginManager": "Disabled in Plugin Manager", "statusNotDetected": "✗ {{reason}}", "subname": "by Nous Research", "testFailed": "Test failed — see status above.", @@ -4402,6 +4403,7 @@ export default interface Resources { "savedNotFound": "Saved · ✗ openclaw not found", "savedProbeFailed": "Saved, but probe failed.", "statusDetected": "✓ Detected{{version}}{{path}}", + "statusDisabledInPluginManager": "Disabled in Plugin Manager", "statusNotFound": "✗ not detected on PATH", "subprocess": "CLI subprocess timeout (ms)", "subprocessHint": "Fusion-side hard cap before the CLI subprocess is killed (default: {{default}}s).", @@ -4465,6 +4467,7 @@ export default interface Resources { "statusCliDiscoveryFailed": "CLI discovery failed: {{reason}}", "statusConnected": "Connected", "statusConnectedAs": "Connected as {{agentName}}{{roleInfo}}{{companyInfo}}", + "statusDisabledInPluginManager": "Disabled in Plugin Manager", "statusProbing": "Probing Paperclip server…", "statusUnreachable": "{{reason}}", "tabApi": "API (URL + token)", @@ -4662,6 +4665,7 @@ export default interface Resources { "backToList": "Back to plugin list", "browseRegistry": "Browse Registry", "builtinInstallFailed": "Failed to install {{name}}: {{error}}", + "builtinDisabled": "Disabled", "builtinInstalledGlobally": "{{name}} installed globally", "builtinMetadataOnly": "Built-in metadata only", "builtinNoPackage": "{{name}} is built in and does not have an installable package yet",