From 0974b96fa22478ff0daeef75a04ce00ba1ca0ae3 Mon Sep 17 00:00:00 2001 From: oyi77 Date: Tue, 19 May 2026 05:42:12 +0700 Subject: [PATCH] feat(plugins): WordPress-style plugin system backend --- open-sse/handlers/chatCore.ts | 58 ++++ open-sse/mcp-server/server.ts | 27 +- open-sse/mcp-server/tools/pluginTools.ts | 149 +++++++++++ src/app/api/plugins/[name]/activate/route.ts | 27 ++ src/app/api/plugins/[name]/config/route.ts | 69 +++++ .../api/plugins/[name]/deactivate/route.ts | 27 ++ src/app/api/plugins/[name]/route.ts | 74 ++++++ src/app/api/plugins/route.ts | 69 +++++ src/app/api/plugins/scan/route.ts | 22 ++ src/lib/db/migrations/059_create_plugins.sql | 31 +++ src/lib/db/plugins.ts | 191 +++++++++++++ src/lib/localDb.ts | 13 + src/lib/plugins/loader.ts | 210 +++++++++++++++ src/lib/plugins/manager.ts | 250 ++++++++++++++++++ src/lib/plugins/manifest.ts | 129 +++++++++ src/lib/plugins/scanner.ts | 110 ++++++++ 16 files changed, 1455 insertions(+), 1 deletion(-) create mode 100644 open-sse/mcp-server/tools/pluginTools.ts create mode 100644 src/app/api/plugins/[name]/activate/route.ts create mode 100644 src/app/api/plugins/[name]/config/route.ts create mode 100644 src/app/api/plugins/[name]/deactivate/route.ts create mode 100644 src/app/api/plugins/[name]/route.ts create mode 100644 src/app/api/plugins/route.ts create mode 100644 src/app/api/plugins/scan/route.ts create mode 100644 src/lib/db/migrations/059_create_plugins.sql create mode 100644 src/lib/db/plugins.ts create mode 100644 src/lib/plugins/loader.ts create mode 100644 src/lib/plugins/manager.ts create mode 100644 src/lib/plugins/manifest.ts create mode 100644 src/lib/plugins/scanner.ts diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 49ebfcdc6c..98ffd4cdaa 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -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).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 diff --git a/open-sse/mcp-server/server.ts b/open-sse/mcp-server/server.ts index c24757d3d6..7b75448745 100644 --- a/open-sse/mcp-server/server.ts +++ b/open-sse/mcp-server/server.ts @@ -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; @@ -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( diff --git a/open-sse/mcp-server/tools/pluginTools.ts b/open-sse/mcp-server/tools/pluginTools.ts new file mode 100644 index 0000000000..a6d4a3f9b1 --- /dev/null +++ b/open-sse/mcp-server/tools/pluginTools.ts @@ -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 }) => { + 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, + }; + }, + }, +]; diff --git a/src/app/api/plugins/[name]/activate/route.ts b/src/app/api/plugins/[name]/activate/route.ts new file mode 100644 index 0000000000..afd9b5c099 --- /dev/null +++ b/src/app/api/plugins/[name]/activate/route.ts @@ -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 }); + } +} diff --git a/src/app/api/plugins/[name]/config/route.ts b/src/app/api/plugins/[name]/config/route.ts new file mode 100644 index 0000000000..f0861d5aa8 --- /dev/null +++ b/src/app/api/plugins/[name]/config/route.ts @@ -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 } + ); +} diff --git a/src/app/api/plugins/[name]/deactivate/route.ts b/src/app/api/plugins/[name]/deactivate/route.ts new file mode 100644 index 0000000000..1bdf8de77d --- /dev/null +++ b/src/app/api/plugins/[name]/deactivate/route.ts @@ -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 }); + } +} diff --git a/src/app/api/plugins/[name]/route.ts b/src/app/api/plugins/[name]/route.ts new file mode 100644 index 0000000000..41c0c977e3 --- /dev/null +++ b/src/app/api/plugins/[name]/route.ts @@ -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 }); + } +} diff --git a/src/app/api/plugins/route.ts b/src/app/api/plugins/route.ts new file mode 100644 index 0000000000..c0ef13cd48 --- /dev/null +++ b/src/app/api/plugins/route.ts @@ -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, + }; +} diff --git a/src/app/api/plugins/scan/route.ts b/src/app/api/plugins/scan/route.ts new file mode 100644 index 0000000000..2acbda2896 --- /dev/null +++ b/src/app/api/plugins/scan/route.ts @@ -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 }); + } +} diff --git a/src/lib/db/migrations/059_create_plugins.sql b/src/lib/db/migrations/059_create_plugins.sql new file mode 100644 index 0000000000..c4bf8e3f03 --- /dev/null +++ b/src/lib/db/migrations/059_create_plugins.sql @@ -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); diff --git a/src/lib/db/plugins.ts b/src/lib/db/plugins.ts new file mode 100644 index 0000000000..41ed7f24c9 --- /dev/null +++ b/src/lib/db/plugins.ts @@ -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; + config?: Record; + configSchema?: Record; + 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): 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; +} diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index 3ac0ca7847..9f3c51b906 100755 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -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"; diff --git a/src/lib/plugins/loader.ts b/src/lib/plugins/loader.ts new file mode 100644 index 0000000000..e686b3a3d4 --- /dev/null +++ b/src/lib/plugins/loader.ts @@ -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 { + const sandbox: Record = { + 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 { + 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 = {}; + const moduleObj = { exports: moduleExports }; + sandbox.module = moduleObj; + sandbox.exports = moduleExports; + sandbox.require = (id: string) => { + // Only allow specific safe modules + const allowed: Record = {}; + 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 => { + 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 => { + 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 => { + 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 }; +} diff --git a/src/lib/plugins/manager.ts b/src/lib/plugins/manager.ts new file mode 100644 index 0000000000..b2609b57bb --- /dev/null +++ b/src/lib/plugins/manager.ts @@ -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 = 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 { + 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, + configSchema: manifest.configSchema as unknown as Record, + 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 { + 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 { + 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 { + 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, + configSchema: discovered.manifest.configSchema as unknown as Record, + 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 { + 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(); diff --git a/src/lib/plugins/manifest.ts b/src/lib/plugins/manifest.ts new file mode 100644 index 0000000000..c3b5fb7815 --- /dev/null +++ b/src/lib/plugins/manifest.ts @@ -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; + +// ── 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; + +// ── 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; + +// ── 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; + +// ── 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; +} + +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}`), + }; +} diff --git a/src/lib/plugins/scanner.ts b/src/lib/plugins/scanner.ts new file mode 100644 index 0000000000..b9beaf4000 --- /dev/null +++ b/src/lib/plugins/scanner.ts @@ -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 }; +}