feat(plugins): WordPress-style plugin system backend

This commit is contained in:
oyi77
2026-05-19 05:42:12 +07:00
committed by diegosouzapw
parent 25741a7fb0
commit 0974b96fa2
16 changed files with 1455 additions and 1 deletions

View File

@@ -1525,6 +1525,50 @@ export async function handleChatCore({
};
let tokensCompressed: number | null = null;
body = injectSystemPrompt(body);
// ── Plugin onRequest hook ──
try {
const { runOnRequest } = await import("@/lib/plugins/index");
const pluginCtx = {
requestId: traceId,
body,
model,
provider,
apiKeyInfo,
metadata: {},
};
const pluginResult = await runOnRequest(pluginCtx);
if (pluginResult?.blocked) {
log?.info?.("PLUGIN", `Request blocked by plugin`);
return {
success: false,
status: 403,
error: "Request blocked by plugin",
response: pluginResult.response
? new Response(JSON.stringify(pluginResult.response), {
status: 403,
headers: { "Content-Type": "application/json" },
})
: new Response(
JSON.stringify({
error: { message: "Request blocked by plugin", type: "plugin_block" },
}),
{
status: 403,
headers: { "Content-Type": "application/json" },
}
),
};
}
if (pluginResult?.ctx && "body" in pluginResult.ctx) {
body = (pluginResult.ctx as Record<string, unknown>).body;
}
} catch (pluginErr) {
log?.debug?.(
"PLUGIN",
`onRequest hook error (non-fatal): ${pluginErr instanceof Error ? pluginErr.message : String(pluginErr)}`
);
}
type EffectiveServiceTier = "standard" | CodexServiceTier;
let effectiveServiceTier: EffectiveServiceTier = "standard";
const resolveEffectiveServiceTier = (requestBody?: unknown): EffectiveServiceTier => {
@@ -3199,6 +3243,20 @@ export async function handleChatCore({
);
}
} catch (error) {
// ── Plugin onError hook ──
try {
const { runOnError } = await import("@/lib/plugins/index");
await runOnError(
{ requestId: traceId, body, model, provider, apiKeyInfo, metadata: {} },
error instanceof Error ? error : new Error(String(error))
);
} catch (pluginErr) {
log?.debug?.(
"PLUGIN",
`onError hook error (non-fatal): ${pluginErr instanceof Error ? pluginErr.message : String(pluginErr)}`
);
}
const parsedStatus = Number(error?.statusCode);
const statusCode =
Number.isInteger(parsedStatus) && parsedStatus >= 400 && parsedStatus <= 599

View File

@@ -75,6 +75,7 @@ import {
} from "./tools/advancedTools.ts";
import { memoryTools } from "./tools/memoryTools.ts";
import { skillTools } from "./tools/skillTools.ts";
import { pluginTools } from "./tools/pluginTools.ts";
import { compressionTools } from "./tools/compressionTools.ts";
import { gamificationTools } from "./tools/gamificationTools.ts";
import { compressMcpRegistryMetadata } from "./descriptionCompressor.ts";
@@ -102,7 +103,8 @@ const TOTAL_MCP_TOOL_COUNT =
MCP_TOOLS.length +
Object.keys(memoryTools).length +
Object.keys(skillTools).length +
gamificationTools.length;
gamificationTools.length +
pluginTools.length;
type JsonRecord = Record<string, unknown>;
@@ -1003,6 +1005,29 @@ export function createMcpServer(): McpServer {
);
});
// ── Plugin Tools ──────────────────────────────
pluginTools.forEach((toolDef) => {
server.registerTool(
toolDef.name,
{
description: toolDef.description,
// @ts-ignore: dynamic zod access
inputSchema: toolDef.inputSchema,
},
withScopeEnforcement(toolDef.name, async (args) => {
try {
const parsedArgs = toolDef.inputSchema.parse(args ?? {});
// @ts-ignore: handler expected specific object
const result = await toolDef.handler(parsedArgs);
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
}
})
);
});
// ── Compression Tools ─────────────────────────
Object.values(compressionTools).forEach((toolDef) => {
server.registerTool(

View File

@@ -0,0 +1,149 @@
/**
* MCP Plugin Tools — 8 tools for plugin management.
*
* @module mcp-server/tools/pluginTools
*/
import { z } from "zod";
import { listPlugins, getPluginByName, updatePluginConfig } from "../../../src/lib/db/plugins";
import { pluginManager } from "../../../src/lib/plugins/manager";
export const pluginTools = [
{
name: "plugin_list",
description: "List all installed plugins with their status, hooks, and metadata.",
inputSchema: z.object({
status: z
.enum(["installed", "active", "inactive", "error"])
.optional()
.describe("Filter by plugin status"),
}),
handler: async (args: { status?: string }) => {
const plugins = listPlugins(args.status as any);
return {
plugins: plugins.map((p) => ({
name: p.name,
version: p.version,
description: p.description,
status: p.status,
enabled: p.enabled === 1,
hooks: JSON.parse(p.hooks || "[]"),
permissions: JSON.parse(p.permissions || "[]"),
installedAt: p.installedAt,
activatedAt: p.activatedAt,
})),
};
},
},
{
name: "plugin_install",
description: "Install a plugin from a local directory path.",
inputSchema: z.object({
path: z.string().describe("Absolute path to the plugin directory containing plugin.json"),
}),
handler: async (args: { path: string }) => {
const plugin = await pluginManager.install(args.path);
return {
success: true,
plugin: {
name: plugin.name,
version: plugin.version,
status: plugin.status,
},
};
},
},
{
name: "plugin_activate",
description: "Activate an installed plugin (loads hooks into the request pipeline).",
inputSchema: z.object({
name: z.string().describe("Plugin name (kebab-case)"),
}),
handler: async (args: { name: string }) => {
await pluginManager.activate(args.name);
return { success: true, message: `Plugin '${args.name}' activated` };
},
},
{
name: "plugin_deactivate",
description: "Deactivate an active plugin (unloads hooks from the request pipeline).",
inputSchema: z.object({
name: z.string().describe("Plugin name (kebab-case)"),
}),
handler: async (args: { name: string }) => {
await pluginManager.deactivate(args.name);
return { success: true, message: `Plugin '${args.name}' deactivated` };
},
},
{
name: "plugin_uninstall",
description: "Uninstall a plugin (deactivates, removes files, removes from DB).",
inputSchema: z.object({
name: z.string().describe("Plugin name (kebab-case)"),
}),
handler: async (args: { name: string }) => {
await pluginManager.uninstall(args.name);
return { success: true, message: `Plugin '${args.name}' uninstalled` };
},
},
{
name: "plugin_configure",
description: "Get or update a plugin's configuration.",
inputSchema: z.object({
name: z.string().describe("Plugin name"),
config: z
.record(z.string(), z.unknown())
.optional()
.describe("New config values to merge (omit to just read current config)"),
}),
handler: async (args: { name: string; config?: Record<string, unknown> }) => {
const plugin = getPluginByName(args.name);
if (!plugin) throw new Error(`Plugin '${args.name}' not found`);
if (args.config) {
const current = JSON.parse(plugin.config || "{}");
const merged = { ...current, ...args.config };
updatePluginConfig(args.name, merged);
return { success: true, config: merged };
}
return {
config: JSON.parse(plugin.config || "{}"),
configSchema: JSON.parse(plugin.configSchema || "{}"),
};
},
},
{
name: "plugin_executions",
description: "View plugin execution history (from skill_executions table).",
inputSchema: z.object({
name: z.string().optional().describe("Filter by plugin name"),
limit: z.number().min(1).max(100).default(20).describe("Max results to return"),
}),
handler: async (args: { name?: string; limit?: number }) => {
// Plugin executions are tracked via the skills system
const { skillExecutor } = await import("../../../src/lib/skills/executor");
const executions = skillExecutor.listExecutions(undefined, args.limit || 20);
return { executions };
},
},
{
name: "plugin_scan",
description: "Scan the plugin directory for new plugins and sync with DB.",
inputSchema: z.object({}),
handler: async () => {
const result = await pluginManager.scan();
return {
discovered: result.discovered,
errors: result.errors,
};
},
},
];

View File

@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from "next/server";
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
import { pluginManager } from "@/lib/plugins/manager";
export async function OPTIONS() {
return handleCorsOptions();
}
/**
* POST /api/plugins/[name]/activate — Activate a plugin
*/
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ name: string }> }
) {
const { name } = await params;
try {
await pluginManager.activate(name);
return NextResponse.json(
{ success: true, message: `Plugin '${name}' activated` },
{ headers: CORS_HEADERS }
);
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 400, headers: CORS_HEADERS });
}
}

