From dad82d59f721a2aa79b8b1b70f8157ff912e2dd4 Mon Sep 17 00:00:00 2001 From: "R.D." Date: Fri, 29 May 2026 20:27:33 -0400 Subject: [PATCH 1/2] Fix plugin loader Turbopack build --- src/lib/plugins/loader.ts | 9 +++-- tests/unit/plugins-loader.test.ts | 57 ++++++++++++++++++++++++++++--- 2 files changed, 57 insertions(+), 9 deletions(-) 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 }); + } +}); From 150869198b3076eb97dd8e855131a0edfabae944 Mon Sep 17 00:00:00 2001 From: "R.D." Date: Fri, 29 May 2026 20:31:56 -0400 Subject: [PATCH 2/2] Address plugin loader review feedback --- src/lib/plugins/loader.ts | 2 +- tests/unit/plugins-loader.test.ts | 59 +++++++++++++++++-------------- 2 files changed, 33 insertions(+), 28 deletions(-) diff --git a/src/lib/plugins/loader.ts b/src/lib/plugins/loader.ts index c5d40e1a4d..5854209dc3 100644 --- a/src/lib/plugins/loader.ts +++ b/src/lib/plugins/loader.ts @@ -85,7 +85,7 @@ export async function loadPlugin( const child = spawn(process.execPath, ["--no-warnings", hostScriptPath, entryPoint], { env, - stdio: ["pipe", "pipe", "pipe", "ipc"], + stdio: ["ignore", "ignore", "ignore", "ipc"], }); // Track pending calls with timeout support diff --git a/tests/unit/plugins-loader.test.ts b/tests/unit/plugins-loader.test.ts index 873861b876..2e67022827 100644 --- a/tests/unit/plugins-loader.test.ts +++ b/tests/unit/plugins-loader.test.ts @@ -39,7 +39,7 @@ test("Plugin interface supports lifecycle hooks", () => { onRequest: async (_ctx: PluginContext): Promise => { return { blocked: false }; }, - onResponse: async (_ctx: PluginContext, response: any) => response, + onResponse: async (_ctx: PluginContext, response: unknown) => response, onError: async (_ctx: PluginContext, _error: Error) => null, }; assert.equal(typeof plugin.onRequest, "function"); @@ -77,13 +77,22 @@ test("PluginResult supports body modification", () => { 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"); +test( + "loadPlugin runs hooks in an isolated child process over IPC", + { timeout: 5_000 }, + async (t) => { + const pluginDir = await mkdtemp(join(tmpdir(), "omniroute-plugin-loader-")); + const entryPoint = join(pluginDir, "index.mjs"); + let loaded: LoadedPlugin | undefined; - await writeFile( - entryPoint, - ` + t.after(async () => { + loaded?.cleanup(); + await rm(pluginDir, { recursive: true, force: true }); + }); + + await writeFile( + entryPoint, + ` export async function onRequest(ctx) { return { body: { ...ctx.body, touchedByPlugin: true }, @@ -91,24 +100,23 @@ export async function onRequest(ctx) { }; } `, - "utf-8" - ); + "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: {}, - }); + 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" }, @@ -120,8 +128,5 @@ export async function onRequest(ctx) { body: { model: "gpt-4", touchedByPlugin: true }, metadata: { pluginHook: "onRequest" }, }); - } finally { - loaded.cleanup(); - await rm(pluginDir, { recursive: true, force: true }); } -}); +);