--- title: "Plugin Marketplace" version: 3.8.40 lastUpdated: 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](#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`](../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 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` (`pluginManager` singleton): install, upgrade, activate, deactivate, uninstall, scan, startup load. - **Manifest layer** — `src/lib/plugins/manifest.ts`: Zod schema + defaults for `plugin.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 `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 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 ```bash curl http://localhost:20128/api/plugins \ -H "Cookie: auth_token=..." ``` ### Install from a local path ```bash 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 ```bash curl http://localhost:20128/api/plugins/marketplace \ -H "Cookie: auth_token=..." ``` ### Update plugin config ```bash 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.json └─ index.js # (or whatever manifest.main points to) ``` `getDefaultPluginDir()` (`src/lib/plugins/scanner.ts`) resolves this to `/.omniroute/plugins`, where `` 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`](../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 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 with `SIGTERM` → `SIGKILL` 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-`, 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-]+$`), 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-` 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/ → 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 - [`docs/security/ROUTE_GUARD_TIERS.md`](../security/ROUTE_GUARD_TIERS.md) — why `/api/plugins` is loopback-only (Tier 1) - [`docs/frameworks/SKILLS.md`](./SKILLS.md) — the related skills framework (`src/lib/skills/`); plugins may declare skills in their manifest - [`docs/frameworks/WEBHOOKS.md`](./WEBHOOKS.md) — event-driven outbound integrations - [`docs/security/ERROR_SANITIZATION.md`](../security/ERROR_SANITIZATION.md) — the `buildErrorBody()` pattern every plugin route uses for error responses