| |
| |
| |
| |
| |
| |
| |
| |
|
|
|
|
| import { spawn } from "child_process";
|
| import { writeFile, rm, readFile } from "fs/promises";
|
| import { join } from "path";
|
| import { tmpdir } from "os";
|
| import { randomUUID, createHash } from "crypto";
|
| import { logger } from "../../../open-sse/utils/logger.ts";
|
| import type { PluginManifestWithDefaults, Permission } from "./manifest";
|
| import type { Plugin, PluginContext, PluginResult } from "./index";
|
|
|
| const log = logger("PLUGIN_LOADER");
|
|
|
| const DEFAULT_HOOK_TIMEOUT = 10_000;
|
| const SIGKILL_GRACE_MS = 3_000;
|
|
|
| |
| |
| |
|
|
| export function computeIntegrity(source: string): string {
|
| const hash = createHash("sha256").update(source, "utf-8").digest("base64");
|
| return `sha256-${hash}`;
|
| }
|
|
|
| export interface LoadedPlugin {
|
| name: string;
|
| manifest: PluginManifestWithDefaults;
|
| plugin: Plugin;
|
| cleanup: () => void;
|
| }
|
|
|
|
|
|
|
|
|
|
|
| const PLUGIN_HOST_SCRIPT = `
|
| import { createRequire } from "node:module";
|
| const require = createRequire(import.meta.url);
|
|
|
| const pluginPath = process.argv[2];
|
| const plugin = await import(pluginPath);
|
| const exports = plugin.default || plugin;
|
|
|
| // Send ready signal
|
| process.send({ type: "ready", hooks: Object.keys(exports).filter(k => typeof exports[k] === "function") });
|
|
|
| // Handle messages from parent
|
| process.on("message", async (msg) => {
|
| if (msg.type === "call") {
|
| try {
|
| const handler = exports[msg.hook];
|
| if (typeof handler !== "function") {
|
| process.send({ type: "result", id: msg.id, error: "Hook not found" });
|
| return;
|
| }
|
| const result = await handler(msg.payload);
|
| process.send({ type: "result", id: msg.id, result });
|
| } catch (err) {
|
| process.send({ type: "result", id: msg.id, error: err.message });
|
| }
|
| }
|
| });
|
| `;
|
|
|
| |
| |
| |
|
|
| export async function loadPlugin(
|
| entryPoint: string,
|
| manifest: PluginManifestWithDefaults
|
| ): Promise<LoadedPlugin> {
|
|
|
|
|
| const integrityField = (manifest as unknown as Record<string, unknown>).integrity;
|
| if (typeof integrityField === "string" && integrityField.length > 0) {
|
| let source: string;
|
| try {
|
| source = await readFile(entryPoint, "utf-8");
|
| } catch (err: unknown) {
|
| throw new Error(
|
| `Plugin '${manifest.name}' integrity check failed: cannot read entry point — ${err instanceof Error ? err.message : String(err)}`
|
| );
|
| }
|
| const actual = computeIntegrity(source);
|
| if (actual !== integrityField) {
|
| throw new Error(
|
| `Plugin '${manifest.name}' integrity mismatch: expected ${integrityField}, got ${actual}`
|
| );
|
| }
|
| }
|
|
|
| const permissions = manifest.requires.permissions;
|
|
|
|
|
|
|
|
|
|
|
|
|
| let hostScriptPath: string;
|
| {
|
|
|
| const tryWrite = async (id: string): Promise<string> => {
|
| const p = join(tmpdir(), `omniroute-plugin-host-${id}.mjs`);
|
| await writeFile(p, PLUGIN_HOST_SCRIPT, { encoding: "utf-8", mode: 0o600, flag: "wx" });
|
| return p;
|
| };
|
| try {
|
| hostScriptPath = await tryWrite(randomUUID());
|
| } catch (err: unknown) {
|
|
|
| if (err instanceof Error && (err as NodeJS.ErrnoException).code === "EEXIST") {
|
| hostScriptPath = await tryWrite(randomUUID());
|
| } else {
|
| throw err;
|
| }
|
| }
|
| }
|
|
|
| const env: Record<string, string> = {
|
| ...getFilteredEnv(permissions),
|
| PLUGIN_ENTRY: entryPoint,
|
| PLUGIN_NAME: manifest.name,
|
| };
|
|
|
| const child = spawn(process.execPath, ["--no-warnings", hostScriptPath, entryPoint], {
|
| env,
|
| stdio: ["ignore", "ignore", "ignore", "ipc"],
|
| });
|
|
|
|
|
| const pendingCalls: Map<
|
| string,
|
| {
|
| resolve: (value: unknown) => void;
|
| reject: (reason: Error) => void;
|
| timer: ReturnType<typeof setTimeout>;
|
| }
|
| > = new Map();
|
| let callCounter = 0;
|
|
|
| child.on(
|
| "message",
|
| (msg: { type: string; id?: string; hooks?: string[]; result?: unknown; error?: string }) => {
|
| if (msg.type === "ready") {
|
| log.info("loader.process_ready", { name: manifest.name, hooks: msg.hooks });
|
| } else if (msg.type === "result" && msg.id) {
|
| const pending = pendingCalls.get(msg.id);
|
| if (pending) {
|
| clearTimeout(pending.timer);
|
| pendingCalls.delete(msg.id);
|
| if (msg.error) {
|
| pending.reject(new Error(msg.error));
|
| } else {
|
| pending.resolve(msg.result);
|
| }
|
| }
|
| }
|
| }
|
| );
|
|
|
| child.on("error", (err) => {
|
| log.error("loader.process_error", { name: manifest.name, error: err.message });
|
| });
|
|
|
| child.on("exit", (code) => {
|
| log.info("loader.process_exit", { name: manifest.name, code });
|
| for (const [, pending] of pendingCalls) {
|
| clearTimeout(pending.timer);
|
| pending.reject(new Error(`Plugin process exited with code ${code}`));
|
| }
|
| pendingCalls.clear();
|
| rm(hostScriptPath, { force: true }).catch(() => {});
|
| });
|
|
|
|
|
| const callHook = (
|
| hook: string,
|
| payload: unknown,
|
| timeout = DEFAULT_HOOK_TIMEOUT
|
| ): Promise<unknown> => {
|
| return new Promise((resolve, reject) => {
|
| const id = String(++callCounter);
|
| const timer = setTimeout(() => {
|
| pendingCalls.delete(id);
|
| child.kill("SIGTERM");
|
|
|
| const killTimer = setTimeout(() => {
|
| try {
|
| child.kill("SIGKILL");
|
| } catch {}
|
| }, SIGKILL_GRACE_MS);
|
| child.once("exit", () => clearTimeout(killTimer));
|
| reject(new Error(`Plugin hook '${hook}' timed out after ${timeout}ms`));
|
| }, timeout);
|
|
|
| pendingCalls.set(id, { resolve, reject, timer });
|
| child.send({ type: "call", id, hook, payload });
|
| });
|
| };
|
|
|
|
|
| const plugin: Plugin = {
|
| name: manifest.name,
|
| priority: 100,
|
| enabled: true,
|
| };
|
|
|
| const registeredHooks: string[] = [];
|
|
|
| if (manifest.hooks.onRequest) {
|
| plugin.onRequest = async (ctx: PluginContext): Promise<PluginResult | void> => {
|
| try {
|
| const result = await callHook("onRequest", ctx);
|
| return result as PluginResult | void;
|
| } catch (err: unknown) {
|
| log.error("plugin.onRequest_error", {
|
| name: manifest.name,
|
| error: err instanceof Error ? err.message : String(err),
|
| });
|
| }
|
| };
|
| registeredHooks.push("onRequest");
|
| }
|
|
|
| if (manifest.hooks.onResponse) {
|
| plugin.onResponse = async (ctx: PluginContext, response: unknown): Promise<unknown | void> => {
|
| try {
|
| return await callHook("onResponse", { ctx, response });
|
| } catch (err: unknown) {
|
| log.error("plugin.onResponse_error", {
|
| name: manifest.name,
|
| error: err instanceof Error ? err.message : String(err),
|
| });
|
| }
|
| };
|
| registeredHooks.push("onResponse");
|
| }
|
|
|
| if (manifest.hooks.onError) {
|
| plugin.onError = async (ctx: PluginContext, error: Error): Promise<unknown | void> => {
|
| try {
|
| return await callHook("onError", { ctx, error: error.message });
|
| } catch (err: unknown) {
|
| log.error("plugin.onError_error", {
|
| name: manifest.name,
|
| error: err instanceof Error ? err.message : String(err),
|
| });
|
| }
|
| };
|
| registeredHooks.push("onError");
|
| }
|
|
|
| log.info("loader.loaded", {
|
| name: manifest.name,
|
| hooks: registeredHooks,
|
| pid: child.pid,
|
| });
|
|
|
| const cleanup = () => {
|
| child.kill("SIGTERM");
|
|
|
| const killTimer = setTimeout(() => {
|
| try {
|
| child.kill("SIGKILL");
|
| } catch {}
|
| }, SIGKILL_GRACE_MS);
|
| child.once("exit", () => clearTimeout(killTimer));
|
| rm(hostScriptPath, { force: true }).catch(() => {});
|
| log.info("loader.cleanup", { name: manifest.name });
|
| };
|
|
|
| return { name: manifest.name, manifest, plugin, cleanup };
|
| }
|
|
|
| |
| |
| |
|
|
| function getFilteredEnv(permissions: Permission[]): Record<string, string> {
|
| const safeKeys = ["PATH", "HOME", "USER", "LANG", "LC_ALL", "NODE_ENV"];
|
| const extendedSafeKeys = [...safeKeys, "PORT", "HOSTNAME", "TZ", "TMPDIR"];
|
| const allowedKeys = permissions.includes("env") ? extendedSafeKeys : safeKeys;
|
| const env: Record<string, string> = {};
|
|
|
| for (const key of allowedKeys) {
|
| if (process.env[key] !== undefined) env[key] = process.env[key]!;
|
| }
|
|
|
| return env;
|
| }
|
|
|