| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
|
|
| 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;
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| function createSandbox(permissions: string[], pluginDir: string): Record<string, unknown> {
|
| const activeTimers = new Set<ReturnType<typeof setTimeout>>();
|
|
|
| const sandbox: Record<string, unknown> = {
|
| 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<typeof setTimeout>); clearTimeout(t as ReturnType<typeof setTimeout>); },
|
| 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<typeof setInterval>); clearInterval(t as ReturnType<typeof setInterval>); },
|
| 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<string, unknown> || {};
|
| 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<string, unknown> | null = null;
|
| let activeTimers: Set<ReturnType<typeof setTimeout>> | null = null;
|
|
|
| async function loadPlugin(entryPoint: string, permissions: string[], name: string): Promise<string[]> {
|
| const pluginDir = resolve(entryPoint, "..");
|
| const sandbox = createSandbox(permissions, pluginDir);
|
| context = vm.createContext(sandbox);
|
| activeTimers = sandbox.__activeTimers as Set<ReturnType<typeof setTimeout>>;
|
|
|
| const moduleExports: Record<string, unknown> = {};
|
| const moduleObj = { exports: moduleExports };
|
| sandbox.module = moduleObj;
|
| sandbox.exports = moduleExports;
|
| sandbox.require = (id: string) => {
|
| const allowed: Record<string, unknown> = {};
|
| 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<string, unknown>);
|
| }
|
|
|
| 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<string, unknown>);
|
| }
|
|
|
| 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" });
|
|
|