mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
feat(plugins): WordPress-style plugin system backend
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
149
open-sse/mcp-server/tools/pluginTools.ts
Normal file
149
open-sse/mcp-server/tools/pluginTools.ts
Normal 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,
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
27
src/app/api/plugins/[name]/activate/route.ts
Normal file
27
src/app/api/plugins/[name]/activate/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
69
src/app/api/plugins/[name]/config/route.ts
Normal file
69
src/app/api/plugins/[name]/config/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
27
src/app/api/plugins/[name]/deactivate/route.ts
Normal file
27
src/app/api/plugins/[name]/deactivate/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
74
src/app/api/plugins/[name]/route.ts
Normal file
74
src/app/api/plugins/[name]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
69
src/app/api/plugins/route.ts
Normal file
69
src/app/api/plugins/route.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
22
src/app/api/plugins/scan/route.ts
Normal file
22
src/app/api/plugins/scan/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
31
src/lib/db/migrations/059_create_plugins.sql
Normal file
31
src/lib/db/migrations/059_create_plugins.sql
Normal 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
191
src/lib/db/plugins.ts
Normal 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;
|
||||
}
|
||||
@@ -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
210
src/lib/plugins/loader.ts
Normal 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
250
src/lib/plugins/manager.ts
Normal 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
129
src/lib/plugins/manifest.ts
Normal 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
110
src/lib/plugins/scanner.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user