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-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
Section titled “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 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.
Marketplace Catalog
Section titled “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 | 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).
REST API
Section titled “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
Section titled “List installed plugins”curl http://localhost:20128/api/plugins \ -H "Cookie: auth_token=..."Install from a local path
Section titled “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
Section titled “Browse the marketplace”curl http://localhost:20128/api/plugins/marketplace \ -H "Cookie: auth_token=..."Update plugin config
Section titled “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
Section titled “Configuration”Plugin directory
Section titled “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 that directory in
three steps:
OMNIROUTE_PLUGINS_DIR, when set — used verbatim, whateverHOMEsays. This is the explicit knob for Docker/K8s, where the plugin tree is bind-mounted at a path that usually has nothing to do with the container’s home directory (#11827).<home>/.omniroute/plugins, where<home>comes from theHOME/USERPROFILEenvironment variables./tmp/.omniroute/plugins, when the process exports no home at all.
The resolved directory is logged once at startup as scanner.dir_resolved, naming the
input that won (OMNIROUTE_PLUGINS_DIR, home, or no-home-fallback) — so an image
that silently lands on step 3 says so, instead of only reporting an empty plugin list.
POST /api/plugins/scan discovers any subdirectory there that holds a valid
plugin.json and registers it; the same directory is the root that
pluginManager.install() copies plugins into, so an override moves discovery and
installation together.
OMNIROUTE_PLUGINS_DIRis notOMNIROUTE_PLUGIN_PATH. The latter is read only by the CLI command-plugin loader (bin/cli/plugins.mjs) to findomniroute-cmd-*npm packages that addomniroutesubcommands — it has no effect on the runtime scanner described here. See PLUGINS.md for that side.
Custom marketplace registry URL
Section titled “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
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.
Security
Section titled “Security”Route tier — loopback only
Section titled “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
Section titled “Marketplace registry SSRF guard”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.
Plugin execution isolation
Section titled “Plugin execution isolation”- 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.
Manifest (plugin.json)
Section titled “Manifest (plugin.json)”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.
Lifecycle Flow
Section titled “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 rowRe-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
Section titled “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
Section titled “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/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).
See Also
Section titled “See Also”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 manifestdocs/frameworks/WEBHOOKS.md— event-driven outbound integrationsdocs/security/ERROR_SANITIZATION.md— thebuildErrorBody()pattern every plugin route uses for error responses
HagiCode
HagiCode is an agentic coding workspace: structured workflows, multi-agent execution, and Hero Dungeon views turn ideas into shipped software.
Turn ideas into polished, usable software with a smarter, faster, and more enjoyable agentic coding workflow.

- SmartStructured workflows turn intent into an executable path from idea to shipped change.
- EfficientMulti-agent workflows keep research, implementation, and review moving in parallel.
- FunHero Dungeon interfaces make long coding sessions visual, collaborative, and rewarding.