Skip to content
Merged
Show file tree
Hide file tree
Changes from 83 commits
Commits
Show all changes
90 commits
Select commit Hold shift + click to select a range
7d65115
feat(plugins): WordPress-style plugin system backend
oyi77 May 18, 2026
ae6fd7d
fix(plugins): address code review feedback
oyi77 May 18, 2026
50b5c30
fix(plugins): replace vm with child_process, add auth to all routes
oyi77 May 18, 2026
7466af0
fix(plugins): security + ESM fixes for loader and manager
oyi77 May 29, 2026
5420c27
feat(discovery): add discovery tool stub service
oyi77 May 29, 2026
20b2081
chore(plugins): slop cleanup — pino logger, remove redundant sorts
oyi77 May 29, 2026
478350c
test(plugins): add scanner, loader, manager unit tests
oyi77 May 29, 2026
043e36c
fix(settings): add missing home page pin keys to updateSettingsSchema
apoapostolov May 29, 2026
5949725
feat(plugins): add i18n keys to all 42 locales
oyi77 May 29, 2026
ac517ea
fix(settings): add missing security keys to updateSettingsSchema and …
apoapostolov May 29, 2026
3ecd2a1
fix(analytics): address merged review regressions
htkarabulut May 29, 2026
170b549
fix(executor): normalize max effort for openai shape providers
rdself May 29, 2026
99aac28
Make zero-latency combo optimizations opt-in
rdself May 29, 2026
8c4aafc
Address zero-latency combo review feedback
rdself May 29, 2026
b5f0d72
fix(cleanup): restore usage history cutoff boundary
htkarabulut May 29, 2026
c3e5f2d
docs(changelog): rank 3.8.6 contributors in a commits table with thei…
diegosouzapw May 29, 2026
8a409fc
fix(dashboard): theme ReactFlow Controls +/- buttons for dark mode
apoapostolov May 29, 2026
fe990e0
fix(settings): add missing home page pin keys to updateSettingsSchema
apoapostolov May 29, 2026
863ee3e
fix(settings): add missing security keys to updateSettingsSchema and …
apoapostolov May 29, 2026
28ded81
fix(executor): normalize max effort for openai shape providers
rdself May 29, 2026
1ce07aa
Make zero-latency combo optimizations opt-in
rdself May 29, 2026
a7c098e
Address zero-latency combo review feedback
rdself May 29, 2026
6123e48
fix(analytics): address merged review regressions
htkarabulut May 29, 2026
25741a7
fix(cleanup): restore usage history cutoff boundary
htkarabulut May 29, 2026
0974b96
feat(plugins): WordPress-style plugin system backend
oyi77 May 18, 2026
087a880
fix(plugins): address code review feedback
oyi77 May 18, 2026
46d01be
fix(plugins): replace vm with child_process, add auth to all routes
oyi77 May 18, 2026
31a615c
fix(plugins): security + ESM fixes for loader and manager
oyi77 May 29, 2026
969e963
feat(discovery): add discovery tool stub service
oyi77 May 29, 2026
98b3822
chore(plugins): slop cleanup — pino logger, remove redundant sorts
oyi77 May 29, 2026
1ff3f32
test(plugins): add scanner, loader, manager unit tests
oyi77 May 29, 2026
9540812
feat(plugins): add i18n keys to all 42 locales
oyi77 May 29, 2026
0137435
chore(plugins): remove duplicate migration 059_create_plugins.sql
diegosouzapw May 29, 2026
2467a06
Merge pull request #2918 from unitythemaker/fix/analytics-gemini-foll…
diegosouzapw May 29, 2026
2f975c0
Merge pull request #2912 from oyi77/feat/plugin-system
diegosouzapw May 29, 2026
338636a
Merge pull request #2915 from apoapostolov/fix/pin-settings-schema-va…
diegosouzapw May 29, 2026
2ae278f
Merge pull request #2916 from rdself/coder/openai-compatible-max-effo…
diegosouzapw May 29, 2026
41ef944
Merge pull request #2917 from rdself/coder/zero-latency-combo-toggle
diegosouzapw May 29, 2026
8477789
chore(plugins): remove duplicate migration 059_create_plugins.sql (po…
diegosouzapw May 29, 2026
a800dc8
fix(sse): guard non-string error.code in proxyFetch + harden model pa…
diegosouzapw May 29, 2026
3c6ee6c
fix(docker): add runner-web stage with Playwright Chromium (#2832) (#…
diegosouzapw May 29, 2026
f42a072
docs(changelog): document NVIDIA NIM and error code type-crash fix (#…
diegosouzapw May 29, 2026
75012e6
test: ignore NVIDIA_BASE_URL and NVIDIA_MODEL in env contract check
diegosouzapw May 29, 2026
55dd7dd
feat(plugins): comprehensive plugin system — all 10 items
oyi77 May 29, 2026
b778ad2
feat(notion): add Notion MCP context source with 6 tools, dashboard t…
branben May 31, 2026
38221f2
fix(mcp): reorder enforceScopes guard before MCP_TOOL_MAP lookup, add…
branben May 31, 2026
379b72c
fix(routing): add agy to executor map so it uses AntigravityExecutor …
ReqX May 31, 2026
8477990
fix: CPU leak from Bottleneck limiter accumulation + per-request opti…
soyelmismo May 31, 2026
af8e134
fix: combo credential resolution ignores target.providerId — prefer c…
oyi77 May 31, 2026
8b074d2
fix(claude): sanitize tool schemas + cloak third-party tool names on …
NomenAK May 31, 2026
2b613d9
fix combo vision and codex tool history (#2940)
charithharshana May 31, 2026
4c38961
fix(claude): map WebSearch to Responses web_search (#2938)
makcimbx May 31, 2026
ec72330
fix(claude): strip empty Read pages tool input (#2937)
makcimbx May 31, 2026
e51ab94
Improve self-service provider quota visibility (#2931)
guanbear May 31, 2026
8adc0a0
feat(oauth): add Trae SOLO provider (work/code modes)
S0yora May 31, 2026
9430a53
feat(dashboard): add Trae provider brand icon via @lobehub/icons
S0yora May 31, 2026
2fd1271
fix(oom): prevent unbounded memory growth in caches and provider regi…
May 31, 2026
2f707e0
fix(oom): increase Docker heap to 1024MB + wire OMNIROUTE_MEMORY_MB o…
May 31, 2026
74ba399
fix: address Gemini code review on OOM fix PR #2965
May 31, 2026
778f74c
feat(providers): add SiliconFlow endpoint selector
xz-dev May 31, 2026
bfe4e39
fix(i18n): translate Ukrainian (uk-UA) menu and UI strings
Lion-killer May 31, 2026
ef58d83
Merge pull request #2981 from Lion-killer/i18n/uk-ua-ui-menu
diegosouzapw May 31, 2026
86a22d6
Merge pull request #2975 from xz-dev/feat/siliconflow-cn-endpoint-sel…
diegosouzapw May 31, 2026
6ff3238
fix(oauth): address Trae review feedback (loopback origin, stream cle…
S0yora May 31, 2026
c4a359e
Merge pull request #2964 from S0yora/feat/trae-solo-provider
diegosouzapw May 31, 2026
009ad13
Merge pull request #2965 from soyelmismo/fix/oom-memory-leak-caches
diegosouzapw May 31, 2026
c507451
feat(plugins): Phase 1-4 improvements — rate limiting, error codes, a…
oyi77 May 31, 2026
a710128
chore: remove accidentally committed state file
oyi77 May 31, 2026
c490ca3
Merge remote-tracking branch 'origin/main' into feat/plugin-custom-hooks
oyi77 May 31, 2026
5d01544
fix: address PR #2913 review feedback
oyi77 May 31, 2026
9b9e848
feat: add a switch to disable use of non-published models
androw May 31, 2026
49b1301
Merge branch 'pr-3017' into feat/plugins-and-model-policy
diegosouzapw Jun 1, 2026
abe55fa
perf(apikeys): hoist model-discovery imports out of isModelAllowedFor…
diegosouzapw Jun 1, 2026
cbcd0e8
fix(policy): disable_non_public gate skips auto/*/combo/qtSd routing …
diegosouzapw Jun 1, 2026
3ac6ff2
test(policy): align quota-only test to group-slug scope + disable_non…
diegosouzapw Jun 1, 2026
afc7b80
Merge branch 'pr-2913' into feat/plugins-and-model-policy
diegosouzapw Jun 1, 2026
81aab37
merge: integrate #2913 plugin framework onto release; drop stray db/m…
diegosouzapw Jun 1, 2026
c1e163d
security(authz): classify /api/plugins/ as LOCAL_ONLY (process-spawni…
diegosouzapw Jun 1, 2026
d69e678
security(plugins): gate child_process exec behind OMNIROUTE_PLUGINS_A…
diegosouzapw Jun 1, 2026
6b8e52f
fix(plugins): route all /api/plugins errors through buildErrorBody (n…
diegosouzapw Jun 1, 2026
7fe2911
test(plugins): align bundled plugin tests to integrated branch (secur…
diegosouzapw Jun 1, 2026
af902e5
fix(db): add canonical 091_plugin_analytics migration (was masked by …
diegosouzapw Jun 1, 2026
02f780e
docs(changelog): credit #2913 (@oyi77 plugins) + #3017 (@androw disab…
diegosouzapw Jun 1, 2026
3941cd4
security(plugins): assert pluginDir containment before rm (no arbitra…
diegosouzapw Jun 1, 2026
94b8b11
security(plugins): create plugin host tmp script with O_EXCL + 0600 (…
diegosouzapw Jun 1, 2026
a11a1c8
security(plugins): integrity field in manifest schema + opt-in verifi…
diegosouzapw Jun 1, 2026
59a6331
fix(plugins): rate-limit blocking hooks + evict rateLimitMap on unreg…
diegosouzapw Jun 1, 2026
afaa99e
fix(plugins): validate config in plugin_configure + correct plugin_ex…
diegosouzapw Jun 1, 2026
9064188
fix(plugins): compareSemver NaN-safe + document plugin_metrics/activa…
diegosouzapw Jun 1, 2026
bc1b926
Merge remote-tracking branch 'origin/release/v3.8.8' into feat/plugin…
diegosouzapw Jun 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

### Added

- **Plugins framework** (`src/lib/plugins/`, `/api/plugins/*`, `/dashboard/plugins`) — hooks + registry unification, plugin SDK (`definePlugin`), worker-thread sandbox, per-plugin hook rate limiting, SHA-256 integrity verification, semver-gated upgrade, and execution analytics. Plugin routes are loopback-only (`isLocalOnlyPath`) and `child_process` exec is opt-in via `OMNIROUTE_PLUGINS_ALLOW_EXEC`. (#2913 — thanks @oyi77)
- **API key option: disable non-published models** — a per-key flag restricting the key to discovered, public models (combos / `auto/*` / `qtSd/*` routing still allowed). (#3017 — thanks @androw)
- **SessionPool — modular & provider-agnostic** (`open-sse/services/sessionPool/`) — pooled
cookie/session manager with round-robin fingerprint rotation (distinct fingerprint per pooled
session), per-session cooldown/backoff, and a provider-agnostic `webExecutorWrapper`. Adds pool
Expand Down Expand Up @@ -292,6 +294,14 @@

- **mcp:** move `enforceScopes` guard before `MCP_TOOL_MAP` lookup, add inline `scopes` parameter to `withScopeEnforcement()`, and declare scopes on all 24 dynamic tool definitions (memory, skills, plugins, gamification, compression) to fix scope enforcement for dynamic MCP tool groups (#2958)

### ✨ New Features

- **notion:** add Notion as an MCP context source — 6 tools (`notion_search`, `notion_list_databases`, `notion_get_database`, `notion_query_database`, `notion_read`, `notion_append_blocks`) scoped under `read:notion` / `write:notion`, with dashboard "Context Sources" tab, settings API, and token persistence in `key_value` table (#2959)

### 🔧 Bug Fixes

- **mcp:** move `enforceScopes` guard before `MCP_TOOL_MAP` lookup, add inline `scopes` parameter to `withScopeEnforcement()`, and declare scopes on all 24 dynamic tool definitions (memory, skills, plugins, gamification, compression) to fix scope enforcement for dynamic MCP tool groups (#2958)

---

## [3.8.7] — 2026-05-29
Expand Down
242 changes: 242 additions & 0 deletions docs/plugins/PLUGIN_SDK.md
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;
},
});
```
73 changes: 60 additions & 13 deletions open-sse/mcp-server/tools/pluginTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Comment on lines +17 to +33

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-critical critical

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.

function validatePluginPath(path: string): string {
  // Reject null bytes and traversal patterns
  if (path.includes("\0") || path.includes("..") || path.includes("~")) {
    throw new Error("Invalid path: directory traversal or null bytes detected");
  }
  // Must be absolute
  if (!isAbsolute(path)) {
    throw new Error("Path must be absolute");
  }
  return normalize(resolve(path));
}


export const pluginTools = [
{
name: "plugin_list",
Expand Down Expand Up @@ -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: {
Expand All @@ -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 };
}
},
},

Expand All @@ -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 };
}
},
},

Expand All @@ -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 };
}
},
},

Expand All @@ -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 || "{}");
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Record executions before serving plugin metrics

The tool now reads plugin_analytics, but I searched production code for recordPluginExecution/recordPluginMetric and found no hook or loader path that writes rows when plugins actually run. In normal use, plugin_executions will therefore return empty per-plugin rows and zero summaries no matter how often hooks execute; wire the recording into the hook execution path before switching the MCP tool to this table.

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 };
},
},

Expand Down
Loading