/** * Plugin Host — Web Worker sandbox for Agents Orchestrator plugins. * * Each plugin runs in a dedicated Web Worker, providing true process-level * isolation from the main thread. The Worker has no access to the DOM, * Tauri IPC, or any main-thread state. * * Communication: * - Main → Worker: plugin code, permissions, callback invocations * - Worker → Main: API call proxies (palette, tasks, messages, events) * * On unload, the Worker is terminated — all plugin state is destroyed. */ import type { PluginMeta } from '@agor/types'; import { getBackend } from '../backend/backend'; import { addPluginCommand, removePluginCommands, pluginEventBus, } from '../stores/plugins.svelte'; import type { GroupId, AgentId } from '../types/ids'; interface LoadedPlugin { meta: PluginMeta; worker: Worker; callbacks: Map void>; eventSubscriptions: Array<{ event: string; handler: (data: unknown) => void }>; cleanup: () => void; } const loadedPlugins = new Map(); /** * Build the Worker script as an inline blob. * The Worker receives plugin code + permissions and builds a sandboxed agor API * that proxies all calls to the main thread via postMessage. */ function buildWorkerScript(): string { return ` "use strict"; // Callback registry for palette commands and event handlers const _callbacks = new Map(); let _callbackId = 0; function _nextCallbackId() { return '__cb_' + (++_callbackId); } // Pending RPC calls (for async APIs like tasks.list) const _pending = new Map(); let _rpcId = 0; function _rpc(method, args) { return new Promise((resolve, reject) => { const id = '__rpc_' + (++_rpcId); _pending.set(id, { resolve, reject }); self.postMessage({ type: 'rpc', id, method, args }); }); } // Handle messages from main thread self.onmessage = function(e) { const msg = e.data; if (msg.type === 'init') { const permissions = msg.permissions || []; const meta = msg.meta; // Build the agor API based on permissions const api = { meta: Object.freeze(meta) }; if (permissions.includes('palette')) { api.palette = { registerCommand(label, callback) { if (typeof label !== 'string' || !label.trim()) { throw new Error('Command label must be a non-empty string'); } if (typeof callback !== 'function') { throw new Error('Command callback must be a function'); } const cbId = _nextCallbackId(); _callbacks.set(cbId, callback); self.postMessage({ type: 'palette-register', label, callbackId: cbId }); }, }; } if (permissions.includes('bttask:read')) { api.tasks = { list() { return _rpc('tasks.list', {}); }, comments(taskId) { return _rpc('tasks.comments', { taskId }); }, }; } if (permissions.includes('btmsg:read')) { api.messages = { inbox() { return _rpc('messages.inbox', {}); }, channels() { return _rpc('messages.channels', {}); }, }; } if (permissions.includes('events')) { api.events = { on(event, callback) { if (typeof event !== 'string' || typeof callback !== 'function') { throw new Error('event.on requires (string, function)'); } const cbId = _nextCallbackId(); _callbacks.set(cbId, callback); self.postMessage({ type: 'event-on', event, callbackId: cbId }); }, off(event, callbackId) { // Worker-side off is a no-op for now (main thread handles cleanup on terminate) self.postMessage({ type: 'event-off', event, callbackId }); }, }; } Object.freeze(api); // Execute the plugin code try { const fn = (0, eval)( '(function(agor) { "use strict"; ' + msg.code + '\\n})' ); fn(api); self.postMessage({ type: 'loaded' }); } catch (err) { self.postMessage({ type: 'error', message: String(err) }); } } if (msg.type === 'invoke-callback') { const cb = _callbacks.get(msg.callbackId); if (cb) { try { cb(msg.data); } catch (err) { self.postMessage({ type: 'callback-error', callbackId: msg.callbackId, message: String(err) }); } } } if (msg.type === 'rpc-result') { const pending = _pending.get(msg.id); if (pending) { _pending.delete(msg.id); if (msg.error) { pending.reject(new Error(msg.error)); } else { pending.resolve(msg.result); } } } }; `; } let workerBlobUrl: string | null = null; function getWorkerBlobUrl(): string { if (!workerBlobUrl) { const blob = new Blob([buildWorkerScript()], { type: 'application/javascript' }); workerBlobUrl = URL.createObjectURL(blob); } return workerBlobUrl; } /** * Load and execute a plugin in a Web Worker sandbox. */ export async function loadPlugin( meta: PluginMeta, groupId: GroupId, agentId: AgentId, ): Promise { if (loadedPlugins.has(meta.id)) { console.warn(`Plugin '${meta.id}' is already loaded`); return; } // Read the plugin's entry file let code: string; try { code = await getBackend().readPluginFile(meta.id, meta.main); } catch (e) { throw new Error(`Failed to read plugin '${meta.id}' entry file '${meta.main}': ${e}`); } const worker = new Worker(getWorkerBlobUrl(), { type: 'classic' }); const callbacks = new Map void>(); const eventSubscriptions: Array<{ event: string; handler: (data: unknown) => void }> = []; // Set up message handler before sending init const loadResult = await new Promise((resolve, reject) => { const onMessage = async (e: MessageEvent) => { const msg = e.data; switch (msg.type) { case 'loaded': resolve(); break; case 'error': // Clean up any commands/events registered before the crash removePluginCommands(meta.id); for (const sub of eventSubscriptions) { pluginEventBus.off(sub.event, sub.handler); } worker.terminate(); reject(new Error(`Plugin '${meta.id}' execution failed: ${msg.message}`)); break; case 'palette-register': { const cbId = msg.callbackId as string; const invokeCallback = () => { worker.postMessage({ type: 'invoke-callback', callbackId: cbId }); }; callbacks.set(cbId, invokeCallback); addPluginCommand(meta.id, msg.label, invokeCallback); break; } case 'event-on': { const cbId = msg.callbackId as string; const handler = (data: unknown) => { worker.postMessage({ type: 'invoke-callback', callbackId: cbId, data }); }; eventSubscriptions.push({ event: msg.event, handler }); pluginEventBus.on(msg.event, handler); break; } case 'event-off': { const idx = eventSubscriptions.findIndex(s => s.event === msg.event); if (idx >= 0) { pluginEventBus.off(eventSubscriptions[idx].event, eventSubscriptions[idx].handler); eventSubscriptions.splice(idx, 1); } break; } case 'rpc': { const { id, method, args } = msg; try { let result: unknown; switch (method) { case 'tasks.list': result = await getBackend().bttaskList(groupId); break; case 'tasks.comments': result = await getBackend().bttaskComments(args.taskId); break; case 'messages.inbox': result = await getBackend().btmsgUnreadMessages(agentId); break; case 'messages.channels': result = await getBackend().btmsgGetChannels(groupId); break; default: throw new Error(`Unknown RPC method: ${method}`); } worker.postMessage({ type: 'rpc-result', id, result }); } catch (err) { worker.postMessage({ type: 'rpc-result', id, error: err instanceof Error ? err.message : String(err), }); } break; } case 'callback-error': console.error(`Plugin '${meta.id}' callback error:`, msg.message); break; } }; worker.onmessage = onMessage; worker.onerror = (err) => { reject(new Error(`Plugin '${meta.id}' worker error: ${err.message}`)); }; // Send init message with plugin code, permissions, and meta worker.postMessage({ type: 'init', code, permissions: meta.permissions, meta: { id: meta.id, name: meta.name, version: meta.version, description: meta.description }, }); }); // If we get here, the plugin loaded successfully const cleanup = () => { removePluginCommands(meta.id); for (const sub of eventSubscriptions) { pluginEventBus.off(sub.event, sub.handler); } eventSubscriptions.length = 0; callbacks.clear(); worker.terminate(); }; loadedPlugins.set(meta.id, { meta, worker, callbacks, eventSubscriptions, cleanup }); } /** * Unload a plugin, terminating its Worker. */ export function unloadPlugin(id: string): void { const plugin = loadedPlugins.get(id); if (!plugin) return; plugin.cleanup(); loadedPlugins.delete(id); } /** * Get all currently loaded plugins. */ export function getLoadedPlugins(): PluginMeta[] { return Array.from(loadedPlugins.values()).map(p => p.meta); } /** * Unload all plugins. */ export function unloadAllPlugins(): void { for (const [id] of loadedPlugins) { unloadPlugin(id); } }