View File

@@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from "next/server";
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
import { getPluginByName, updatePluginConfig } from "@/lib/db/plugins";
import { z } from "zod";
export async function OPTIONS() {
return handleCorsOptions();
}
/**
* GET /api/plugins/[name]/config — Get plugin configuration
*/
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ name: string }> }
) {
const { name } = await params;
const plugin = getPluginByName(name);
if (!plugin) {
return NextResponse.json(
{ error: `Plugin '${name}' not found` },
{ status: 404, headers: CORS_HEADERS }
);
}
return NextResponse.json(
{
config: JSON.parse(plugin.config || "{}"),
configSchema: JSON.parse(plugin.configSchema || "{}"),
},
{ headers: CORS_HEADERS }
);
}
/**
* PUT /api/plugins/[name]/config — Update plugin configuration
*/
export async function PUT(request: NextRequest, { params }: { params: Promise<{ name: string }> }) {
const { name } = await params;
const body = await request.json();
const schema = z.object({
config: z.record(z.string(), z.unknown()),
});
const parsed = schema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid request", details: parsed.error.issues },
{ status: 400, headers: CORS_HEADERS }
);
}
const plugin = getPluginByName(name);
if (!plugin) {
return NextResponse.json(
{ error: `Plugin '${name}' not found` },
{ status: 404, headers: CORS_HEADERS }
);
}
updatePluginConfig(name, parsed.data.config);
return NextResponse.json(
{ success: true, config: parsed.data.config },
{ headers: CORS_HEADERS }
);
}

