diff --git a/src/lib/plugins/loader.ts b/src/lib/plugins/loader.ts index b6a60181da..c5d40e1a4d 100644 --- a/src/lib/plugins/loader.ts +++ b/src/lib/plugins/loader.ts @@ -1,14 +1,14 @@ /** * Plugin loader — loads plugins in isolated child processes. * - * Uses child_process.fork() for process-level isolation. Each plugin + * Uses a child Node.js process with IPC for process-level isolation. Each plugin * runs in a separate Node.js process with restricted environment. * Complies with Rule 3 (no eval/new Function/implied eval). * * @module plugins/loader */ -import { fork } from "child_process"; +import { spawn } from "child_process"; import { writeFile, rm } from "fs/promises"; import { join } from "path"; import { tmpdir } from "os"; @@ -29,7 +29,7 @@ export interface LoadedPlugin { cleanup: () => void; } -// ── Plugin host script (runs in child process via fork) ── +// ── Plugin host script (runs in child process over IPC) ── // Uses process.send()/process.on("message") — NOT worker_threads. // Written as .mjs to force ESM execution regardless of package.json. @@ -83,10 +83,9 @@ export async function loadPlugin( PLUGIN_NAME: manifest.name, }; - const child = fork(hostScriptPath, [entryPoint], { + const child = spawn(process.execPath, ["--no-warnings", hostScriptPath, entryPoint], { env, stdio: ["pipe", "pipe", "pipe", "ipc"], - execArgv: ["--no-warnings"], }); // Track pending calls with timeout support diff --git a/tests/unit/plugins-loader.test.ts b/tests/unit/plugins-loader.test.ts index 8fbe097e84..873861b876 100644 --- a/tests/unit/plugins-loader.test.ts +++ b/tests/unit/plugins-loader.test.ts @@ -1,10 +1,10 @@ import test from "node:test"; import assert from "node:assert/strict"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; -// Loader uses child_process.fork() — we test the module exports and types, -// not actual fork behavior (that requires integration tests with real plugins). - -import type { LoadedPlugin } from "../../src/lib/plugins/loader.ts"; +import { loadPlugin, type LoadedPlugin } from "../../src/lib/plugins/loader.ts"; import type { Plugin, PluginContext, PluginResult } from "../../src/lib/plugins/index.ts"; // ── Type checks ── @@ -76,3 +76,52 @@ test("PluginResult supports body modification", () => { assert.equal(modified.body.model, "gpt-4-turbo"); assert.equal(modified.metadata?.plugin, "model-switcher"); }); + +test("loadPlugin runs hooks in an isolated child process over IPC", async () => { + const pluginDir = await mkdtemp(join(tmpdir(), "omniroute-plugin-loader-")); + const entryPoint = join(pluginDir, "index.mjs"); + + await writeFile( + entryPoint, + ` +export async function onRequest(ctx) { + return { + body: { ...ctx.body, touchedByPlugin: true }, + metadata: { pluginHook: "onRequest" }, + }; +} +`, + "utf-8" + ); + + const loaded = await loadPlugin(entryPoint, { + name: "ipc-test", + version: "1.0.0", + license: "MIT", + main: "index.mjs", + source: "local", + tags: [], + requires: { permissions: [] }, + hooks: { onRequest: true, onResponse: false, onError: false }, + skills: [], + enabledByDefault: false, + configSchema: {}, + }); + + try { + const result = await loaded.plugin.onRequest?.({ + requestId: "test-request", + body: { model: "gpt-4" }, + model: "gpt-4", + metadata: {}, + }); + + assert.deepEqual(result, { + body: { model: "gpt-4", touchedByPlugin: true }, + metadata: { pluginHook: "onRequest" }, + }); + } finally { + loaded.cleanup(); + await rm(pluginDir, { recursive: true, force: true }); + } +});