Files
OmniRoute/docs/frameworks/PLUGIN_MARKETPLACE.md
Diego Rodrigues de Sa e Souza 5ac4637816 docs: reorganize docs/ + accuracy audit + drop Node 20 (rebased onto current release tip) (#5262)
* docs: move superpowers/research artifacts to isolated _tasks repo + docs tree cleanup

- Move docs/superpowers/{plans,specs} and docs/research/* into the gitignored,
  separately-versioned _tasks/ repo; untrack the two tracked research design docs.
- Add CLAUDE.md "Planning & Research Artifacts" section overriding the superpowers
  default save paths (docs/... -> _tasks/...); align REPOSITORY_MAP and
  DOCUMENTATION_OVERHAUL_PLAN with the new convention.
- Drop 4 now-obsolete /api/discovery/* entries from check-docs-symbols allowlist
  (stale-enforcement) and refresh code/spec path comments to _tasks/...
- Sweeps in concurrent docs-tree restructuring (root-level provider/guide docs,
  compression spec cleanup, .mcp.json.example removal).

* docs: reorganize docs/ tree + fix stale facts across ~26 docs

Phase A — reorganization:
- Move 7 orphan root docs into subfolders (providers/ created; TIERS+USAGE_QUOTA→guides/;
  plugins+PLUGIN_SDK→frameworks/); delete 8 obsolete/redundant docs (SUBMIT_PR superseded
  by CONTRIBUTING; DOCUMENTATION_OVERHAUL_PLAN; INCIDENT_RESPONSE/PERF_BUDGETS/THREAT_MODEL;
  3 ops snapshots). Rebuild README index (was missing ~40 files) + per-folder meta.json nav.
- Clean 14 dangling doc-path references in bin/ ops scripts, scripts/, workflow, tests;
  fix the dockerignore-docs-coverage required-docs path (PROVIDERS→providers/CLAUDE_WEB).

Phase B — content accuracy (verified against code, not the audit summary):
- Functional: ENVIRONMENT flag defaults (INPUT_SANITIZER/MCP_ENFORCE_SCOPES=true,
  COMPRESS_DESCRIPTIONS=false, dynamic heap); MCP-SERVER notion tool names (omniroute_*→
  notion_*) + counts 87→94; coverage gate 75/70→60/60/60/60 (RELEASE_CHECKLIST, COVERAGE_PLAN,
  ERROR_SANITIZATION, CONTRIBUTING); pre-push hook description; regenerate PROVIDER_REFERENCE (237).
- Count drift: providers 237, executors 70, migrations 106, db modules 94, oauth 19,
  strategies 17, MCP 94, flags 38, TS 6.0, open-sse ~900/services 294 across architecture/
  frameworks/ops docs; AUTO-COMBO 9→12 factors w/ correct DEFAULT_WEIGHTS; REASONING +2
  patterns; STEALTH UA defaults; AGENT_PROTOCOLS +cursor-cloud/list-capabilities;
  LANGUAGE_PACKS +id pack.
- Kept Node 20 (runtime guard accepts 20.20.2+; only engines is stricter) and MCP scopes=13
  (mcpScopes.ts) — both were correct in the docs; corrected only the attribution.

* docs: finish content refresh — compression engines, CLI_TOKEN merge, metadata sweep

- Compression: document the additional built-in engines (CCR, headroom, ionizer,
  session-dedup) in COMPRESSION_ENGINES; clarify LLMLingua-2 is the ultra-mode SLM
  backend + cross-ref the extra engines in EXTENDING_COMPRESSION; add the id
  (Indonesian) language pack to LANGUAGE_PACKS.
- AUTO-COMBO: replace the orphan 'How tiers fit' weight table (stale weights) with a
  pointer to the canonical 12-factor DEFAULT_WEIGHTS table.
- Security: merge CLI_TOKEN_AUTH.md (legacy 32-char SHA-256 format) into CLI_TOKEN.md
  as a 'Legacy format — still accepted' section (server accepts both HMAC + legacy),
  delete CLI_TOKEN_AUTH.md, drop it from the index + security nav.
- Metadata: bump stale frontmatter (version/lastUpdated) to 3.8.40/2026-06-28 across the
  doc set audited this pass, and normalize the in-body 'Last updated' header lines to match.

* fix(runtime): drop Node 20 from supported range + align all docs/diagrams/counts

- Node minimum is now 22 (aligned with package.json engines). SUPPORTED_NODE_RANGE in
  src/shared/utils/nodeRuntimeSupport.ts (and the bin/ mirror) drops the 20.x line →
  '>=22.22.2 <23 || >=24.0.0 <27'; getNodeRuntimeSupport now rejects Node 20 as
  unsupported-major. Test updated (TDD): node-runtime-support.test.ts asserts Node 20
  rejected. Docs aligned (TROUBLESHOOTING ×2, TERMUX, RELEASE_CHECKLIST, CODEBASE,
  CLI-TOOLS, README, llm.txt + 42 i18n llm.txt mirrors, skills/cli-serve).
- Diagrams regenerated: mcp-tools-87 -> mcp-tools-94 (34 base + pool 6 = 94) and
  auto-combo-9factor -> auto-combo-12factor (correct DEFAULT_WEIGHTS); SVGs re-rendered
  via mermaid-cli; doc refs + diagrams/README updated; fixed a pre-existing broken
  resilience-3layers image path.
- CLAUDE.md + AGENTS.md aligned to real counts (237 providers, 94 MCP tools / 34 base,
  106 migrations, 94 db modules, 12-factor auto-combo, 17 strategies); README provider
  count 231 -> 237; executor count corrected to 68 (provider executors, excl base/index)
  and OAuth to 18 across architecture docs. check:docs-all now passes (0 strict drift,
  0 broken links); removed dead .mcp.json.example doc link.

* fix(services): update installer Node hint to >=22.22.2 (aligned with dropped Node 20)

* docs: realign counts to current release tip after rebase

The release tip advanced while this work was in flight (Gemini CLI provider/executor
removed by #5246, plus other PRs). Re-counted against the current code and updated:
providers 237->236, executors 68->67, OAuth modules 18->17, open-sse services 294->298;
regenerated PROVIDER_REFERENCE.md (236). check:docs-all passes (0 strict drift).

* docs(changelog) + i18n: record Node 20 drop + fix nodeIncompatibleHint

- CHANGELOG: add [3.8.40] entries for the Node 20.x removal (runtime) and the docs
  reorganization/accuracy audit.
- i18n: nodeIncompatibleHint across all 42 locales no longer lists Node 20.x as
  supported (ASCII + CJK full-width variants), aligned with the dropped Node 20.

* fix(docs): repair CI breakages from the doc moves

- test: cli-plugin-system asserted docs/dev/plugins.md exists; the file moved to
  docs/frameworks/PLUGINS.md — point the test at the new path (Unit fast-path 2/2 fix).
- frontmatter: PLUGINS.md and PLUGIN_SDK.md moved into the fumadocs-indexed
  docs/frameworks/ which requires a 'title' frontmatter; the missing frontmatter
  failed the Next.js MDX build (dast-smoke 'invalid frontmatter'). Added frontmatter
  to both, plus the providers/ docs (consistency; that folder is not indexed).
2026-06-28 22:07:18 -03:00

17 KiB
Raw Blame History

title, version, lastUpdated
title version lastUpdated
Plugin Marketplace 3.8.40 2026-06-28

Plugin Marketplace

Source of truth: src/lib/plugins/ (marketplace.ts, manager.ts, manifest.ts, scanner.ts, loader.ts), src/app/api/plugins/, and src/app/(dashboard)/dashboard/plugins/ Last updated: 2026-06-28 — v3.8.40

OmniRoute ships a WordPress-style plugin system. Plugins are self-contained directories — each with a plugin.json manifest and an entry file — that hook into the request pipeline (onRequest / onResponse / onError) and into lifecycle events (onInstall / onActivate / onDeactivate / onUninstall).

The Plugin Marketplace is the discovery layer on top of that system. It exposes a browsable catalog of installable plugins. By default the catalog is a small built-in seed registry; an operator can point it at a custom remote registry URL, in which case the fetch is hardened by a DNS-resolving SSRF guard (see Security).

Every plugin route is loopback-only (Tier 1 — LOCAL_ONLY): plugins load and execute code in child processes, so the routes are unreachable from a non-loopback origin regardless of auth. See docs/security/ROUTE_GUARD_TIERS.md.

How It Fits Together

Dashboard (/dashboard/plugins)
  ├─ "Installed" tab  → GET /api/plugins            (listPlugins)
  │                     POST /api/plugins/scan      (pluginManager.scan)
  │                     POST /api/plugins/{name}/activate|deactivate
  │                     DELETE /api/plugins/{name}   (uninstall)
  └─ "Marketplace" tab → GET /api/plugins/marketplace
                          → listMarketplacePlugins()
                            ├─ no custom URL → built-in SEED_REGISTRY
                            └─ custom URL → isSafeMarketplaceUrl() SSRF guard
                                          → safeOutboundFetch(guard:"public-only")
  • Registry layersrc/lib/plugins/marketplace.ts: lists / searches the catalog, falling back to the seed registry on any failure.
  • Lifecycle layersrc/lib/plugins/manager.ts (pluginManager singleton): install, upgrade, activate, deactivate, uninstall, scan, startup load.
  • Manifest layersrc/lib/plugins/manifest.ts: Zod schema + defaults for plugin.json.
  • Scannersrc/lib/plugins/scanner.ts: discovers plugins on disk under the plugin directory.
  • Loadersrc/lib/plugins/loader.ts: spawns each plugin in an isolated child process and brokers hook calls over IPC.

Marketplace Catalog

listMarketplacePlugins() (src/lib/plugins/marketplace.ts) returns a list of MarketplaceEntry objects:

Field Type Notes
name string kebab-case plugin name
version string semver
description string Short summary
author string Author / org
license string SPDX-style license id
downloadUrl string Source download URL (may be empty)
repository string? Optional repository URL
tags string[] Search/filter tags
downloads number Download count
rating number 05
verified boolean Whether the entry is marked verified
lastUpdated string ISO-ish date string

When no custom registry URL is configured, the catalog is the built-in SEED_REGISTRY (currently request-logger, rate-limiter, cost-tracker, and theme-manager). The seed registry is always available — if a configured remote registry is unreachable, returns a non-200 status, or returns an unrecognized body, listMarketplacePlugins() logs a warning and falls back to the seed list.

Note: the marketplace catalog (browse/search) is wired end to end, but one-click marketplace install from the catalog is not yet implemented — the dashboard's "Install" button on a marketplace entry currently shows a "coming soon" notice. Installation today goes through the local-path install flow (POST /api/plugins) and on-disk discovery (POST /api/plugins/scan).

REST API

All endpoints require management auth (requireManagementAuth) and are loopback-only — /api/plugins and /api/plugins/ are listed in LOCAL_ONLY_API_PREFIXES (src/server/authz/routeGuard.ts).

Endpoint Method Description
/api/plugins GET List installed plugins (optional ?status= filter)
/api/plugins POST Install a plugin from an absolute local path
/api/plugins/scan POST Scan the plugin directory and register new plugins
/api/plugins/marketplace GET List marketplace catalog entries
/api/plugins/[name] GET Get installed plugin details
/api/plugins/[name] DELETE Uninstall a plugin
/api/plugins/[name]/activate POST Activate (load + register hooks)
/api/plugins/[name]/deactivate POST Deactivate (fire onDeactivate, unregister hooks)
/api/plugins/[name]/config GET Get plugin config + config schema
/api/plugins/[name]/config PUT Update plugin config (validated against schema)

The GET /api/plugins status filter accepts one of installed / active / inactive / error. An invalid value returns 400.

List installed plugins

curl http://localhost:20128/api/plugins \
  -H "Cookie: auth_token=..."

Install from a local path

curl -X POST http://localhost:20128/api/plugins \
  -H "Cookie: auth_token=..." \
  -H "Content-Type: application/json" \
  -d '{ "path": "/absolute/path/to/my-plugin" }'

The path must be absolute and may not contain .. traversal segments or null bytes (enforced by Zod). The source directory must contain a valid plugin.json (or be a parent of one). On success the response is 201 with the installed plugin row.

Browse the marketplace

curl http://localhost:20128/api/plugins/marketplace \
  -H "Cookie: auth_token=..."

Update plugin config

curl -X PUT http://localhost:20128/api/plugins/my-plugin/config \
  -H "Cookie: auth_token=..." \
  -H "Content-Type: application/json" \
  -d '{ "config": { "level": "debug", "maxItems": 100 } }'

PUT .../config validates each provided value against the plugin's configSchema (declared in the manifest): number fields honor min/max, select fields must match the declared enum. Keys not present in the schema are allowed through.

Configuration

Plugin directory

Plugins live under the OmniRoute data directory:

~/.omniroute/plugins/<plugin-name>/
  ├─ plugin.json
  └─ index.js          # (or whatever manifest.main points to)

getDefaultPluginDir() (src/lib/plugins/scanner.ts) resolves this to <home>/.omniroute/plugins, where <home> is taken from the HOME / USERPROFILE environment variables. POST /api/plugins/scan discovers any subdirectory there that holds a valid plugin.json and registers it.

Custom marketplace registry URL

The marketplace catalog source is read from the pluginMarketplaceUrl setting (src/lib/plugins/marketplace.ts reads settings.pluginMarketplaceUrl). When set to an http(s) URL, listMarketplacePlugins() fetches that URL and accepts either a top-level JSON array of entries or an object with a plugins array; entries without a string name are filtered out. When unset (or when the fetch fails the SSRF guard / returns a bad response), the built-in seed registry is used.

The dashboard "Marketplace" tab exposes a field for this URL (read back from GET /api/settings).

Implementation note: the dashboard "Save" action sends pluginMarketplaceUrl to PATCH /api/settings. At the time of writing this key is not declared in updateSettingsSchema (src/shared/validation/settingsSchemas.ts), so verify persistence in your release before relying on it — the read path (getSettings()listMarketplacePlugins()) honors the key once it is present in the settings store.

Security

Route tier — loopback only

Plugins execute code in spawned child processes, so the entire /api/plugins surface is classified LOCAL_ONLY (Tier 1). Loopback enforcement runs unconditionally before any auth check, so a leaked management token reaching the box over a tunnel still cannot install, activate, or uninstall a plugin. See docs/security/ROUTE_GUARD_TIERS.md and Hard Rules #15 / #17.

Marketplace registry SSRF guard

A custom registry URL is attacker-influenceable configuration, so before fetching it listMarketplacePlugins() runs it through two layers:

  1. isSafeMarketplaceUrl(url) (src/lib/plugins/marketplace.ts):
    • Rejects anything that is not http: / https:.
    • Rejects literal private/loopback/link-local/ULA hosts (IPv4 and IPv6, including IPv4-mapped) via the canonical isPrivateHost (src/shared/network/outboundUrlGuard.ts).
    • Resolves both A and AAAA records and rejects if any resolved address is private — closing the public-hostname → private-IP bypass.
    • Fails closed: a DNS resolution failure rejects the URL.
  2. safeOutboundFetch(url, { guard: "public-only", timeoutMs: 5000 }) (src/shared/network/safeOutboundFetch.ts): re-applies the public-only URL guard at fetch time and blocks redirects (no public → private 30x pivot).

A URL that fails either layer does not abort the request — the marketplace silently falls back to the built-in seed registry and logs a warning.

This guard was hardened in PR #3774 specifically to resolve A + AAAA and use the canonical isPrivateHost instead of an IPv4-only check.

Plugin execution isolation

  • Process isolationloadPlugin() (src/lib/plugins/loader.ts) spawns each plugin in a separate Node.js child process and communicates over IPC. Hook calls have a timeout with SIGTERMSIGKILL escalation.
  • Env allowlist — the child receives only an allowlisted set of environment variables; the broader set is only granted when the manifest requests the env permission.
  • Path containment — install/upgrade/uninstall assert that the plugin directory and manifest.main resolve within the managed plugin root before any copy or recursive delete (guards against tampered DB paths and ../ traversal in manifest.main). Activation resolves symlinks via realpath and refuses to load an entry point that escapes the plugin directory.
  • Optional integrity pin — a manifest may declare an integrity (sha256-<base64>, SRI format) field. When present, the loader verifies the entry file hash at load time and refuses to activate on mismatch. It is opt-in tamper-detection, not a security boundary — loopback-only routing and the permission model are the real boundaries.

Manifest (plugin.json)

Validated by PluginManifestSchema (src/lib/plugins/manifest.ts):

Field Type Notes
name string Required; kebab-case (^[a-z0-9-]+$), 1100 chars
version string Required; semver (MAJOR.MINOR.PATCH)
description string? ≤ 500 chars
author string? ≤ 200 chars
license string? Defaults to MIT
main string? Entry file; defaults to index.js
source enum? local | marketplace (defaults to local)
tags string[]? Search tags
requires object? { omniroute?, permissions[] }
hooks object? Booleans declaring which hooks the plugin implements
skills object[]? Optional skill definitions
enabledByDefault boolean? Auto-activate on install
configSchema object? Map of config fields (string/number/boolean/select)
integrity string? Optional sha256-<base64> entry-file pin

Permissions are drawn from the enum network / file-read / file-write / env / exec.

Lifecycle Flow

install (POST /api/plugins, path)
  → scan/validate manifest → copy to staging → assert main within dir
  → atomic rename into ~/.omniroute/plugins/<name> → insert DB row
  → fire onInstall → if enabledByDefault: activate

activate (POST /api/plugins/{name}/activate)
  → realpath containment check → loadPlugin() (spawn child process)
  → register declared hooks → status = "active" → fire onActivate

deactivate (POST /api/plugins/{name}/deactivate)
  → fire onDeactivate (BEFORE unregister) → unregister hooks
  → kill child process → status = "inactive"

uninstall (DELETE /api/plugins/{name})
  → deactivate if active → fire onUninstall
  → containment-checked recursive delete of plugin dir → delete DB row

Re-running install against a directory whose manifest version is strictly newer than the installed version auto-upgrades (clean reinstall; config resets to defaults). A same-or-older version is rejected.

Database

Table plugins (migration 076_create_plugins.sql):

Column Type Notes
id TEXT PK UUID
name TEXT Unique
version TEXT semver; default 1.0.0
description TEXT Optional
author TEXT Optional
license TEXT Default MIT
main TEXT Entry file; default index.js
source TEXT Default local
tags TEXT JSON array; default []
status TEXT installed | active | inactive | error
enabled INT 0/1; default 0
manifest TEXT Full manifest JSON
config TEXT JSON; default {}
config_schema TEXT JSON; default {}
hooks TEXT JSON array of declared hook names; default []
permissions TEXT JSON array; default []
plugin_dir TEXT Absolute install directory
error_message TEXT Set when status = "error"
installed_at TEXT datetime('now')
updated_at TEXT datetime('now')
activated_at TEXT Set on activation

Plugin metrics/analytics are tracked in additional tables (090_plugin_metrics.sql, 091_plugin_analytics.sql).

Dashboard

The dashboard page at /dashboard/plugins (src/app/(dashboard)/dashboard/plugins/page.tsx) provides two tabs:

  • Installed — lists installed plugins with their declared hooks, an activate/deactivate toggle, an uninstall button, and a "Scan for plugins" action (POST /api/plugins/scan).
  • Marketplace — shows the catalog from GET /api/plugins/marketplace with a field to set the custom registry URL.

A per-plugin config page lives at /dashboard/plugins/[name]/config (src/app/(dashboard)/dashboard/plugins/[name]/config/page.tsx).

See Also