View File

@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from "next/server";
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
import { pluginManager } from "@/lib/plugins/manager";
export async function OPTIONS() {
return handleCorsOptions();
}
/**
* POST /api/plugins/[name]/deactivate — Deactivate a plugin
*/
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ name: string }> }
) {
const { name } = await params;
try {
await pluginManager.deactivate(name);
return NextResponse.json(
{ success: true, message: `Plugin '${name}' deactivated` },
{ headers: CORS_HEADERS }
);
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 400, headers: CORS_HEADERS });
}
}

View File

@@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from "next/server";
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
import { getPluginByName } from "@/lib/db/plugins";
import { pluginManager } from "@/lib/plugins/manager";
export async function OPTIONS() {
return handleCorsOptions();
}
/**
* GET /api/plugins/[name] — Get plugin details
*/
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ name: string }> }
) {
const { name } = await params;
const plugin = getPluginByName(name);
if (!plugin) {
return NextResponse.json(
{ error: `Plugin '${name}' not found` },
{ status: 404, headers: CORS_HEADERS }
);
}
return NextResponse.json(
{
plugin: {
id: plugin.id,
name: plugin.name,
version: plugin.version,
description: plugin.description,
author: plugin.author,
license: plugin.license,
main: plugin.main,
source: plugin.source,
tags: JSON.parse(plugin.tags || "[]"),
status: plugin.status,
enabled: plugin.enabled === 1,
config: JSON.parse(plugin.config || "{}"),
configSchema: JSON.parse(plugin.configSchema || "{}"),
hooks: JSON.parse(plugin.hooks || "[]"),
permissions: JSON.parse(plugin.permissions || "[]"),
pluginDir: plugin.pluginDir,
errorMessage: plugin.errorMessage,
installedAt: plugin.installedAt,
updatedAt: plugin.updatedAt,
activatedAt: plugin.activatedAt,
},
},
{ headers: CORS_HEADERS }
);
}
/**
* DELETE /api/plugins/[name] — Uninstall a plugin
*/
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ name: string }> }
) {
const { name } = await params;
try {
await pluginManager.uninstall(name);
return NextResponse.json(
{ success: true, message: `Plugin '${name}' uninstalled` },
{ headers: CORS_HEADERS }
);
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 400, headers: CORS_HEADERS });
}
}

View File

@@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from "next/server";
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
import { listPlugins } from "@/lib/db/plugins";
import { pluginManager } from "@/lib/plugins/manager";
import { z } from "zod";
export async function OPTIONS() {
return handleCorsOptions();
}
/**
* GET /api/plugins — List all installed plugins
*/
export async function GET(request: NextRequest) {
const url = new URL(request.url);
const status = url.searchParams.get("status") as any;
try {
const plugins = listPlugins(status || undefined);
return NextResponse.json({ plugins: plugins.map(formatPlugin) }, { headers: CORS_HEADERS });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500, headers: CORS_HEADERS });
}
}
/**
* POST /api/plugins — Install a plugin from a local path
*/
export async function POST(request: NextRequest) {
const body = await request.json();
const schema = z.object({
path: z.string().min(1),
});
const parsed = schema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid request", details: parsed.error.issues },
{ status: 400, headers: CORS_HEADERS }
);
}
try {
const plugin = await pluginManager.install(parsed.data.path);
return NextResponse.json(
{ plugin: formatPlugin(plugin) },
{ status: 201, headers: CORS_HEADERS }
);
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 400, headers: CORS_HEADERS });
}
}
function formatPlugin(row: any) {
return {
id: row.id,
name: row.name,
version: row.version,
description: row.description,
author: row.author,
status: row.status,
enabled: row.enabled === 1,
hooks: JSON.parse(row.hooks || "[]"),
permissions: JSON.parse(row.permissions || "[]"),
installedAt: row.installedAt,
updatedAt: row.updatedAt,
activatedAt: row.activatedAt,
};
}

View File

@@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from "next/server";
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
import { pluginManager } from "@/lib/plugins/manager";
export async function OPTIONS() {
return handleCorsOptions();
}
/**
* POST /api/plugins/scan — Scan plugin directory for new plugins
*/
export async function POST(_request: NextRequest) {
try {
const result = await pluginManager.scan();
return NextResponse.json(
{ discovered: result.discovered, errors: result.errors },
{ headers: CORS_HEADERS }
);
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500, headers: CORS_HEADERS });
}
}

View File

