FN-8521: separate runtime installation from toggles

Keep built-in runtime installation distinct from project-scoped enablement.

- Show enable/disable toggles only for installed runtime plugins.
- Prevent toggles from registering or reinstalling uninstalled runtimes.
- Update plugin lifecycle documentation and regression coverage.

Files changed:
 .../fn-8521-separate-plugin-install-toggle.md      |  7 ++
 docs/dashboard-guide.md                            |  2 +-
 docs/plugin-management.md                          |  2 +-
 .../dashboard/app/components/PluginManager.tsx     | 74 ++++------------------
 .../components/__tests__/PluginManager.test.tsx    | 30 ++++++---
 .../__tests__/PluginManager.toggle.test.tsx        | 51 +++++++--------
 6 files changed, 67 insertions(+), 99 deletions(-)

Fusion-Task-Id: FN-8521

Fusion-Task-Lineage: 0de225f4-1d9c-411e-b5cc-851d3579a2ec

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-22 20:55:27 -07:00
parent 0e2aa49f8d
commit 059b9549a9
6 changed files with 67 additions and 99 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Prevent plugin toggles from reinstalling uninstalled runtimes.
category: fix
dev: FN-8521 / Runfusion/Fusion#2409 separates install from project-scoped enablement.

View File

@@ -1385,7 +1385,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.
- A built-in runtime without an installed plugin record offers **Install** only. Once installed, it exposes project-scoped Enable/Disable, management, and uninstall controls; toggling never installs or reinstalls a runtime. The Runtimes settings cards mirror the installed runtime 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).

View File

@@ -135,7 +135,7 @@ 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.
Built-in runtime rows without a `plugin_installs` record expose **Install** only. After installation, their project-scoped **Enable/Disable** toggle and management/uninstall controls become available. Installing and enabling are separate actions: changing enabled state never registers or reinstalls a runtime. The **Runtimes** settings cards (Hermes/OpenClaw/Paperclip) reflect the installed plugin state and show "Disabled in Plugin Manager" when an installed runtime has been turned off.
## 5) Configure plugin settings

View File

