Skip to content

feat(plugins): custom hooks + registry unification + SDK + marketplace + dashboard [deferred to v4.0.0-rc1] - #2913

Closed
oyi77 wants to merge 51 commits into
diegosouzapw:mainfrom
oyi77:feat/plugin-custom-hooks
Closed

oyi77 wants to merge 51 commits into
diegosouzapw:mainfrom
oyi77:feat/plugin-custom-hooks

Conversation

@oyi77

@oyi77 oyi77 commented May 29, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Plugin system improvements for v4.0.0-rc1. Extends PR #2385 with:

Custom Hooks (Priority 1+2)

  • emitHookBlocking() with body/metadata chaining for blocking events
  • runOnRequest, runOnResponse, runOnError lifecycle wrappers
  • Unified hook registry — hooks.ts is canonical, index.ts is re-export shim
  • runOnResponse wired in chatCore.ts (was missing)

SDK

  • sdk.ts: definePlugin() factory + blockRequest, modifyBody, addMetadata helpers
  • Dashboard plugin list page with scan/activate/deactivate/uninstall
  • Config page with auto-generated form from configSchema

Marketplace

  • marketplace.ts: local plugin registry with 3 seed plugins
  • Phase 1: local data. Phase 2: remote registry.

Tests

  • 19 hooks.ts unit tests
  • 17 integration tests (full lifecycle)

Test plan

  • npm run typecheck:core — 0 errors
  • node --import tsx/esm --test tests/unit/plugins-hooks.test.ts — 19/19 pass
  • node --import tsx/esm --test tests/integration/plugins-lifecycle.test.ts — 17/17 pass

oyi77 and others added 7 commits May 29, 2026 23:48
- 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>
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>
loader.ts:
- Fix IPC: use process.send()/process.on("message") instead of worker_threads.parentPort
- Fix ESM: write host script as .mjs (not .js) to force ESM execution
- Add timeout: 10s default on callHook() with Promise.race
- Add SIGKILL escalation: SIGTERM first, then SIGKILL after 3s grace
- Fix env filtering: use allowlist (safeKeys) instead of passing all env vars
- Clear timeout on successful IPC response (no timer leak)

manager.ts:
- Fix path traversal: use fs.realpath() instead of startsWith()
- Fix imports: use registerHook/unregisterHooks from hooks.ts
- Register hooks individually via registerHook(event, name, handler)

hooks.ts:
- Copied from feat/plugin-custom-hooks (canonical registry)
Phase 1 scaffold for automated provider discovery:
- DiscoveryConfig, DiscoveryResult types
- probeEndpoint() for URL availability checking
- scanProvider() stub (Phase 2 will implement real scanning)
- getDiscoveryResults() stub
- Default config: disabled (opt-in)
- index.ts: replace console.log/error with pino structured logging
- hooks.ts: remove redundant .sort() in emitHookBlocking/runOnResponse (already sorted on registration)
- manager.ts: add readFile import
- scanner: 9 tests (discovery, hidden dirs, validation, entry point, multiple)
- loader: 5 tests (type contracts, Plugin/PluginContext/PluginResult interfaces)
- manager: 6 tests (singleton, lifecycle methods, error on unknown)
- Total: 20 tests, all passing
@oyi77
oyi77 requested a review from diegosouzapw as a code owner May 29, 2026 17:59

@gemini-code-assist gemini-code-assist Bot left a comment

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.

Code Review

This pull request introduces a comprehensive plugin management system, including lifecycle tracking, a custom hook registry, sandboxed loading, and dashboard UI pages, while removing legacy gamification and token limit features. The review feedback highlights critical security vulnerabilities, specifically sandbox escapes through the Node.js vm module and path traversal risks in the plugin scanner. Additionally, the feedback addresses functional issues such as broken hook chaining in emitHookBlocking, performance overhead from dynamic imports in chatCore.ts, and silent UI failures during plugin activation.

Comment thread src/lib/plugins/loader.ts Outdated
Comment on lines +113 to +117
const wrapped = `(async function(module, exports, require) { ${source} })(module, exports, require);`;
vm.runInContext(wrapped, context, {
filename: entryPoint,
timeout: 10000, // 10s init timeout
});

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

The Node.js vm module is not a secure sandbox. Untrusted plugins running in vm can easily escape the sandbox and execute arbitrary code on the host system (e.g., by accessing the constructor of standard objects to run arbitrary shell commands). If this plugin system is intended to support third-party plugins or a marketplace, using vm poses a critical security risk. Consider using a more secure isolation library like isolated-vm or running plugins in separate worker threads/processes with restricted permissions.