@@ -0,0 +1,31 @@
-- 059: Plugin system tables
-- WordPress-style plugin management with lifecycle tracking
CREATE TABLE IF NOT EXISTS plugins (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
version TEXT NOT NULL DEFAULT '1.0.0',
description TEXT,
author TEXT,
license TEXT DEFAULT 'MIT',
main TEXT NOT NULL DEFAULT 'index.js',
source TEXT NOT NULL DEFAULT 'local',
tags TEXT DEFAULT '[]',
status TEXT NOT NULL DEFAULT 'installed'
CHECK (status IN ('installed', 'active', 'inactive', 'error')),
enabled INTEGER NOT NULL DEFAULT 0,
manifest TEXT NOT NULL,
config TEXT DEFAULT '{}',
config_schema TEXT DEFAULT '{}',
hooks TEXT DEFAULT '[]',
permissions TEXT DEFAULT '[]',
plugin_dir TEXT NOT NULL,
error_message TEXT,
installed_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
activated_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_plugins_status ON plugins(status);
CREATE INDEX IF NOT EXISTS idx_plugins_enabled ON plugins(enabled);
CREATE INDEX IF NOT EXISTS idx_plugins_name ON plugins(name);

191
src/lib/db/plugins.ts Normal file
View File

@@ -0,0 +1,191 @@
/**
* Plugin DB module — CRUD operations for the plugins table.
*
* @module db/plugins
*/
import { getDbInstance } from "./core";
import { logger } from "../../../open-sse/utils/logger.ts";
const log = logger("DB_PLUGINS");
// ── Types ──
export interface PluginRow {
id: string;
name: string;
version: string;
description: string | null;
author: string | null;
license: string;
main: string;
source: string;
tags: string; // JSON array
status: "installed" | "active" | "inactive" | "error";
enabled: number; // 0 | 1
manifest: string; // JSON
config: string; // JSON
configSchema: string; // JSON
hooks: string; // JSON array
permissions: string; // JSON array
pluginDir: string;
errorMessage: string | null;
installedAt: string;
updatedAt: string;
activatedAt: string | null;
}
export interface PluginCreateInput {
id: string;
name: string;
version: string;
description?: string;
author?: string;
license?: string;
main: string;
source?: string;
tags?: string[];
status?: PluginRow["status"];
enabled?: boolean;
manifest: Record<string, unknown>;
config?: Record<string, unknown>;
configSchema?: Record<string, unknown>;
hooks?: string[];
permissions?: string[];
pluginDir: string;
}
// ── Helpers ──
function rowToPlugin(row: any): PluginRow {
return {
id: row.id,
name: row.name,
version: row.version,
description: row.description,
author: row.author,
license: row.license,
main: row.main,
source: row.source,
tags: row.tags,
status: row.status,
enabled: row.enabled,
manifest: row.manifest,
config: row.config,
configSchema: row.config_schema,
hooks: row.hooks,
permissions: row.permissions,
pluginDir: row.plugin_dir,
errorMessage: row.error_message,
installedAt: row.installed_at,
updatedAt: row.updated_at,
activatedAt: row.activated_at,
};
}
// ── CRUD ──
export function insertPlugin(input: PluginCreateInput): PluginRow {
const db = getDbInstance();
const now = new Date().toISOString();
db.prepare(
`INSERT INTO plugins (
id, name, version, description, author, license, main, source, tags,
status, enabled, manifest, config, config_schema, hooks, permissions,
plugin_dir, installed_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
input.id,
input.name,
input.version,
input.description ?? null,
input.author ?? null,
input.license ?? "MIT",
input.main,
input.source ?? "local",
JSON.stringify(input.tags ?? []),
input.status ?? "installed",
input.enabled ? 1 : 0,
JSON.stringify(input.manifest),
JSON.stringify(input.config ?? {}),
JSON.stringify(input.configSchema ?? {}),
JSON.stringify(input.hooks ?? []),
JSON.stringify(input.permissions ?? []),
input.pluginDir,
now,
now
);
log.info("plugin.inserted", { id: input.id, name: input.name });
return getPluginByName(input.name)!;
}
export function getPluginById(id: string): PluginRow | null {
const db = getDbInstance();
const row = db.prepare("SELECT * FROM plugins WHERE id = ?").get(id);
return row ? rowToPlugin(row) : null;
}
export function getPluginByName(name: string): PluginRow | null {
const db = getDbInstance();
const row = db.prepare("SELECT * FROM plugins WHERE name = ?").get(name);
return row ? rowToPlugin(row) : null;
}
export function listPlugins(status?: PluginRow["status"]): PluginRow[] {
const db = getDbInstance();
const rows = status
? db.prepare("SELECT * FROM plugins WHERE status = ? ORDER BY name").all(status)
: db.prepare("SELECT * FROM plugins ORDER BY name").all();
return rows.map(rowToPlugin);
}
export function updatePluginStatus(
name: string,
status: PluginRow["status"],
errorMessage?: string
): boolean {
const db = getDbInstance();
const now = new Date().toISOString();
const activatedAt = status === "active" ? now : null;
const result = db
.prepare(
`UPDATE plugins SET status = ?, enabled = ?, error_message = ?,
updated_at = ?, activated_at = COALESCE(?, activated_at)
WHERE name = ?`
)
.run(status, status === "active" ? 1 : 0, errorMessage ?? null, now, activatedAt, name);
if (result.changes > 0) {
log.info("plugin.status_updated", { name, status });
}
return result.changes > 0;
}
export function updatePluginConfig(name: string, config: Record<string, unknown>): boolean {
const db = getDbInstance();
const now = new Date().toISOString();
const result = db
.prepare("UPDATE plugins SET config = ?, updated_at = ? WHERE name = ?")
.run(JSON.stringify(config), now, name);
return result.changes > 0;
}
export function deletePlugin(name: string): boolean {
const db = getDbInstance();
const result = db.prepare("DELETE FROM plugins WHERE name = ?").run(name);
if (result.changes > 0) {
log.info("plugin.deleted", { name });
}
return result.changes > 0;
}
export function pluginExists(name: string): boolean {
const db = getDbInstance();
const row = db.prepare("SELECT 1 FROM plugins WHERE name = ?").get(name);
return !!row;
}

View File

@@ -527,3 +527,16 @@ export type {
UpsertTokenLimitInput,
TokenWindowState,
} from "./db/tokenLimits";
export {
insertPlugin,
getPluginById,
getPluginByName,
listPlugins,
updatePluginStatus,
updatePluginConfig,
deletePlugin,
pluginExists,
} from "./db/plugins";
export type { PluginRow, PluginCreateInput } from "./db/plugins";

210
src/lib/plugins/loader.ts Normal file
View File

@@ -0,0 +1,210 @@
/**
* Plugin loader — loads plugins in sandboxed VM contexts.
*
* Uses Node.js vm module for in-process isolation. Plugins run in a
* restricted context with permission-gated globals.
*
* @module plugins/loader
*/
import { readFile } from "fs/promises";
import * as vm from "vm";
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");
export interface LoadedPlugin {
name: string;
manifest: PluginManifestWithDefaults;
plugin: Plugin;
cleanup: () => void;
}
/**
* Create a sandboxed context with permission-gated globals.
*/
function createSandbox(permissions: Permission[]): Record<string, unknown> {
const sandbox: Record<string, unknown> = {
console: {
log: (...args: unknown[]) => log.info("plugin.log", { args }),
warn: (...args: unknown[]) => log.warn("plugin.warn", { args }),
error: (...args: unknown[]) => log.error("plugin.error", { args }),
},
setTimeout,
clearTimeout,
setInterval,
clearInterval,
Promise,
JSON,
Math,
Date,
Array,
Object,
String,
Number,
Boolean,
RegExp,
Error,
TypeError,
RangeError,
SyntaxError,
URIError,
Map,
Set,
WeakMap,
WeakSet,
Symbol,
parseInt,
parseFloat,
isNaN,
isFinite,
Buffer,
URL,
URLSearchParams,
};
if (permissions.includes("network")) {
sandbox.fetch = globalThis.fetch;
sandbox.AbortController = globalThis.AbortController;
sandbox.Headers = globalThis.Headers;
sandbox.Request = globalThis.Request;
sandbox.Response = globalThis.Response;
}
return sandbox;
}
/**
* Load a plugin entry point in a VM context.
* Returns the exported hooks (onRequest, onResponse, onError) wrapped as a Plugin.
*/
export async function loadPlugin(
entryPoint: string,
manifest: PluginManifestWithDefaults
): Promise<LoadedPlugin> {
const permissions = manifest.requires.permissions;
const sandbox = createSandbox(permissions);
// Read plugin source
const source = await readFile(entryPoint, "utf-8");
// Create VM context
const context = vm.createContext(sandbox);
// Provide a minimal module system
const moduleExports: Record<string, any> = {};
const moduleObj = { exports: moduleExports };
sandbox.module = moduleObj;
sandbox.exports = moduleExports;
sandbox.require = (id: string) => {
// Only allow specific safe modules
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`);
};
try {
// Wrap source in a function to capture exports
const wrapped = `(async function(module, exports, require) { ${source} })(module, exports, require);`;
vm.runInContext(wrapped, context, {
filename: entryPoint,
timeout: 10000, // 10s init timeout
});
} catch (err: any) {
log.error("loader.vm_error", { name: manifest.name, error: err.message });
throw new Error(`Failed to load plugin '${manifest.name}': ${err.message}`);
}
// Extract exports
const pluginExports = moduleObj.exports;
// Build Plugin interface
const hooks: string[] = [];
const plugin: Plugin = {
name: manifest.name,
priority: 100,
enabled: true,
};
if (typeof pluginExports.onRequest === "function") {
plugin.onRequest = async (ctx: PluginContext): Promise<PluginResult | void> => {
try {
return await pluginExports.onRequest(ctx);
} catch (err: any) {
log.error("plugin.onRequest_error", { name: manifest.name, error: err.message });
}
};
hooks.push("onRequest");
}
if (typeof pluginExports.onResponse === "function") {
plugin.onResponse = async (ctx: PluginContext, response: any): Promise<any | void> => {
try {
return await pluginExports.onResponse(ctx, response);
} catch (err: any) {
log.error("plugin.onResponse_error", { name: manifest.name, error: err.message });
}
};
hooks.push("onResponse");
}
if (typeof pluginExports.onError === "function") {
plugin.onError = async (ctx: PluginContext, error: Error): Promise<any | void> => {
try {
return await pluginExports.onError(ctx, error);
} catch (err: any) {
log.error("plugin.onError_error", { name: manifest.name, error: err.message });
}
};
hooks.push("onError");
}
// Also check for default export
if (pluginExports.default && typeof pluginExports.default === "object") {
const def = pluginExports.default;
if (typeof def.onRequest === "function" && !plugin.onRequest) {
plugin.onRequest = async (ctx) => {
try {
return await def.onRequest(ctx);
} catch (err: any) {
log.error("plugin.onRequest_error", { name: manifest.name, error: err.message });
}
};
hooks.push("onRequest");
}
if (typeof def.onResponse === "function" && !plugin.onResponse) {
plugin.onResponse = async (ctx, resp) => {
try {
return await def.onResponse(ctx, resp);
} catch (err: any) {
log.error("plugin.onResponse_error", { name: manifest.name, error: err.message });
}
};
hooks.push("onResponse");
}
if (typeof def.onError === "function" && !plugin.onError) {
plugin.onError = async (ctx, err) => {
try {
return await def.onError(ctx, err);
} catch (e: any) {
log.error("plugin.onError_error", { name: manifest.name, error: e.message });
}
};
hooks.push("onError");
}
}
log.info("loader.loaded", { name: manifest.name, hooks });
const cleanup = () => {
// VM contexts are GC'd when no references remain
log.info("loader.cleanup", { name: manifest.name });
};
return { name: manifest.name, manifest, plugin, cleanup };
}

250
src/lib/plugins/manager.ts Normal file
View File

@@ -0,0 +1,250 @@
/**
* Plugin manager — lifecycle management for plugins.
*
* Singleton that coordinates scanner, loader, DB, and hook registry.
* Handles install, activate, deactivate, uninstall, scan, and startup loading.
*
* @module plugins/manager
*/
import { mkdir, cp, rm } from "fs/promises";
import { join, dirname } from "path";
import { randomUUID } from "crypto";
import { logger } from "../../../open-sse/utils/logger.ts";
import { getDefaultPluginDir, scanPluginDir } from "./scanner";
import { loadPlugin, type LoadedPlugin } from "./loader";
import { registerPlugin, unregisterPlugin } from "./index";
import {
insertPlugin,
getPluginByName,
listPlugins as dbListPlugins,
updatePluginStatus,
deletePlugin as dbDeletePlugin,
pluginExists,
type PluginRow,
} from "../db/plugins";
import type { PluginManifestWithDefaults } from "./manifest";
const log = logger("PLUGIN_MANAGER");
class PluginManager {
private static instance: PluginManager;
private loadedPlugins: Map<string, LoadedPlugin> = new Map();
private pluginDir: string;
private constructor() {
this.pluginDir = getDefaultPluginDir();
}
static getInstance(): PluginManager {
if (!PluginManager.instance) {
PluginManager.instance = new PluginManager();
}
return PluginManager.instance;
}
/**
* Install a plugin from a source directory.
* Copies to plugin dir, validates manifest, registers in DB.
*/
async install(sourceDir: string): Promise<PluginRow> {
const { plugins, errors } = await scanPluginDir(sourceDir);
if (plugins.length === 0) {
throw new Error(
`No valid plugin found in ${sourceDir}: ${errors.map((e) => e.error).join(", ")}`
);
}
const discovered = plugins[0];
const { name, manifest, pluginDir: srcDir } = discovered;
// Check if already installed
if (pluginExists(name)) {
throw new Error(`Plugin '${name}' is already installed`);
}
// Copy to plugin directory
const destDir = join(this.pluginDir, name);
await mkdir(dirname(destDir), { recursive: true });
await cp(srcDir, destDir, { recursive: true });
// Register in DB
const row = insertPlugin({
id: randomUUID(),
name,
version: manifest.version,
description: manifest.description,
author: manifest.author,
license: manifest.license,
main: manifest.main,
source: manifest.source,
tags: manifest.tags,
manifest: manifest as unknown as Record<string, unknown>,
configSchema: manifest.configSchema as unknown as Record<string, unknown>,
hooks: [
manifest.hooks.onRequest && "onRequest",
manifest.hooks.onResponse && "onResponse",
manifest.hooks.onError && "onError",
].filter(Boolean) as string[],
permissions: manifest.requires.permissions,
pluginDir: destDir,
enabled: manifest.enabledByDefault,
});
log.info("manager.installed", { name, version: manifest.version });
// Auto-activate if enabledByDefault
if (manifest.enabledByDefault) {
await this.activate(name);
}
return row;
}
/**
* Activate a plugin — load into VM, register hooks, update DB.
*/
async activate(name: string): Promise<void> {
const row = getPluginByName(name);
if (!row) throw new Error(`Plugin '${name}' not found`);
if (row.status === "active") return;
const manifest = JSON.parse(row.manifest) as PluginManifestWithDefaults;
const entryPoint = join(row.pluginDir, manifest.main);
try {
const loaded = await loadPlugin(entryPoint, manifest);
// Register hooks with the existing plugin system
registerPlugin(loaded.plugin);
this.loadedPlugins.set(name, loaded);
updatePluginStatus(name, "active");
log.info("manager.activated", { name });
} catch (err: any) {
updatePluginStatus(name, "error", err.message);
log.error("manager.activate_failed", { name, error: err.message });
throw err;
}
}
/**
* Deactivate a plugin — unregister hooks, update DB.
*/
async deactivate(name: string): Promise<void> {
const loaded = this.loadedPlugins.get(name);
if (loaded) {
unregisterPlugin(name);
loaded.cleanup();
this.loadedPlugins.delete(name);
}
updatePluginStatus(name, "inactive");
log.info("manager.deactivated", { name });
}
/**
* Uninstall a plugin — deactivate, delete directory, remove from DB.
*/
async uninstall(name: string): Promise<void> {
const row = getPluginByName(name);
if (!row) throw new Error(`Plugin '${name}' not found`);
// Deactivate first if active
if (row.status === "active") {
await this.deactivate(name);
}
// Delete plugin directory
try {
await rm(row.pluginDir, { recursive: true, force: true });
} catch (err: any) {
log.warn("manager.uninstall_dir_error", { name, error: err.message });
}
// Remove from DB
dbDeletePlugin(name);
log.info("manager.uninstalled", { name });
}
/**
* Scan plugin directory and sync with DB.
* Discovers new plugins and marks missing ones.
*/
async scan(): Promise<{ discovered: number; errors: Array<{ name: string; error: string }> }> {
const { plugins, errors } = await scanPluginDir(this.pluginDir);
// Register newly discovered plugins that aren't in DB
for (const discovered of plugins) {
if (!pluginExists(discovered.name)) {
try {
insertPlugin({
id: randomUUID(),
name: discovered.name,
version: discovered.manifest.version,
description: discovered.manifest.description,
author: discovered.manifest.author,
license: discovered.manifest.license,
main: discovered.manifest.main,
source: discovered.manifest.source,
tags: discovered.manifest.tags,
manifest: discovered.manifest as unknown as Record<string, unknown>,
configSchema: discovered.manifest.configSchema as unknown as Record<string, unknown>,
hooks: [
discovered.manifest.hooks.onRequest && "onRequest",
discovered.manifest.hooks.onResponse && "onResponse",
discovered.manifest.hooks.onError && "onError",
].filter(Boolean) as string[],
permissions: discovered.manifest.requires.permissions,
pluginDir: discovered.pluginDir,
enabled: discovered.manifest.enabledByDefault,
});
} catch (err: any) {
errors.push({ name: discovered.name, error: `DB insert failed: ${err.message}` });
}
}
}
return { discovered: plugins.length, errors };
}
/**
* Load all active plugins on startup.
*/
async loadAll(): Promise<void> {
const rows = dbListPlugins("active");
log.info("manager.loadAll", { count: rows.length });
for (const row of rows) {
try {
await this.activate(row.name);
} catch (err: any) {
log.error("manager.loadAll_failed", { name: row.name, error: err.message });
}
}
}
/**
* Get a loaded plugin by name.
*/
getLoaded(name: string): LoadedPlugin | undefined {
return this.loadedPlugins.get(name);
}
/**
* List all plugins from DB.
*/
listAll(): PluginRow[] {
return dbListPlugins();
}
/**
* Get plugin by name from DB.
*/
getPlugin(name: string): PluginRow | null {
return getPluginByName(name);
}
}
export const pluginManager = PluginManager.getInstance();

129
src/lib/plugins/manifest.ts Normal file
View File

@@ -0,0 +1,129 @@
/**
* Plugin manifest validator — Zod schema for plugin.json files.
*
* @module plugins/manifest
*/
import { z } from "zod";
// ── Permission enum ──
export const PermissionSchema = z.enum(["network", "file-read", "file-write", "env", "exec"]);
export type Permission = z.infer<typeof PermissionSchema>;
// ── Skill definition in manifest ──
export const ManifestSkillSchema = z.object({
name: z.string().min(1).max(100),
description: z.string().max(500).optional(),
input: z.record(z.string(), z.unknown()).optional(),
output: z.record(z.string(), z.unknown()).optional(),
});
export type ManifestSkill = z.infer<typeof ManifestSkillSchema>;
// ── Config schema field ──
export const ConfigFieldSchema = z.object({
type: z.enum(["string", "number", "boolean", "select"]),
default: z.unknown().optional(),
min: z.number().optional(),
max: z.number().optional(),
enum: z.array(z.string()).optional(),
description: z.string().optional(),
});
export type ConfigField = z.infer<typeof ConfigFieldSchema>;
// ── Hooks ──
export const HooksSchema = z.object({
onRequest: z.boolean().optional(),
onResponse: z.boolean().optional(),
onError: z.boolean().optional(),
});
// ── Requires ──
export const RequiresSchema = z.object({
omniroute: z.string().optional(),
permissions: z.array(PermissionSchema).optional(),
});
// ── Full manifest ──
export const PluginManifestSchema = z.object({
name: z
.string()
.min(1)
.max(100)
.regex(/^[a-z0-9-]+$/, "Name must be kebab-case (lowercase, hyphens only)"),
version: z.string().regex(/^\d+\.\d+\.\d+$/, "Version must be semver (e.g. 1.0.0)"),
description: z.string().max(500).optional(),
author: z.string().max(200).optional(),
license: z.string().optional(),
main: z.string().optional(),
source: z.enum(["local", "marketplace"]).optional(),
tags: z.array(z.string()).optional(),
requires: RequiresSchema.optional(),
hooks: HooksSchema.optional(),
skills: z.array(ManifestSkillSchema).optional(),
enabledByDefault: z.boolean().optional(),
configSchema: z.record(z.string(), ConfigFieldSchema).optional(),
});
export type PluginManifest = z.infer<typeof PluginManifestSchema>;
// ── Defaults applied after parsing ──
export interface PluginManifestWithDefaults extends PluginManifest {
license: string;
main: string;
source: "local" | "marketplace";
tags: string[];
requires: { omniroute?: string; permissions: Permission[] };
hooks: { onRequest: boolean; onResponse: boolean; onError: boolean };
skills: ManifestSkill[];
enabledByDefault: boolean;
configSchema: Record<string, ConfigField>;
}
export function applyDefaults(manifest: PluginManifest): PluginManifestWithDefaults {
return {
...manifest,
license: manifest.license ?? "MIT",
main: manifest.main ?? "index.js",
source: manifest.source ?? "local",
tags: manifest.tags ?? [],
requires: {
omniroute: manifest.requires?.omniroute,
permissions: manifest.requires?.permissions ?? [],
},
hooks: {
onRequest: manifest.hooks?.onRequest ?? false,
onResponse: manifest.hooks?.onResponse ?? false,
onError: manifest.hooks?.onError ?? false,
},
skills: manifest.skills ?? [],
enabledByDefault: manifest.enabledByDefault ?? false,
configSchema: manifest.configSchema ?? {},
};
}
// ── Validation ──
export function validateManifest(raw: unknown): PluginManifestWithDefaults {
const parsed = PluginManifestSchema.parse(raw);
return applyDefaults(parsed);
}
export function safeValidateManifest(
raw: unknown
): { success: true; data: PluginManifestWithDefaults } | { success: false; errors: string[] } {
const result = PluginManifestSchema.safeParse(raw);
if (result.success) {
return { success: true, data: applyDefaults(result.data) };
}
return {
success: false,
errors: result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`),
};
}

110
src/lib/plugins/scanner.ts Normal file
View File

@@ -0,0 +1,110 @@
/**
* Plugin scanner — discovers plugins from the filesystem.
*
* Scans ~/.omniroute/plugins/ for subdirectories containing plugin.json manifests.
* Returns validated manifests with directory paths.
*
* @module plugins/scanner
*/
import { readdir, stat, readFile } from "fs/promises";
import { join } from "path";
import { logger } from "../../../open-sse/utils/logger.ts";
import { safeValidateManifest, type PluginManifestWithDefaults } from "./manifest";
const log = logger("PLUGIN_SCANNER");
export interface DiscoveredPlugin {
name: string;
manifest: PluginManifestWithDefaults;
pluginDir: string;
entryPoint: string;
}
/**
* Get the default plugin directory: ~/.omniroute/plugins/
*/
export function getDefaultPluginDir(): string {
const home = process.env.HOME || process.env.USERPROFILE || "/tmp";
return join(home, ".omniroute", "plugins");
}
/**
* Scan a directory for plugin subdirectories containing plugin.json.
* Skips hidden directories (.xxx) and non-directories.
*/
export async function scanPluginDir(
dir: string
): Promise<{ plugins: DiscoveredPlugin[]; errors: Array<{ name: string; error: string }> }> {
const plugins: DiscoveredPlugin[] = [];
const errors: Array<{ name: string; error: string }> = [];
let entries: string[];
try {
const dirEntries = await readdir(dir, { withFileTypes: true });
entries = dirEntries
.filter((e) => e.isDirectory() && !e.name.startsWith("."))
.map((e) => e.name);
} catch (err: any) {
if (err.code === "ENOENT") {
log.info("scanner.dir_not_found", { dir });
return { plugins: [], errors: [] };
}
throw err;
}
for (const entry of entries) {
const pluginDir = join(dir, entry);
const manifestPath = join(pluginDir, "plugin.json");
try {
const manifestStat = await stat(manifestPath);
if (!manifestStat.isFile()) {
errors.push({ name: entry, error: "plugin.json is not a file" });
continue;
}
} catch {
errors.push({ name: entry, error: "no plugin.json found" });
continue;
}
try {
const raw = await readFile(manifestPath, "utf-8");
const parsed = JSON.parse(raw);
const result = safeValidateManifest(parsed);
if (!result.success) {
const failResult = result as { success: false; errors: string[] };
errors.push({ name: entry, error: `invalid manifest: ${failResult.errors.join("; ")}` });
continue;
}
const manifest = result.data;
const entryPoint = join(pluginDir, manifest.main);
// Verify entry point exists
try {
await stat(entryPoint);
} catch {
errors.push({
name: entry,
error: `entry point not found: ${manifest.main}`,
});
continue;
}
plugins.push({
name: manifest.name,
manifest,
pluginDir,
entryPoint,
});
log.info("scanner.discovered", { name: manifest.name, version: manifest.version });
} catch (err: any) {
errors.push({ name: entry, error: `failed to read manifest: ${err.message}` });
}
}
return { plugins, errors };
}