@@ -289,14 +289,6 @@ export function PluginManager({ addToast, projectId, onPluginsChanged }: PluginM
const [builtinSetupStatusById, setBuiltinSetupStatusById] = useState<Record<string, PluginSetupStatusResponse>>({});
const [loadingBuiltinSetupId, setLoadingBuiltinSetupId] = useState<string | null>(null);
const [installingBuiltinSetupId, setInstallingBuiltinSetupId] = useState<string | null>(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<string | null>(null);
const { confirm } = useConfirm();
const loadPlugins = useCallback(async (background = false, mutationResponse?: PluginInstallation) => {
@@ -614,44 +606,15 @@ export function PluginManager({ addToast, projectId, onPluginsChanged }: PluginM
};
/*
* 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.
* FNXC:PluginManager 2026-07-22-20:41:
* FN-8521 keeps installation and project-scoped enablement as separate actions: only a
* PluginInstallation can reach this toggle, so toggling can never register a missing package.
*/
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 handleToggleBuiltinRuntime = async (installedPlugin: PluginInstallation) => {
if (installedPlugin.enabled) {
await handleDisable(installedPlugin);
} else {
await handleEnable(installedPlugin);
}
};
@@ -1167,16 +1130,8 @@ export function PluginManager({ addToast, projectId, onPluginsChanged }: PluginM
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;
const runtimeEnabled = installedPlugin?.enabled;
return (
<div key={builtinPlugin.id} className="plugin-builtins-item">
@@ -1187,7 +1142,7 @@ export function PluginManager({ addToast, projectId, onPluginsChanged }: PluginM
<span className={`plugin-builtins-status ${isInstalled ? "plugin-builtins-status--installed" : "plugin-builtins-status--available"}`}>
{isInstalled ? t("plugins.statusInstalled", "Installed") : metadataOnly ? t("plugins.statusBuiltIn", "Built in") : t("plugins.statusNotInstalled", "Not installed")}
</span>
{isRuntimeBuiltin && !runtimeEnabled && (
{isRuntimeBuiltin && runtimeEnabled === false && (
<span className="plugin-builtins-setup-status plugin-builtins-setup-status--warning">{t("plugins.builtinDisabled", "Disabled")}</span>
)}
{requiresSetupAction && (
@@ -1205,14 +1160,13 @@ export function PluginManager({ addToast, projectId, onPluginsChanged }: PluginM
<span className="plugin-builtins-description-text">{builtinPlugin.description}</span>
</div>
<div className="plugin-builtins-actions">
{isRuntimeBuiltin && (
{isRuntimeBuiltin && installedPlugin && (
<label className="toggle-switch">
<input
type="checkbox"
checked={runtimeEnabled}
onChange={() => void handleToggleBuiltinRuntime(builtinPlugin, installedPlugin)}
disabled={isTogglingRuntime}
aria-label={runtimeEnabled
checked={installedPlugin.enabled}
onChange={() => void handleToggleBuiltinRuntime(installedPlugin)}
aria-label={installedPlugin.enabled
? t("plugins.disablePlugin", "Disable {{name}}", { name: builtinPlugin.name })
: t("plugins.enablePlugin", "Enable {{name}}", { name: builtinPlugin.name })}
/>

View File

@@ -1209,22 +1209,33 @@ describe("PluginManager", () => {
});
});
it("handles plugin uninstalled SSE event", async () => {
vi.mocked(fetchPlugins).mockResolvedValueOnce(mockPlugins);
it("removes a runtime toggle after its uninstalled lifecycle event", async () => {
const hermesRuntime: PluginInstallation = {
id: "fusion-plugin-hermes-runtime",
name: "Hermes Runtime",
version: "1.0.0",
state: "started",
enabled: true,
path: "./plugins/fusion-plugin-hermes-runtime",
settings: {},
settingsSchema: {},
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
vi.mocked(fetchPlugins).mockResolvedValueOnce([hermesRuntime]);
render(<PluginManager addToast={addToast} />);
await waitFor(() => {
expect(screen.getByText("Test Plugin A")).toBeTruthy();
});
const installedCard = await waitFor(() => getBuiltInPluginCard("Hermes Runtime"));
expect(within(installedCard).getByRole("checkbox", { name: "Disable Hermes Runtime" })).toBeChecked();
const eventSourceInstance = (globalThis as any).__testEventSourceInstance;
const eventHandler = eventSourceInstance?.handlers?.["plugin:lifecycle"];
act(() => {
eventHandler({
data: JSON.stringify({
pluginId: "plugin-a",
pluginId: hermesRuntime.id,
transition: "uninstalled",
sourceEvent: "plugin:unregistered",
timestamp: new Date().toISOString(),
@@ -1237,7 +1248,10 @@ describe("PluginManager", () => {
});
await waitFor(() => {
expect(screen.queryByText("Test Plugin A")).toBeNull();
const card = getBuiltInPluginCard("Hermes Runtime");
expect(within(card).getByRole("button", { name: "Install Hermes Runtime" })).toBeVisible();
expect(within(card).queryByRole("checkbox")).not.toBeInTheDocument();
expect(card.querySelector("label.toggle-switch")).toBeNull();
});
});

View File

@@ -51,7 +51,10 @@ beforeEach(() => {
const esInstance = {
readyState: 1,
close: vi.fn(),
addEventListener: vi.fn(),
addEventListener: vi.fn((event: string, handler: (event: MessageEvent) => void) => {
(esInstance as { handlers?: Record<string, (event: MessageEvent) => void> }).handlers ??= {};
(esInstance as { handlers: Record<string, (event: MessageEvent) => void> }).handlers[event] = handler;
}),
removeEventListener: vi.fn(),
onerror: null,
onopen: null,
@@ -64,12 +67,14 @@ beforeEach(() => {
(MockES as unknown as { CONNECTING: number; OPEN: number; CLOSED: number }).OPEN = 1;
(MockES as unknown as { CONNECTING: number; OPEN: number; CLOSED: number }).CLOSED = 2;
vi.stubGlobal("EventSource", MockES);
(globalThis as { __testEventSourceInstance?: typeof esInstance }).__testEventSourceInstance = esInstance;
});
afterEach(() => {
cleanup();
document.querySelector('[data-test-id="all-app-css"]')?.remove();
vi.restoreAllMocks();
delete (globalThis as { __testEventSourceInstance?: unknown }).__testEventSourceInstance;
});
function builtinPlugin(id: string, enabled: boolean) {
@@ -174,57 +179,44 @@ describe("PluginManager toggle switch", () => {
});
/*
* 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).
* FNXC:PluginManager 2026-07-22-20:41:
* FN-8521 prevents the post-uninstall toggle from re-registering a runtime. A missing
* PluginInstallation has exactly one action (Install); only installed records expose toggles.
*/
describe("PluginManager built-in runtime enable/disable toggle (FN-7629)", () => {
describe("PluginManager built-in runtime install and enable controls (FN-8521)", () => {
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 () => {
it("shows every uninstalled runtime's Install action without a toggle or orphaned toggle shell", async () => {
vi.mocked(fetchPlugins).mockResolvedValue([]);
render(<PluginManager addToast={addToast} />);
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();
expect(within(row).getByRole("button", { name: `Install ${runtime.name}` })).toBeVisible();
expect(within(row).queryByRole("checkbox")).not.toBeInTheDocument();
expect(row.querySelector("label.toggle-switch")).toBeNull();
expect(row.querySelector(".toggle-slider")).toBeNull();
}
});
it("disabling a not-installed runtime built-in registers it then disables it (durable path)", async () => {
it("uses Install as the only transition from an uninstalled runtime", async () => {
vi.mocked(fetchPlugins).mockResolvedValue([]);
vi.mocked(installPlugin).mockResolvedValue(builtinPlugin("fusion-plugin-hermes-runtime", true));
render(<PluginManager addToast={addToast} />);
const toggle = await (await builtinSection()).findByRole("checkbox", { name: "Disable Hermes Runtime" });
await userEvent.click(toggle.closest("label.toggle-switch") as HTMLLabelElement);
const hermes = (await builtinSection()).getByText("Hermes Runtime").closest(".plugin-builtins-item") as HTMLElement;
await userEvent.click(within(hermes).getByRole("button", { name: "Install Hermes Runtime" }));
await waitFor(() => {
expect(installPlugin).toHaveBeenCalledWith({ path: "./plugins/fusion-plugin-hermes-runtime" }, undefined);
});
await waitFor(() => {
expect(disablePlugin).toHaveBeenCalledWith("fusion-plugin-hermes-runtime", undefined);
});
expect(disablePlugin).not.toHaveBeenCalled();
expect(enablePlugin).not.toHaveBeenCalled();
});
it("toggles an installed, enabled runtime built-in via the standard disable path (no re-install)", async () => {
@@ -241,7 +233,7 @@ describe("PluginManager built-in runtime enable/disable toggle (FN-7629)", () =>
expect(installPlugin).not.toHaveBeenCalled();
});
it("toggles an installed, disabled runtime built-in back on via the standard enable path", async () => {
it("toggles an installed, disabled runtime built-in back on via the standard enable path without installing", async () => {
vi.mocked(fetchPlugins).mockResolvedValue([builtinPlugin("fusion-plugin-openclaw-runtime", false)]);
render(<PluginManager addToast={addToast} />);
@@ -253,5 +245,6 @@ describe("PluginManager built-in runtime enable/disable toggle (FN-7629)", () =>
await waitFor(() => {
expect(enablePlugin).toHaveBeenCalledWith("fusion-plugin-openclaw-runtime", undefined);
});
expect(installPlugin).not.toHaveBeenCalled();
});
});