/** * Plugin Worker Thread — runs plugins in isolated Worker threads. * * Receives messages from the main thread: * - { type: "load", entryPoint, permissions, name } → load plugin, send back hooks * - { type: "call", hook, payload, response?, error? } → call hook, send back result * - { type: "cleanup" } → terminate gracefully * * @module plugins/pluginWorker */ import { parentPort, workerData } from "worker_threads"; import { readFile, readdir, stat, writeFile, mkdir, rm } from "fs/promises"; import { resolve } from "path"; import * as vm from "vm"; if (!parentPort) { throw new Error("pluginWorker must be run as a Worker thread"); } const port = parentPort; interface LoadMessage { type: "load"; entryPoint: string; permissions: string[]; name: string; } interface CallMessage { type: "call"; hook: string; payload: unknown; response?: unknown; error?: string; } interface CleanupMessage { type: "cleanup" | "exit" | "terminate"; } type WorkerMessage = LoadMessage | CallMessage | CleanupMessage; /** * createSandbox — capability-gated object passed to vm.createContext(). * * TRUST MODEL: vm is NOT a security boundary (shares the worker's V8 heap; * prototype-chain escapes are possible). Plugin execution is safe only because: * 1. /api/plugins/ is classified LOCAL_ONLY in routeGuard — loopback enforced * before any auth check (Hard Rules #15/#17). * 2. The `exec` permission additionally requires OMNIROUTE_PLUGINS_ALLOW_EXEC=1 * (opt-in, default OFF) — child_process is never wired silently. * Treat plugins as local-operator-trusted code, not sandboxed untrusted code. */ function createSandbox(permissions: string[], pluginDir: string): Record { const activeTimers = new Set>(); const sandbox: Record = { console: { log: (...args: unknown[]) => port.postMessage({ type: "log", level: "info", args }), warn: (...args: unknown[]) => port.postMessage({ type: "log", level: "warn", args }), error: (...args: unknown[]) => port.postMessage({ type: "log", level: "error", args }), }, setTimeout: (fn: (...args: unknown[]) => void, ms?: number) => { const t = setTimeout(fn, ms); activeTimers.add(t); return t; }, clearTimeout: (t: unknown) => { activeTimers.delete(t as ReturnType); clearTimeout(t as ReturnType); }, setInterval: (fn: (...args: unknown[]) => void, ms?: number) => { const t = setInterval(fn, ms); activeTimers.add(t); return t; }, clearInterval: (t: unknown) => { activeTimers.delete(t as ReturnType); clearInterval(t as ReturnType); }, Promise, JSON, Math, Date, Array, Object, String, Number, Boolean, RegExp, Error, TypeError, RangeError, SyntaxError, URIError, Map, Set, WeakMap, WeakSet, Symbol, parseInt, parseFloat, isNaN, isFinite, URL, URLSearchParams, }; if (permissions.includes("file-read") || permissions.includes("file-write")) { sandbox.Buffer = Buffer; } if (permissions.includes("network")) { sandbox.fetch = globalThis.fetch; sandbox.AbortController = globalThis.AbortController; sandbox.Headers = globalThis.Headers; sandbox.Request = globalThis.Request; sandbox.Response = globalThis.Response; } if (permissions.includes("file-read")) { sandbox.fs = { readFile: (p: string, enc?: string) => readFile(resolve(pluginDir, p), enc as BufferEncoding), readdir: (p: string) => readdir(resolve(pluginDir, p)), stat: (p: string) => stat(resolve(pluginDir, p)), }; } if (permissions.includes("file-write")) { const fs = sandbox.fs as Record || {}; fs.writeFile = (p: string, data: string) => writeFile(resolve(pluginDir, p), data); fs.mkdir = (p: string) => mkdir(resolve(pluginDir, p), { recursive: true }); fs.rm = (p: string) => rm(resolve(pluginDir, p), { recursive: true, force: true }); sandbox.fs = fs; } if (permissions.includes("env")) { sandbox.process = { env: new Proxy({}, { get: (_t, key) => typeof key === "string" ? process.env[key] : undefined, set: () => false, has: (_t, key) => typeof key === "string" ? key in process.env : false, }) }; } if (permissions.includes("exec")) { if (process.env.OMNIROUTE_PLUGINS_ALLOW_EXEC !== "1") { throw new Error( `Plugin '${name}' requested the 'exec' permission, which is disabled. Set OMNIROUTE_PLUGINS_ALLOW_EXEC=1 to enable (local operator only).` ); } sandbox.child_process = { exec: require("child_process").exec, execSync: require("child_process").execSync, }; } sandbox.__activeTimers = activeTimers; return sandbox; } let context: vm.Context | null = null; let pluginExports: Record | null = null; let activeTimers: Set> | null = null; async function loadPlugin(entryPoint: string, permissions: string[], name: string): Promise { const pluginDir = resolve(entryPoint, ".."); const sandbox = createSandbox(permissions, pluginDir); context = vm.createContext(sandbox); activeTimers = sandbox.__activeTimers as Set>; const moduleExports: Record = {}; const moduleObj = { exports: moduleExports }; sandbox.module = moduleObj; sandbox.exports = moduleExports; sandbox.require = (id: string) => { const allowed: Record = {}; if (id === "crypto") allowed.crypto = require("crypto"); if (allowed[id]) return allowed[id]; throw new Error(`Module '${id}' is not allowed in plugin sandbox`); }; const source = await readFile(entryPoint, "utf-8"); const wrapped = `(async function(module, exports, require) { ${source} })(module, exports, require);`; vm.runInContext(wrapped, context, { filename: entryPoint, timeout: 10000 }); pluginExports = moduleObj.exports; const hooks: string[] = []; const sources = [pluginExports]; if (pluginExports.default && typeof pluginExports.default === "object") { sources.push(pluginExports.default as Record); } for (const src of sources) { if (typeof src.onRequest === "function" && !hooks.includes("onRequest")) hooks.push("onRequest"); if (typeof src.onResponse === "function" && !hooks.includes("onResponse")) hooks.push("onResponse"); if (typeof src.onError === "function" && !hooks.includes("onError")) hooks.push("onError"); } return hooks; } function callHook(hook: string, payload: unknown, extra?: { response?: unknown; error?: string }): unknown { if (!context || !pluginExports) throw new Error("Plugin not loaded"); const sources = [pluginExports]; if (pluginExports.default && typeof pluginExports.default === "object") { sources.push(pluginExports.default as Record); } for (const src of sources) { const fn = src[hook]; if (typeof fn === "function") { if (hook === "onResponse" && extra?.response !== undefined) { return fn(payload, extra.response); } if (hook === "onError" && extra?.error !== undefined) { return fn(payload, new Error(extra.error)); } return fn(payload); } } throw new Error(`Hook '${hook}' not found in plugin exports`); } function cleanup(): void { if (activeTimers) { for (const t of activeTimers) { clearTimeout(t); clearInterval(t); } activeTimers.clear(); } context = null; pluginExports = null; activeTimers = null; } port.on("message", async (msg: WorkerMessage) => { try { if (msg.type === "load") { const hooks = await loadPlugin(msg.entryPoint, msg.permissions, msg.name); port.postMessage({ type: "loaded", hooks }); } else if (msg.type === "call") { const result = callHook(msg.hook, msg.payload, { response: (msg as CallMessage).response, error: (msg as CallMessage).error }); port.postMessage({ type: "result", value: result }); } else if (msg.type === "cleanup" || msg.type === "exit" || msg.type === "terminate") { cleanup(); port.postMessage({ type: "cleaned" }); process.exit(0); } } catch (err: unknown) { const errMsg = err instanceof Error ? err.message : String(err); port.postMessage({ type: "error", error: errMsg, hook: (msg as CallMessage).hook }); } }); port.postMessage({ type: "ready" });