diff --git a/Overlord-Server/src/protocol.ts b/Overlord-Server/src/protocol.ts index 875b6f1..bddb1a5 100644 --- a/Overlord-Server/src/protocol.ts +++ b/Overlord-Server/src/protocol.ts @@ -345,6 +345,7 @@ export type PluginManifest = { apiVersion?: number; runtime?: "native" | "wasm" | "server" | string; nativeLoader?: "memory" | "os" | string; + autoLoadByDefault?: boolean; nativeEntrypoints?: PluginNativeEntrypoints; version?: string; description?: string; diff --git a/Overlord-Server/src/server/plugin-runtime/runtime.ts b/Overlord-Server/src/server/plugin-runtime/runtime.ts index 3ee6178..e52c6b6 100644 --- a/Overlord-Server/src/server/plugin-runtime/runtime.ts +++ b/Overlord-Server/src/server/plugin-runtime/runtime.ts @@ -217,6 +217,7 @@ export function createPluginRuntime(opts: PluginRuntimeOptions): PluginRuntime { } } + function rpc( pluginId: string, method: string, diff --git a/Overlord-Server/src/server/plugin-state-bundle.test.ts b/Overlord-Server/src/server/plugin-state-bundle.test.ts index 169b61a..23b9636 100644 --- a/Overlord-Server/src/server/plugin-state-bundle.test.ts +++ b/Overlord-Server/src/server/plugin-state-bundle.test.ts @@ -105,6 +105,7 @@ describe("loadPluginBundle", () => { apiVersion: 2, runtime: "native", nativeLoader: "loadlibraryex", + autoLoadByDefault: true, nativeEntrypoints: { onLoad: "StartPlugin", onEvent: "HandlePluginEvent", @@ -123,6 +124,7 @@ describe("loadPluginBundle", () => { const manifest = JSON.parse(await readFile(join(root, "native-loader-demo", "manifest.json"), "utf-8")); expect(manifest.nativeLoader).toBe("os"); + expect(manifest.autoLoadByDefault).toBe(true); expect(manifest.nativeEntrypoints.onLoad).toBe("StartPlugin"); expect(manifest.nativeEntrypoints.onEvent).toBe("HandlePluginEvent"); expect(manifest.nativeEntrypoints.onUnload).toBe("StopPlugin"); diff --git a/Overlord-Server/src/server/plugin-state-bundle.ts b/Overlord-Server/src/server/plugin-state-bundle.ts index e36966f..bc9452f 100644 --- a/Overlord-Server/src/server/plugin-state-bundle.ts +++ b/Overlord-Server/src/server/plugin-state-bundle.ts @@ -210,6 +210,7 @@ export async function ensurePluginExtracted( apiVersion: Number(extraConfig.apiVersion) === 2 || runtime === "wasm" ? 2 : 1, runtime, ...(nativeLoader && { nativeLoader }), + ...(extraConfig.autoLoadByDefault === true && { autoLoadByDefault: true }), ...(nativeEntrypoints && { nativeEntrypoints }), version: extraConfig.version || "1.0.0", description: extraConfig.description, diff --git a/Overlord-Server/src/server/routes/plugin-routes.ts b/Overlord-Server/src/server/routes/plugin-routes.ts index 90d75e2..33dc4bc 100644 --- a/Overlord-Server/src/server/routes/plugin-routes.ts +++ b/Overlord-Server/src/server/routes/plugin-routes.ts @@ -8,7 +8,11 @@ import { requireAnyPermission, requireClientAccess, requirePermission, requirePl import { canUserAccessClient, canUserAccessPlugin } from "../../users"; import * as clientManager from "../../clientManager"; import { metrics } from "../../metrics"; -import { encodeMessage, type PluginSignatureInfo } from "../../protocol"; +import { + encodeMessage, + type PluginManifest as ProtocolPluginManifest, + type PluginSignatureInfo, +} from "../../protocol"; import { getConfig, updatePluginsConfig } from "../../config"; import { getOrVerifySignature, BUILTIN_TRUSTED_KEYS } from "../plugin-signature"; import type { PluginRuntime } from "../plugin-runtime/runtime"; @@ -16,17 +20,9 @@ import { arePluginNeedsApproved, computePluginNeedsHash, getPluginPull, deletePl import { isAuthorizedAgentRequest } from "../agent-auth"; import { logger } from "../../logger"; -type PluginManifest = { - id: string; - name: string; +type PluginManifest = ProtocolPluginManifest & { signature?: PluginSignatureInfo; - hasServer?: boolean; - apiVersion?: number; - runtime?: string; - wasm?: string; - needs?: any; needsHash?: string; - dashboard?: any; }; type PluginBundle = { @@ -118,6 +114,20 @@ export async function handlePluginRoutes( ); } + async function loadPluginOnConnectedClients(pluginId: string): Promise { + const allClients = clientManager.getAllClients(); + for (const [clientId, client] of allClients) { + if (deps.isPluginLoaded(clientId, pluginId) || deps.isPluginLoading(clientId, pluginId)) continue; + try { + const bundle = await deps.loadPluginBundle(pluginId, client.os, client.arch); + deps.markPluginLoading(clientId, pluginId); + deps.sendPluginBundle(client, bundle); + metrics.recordCommand("plugin_load"); + } catch { + } + } + } + if (req.method === "GET" && url.pathname.startsWith("/api/plugins/pull/")) { const agentToken = getConfig().auth.agentToken; if (!isAuthorizedAgentRequest(req, url, agentToken)) { @@ -531,7 +541,15 @@ export async function handlePluginRoutes( const uploadedManifest = await loadManifest(pluginId); if (deps.pluginState.enabled[pluginId] === undefined) { - deps.pluginState.enabled[pluginId] = sigInfo.trusted === true && needsApproved(uploadedManifest); + const enabledByDefault = sigInfo.trusted === true && needsApproved(uploadedManifest); + deps.pluginState.enabled[pluginId] = enabledByDefault; + if ( + enabledByDefault && + uploadedManifest.runtime !== "server" && + uploadedManifest.autoLoadByDefault === true + ) { + deps.pluginState.autoLoad[pluginId] = true; + } await deps.savePluginState(); } @@ -544,6 +562,9 @@ export async function handlePluginRoutes( } else if (deps.pluginRuntime.isRunning(pluginId)) { await deps.pluginRuntime.stopPlugin(pluginId); } + if (deps.pluginState.enabled[pluginId] && deps.pluginState.autoLoad[pluginId]) { + await loadPluginOnConnectedClients(pluginId); + } return Response.json({ ok: true, id: pluginId, enabled: deps.pluginState.enabled[pluginId], signature: sigInfo }); } @@ -587,6 +608,12 @@ export async function handlePluginRoutes( } deps.pluginState.enabled[pluginId] = enabled; + if (enabled && deps.pluginState.autoLoad[pluginId] === undefined) { + const manifest = await loadManifest(pluginId); + if (manifest.runtime !== "server" && manifest.autoLoadByDefault === true) { + deps.pluginState.autoLoad[pluginId] = true; + } + } await deps.savePluginState(); if (enabled) { @@ -596,6 +623,9 @@ export async function handlePluginRoutes( } else if (deps.pluginRuntime.isRunning(pluginId)) { await deps.pluginRuntime.stopPlugin(pluginId); } + if (enabled && deps.pluginState.autoLoad[pluginId]) { + await loadPluginOnConnectedClients(pluginId); + } return Response.json({ ok: true, id: pluginId, enabled }); } @@ -658,17 +688,7 @@ export async function handlePluginRoutes( await deps.savePluginState(); if (autoLoad) { - const allClients = clientManager.getAllClients(); - for (const [cid, client] of allClients) { - if (deps.isPluginLoaded(cid, pluginId) || deps.isPluginLoading(cid, pluginId)) continue; - try { - const bundle = await deps.loadPluginBundle(pluginId, client.os, client.arch); - deps.markPluginLoading(cid, pluginId); - deps.sendPluginBundle(client, bundle); - metrics.recordCommand("plugin_load"); - } catch { - } - } + await loadPluginOnConnectedClients(pluginId); } return Response.json({ diff --git a/plugins/docs/bundle-format.md b/plugins/docs/bundle-format.md index 82ab990..67a5252 100644 --- a/plugins/docs/bundle-format.md +++ b/plugins/docs/bundle-format.md @@ -85,6 +85,13 @@ Native plugins can choose the Windows loader mode and exported ABI names: `navbar.icon` accepts a Font Awesome 6 solid icon class such as `fa-cube`, `fa-key`, or `fa-network-wired`. If the plugin is enabled, navbar plugins appear in the Plugin Apps group and open at `/plugins/`. +Client-side bundles can set `autoLoadByDefault: true`. The default is applied only +after a trusted plugin is enabled automatically or an administrator explicitly +confirms and enables an untrusted plugin. An existing administrator auto-load +choice is preserved across reinstalls. +When the default is applied, compatible connected clients are loaded immediately; +future clients receive the plugin as they connect. + ## Dashboard Badges Plugins can contribute compact badges to client cards on the main dashboard. Declare the dashboard integration in `config.json`: diff --git a/plugins/docs/install-and-manage.md b/plugins/docs/install-and-manage.md index d32a1fd..a1fb698 100644 --- a/plugins/docs/install-and-manage.md +++ b/plugins/docs/install-and-manage.md @@ -53,6 +53,7 @@ Auto-load notes: | WASM needs | WASM plugins with unapproved needs are blocked. | | Persistence | Auto-load state is saved in `.plugin-state.json`. | | Platform matching | WASM is universal; legacy native requires a matching binary. | +| Bundle default | `autoLoadByDefault: true` enables auto-load only after the plugin passes trusted enablement or an administrator explicitly confirms enablement. | ## Useful APIs diff --git a/plugins/docs/server-side-plugins.md b/plugins/docs/server-side-plugins.md index cccbf1f..089ac85 100644 --- a/plugins/docs/server-side-plugins.md +++ b/plugins/docs/server-side-plugins.md @@ -37,8 +37,8 @@ export default { ctx.log.info("server plugin started"); }, - async onEvent(ctx, event) { - ctx.log.info(`plugin event from ${event.clientId}`); + async onEvent(ctx, clientId, event, payload) { + ctx.log.info(`${event} from ${clientId}: ${JSON.stringify(payload)}`); }, rpc: { @@ -53,6 +53,8 @@ export default { }; ``` +`onEvent` receives events emitted by that plugin's client module. + ## The `ctx` Object | Field | Description |