-
-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Plugin Marketplace
Source of truth:
src/lib/plugins/(marketplace.ts,manager.ts,manifest.ts,scanner.ts,loader.ts),src/app/api/plugins/, andsrc/app/(dashboard)/dashboard/plugins/Last updated: 2026-06-13 — v3.8.24
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.
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 layer —
src/lib/plugins/marketplace.ts: lists / searches the catalog, falling back to the seed registry on any failure. -
Lifecycle layer —
src/lib/plugins/manager.ts(pluginManagersingleton): install, upgrade, activate, deactivate, uninstall, scan, startup load. -
Manifest layer —
src/lib/plugins/manifest.ts: Zod schema + defaults forplugin.json. -
Scanner —
src/lib/plugins/scanner.ts: discovers plugins on disk under the plugin directory. -
Loader —
src/lib/plugins/loader.ts: spawns each plugin in an isolated child process and brokers hook calls over IPC.
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 | 0–5 |
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).
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.
curl http://localhost:20128/api/plugins \
-H "Cookie: auth_token=..."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.
curl http://localhost:20128/api/plugins/marketplace \
-H "Cookie: auth_token=..."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.
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.
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
pluginMarketplaceUrltoPATCH /api/settings. At the time of writing this key is not declared inupdateSettingsSchema(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.
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.
A custom registry URL is attacker-influenceable configuration, so before
fetching it listMarketplacePlugins() runs it through two layers:
-
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
AandAAAArecords and rejects if any resolved address is private — closing the public-hostname → private-IP bypass. - Fails closed: a DNS resolution failure rejects the URL.
- Rejects anything that is not
-
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 → private30xpivot).
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
isPrivateHostinstead of an IPv4-only check.
-
Process isolation —
loadPlugin()(src/lib/plugins/loader.ts) spawns each plugin in a separate Node.js child process and communicates over IPC. Hook calls have a timeout withSIGTERM→SIGKILLescalation. -
Env allowlist — the child receives only an allowlisted set of environment
variables; the broader set is only granted when the manifest requests the
envpermission. -
Path containment — install/upgrade/uninstall assert that the plugin
directory and
manifest.mainresolve within the managed plugin root before any copy or recursive delete (guards against tampered DB paths and../traversal inmanifest.main). Activation resolves symlinks viarealpathand 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.
Validated by PluginManifestSchema (src/lib/plugins/manifest.ts):
| Field | Type | Notes |
|---|---|---|
name |
string | Required; kebab-case (^[a-z0-9-]+$), 1–100 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.
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.
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).
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/marketplacewith 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).
-
docs/security/ROUTE_GUARD_TIERS.md— why/api/pluginsis loopback-only (Tier 1) -
docs/frameworks/SKILLS.md— the related skills framework (src/lib/skills/); plugins may declare skills in their manifest -
docs/frameworks/WEBHOOKS.md— event-driven outbound integrations -
docs/security/ERROR_SANITIZATION.md— thebuildErrorBody()pattern every plugin route uses for error responses
OmniRoute · Website · npm · Docker Hub
- Setup Guide
- User Guide
- Features
- Quick Start (Docker)
- Electron Desktop App
- Termux (Android)
- PWA Guide
- MCP Server
- A2A Server
- Agent Protocols
- OpenCode Plugin
- Webhooks
- Cloud Agents
- Skills
- Memory
- Evals
- Gamification
- Guardrails
- Compliance
- Error Sanitization
- Public Credentials
- Route Guard Tiers
- Stealth Guide
- CLI Token Auth