File size: 17,568 Bytes
cd8bd0a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
---
title: "Plugin Marketplace"
version: 3.8.24
lastUpdated: 2026-06-13
---

# 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-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](#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-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`](../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-<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-]+$`), 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

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

- [`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