Comment thread src/lib/plugins/hooks.ts Outdated
Comment on lines +148 to +150
for (const reg of list.sort((a, b) => a.priority - b.priority)) {
try {
const result = await reg.handler(payload);

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.

high

In emitHookBlocking, subsequent hook handlers are called with the original payload object. If a previous handler modified the request body or metadata, those modifications are accumulated in mergedBody and mergedMetadata but are not passed to the next handler in the chain. This breaks hook chaining. We should pass an updated copy of the payload to each handler.

  for (const reg of list.sort((a, b) => a.priority - b.priority)) {
    try {
      const currentPayload = { ...ctx, body: mergedBody, metadata: mergedMetadata };
      const result = await reg.handler(currentPayload);

Comment on lines +82 to +84
const manifest = result.data;
const entryPoint = join(pluginDir, manifest.main);

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-high high

There is a potential path traversal vulnerability when resolving the plugin's entry point. If a malicious or malformed plugin.json specifies a main path containing directory traversal sequences (e.g., ../../malicious.js), join(pluginDir, manifest.main) will resolve to a path outside the plugin's directory. We should validate that the resolved entryPoint is strictly within pluginDir.

      const manifest = result.data;
      const entryPoint = join(pluginDir, manifest.main);

      if (!entryPoint.startsWith(pluginDir)) {
        errors.push({
          name: entry,
          error: `entry point must be within the plugin directory`,
        });
        continue;
      }

Comment on lines +1281 to +1283
try {
const { runOnRequest } = await import("@/lib/plugins/hooks");
const pluginCtx = {

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.

medium

Importing @/lib/plugins/hooks dynamically on every request (and doing so multiple times for different hooks like runOnRequest, runOnResponse, runOnError, and emitHook) introduces unnecessary overhead. Since there are no circular dependencies, these should be statically imported at the top of the file for better performance.

Comment on lines +60 to +63
if (res.ok) {
addNotification({ type: "success", message: `${name} ${enable ? "activated" : "deactivated"}` });
await fetchPlugins();
}

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.

medium

In handleToggle, if the fetch request returns a non-OK status (e.g., 400 Bad Request because activation failed), the promise still resolves successfully, so the catch block is not triggered. As a result, no error notification is shown, and the failure is silently ignored in the UI. We should handle res.ok explicitly to show an error notification.

      if (res.ok) {
        addNotification({ type: "success", message: `${name} ${enable ? "activated" : "deactivated"}` });
        await fetchPlugins();
      } else {
        addNotification({ type: "error", message: `Failed to ${enable ? "activate" : "deactivate"} ${name}` });
      }

apoapostolov and others added 18 commits May 29, 2026 21:52
…l_logs only

The 3.8.6 variant of diegosouzapw#2904 added SELECTs of combo_name/requested_model
against usage_history, but those columns only exist in call_logs (no
migration adds them to usage_history). This returned HTTP 500 on
/api/usage/analytics. Restore the working query shape from the 3.8.7
variant. Fixes 18 failing usage-analytics-route tests.
…maphore test to diegosouzapw#2903 gate pruning

- progressiveAging: type compression results so messages[0].content is
  indexable (was TS7053 against {}); restores typecheck:noimplicit:core gate.
- services-branch-hardening: diegosouzapw#2903 (perf-ram) prunes idle rate-limit gates
  on zero; assert no-running/empty-queue without assuming the entry persists.
- llm.txt → 3.8.7 (Current version + Key Features header)
- CHANGELOG: add Dmitry Kuznetsov & Nikolay Alafuzov to 3.8.6 Hall of Contributors
- version already 3.8.7 across package.json/open-sse/electron/openapi (from diegosouzapw#2909)
Comment thread db/migrations/077_discovery_results.sql Outdated
endpoint TEXT,
models_json TEXT DEFAULT '[]',
rate_limit TEXT,
feasibility INTEGER DEFAULT 0 CHECK (feasibility BETWEEN 1 AND 5),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

CRITICAL: DEFAULT value 0 for feasibility conflicts with CHECK constraint requiring values BETWEEN 1 AND 5. Rows using the default will violate the constraint and fail to insert.

Either change DEFAULT to 1, or update the CHECK to allow 0: CHECK (feasibility >= 0 AND feasibility <= 5).

@diegosouzapw
diegosouzapw changed the base branch from release/v3.8.6 to release/v3.8.7 May 29, 2026 22:05
Comment thread src/lib/plugins/loader.ts Outdated

if (permissions.includes("file-read")) {
sandbox.fs = {
readFile: (p: string, enc?: string) => readFile(resolve(pluginDir, p), enc as BufferEncoding),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WARNING: Potential directory traversal via absolute path in file operations. The plugin is granted file-read permission but can use absolute paths to read/write outside the plugin directory. The sandbox should restrict paths to be within pluginDir.

Explanation: The resolve(pluginDir, p) function will return p if it is an absolute path, allowing plugins to bypass the intended directory restriction.

@oyi77
oyi77 force-pushed the feat/plugin-custom-hooks branch 5 times, most recently from 4b9cbb0 to b1b78d9 Compare May 29, 2026 23:23
Backend:
- WordPress-style plugin system with scanner, loader, manager
- Custom hooks with event-driven registry and priority
- VM sandbox with 5 permission gates (path-restricted)
- Plugin upgrade with semver comparison
- Discovery implementation with real provider probing
- Execution metrics tracking (calls, errors, latency)
- Hot-reload with fs.watch and debounce
- Plugin signing with SHA-256 integrity verification
- Worker thread scaffold for v4.0 isolation

Frontend:
- Dashboard pages (list + config) with useTranslations
- 42 locale i18n support (34 plugin keys)
- 9 MCP tools for plugin management

Tests: 138 total (hooks, scanner, loader, manager, tools, edge-cases, config-route, upgrade, metrics, signing, lifecycle)

Docs: SDK documentation at docs/plugins/PLUGIN_SDK.md
@oyi77
oyi77 force-pushed the feat/plugin-custom-hooks branch from b1b78d9 to 55dd7dd Compare May 29, 2026 23:27
@diegosouzapw
diegosouzapw changed the base branch from release/v3.8.7 to main May 30, 2026 04:03
@diegosouzapw

Copy link
Copy Markdown
Owner

Hi @oyi77 — thank you for the ambitious plugin system work here! 🙏

We're keeping this deferred to v4.0.0-rc1 as you flagged. While reviewing it for integration I want to flag one important security item and a concrete path forward.

🔒 Loader sandbox regression. On this branch src/lib/plugins/loader.ts and src/lib/plugins/pluginWorker.ts execute plugin code via vm.createContext / vm.runInContext, and pluginWorker.ts also exposes child_process.exec/execSync into the sandbox. That reverts the process-level isolation that the already-merged PR #2912 established (each plugin runs in a separate child_process over IPC with a permission-filtered env). vm + exposed exec together would let a plugin run arbitrary host commands, so we can't ship the loader from this branch as-is. It looks like it came from the merge history — the branch carries both the old vm loader and #2912's child_process fix, and the vm one won the merge.

Path forward for v4: could you rebase this onto the merged #2912 backend so the loader stays child_process-based, and build the new pieces on top of it? The genuinely new value here is excellent and we definitely want it:

  • the unified hook registry (index.ts → re-export shim over hooks.ts) — this is the key fix that makes activated plugins actually fire on requests (today the two registries are disconnected)
  • runOnResponse wiring in chatCore.ts
  • the SDK (definePlugin), per-hook metrics, and the dashboard pages

One small robustness note: recordPluginMetric should be best-effort (wrap in try/catch) so a DB hiccup never breaks hook execution.

Happy to help coordinate the v4 integration — thanks again for pushing this forward! 🚀

oyi77 added 4 commits May 31, 2026 21:54
…nalytics, sandbox, doctor, logger, dev mode

Phase 1 (Security): config validation, rate limiting (100/sec/plugin), memory limit, install hooks
Phase 3 (DX): error codes (14 PluginErrorCodes), per-plugin logger, dev mode hot-reload, doctor diagnostics, test runner
Phase 4 (Ecosystem): signing (SHA-256 + Ed25519), sandbox levels (4), analytics (migration 079)

8 new source files, 9 new test files, 4 modified files. 158/158 tests pass. 0 typecheck errors, 0 lint errors.
# Conflicts:
#	open-sse/handlers/chatCore.ts
#	open-sse/mcp-server/server.ts
#	open-sse/mcp-server/tools/pluginTools.ts
#	src/app/api/plugins/[name]/activate/route.ts
#	src/app/api/plugins/[name]/config/route.ts
#	src/app/api/plugins/[name]/deactivate/route.ts
#	src/app/api/plugins/[name]/route.ts
#	src/app/api/plugins/route.ts
#	src/app/api/plugins/scan/route.ts
#	src/i18n/messages/ar.json
#	src/i18n/messages/az.json
#	src/i18n/messages/bg.json
#	src/i18n/messages/bn.json
#	src/i18n/messages/cs.json
#	src/i18n/messages/da.json
#	src/i18n/messages/de.json
#	src/i18n/messages/es.json
#	src/i18n/messages/fa.json
#	src/i18n/messages/fi.json
#	src/i18n/messages/fr.json
#	src/i18n/messages/gu.json
#	src/i18n/messages/he.json
#	src/i18n/messages/hi.json
#	src/i18n/messages/hu.json
#	src/i18n/messages/id.json
#	src/i18n/messages/in.json
#	src/i18n/messages/it.json
#	src/i18n/messages/ja.json
#	src/i18n/messages/ko.json
#	src/i18n/messages/mr.json
#	src/i18n/messages/ms.json
#	src/i18n/messages/nl.json
#	src/i18n/messages/no.json
#	src/i18n/messages/phi.json
#	src/i18n/messages/pl.json
#	src/i18n/messages/pt-BR.json
#	src/i18n/messages/pt.json
#	src/i18n/messages/ro.json
#	src/i18n/messages/ru.json
#	src/i18n/messages/sk.json
#	src/i18n/messages/sv.json
#	src/i18n/messages/sw.json
#	src/i18n/messages/ta.json
#	src/i18n/messages/te.json
#	src/i18n/messages/th.json
#	src/i18n/messages/tr.json
#	src/i18n/messages/uk-UA.json
#	src/i18n/messages/ur.json
#	src/i18n/messages/vi.json
#	src/i18n/messages/zh-CN.json
#	src/lib/db/plugins.ts
#	src/lib/discovery/index.ts
#	src/lib/localDb.ts
#	src/lib/plugins/hooks.ts
#	src/lib/plugins/index.ts
#	src/lib/plugins/loader.ts
#	src/lib/plugins/manager.ts
#	src/lib/plugins/manifest.ts
#	tests/unit/plugins-loader.test.ts
- Fix DEFAULT value conflict in 077_discovery_results.sql (0 -> 1)
- Add path validation in pluginTools.ts to prevent directory traversal
- Add proper Zod validation for status parameter in route.ts
- Add regex validation for path to prevent traversal patterns
- Wrap recordPluginMetric in try/catch for best-effort metrics
diegosouzapw added a commit that referenced this pull request Jun 1, 2026
…els (#3041)

Integrates two community contributions into release/v3.8.8 with security hardening and conflict resolution.

- **Plugins framework** (#2913 — thanks @oyi77): 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`); `child_process` exec is opt-in via `OMNIROUTE_PLUGINS_ALLOW_EXEC` (default off).
- **API key option: disable non-published models** (#3017 — thanks @androw): a per-key flag restricting the key to discovered public models (combos / `auto/*` / `qtSd/*` routing still allowed).

Hardening applied during integration: migration renumber (089/090/091), `/api/plugins` LOCAL_ONLY route-guard classification (closes the plugin-RCE vector), atomic install/upgrade with path containment, `O_EXCL` tmp-file creation (TOCTOU), rate-limit-map eviction, `validatePluginConfig` on configure, `buildErrorBody` on all plugin error paths. 246/246 tests; typecheck / cycles / docs-sync clean.

Co-authored-by: oyi77 <14921983+oyi77@users.noreply.github.com>
Co-authored-by: Nicolas Lorin <androw95220@gmail.com>
@diegosouzapw

Copy link
Copy Markdown
Owner

Hi @oyi77 — thank you for this substantial contribution! 🙏

Your plugins framework has been integrated into release/v3.8.8 via #3041 (merged as 20c31493a). During integration I resolved the migration-numbering collisions (your 077 clashed with the release → renumbered to 089/090/091), added the missing plugin_analytics migration, and hardened the sandbox for production:

  • /api/plugins/* is now classified loopback-only (isLocalOnlyPath) in the route guard, closing a remote process-spawn vector
  • child_process exec is opt-in behind OMNIROUTE_PLUGINS_ALLOW_EXEC (default off); the worker sandbox keeps its vm timeout
  • atomic install/upgrade with path containment (assertWithinPluginDir), O_EXCL tmp-file creation (TOCTOU fix), rate-limit-map eviction, validatePluginConfig on configure, and buildErrorBody on every error path

Because the work shipped through the combined integration branch rather than a direct merge of this PR, I'm closing this one — but you're credited in the CHANGELOG (#2913 — thanks @oyi77) and as a co-author on the squash commit. It'll be part of the upcoming v3.8.8 release. Thanks again for the excellent work! 🚀

oyi77 added a commit to oyi77/OmniRoute that referenced this pull request Jun 9, 2026
- hooks.ts: fix emitHookBlocking hook chaining — pass accumulated
  body/metadata via currentPayload to each handler so downstream
  plugins observe upstream modifications
- scanner.ts: add path traversal guard — reject entryPoints that
  resolve outside the plugin directory
- chatCore.ts: convert dynamic plugin imports to static imports
  (no circular dependency risk; avoids per-request overhead)
- dashboard page.tsx: add error notification when activate/deactivate
  returns non-OK response (was silently swallowed)
HouMinXi pushed a commit to HouMinXi/OmniRoute that referenced this pull request Aug 2, 2026
…els (diegosouzapw#3041)

Integrates two community contributions into release/v3.8.8 with security hardening and conflict resolution.

- **Plugins framework** (diegosouzapw#2913 — thanks @oyi77): 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`); `child_process` exec is opt-in via `OMNIROUTE_PLUGINS_ALLOW_EXEC` (default off).
- **API key option: disable non-published models** (diegosouzapw#3017 — thanks @androw): a per-key flag restricting the key to discovered public models (combos / `auto/*` / `qtSd/*` routing still allowed).

Hardening applied during integration: migration renumber (089/090/091), `/api/plugins` LOCAL_ONLY route-guard classification (closes the plugin-RCE vector), atomic install/upgrade with path containment, `O_EXCL` tmp-file creation (TOCTOU), rate-limit-map eviction, `validatePluginConfig` on configure, `buildErrorBody` on all plugin error paths. 246/246 tests; typecheck / cycles / docs-sync clean.

Co-authored-by: oyi77 <14921983+oyi77@users.noreply.github.com>
Co-authored-by: Nicolas Lorin <androw95220@gmail.com>
Poid-ZA pushed a commit to Poid-ZA/OmniRoute that referenced this pull request Aug 5, 2026
…els (diegosouzapw#3041)

Integrates two community contributions into release/v3.8.8 with security hardening and conflict resolution.

- **Plugins framework** (diegosouzapw#2913 — thanks @oyi77): 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`); `child_process` exec is opt-in via `OMNIROUTE_PLUGINS_ALLOW_EXEC` (default off).
- **API key option: disable non-published models** (diegosouzapw#3017 — thanks @androw): a per-key flag restricting the key to discovered public models (combos / `auto/*` / `qtSd/*` routing still allowed).

Hardening applied during integration: migration renumber (089/090/091), `/api/plugins` LOCAL_ONLY route-guard classification (closes the plugin-RCE vector), atomic install/upgrade with path containment, `O_EXCL` tmp-file creation (TOCTOU), rate-limit-map eviction, `validatePluginConfig` on configure, `buildErrorBody` on all plugin error paths. 246/246 tests; typecheck / cycles / docs-sync clean.

Co-authored-by: oyi77 <14921983+oyi77@users.noreply.github.com>
Co-authored-by: Nicolas Lorin <androw95220@gmail.com>
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
…els (diegosouzapw#3041)

Integrates two community contributions into release/v3.8.8 with security hardening and conflict resolution.

- **Plugins framework** (diegosouzapw#2913 — thanks @oyi77): 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`); `child_process` exec is opt-in via `OMNIROUTE_PLUGINS_ALLOW_EXEC` (default off).
- **API key option: disable non-published models** (diegosouzapw#3017 — thanks @androw): a per-key flag restricting the key to discovered public models (combos / `auto/*` / `qtSd/*` routing still allowed).

Hardening applied during integration: migration renumber (089/090/091), `/api/plugins` LOCAL_ONLY route-guard classification (closes the plugin-RCE vector), atomic install/upgrade with path containment, `O_EXCL` tmp-file creation (TOCTOU), rate-limit-map eviction, `validatePluginConfig` on configure, `buildErrorBody` on all plugin error paths. 246/246 tests; typecheck / cycles / docs-sync clean.

Co-authored-by: oyi77 <14921983+oyi77@users.noreply.github.com>
Co-authored-by: Nicolas Lorin <androw95220@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants