feat(plugins): WordPress-style plugin system — backend core - [deferred to v4.0.0-rc1] #2385
feat(plugins): WordPress-style plugin system — backend core - [deferred to v4.0.0-rc1] #2385oyi77 wants to merge 3 commits into
Conversation
There was a problem hiding this comment.
Code Review
This pull request implements a comprehensive plugin system featuring lifecycle management, a sandboxed execution environment using the Node.js vm module, and integration with the MCP server and API routes. The reviewer identified critical security vulnerabilities, including potential sandbox escapes and path traversal risks in manifest handling. Further feedback highlighted performance bottlenecks caused by dynamic imports in the request pipeline, the need for configuration validation against schemas, and opportunities to improve code safety through better error handling and script wrapping.
| try { | ||
| // Wrap source in a function to capture exports | ||
| const wrapped = `(async function(module, exports, require) { ${source} })(module, exports, require);`; | ||
| vm.runInContext(wrapped, context, { |
There was a problem hiding this comment.
The Node.js vm module is not a security mechanism and should not be used to run untrusted code. Malicious plugins can escape this sandbox to access the main process and host system. This also violates the repository style guide Rule 3 regarding the use of eval() or implied eval. Consider using a more secure isolation library like isolated-vm or running plugins in separate processes with restricted capabilities.
References
- Never use eval(), new Function(), or any implied eval. ESLint enforces this. (link)
| * Copies to plugin dir, validates manifest, registers in DB. | ||
| */ | ||
| async install(sourceDir: string): Promise<PluginRow> { | ||
| const { plugins, errors } = await scanPluginDir(sourceDir); |
There was a problem hiding this comment.
The install method currently expects sourceDir to be a parent directory containing the plugin folder, because scanPluginDir iterates over subdirectories. If a user provides the path to the plugin directory itself (which is standard for 'install from path'), the installation will fail. Consider checking if sourceDir contains a plugin.json file directly before falling back to scanning subdirectories.
| if (row.status === "active") return; | ||
|
|
||
| const manifest = JSON.parse(row.manifest) as PluginManifestWithDefaults; | ||
| const entryPoint = join(row.pluginDir, manifest.main); |
There was a problem hiding this comment.
The manifest.main property is used to construct the entryPoint path without validation. A malicious plugin could use path traversal (e.g., ../../secret.js) or an absolute path to execute code outside the intended plugin directory. The path should be validated to ensure it is relative and stays within the plugin's root.
|
|
||
| // ── Plugin onRequest hook ── | ||
| try { | ||
| const { runOnRequest } = await import("@/lib/plugins/index"); |
There was a problem hiding this comment.
Performing a dynamic import() inside the request pipeline introduces unnecessary overhead for every chat request. While Node.js caches modules, the resolution and await logic still impact the hot path. It is better to import the plugin runner at the top level or manage it via a singleton initialized during startup.
| } catch (error) { | ||
| // ── Plugin onError hook ── | ||
| try { | ||
| const { runOnError } = await import("@/lib/plugins/index"); |
| ); | ||
| } | ||
|
|
||
| updatePluginConfig(name, parsed.data.config); |
| ); | ||
|
|
||
| log.info("plugin.inserted", { id: input.id, name: input.name }); | ||
| return getPluginByName(input.name)!; |
There was a problem hiding this comment.
Using a non-null assertion (!) here is risky. If the database insertion succeeds but the subsequent fetch fails (e.g., due to a race condition or database constraint issue), this will throw a runtime error. It is safer to handle the potential null case explicitly.
| return getPluginByName(input.name)!; | |
| const plugin = getPluginByName(input.name); | |
| if (!plugin) { | |
| throw new Error(`Failed to retrieve plugin '${input.name}' after insertion`); | |
| } | |
| return plugin; |
| 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`); | ||
| }; |
There was a problem hiding this comment.
The require implementation inside the sandbox is inefficient as it re-initializes the allowed object and calls require('crypto') on every invocation. Consider moving the allowed modules map outside the function.
const allowedModules: Record<string, unknown> = {
crypto: require("crypto"),
};
sandbox.require = (id: string) => {
if (id in allowedModules) return allowedModules[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);`; |
There was a problem hiding this comment.
If the plugin source code ends with a single-line comment (e.g., // comment) without a trailing newline, the closing part of the IIFE wrapper will be commented out, leading to a syntax error. Adding explicit newlines around the source ensures the wrapper remains valid.
| const wrapped = `(async function(module, exports, require) { ${source} })(module, exports, require);`; | |
| const wrapped = `(async function(module, exports, require) {\n${source}\n})(module, exports, require);`; |
- Path traversal guard: validate entryPoint stays within plugin dir - install() now handles direct plugin directories (not just parent dirs) - Non-null assertion replaced with explicit null check - require efficiency: allowedModules map moved outside function - Source wrapper: add newlines to prevent trailing comment issues - Config validation: validate values against configSchema on save - Dynamic import comment: clarify Node.js caching behavior Co-Authored-By: OpenClaude (mimo-v2.5-pro) <openclaude@gitlawb.com>
Review Feedback AddressedAll review comments have been addressed in commit Critical/High
Medium
Not Addressed (Future)
🤖 Generated with OpenClaude |
Addresses all remaining code review feedback: 1. **Loader rewrite**: Replaced Node.js vm module with child_process.fork() for proper process-level isolation. Complies with Rule 3 (no eval). Each plugin runs in a separate Node.js process with IPC communication. 2. **Auth on all routes**: Added requireManagementAuth to all 6 plugin API route files (list, install, scan, details, activate, deactivate, config). 3. **Env filtering**: Only safe env vars passed to plugin processes unless "env" permission is granted. Co-Authored-By: OpenClaude (mimo-v2.5-pro) <openclaude@gitlawb.com>
All Review Feedback Now AddressedCommit 1. VM Sandbox → child_process.fork() (CRITICAL)
2. Auth on ALL API Routes (CRITICAL)
Previously Fixed (commit 9fb6d52)
No backlogged items remain. 🤖 Generated with OpenClaude |
|
Thank you so much for this comprehensive plugin system, @oyi77! The WordPress-style architecture with a backend core, dashboard UI, hooks registry, and test suite is a genuinely impressive contribution. After a thorough review, we've decided to defer this to the v4.0.0-rc1 milestone. The plugin system is a large architectural addition that warrants a dedicated release cycle to ensure proper integration testing, security review (sandbox isolation, hook injection surface), and documentation. Merging it mid-patch would risk destabilising the current v3.8.x release stream. Your PRs (#2385, #2386, #2387, #2388) are being kept open and tracked for v4.0.0-rc1. When that cycle opens we'll pick this up, resolve any conflicts against the then-current main, and merge it properly so you get full credit. Thanks again for the effort — this is exactly the kind of extensibility we want to build toward. |
Summary
Implements the backend foundation for a WordPress-style plugin system (closes #2060).
What's included:
pluginstable migration + CRUD module (src/lib/db/plugins.ts)plugin.jsonschema (src/lib/plugins/manifest.ts)~/.omniroute/plugins/(src/lib/plugins/scanner.ts)src/lib/plugins/loader.ts)src/lib/plugins/manager.ts)src/app/api/plugins/)open-sse/mcp-server/tools/pluginTools.ts)onRequest+onErrorhooks wired intochatCore.tsWhat's NOT included (next PR):
/dashboard/plugins)Test plan
npm run typecheck:core— passesnpm run lint— passes🤖 Generated with OpenClaude