Repository navigation
feat: plugins framework (#2913) + disable-non-public-models (#3017) — integrated + security-hardened #3041
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: plugins framework (#2913) + disable-non-public-models (#3017) — integrated + security-hardened #3041
Changes from 83 commits
7d65115
ae6fd7d
50b5c30
7466af0
5420c27
20b2081
478350c
043e36c
5949725
ac517ea
3ecd2a1
170b549
99aac28
8c4aafc
b5f0d72
c3e5f2d
8a409fc
fe990e0
863ee3e
28ded81
1ce07aa
a7c098e
6123e48
25741a7
0974b96
087a880
46d01be
31a615c
969e963
98b3822
1ff3f32
9540812
0137435
2467a06
2f975c0
338636a
2ae278f
41ef944
8477789
a800dc8
3c6ee6c
f42a072
75012e6
55dd7dd
b778ad2
38221f2
379b72c
8477990
af8e134
8b074d2
2b613d9
4c38961
ec72330
e51ab94
8adc0a0
9430a53
2fd1271
2f707e0
74ba399
778f74c
bfe4e39
ef58d83
86a22d6
6ff3238
c4a359e
009ad13
c507451
a710128
c490ca3
5d01544
9b9e848
49b1301
abe55fa
cbcd0e8
3ac6ff2
afc7b80
81aab37
c1e163d
d69e678
6b8e52f
7fe2911
af902e5
02f780e
3941cd4
94b8b11
a11a1c8
59a6331
afaa99e
9064188
bc1b926
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,242 @@ | ||
| # OmniRoute Plugin SDK | ||
|
|
||
| ## Quick Start | ||
|
|
||
| ```ts | ||
| import { definePlugin } from "omniroute/plugins/sdk"; | ||
|
|
||
| export default definePlugin({ | ||
| name: "my-plugin", | ||
| priority: 50, | ||
| onRequest: async (ctx) => { | ||
| console.log(`Request ${ctx.requestId} for ${ctx.model}`); | ||
| }, | ||
| onResponse: async (ctx, response) => { | ||
| console.log(`Response for ${ctx.requestId}`); | ||
| return response; | ||
| }, | ||
| onError: async (ctx, error) => { | ||
| console.error(`Error: ${error.message}`); | ||
| }, | ||
| }); | ||
| ``` | ||
|
|
||
| ## API Reference | ||
|
|
||
| ### `definePlugin(def: PluginDefinition): Plugin` | ||
|
|
||
| Factory function that creates a Plugin object with defaults. | ||
|
|
||
| **Parameters:** | ||
| - `name` (string, required) — Plugin name in kebab-case | ||
| - `priority` (number, optional, default: 100) — Lower runs first | ||
| - `enabled` (boolean, optional, default: true) — Start enabled? | ||
| - `onRequest` (function, optional) — Runs before chat handler | ||
| - `onResponse` (function, optional) — Runs after chat handler | ||
| - `onError` (function, optional) — Runs on handler error | ||
|
|
||
| ### `blockRequest(response?): BlockingHookResult` | ||
|
|
||
| Block the request and optionally return a custom response. | ||
|
|
||
| ```ts | ||
| onRequest: (ctx) => { | ||
| if (!ctx.headers["authorization"]) { | ||
| return blockRequest({ error: "Unauthorized", status: 401 }); | ||
| } | ||
| }; | ||
| ``` | ||
|
|
||
| ### `modifyBody(body): PluginResult` | ||
|
|
||
| Modify the request body before it reaches the provider. | ||
|
|
||
| ```ts | ||
| onRequest: (ctx) => { | ||
| return modifyBody({ ...ctx.body, temperature: 0.7 }); | ||
| }; | ||
| ``` | ||
|
|
||
| ### `addMetadata(metadata): PluginResult` | ||
|
|
||
| Attach metadata to the request context. | ||
|
|
||
| ```ts | ||
| onRequest: (ctx) => { | ||
| return addMetadata({ source: "my-plugin", version: "1.0.0" }); | ||
| }; | ||
| ``` | ||
|
|
||
| ## Plugin Context (`PluginContext`) | ||
|
|
||
| | Field | Type | Description | | ||
| |---|---|---| | ||
| | `requestId` | `string` | Unique request identifier | | ||
| | `model` | `string` | Requested model name | | ||
| | `provider` | `string` | Target provider ID | | ||
| | `body` | `Record<string, unknown>` | Request body | | ||
| | `headers` | `Record<string, string>` | Request headers | | ||
| | `metadata` | `Record<string, unknown>` | Mutable metadata | | ||
| | `timestamp` | `number` | Request timestamp | | ||
|
|
||
| ## Manifest (`plugin.json`) | ||
|
|
||
| ```json | ||
| { | ||
| "name": "my-plugin", | ||
| "version": "1.0.0", | ||
| "description": "A sample plugin", | ||
| "author": "your-name", | ||
| "main": "index.js", | ||
| "hooks": { | ||
| "onRequest": { "enabled": true, "priority": 50 }, | ||
| "onResponse": true, | ||
| "onError": false | ||
| }, | ||
| "requires": { | ||
| "permissions": ["network", "file-read"] | ||
| }, | ||
| "enabledByDefault": false, | ||
| "configSchema": { | ||
| "apiKey": { "type": "string", "description": "API key for external service" }, | ||
| "maxRetries": { "type": "number", "min": 1, "max": 10, "default": 3 }, | ||
| "debug": { "type": "boolean", "default": false }, | ||
| "mode": { "type": "string", "enum": ["fast", "slow"], "default": "fast" } | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| ### Hook Priority | ||
|
|
||
| Hooks can be configured with priority (lower = runs first): | ||
|
|
||
| ```json | ||
| { | ||
| "hooks": { | ||
| "onRequest": { "enabled": true, "priority": 10 }, | ||
| "onResponse": { "enabled": true, "priority": 100 } | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| Or as simple booleans (default priority 100): | ||
|
|
||
| ```json | ||
| { | ||
| "hooks": { | ||
| "onRequest": true, | ||
| "onResponse": true | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| ## Permission System | ||
|
|
||
| Plugins run in a sandboxed VM context. Access to external resources requires explicit permissions: | ||
|
|
||
| | Permission | Grants | | ||
| |---|---| | ||
| | `network` | `fetch`, `AbortController`, `Headers`, `Request`, `Response` | | ||
| | `file-read` | `fs.readFile`, `fs.readdir`, `fs.stat` | | ||
| | `file-write` | `fs.writeFile`, `fs.mkdir`, `fs.rm` | | ||
| | `env` | Read-only `process.env` proxy | | ||
| | `exec` | `child_process.exec`, `child_process.execSync` | | ||
|
|
||
| Without a permission, the corresponding globals are simply not available in the sandbox. | ||
|
|
||
| ## Config Schema | ||
|
|
||
| Define configurable settings in `configSchema`: | ||
|
|
||
| ```json | ||
| { | ||
| "configSchema": { | ||
| "apiKey": { "type": "string", "description": "External API key" }, | ||
| "maxRetries": { "type": "number", "min": 1, "max": 10, "default": 3 }, | ||
| "debug": { "type": "boolean", "default": false }, | ||
| "mode": { "type": "string", "enum": ["fast", "slow"], "default": "fast" } | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| Field types: `string`, `number`, `boolean`, `select` | ||
|
|
||
| Field options: `default`, `min`, `max`, `enum`, `description` | ||
|
|
||
| Config values are persisted in the database and accessible via the dashboard config page. | ||
|
|
||
| ## Built-in Events | ||
|
|
||
| | Event | When | Payload | | ||
| |---|---|---| | ||
| | `onRequest` | Before chat handler | Request context | | ||
| | `onResponse` | After chat handler | Response data | | ||
| | `onError` | On handler error | Error object | | ||
| | `onModelSelect` | Model selected for routing | Model info | | ||
| | `onComboResolve` | Combo routing resolved | Combo targets | | ||
| | `onRateLimit` | Rate limit hit | Limit info | | ||
| | `onQuotaExhaust` | Quota exhausted | Quota info | | ||
| | `onProviderError` | Provider returned error | Error details | | ||
| | `onStreamStart` | SSE stream started | Stream info | | ||
| | `onStreamEnd` | SSE stream ended | Stream stats | | ||
|
|
||
| ## Examples | ||
|
|
||
| ### Request Logger | ||
|
|
||
| ```ts | ||
| import { definePlugin } from "omniroute/plugins/sdk"; | ||
|
|
||
| export default definePlugin({ | ||
| name: "request-logger", | ||
| onRequest: async (ctx) => { | ||
| console.log(`[${new Date().toISOString()}] ${ctx.method} ${ctx.model} -> ${ctx.provider}`); | ||
| }, | ||
| }); | ||
| ``` | ||
|
|
||
| ### Rate Limiter | ||
|
|
||
| ```ts | ||
| import { definePlugin, blockRequest } from "omniroute/plugins/sdk"; | ||
|
|
||
| const requests = new Map<string, number[]>(); | ||
|
|
||
| export default definePlugin({ | ||
| name: "rate-limiter", | ||
| priority: 10, | ||
| onRequest: async (ctx) => { | ||
| const key = ctx.headers["x-api-key"] || "anonymous"; | ||
| const now = Date.now(); | ||
| const window = 60000; // 1 minute | ||
| const maxRequests = 100; | ||
|
|
||
| const timestamps = (requests.get(key) || []).filter(t => t > now - window); | ||
| timestamps.push(now); | ||
| requests.set(key, timestamps); | ||
|
|
||
| if (timestamps.length > maxRequests) { | ||
| return blockRequest({ error: "Rate limit exceeded", status: 429 }); | ||
| } | ||
| }, | ||
| }); | ||
| ``` | ||
|
|
||
| ### Response Transformer | ||
|
|
||
| ```ts | ||
| import { definePlugin } from "omniroute/plugins/sdk"; | ||
|
|
||
| export default definePlugin({ | ||
| name: "response-transformer", | ||
| onResponse: async (ctx, response) => { | ||
| if (response.choices) { | ||
| response.choices = response.choices.map((c: any) => ({ | ||
| ...c, | ||
| message: { ...c.message, content: c.message.content.trim() }, | ||
| })); | ||
| } | ||
| return response; | ||
| }, | ||
| }); | ||
| ``` |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,9 +5,32 @@ | |
| */ | ||
|
|
||
| import { z } from "zod"; | ||
| import { resolve, normalize, isAbsolute } from "path"; | ||
| import { listPlugins, getPluginByName, updatePluginConfig } from "../../../src/lib/db/plugins"; | ||
| import { pluginManager } from "../../../src/lib/plugins/manager"; | ||
|
|
||
| /** | ||
| * Validate a path is safe for plugin installation. | ||
| * Prevents directory traversal and null byte injection. | ||
| */ | ||
| function validatePluginPath(path: string): string { | ||
| // Reject null bytes | ||
| if (path.includes("\0")) { | ||
| throw new Error("Invalid path: contains null bytes"); | ||
| } | ||
| // Must be absolute | ||
| if (!isAbsolute(path)) { | ||
| throw new Error("Path must be absolute"); | ||
| } | ||
| // Normalize and resolve to prevent traversal | ||
| const normalized = normalize(resolve(path)); | ||
| // Reject paths with traversal patterns | ||
| if (normalized.includes("..") || normalized.includes("~")) { | ||
| throw new Error("Invalid path: directory traversal detected"); | ||
| } | ||
| return normalized; | ||
| } | ||
|
|
||
| export const pluginTools = [ | ||
| { | ||
| name: "plugin_list", | ||
|
|
@@ -45,7 +68,8 @@ export const pluginTools = [ | |
| 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); | ||
| const safePath = validatePluginPath(args.path); | ||
| const plugin = await pluginManager.install(safePath); | ||
| return { | ||
| success: true, | ||
| plugin: { | ||
|
|
@@ -65,8 +89,13 @@ export const pluginTools = [ | |
| 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` }; | ||
| try { | ||
| await pluginManager.activate(args.name); | ||
| return { success: true, message: `Plugin '${args.name}' activated` }; | ||
| } catch (err: unknown) { | ||
| const msg = err instanceof Error ? err.message : String(err); | ||
| return { success: false, error: msg }; | ||
| } | ||
| }, | ||
| }, | ||
|
|
||
|
|
@@ -78,8 +107,13 @@ export const pluginTools = [ | |
| 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` }; | ||
| try { | ||
| await pluginManager.deactivate(args.name); | ||
| return { success: true, message: `Plugin '${args.name}' deactivated` }; | ||
| } catch (err: unknown) { | ||
| const msg = err instanceof Error ? err.message : String(err); | ||
| return { success: false, error: msg }; | ||
| } | ||
| }, | ||
| }, | ||
|
|
||
|
|
@@ -91,8 +125,13 @@ export const pluginTools = [ | |
| 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` }; | ||
| try { | ||
| await pluginManager.uninstall(args.name); | ||
| return { success: true, message: `Plugin '${args.name}' uninstalled` }; | ||
| } catch (err: unknown) { | ||
| const msg = err instanceof Error ? err.message : String(err); | ||
| return { success: false, error: msg }; | ||
| } | ||
| }, | ||
| }, | ||
|
|
||
|
|
@@ -109,7 +148,7 @@ export const pluginTools = [ | |
| }), | ||
| 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 (!plugin) return { success: false, error: `Plugin '${args.name}' not found` }; | ||
|
|
||
| if (args.config) { | ||
| const current = JSON.parse(plugin.config || "{}"); | ||
|
|
@@ -127,17 +166,25 @@ export const pluginTools = [ | |
|
|
||
| { | ||
| name: "plugin_executions", | ||
| description: "View plugin execution history (from skill_executions table).", | ||
| description: "View plugin execution metrics (from plugin_metrics table).", | ||
| scopes: ["read:plugins"], | ||
| 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 }; | ||
| const { getPluginAnalytics, getPluginAnalyticsSummary } = await import( | ||
| "../../../src/lib/db/plugins" | ||
| ); | ||
|
Comment on lines
+188
to
+190
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The tool now reads Useful? React with 👍 / 👎. |
||
| const limit = args.limit || 20; | ||
| if (args.name) { | ||
| const rows = getPluginAnalytics(args.name).slice(0, limit); | ||
| return { metrics: rows }; | ||
| } | ||
| // No name filter: return all plugins' summaries | ||
| const allPlugins = listPlugins(); | ||
| const metrics = allPlugins.slice(0, limit).map((p) => getPluginAnalyticsSummary(p.name)); | ||
| return { metrics }; | ||
| }, | ||
| }, | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A verificação de segurança contra directory traversal ("normalized.includes("..")") ocorre após a chamada de "resolve(path)". Como "resolve" resolve e remove todos os segmentos ".." do caminho, a variável "normalized" nunca conterá "..", permitindo que um invasor ignore completamente essa validação (por exemplo, passando "/app/plugins/../../etc/passwd"). Valide a presença de padrões de travessia diretamente no caminho de entrada antes de resolvê-lo.