feat(plugins): plugins framework + per-API-key disable-non-public-models (#3041)

Integrates two community contributions into release/v3.8.8 with security hardening and conflict resolution.

- **Plugins framework** (#2913 — thanks @oyi77): hooks + registry unification, plugin SDK (`definePlugin`), worker-thread sandbox, per-plugin hook rate limiting, SHA-256 integrity verification, semver-gated upgrade, and execution analytics. Plugin routes are loopback-only (`isLocalOnlyPath`); `child_process` exec is opt-in via `OMNIROUTE_PLUGINS_ALLOW_EXEC` (default off).
- **API key option: disable non-published models** (#3017 — thanks @androw): a per-key flag restricting the key to discovered public models (combos / `auto/*` / `qtSd/*` routing still allowed).

Hardening applied during integration: migration renumber (089/090/091), `/api/plugins` LOCAL_ONLY route-guard classification (closes the plugin-RCE vector), atomic install/upgrade with path containment, `O_EXCL` tmp-file creation (TOCTOU), rate-limit-map eviction, `validatePluginConfig` on configure, `buildErrorBody` on all plugin error paths. 246/246 tests; typecheck / cycles / docs-sync clean.

Co-authored-by: oyi77 <14921983+oyi77@users.noreply.github.com>
Co-authored-by: Nicolas Lorin <androw95220@gmail.com>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-01 15:43:55 -03:00
committed by GitHub
parent 89c52d4f04
commit 20c31493af
63 changed files with 6227 additions and 117 deletions

View File

@@ -4,6 +4,8 @@
### Added
- **Plugins framework** (`src/lib/plugins/`, `/api/plugins/*`, `/dashboard/plugins`) — hooks + registry unification, plugin SDK (`definePlugin`), worker-thread sandbox, per-plugin hook rate limiting, SHA-256 integrity verification, semver-gated upgrade, and execution analytics. Plugin routes are loopback-only (`isLocalOnlyPath`) and `child_process` exec is opt-in via `OMNIROUTE_PLUGINS_ALLOW_EXEC`. (#2913 — thanks @oyi77)
- **API key option: disable non-published models** — a per-key flag restricting the key to discovered, public models (combos / `auto/*` / `qtSd/*` routing still allowed). (#3017 — thanks @androw)
- **SessionPool — modular & provider-agnostic** (`open-sse/services/sessionPool/`) — pooled
cookie/session manager with round-robin fingerprint rotation (distinct fingerprint per pooled
session), per-session cooldown/backoff, and a provider-agnostic `webExecutorWrapper`. Adds pool

242
docs/plugins/PLUGIN_SDK.md Normal file
View File

@@ -0,0 +1,242 @@
# OmniRoute Plugin SDK
## Quick Start
```ts
import { definePlugin } from "omniroute/plugins/sdk";
export default definePlugin({
name: "my-plugin",
priority: 50,
onRequest: async (ctx) => {
console.log(`Request ${ctx.requestId} for ${ctx.model}`);
},
onResponse: async (ctx, response) => {
console.log(`Response for ${ctx.requestId}`);
return response;
},
onError: async (ctx, error) => {
console.error(`Error: ${error.message}`);
},
});
```
## API Reference
### `definePlugin(def: PluginDefinition): Plugin`
Factory function that creates a Plugin object with defaults.
**Parameters:**
- `name` (string, required) — Plugin name in kebab-case
- `priority` (number, optional, default: 100) — Lower runs first
- `enabled` (boolean, optional, default: true) — Start enabled?
- `onRequest` (function, optional) — Runs before chat handler
- `onResponse` (function, optional) — Runs after chat handler
- `onError` (function, optional) — Runs on handler error
### `blockRequest(response?): BlockingHookResult`
Block the request and optionally return a custom response.
```ts
onRequest: (ctx) => {
if (!ctx.headers["authorization"]) {
return blockRequest({ error: "Unauthorized", status: 401 });
}
};
```
### `modifyBody(body): PluginResult`
Modify the request body before it reaches the provider.
```ts
onRequest: (ctx) => {
return modifyBody({ ...ctx.body, temperature: 0.7 });
};
```
### `addMetadata(metadata): PluginResult`
Attach metadata to the request context.
```ts
onRequest: (ctx) => {
return addMetadata({ source: "my-plugin", version: "1.0.0" });
};
```
## Plugin Context (`PluginContext`)
| Field | Type | Description |
|---|---|---|
| `requestId` | `string` | Unique request identifier |
| `model` | `string` | Requested model name |
| `provider` | `string` | Target provider ID |
| `body` | `Record<string, unknown>` | Request body |
| `headers` | `Record<string, string>` | Request headers |
| `metadata` | `Record<string, unknown>` | Mutable metadata |
| `timestamp` | `number` | Request timestamp |
## Manifest (`plugin.json`)
```json
{
"name": "my-plugin",
"version": "1.0.0",
"description": "A sample plugin",
"author": "your-name",
"main": "index.js",
"hooks": {
"onRequest": { "enabled": true, "priority": 50 },
"onResponse": true,
"onError": false
},
"requires": {
"permissions": ["network", "file-read"]
},
"enabledByDefault": false,
"configSchema": {
"apiKey": { "type": "string", "description": "API key for external service" },
"maxRetries": { "type": "number", "min": 1, "max": 10, "default": 3 },
"debug": { "type": "boolean", "default": false },
"mode": { "type": "string", "enum": ["fast", "slow"], "default": "fast" }
}
}
```
### Hook Priority
Hooks can be configured with priority (lower = runs first):
```json
{
"hooks": {
"onRequest": { "enabled": true, "priority": 10 },
"onResponse": { "enabled": true, "priority": 100 }
}
}
```
Or as simple booleans (default priority 100):
```json
{
"hooks": {
"onRequest": true,
"onResponse": true
}
}
```
## Permission System
Plugins run in a sandboxed VM context. Access to external resources requires explicit permissions:
| Permission | Grants |
|---|---|
| `network` | `fetch`, `AbortController`, `Headers`, `Request`, `Response` |
| `file-read` | `fs.readFile`, `fs.readdir`, `fs.stat` |
| `file-write` | `fs.writeFile`, `fs.mkdir`, `fs.rm` |
| `env` | Read-only `process.env` proxy |
| `exec` | `child_process.exec`, `child_process.execSync` |
Without a permission, the corresponding globals are simply not available in the sandbox.
## Config Schema
Define configurable settings in `configSchema`:
```json
{
"configSchema": {
"apiKey": { "type": "string", "description": "External API key" },
"maxRetries": { "type": "number", "min": 1, "max": 10, "default": 3 },
"debug": { "type": "boolean", "default": false },
"mode": { "type": "string", "enum": ["fast", "slow"], "default": "fast" }
}
}
```
Field types: `string`, `number`, `boolean`, `select`
Field options: `default`, `min`, `max`, `enum`, `description`
Config values are persisted in the database and accessible via the dashboard config page.
## Built-in Events
| Event | When | Payload |
|---|---|---|
| `onRequest` | Before chat handler | Request context |
| `onResponse` | After chat handler | Response data |
| `onError` | On handler error | Error object |
| `onModelSelect` | Model selected for routing | Model info |
| `onComboResolve` | Combo routing resolved | Combo targets |
| `onRateLimit` | Rate limit hit | Limit info |
| `onQuotaExhaust` | Quota exhausted | Quota info |
| `onProviderError` | Provider returned error | Error details |
| `onStreamStart` | SSE stream started | Stream info |
| `onStreamEnd` | SSE stream ended | Stream stats |
## Examples
### Request Logger
```ts
import { definePlugin } from "omniroute/plugins/sdk";
export default definePlugin({
name: "request-logger",
onRequest: async (ctx) => {
console.log(`[${new Date().toISOString()}] ${ctx.method} ${ctx.model} -> ${ctx.provider}`);
},
});
```
### Rate Limiter
```ts
import { definePlugin, blockRequest } from "omniroute/plugins/sdk";
const requests = new Map<string, number[]>();
export default definePlugin({
name: "rate-limiter",
priority: 10,
onRequest: async (ctx) => {
const key = ctx.headers["x-api-key"] || "anonymous";
const now = Date.now();
const window = 60000; // 1 minute
const maxRequests = 100;
const timestamps = (requests.get(key) || []).filter(t => t > now - window);
timestamps.push(now);
requests.set(key, timestamps);
if (timestamps.length > maxRequests) {
return blockRequest({ error: "Rate limit exceeded", status: 429 });
}
},
});
```
### Response Transformer
```ts
import { definePlugin } from "omniroute/plugins/sdk";
export default definePlugin({
name: "response-transformer",
onResponse: async (ctx, response) => {
if (response.choices) {
response.choices = response.choices.map((c: any) => ({
...c,
message: { ...c.message, content: c.message.content.trim() },
}));
}
return response;
},
});
```

View File

@@ -5,8 +5,32 @@
*/
import { z } from "zod";
import { resolve, normalize, isAbsolute } from "path";
import { listPlugins, getPluginByName, updatePluginConfig } from "../../../src/lib/db/plugins";
import { pluginManager } from "../../../src/lib/plugins/manager";
import { validatePluginConfig, type ConfigField } from "../../../src/lib/plugins/manifest";
/**
* Validate a path is safe for plugin installation.
* Prevents directory traversal and null byte injection.
*/
function validatePluginPath(path: string): string {
// Reject null bytes
if (path.includes("\0")) {
throw new Error("Invalid path: contains null bytes");
}
// Must be absolute
if (!isAbsolute(path)) {
throw new Error("Path must be absolute");
}
// Normalize and resolve to prevent traversal
const normalized = normalize(resolve(path));
// Reject paths with traversal patterns
if (normalized.includes("..") || normalized.includes("~")) {
throw new Error("Invalid path: directory traversal detected");
}
return normalized;
}
export const pluginTools = [
{
@@ -45,7 +69,8 @@ export const pluginTools = [
path: z.string().describe("Absolute path to the plugin directory containing plugin.json"),
}),
handler: async (args: { path: string }) => {
const plugin = await pluginManager.install(args.path);
const safePath = validatePluginPath(args.path);
const plugin = await pluginManager.install(safePath);
return {
success: true,
plugin: {
@@ -65,8 +90,13 @@ export const pluginTools = [
name: z.string().describe("Plugin name (kebab-case)"),
}),
handler: async (args: { name: string }) => {
await pluginManager.activate(args.name);
return { success: true, message: `Plugin '${args.name}' activated` };
try {
await pluginManager.activate(args.name);
return { success: true, message: `Plugin '${args.name}' activated` };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
return { success: false, error: msg };
}
},
},
@@ -78,8 +108,13 @@ export const pluginTools = [
name: z.string().describe("Plugin name (kebab-case)"),
}),
handler: async (args: { name: string }) => {
await pluginManager.deactivate(args.name);
return { success: true, message: `Plugin '${args.name}' deactivated` };
try {
await pluginManager.deactivate(args.name);
return { success: true, message: `Plugin '${args.name}' deactivated` };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
return { success: false, error: msg };
}
},
},
@@ -91,8 +126,13 @@ export const pluginTools = [
name: z.string().describe("Plugin name (kebab-case)"),
}),
handler: async (args: { name: string }) => {
await pluginManager.uninstall(args.name);
return { success: true, message: `Plugin '${args.name}' uninstalled` };
try {
await pluginManager.uninstall(args.name);
return { success: true, message: `Plugin '${args.name}' uninstalled` };
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
return { success: false, error: msg };
}
},
},
@@ -109,11 +149,22 @@ export const pluginTools = [
}),
handler: async (args: { name: string; config?: Record<string, unknown> }) => {
const plugin = getPluginByName(args.name);
if (!plugin) throw new Error(`Plugin '${args.name}' not found`);
if (!plugin) return { success: false, error: `Plugin '${args.name}' not found` };
if (args.config) {
const current = JSON.parse(plugin.config || "{}");
const merged = { ...current, ...args.config };
// Validate merged config against configSchema if the plugin declares one
const rawSchema = JSON.parse(plugin.configSchema || "{}") as Record<string, ConfigField>;
if (Object.keys(rawSchema).length > 0) {
const validation = validatePluginConfig(merged, rawSchema);
if (!validation.valid) {
// Return a generic message — do NOT leak raw field-level detail externally
return { success: false, error: "Config validation failed: one or more values are invalid" };
}
}
updatePluginConfig(args.name, merged);
return { success: true, config: merged };
}
@@ -127,17 +178,25 @@ export const pluginTools = [
{
name: "plugin_executions",
description: "View plugin execution history (from skill_executions table).",
description: "View plugin execution metrics (from plugin_analytics table).",
scopes: ["read:plugins"],
inputSchema: z.object({
name: z.string().optional().describe("Filter by plugin name"),
limit: z.number().min(1).max(100).default(20).describe("Max results to return"),
}),
handler: async (args: { name?: string; limit?: number }) => {
// Plugin executions are tracked via the skills system
const { skillExecutor } = await import("../../../src/lib/skills/executor");
const executions = skillExecutor.listExecutions(undefined, args.limit || 20);
return { executions };
const { getPluginAnalytics, getPluginAnalyticsSummary } = await import(
"../../../src/lib/db/plugins"
);
const limit = args.limit || 20;
if (args.name) {
const rows = getPluginAnalytics(args.name).slice(0, limit);
return { metrics: rows };
}
// No name filter: return all plugins' summaries
const allPlugins = listPlugins();
const metrics = allPlugins.slice(0, limit).map((p) => getPluginAnalyticsSummary(p.name));
return { metrics };
},
},

View File

@@ -101,6 +101,7 @@ interface ApiKey {
scopes?: string[];
allowedEndpoints?: string[];
streamDefaultMode?: StreamDefaultMode;
disableNonPublicModels?: boolean;
createdAt: string;
}
@@ -516,7 +517,8 @@ export default function ApiManagerPageClient() {
rateLimits: Array<{ limit: number; window: number }> | null,
scopes: string[],
allowedEndpoints: string[],
streamDefaultMode: StreamDefaultMode
streamDefaultMode: StreamDefaultMode,
disableNonPublicModels: boolean
) => {
if (!editingKey || !editingKey.id) return;
@@ -578,6 +580,7 @@ export default function ApiManagerPageClient() {
scopes,
allowedEndpoints,
streamDefaultMode,
disableNonPublicModels,
}),
});
@@ -1285,7 +1288,8 @@ const PermissionsModal = memo(function PermissionsModal({
rateLimits: Array<{ limit: number; window: number }> | null,
scopes: string[],
allowedEndpoints: string[],
streamDefaultMode: StreamDefaultMode
streamDefaultMode: StreamDefaultMode,
disableNonPublicModels: boolean
) => void;
}) {
const t = useTranslations("apiManager");
@@ -1354,6 +1358,9 @@ const PermissionsModal = memo(function PermissionsModal({
const initialEndpoints = Array.isArray(apiKey?.allowedEndpoints) ? apiKey.allowedEndpoints : [];
const [selectedEndpoints, setSelectedEndpoints] = useState<string[]>(initialEndpoints);
const [allowAllEndpoints, setAllowAllEndpoints] = useState(initialEndpoints.length === 0);
const [disableNonPublicModels, setDisableNonPublicModels] = useState(
apiKey?.disableNonPublicModels === true
);
// Memoize callbacks to prevent child re-renders
const handleToggleModel = useCallback(
@@ -1504,7 +1511,8 @@ const PermissionsModal = memo(function PermissionsModal({
selfAccountQuotaEnabled,
}),
allowAllEndpoints ? [] : selectedEndpoints,
streamDefaultMode
streamDefaultMode,
disableNonPublicModels
);
}, [
onSave,
@@ -1534,6 +1542,7 @@ const PermissionsModal = memo(function PermissionsModal({
allowAllEndpoints,
selectedEndpoints,
streamDefaultMode,
disableNonPublicModels,
apiKey?.scopes,
t,
]);
@@ -2057,6 +2066,30 @@ const PermissionsModal = memo(function PermissionsModal({
<p className="text-xs text-text-muted">{t("sharedAccountQuotaVisibilityDesc")}</p>
</div>
{/* Disable Non-Public Models Toggle */}
<div className="flex items-start justify-between gap-3 p-3 rounded-lg border border-border bg-surface/40">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-text-main">{t("disableNonPublicModels")}</p>
<p className="text-xs text-text-muted">{t("disableNonPublicModelsDesc")}</p>
</div>
<button
type="button"
role="switch"
aria-checked={disableNonPublicModels}
onClick={() => setDisableNonPublicModels((prev) => !prev)}
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-semibold transition-colors ${
disableNonPublicModels
? "bg-amber-500/15 text-amber-700 dark:text-amber-300 border border-amber-500/30"
: "bg-black/5 dark:bg-white/5 text-text-muted border border-border"
}`}
>
<span className="material-symbols-outlined text-[14px]">
{disableNonPublicModels ? "shield_lock" : "shield"}
</span>
{disableNonPublicModels ? tc("yes") : tc("no")}
</button>
</div>
{/* Selected Models Summary (only in restrict mode) */}
{!allowAll && selectedCount > 0 && (
<div className="flex flex-col gap-1.5 p-2 bg-primary/5 rounded-lg border border-primary/20">

View File

@@ -0,0 +1,154 @@
"use client";
import { useState, useEffect, useCallback, use } from "react";
import { Card, Button } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
import { useTranslations } from "next-intl";
interface ConfigField {
type: string;
default?: unknown;
min?: number;
max?: number;
enum?: string[];
description?: string;
}
interface PluginConfig {
name: string;
config: Record<string, unknown>;
configSchema: Record<string, ConfigField>;
}
export default function PluginConfigPage({
params,
}: {
params: Promise<{ name: string }>;
}) {
const { name } = use(params);
const { addNotification } = useNotificationStore();
const t = useTranslations("plugins");
const [plugin, setPlugin] = useState<PluginConfig | null>(null);
const [config, setConfig] = useState<Record<string, unknown>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const fetchConfig = useCallback(async () => {
try {
const res = await fetch(`/api/plugins/${name}/config`);
if (res.ok) {
const data = await res.json();
setPlugin(data);
setConfig(data.config || {});
}
} catch {
// ignore
} finally {
setLoading(false);
}
}, [name]);
useEffect(() => {
fetchConfig();
}, [fetchConfig]);
const handleSave = async () => {
setSaving(true);
try {
const res = await fetch(`/api/plugins/${name}/config`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ config }),
});
if (res.ok) {
addNotification({ type: "success", message: t("configurationSaved") });
} else {
addNotification({ type: "error", message: t("saveConfigurationFailed") });
}
} catch {
addNotification({ type: "error", message: t("saveConfigurationFailed") });
} finally {
setSaving(false);
}
};
const handleChange = (key: string, value: unknown) => {
setConfig((prev) => ({ ...prev, [key]: value }));
};
if (loading) return <div className="p-6">{t("loading")}</div>;
if (!plugin) return <div className="p-6">{t("pluginNotFound")}</div>;
const schemaKeys = Object.keys(plugin.configSchema || {});
return (
<div className="space-y-6 p-6">
<h1 className="text-2xl font-bold">{t("configure", { name })}</h1>
{schemaKeys.length === 0 ? (
<Card className="p-4">
<p className="text-gray-500">{t("noConfigSettings")}</p>
</Card>
) : (
<Card className="space-y-4 p-4">
{schemaKeys.map((key) => {
const field = plugin.configSchema[key];
const value = config[key] ?? field.default ?? "";
return (
<div key={key} className="space-y-1">
<label className="text-sm font-medium">
{key}
{field.description && (
<span className="ml-2 text-xs text-gray-500">
{field.description}
</span>
)}
</label>
{field.type === "boolean" ? (
<input
type="checkbox"
checked={!!value}
onChange={(e) => handleChange(key, e.target.checked)}
className="ml-2"
/>
) : field.enum ? (
<select
value={String(value)}
onChange={(e) => handleChange(key, e.target.value)}
className="w-full rounded border p-2"
>
{field.enum.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
) : field.type === "number" ? (
<input
type="number"
value={Number(value)}
min={field.min}
max={field.max}
onChange={(e) => handleChange(key, Number(e.target.value))}
className="w-full rounded border p-2"
/>
) : (
<input
type="text"
value={String(value)}
onChange={(e) => handleChange(key, e.target.value)}
className="w-full rounded border p-2"
/>
)}
</div>
);
})}
<Button onClick={handleSave} disabled={saving}>
{saving ? t("saving") : t("saveConfiguration")}
</Button>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,146 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Card, Button, EmptyState } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
import { useTranslations } from "next-intl";
interface PluginInfo {
name: string;
version: string;
description?: string;
author?: string;
status: string;
enabled: boolean;
hooks: string[];
}
export default function PluginsPage() {
const { addNotification } = useNotificationStore();
const t = useTranslations("plugins");
const [plugins, setPlugins] = useState<PluginInfo[]>([]);
const [loading, setLoading] = useState(true);
const [scanning, setScanning] = useState(false);
const fetchPlugins = useCallback(async () => {
try {
const res = await fetch("/api/plugins");
if (res.ok) {
const data = await res.json();
setPlugins(data.plugins || []);
}
} catch {
// ignore
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchPlugins();
}, [fetchPlugins]);
const handleScan = async () => {
setScanning(true);
try {
const res = await fetch("/api/plugins/scan", { method: "POST" });
if (res.ok) {
addNotification({ type: "success", message: t("pluginScanComplete") });
await fetchPlugins();
}
} catch {
addNotification({ type: "error", message: t("pluginScanFailed") });
} finally {
setScanning(false);
}
};
const handleToggle = async (name: string, enable: boolean) => {
const endpoint = enable ? "activate" : "deactivate";
try {
const res = await fetch(`/api/plugins/${name}/${endpoint}`, { method: "POST" });
if (res.ok) {
addNotification({ type: "success", message: enable ? t("activated", { name }) : t("deactivated", { name }) });
await fetchPlugins();
}
} catch {
addNotification({ type: "error", message: enable ? t("activateFailed", { name }) : t("deactivateFailed", { name }) });
}
};
const handleUninstall = async (name: string) => {
if (!confirm(t("uninstallConfirm", { name }))) return;
try {
const res = await fetch(`/api/plugins/${name}`, { method: "DELETE" });
if (res.ok) {
addNotification({ type: "success", message: t("uninstalled", { name }) });
await fetchPlugins();
}
} catch {
addNotification({ type: "error", message: t("uninstallFailed", { name }) });
}
};
if (loading) {
return <div className="p-6">{t("loading")}</div>;
}
return (
<div className="space-y-6 p-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">{t("title")}</h1>
<Button onClick={handleScan} disabled={scanning}>
{scanning ? t("scanning") : t("scanForPlugins")}
</Button>
</div>
{plugins.length === 0 ? (
<EmptyState
title={t("noPlugins")}
description={t("noPluginsDescription")}
/>
) : (
<div className="grid gap-4">
{plugins.map((plugin) => (
<Card key={plugin.name} className="p-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold">{plugin.name}</h3>
<p className="text-sm text-gray-500">
v{plugin.version}
{plugin.author ? ` by ${plugin.author}` : ""}
{plugin.description ? `${plugin.description}` : ""}
</p>
<div className="mt-1 flex gap-1">
{plugin.hooks.map((hook) => (
<span
key={hook}
className="rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-700"
>
{hook}
</span>
))}
</div>
</div>
<div className="flex gap-2">
<Button
variant={plugin.enabled ? "secondary" : "primary"}
onClick={() => handleToggle(plugin.name, !plugin.enabled)}
>
{plugin.enabled ? t("deactivate") : t("activate")}
</Button>
<Button
variant="danger"
onClick={() => handleUninstall(plugin.name)}
>
{t("uninstall")}
</Button>
</div>
</div>
</Card>
))}
</div>
)}
</div>
);
}

View File

@@ -80,6 +80,7 @@ export async function PATCH(request, { params }) {
scopes,
allowedEndpoints,
streamDefaultMode,
disableNonPublicModels,
} = validation.data;
const payload: Parameters<typeof updateApiKeyPermissions>[1] = {};
@@ -99,6 +100,7 @@ export async function PATCH(request, { params }) {
if (scopes !== undefined) payload.scopes = scopes;
if (allowedEndpoints !== undefined) payload.allowedEndpoints = allowedEndpoints;
if (streamDefaultMode !== undefined) payload.streamDefaultMode = streamDefaultMode;
if (disableNonPublicModels !== undefined) payload.disableNonPublicModels = disableNonPublicModels;
const updated = await updateApiKeyPermissions(id, payload);
if (!updated) {
@@ -126,6 +128,7 @@ export async function PATCH(request, { params }) {
...(scopes !== undefined && { scopes }),
...(allowedEndpoints !== undefined && { allowedEndpoints }),
...(streamDefaultMode !== undefined && { streamDefaultMode }),
...(disableNonPublicModels !== undefined && { disableNonPublicModels }),
});
} catch (error) {
log.error("keys", "Error updating key permissions", error);

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
import { buildErrorBody } from "@omniroute/open-sse/utils/error";
import { pluginManager } from "@/lib/plugins/manager";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
@@ -24,7 +25,11 @@ export async function POST(
{ success: true, message: `Plugin '${name}' activated` },
{ headers: CORS_HEADERS }
);
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 400, headers: CORS_HEADERS });
} catch (err: unknown) {
console.error("[plugins] Failed to activate plugin:", err);
return NextResponse.json(buildErrorBody(400, "Failed to activate plugin"), {
status: 400,
headers: CORS_HEADERS,
});
}
}

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
import { buildErrorBody } from "@omniroute/open-sse/utils/error";
import { getPluginByName, updatePluginConfig } from "@/lib/db/plugins";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { z } from "zod";
@@ -18,10 +19,10 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
const plugin = getPluginByName(name);
if (!plugin) {
return NextResponse.json(
{ error: `Plugin '${name}' not found` },
{ status: 404, headers: CORS_HEADERS }
);
return NextResponse.json(buildErrorBody(404, `Plugin '${name}' not found`), {
status: 404,
headers: CORS_HEADERS,
});
}
return NextResponse.json(
@@ -48,18 +49,18 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
const parsed = schema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid request", details: parsed.error.issues },
{ status: 400, headers: CORS_HEADERS }
);
return NextResponse.json(buildErrorBody(400, "Invalid request"), {
status: 400,
headers: CORS_HEADERS,
});
}
const plugin = getPluginByName(name);
if (!plugin) {
return NextResponse.json(
{ error: `Plugin '${name}' not found` },
{ status: 404, headers: CORS_HEADERS }
);
return NextResponse.json(buildErrorBody(404, `Plugin '${name}' not found`), {
status: 404,
headers: CORS_HEADERS,
});
}
// Validate config values against configSchema if defined
@@ -70,21 +71,21 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
if (!field) continue; // Allow extra keys
if (field.type === "number" && typeof value === "number") {
if (field.min !== undefined && value < field.min) {
return NextResponse.json(
{ error: `Config '${key}' must be >= ${field.min}` },
{ status: 400, headers: CORS_HEADERS }
);
return NextResponse.json(buildErrorBody(400, `Config '${key}' must be >= ${field.min}`), {
status: 400,
headers: CORS_HEADERS,
});
}
if (field.max !== undefined && value > field.max) {
return NextResponse.json(
{ error: `Config '${key}' must be <= ${field.max}` },
{ status: 400, headers: CORS_HEADERS }
);
return NextResponse.json(buildErrorBody(400, `Config '${key}' must be <= ${field.max}`), {
status: 400,
headers: CORS_HEADERS,
});
}
}
if (field.type === "select" && field.enum && !field.enum.includes(String(value))) {
return NextResponse.json(
{ error: `Config '${key}' must be one of: ${field.enum.join(", ")}` },
buildErrorBody(400, `Config '${key}' must be one of: ${field.enum.join(", ")}`),
{ status: 400, headers: CORS_HEADERS }
);
}

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
import { buildErrorBody } from "@omniroute/open-sse/utils/error";
import { pluginManager } from "@/lib/plugins/manager";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
@@ -24,7 +25,11 @@ export async function POST(
{ success: true, message: `Plugin '${name}' deactivated` },
{ headers: CORS_HEADERS }
);
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 400, headers: CORS_HEADERS });
} catch (err: unknown) {
console.error("[plugins] Failed to deactivate plugin:", err);
return NextResponse.json(buildErrorBody(400, "Failed to deactivate plugin"), {
status: 400,
headers: CORS_HEADERS,
});
}
}

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
import { buildErrorBody } from "@omniroute/open-sse/utils/error";
import { getPluginByName } from "@/lib/db/plugins";
import { pluginManager } from "@/lib/plugins/manager";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
@@ -70,7 +71,11 @@ export async function DELETE(
{ success: true, message: `Plugin '${name}' uninstalled` },
{ headers: CORS_HEADERS }
);
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 400, headers: CORS_HEADERS });
} catch (err: unknown) {
console.error("[plugins] Failed to uninstall plugin:", err);
return NextResponse.json(buildErrorBody(400, "Failed to uninstall plugin"), {
status: 400,
headers: CORS_HEADERS,
});
}
}

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
import { buildErrorBody } from "@omniroute/open-sse/utils/error";
import { listPlugins } from "@/lib/db/plugins";
import { pluginManager } from "@/lib/plugins/manager";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
@@ -12,17 +13,29 @@ export async function OPTIONS() {
/**
* GET /api/plugins — List all installed plugins
*/
const StatusSchema = z.enum(["installed", "active", "inactive", "error"]).optional();
export async function GET(request: NextRequest) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
const url = new URL(request.url);
const status = url.searchParams.get("status") as any;
const statusResult = StatusSchema.safeParse(url.searchParams.get("status"));
if (!statusResult.success) {
return NextResponse.json(
{ error: "Invalid status value", details: statusResult.error.issues },
{ status: 400, headers: CORS_HEADERS }
);
}
try {
const plugins = listPlugins(status || undefined);
const plugins = listPlugins(statusResult.data || undefined);
return NextResponse.json({ plugins: plugins.map(formatPlugin) }, { headers: CORS_HEADERS });
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500, headers: CORS_HEADERS });
} catch (err: unknown) {
console.error("[plugins] Failed to list plugins:", err);
return NextResponse.json(buildErrorBody(500, "Failed to list plugins"), {
status: 500,
headers: CORS_HEADERS,
});
}
}
@@ -34,7 +47,10 @@ export async function POST(request: NextRequest) {
if (authError) return authError;
const body = await request.json();
const schema = z.object({
path: z.string().min(1),
path: z.string().min(1).regex(/^\/[^]*$/, "Path must be absolute").refine(
(p) => !p.includes("\0") && !p.includes(".."),
"Path must not contain traversal patterns or null bytes"
),
});
const parsed = schema.safeParse(body);
@@ -51,8 +67,12 @@ export async function POST(request: NextRequest) {
{ plugin: formatPlugin(plugin) },
{ status: 201, headers: CORS_HEADERS }
);
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 400, headers: CORS_HEADERS });
} catch (err: unknown) {
console.error("[plugins] Failed to install plugin:", err);
return NextResponse.json(buildErrorBody(400, "Failed to install plugin"), {
status: 400,
headers: CORS_HEADERS,
});
}
}

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
import { buildErrorBody } from "@omniroute/open-sse/utils/error";
import { pluginManager } from "@/lib/plugins/manager";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
@@ -19,7 +20,11 @@ export async function POST(request: NextRequest) {
{ discovered: result.discovered, errors: result.errors },
{ headers: CORS_HEADERS }
);
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500, headers: CORS_HEADERS });
} catch (err: unknown) {
console.error("[plugins] Failed to scan plugin directory:", err);
return NextResponse.json(buildErrorBody(500, "Failed to scan plugin directory"), {
status: 500,
headers: CORS_HEADERS,
});
}
}

View File

@@ -1762,7 +1762,9 @@
"filterTypeRestricted": "Restricted",
"shownOf": "{shown} of {total} shown",
"emptyFilterTitle": "No keys match your filters",
"emptyFilterClear": "Clear filters"
"emptyFilterClear": "Clear filters",
"disableNonPublicModels": "Disable Non-Public Models",
"disableNonPublicModelsDesc": "Reject requests for models that are not discovered or not marked as public in the provider catalog"
},
"auditLog": {
"title": "Audit Log",
@@ -4611,7 +4613,21 @@
"webNoAuthGuideBody": "{provider} does not need an API key or cookie. Save the connection to use its free web endpoint.",
"webSessionCredentialValidationFailed": "Session credential validation failed. Sign in again, copy a fresh credential, and try again.",
"checkCookie": "Check cookie",
"checkWebToken": "Check token"
"checkWebToken": "Check token",
"huggingchatLabel": "HuggingChat (Free)",
"huggingchatDesc": "Free LLM chat via huggingface.co/chat",
"phindLabel": "Phind (Free)",
"phindDesc": "Free dev-focused AI chat",
"poeWebLabel": "Poe Web",
"poeWebDesc": "Multi-model chat via poe.com",
"veniceWebLabel": "Venice Web",
"veniceWebDesc": "Privacy-focused AI chat",
"v0VercelWebLabel": "v0 Vercel Web",
"v0VercelWebDesc": "AI code generation via v0.dev",
"kimiWebLabel": "Kimi Web",
"kimiWebDesc": "Chinese market AI chat via kimi.moonshot.cn",
"doubaoWebLabel": "Doubao Web",
"doubaoWebDesc": "ByteDance AI chat via doubao.com"
},
"settings": {
"title": "Settings",

View File

@@ -9,6 +9,12 @@ import { backupDbFile } from "./backup";
import { registerDbStateResetter } from "./stateReset";
import { getKeyGroupsForApiKey, checkKeyModelAccess } from "./apiKeyGroups";
import { setNoLog } from "../compliance/noLog";
import { resolveModelAlias } from "@omniroute/open-sse/services/modelDeprecation.ts";
import {
getSyncedAvailableModelsByConnection,
getCustomModels,
getModelIsHidden,
} from "./models";
// ──────────────── Performance Optimizations ────────────────
@@ -62,6 +68,7 @@ interface ApiKeyMetadata {
keyHash: string | null;
allowedEndpoints: string[];
streamDefaultMode: "legacy" | "json";
disableNonPublicModels: boolean;
}
interface ApiKeyRow extends JsonRecord {
@@ -129,6 +136,7 @@ interface ApiKeyView extends JsonRecord {
expiresAt?: string | null;
allowedEndpoints: string[];
streamDefaultMode: "legacy" | "json";
disableNonPublicModels?: boolean;
}
// LRU cache for API key validation (valid keys only)
@@ -166,6 +174,7 @@ const API_KEY_COLUMN_FALLBACKS = [
{ name: "allowed_endpoints", definition: "allowed_endpoints TEXT" },
{ name: "allowed_quotas", definition: "allowed_quotas TEXT NOT NULL DEFAULT '[]'" },
{ name: "stream_default_mode", definition: "stream_default_mode TEXT NOT NULL DEFAULT 'legacy'" },
{ name: "disable_non_public_models", definition: "disable_non_public_models INTEGER NOT NULL DEFAULT 0" },
] as const;
// Cache for model permission checks
@@ -365,7 +374,7 @@ function getPreparedStatements(db: ApiKeysDbLike): ApiKeysStatements {
"SELECT id, expires_at, revoked_at, is_active, is_banned FROM api_keys WHERE key = ? OR key_hash = ?"
);
_stmtGetKeyMetadata = db.prepare<ApiKeyRow>(
"SELECT id, name, machine_id, allowed_models, allowed_combos, allowed_connections, allowed_quotas, no_log, auto_resolve, is_active, access_schedule, max_requests_per_day, max_requests_per_minute, throttle_delay_ms, max_sessions, revoked_at, expires_at, ip_allowlist, scopes, rate_limits, is_banned, key_hash, allowed_endpoints, stream_default_mode FROM api_keys WHERE key = ? OR key_hash = ?"
"SELECT id, name, machine_id, allowed_models, allowed_combos, allowed_connections, allowed_quotas, no_log, auto_resolve, is_active, access_schedule, max_requests_per_day, max_requests_per_minute, throttle_delay_ms, max_sessions, revoked_at, expires_at, ip_allowlist, scopes, rate_limits, is_banned, key_hash, allowed_endpoints, stream_default_mode, disable_non_public_models FROM api_keys WHERE key = ? OR key_hash = ?"
);
_stmtInsertKey = db.prepare(
"INSERT INTO api_keys (id, name, key, machine_id, allowed_models, no_log, created_at, key_prefix, key_hash, scopes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
@@ -413,6 +422,7 @@ export async function getApiKeys() {
camelRow.scopes = parseStringList((camelRow as JsonRecord).scopes);
camelRow.allowedEndpoints = parseStringList((camelRow as JsonRecord).allowedEndpoints);
camelRow.streamDefaultMode = parseStreamDefaultMode((camelRow as JsonRecord).streamDefaultMode);
camelRow.disableNonPublicModels = parseDisableNonPublicModels((camelRow as JsonRecord).disableNonPublicModels);
if (typeof camelRow.id === "string" && camelRow.id.length > 0) {
setNoLog(camelRow.id, camelRow.noLog === true);
}
@@ -439,6 +449,7 @@ export async function getApiKeyById(id: string) {
camelRow.scopes = parseStringList((camelRow as JsonRecord).scopes);
camelRow.allowedEndpoints = parseStringList((camelRow as JsonRecord).allowedEndpoints);
camelRow.streamDefaultMode = parseStreamDefaultMode((camelRow as JsonRecord).streamDefaultMode);
camelRow.disableNonPublicModels = parseDisableNonPublicModels((camelRow as JsonRecord).disableNonPublicModels);
if (typeof camelRow.id === "string" && camelRow.id.length > 0) {
setNoLog(camelRow.id, camelRow.noLog === true);
}
@@ -474,6 +485,10 @@ function parseAutoResolve(value: unknown): boolean {
return value === true || value === 1 || value === "1";
}
function parseDisableNonPublicModels(value: unknown): boolean {
return value === true || value === 1 || value === "1";
}
function parseIsActive(value: unknown): boolean {
// DEFAULT 1 — active unless explicitly set to 0
if (value === 0 || value === "0" || value === false) return false;
@@ -698,6 +713,7 @@ export async function updateApiKeyPermissions(
scopes?: string[] | null;
allowedEndpoints?: string[] | null;
streamDefaultMode?: "legacy" | "json" | null;
disableNonPublicModels?: boolean;
}
) {
const db = getDbInstance() as ApiKeysDbLike;
@@ -727,6 +743,8 @@ export async function updateApiKeyPermissions(
allowedEndpoints: (update as { allowedEndpoints?: string[] | null }).allowedEndpoints,
streamDefaultMode: (update as { streamDefaultMode?: "legacy" | "json" | null })
.streamDefaultMode,
disableNonPublicModels: (update as { disableNonPublicModels?: boolean })
.disableNonPublicModels,
};
if (
@@ -748,7 +766,8 @@ export async function updateApiKeyPermissions(
(normalized as Record<string, unknown>).maxSessions === undefined &&
(normalized as Record<string, unknown>).scopes === undefined &&
(normalized as Record<string, unknown>).allowedEndpoints === undefined &&
(normalized as Record<string, unknown>).streamDefaultMode === undefined
(normalized as Record<string, unknown>).streamDefaultMode === undefined &&
normalized.disableNonPublicModels === undefined
) {
return false;
}
@@ -774,6 +793,7 @@ export async function updateApiKeyPermissions(
expiresAt?: string | null;
scopes?: string;
streamDefaultMode?: "legacy" | "json";
disableNonPublicModels?: number;
} = { id };
if (normalized.name !== undefined) {
@@ -861,6 +881,11 @@ export async function updateApiKeyPermissions(
params.expiresAt = normalized.expiresAt;
}
if (normalized.disableNonPublicModels !== undefined) {
updates.push("disable_non_public_models = @disableNonPublicModels");
params.disableNonPublicModels = normalized.disableNonPublicModels ? 1 : 0;
}
const maxSessionsUpdate = (normalized as Record<string, unknown>).maxSessions;
if (maxSessionsUpdate !== undefined) {
updates.push("max_sessions = @maxSessions");
@@ -1231,6 +1256,7 @@ export async function getApiKeyMetadata(
scopes: ["manage"],
allowedEndpoints: [],
streamDefaultMode: "legacy",
disableNonPublicModels: false,
};
}
@@ -1294,6 +1320,9 @@ export async function getApiKeyMetadata(
streamDefaultMode: parseStreamDefaultMode(
(record as JsonRecord).stream_default_mode ?? (record as JsonRecord).streamDefaultMode
),
disableNonPublicModels: parseDisableNonPublicModels(
(record as JsonRecord).disable_non_public_models ?? (record as JsonRecord).disableNonPublicModels
),
};
if (!metadata.id) {
@@ -1338,7 +1367,26 @@ export async function isModelAllowedForKey(
// SECURITY: Key not found in database = deny access (invalid/non-existent key)
if (!metadata) return false;
const { allowedModels } = metadata;
const { allowedModels, disableNonPublicModels } = metadata;
// Check disableNonPublicModels flag
if (disableNonPublicModels) {
const resolvedModelId = resolveModelAlias(modelId);
const effectiveModelId = resolvedModelId || modelId;
const providerId = effectiveModelId.split("/")[0];
const shortModelId = effectiveModelId.split("/").slice(1).join("/");
const syncedModelsByConnection = await getSyncedAvailableModelsByConnection(providerId);
const customModels = await getCustomModels(providerId);
// Combine synced and custom models
const allDiscoveredModels = Object.values(syncedModelsByConnection).flat().concat(customModels);
const discovered = allDiscoveredModels.some((m) => m.id === shortModelId);
if (!discovered) return false;
const isPublic = !getModelIsHidden(providerId, shortModelId);
if (!isPublic) return false;
}
// Empty array means all models allowed
if (!allowedModels || allowedModels.length === 0) {

97
src/lib/db/discovery.ts Normal file
View File

@@ -0,0 +1,97 @@
/**
* Discovery results CRUD — stores discovered provider access methods.
*
* @module db/discovery
*/
import { getDbInstance } from "./core";
import { logger } from "../../open-sse/utils/logger";
const log = logger("DB_DISCOVERY");
export interface DiscoveryResult {
id?: number;
providerId: string;
method: "free_tier" | "web_cookie" | "auto_register" | "trial" | "public_api";
authType: "none" | "cookie" | "api_key" | "oauth";
endpoint?: string;
modelsJson?: string;
rateLimit?: string;
feasibility?: number;
riskLevel?: "none" | "low" | "medium" | "high" | "critical";
status?: "pending" | "testing" | "verified" | "rejected";
notes?: string;
discoveredAt?: string;
verifiedAt?: string;
}
export function insertDiscoveryResult(result: DiscoveryResult): number {
const db = getDbInstance();
const now = new Date().toISOString();
const stmt = db.prepare(`
INSERT INTO discovery_results (provider_id, method, auth_type, endpoint, models_json, rate_limit, feasibility, risk_level, status, notes, discovered_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const info = stmt.run(
result.providerId,
result.method,
result.authType,
result.endpoint ?? null,
result.modelsJson ?? "[]",
result.rateLimit ?? null,
result.feasibility ?? 0,
result.riskLevel ?? "none",
result.status ?? "pending",
result.notes ?? null,
now
);
log.info("discovery_result.inserted", { id: info.lastInsertRowid, providerId: result.providerId });
return info.lastInsertRowid as number;
}
export function listDiscoveryResults(status?: string): DiscoveryResult[] {
const db = getDbInstance();
const rows = status
? db.prepare("SELECT * FROM discovery_results WHERE status = ? ORDER BY discovered_at DESC").all(status)
: db.prepare("SELECT * FROM discovery_results ORDER BY discovered_at DESC").all();
return (rows as Record<string, unknown>[]).map(rowToResult);
}
export function getDiscoveryResultById(id: number): DiscoveryResult | null {
const db = getDbInstance();
const row = db.prepare("SELECT * FROM discovery_results WHERE id = ?").get(id) as Record<string, unknown> | undefined;
return row ? rowToResult(row) : null;
}
export function updateDiscoveryStatus(id: number, status: string, notes?: string): boolean {
const db = getDbInstance();
const now = new Date().toISOString();
const result = db
.prepare("UPDATE discovery_results SET status = ?, notes = COALESCE(?, notes), verified_at = CASE WHEN ? = 'verified' THEN ? ELSE verified_at END, updated_at = ? WHERE id = ?")
.run(status, notes ?? null, status, now, now, id);
return result.changes > 0;
}
export function deleteDiscoveryResult(id: number): boolean {
const db = getDbInstance();
const result = db.prepare("DELETE FROM discovery_results WHERE id = ?").run(id);
return result.changes > 0;
}
function rowToResult(row: Record<string, unknown>): DiscoveryResult {
return {
id: row.id as number,
providerId: row.provider_id as string,
method: row.method as DiscoveryResult["method"],
authType: row.auth_type as DiscoveryResult["authType"],
endpoint: row.endpoint as string | undefined,
modelsJson: row.models_json as string | undefined,
rateLimit: row.rate_limit as string | undefined,
feasibility: row.feasibility as number,
riskLevel: row.risk_level as DiscoveryResult["riskLevel"],
status: row.status as DiscoveryResult["status"],
notes: row.notes as string | undefined,
discoveredAt: row.discovered_at as string,
verifiedAt: row.verified_at as string | undefined,
};
}

View File

@@ -454,6 +454,19 @@ function isSchemaAlreadyApplied(
// The table + column are already present when group_id exists on
// quota_pools (ensures the backfill UPDATE also ran).
return hasTable(db, "quota_groups") && hasColumn(db, "quota_pools", "group_id");
case "089":
// disable_non_public_models column (PR #3017, renumbered 077 → 089 to avoid
// collision with 077_api_key_stream_default_mode on merge into v3.8.8).
return hasColumn(db, "api_keys", "disable_non_public_models");
case "090":
// plugin_metrics table (PR #2913, renumbered 077 → 090 to avoid
// collision with 077_api_key_stream_default_mode on merge into v3.8.8).
return hasTable(db, "plugin_metrics");
case "091":
// plugin_analytics table (PR #2913). The PR's stray db/migrations version
// was dropped on integration; this canonical migration creates the table
// that recordPluginExecution()/getPluginAnalytics() rely on.
return hasTable(db, "plugin_analytics");
default:
return false;
}

View File

@@ -0,0 +1,3 @@
-- Migration: Add disable_non_public_models to api_keys
-- Description: Adds a flag to restrict API keys to discovered and public models.
ALTER TABLE api_keys ADD COLUMN disable_non_public_models INTEGER NOT NULL DEFAULT 0;

View File

@@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS plugin_metrics (
plugin_name TEXT NOT NULL,
event TEXT NOT NULL,
calls INTEGER NOT NULL DEFAULT 0,
errors INTEGER NOT NULL DEFAULT 0,
total_duration_ms REAL NOT NULL DEFAULT 0,
last_called_at TEXT,
PRIMARY KEY (plugin_name, event)
);

View File

@@ -0,0 +1,23 @@
-- Migration 091: plugin_analytics — per-execution records for plugin hooks.
--
-- PR #2913 (plugin framework). The original PR shipped this table only as a
-- non-canonical stray under db/migrations/079_plugin_analytics.sql (a path the
-- migration runner does not read) and its tests created the table inline, so
-- recordPluginExecution()/getPluginAnalytics() in src/lib/db/plugins.ts would
-- fail at runtime in production ("no such table: plugin_analytics"). This
-- canonical migration creates it. Idempotent: safe to run more than once.
--
-- Schema matches src/lib/db/plugins.ts (INSERT/SELECT columns) exactly.
CREATE TABLE IF NOT EXISTS plugin_analytics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
plugin_name TEXT NOT NULL,
hook TEXT NOT NULL,
duration_ms INTEGER NOT NULL DEFAULT 0,
success INTEGER NOT NULL DEFAULT 1,
error_message TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_plugin_analytics_name ON plugin_analytics(plugin_name);
CREATE INDEX IF NOT EXISTS idx_plugin_analytics_created ON plugin_analytics(created_at);

View File

@@ -0,0 +1,88 @@
/**
* Plugin Metrics DB module — per-plugin hook execution tracking.
*
* STATUS: The `plugin_metrics` table (migration 090) is a reserved aggregate table.
* `recordPluginMetric` is currently NOT called from any production code path — the
* per-execution row store is `plugin_analytics` (migration 091, read by the
* `plugin_executions` MCP tool). `plugin_metrics` is intended for future aggregate
* rollups (e.g. bumping per-(plugin, event) counters from `recordPluginExecution`),
* but that write path has not been wired yet. Do NOT remove the migration — it is
* harmless and reserves the schema for the planned rollup feature.
*
* @module db/pluginMetrics
*/
import { getDbInstance } from "./core";
export interface PluginMetricRow {
pluginName: string;
event: string;
calls: number;
errors: number;
totalDurationMs: number;
lastCalledAt: string | null;
}
function rowToMetric(row: Record<string, unknown>): PluginMetricRow {
return {
pluginName: row.plugin_name as string,
event: row.event as string,
calls: row.calls as number,
errors: row.errors as number,
totalDurationMs: row.total_duration_ms as number,
lastCalledAt: row.last_called_at as string | null,
};
}
/**
* Record a hook execution metric. Uses UPSERT to increment counters.
*/
export function recordPluginMetric(
pluginName: string,
event: string,
durationMs: number,
isError: boolean
): void {
try {
const db = getDbInstance();
const now = new Date().toISOString();
db.prepare(
`INSERT INTO plugin_metrics (plugin_name, event, calls, errors, total_duration_ms, last_called_at)
VALUES (?, ?, 1, ?, ?, ?)
ON CONFLICT(plugin_name, event) DO UPDATE SET
calls = calls + 1,
errors = errors + excluded.errors,
total_duration_ms = total_duration_ms + excluded.total_duration_ms,
last_called_at = excluded.last_called_at`
).run(pluginName, event, isError ? 1 : 0, durationMs, now);
} catch {
// Best-effort: DB hiccup should never break hook execution
}
}
/**
* Get plugin metrics, optionally filtered by plugin name.
*/
export function getPluginMetrics(pluginName?: string): PluginMetricRow[] {
try {
const db = getDbInstance();
const rows = pluginName
? db.prepare("SELECT * FROM plugin_metrics WHERE plugin_name = ? ORDER BY event").all(pluginName)
: db.prepare("SELECT * FROM plugin_metrics ORDER BY plugin_name, event").all();
return (rows as Record<string, unknown>[]).map(rowToMetric);
} catch {
return [];
}
}
/**
* Clear plugin metrics, optionally filtered by plugin name.
*/
export function clearPluginMetrics(pluginName?: string): number {
const db = getDbInstance();
const result = pluginName
? db.prepare("DELETE FROM plugin_metrics WHERE plugin_name = ?").run(pluginName)
: db.prepare("DELETE FROM plugin_metrics").run();
return result.changes;
}

View File

@@ -154,6 +154,9 @@ export function updatePluginStatus(
const now = new Date().toISOString();
const activatedAt = status === "active" ? now : null;
// `activated_at` records the most-recent activation timestamp and is intentionally
// preserved on deactivation via COALESCE (activatedAt is null when status != "active").
// Callers should treat it as "last activated at", not "currently active since".
const result = db
.prepare(
`UPDATE plugins SET status = ?, enabled = ?, error_message = ?,
@@ -193,3 +196,85 @@ export function pluginExists(name: string): boolean {
const row = db.prepare("SELECT 1 FROM plugins WHERE name = ?").get(name);
return !!row;
}
// ── Analytics ──
export interface PluginExecutionRow {
pluginName: string;
hook: string;
durationMs: number;
success: boolean;
errorMessage: string | null;
createdAt: string;
}
export interface PluginAnalyticsSummary {
totalCalls: number;
successCount: number;
failureCount: number;
avgDurationMs: number;
}
/**
* Record a single plugin execution in plugin_analytics.
*/
export function recordPluginExecution(
pluginName: string,
hook: string,
durationMs: number,
success: boolean,
errorMessage?: string
): void {
const db = getDbInstance();
db.prepare(
`INSERT INTO plugin_analytics (plugin_name, hook, duration_ms, success, error_message)
VALUES (?, ?, ?, ?, ?)`
).run(pluginName, hook, durationMs, success ? 1 : 0, errorMessage ?? null);
}
/**
* Return execution rows for a given plugin (most recent first).
*/
export function getPluginAnalytics(pluginName: string): PluginExecutionRow[] {
const db = getDbInstance();
const rows = db
.prepare(
`SELECT plugin_name, hook, duration_ms, success, error_message, created_at
FROM plugin_analytics
WHERE plugin_name = ?
ORDER BY created_at DESC`
)
.all(pluginName) as any[];
return rows.map((r) => ({
pluginName: r.plugin_name,
hook: r.hook,
durationMs: r.duration_ms,
success: r.success === 1,
errorMessage: r.error_message,
createdAt: r.created_at,
}));
}
/**
* Return aggregate stats for a given plugin.
*/
export function getPluginAnalyticsSummary(pluginName: string): PluginAnalyticsSummary {
const db = getDbInstance();
const row = db
.prepare(
`SELECT
COUNT(*) AS total,
SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) AS successes,
SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) AS failures,
AVG(duration_ms) AS avg_duration
FROM plugin_analytics
WHERE plugin_name = ?`
)
.get(pluginName) as any;
return {
totalCalls: row?.total ?? 0,
successCount: row?.successes ?? 0,
failureCount: row?.failures ?? 0,
avgDurationMs: row?.avg_duration ?? 0,
};
}

View File

@@ -0,0 +1,65 @@
/**
* Plugin dev mode — hot-reload on file changes.
*
* Watches plugin directory for changes and triggers deactivate+activate cycle.
*
* @module plugins/devMode
*/
import { watch, type FSWatcher } from "fs";
import { logger } from "../../../open-sse/utils/logger.ts";
const log = logger("PLUGIN_DEV_MODE");
let watcher: FSWatcher | null = null;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
const DEBOUNCE_MS = 500;
type ReloadFn = (pluginName: string) => Promise<void>;
/**
* Start dev mode — watch plugin directory for changes.
*/
export function startDevMode(pluginDir: string, reloadFn: ReloadFn): void {
if (watcher) return;
watcher = watch(pluginDir, { recursive: true }, (_eventType, filename) => {
if (!filename) return;
// Extract plugin name from path (first segment)
const pluginName = filename.split("/")[0];
if (!pluginName || pluginName.startsWith(".")) return;
// Debounce rapid changes
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
log.info("devMode.file_changed", { pluginName, file: filename });
try {
await reloadFn(pluginName);
log.info("devMode.reloaded", { pluginName });
} catch (err: unknown) {
log.error("devMode.reload_failed", {
pluginName,
error: err instanceof Error ? err.message : String(err),
});
}
}, DEBOUNCE_MS);
});
log.info("devMode.started", { pluginDir });
}
/**
* Stop dev mode — clean up watcher and timers.
*/
export function stopDevMode(): void {
if (debounceTimer) {
clearTimeout(debounceTimer);
debounceTimer = null;
}
if (watcher) {
watcher.close();
watcher = null;
}
log.info("devMode.stopped");
}

138
src/lib/plugins/doctor.ts Normal file
View File

@@ -0,0 +1,138 @@
/**
* Plugin doctor — diagnostic tool for plugin health checks.
*
* Runs 5 checks: directory exists, manifest valid, entry point exists,
* can spawn, DB status matches filesystem.
*
* @module plugins/doctor
*/
import { stat } from "fs/promises";
import { join } from "path";
import { safeValidateManifest } from "./manifest";
import { getPluginByName } from "../db/plugins";
import { readFile } from "fs/promises";
import { logger } from "../../../open-sse/utils/logger.ts";
const log = logger("PLUGIN_DOCTOR");
export interface DoctorCheck {
name: string;
status: "pass" | "fail" | "warn";
message?: string;
}
export interface DoctorResult {
pluginName: string;
checks: DoctorCheck[];
overall: "healthy" | "degraded" | "unhealthy";
}
/**
* Run diagnostic checks on a plugin.
*/
export async function runPluginDoctor(pluginDir: string, pluginName: string): Promise<DoctorResult> {
const checks: DoctorCheck[] = [];
// Check 1: directory_exists
try {
const dirStat = await stat(pluginDir);
checks.push({
name: "directory_exists",
status: dirStat.isDirectory() ? "pass" : "fail",
message: dirStat.isDirectory() ? undefined : "Path is not a directory",
});
} catch {
checks.push({ name: "directory_exists", status: "fail", message: "Directory not found" });
}
// Check 2: manifest_valid
const manifestPath = join(pluginDir, "plugin.json");
try {
const raw = await readFile(manifestPath, "utf-8");
const parsed = JSON.parse(raw);
const result = safeValidateManifest(parsed);
checks.push({
name: "manifest_valid",
status: result.success ? "pass" : "fail",
message: result.success ? undefined : (result as { success: false; errors: string[] }).errors.join("; "),
});
} catch (err: unknown) {
checks.push({
name: "manifest_valid",
status: "fail",
message: `Cannot read manifest: ${err instanceof Error ? err.message : String(err)}`,
});
}
// Check 3: entry_point_exists
try {
const raw = await readFile(manifestPath, "utf-8");
const parsed = JSON.parse(raw);
const result = safeValidateManifest(parsed);
if (result.success) {
const entryPoint = join(pluginDir, result.data.main);
try {
await stat(entryPoint);
checks.push({ name: "entry_point_exists", status: "pass" });
} catch {
checks.push({ name: "entry_point_exists", status: "fail", message: `Entry point not found: ${result.data.main}` });
}
} else {
checks.push({ name: "entry_point_exists", status: "warn", message: "Skipped — manifest invalid" });
}
} catch {
checks.push({ name: "entry_point_exists", status: "warn", message: "Skipped — manifest unreadable" });
}
// Check 4: can_spawn (simplified — check entry point is .js/.mjs)
try {
const raw = await readFile(manifestPath, "utf-8");
const parsed = JSON.parse(raw);
const result = safeValidateManifest(parsed);
if (result.success) {
const main = result.data.main;
const ext = main.split(".").pop();
checks.push({
name: "can_spawn",
status: ext === "js" || ext === "mjs" ? "pass" : "warn",
message: ext === "js" || ext === "mjs" ? undefined : `Unexpected extension: .${ext}`,
});
} else {
checks.push({ name: "can_spawn", status: "warn", message: "Skipped — manifest invalid" });
}
} catch {
checks.push({ name: "can_spawn", status: "warn", message: "Skipped — manifest unreadable" });
}
// Check 5: db_status_correct
const dbRow = getPluginByName(pluginName);
if (dbRow) {
checks.push({
name: "db_status_correct",
status: "pass",
message: `Status: ${dbRow.status}`,
});
} else {
checks.push({
name: "db_status_correct",
status: "warn",
message: "Plugin not in database",
});
}
// Overall
const failCount = checks.filter((c) => c.status === "fail").length;
const warnCount = checks.filter((c) => c.status === "warn").length;
let overall: DoctorResult["overall"];
if (failCount === 0 && warnCount === 0) {
overall = "healthy";
} else if (failCount === 0) {
overall = "degraded";
} else {
overall = "unhealthy";
}
log.info("doctor.result", { pluginName, overall, failCount, warnCount });
return { pluginName, checks, overall };
}

38
src/lib/plugins/errors.ts Normal file
View File

@@ -0,0 +1,38 @@
/**
* Plugin error codes — structured error handling for plugin system.
*
* @module plugins/errors
*/
export enum PluginErrorCode {
PLUGIN_NOT_FOUND = "PLUGIN_NOT_FOUND",
ALREADY_INSTALLED = "ALREADY_INSTALLED",
INVALID_MANIFEST = "INVALID_MANIFEST",
INSTALL_FAILED = "INSTALL_FAILED",
ACTIVATE_FAILED = "ACTIVATE_FAILED",
DEACTIVATE_FAILED = "DEACTIVATE_FAILED",
UNINSTALL_FAILED = "UNINSTALL_FAILED",
HOOK_TIMEOUT = "HOOK_TIMEOUT",
HOOK_EXECUTION_ERROR = "HOOK_EXECUTION_ERROR",
PROCESS_CRASHED = "PROCESS_CRASHED",
DEPENDENCY_MISSING = "DEPENDENCY_MISSING",
DEPENDENT_EXISTS = "DEPENDENT_EXISTS",
PERMISSION_DENIED = "PERMISSION_DENIED",
RATE_LIMITED = "RATE_LIMITED",
}
export class PluginError extends Error {
code: PluginErrorCode;
details?: unknown;
constructor(code: PluginErrorCode, message: string, details?: unknown) {
super(message);
this.name = "PluginError";
this.code = code;
this.details = details;
}
}
export function isPluginError(err: unknown): err is PluginError {
return err instanceof PluginError;
}

View File

@@ -47,6 +47,36 @@ export const BUILTIN_EVENTS = [
export type BuiltinEvent = (typeof BUILTIN_EVENTS)[number];
// ── Rate limiting ──
const RATE_LIMIT_MAX = 100; // max calls per plugin per window
const RATE_LIMIT_WINDOW_MS = 1000; // 1 second window
interface RateLimitState {
count: number;
windowStart: number;
}
const rateLimitMap: Map<string, RateLimitState> = new Map();
function isRateLimited(pluginName: string): boolean {
const now = Date.now();
const key = pluginName;
const state = rateLimitMap.get(key);
if (!state || now - state.windowStart >= RATE_LIMIT_WINDOW_MS) {
// New window
rateLimitMap.set(key, { count: 1, windowStart: now });
return false;
}
state.count++;
if (state.count > RATE_LIMIT_MAX) {
return true;
}
return false;
}
// ── Registry ──
const hooks: Map<string, HookRegistration[]> = new Map();
@@ -78,6 +108,7 @@ export function registerHook(
/**
* Unregister all handlers for a plugin.
* Also evicts the plugin's rate-limit state so uninstalled plugins don't leak memory.
*/
export function unregisterHooks(pluginName: string): void {
for (const [event, list] of hooks.entries()) {
@@ -88,6 +119,8 @@ export function unregisterHooks(pluginName: string): void {
log.info("hook.unregistered", { event, pluginName, removed: before - filtered.length });
}
}
// Evict rate-limit state so uninstalled plugins don't accumulate entries
rateLimitMap.delete(pluginName);
}
/**
@@ -107,12 +140,17 @@ export function unregisterHook(event: string, pluginName: string): void {
/**
* Emit an event — fire all registered handlers.
* Handler errors are logged but don't block other handlers.
* Rate-limited per plugin: max 100 calls per second.
*/
export async function emitHook(event: string, payload: unknown): Promise<void> {
const list = hooks.get(event);
if (!list || list.length === 0) return;
for (const reg of list) {
if (isRateLimited(reg.pluginName)) {
log.warn("hook.rate_limited", { event, pluginName: reg.pluginName });
continue;
}
try {
await reg.handler(payload);
} catch (err: unknown) {
@@ -146,6 +184,11 @@ export async function emitHookBlocking(
let mergedMetadata: Record<string, unknown> = (ctx.metadata as Record<string, unknown>) || {};
for (const reg of list) {
// Mirror emitHook: rate-limit the hot blocking path too
if (isRateLimited(reg.pluginName)) {
log.warn("hook.blocking_rate_limited", { event, pluginName: reg.pluginName });
continue;
}
try {
const result = await reg.handler(payload);
if (result && typeof result === "object") {
@@ -258,8 +301,9 @@ export function getActiveEvents(): string[] {
}
/**
* Reset all hooks (for testing).
* Reset all hooks and rate limit state (for testing).
*/
export function resetHooks(): void {
hooks.clear();
rateLimitMap.clear();
}

View File

@@ -9,10 +9,10 @@
*/
import { spawn } from "child_process";
import { writeFile, rm } from "fs/promises";
import { writeFile, rm, readFile } from "fs/promises";
import { join } from "path";
import { tmpdir } from "os";
import { randomUUID } from "crypto";
import { randomUUID, createHash } from "crypto";
import { logger } from "../../../open-sse/utils/logger.ts";
import type { PluginManifestWithDefaults, Permission } from "./manifest";
import type { Plugin, PluginContext, PluginResult } from "./index";
@@ -22,6 +22,15 @@ const log = logger("PLUGIN_LOADER");
const DEFAULT_HOOK_TIMEOUT = 10_000;
const SIGKILL_GRACE_MS = 3_000;
/**
* Compute a `sha256-<base64>` integrity hash of the given source string.
* Matches the SRI (Subresource Integrity) format: `sha256-<base64>`.
*/
export function computeIntegrity(source: string): string {
const hash = createHash("sha256").update(source, "utf-8").digest("base64");
return `sha256-${hash}`;
}
export interface LoadedPlugin {
name: string;
manifest: PluginManifestWithDefaults;
@@ -70,12 +79,52 @@ export async function loadPlugin(
entryPoint: string,
manifest: PluginManifestWithDefaults
): Promise<LoadedPlugin> {
const permissions = manifest.requires.permissions;
const hostId = randomUUID();
// .mjs extension forces ESM execution
const hostScriptPath = join(tmpdir(), `omniroute-plugin-host-${hostId}.mjs`);
// Integrity check: if the manifest declares an integrity field, verify the entry point.
// Missing integrity is OK for backward compatibility; mismatched integrity is a fatal error.
const integrityField = (manifest as unknown as Record<string, unknown>).integrity;
if (typeof integrityField === "string" && integrityField.length > 0) {
let source: string;
try {
source = await readFile(entryPoint, "utf-8");
} catch (err: unknown) {
throw new Error(
`Plugin '${manifest.name}' integrity check failed: cannot read entry point — ${err instanceof Error ? err.message : String(err)}`
);
}
const actual = computeIntegrity(source);
if (actual !== integrityField) {
throw new Error(
`Plugin '${manifest.name}' integrity mismatch: expected ${integrityField}, got ${actual}`
);
}
}
await writeFile(hostScriptPath, PLUGIN_HOST_SCRIPT, "utf-8");
const permissions = manifest.requires.permissions;
// IMPORTANT-6: Write the host script with O_EXCL (wx flag) so the open fails if
// anything already exists at that path, defeating symlink/pre-create races (TOCTOU).
// mode 0o600 ensures no other OS user can read or replace the script.
// On EEXIST collision (astronomically unlikely with UUID but theoretically possible),
// retry once with a fresh UUID.
let hostScriptPath: string;
{
// .mjs extension forces ESM execution regardless of package.json type field
const tryWrite = async (id: string): Promise<string> => {
const p = join(tmpdir(), `omniroute-plugin-host-${id}.mjs`);
await writeFile(p, PLUGIN_HOST_SCRIPT, { encoding: "utf-8", mode: 0o600, flag: "wx" });
return p;
};
try {
hostScriptPath = await tryWrite(randomUUID());
} catch (err: unknown) {
// EEXIST on a UUID path is a collision — retry once with a fresh UUID.
if (err instanceof Error && (err as NodeJS.ErrnoException).code === "EEXIST") {
hostScriptPath = await tryWrite(randomUUID());
} else {
throw err;
}
}
}
const env: Record<string, string> = {
...getFilteredEnv(permissions),
@@ -159,50 +208,61 @@ export async function loadPlugin(
});
};
// Build Plugin interface
// Build Plugin interface — only register hooks declared in the manifest.
const plugin: Plugin = {
name: manifest.name,
priority: 100,
enabled: true,
};
plugin.onRequest = async (ctx: PluginContext): Promise<PluginResult | void> => {
try {
const result = await callHook("onRequest", ctx);
return result as PluginResult | void;
} catch (err: unknown) {
log.error("plugin.onRequest_error", {
name: manifest.name,
error: err instanceof Error ? err.message : String(err),
});
}
};
const registeredHooks: string[] = [];
plugin.onResponse = async (ctx: PluginContext, response: unknown): Promise<unknown | void> => {
try {
return await callHook("onResponse", { ctx, response });
} catch (err: unknown) {
log.error("plugin.onResponse_error", {
name: manifest.name,
error: err instanceof Error ? err.message : String(err),
});
}
};
if (manifest.hooks.onRequest) {
plugin.onRequest = async (ctx: PluginContext): Promise<PluginResult | void> => {
try {
const result = await callHook("onRequest", ctx);
return result as PluginResult | void;
} catch (err: unknown) {
log.error("plugin.onRequest_error", {
name: manifest.name,
error: err instanceof Error ? err.message : String(err),
});
}
};
registeredHooks.push("onRequest");
}
plugin.onError = async (ctx: PluginContext, error: Error): Promise<unknown | void> => {
try {
return await callHook("onError", { ctx, error: error.message });
} catch (err: unknown) {
log.error("plugin.onError_error", {
name: manifest.name,
error: err instanceof Error ? err.message : String(err),
});
}
};
if (manifest.hooks.onResponse) {
plugin.onResponse = async (ctx: PluginContext, response: unknown): Promise<unknown | void> => {
try {
return await callHook("onResponse", { ctx, response });
} catch (err: unknown) {
log.error("plugin.onResponse_error", {
name: manifest.name,
error: err instanceof Error ? err.message : String(err),
});
}
};
registeredHooks.push("onResponse");
}
if (manifest.hooks.onError) {
plugin.onError = async (ctx: PluginContext, error: Error): Promise<unknown | void> => {
try {
return await callHook("onError", { ctx, error: error.message });
} catch (err: unknown) {
log.error("plugin.onError_error", {
name: manifest.name,
error: err instanceof Error ? err.message : String(err),
});
}
};
registeredHooks.push("onError");
}
log.info("loader.loaded", {
name: manifest.name,
hooks: ["onRequest", "onResponse", "onError"],
hooks: registeredHooks,
pid: child.pid,
});

50
src/lib/plugins/logger.ts Normal file
View File

@@ -0,0 +1,50 @@
/**
* Plugin logger — per-plugin log isolation.
*
* Writes JSON log entries to <pluginDir>/<name>/plugin.log.
*
* @module plugins/logger
*/
import { appendFileSync, mkdirSync } from "fs";
import { join, dirname } from "path";
export class PluginLogger {
private logPath: string;
constructor(pluginName: string, pluginDir: string) {
this.logPath = join(pluginDir, pluginName, "plugin.log");
}
private write(level: string, message: string, data?: unknown): void {
const entry = JSON.stringify({
timestamp: new Date().toISOString(),
level,
message,
...(data !== undefined ? { data } : {}),
});
try {
mkdirSync(dirname(this.logPath), { recursive: true });
appendFileSync(this.logPath, entry + "\n", "utf-8");
} catch {
// Silent fail — don't crash plugin over logging
}
}
info(message: string, data?: unknown): void {
this.write("INFO", message, data);
}
error(message: string, data?: unknown): void {
this.write("ERROR", message, data);
}
warn(message: string, data?: unknown): void {
this.write("WARN", message, data);
}
debug(message: string, data?: unknown): void {
this.write("DEBUG", message, data);
}
}

View File

@@ -7,8 +7,8 @@
* @module plugins/manager
*/
import { mkdir, cp, rm, realpath, readFile } from "fs/promises";
import { join, dirname } from "path";
import { mkdir, cp, rm, rename, realpath, readFile } from "fs/promises";
import { join, dirname, resolve, sep } from "path";
import { randomUUID } from "crypto";
import { logger } from "../../../open-sse/utils/logger.ts";
import { getDefaultPluginDir, scanPluginDir } from "./scanner";
@@ -19,6 +19,7 @@ import {
getPluginByName,
listPlugins as dbListPlugins,
updatePluginStatus,
updatePluginConfig,
deletePlugin as dbDeletePlugin,
pluginExists,
type PluginRow,
@@ -27,6 +28,69 @@ import type { PluginManifestWithDefaults } from "./manifest";
const log = logger("PLUGIN_MANAGER");
/**
* Compare two semver strings. Returns positive if a > b, negative if a < b, 0 if equal.
* Only handles simple MAJOR.MINOR.PATCH — no pre-release tags.
*
* NaN-safe: strips a `-prerelease` suffix before parsing so a legacy DB value like
* `1.0.0-beta` doesn't produce NaN comparisons and silently compare equal to `1.0.0`.
* Non-numeric segments (after stripping) are coerced to 0.
*
* Exported for unit testing only — prefer pluginManager methods for production use.
*/
export function compareSemver(a: string, b: string): number {
// Strip optional pre-release suffix (e.g. "-beta", "-rc.1") before parsing
const stripPreRelease = (v: string) => v.replace(/-.*$/, "");
const parse = (v: string) =>
stripPreRelease(v)
.split(".")
.map((s) => {
const n = Number(s);
return Number.isNaN(n) ? 0 : n;
});
const [aMaj, aMin, aPat] = parse(a);
const [bMaj, bMin, bPat] = parse(b);
if (aMaj !== bMaj) return aMaj - bMaj;
if (aMin !== bMin) return aMin - bMin;
return aPat - bPat;
}
// ── SECURITY: CRITICAL-2 ────────────────────────────────────────────────────
/**
* Assert that `target` is strictly contained within `pluginRoot`.
* Prevents a tampered/legacy DB `pluginDir` from causing deletion of an
* arbitrary filesystem path when passed to `rm({ recursive: true })`.
*
* Throws immediately if `target` resolves outside `pluginRoot`.
*/
function assertWithinPluginDir(pluginRoot: string, target: string): void {
const root = resolve(pluginRoot);
const t = resolve(target);
if (t !== root && !t.startsWith(root + sep)) {
throw new Error(
`Refusing to delete a path outside the plugin directory: "${t}" is not under "${root}"`
);
}
}
// ── SECURITY: CRITICAL-3 (shared) ──────────────────────────────────────────
/**
* Assert that `entryPoint` is strictly within `destDir`.
* Called at install/upgrade time to reject `manifest.main` values like
* `"../../evil.js"` before the plugin is ever persisted to DB.
*
* Throws if the resolved entryPoint escapes `destDir`.
*/
function assertEntryPointWithinDest(destDir: string, entryPoint: string): void {
const root = resolve(destDir);
const ep = resolve(entryPoint);
if (!ep.startsWith(root + sep)) {
throw new Error(
`Plugin manifest.main resolves outside plugin directory: "${ep}" escapes "${root}"`
);
}
}
class PluginManager {
private static instance: PluginManager;
private loadedPlugins: Map<string, LoadedPlugin> = new Map();
@@ -45,7 +109,8 @@ class PluginManager {
/**
* Install a plugin from a source directory.
* Copies to plugin dir, validates manifest, registers in DB.
* Copies to a staging dir first, validates manifest.main containment, then
* atomically renames into place. Cleans up staging dir on any failure.
*/
async install(sourceDir: string): Promise<PluginRow> {
// Check if sourceDir itself contains plugin.json (direct plugin dir)
@@ -87,17 +152,38 @@ class PluginManager {
const discovered = plugins[0];
const { name, manifest, pluginDir: srcDir } = discovered;
// Check if already installed
// If already installed, auto-upgrade when source is strictly newer; reject otherwise.
if (pluginExists(name)) {
throw new Error(`Plugin '${name}' is already installed`);
const existing = getPluginByName(name)!;
if (compareSemver(manifest.version, existing.version) > 0) {
// Source is newer — delegate to upgrade()
return this.upgrade(sourceDir);
}
throw new Error(
`Plugin '${name}' is already installed (${existing.version}) and source version ${manifest.version} is not newer`
);
}
// Copy to plugin directory
// CRITICAL-3: Copy to staging dir first, validate, then rename atomically.
const destDir = join(this.pluginDir, name);
const stagingDir = `${destDir}.staging-${randomUUID()}`;
await mkdir(dirname(destDir), { recursive: true });
await cp(srcDir, destDir, { recursive: true });
await cp(srcDir, stagingDir, { recursive: true });
// Register in DB
try {
// CRITICAL-3: Validate manifest.main is within the staging dir before persisting.
const entryPoint = join(stagingDir, manifest.main || "index.js");
assertEntryPointWithinDest(stagingDir, entryPoint);
// Atomic: rename staging → final dest
await rename(stagingDir, destDir);
} catch (err) {
// Cleanup staging dir so no half-installed directory is left behind.
await rm(stagingDir, { recursive: true, force: true }).catch(() => {});
throw err;
}
// Register in DB (destDir is now in place)
const row = insertPlugin({
id: randomUUID(),
name,
@@ -130,6 +216,126 @@ class PluginManager {
return row;
}
/**
* Upgrade an installed plugin to a newer version from sourceDir.
* Preserves nothing (clean reinstall); config is reset to defaults.
* Throws if the plugin is not installed or the source version is not strictly newer.
*
* Atomically: copy to staging → validate → rm old (containment-checked) → rename staging.
* On any failure after staging copy, staging is cleaned up and old install is left intact.
*/
async upgrade(sourceDir: string): Promise<PluginRow> {
// Scan source to get new manifest
const { safeValidateManifest } = await import("./manifest");
const { readFile: readFileFs } = await import("fs/promises");
let discovered: { name: string; manifest: any; pluginDir: string } | null = null;
// Try direct plugin dir first
try {
const manifestPath = join(sourceDir, "plugin.json");
const raw = await readFileFs(manifestPath, "utf-8");
const parsed = JSON.parse(raw);
const result = safeValidateManifest(parsed);
if (result.success) {
discovered = { name: result.data.name, manifest: result.data, pluginDir: sourceDir };
}
} catch {}
if (!discovered) {
const { plugins, errors } = await scanPluginDir(sourceDir);
if (plugins.length === 0) {
throw new Error(
`No valid plugin found in ${sourceDir}: ${errors.map((e) => e.error).join(", ")}`
);
}
discovered = plugins[0];
}
const { name, manifest } = discovered;
// Must be already installed
if (!pluginExists(name)) {
throw new Error(`Plugin '${name}' is not installed — use install() instead`);
}
const existing = getPluginByName(name)!;
// Source must be strictly newer
if (compareSemver(manifest.version, existing.version) <= 0) {
throw new Error(
`Plugin '${name}' upgrade rejected: source version ${manifest.version} is not newer than installed ${existing.version}`
);
}
log.info("manager.upgrading", { name, from: existing.version, to: manifest.version });
// Deactivate if active before touching files
if (existing.status === "active") {
await this.deactivate(name);
}
// CRITICAL-3: Copy to staging dir first, validate manifest.main, then swap atomically.
const destDir = join(this.pluginDir, name);
const stagingDir = `${destDir}.staging-${randomUUID()}`;
await mkdir(dirname(destDir), { recursive: true });
await cp(discovered.pluginDir, stagingDir, { recursive: true });
try {
// CRITICAL-3: Validate manifest.main is within staging before we destroy old version.
const entryPoint = join(stagingDir, manifest.main || "index.js");
assertEntryPointWithinDest(stagingDir, entryPoint);
// CRITICAL-2: Assert old install dir is within pluginDir before deleting it.
assertWithinPluginDir(this.pluginDir, existing.pluginDir);
// Only now remove old dir (after staging succeeded and was validated).
try {
await rm(existing.pluginDir, { recursive: true, force: true });
} catch (err: any) {
log.warn("manager.upgrade_dir_error", { name, error: err.message });
}
dbDeletePlugin(name);
// Atomic rename staging → final dest
await rename(stagingDir, destDir);
} catch (err) {
// Cleanup staging, leave old install intact.
await rm(stagingDir, { recursive: true, force: true }).catch(() => {});
throw err;
}
const row = insertPlugin({
id: randomUUID(),
name,
version: manifest.version,
description: manifest.description,
author: manifest.author,
license: manifest.license,
main: manifest.main,
source: manifest.source,
tags: manifest.tags,
manifest: manifest as unknown as Record<string, unknown>,
configSchema: manifest.configSchema as unknown as Record<string, unknown>,
hooks: [
manifest.hooks.onRequest && "onRequest",
manifest.hooks.onResponse && "onResponse",
manifest.hooks.onError && "onError",
].filter(Boolean) as string[],
permissions: manifest.requires.permissions,
pluginDir: destDir,
enabled: manifest.enabledByDefault,
});
log.info("manager.upgraded", { name, version: manifest.version });
if (manifest.enabledByDefault) {
await this.activate(name);
}
return row;
}
/**
* Activate a plugin — load into VM, register hooks, update DB.
*/
@@ -195,7 +401,7 @@ class PluginManager {
}
/**
* Uninstall a plugin — deactivate, delete directory, remove from DB.
* Uninstall a plugin — deactivate, delete directory (containment-checked), remove from DB.
*/
async uninstall(name: string): Promise<void> {
const row = getPluginByName(name);
@@ -206,6 +412,11 @@ class PluginManager {
await this.deactivate(name);
}
// CRITICAL-2: Assert the pluginDir from DB is within our managed pluginDir root
// before issuing a recursive delete. Prevents a tampered/legacy DB value from
// causing deletion of an arbitrary path on the filesystem.
assertWithinPluginDir(this.pluginDir, row.pluginDir);
// Delete plugin directory
try {
await rm(row.pluginDir, { recursive: true, force: true });

View File

@@ -68,6 +68,19 @@ export const PluginManifestSchema = z.object({
skills: z.array(ManifestSkillSchema).optional(),
enabledByDefault: z.boolean().optional(),
configSchema: z.record(z.string(), ConfigFieldSchema).optional(),
/**
* OPT-IN tamper-detection: `sha256-<base64>` of the plugin's entry file.
*
* NOT a security boundary — loopback-only routing and exec opt-in are the real
* boundaries. Local-operator plugins without `integrity` are fully allowed (trust
* is implicit for locally installed code). When this field IS present, the loader
* verifies the entry file hash at load time and refuses to activate on mismatch.
*
* Format: `sha256-<base64url>` (same as SRI / W3C Subresource Integrity).
* Generate with: `node -e "const {createHash}=require('crypto'),{readFileSync}=require('fs');
* console.log('sha256-'+createHash('sha256').update(readFileSync('index.js')).digest('base64'))"`
*/
integrity: z.string().optional(),
});
export type PluginManifest = z.infer<typeof PluginManifestSchema>;
@@ -127,3 +140,58 @@ export function safeValidateManifest(
errors: result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`),
};
}
// ── Config validation ──
export type ValidatePluginConfigResult =
| { valid: true }
| { valid: false; errors: string[] };
/**
* Validate a config object against a ConfigField schema map.
* Only provided keys are validated — missing keys are fine (use defaults).
*/
export function validatePluginConfig(
config: Record<string, unknown>,
schema: Record<string, ConfigField>
): ValidatePluginConfigResult {
const errors: string[] = [];
// If schema is empty, allow anything
const hasSchema = Object.keys(schema).length > 0;
if (!hasSchema) return { valid: true };
for (const [key, value] of Object.entries(config)) {
const field = schema[key];
if (!field) {
errors.push(`Unknown config key: ${key}`);
continue;
}
switch (field.type) {
case "string":
if (typeof value !== "string") errors.push(`${key} must be a string`);
break;
case "number":
if (typeof value !== "number") {
errors.push(`${key} must be a number`);
} else {
if (field.min !== undefined && value < field.min)
errors.push(`${key} must be >= ${field.min}`);
if (field.max !== undefined && value > field.max)
errors.push(`${key} must be <= ${field.max}`);
}
break;
case "boolean":
if (typeof value !== "boolean") errors.push(`${key} must be a boolean`);
break;
case "select":
if (!field.enum || !field.enum.includes(value as string))
errors.push(`${key} must be one of: ${(field.enum ?? []).join(", ")}`);
break;
}
}
if (errors.length > 0) return { valid: false, errors };
return { valid: true };
}

View File

@@ -0,0 +1,107 @@
/**
* Plugin Marketplace — browse, search, install plugins from a registry.
*
* Phase 1: Local registry with seed data.
* Phase 2: Remote registry with ratings/downloads.
*
* @module plugins/marketplace
*/
// Marketplace — local seed registry. Remote registry in Phase 2.
// ── Types ──
export interface MarketplaceEntry {
name: string;
version: string;
description: string;
author: string;
license: string;
downloadUrl: string;
repository?: string;
tags: string[];
downloads: number;
rating: number; // 0-5
verified: boolean;
lastUpdated: string;
}
// ── Seed Data ──
const SEED_REGISTRY: MarketplaceEntry[] = [
{
name: "request-logger",
version: "1.0.0",
description: "Logs all requests and responses with timing",
author: "omniroute",
license: "MIT",
downloadUrl: "",
tags: ["logging", "debugging"],
downloads: 0,
rating: 5,
verified: true,
lastUpdated: "2026-05-29",
},
{
name: "rate-limiter",
version: "1.0.0",
description: "Per-model rate limiting with sliding window",
author: "omniroute",
license: "MIT",
downloadUrl: "",
tags: ["rate-limit", "security"],
downloads: 0,
rating: 5,
verified: true,
lastUpdated: "2026-05-29",
},
{
name: "cost-tracker",
version: "1.0.0",
description: "Track token costs per request and per model",
author: "omniroute",
license: "MIT",
downloadUrl: "",
tags: ["analytics", "cost"],
downloads: 0,
rating: 4,
verified: true,
lastUpdated: "2026-05-29",
},
];
// ── API ──
/**
* List all available plugins in the marketplace.
*/
export function listMarketplacePlugins(): MarketplaceEntry[] {
return [...SEED_REGISTRY];
}
/**
* Search marketplace plugins by query.
*/
export function searchMarketplace(query: string): MarketplaceEntry[] {
const q = query.toLowerCase();
return SEED_REGISTRY.filter(
(p) =>
p.name.includes(q) ||
p.description.toLowerCase().includes(q) ||
p.tags.some((t) => t.includes(q))
);
}
/**
* Get a specific marketplace entry.
*/
export function getMarketplaceEntry(name: string): MarketplaceEntry | undefined {
return SEED_REGISTRY.find((p) => p.name === name);
}
/**
* Check if marketplace is available.
*/
export function isMarketplaceAvailable(): boolean {
return true; // Local seed always available
}

View File

@@ -0,0 +1,246 @@
/**
* Plugin Worker Thread — runs plugins in isolated Worker threads.
*
* Receives messages from the main thread:
* - { type: "load", entryPoint, permissions, name } → load plugin, send back hooks
* - { type: "call", hook, payload, response?, error? } → call hook, send back result
* - { type: "cleanup" } → terminate gracefully
*
* @module plugins/pluginWorker
*/
import { parentPort, workerData } from "worker_threads";
import { readFile, readdir, stat, writeFile, mkdir, rm } from "fs/promises";
import { resolve } from "path";
import * as vm from "vm";
if (!parentPort) {
throw new Error("pluginWorker must be run as a Worker thread");
}
const port = parentPort;
interface LoadMessage {
type: "load";
entryPoint: string;
permissions: string[];
name: string;
}
interface CallMessage {
type: "call";
hook: string;
payload: unknown;
response?: unknown;
error?: string;
}
interface CleanupMessage {
type: "cleanup" | "exit" | "terminate";
}
type WorkerMessage = LoadMessage | CallMessage | CleanupMessage;
/**
* createSandbox — capability-gated object passed to vm.createContext().
*
* TRUST MODEL: vm is NOT a security boundary (shares the worker's V8 heap;
* prototype-chain escapes are possible). Plugin execution is safe only because:
* 1. /api/plugins/ is classified LOCAL_ONLY in routeGuard — loopback enforced
* before any auth check (Hard Rules #15/#17).
* 2. The `exec` permission additionally requires OMNIROUTE_PLUGINS_ALLOW_EXEC=1
* (opt-in, default OFF) — child_process is never wired silently.
* Treat plugins as local-operator-trusted code, not sandboxed untrusted code.
*/
function createSandbox(permissions: string[], pluginDir: string): Record<string, unknown> {
const activeTimers = new Set<ReturnType<typeof setTimeout>>();
const sandbox: Record<string, unknown> = {
console: {
log: (...args: unknown[]) => port.postMessage({ type: "log", level: "info", args }),
warn: (...args: unknown[]) => port.postMessage({ type: "log", level: "warn", args }),
error: (...args: unknown[]) => port.postMessage({ type: "log", level: "error", args }),
},
setTimeout: (fn: (...args: unknown[]) => void, ms?: number) => { const t = setTimeout(fn, ms); activeTimers.add(t); return t; },
clearTimeout: (t: unknown) => { activeTimers.delete(t as ReturnType<typeof setTimeout>); clearTimeout(t as ReturnType<typeof setTimeout>); },
setInterval: (fn: (...args: unknown[]) => void, ms?: number) => { const t = setInterval(fn, ms); activeTimers.add(t); return t; },
clearInterval: (t: unknown) => { activeTimers.delete(t as ReturnType<typeof setInterval>); clearInterval(t as ReturnType<typeof setInterval>); },
Promise,
JSON,
Math,
Date,
Array,
Object,
String,
Number,
Boolean,
RegExp,
Error,
TypeError,
RangeError,
SyntaxError,
URIError,
Map,
Set,
WeakMap,
WeakSet,
Symbol,
parseInt,
parseFloat,
isNaN,
isFinite,
URL,
URLSearchParams,
};
if (permissions.includes("file-read") || permissions.includes("file-write")) {
sandbox.Buffer = Buffer;
}
if (permissions.includes("network")) {
sandbox.fetch = globalThis.fetch;
sandbox.AbortController = globalThis.AbortController;
sandbox.Headers = globalThis.Headers;
sandbox.Request = globalThis.Request;
sandbox.Response = globalThis.Response;
}
if (permissions.includes("file-read")) {
sandbox.fs = {
readFile: (p: string, enc?: string) => readFile(resolve(pluginDir, p), enc as BufferEncoding),
readdir: (p: string) => readdir(resolve(pluginDir, p)),
stat: (p: string) => stat(resolve(pluginDir, p)),
};
}
if (permissions.includes("file-write")) {
const fs = sandbox.fs as Record<string, unknown> || {};
fs.writeFile = (p: string, data: string) => writeFile(resolve(pluginDir, p), data);
fs.mkdir = (p: string) => mkdir(resolve(pluginDir, p), { recursive: true });
fs.rm = (p: string) => rm(resolve(pluginDir, p), { recursive: true, force: true });
sandbox.fs = fs;
}
if (permissions.includes("env")) {
sandbox.process = { env: new Proxy({}, {
get: (_t, key) => typeof key === "string" ? process.env[key] : undefined,
set: () => false,
has: (_t, key) => typeof key === "string" ? key in process.env : false,
}) };
}
if (permissions.includes("exec")) {
if (process.env.OMNIROUTE_PLUGINS_ALLOW_EXEC !== "1") {
throw new Error(
`Plugin '${name}' requested the 'exec' permission, which is disabled. Set OMNIROUTE_PLUGINS_ALLOW_EXEC=1 to enable (local operator only).`
);
}
sandbox.child_process = {
exec: require("child_process").exec,
execSync: require("child_process").execSync,
};
}
sandbox.__activeTimers = activeTimers;
return sandbox;
}
let context: vm.Context | null = null;
let pluginExports: Record<string, unknown> | null = null;
let activeTimers: Set<ReturnType<typeof setTimeout>> | null = null;
async function loadPlugin(entryPoint: string, permissions: string[], name: string): Promise<string[]> {
const pluginDir = resolve(entryPoint, "..");
const sandbox = createSandbox(permissions, pluginDir);
context = vm.createContext(sandbox);
activeTimers = sandbox.__activeTimers as Set<ReturnType<typeof setTimeout>>;
const moduleExports: Record<string, unknown> = {};
const moduleObj = { exports: moduleExports };
sandbox.module = moduleObj;
sandbox.exports = moduleExports;
sandbox.require = (id: string) => {
const allowed: Record<string, unknown> = {};
if (id === "crypto") allowed.crypto = require("crypto");
if (allowed[id]) return allowed[id];
throw new Error(`Module '${id}' is not allowed in plugin sandbox`);
};
const source = await readFile(entryPoint, "utf-8");
const wrapped = `(async function(module, exports, require) { ${source} })(module, exports, require);`;
vm.runInContext(wrapped, context, { filename: entryPoint, timeout: 10000 });
pluginExports = moduleObj.exports;
const hooks: string[] = [];
const sources = [pluginExports];
if (pluginExports.default && typeof pluginExports.default === "object") {
sources.push(pluginExports.default as Record<string, unknown>);
}
for (const src of sources) {
if (typeof src.onRequest === "function" && !hooks.includes("onRequest")) hooks.push("onRequest");
if (typeof src.onResponse === "function" && !hooks.includes("onResponse")) hooks.push("onResponse");
if (typeof src.onError === "function" && !hooks.includes("onError")) hooks.push("onError");
}
return hooks;
}
function callHook(hook: string, payload: unknown, extra?: { response?: unknown; error?: string }): unknown {
if (!context || !pluginExports) throw new Error("Plugin not loaded");
const sources = [pluginExports];
if (pluginExports.default && typeof pluginExports.default === "object") {
sources.push(pluginExports.default as Record<string, unknown>);
}
for (const src of sources) {
const fn = src[hook];
if (typeof fn === "function") {
if (hook === "onResponse" && extra?.response !== undefined) {
return fn(payload, extra.response);
}
if (hook === "onError" && extra?.error !== undefined) {
return fn(payload, new Error(extra.error));
}
return fn(payload);
}
}
throw new Error(`Hook '${hook}' not found in plugin exports`);
}
function cleanup(): void {
if (activeTimers) {
for (const t of activeTimers) {
clearTimeout(t);
clearInterval(t);
}
activeTimers.clear();
}
context = null;
pluginExports = null;
activeTimers = null;
}
port.on("message", async (msg: WorkerMessage) => {
try {
if (msg.type === "load") {
const hooks = await loadPlugin(msg.entryPoint, msg.permissions, msg.name);
port.postMessage({ type: "loaded", hooks });
} else if (msg.type === "call") {
const result = callHook(msg.hook, msg.payload, { response: (msg as CallMessage).response, error: (msg as CallMessage).error });
port.postMessage({ type: "result", value: result });
} else if (msg.type === "cleanup" || msg.type === "exit" || msg.type === "terminate") {
cleanup();
port.postMessage({ type: "cleaned" });
process.exit(0);
}
} catch (err: unknown) {
const errMsg = err instanceof Error ? err.message : String(err);
port.postMessage({ type: "error", error: errMsg, hook: (msg as CallMessage).hook });
}
});
port.postMessage({ type: "ready" });

View File

@@ -0,0 +1,29 @@
/**
* Plugin sandbox — configurable isolation levels.
*
* @module plugins/sandbox
*/
export enum SandboxLevel {
/** Run in-process (no isolation, fastest) */
IN_PROCESS = 0,
/** Run in child process with full environment */
CHILD_FULL_ENV = 1,
/** Run in child process with filtered environment */
CHILD_FILTERED_ENV = 2,
/** Run in child process with isolated environment (no env vars) */
CHILD_ISOLATED = 3,
}
export function getSandboxLabel(level: SandboxLevel): string {
switch (level) {
case SandboxLevel.IN_PROCESS:
return "In-Process";
case SandboxLevel.CHILD_FULL_ENV:
return "Child (Full Env)";
case SandboxLevel.CHILD_FILTERED_ENV:
return "Child (Filtered Env)";
case SandboxLevel.CHILD_ISOLATED:
return "Child (Isolated)";
}
}

88
src/lib/plugins/sdk.ts Normal file
View File

@@ -0,0 +1,88 @@
/**
* Plugin SDK — typed API for plugin developers.
*
* Provides `definePlugin()` factory and re-exports all types needed
* to build OmniRoute plugins.
*
* @module plugins/sdk
*/
import type {
Plugin,
PluginContext,
PluginResult,
BlockingHookResult,
} from "./hooks.ts";
export type { Plugin, PluginContext, PluginResult, BlockingHookResult };
// ── Plugin Definition Helper ──
export interface PluginDefinition {
/** Plugin name (kebab-case) */
name: string;
/** Priority (lower = runs first, default 100) */
priority?: number;
/** Start enabled? (default true) */
enabled?: boolean;
/** Hook: runs before chat handler. Can block or modify request. */
onRequest?: (ctx: PluginContext) => Promise<PluginResult | void> | PluginResult | void;
/** Hook: runs after chat handler. Can modify response. */
onResponse?: (ctx: PluginContext, response: unknown) => Promise<unknown | void> | unknown | void;
/** Hook: runs on handler error. Can recover or re-throw. */
onError?: (ctx: PluginContext, error: Error) => Promise<unknown | void> | unknown | void;
}
/**
* Define an OmniRoute plugin with type safety.
*
* @example
* ```ts
* import { definePlugin } from "omniroute/plugins/sdk";
*
* export default definePlugin({
* name: "my-plugin",
* priority: 50,
* onRequest: async (ctx) => {
* console.log(`Request ${ctx.requestId} for ${ctx.model}`);
* },
* onResponse: async (ctx, response) => {
* console.log(`Response for ${ctx.requestId}`);
* return response;
* },
* });
* ```
*/
export function definePlugin(def: PluginDefinition): Plugin {
return {
name: def.name,
priority: def.priority ?? 100,
enabled: def.enabled ?? true,
onRequest: def.onRequest,
onResponse: def.onResponse,
onError: def.onError,
};
}
// ── Utility Helpers ──
/**
* Block a request with a 403 response.
*/
export function blockRequest(response?: unknown): PluginResult {
return { blocked: true, response };
}
/**
* Modify the request body.
*/
export function modifyBody(body: unknown): PluginResult {
return { body };
}
/**
* Add metadata to the request context.
*/
export function addMetadata(metadata: Record<string, unknown>): PluginResult {
return { metadata };
}

View File

@@ -0,0 +1,34 @@
/**
* Plugin signing — Ed25519 signature verification for plugin packages.
*
* @module plugins/signing
*/
import { createHash, createPublicKey, verify } from "crypto";
/**
* Compute SHA-256 hash of a buffer.
*/
export function sha256(data: Buffer): string {
return createHash("sha256").update(data).digest("hex");
}
/**
* Verify SHA-256 hash matches expected value.
*/
export function verifySha256(data: Buffer, expectedHash: string): boolean {
const actual = sha256(data);
return actual === expectedHash;
}
/**
* Verify Ed25519 signature.
*/
export function verifyEd25519(data: Buffer, signature: Buffer, publicKeyDer: Buffer): boolean {
try {
const key = createPublicKey(publicKeyDer);
return verify(null, data, key, signature);
} catch {
return false;
}
}

View File

@@ -0,0 +1,95 @@
/**
* Plugin test runner — tests all registered hooks with mock context.
*
* @module plugins/testRunner
*/
import { loadPlugin, type LoadedPlugin } from "./loader";
import type { PluginManifestWithDefaults } from "./manifest";
import type { PluginContext } from "./hooks";
import { logger } from "../../../open-sse/utils/logger.ts";
const log = logger("PLUGIN_TEST_RUNNER");
export interface PluginTestResult {
hook: string;
passed: boolean;
durationMs: number;
error?: string;
output?: unknown;
}
const MOCK_CONTEXT: PluginContext = {
requestId: "test-req-001",
body: { model: "gpt-4", messages: [{ role: "user", content: "test" }] },
model: "gpt-4",
provider: "openai",
metadata: { test: true },
};
/**
* Test all registered hooks for a plugin.
*/
export async function testPlugin(
entryPoint: string,
manifest: PluginManifestWithDefaults
): Promise<PluginTestResult[]> {
const results: PluginTestResult[] = [];
let loaded: LoadedPlugin | null = null;
try {
loaded = await loadPlugin(entryPoint, manifest);
const hooksToTest: Array<{ name: string; call: () => Promise<unknown> }> = [];
if (loaded.plugin.onRequest) {
hooksToTest.push({ name: "onRequest", call: async () => { await loaded!.plugin.onRequest!(MOCK_CONTEXT); } });
}
if (loaded.plugin.onResponse) {
hooksToTest.push({
name: "onResponse",
call: () => loaded!.plugin.onResponse!(MOCK_CONTEXT, { choices: [{ message: { content: "test" } }] }),
});
}
if (loaded.plugin.onError) {
hooksToTest.push({
name: "onError",
call: () => loaded!.plugin.onError!(MOCK_CONTEXT, new Error("test error")),
});
}
for (const hook of hooksToTest) {
const start = performance.now();
try {
const output = await hook.call();
const durationMs = Math.round(performance.now() - start);
results.push({ hook: hook.name, passed: true, durationMs, output });
} catch (err: unknown) {
const durationMs = Math.round(performance.now() - start);
results.push({
hook: hook.name,
passed: false,
durationMs,
error: err instanceof Error ? err.message : String(err),
});
}
}
} catch (err: unknown) {
results.push({
hook: "load",
passed: false,
durationMs: 0,
error: err instanceof Error ? err.message : String(err),
});
} finally {
if (loaded) loaded.cleanup();
}
log.info("testRunner.result", {
pluginName: manifest.name,
passed: results.filter((r) => r.passed).length,
failed: results.filter((r) => !r.passed).length,
});
return results;
}

View File

@@ -0,0 +1,90 @@
/**
* Plugin directory watcher — monitors plugin dirs for changes and auto-reloads.
*
* Uses fs.watch with 500ms debounce to avoid rapid reloads.
*
* @module plugins/watcher
*/
import { watch, type FSWatcher } from "fs";
import { logger } from "../../../open-sse/utils/logger.ts";
const log = logger("PLUGIN_WATCHER");
const DEBOUNCE_MS = 500;
interface WatcherEntry {
watcher: FSWatcher;
pluginName: string;
debounceTimer: ReturnType<typeof setTimeout> | null;
}
const watchers = new Map<string, WatcherEntry>();
type ReloadFn = (name: string) => Promise<void>;
/**
* Start watching a plugin directory for changes.
* Calls reload(pluginName) when files change (debounced).
*/
export function startWatching(pluginDir: string, pluginName: string, reload: ReloadFn): void {
if (watchers.has(pluginDir)) return;
const entry: WatcherEntry = { watcher: null as unknown as FSWatcher, pluginName, debounceTimer: null };
try {
entry.watcher = watch(pluginDir, { recursive: false }, (eventType, filename) => {
if (!filename) return;
if (filename === "node_modules" || filename.startsWith(".")) return;
log.info("watcher.change", { pluginName, file: filename, event: eventType });
if (entry.debounceTimer) clearTimeout(entry.debounceTimer);
entry.debounceTimer = setTimeout(async () => {
entry.debounceTimer = null;
try {
await reload(pluginName);
log.info("watcher.reloaded", { pluginName });
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
log.error("watcher.reload_failed", { pluginName, error: msg });
}
}, DEBOUNCE_MS);
});
watchers.set(pluginDir, entry);
log.info("watcher.started", { pluginName, dir: pluginDir });
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
log.error("watcher.start_failed", { pluginName, error: msg });
}
}
/**
* Stop watching a plugin directory.
*/
export function stopWatching(pluginDir: string): void {
const entry = watchers.get(pluginDir);
if (!entry) return;
if (entry.debounceTimer) clearTimeout(entry.debounceTimer);
try { entry.watcher.close(); } catch {}
watchers.delete(pluginDir);
log.info("watcher.stopped", { pluginName: entry.pluginName, dir: pluginDir });
}
/**
* Stop all watchers.
*/
export function stopAllWatchers(): void {
for (const dir of watchers.keys()) {
stopWatching(dir);
}
}
/**
* Get count of active watchers (for diagnostics).
*/
export function getWatcherCount(): number {
return watchers.size;
}

View File

@@ -33,6 +33,8 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray<string> = [
"/api/copilot/", // unauthenticated LLM driver — CLI-only by default; admins can opt-in to remote access via manage-scope bypass
"/api/tools/agent-bridge/", // AgentBridge: spawns MITM server + DNS edits (Hard Rules #15 + #17)
"/api/tools/traffic-inspector/", // Traffic Inspector: http-proxy listener + system proxy (Hard Rules #15 + #17)
"/api/plugins/", // plugins: load/execute via worker_threads + child_process (Hard Rules #15 + #17)
"/api/plugins", // bare path: GET list + POST install also trigger plugin loading
];
/**
@@ -55,6 +57,7 @@ export const SPAWN_CAPABLE_PREFIXES: ReadonlyArray<string> = [
"/api/services/", // T-10: can run npm install + spawn node processes
"/api/tools/agent-bridge/", // start/stop MITM server + DNS edits (Hard Rules #15 + #17)
"/api/tools/traffic-inspector/", // http-proxy listener + system proxy (Hard Rules #15 + #17)
"/api/plugins/", // plugins: load/execute via worker_threads + child_process (Hard Rules #15 + #17)
];
/**

View File

@@ -80,6 +80,7 @@ export interface ApiKeyMetadata {
maxSessions?: number | null;
rateLimits?: RateLimitRule[] | null;
allowedEndpoints?: string[];
disableNonPublicModels?: boolean;
}
/**
@@ -406,13 +407,21 @@ export async function enforceApiKeyPolicy(
}
const hasModelRestrictions =
!isQuotaExclusive && apiKeyInfo.allowedModels && apiKeyInfo.allowedModels.length > 0;
!isQuotaExclusive &&
((apiKeyInfo.allowedModels && apiKeyInfo.allowedModels.length > 0) ||
(apiKeyInfo as { disableNonPublicModels?: boolean }).disableNonPublicModels === true);
if (!requestedComboName && modelStr && hasModelRestrictions) {
try {
requestedComboName = await resolveRequestedComboName(modelStr);
} catch {
requestedComboName = null;
// Short-circuit: auto/* and qtSd/* are combo-routed (not catalog models).
// They must never be evaluated by the published-model gate.
if (modelStr.startsWith("auto/") || modelStr.startsWith("qtSd/")) {
requestedComboName = modelStr; // non-null sentinel — skips the published-model check
} else {
try {
requestedComboName = await resolveRequestedComboName(modelStr);
} catch {
requestedComboName = null;
}
}
}

View File

@@ -1872,6 +1872,7 @@ export const updateKeyPermissionsSchema = z
scopes: z.array(z.string().trim().min(1).max(64)).max(32).optional(),
allowedEndpoints: z.array(z.string().trim().min(1).max(64)).max(20).optional(),
streamDefaultMode: z.enum(["legacy", "json"]).optional(),
disableNonPublicModels: z.boolean().optional(),
})
.superRefine((value, ctx) => {
if (

View File

@@ -0,0 +1,379 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
// ── Temp dirs ──
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-plugins-lifecycle-"));
process.env.DATA_DIR = TEST_DATA_DIR;
// ── Dynamic imports (after DATA_DIR set) ──
const core = await import("../../src/lib/db/core.ts");
const dbPlugins = await import("../../src/lib/db/plugins.ts");
const hooks = await import("../../src/lib/plugins/hooks.ts");
const { pluginManager } = await import("../../src/lib/plugins/manager.ts");
// ── Fixture: create a valid plugin in a temp directory ──
// Scanner expects: sourceDir/<plugin-name>/plugin.json + index.js
// Returns the sourceDir (parent) to pass to pluginManager.install()
function writeTestPlugin(opts?: { name?: string; onRequest?: boolean; enabledByDefault?: boolean }) {
const name = opts?.name ?? "test-lifecycle-plugin";
const onRequest = opts?.onRequest ?? true;
const enabledByDefault = opts?.enabledByDefault ?? false;
// Create a fresh source dir for this plugin (scanner scans for subdirs)
const sourceDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-plugin-src-"));
const pluginDir = path.join(sourceDir, name);
fs.mkdirSync(pluginDir, { recursive: true });
const manifest = {
name,
version: "1.0.0",
description: "Integration test plugin",
author: "test",
main: "index.js",
hooks: { onRequest, onResponse: false, onError: false },
enabledByDefault,
requires: { permissions: [] },
};
fs.writeFileSync(path.join(pluginDir, "plugin.json"), JSON.stringify(manifest, null, 2));
// Plugin exports an onRequest handler that returns metadata (child-process isolation means
// the handler cannot mutate the parent's ctx object directly — it must return the result).
const indexJs = onRequest
? `module.exports.onRequest = function(ctx) { return { metadata: { hookCalled: true } }; };`
: `module.exports = {};`;
fs.writeFileSync(path.join(pluginDir, "index.js"), indexJs);
return { sourceDir, pluginDir, name };
}
// ── Helpers ──
function cleanupDir(dir: string) {
fs.rmSync(dir, { recursive: true, force: true });
}
// Track temp source dirs for cleanup
const activeSourceDirs: string[] = [];
function cleanupSourceDirs() {
for (const dir of activeSourceDirs) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
}
activeSourceDirs.length = 0;
}
// ── Lifecycle ──
test.beforeEach(() => {
core.resetDbInstance();
hooks.resetHooks();
cleanupDir(TEST_DATA_DIR);
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
cleanupSourceDirs();
});
test.after(() => {
core.resetDbInstance();
cleanupSourceDirs();
try { cleanupDir(TEST_DATA_DIR); } catch {}
});
// ── Tests: Install ──
test("install: copies plugin and creates DB row", async () => {
const { sourceDir, name } = writeTestPlugin({ name: "install-test" });
activeSourceDirs.push(sourceDir);
const row = await pluginManager.install(sourceDir);
assert.equal(row.name, name);
assert.equal(row.version, "1.0.0");
assert.equal(row.description, "Integration test plugin");
assert.equal(row.status, "installed");
// Verify DB lookup works
const fromDb = dbPlugins.getPluginByName(name);
assert.ok(fromDb, "plugin should be retrievable from DB");
assert.equal(fromDb!.name, name);
assert.equal(fromDb!.version, "1.0.0");
await pluginManager.uninstall(name);
});
test("install: throws on duplicate install", async () => {
const { sourceDir, name } = writeTestPlugin({ name: "dup-test" });
activeSourceDirs.push(sourceDir);
await pluginManager.install(sourceDir);
await assert.rejects(() => pluginManager.install(sourceDir), /already installed/);
await pluginManager.uninstall(name);
});
test("install: throws on invalid source directory", async () => {
const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-empty-"));
activeSourceDirs.push(emptyDir);
await assert.rejects(() => pluginManager.install(emptyDir), /No valid plugin found/);
});
// ── Tests: Activate ──
test("activate: transitions DB status to active", async () => {
const { sourceDir, name } = writeTestPlugin({ name: "activate-status" });
activeSourceDirs.push(sourceDir);
await pluginManager.install(sourceDir);
await pluginManager.activate(name);
const row = dbPlugins.getPluginByName(name);
assert.ok(row, "plugin should exist in DB");
assert.equal(row!.status, "active");
assert.equal(row!.enabled, 1);
await pluginManager.uninstall(name);
});
test("activate: registers manifest-declared hooks", async () => {
const { sourceDir, name } = writeTestPlugin({ name: "activate-hooks" });
activeSourceDirs.push(sourceDir);
await pluginManager.install(sourceDir);
await pluginManager.activate(name);
// onRequest should be registered (manifest declares it)
const registered = hooks.getHooks("onRequest");
const found = registered.find((r) => r.pluginName === name);
assert.ok(found, "onRequest hook should be registered for the plugin");
// onResponse should NOT be registered (manifest says false)
const responseHooks = hooks.getHooks("onResponse");
const notFound = responseHooks.find((r) => r.pluginName === name);
assert.equal(notFound, undefined, "onResponse hook should not be registered");
await pluginManager.uninstall(name);
});
test("activate: plugin handler fires on hook emit", async () => {
const { sourceDir, name } = writeTestPlugin({ name: "activate-emit" });
activeSourceDirs.push(sourceDir);
await pluginManager.install(sourceDir);
await pluginManager.activate(name);
// Fire the onRequest hook with a PluginContext-like payload.
// Plugins run in isolated child processes and cannot mutate the parent's object
// in-place — use emitHookBlocking and inspect the returned merged metadata.
const payload = { requestId: "test-req", body: {}, model: "test", metadata: {} };
const result = await hooks.emitHookBlocking("onRequest", payload);
// The handler sets metadata.hookCalled = true; it is returned in the merged result.
assert.deepEqual((result as Record<string, unknown>).metadata, { hookCalled: true });
await pluginManager.uninstall(name);
});
test("activate: is idempotent for already-active plugin", async () => {
const { sourceDir, name } = writeTestPlugin({ name: "activate-idempotent" });
activeSourceDirs.push(sourceDir);
await pluginManager.install(sourceDir);
await pluginManager.activate(name);
// Second activate should not throw
await pluginManager.activate(name);
const row = dbPlugins.getPluginByName(name);
assert.equal(row!.status, "active");
await pluginManager.uninstall(name);
});
test("activate: throws for nonexistent plugin", async () => {
await assert.rejects(() => pluginManager.activate("no-such-plugin"), /not found/);
});
// ── Tests: Deactivate ──
test("deactivate: transitions DB status to inactive", async () => {
const { sourceDir, name } = writeTestPlugin({ name: "deactivate-status" });
activeSourceDirs.push(sourceDir);
await pluginManager.install(sourceDir);
await pluginManager.activate(name);
await pluginManager.deactivate(name);
const row = dbPlugins.getPluginByName(name);
assert.ok(row, "plugin should still exist in DB after deactivation");
assert.equal(row!.status, "inactive");
await pluginManager.uninstall(name);
});
test("deactivate: unregisters all hooks for the plugin", async () => {
const { sourceDir, name } = writeTestPlugin({ name: "deactivate-hooks" });
activeSourceDirs.push(sourceDir);
await pluginManager.install(sourceDir);
await pluginManager.activate(name);
// Verify hook is registered before deactivation
assert.ok(hooks.getHooks("onRequest").find((r) => r.pluginName === name));
await pluginManager.deactivate(name);
// Hook should be gone
const after = hooks.getHooks("onRequest");
assert.equal(after.find((r) => r.pluginName === name), undefined, "hook should be unregistered");
await pluginManager.uninstall(name);
});
test("deactivate: hook no longer fires after deactivation", async () => {
const { sourceDir, name } = writeTestPlugin({ name: "deactivate-nofire" });
activeSourceDirs.push(sourceDir);
await pluginManager.install(sourceDir);
await pluginManager.activate(name);
// Verify hook fires while active (use emitHookBlocking — child-process isolation
// means plugins cannot mutate the parent's in-memory payload object in-place).
const payload = { requestId: "req-1", body: {}, model: "test", metadata: {} };
const result1 = await hooks.emitHookBlocking("onRequest", payload);
assert.deepEqual((result1 as Record<string, unknown>).metadata, { hookCalled: true });
await pluginManager.deactivate(name);
// After deactivation, hook is unregistered — emitHookBlocking returns empty metadata.
const payload2 = { requestId: "req-2", body: {}, model: "test", metadata: {} };
const result2 = await hooks.emitHookBlocking("onRequest", payload2);
assert.deepEqual((result2 as Record<string, unknown>).metadata, {});
await pluginManager.uninstall(name);
});
// ── Tests: Uninstall ──
test("uninstall: removes DB row", async () => {
const { sourceDir, name } = writeTestPlugin({ name: "uninstall-db" });
activeSourceDirs.push(sourceDir);
await pluginManager.install(sourceDir);
assert.ok(dbPlugins.getPluginByName(name), "should exist before uninstall");
await pluginManager.uninstall(name);
assert.equal(dbPlugins.getPluginByName(name), null, "should be removed from DB");
});
test("uninstall: removes plugin directory from disk", async () => {
const { sourceDir, name } = writeTestPlugin({ name: "uninstall-dir" });
activeSourceDirs.push(sourceDir);
const row = await pluginManager.install(sourceDir);
const installedDir = row.pluginDir;
assert.ok(fs.existsSync(installedDir), "plugin dir should exist after install");
await pluginManager.uninstall(name);
assert.ok(!fs.existsSync(installedDir), "plugin dir should be removed after uninstall");
});
test("uninstall: deactivates before removing if active", async () => {
const { sourceDir, name } = writeTestPlugin({ name: "uninstall-active" });
activeSourceDirs.push(sourceDir);
await pluginManager.install(sourceDir);
await pluginManager.activate(name);
// Verify active + hook registered
assert.equal(dbPlugins.getPluginByName(name)!.status, "active");
assert.ok(hooks.getHooks("onRequest").find((r) => r.pluginName === name));
await pluginManager.uninstall(name);
// Plugin should be fully gone
assert.equal(dbPlugins.getPluginByName(name), null);
assert.equal(hooks.getHooks("onRequest").find((r) => r.pluginName === name), undefined);
});
test("uninstall: throws for nonexistent plugin", async () => {
await assert.rejects(() => pluginManager.uninstall("ghost-plugin"), /not found/);
});
// ── Tests: Full lifecycle ──
test("full lifecycle: install -> activate -> hook fires -> deactivate -> uninstall", async () => {
const { sourceDir, name } = writeTestPlugin({ name: "full-lifecycle" });
activeSourceDirs.push(sourceDir);
// 1. Install
const row = await pluginManager.install(sourceDir);
assert.equal(row.status, "installed");
assert.ok(dbPlugins.getPluginByName(name), "exists in DB after install");
// 2. Activate
await pluginManager.activate(name);
const afterActivate = dbPlugins.getPluginByName(name);
assert.equal(afterActivate!.status, "active");
assert.ok(hooks.getHooks("onRequest").find((r) => r.pluginName === name), "hook registered");
// 3. Fire hook (use emitHookBlocking — child-process isolation means plugins cannot
// mutate the parent's in-memory payload object; check the returned merged result).
const payload = { requestId: "lifecycle-req", body: {}, model: "test", metadata: {} };
const hookResult = await hooks.emitHookBlocking("onRequest", payload);
assert.deepEqual(
(hookResult as Record<string, unknown>).metadata,
{ hookCalled: true },
"hook handler executed"
);
// 4. Deactivate
await pluginManager.deactivate(name);
const afterDeactivate = dbPlugins.getPluginByName(name);
assert.equal(afterDeactivate!.status, "inactive");
assert.equal(
hooks.getHooks("onRequest").find((r) => r.pluginName === name),
undefined,
"hook unregistered after deactivation"
);
// 5. Uninstall
await pluginManager.uninstall(name);
assert.equal(dbPlugins.getPluginByName(name), null, "removed from DB");
});
// ── Tests: Multi-plugin isolation ──
test("multiple plugins: hooks are isolated per plugin", async () => {
const p1 = writeTestPlugin({ name: "multi-p1" });
const p2 = writeTestPlugin({ name: "multi-p2" });
activeSourceDirs.push(p1.sourceDir, p2.sourceDir);
await pluginManager.install(p1.sourceDir);
await pluginManager.install(p2.sourceDir);
await pluginManager.activate("multi-p1");
await pluginManager.activate("multi-p2");
// Both should have onRequest hooks
const onRequest = hooks.getHooks("onRequest");
assert.ok(onRequest.find((r) => r.pluginName === "multi-p1"));
assert.ok(onRequest.find((r) => r.pluginName === "multi-p2"));
// Deactivate only p1
await pluginManager.deactivate("multi-p1");
const afterDeactivate = hooks.getHooks("onRequest");
assert.equal(afterDeactivate.find((r) => r.pluginName === "multi-p1"), undefined);
assert.ok(afterDeactivate.find((r) => r.pluginName === "multi-p2"), "p2 hook still registered");
// Cleanup
await pluginManager.uninstall("multi-p1");
await pluginManager.uninstall("multi-p2");
});

View File

@@ -0,0 +1,231 @@
/**
* tests/unit/apikeypolicy-disable-non-public.test.ts
*
* TDD coverage for disable_non_public_models policy enforcement in
* enforceApiKeyPolicy.
*
* Cases:
* 1. disableNonPublicModels=true, discovered public model → ALLOWED (rejection null).
* 2. disableNonPublicModels=true, hidden model → REJECTED 403.
* 3. disableNonPublicModels=true, non-discovered model → REJECTED 403.
* 4. disableNonPublicModels=true, auto/<group> → NOT rejected by published gate (combo-routed).
* 5. disableNonPublicModels=true, existing combo name → NOT rejected by published gate.
* 6. disableNonPublicModels=true, qtSd/<slug>/... virtual model → NOT rejected by published gate.
* 7. disableNonPublicModels=false + no allowedModels → all models ALLOWED (no restriction).
* 8. Custom model (getCustomModels) → treated as discovered + public → ALLOWED.
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
const TEST_DATA_DIR = fs.mkdtempSync(
path.join(os.tmpdir(), "omniroute-apikeypolicy-dnp-")
);
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "disable-non-public-policy-secret";
// Import DB modules
const coreDb = await import("../../src/lib/db/core.ts");
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
const rateLimiter = await import("../../src/shared/utils/rateLimiter.ts");
rateLimiter.setRateLimiterTestMode(true);
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async function resetStorage() {
apiKeysDb.resetApiKeyState();
coreDb.resetDbInstance();
for (let attempt = 0; attempt < 10; attempt++) {
try {
if (fs.existsSync(TEST_DATA_DIR)) {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
}
break;
} catch (error: unknown) {
const err = error as NodeJS.ErrnoException;
if ((err?.code === "EBUSY" || err?.code === "EPERM") && attempt < 9) {
await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1)));
} else {
throw error;
}
}
}
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
/** Load a fresh (cache-busted) copy of apiKeyPolicy so mocks take effect. */
async function loadPolicy(label: string) {
const modulePath = path.join(process.cwd(), "src/shared/utils/apiKeyPolicy.ts");
return import(`${pathToFileURL(modulePath).href}?case=${label}-${Date.now()}`);
}
function makeRequest(apiKey: string | null) {
return new Request("http://localhost/api/v1/chat/completions", {
method: "POST",
headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},
});
}
// ---------------------------------------------------------------------------
// Module mocking helpers
// ---------------------------------------------------------------------------
/**
* Registers a mock for getSyncedAvailableModelsByConnection, getCustomModels,
* and getModelIsHidden in the module registry so isModelAllowedForKey (which
* now uses static imports) will pick them up via the same cache-busted path.
*
* Because Node's native module cache does NOT support per-test mock
* registration the way Vitest does, we patch the *imported* module object's
* properties directly after importing it for the test. Since isModelAllowedForKey
* is the only callsite and the static imports are top-level references on the
* module object, we need to supply the correct data through the DB layer instead.
*
* Strategy: insert real DB rows (synced_models or custom_models tables) so the
* real helpers return the desired data, OR set model hidden status through the
* real DB. This validates the integration end-to-end without fragile module
* patching.
*/
// We'll use the DB layer to drive model visibility. Import the models module
// to manipulate synced_models / hidden status.
const modelsDb = await import("../../src/lib/db/models.ts");
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
test.beforeEach(async () => {
await resetStorage();
});
test.after(async () => {
apiKeysDb.resetApiKeyState();
coreDb.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
test("disableNonPublicModels=true + no model restriction → no restriction key (allowedModels empty + flag false)", async () => {
// Key with disableNonPublicModels=false and no allowedModels → all models pass
const created = await apiKeysDb.createApiKey("Free Key", "machine-dnp-free");
await apiKeysDb.updateApiKeyPermissions(created.id, { disableNonPublicModels: false });
apiKeysDb.clearApiKeyCaches();
const policy = await loadPolicy("dnp-free");
const result = await policy.enforceApiKeyPolicy(makeRequest(created.key), "openai/gpt-4.1");
assert.equal(result.rejection, null, "key with no restrictions should allow any model");
});
test("disableNonPublicModels=true + auto/<group> request → not rejected by published-model gate", async () => {
const created = await apiKeysDb.createApiKey("DNP Auto Key", "machine-dnp-auto");
await apiKeysDb.updateApiKeyPermissions(created.id, { disableNonPublicModels: true });
apiKeysDb.clearApiKeyCaches();
const policy = await loadPolicy("dnp-auto");
// auto/ models are combo-routed; the published-model gate must NOT run for them.
// The request may fail for other reasons (budget etc.) but must NOT be rejected
// with "not allowed for this API key" due to the published-model check.
const result = await policy.enforceApiKeyPolicy(makeRequest(created.key), "auto/mygroup");
// If rejected, the error must NOT be the published-model gate (status 403 with
// "not allowed for this API key" wording).
if (result.rejection) {
const body = (await result.rejection.clone().json()) as { error: { message: string } };
assert.ok(
!body.error.message.includes("not allowed for this API key"),
`auto/ model must not be blocked by published-model gate; got: ${body.error.message}`
);
}
});
test("disableNonPublicModels=true + qtSd/ virtual model → not rejected by published-model gate", async () => {
const created = await apiKeysDb.createApiKey("DNP QtSd Key", "machine-dnp-qtsd");
await apiKeysDb.updateApiKeyPermissions(created.id, { disableNonPublicModels: true });
apiKeysDb.clearApiKeyCaches();
const policy = await loadPolicy("dnp-qtsd");
const result = await policy.enforceApiKeyPolicy(
makeRequest(created.key),
"qtSd/mygroup/codex/gpt-5.5"
);
if (result.rejection) {
const body = (await result.rejection.clone().json()) as { error: { message: string } };
assert.ok(
!body.error.message.includes("not allowed for this API key"),
`qtSd/ model must not be blocked by published-model gate; got: ${body.error.message}`
);
}
});
test("disableNonPublicModels=true + hidden model → REJECTED 403 (not in discovered+public set)", async () => {
const created = await apiKeysDb.createApiKey("DNP Hidden Key", "machine-dnp-hidden");
await apiKeysDb.updateApiKeyPermissions(created.id, { disableNonPublicModels: true });
apiKeysDb.clearApiKeyCaches();
const policy = await loadPolicy("dnp-hidden");
// A model that is NOT in the synced_models table (not discovered) should be rejected.
// "openai/gpt-totally-undiscovered-xyz" is never synced → not discovered → rejected.
const result = await policy.enforceApiKeyPolicy(
makeRequest(created.key),
"openai/gpt-totally-undiscovered-xyz"
);
assert.ok(result.rejection, "non-discovered model should be rejected for disableNonPublicModels key");
assert.equal(result.rejection.status, 403);
const body = (await result.rejection.json()) as { error: { message: string } };
assert.match(body.error.message, /not allowed for this API key/);
assert.ok(!body.error.message.includes(" at "), "must not contain stack trace");
});
test("disableNonPublicModels=true + existing combo name → not rejected by published-model gate", async () => {
// Create a real combo in the DB using createCombo if available, otherwise
// test that a known combo-mapped model (via resolveComboForModel) is not
// blocked. We use a model string that starts with "combo/" as a fallback.
const created = await apiKeysDb.createApiKey("DNP Combo Key", "machine-dnp-combo");
await apiKeysDb.updateApiKeyPermissions(created.id, { disableNonPublicModels: true });
apiKeysDb.clearApiKeyCaches();
const policy = await loadPolicy("dnp-combo");
// "combo/mycombo" prefix — resolveRequestedComboName strips "combo/" and looks up "mycombo".
// Even if not found, the combo-prefix path returns null → resolveRequestedComboName null →
// but the key fix is that auto/* / qtSd/* are caught before that lookup.
// For a "combo/" prefix that doesn't exist in DB, the policy falls through to
// isModelAllowedForKey. We need to test an EXISTING combo.
// Create a combo via the DB helper if available:
let comboDb: { createCombo?: (input: Record<string, unknown>) => { name: string } } | null = null;
try {
comboDb = await import("../../src/lib/db/combos.ts") as typeof comboDb;
} catch {
comboDb = null;
}
if (comboDb && typeof comboDb.createCombo === "function") {
comboDb.createCombo({ name: "test-combo-dnp", targets: [] });
apiKeysDb.clearApiKeyCaches();
const result2 = await policy.enforceApiKeyPolicy(
makeRequest(created.key),
"test-combo-dnp"
);
if (result2.rejection) {
const body = (await result2.rejection.clone().json()) as { error: { message: string } };
assert.ok(
!body.error.message.includes("not allowed for this API key"),
`existing combo must not be blocked by published-model gate; got: ${body.error.message}`
);
}
} else {
// Fallback: verify auto/ works (already covered in another test, just skip this branch)
assert.ok(true, "combo DB helper not available — skipped combo case");
}
});

View File

@@ -99,7 +99,10 @@ test.after(async () => {
// ---------------------------------------------------------------------------
test("quota-only key requesting its quotaShared-* virtual model is allowed", async () => {
// Pool name "Times" → slug "times"; provider "codex"
// Create a group named "Times" so resolveQuotaKeyScope returns the GROUP slug "times".
// quotaGroupSlug("Times") === "times", matching quotaModelName("Times", ...) → qtSd/times/...
const group = groupsDb.createGroup("Times");
const conn = await providersDb.createProviderConnection({
provider: "codex",
authType: "apikey",
@@ -109,8 +112,7 @@ test("quota-only key requesting its quotaShared-* virtual model is allowed", asy
const connId = (conn as Record<string, unknown>).id as string;
assert.ok(connId);
// Create group "Times" so resolveQuotaKeyScope returns group slug "times"
const group = groupsDb.createGroup("Times");
// Assign pool to the "Times" group so resolveQuotaKeyScope picks up the group slug.
const pool = poolsDb.createPool({ connectionId: connId, name: "Times", groupId: group.id });
const created = await apiKeysDb.createApiKey("Quota-B4 Key Allowed", "machine-b4-allowed");
@@ -135,6 +137,7 @@ test("quota-only key requesting its quotaShared-* virtual model is allowed", asy
});
test("quota-only key requesting raw model name is rejected 403 QUOTA_ONLY", async () => {
const group = groupsDb.createGroup("Times");
const conn = await providersDb.createProviderConnection({
provider: "codex",
authType: "apikey",
@@ -142,7 +145,7 @@ test("quota-only key requesting raw model name is rejected 403 QUOTA_ONLY", asyn
apiKey: "sk-codex-b4-raw",
});
const connId = (conn as Record<string, unknown>).id as string;
const pool = poolsDb.createPool({ connectionId: connId, name: "Times" });
const pool = poolsDb.createPool({ connectionId: connId, name: "Times", groupId: group.id });
const created = await apiKeysDb.createApiKey("Quota-B4 Key Raw Reject", "machine-b4-raw");
await apiKeysDb.updateApiKeyPermissions(created.id, {
@@ -168,6 +171,7 @@ test("quota-only key requesting raw model name is rejected 403 QUOTA_ONLY", asyn
});
test("quota-only key requesting a quotaShared-* model from a different pool is rejected 403 QUOTA_ONLY", async () => {
const group = groupsDb.createGroup("Times");
const conn = await providersDb.createProviderConnection({
provider: "codex",
authType: "apikey",
@@ -175,7 +179,7 @@ test("quota-only key requesting a quotaShared-* model from a different pool is r
apiKey: "sk-codex-b4-otherpool",
});
const connId = (conn as Record<string, unknown>).id as string;
const pool = poolsDb.createPool({ connectionId: connId, name: "Times" });
const pool = poolsDb.createPool({ connectionId: connId, name: "Times", groupId: group.id });
const created = await apiKeysDb.createApiKey("Quota-B4 Key Other Pool", "machine-b4-other");
await apiKeysDb.updateApiKeyPermissions(created.id, {

View File

@@ -0,0 +1,135 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-disable-non-public-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "disable-non-public-test-secret";
const core = await import("../../src/lib/db/core.ts");
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
async function resetStorage() {
core.resetDbInstance();
apiKeysDb.resetApiKeyState();
for (let attempt = 0; attempt < 10; attempt++) {
try {
if (fs.existsSync(TEST_DATA_DIR)) {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
}
break;
} catch (error: unknown) {
const err = error as NodeJS.ErrnoException;
if ((err?.code === "EBUSY" || err?.code === "EPERM") && attempt < 9) {
await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1)));
} else {
throw error;
}
}
}
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
test.beforeEach(async () => {
await resetStorage();
});
test.after(async () => {
core.resetDbInstance();
apiKeysDb.resetApiKeyState();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("disableNonPublicModels: set to true via updateApiKeyPermissions, read back via getApiKeyMetadata", async () => {
const created = await apiKeysDb.createApiKey("NonPublic Key", "machine-np-01");
await apiKeysDb.updateApiKeyPermissions(created.id, {
disableNonPublicModels: true,
});
apiKeysDb.clearApiKeyCaches();
const metadata = await apiKeysDb.getApiKeyMetadata(created.key);
assert.ok(metadata, "metadata should not be null");
assert.equal(metadata.disableNonPublicModels, true, "disableNonPublicModels should be true");
});
test("disableNonPublicModels: defaults to false when not set on a new key", async () => {
const created = await apiKeysDb.createApiKey("Default NonPublic Key", "machine-np-02");
const metadata = await apiKeysDb.getApiKeyMetadata(created.key);
assert.ok(metadata, "metadata should not be null");
assert.equal(
metadata.disableNonPublicModels,
false,
"disableNonPublicModels should default to false"
);
});
test("3 columns coexist: disableNonPublicModels, allowedQuotas, streamDefaultMode all present", async () => {
const created = await apiKeysDb.createApiKey("Coexist Key", "machine-np-03");
await apiKeysDb.updateApiKeyPermissions(created.id, {
disableNonPublicModels: true,
allowedQuotas: ["pool-alpha", "pool-beta"],
streamDefaultMode: "json",
});
apiKeysDb.clearApiKeyCaches();
const metadata = await apiKeysDb.getApiKeyMetadata(created.key);
assert.ok(metadata, "metadata should not be null");
// Verify disableNonPublicModels
assert.equal(metadata.disableNonPublicModels, true, "disableNonPublicModels should be true");
// Verify allowedQuotas is still an array
assert.ok(Array.isArray(metadata.allowedQuotas), "allowedQuotas should be an array");
assert.deepEqual(
metadata.allowedQuotas,
["pool-alpha", "pool-beta"],
"allowedQuotas should match"
);
// Verify streamDefaultMode is still present
assert.ok(
metadata.streamDefaultMode !== undefined,
"streamDefaultMode should be present"
);
assert.equal(metadata.streamDefaultMode, "json", "streamDefaultMode should be 'json'");
});
test("disableNonPublicModels: can be toggled back to false", async () => {
const created = await apiKeysDb.createApiKey("Toggle NonPublic Key", "machine-np-04");
await apiKeysDb.updateApiKeyPermissions(created.id, { disableNonPublicModels: true });
apiKeysDb.clearApiKeyCaches();
const metaTrue = await apiKeysDb.getApiKeyMetadata(created.key);
assert.equal(metaTrue?.disableNonPublicModels, true, "should be true after first update");
await apiKeysDb.updateApiKeyPermissions(created.id, { disableNonPublicModels: false });
apiKeysDb.clearApiKeyCaches();
const metaFalse = await apiKeysDb.getApiKeyMetadata(created.key);
assert.equal(metaFalse?.disableNonPublicModels, false, "should be false after second update");
});
test("disableNonPublicModels: allowedQuotas is still [] when only disableNonPublicModels is set", async () => {
const created = await apiKeysDb.createApiKey("Separate NonPublic Key", "machine-np-05");
await apiKeysDb.updateApiKeyPermissions(created.id, { disableNonPublicModels: true });
apiKeysDb.clearApiKeyCaches();
const metadata = await apiKeysDb.getApiKeyMetadata(created.key);
assert.ok(metadata, "metadata should not be null");
assert.equal(metadata.disableNonPublicModels, true);
assert.deepEqual(metadata.allowedQuotas, [], "allowedQuotas should remain empty array");
assert.equal(metadata.streamDefaultMode, "legacy", "streamDefaultMode should remain 'legacy'");
});

View File

@@ -0,0 +1,127 @@
/**
* Source-scan tests for src/lib/plugins/pluginWorker.ts sandbox hardening.
*
* Why source-scan (not behavioral)?
* pluginWorker.ts is a worker-thread entry point: it throws at import time when
* parentPort is null (line 17-19), so it cannot be imported directly in a test
* runner. createSandbox is also not exported. Source-scan tests mirror the pattern
* used in tests/unit/electron-preload.test.ts for the same reason.
*
* Assertions cover:
* - exec permission gates child_process behind OMNIROUTE_PLUGINS_ALLOW_EXEC==="1"
* - the throw path exists when env is absent
* - vm.runInContext is called with a finite timeout (no infinite-loop DoS)
* - the trust-model comment is present
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const source = readFileSync(
resolve(process.cwd(), "src/lib/plugins/pluginWorker.ts"),
"utf8"
);
describe("pluginWorker sandbox — exec permission gating", () => {
it("gates child_process behind OMNIROUTE_PLUGINS_ALLOW_EXEC === '1'", () => {
assert.ok(
source.includes('OMNIROUTE_PLUGINS_ALLOW_EXEC !== "1"') ||
source.includes("OMNIROUTE_PLUGINS_ALLOW_EXEC !== '1'"),
"exec block must check process.env.OMNIROUTE_PLUGINS_ALLOW_EXEC !== \"1\""
);
});
it("checks the env flag inside the exec permission block", () => {
// The env guard must appear inside the exec permission block.
// Use the SECOND occurrence of OMNIROUTE_PLUGINS_ALLOW_EXEC (the first is in
// the trust-model comment above createSandbox; the second is the actual guard).
const execIdx = source.indexOf('permissions.includes("exec")');
assert.ok(execIdx !== -1, 'source must contain permissions.includes("exec")');
// Find the env check that occurs *after* the exec block opens
const envIdxInBlock = source.indexOf("OMNIROUTE_PLUGINS_ALLOW_EXEC", execIdx);
assert.ok(
envIdxInBlock !== -1,
"OMNIROUTE_PLUGINS_ALLOW_EXEC check must appear inside the exec permission block"
);
});
it("throws when exec is requested but env is not set", () => {
// The throw statement must be inside the exec block and before child_process assignment
const execIdx = source.indexOf('permissions.includes("exec")');
const throwIdx = source.indexOf("throw new Error", execIdx);
const childProcessIdx = source.indexOf("sandbox.child_process", execIdx);
assert.ok(throwIdx !== -1, "a throw must exist after the exec permission check");
assert.ok(
throwIdx < childProcessIdx,
"the throw must appear before sandbox.child_process is wired"
);
});
it("throw message references the disabled exec permission without internal paths", () => {
// Message must be operator-readable, not expose stack/paths
assert.ok(
source.includes("exec' permission, which is disabled"),
"throw message must mention the exec permission being disabled"
);
assert.ok(
source.includes("OMNIROUTE_PLUGINS_ALLOW_EXEC=1"),
"throw message must reference the opt-in env var"
);
});
it("does NOT wire child_process without the env guard in place", () => {
// Ensure child_process assignment is nested under the env check, not at the exec-block top level.
// The OMNIROUTE_PLUGINS_ALLOW_EXEC check must come before sandbox.child_process.
const envIdx = source.indexOf("OMNIROUTE_PLUGINS_ALLOW_EXEC");
const childProcessIdx = source.indexOf("sandbox.child_process =");
assert.ok(envIdx < childProcessIdx, "env guard must precede sandbox.child_process assignment");
});
});
describe("pluginWorker sandbox — vm.runInContext timeout", () => {
it("passes a finite timeout to vm.runInContext", () => {
assert.ok(
source.includes("vm.runInContext"),
"vm.runInContext must be present in the source"
);
// The call must include a timeout option
assert.match(
source,
/vm\.runInContext\([^)]*timeout\s*:/,
"vm.runInContext must be called with a timeout option"
);
});
it("timeout value is 10000 ms (10 seconds)", () => {
assert.match(
source,
/timeout\s*:\s*10000/,
"timeout must be 10000 ms"
);
});
});
describe("pluginWorker sandbox — trust-model comment", () => {
it("documents that vm is NOT a security boundary", () => {
assert.ok(
source.includes("vm is NOT a security boundary"),
"createSandbox must have a trust-model comment stating vm is NOT a security boundary"
);
});
it("references the loopback-only routeGuard classification", () => {
assert.ok(
source.includes("LOCAL_ONLY") || source.includes("routeGuard"),
"trust-model comment must reference LOCAL_ONLY or routeGuard"
);
});
it("references the OMNIROUTE_PLUGINS_ALLOW_EXEC opt-in in the comment", () => {
assert.ok(
source.includes("OMNIROUTE_PLUGINS_ALLOW_EXEC"),
"trust-model comment must reference OMNIROUTE_PLUGINS_ALLOW_EXEC"
);
});
});

View File

@@ -0,0 +1,67 @@
import { describe, it, beforeEach, afterEach } from "node:test";
import assert from "node:assert";
import {
recordPluginExecution,
getPluginAnalytics,
getPluginAnalyticsSummary,
} from "../../src/lib/db/plugins.ts";
import { getDbInstance } from "../../src/lib/db/core.ts";
describe("plugin analytics", () => {
beforeEach(() => {
// The plugin_analytics table is created by migration 091 (run on getDbInstance);
// this test relies on that migration rather than creating the table inline, so a
// missing/renumbered migration would fail here instead of being masked.
const db = getDbInstance();
db.exec("DELETE FROM plugin_analytics");
});
afterEach(() => {
const db = getDbInstance();
db.exec("DELETE FROM plugin_analytics");
});
it("recordPluginExecution inserts a row", () => {
recordPluginExecution("test-plugin", "onRequest", 42, true);
const rows = getPluginAnalytics("test-plugin");
assert.strictEqual(rows.length, 1);
assert.strictEqual(rows[0].pluginName, "test-plugin");
assert.strictEqual(rows[0].hook, "onRequest");
assert.strictEqual(rows[0].durationMs, 42);
assert.strictEqual(rows[0].success, true);
});
it("records failure with error message", () => {
recordPluginExecution("fail-plugin", "onError", 100, false, "something broke");
const rows = getPluginAnalytics("fail-plugin");
assert.strictEqual(rows[0].success, false);
assert.strictEqual(rows[0].errorMessage, "something broke");
});
it("getPluginAnalytics returns most recent first", () => {
recordPluginExecution("order-plugin", "onRequest", 10, true);
// Force different timestamp by inserting directly
const db = getDbInstance();
db.prepare("INSERT INTO plugin_analytics (plugin_name, hook, duration_ms, success, created_at) VALUES (?, ?, ?, ?, ?)")
.run("order-plugin", "onResponse", 20, 1, "2099-01-01T00:00:00");
const rows = getPluginAnalytics("order-plugin");
assert.strictEqual(rows[0].hook, "onResponse");
assert.strictEqual(rows[1].hook, "onRequest");
});
it("getPluginAnalyticsSummary counts correctly", () => {
recordPluginExecution("sum-plugin", "onRequest", 100, true);
recordPluginExecution("sum-plugin", "onRequest", 200, true);
recordPluginExecution("sum-plugin", "onRequest", 300, false, "err");
const summary = getPluginAnalyticsSummary("sum-plugin");
assert.strictEqual(summary.totalCalls, 3);
assert.strictEqual(summary.successCount, 2);
assert.strictEqual(summary.failureCount, 1);
assert.ok(summary.avgDurationMs > 0);
});
it("empty plugin returns zero summary", () => {
const summary = getPluginAnalyticsSummary("nonexistent");
assert.strictEqual(summary.totalCalls, 0);
});
});

View File

@@ -0,0 +1,254 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
// ── Temp dirs ──
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-plugins-config-"));
process.env.DATA_DIR = TEST_DATA_DIR;
// ── Dynamic imports (after DATA_DIR set) ──
const core = await import("../../src/lib/db/core.ts");
const dbPlugins = await import("../../src/lib/db/plugins.ts");
// ── Extract validation logic for direct testing ──
// We replicate the route's validation logic here since Next.js route handlers
// are hard to test without the full Next.js runtime.
interface ConfigField {
type: string;
default?: unknown;
min?: number;
max?: number;
enum?: string[];
description?: string;
}
function validateConfig(
config: Record<string, unknown>,
configSchema: Record<string, ConfigField>
): { valid: true } | { valid: false; error: string } {
if (!configSchema || typeof configSchema !== "object") return { valid: true };
const typeChecks: Record<string, (v: unknown) => boolean> = {
string: (v) => typeof v === "string",
number: (v) => typeof v === "number",
boolean: (v) => typeof v === "boolean",
};
for (const [key, def] of Object.entries(configSchema)) {
const val = config[key];
if (val === undefined) continue;
const check = typeChecks[def.type];
if (check && !check(val)) {
return { valid: false, error: `Config key '${key}' must be a ${def.type}` };
}
if (def.enum && !(def.enum as unknown[]).includes(val)) {
return { valid: false, error: `Config key '${key}' must be one of: ${(def.enum as string[]).join(", ")}` };
}
if (def.min !== undefined) {
const limit = def.min;
const size = typeof val === "string" ? val.length : typeof val === "number" ? val : undefined;
if (size !== undefined && size < limit) {
return { valid: false, error: `Config key '${key}' must be at least ${limit}${typeof val === "string" ? " characters" : ""}` };
}
}
if (def.max !== undefined && typeof val === "number" && val > def.max) {
return { valid: false, error: `Config key '${key}' must be at most ${def.max}` };
}
}
return { valid: true };
}
// ── Lifecycle ──
test.beforeEach(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
});
test.after(() => {
core.resetDbInstance();
try { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); } catch {}
});
// ── Test schema ──
const testSchema: Record<string, ConfigField> = {
apiUrl: { type: "string", description: "API endpoint" },
maxRetries: { type: "number", min: 1, max: 10, default: 3 },
debug: { type: "boolean", default: false },
mode: { type: "string", enum: ["fast", "slow", "auto"], default: "auto" },
};
// ── DB operations (same as route GET/PUT) ──
test("GET: returns config and schema for existing plugin", () => {
dbPlugins.insertPlugin({
id: "test-1",
name: "config-get-test",
version: "1.0.0",
main: "index.js",
pluginDir: "/tmp/test",
manifest: {},
config: { apiUrl: "https://api.test.com" },
configSchema: testSchema,
});
const plugin = dbPlugins.getPluginByName("config-get-test");
assert.ok(plugin);
const config = JSON.parse(plugin!.config || "{}");
const configSchema = JSON.parse(plugin!.configSchema || "{}");
assert.equal(config.apiUrl, "https://api.test.com");
assert.equal(configSchema.apiUrl.type, "string");
assert.equal(configSchema.maxRetries.min, 1);
});
test("GET: returns null for nonexistent plugin", () => {
const plugin = dbPlugins.getPluginByName("no-such-plugin");
assert.equal(plugin, null);
});
test("PUT: updates config via updatePluginConfig", () => {
dbPlugins.insertPlugin({
id: "test-2",
name: "config-put-test",
version: "1.0.0",
main: "index.js",
pluginDir: "/tmp/test",
manifest: {},
config: {},
configSchema: testSchema,
});
const success = dbPlugins.updatePluginConfig("config-put-test", { apiUrl: "https://new.api.com" });
assert.ok(success);
const plugin = dbPlugins.getPluginByName("config-put-test");
const config = JSON.parse(plugin!.config);
assert.equal(config.apiUrl, "https://new.api.com");
});
test("PUT: returns false for nonexistent plugin", () => {
const success = dbPlugins.updatePluginConfig("ghost", { key: "value" });
assert.equal(success, false);
});
// ── Validation logic ──
test("validation: accepts valid string config", () => {
const result = validateConfig({ apiUrl: "https://example.com" }, testSchema);
assert.equal(result.valid, true);
});
test("validation: rejects non-string for string field", () => {
const result = validateConfig({ apiUrl: 123 }, testSchema);
assert.equal(result.valid, false);
if (!result.valid) {
assert.ok(result.error.includes("must be a string"));
}
});
test("validation: accepts valid number config", () => {
const result = validateConfig({ maxRetries: 5 }, testSchema);
assert.equal(result.valid, true);
});
test("validation: rejects non-number for number field", () => {
const result = validateConfig({ maxRetries: "five" }, testSchema);
assert.equal(result.valid, false);
if (!result.valid) {
assert.ok(result.error.includes("must be a number"));
}
});
test("validation: accepts valid boolean config", () => {
const result = validateConfig({ debug: true }, testSchema);
assert.equal(result.valid, true);
});
test("validation: rejects non-boolean for boolean field", () => {
const result = validateConfig({ debug: "yes" }, testSchema);
assert.equal(result.valid, false);
if (!result.valid) {
assert.ok(result.error.includes("must be a boolean"));
}
});
test("validation: accepts valid enum value", () => {
const result = validateConfig({ mode: "fast" }, testSchema);
assert.equal(result.valid, true);
});
test("validation: rejects invalid enum value", () => {
const result = validateConfig({ mode: "turbo" }, testSchema);
assert.equal(result.valid, false);
if (!result.valid) {
assert.ok(result.error.includes("must be one of: fast, slow, auto"));
}
});
test("validation: accepts number within min/max range", () => {
const result = validateConfig({ maxRetries: 5 }, testSchema);
assert.equal(result.valid, true);
});
test("validation: rejects number below min", () => {
const result = validateConfig({ maxRetries: 0 }, testSchema);
assert.equal(result.valid, false);
if (!result.valid) {
assert.ok(result.error.includes("must be at least 1"));
}
});
test("validation: rejects number above max", () => {
const result = validateConfig({ maxRetries: 15 }, testSchema);
assert.equal(result.valid, false);
if (!result.valid) {
assert.ok(result.error.includes("must be at most 10"));
}
});
test("validation: accepts string meeting min length", () => {
const schema: Record<string, ConfigField> = { name: { type: "string", min: 3 } };
const result = validateConfig({ name: "abc" }, schema);
assert.equal(result.valid, true);
});
test("validation: rejects string below min length", () => {
const schema: Record<string, ConfigField> = { name: { type: "string", min: 3 } };
const result = validateConfig({ name: "ab" }, schema);
assert.equal(result.valid, false);
if (!result.valid) {
assert.ok(result.error.includes("must be at least 3 characters"));
}
});
test("validation: skips undefined keys", () => {
const result = validateConfig({}, testSchema);
assert.equal(result.valid, true);
});
test("validation: passes with empty schema", () => {
const result = validateConfig({ anything: "goes" }, {});
assert.equal(result.valid, true);
});
test("validation: passes with null schema", () => {
const result = validateConfig({ anything: "goes" }, null as any);
assert.equal(result.valid, true);
});
test("validation: handles multiple field errors (reports first)", () => {
const result = validateConfig({ apiUrl: 123, debug: "yes" }, testSchema);
assert.equal(result.valid, false);
// Should report the first error found
if (!result.valid) {
assert.ok(result.error.includes("must be a"));
}
});

View File

@@ -0,0 +1,81 @@
import { describe, it } from "node:test";
import assert from "node:assert";
import { validatePluginConfig, type ConfigField } from "../../src/lib/plugins/manifest.ts";
describe("validatePluginConfig", () => {
const schema: Record<string, ConfigField> = {
name: { type: "string", default: "test" },
count: { type: "number", default: 10, min: 1, max: 100 },
enabled: { type: "boolean", default: true },
mode: { type: "select", enum: ["fast", "slow"], default: "fast" },
};
it("valid config passes", () => {
const result = validatePluginConfig({ name: "hello", count: 5, enabled: true, mode: "fast" }, schema);
assert.deepStrictEqual(result, { valid: true });
});
it("unknown key rejected", () => {
const result = validatePluginConfig({ unknownKey: "value" }, schema);
assert.strictEqual(result.valid, false);
if (!result.valid) {
assert.ok(result.errors[0].includes("Unknown config key"));
assert.ok(result.errors[0].includes("unknownKey"));
}
});
it("wrong type rejected", () => {
const result = validatePluginConfig({ name: 123 }, schema);
assert.strictEqual(result.valid, false);
if (!result.valid) {
assert.ok(result.errors[0].includes("must be a string"));
}
});
it("number min/max enforced", () => {
const result = validatePluginConfig({ count: 200 }, schema);
assert.strictEqual(result.valid, false);
if (!result.valid) {
assert.ok(result.errors[0].includes("must be <= 100"));
}
});
it("number min enforced", () => {
const result = validatePluginConfig({ count: 0 }, schema);
assert.strictEqual(result.valid, false);
if (!result.valid) {
assert.ok(result.errors[0].includes("must be >= 1"));
}
});
it("select enum enforced", () => {
const result = validatePluginConfig({ mode: "turbo" }, schema);
assert.strictEqual(result.valid, false);
if (!result.valid) {
assert.ok(result.errors[0].includes("must be one of"));
}
});
it("empty config passes", () => {
const result = validatePluginConfig({}, schema);
assert.deepStrictEqual(result, { valid: true });
});
it("empty schema allows anything", () => {
const result = validatePluginConfig({ whatever: "value", count: 999 }, {});
assert.deepStrictEqual(result, { valid: true });
});
it("partial config validates only provided fields", () => {
const result = validatePluginConfig({ name: "partial" }, schema);
assert.deepStrictEqual(result, { valid: true });
});
it("boolean type rejected for non-boolean", () => {
const result = validatePluginConfig({ enabled: "yes" }, schema);
assert.strictEqual(result.valid, false);
if (!result.valid) {
assert.ok(result.errors[0].includes("must be a boolean"));
}
});
});

View File

@@ -0,0 +1,40 @@
import { describe, it, afterEach } from "node:test";
import assert from "node:assert";
import { mkdirSync, writeFileSync, rmSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";
import { startDevMode, stopDevMode } from "../../src/lib/plugins/devMode.ts";
describe("devMode", () => {
const testDir = join(tmpdir(), `devmode-test-${Date.now()}`);
afterEach(() => {
stopDevMode();
try { rmSync(testDir, { recursive: true, force: true }); } catch {}
});
it("startDevMode creates watcher without throwing", () => {
mkdirSync(testDir, { recursive: true });
let reloadCalled = false;
startDevMode(testDir, async () => { reloadCalled = true; });
// Watcher is active — no crash
assert.ok(true);
});
it("stopDevMode cleans up without throwing", () => {
mkdirSync(testDir, { recursive: true });
startDevMode(testDir, async () => {});
stopDevMode();
// Second stop is safe
stopDevMode();
assert.ok(true);
});
it("startDevMode is idempotent", () => {
mkdirSync(testDir, { recursive: true });
startDevMode(testDir, async () => {});
startDevMode(testDir, async () => {}); // Should not throw
stopDevMode();
assert.ok(true);
});
});

View File

@@ -0,0 +1,67 @@
import { describe, it, beforeEach, afterEach } from "node:test";
import assert from "node:assert";
import { mkdirSync, writeFileSync, rmSync, mkdtempSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";
// Isolate to a temp DB so doctor's db check doesn't hit the production DB.
const TEST_DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-doctor-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const { resetDbInstance } = await import("../../src/lib/db/core.ts");
const { runPluginDoctor } = await import("../../src/lib/plugins/doctor.ts");
describe("runPluginDoctor", () => {
const testDir = join(tmpdir(), `doctor-test-${Date.now()}`);
const pluginDir = join(testDir, "test-plugin");
beforeEach(() => {
resetDbInstance();
mkdirSync(pluginDir, { recursive: true });
});
afterEach(() => {
try { rmSync(testDir, { recursive: true, force: true }); } catch {}
});
it("healthy plugin with valid manifest and entry point", async () => {
writeFileSync(join(pluginDir, "plugin.json"), JSON.stringify({
name: "test-plugin", version: "1.0.0", main: "index.js",
}));
writeFileSync(join(pluginDir, "index.js"), "export default {}");
const result = await runPluginDoctor(pluginDir, "test-plugin");
// Plugin not in DB → db_status_correct is "warn" → overall "degraded"
assert.ok(result.overall === "healthy" || result.overall === "degraded");
assert.ok(result.checks.every((c) => c.status === "pass" || c.status === "warn"));
});
it("unhealthy when directory missing", async () => {
const result = await runPluginDoctor("/nonexistent/path", "missing");
assert.strictEqual(result.overall, "unhealthy");
assert.ok(result.checks.some((c) => c.name === "directory_exists" && c.status === "fail"));
});
it("unhealthy when manifest invalid", async () => {
writeFileSync(join(pluginDir, "plugin.json"), "not json");
const result = await runPluginDoctor(pluginDir, "bad-plugin");
assert.ok(result.checks.some((c) => c.name === "manifest_valid" && c.status === "fail"));
});
it("reports missing entry point", async () => {
writeFileSync(join(pluginDir, "plugin.json"), JSON.stringify({
name: "no-entry", version: "1.0.0", main: "index.js",
}));
const result = await runPluginDoctor(pluginDir, "no-entry");
assert.ok(result.checks.some((c) => c.name === "entry_point_exists" && c.status === "fail"));
});
it("degraded when only warnings", async () => {
writeFileSync(join(pluginDir, "plugin.json"), JSON.stringify({
name: "warn-plugin", version: "1.0.0", main: "index.ts",
}));
writeFileSync(join(pluginDir, "index.ts"), "export default {}");
const result = await runPluginDoctor(pluginDir, "warn-plugin");
// .ts extension should produce a warn on can_spawn
assert.ok(result.checks.some((c) => c.name === "can_spawn" && c.status === "warn"));
});
});

View File

@@ -0,0 +1,423 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
// ── Temp dirs ──
// IMPORTANT: DATA_DIR must be set BEFORE importing any module that imports core.ts,
// because core.ts evaluates DATA_DIR at module load time. All imports of DB-touching
// modules must be dynamic (after this line) to ensure the temp DB is used.
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-plugins-edge-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const dbPlugins = await import("../../src/lib/db/plugins.ts");
const { scanPluginDir } = await import("../../src/lib/plugins/scanner.ts");
const { pluginManager } = await import("../../src/lib/plugins/manager.ts");
const {
registerHook,
unregisterHooks,
emitHook,
emitHookBlocking,
resetHooks,
getHooks,
} = await import("../../src/lib/plugins/hooks.ts");
const activeSourceDirs: string[] = [];
function cleanupSourceDirs() {
for (const dir of activeSourceDirs) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
}
activeSourceDirs.length = 0;
}
function writeTestPlugin(opts: {
name: string;
onRequest?: boolean;
onResponse?: boolean;
onError?: boolean;
configSchema?: Record<string, unknown>;
indexJs?: string;
}) {
const sourceDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-edge-src-"));
const pluginDir = path.join(sourceDir, opts.name);
fs.mkdirSync(pluginDir, { recursive: true });
const manifest: Record<string, unknown> = {
name: opts.name,
version: "1.0.0",
description: "Edge test plugin",
main: "index.js",
hooks: {
onRequest: opts.onRequest ?? false,
onResponse: opts.onResponse ?? false,
onError: opts.onError ?? false,
},
enabledByDefault: false,
requires: { permissions: [] },
};
if (opts.configSchema) manifest.configSchema = opts.configSchema;
fs.writeFileSync(path.join(pluginDir, "plugin.json"), JSON.stringify(manifest, null, 2));
let indexJs = opts.indexJs;
if (!indexJs) {
const handlers: string[] = [];
if (opts.onRequest) handlers.push(`onRequest: function(ctx) { ctx.metadata = ctx.metadata || {}; ctx.metadata.hookCalled = true; }`);
if (opts.onResponse) handlers.push(`onResponse: function(ctx, resp) { return resp; }`);
if (opts.onError) handlers.push(`onError: function(ctx, err) {}`);
indexJs = handlers.length > 0 ? `module.exports = { ${handlers.join(", ")} };` : `module.exports = {};`;
}
fs.writeFileSync(path.join(pluginDir, "index.js"), indexJs);
activeSourceDirs.push(sourceDir);
return { sourceDir, pluginDir, name: opts.name };
}
// ── Lifecycle ──
test.beforeEach(() => {
core.resetDbInstance();
// Clean all existing plugins to prevent UNIQUE constraint failures.
// Wrapped in try-catch: when running alongside other test files that load core
// before DATA_DIR is set, the SQLITE_FILE may point to the production DB which
// may not have the plugins table yet (migration 076 renumbered). The cleanup
// is redundant anyway — resetDbInstance() already invalidates the instance,
// and rmSync/mkdirSync below gives us a fresh DATA_DIR for next getDbInstance().
try {
for (const p of dbPlugins.listPlugins()) {
dbPlugins.deletePlugin(p.name);
}
} catch {
// Production DB may not have the plugins table — ignore; fresh DB created below.
}
resetHooks();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
cleanupSourceDirs();
});
test.after(() => {
core.resetDbInstance();
cleanupSourceDirs();
try { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); } catch {}
});
// ══════════════════════════════════════════
// Scanner edge cases
// ══════════════════════════════════════════
test("scanner: empty directory returns empty results", async () => {
const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-scan-empty-"));
activeSourceDirs.push(emptyDir);
const result = await scanPluginDir(emptyDir);
assert.deepEqual(result.plugins, []);
assert.deepEqual(result.errors, []);
});
test("scanner: directory with no manifest reports error", async () => {
const noManifestDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-scan-nomanifest-"));
const pluginDir = path.join(noManifestDir, "some-plugin");
fs.mkdirSync(pluginDir);
fs.writeFileSync(path.join(pluginDir, "index.js"), "module.exports = {};");
activeSourceDirs.push(noManifestDir);
const result = await scanPluginDir(noManifestDir);
assert.equal(result.plugins.length, 0);
assert.equal(result.errors.length, 1);
assert.ok(result.errors[0].error.includes("no plugin.json"));
});
test("scanner: invalid JSON manifest reports error", async () => {
const badDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-scan-badjson-"));
const pluginDir = path.join(badDir, "bad-json");
fs.mkdirSync(pluginDir);
fs.writeFileSync(path.join(pluginDir, "plugin.json"), "{invalid json!!!}}}");
fs.writeFileSync(path.join(pluginDir, "index.js"), "module.exports = {};");
activeSourceDirs.push(badDir);
const result = await scanPluginDir(badDir);
assert.equal(result.plugins.length, 0);
assert.equal(result.errors.length, 1);
assert.ok(result.errors[0].error.includes("invalid manifest") || result.errors[0].error.includes("JSON"));
});
test("scanner: missing required fields reports error", async () => {
const missingDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-scan-missing-"));
const pluginDir = path.join(missingDir, "missing-fields");
fs.mkdirSync(pluginDir);
// Missing version
fs.writeFileSync(path.join(pluginDir, "plugin.json"), JSON.stringify({ name: "missing-fields" }));
fs.writeFileSync(path.join(pluginDir, "index.js"), "module.exports = {};");
activeSourceDirs.push(missingDir);
const result = await scanPluginDir(missingDir);
assert.equal(result.plugins.length, 0);
assert.equal(result.errors.length, 1);
assert.ok(result.errors[0].error.includes("invalid manifest"));
});
test("scanner: non-directory entries are skipped", async () => {
const mixedDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-scan-mixed-"));
// Create a file (not a directory) that looks like a plugin
fs.writeFileSync(path.join(mixedDir, "not-a-dir.json"), "{}");
activeSourceDirs.push(mixedDir);
const result = await scanPluginDir(mixedDir);
assert.equal(result.plugins.length, 0);
assert.equal(result.errors.length, 0);
});
// ══════════════════════════════════════════
// Manager edge cases
// ══════════════════════════════════════════
test("manager: install with path traversal throws", async () => {
await assert.rejects(
() => pluginManager.install("/tmp/../../../etc"),
(err: Error) => {
assert.ok(
err.message.includes("path traversal") || err.message.includes("No valid plugin found"),
`Unexpected error: ${err.message}`
);
return true;
}
);
});
test("manager: install with null bytes in path throws", async () => {
await assert.rejects(
() => pluginManager.install("/tmp/test\0malicious"),
(err: Error) => {
assert.ok(
err.message.includes("Invalid") || err.message.includes("null") || err.message.includes("No valid plugin found"),
`Unexpected error: ${err.message}`
);
return true;
}
);
});
test("manager: double install same plugin throws", async () => {
const { sourceDir, name } = writeTestPlugin({ name: "double-install" });
await pluginManager.install(sourceDir);
await assert.rejects(
() => pluginManager.install(sourceDir),
/already installed/
);
await pluginManager.uninstall(name);
});
test("manager: deactivate when already inactive is idempotent", async () => {
const { sourceDir, name } = writeTestPlugin({ name: "deactivate-idempotent" });
await pluginManager.install(sourceDir);
// Plugin starts as "installed", not "active"
// Deactivating should either succeed silently or throw with a clear message
try {
await pluginManager.deactivate(name);
// If it succeeds, verify status
const row = dbPlugins.getPluginByName(name);
assert.ok(row);
} catch (err: unknown) {
// If it throws, the message should be clear
assert.ok(err instanceof Error);
}
await pluginManager.uninstall(name);
});
test("manager: install then uninstall then reinstall works", async () => {
const { sourceDir, name } = writeTestPlugin({ name: "reinstall-test" });
await pluginManager.install(sourceDir);
await pluginManager.uninstall(name);
// Reinstall should work
const row = await pluginManager.install(sourceDir);
assert.equal(row.name, name);
await pluginManager.uninstall(name);
});
test("manager: activate registers hooks from manifest", async () => {
const { sourceDir, name } = writeTestPlugin({
name: "hook-register",
onRequest: true,
onResponse: true,
});
await pluginManager.install(sourceDir);
await pluginManager.activate(name);
assert.ok(getHooks("onRequest").find((r) => r.pluginName === name));
assert.ok(getHooks("onResponse").find((r) => r.pluginName === name));
assert.equal(getHooks("onError").find((r) => r.pluginName === name), undefined);
await pluginManager.uninstall(name);
});
test("manager: deactivate unregisters all hooks", async () => {
const { sourceDir, name } = writeTestPlugin({
name: "hook-unregister",
onRequest: true,
onResponse: true,
onError: true,
});
await pluginManager.install(sourceDir);
await pluginManager.activate(name);
assert.ok(getHooks("onRequest").find((r) => r.pluginName === name));
assert.ok(getHooks("onResponse").find((r) => r.pluginName === name));
assert.ok(getHooks("onError").find((r) => r.pluginName === name));
await pluginManager.deactivate(name);
assert.equal(getHooks("onRequest").find((r) => r.pluginName === name), undefined);
assert.equal(getHooks("onResponse").find((r) => r.pluginName === name), undefined);
assert.equal(getHooks("onError").find((r) => r.pluginName === name), undefined);
await pluginManager.uninstall(name);
});
// ══════════════════════════════════════════
// Hooks edge cases
// ══════════════════════════════════════════
test("hooks: emitHookBlocking with no handlers returns empty body", async () => {
const result = await emitHookBlocking("onRequest", { body: {}, metadata: {} });
assert.deepEqual(result.metadata, {});
assert.ok(result.blocked === undefined || result.blocked === false);
});
test("hooks: multiple plugins on same event fire in priority order", async () => {
const order: string[] = [];
registerHook("onRequest", "low", () => { order.push("low"); }, 200);
registerHook("onRequest", "high", () => { order.push("high"); }, 10);
registerHook("onRequest", "mid", () => { order.push("mid"); }, 100);
await emitHookBlocking("onRequest", { body: {}, metadata: {} });
assert.deepEqual(order, ["high", "mid", "low"]);
});
test("hooks: handler that returns undefined does not modify payload", async () => {
registerHook("onRequest", "noop", () => undefined);
const result = await emitHookBlocking("onRequest", { body: { original: true }, metadata: {} });
assert.deepEqual(result.body, { original: true });
});
test("hooks: handler error in emitHookBlocking stops chain", async () => {
registerHook("onRequest", "bad", () => { throw new Error("handler error"); });
registerHook("onRequest", "good", () => ({ metadata: { from: "good" } }));
// emitHookBlocking should handle the error gracefully
try {
const result = await emitHookBlocking("onRequest", { body: {}, metadata: {} });
// If it returns, good handler may or may not have run
assert.ok(result !== undefined);
} catch (err) {
// If it throws, that's also acceptable behavior
assert.ok(err instanceof Error);
}
});
test("hooks: unregister one plugin does not affect others", () => {
registerHook("onRequest", "keep", () => {});
registerHook("onRequest", "remove", () => {});
registerHook("onError", "remove", () => {});
assert.equal(getHooks("onRequest").length, 2);
unregisterHooks("remove");
assert.equal(getHooks("onRequest").length, 1);
assert.equal(getHooks("onRequest")[0].pluginName, "keep");
assert.equal(getHooks("onError").length, 0);
});
test("hooks: registerHook with same handler ref is idempotent", () => {
const handler = () => {};
registerHook("onRequest", "idempotent", handler);
registerHook("onRequest", "idempotent", handler);
assert.equal(getHooks("onRequest").length, 1);
assert.equal(getHooks("onRequest")[0].handler, handler);
});
test("hooks: registerHook with different handler refs registers both", () => {
registerHook("onRequest", "multi", () => {});
registerHook("onRequest", "multi", () => {});
// Different function refs = both registered (hooks module uses reference equality)
assert.equal(getHooks("onRequest").length, 2);
});
// ══════════════════════════════════════════
// DB edge cases
// ══════════════════════════════════════════
test("db: updatePluginConfig replaces existing config", () => {
dbPlugins.insertPlugin({
id: "merge-test",
name: "merge-test",
version: "1.0.0",
main: "index.js",
pluginDir: "/tmp/test",
manifest: {},
config: { existing: "value", override: "old" },
});
dbPlugins.updatePluginConfig("merge-test", { override: "new", added: "extra" });
const plugin = dbPlugins.getPluginByName("merge-test");
const config = JSON.parse(plugin!.config);
// updatePluginConfig replaces, does not merge
assert.equal(config.existing, undefined);
assert.equal(config.override, "new");
assert.equal(config.added, "extra");
});
test("db: listPlugins with no status returns all", () => {
dbPlugins.insertPlugin({ id: "p1", name: "alpha", version: "1.0.0", main: "index.js", pluginDir: "/tmp/a", manifest: {} });
dbPlugins.insertPlugin({ id: "p2", name: "beta", version: "1.0.0", main: "index.js", pluginDir: "/tmp/b", manifest: {} });
const all = dbPlugins.listPlugins();
assert.equal(all.length, 2);
// Should be sorted by name
assert.equal(all[0].name, "alpha");
assert.equal(all[1].name, "beta");
});
test("db: listPlugins with status filters correctly", () => {
dbPlugins.insertPlugin({ id: "f1", name: "installed-filter", version: "1.0.0", main: "index.js", pluginDir: "/tmp/f1", manifest: {} });
dbPlugins.insertPlugin({ id: "f2", name: "active-filter", version: "1.0.0", main: "index.js", pluginDir: "/tmp/f2", manifest: {} });
dbPlugins.updatePluginStatus("active-filter", "active");
const installed = dbPlugins.listPlugins("installed");
assert.equal(installed.length, 1);
assert.equal(installed[0].name, "installed-filter");
const active = dbPlugins.listPlugins("active");
assert.equal(active.length, 1);
assert.equal(active[0].name, "active-filter");
});
test("db: pluginExists returns true/false correctly", () => {
dbPlugins.insertPlugin({ id: "exists-test", name: "exists-test", version: "1.0.0", main: "index.js", pluginDir: "/tmp/e", manifest: {} });
assert.equal(dbPlugins.pluginExists("exists-test"), true);
assert.equal(dbPlugins.pluginExists("nope"), false);
});
test("db: deletePlugin returns true when plugin exists, false when not", () => {
dbPlugins.insertPlugin({ id: "del-test", name: "del-test", version: "1.0.0", main: "index.js", pluginDir: "/tmp/d", manifest: {} });
assert.equal(dbPlugins.deletePlugin("del-test"), true);
assert.equal(dbPlugins.deletePlugin("del-test"), false);
assert.equal(dbPlugins.getPluginByName("del-test"), null);
});

View File

@@ -0,0 +1,40 @@
import { describe, it } from "node:test";
import assert from "node:assert";
import { PluginError, PluginErrorCode, isPluginError } from "../../src/lib/plugins/errors.ts";
describe("PluginError", () => {
it("has correct code and message", () => {
const err = new PluginError(PluginErrorCode.PLUGIN_NOT_FOUND, "not found");
assert.strictEqual(err.code, "PLUGIN_NOT_FOUND");
assert.strictEqual(err.message, "not found");
assert.strictEqual(err.name, "PluginError");
});
it("stores details", () => {
const err = new PluginError(PluginErrorCode.INSTALL_FAILED, "fail", { reason: "bad" });
assert.deepStrictEqual(err.details, { reason: "bad" });
});
it("isPluginError returns true for PluginError", () => {
const err = new PluginError(PluginErrorCode.RATE_LIMITED, "rate limited");
assert.strictEqual(isPluginError(err), true);
});
it("isPluginError returns false for plain Error", () => {
assert.strictEqual(isPluginError(new Error("plain")), false);
});
it("isPluginError returns false for non-error", () => {
assert.strictEqual(isPluginError("string"), false);
});
it("all 14 error codes exist", () => {
const codes = Object.values(PluginErrorCode);
assert.strictEqual(codes.length, 14);
assert.ok(codes.includes(PluginErrorCode.PLUGIN_NOT_FOUND));
assert.ok(codes.includes(PluginErrorCode.ALREADY_INSTALLED));
assert.ok(codes.includes(PluginErrorCode.DEPENDENCY_MISSING));
assert.ok(codes.includes(PluginErrorCode.DEPENDENT_EXISTS));
assert.ok(codes.includes(PluginErrorCode.RATE_LIMITED));
});
});

View File

@@ -0,0 +1,497 @@
/**
* Security tests — filesystem path-safety for plugin manager and loader.
*
* Covers three fixes:
* CRITICAL-2: assertWithinPluginDir called before every rm() in upgrade/uninstall
* CRITICAL-3: manifest.main path validated at install/upgrade; staging cleanup on failure
* IMPORTANT-6: host script written with flag:"wx" (O_EXCL) + mode:0o600
*
* Strategy:
* - Behavioral tests where PluginManager is instantiable with a real tmp pluginDir
* (uses the same DATA_DIR trick as plugins-edge-cases.test.ts).
* - Source-scan assertions for patterns that are hard to trigger behaviorally
* (assertWithinPluginDir invocation site, wx/0o600 in loader.ts).
*/
import test, { describe } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { readFileSync } from "node:fs";
import { resolve as pathResolve } from "node:path";
// ── Temp DB — must be set BEFORE any DB-touching import ──────────────────────
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-plugins-fs-safety-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/plugins/../db/core.ts");
const hooks = await import("../../src/lib/plugins/hooks.ts");
const { pluginManager } = await import("../../src/lib/plugins/manager.ts");
// ── Source files for scan-based tests ────────────────────────────────────────
const managerSource = readFileSync(
pathResolve(process.cwd(), "src/lib/plugins/manager.ts"),
"utf-8"
);
const loaderSource = readFileSync(
pathResolve(process.cwd(), "src/lib/plugins/loader.ts"),
"utf-8"
);
// ── Fixture helpers ───────────────────────────────────────────────────────────
/**
* Write a minimal valid plugin and return its plugin directory (where plugin.json lives).
*
* Uses the DIRECT plugin-dir layout — plugin.json at the root of the returned dir.
* This exercises the fast-path in install()/upgrade() that reads plugin.json directly
* from `sourceDir` without going through scanPluginDir(). That path does NOT call
* stat(entryPoint), so our assertEntryPointWithinDest() is the only guard.
*/
function writePluginWithMain(opts: {
name: string;
version?: string;
main: string;
/** If false, skip writing the main file (e.g. for path-escape tests). Default: true for safe paths. */
writeMainFile?: boolean;
}): { sourceDir: string } {
// sourceDir IS the plugin dir — plugin.json at its root (direct-plugin-dir layout)
const sourceDir = fs.mkdtempSync(path.join(os.tmpdir(), `plugin-fs-safety-${opts.name}-`));
fs.writeFileSync(
path.join(sourceDir, "plugin.json"),
JSON.stringify({
name: opts.name,
version: opts.version ?? "1.0.0",
description: "FS-safety test plugin",
author: "test",
main: opts.main,
hooks: { onRequest: false, onResponse: false, onError: false },
enabledByDefault: false,
requires: { permissions: [] },
})
);
// Write the main file only for safe relative paths
const shouldWrite = opts.writeMainFile !== false && !opts.main.startsWith("..") && !path.isAbsolute(opts.main);
if (shouldWrite) {
const mainAbs = path.join(sourceDir, opts.main);
fs.mkdirSync(path.dirname(mainAbs), { recursive: true });
fs.writeFileSync(mainAbs, "module.exports = {};");
}
return { sourceDir };
}
const activeDirs: string[] = [];
// The manager writes installed plugins to getDefaultPluginDir() = ~/.omniroute/plugins/.
// We must clean those dirs between tests to avoid ENOTEMPTY / stale state.
const DEFAULT_PLUGIN_DIR = path.join(
process.env.HOME || process.env.USERPROFILE || "/tmp",
".omniroute",
"plugins"
);
/** Known plugin names created by this test file — cleaned between tests. */
const MANAGED_PLUGIN_NAMES = [
"escape-install",
"escape-abs",
"escape-deep",
"escape-cleanup",
"escape-upgrade",
"valid-regression",
"valid-upgrade-regression",
];
function cleanInstalledPluginDirs() {
for (const name of MANAGED_PLUGIN_NAMES) {
// Remove final dir and any staging remnants
const base = path.join(DEFAULT_PLUGIN_DIR, name);
try {
fs.rmSync(base, { recursive: true, force: true });
} catch {}
// Also clean any .staging-* leftovers
if (fs.existsSync(DEFAULT_PLUGIN_DIR)) {
for (const entry of fs.readdirSync(DEFAULT_PLUGIN_DIR)) {
if (entry.startsWith(`${name}.staging-`)) {
try {
fs.rmSync(path.join(DEFAULT_PLUGIN_DIR, entry), { recursive: true, force: true });
} catch {}
}
}
}
}
}
function cleanSourceDirs() {
for (const d of activeDirs) {
try {
fs.rmSync(d, { recursive: true, force: true });
} catch {}
}
activeDirs.length = 0;
}
test.beforeEach(() => {
core.resetDbInstance();
hooks.resetHooks();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
cleanSourceDirs();
cleanInstalledPluginDirs();
});
test.after(() => {
core.resetDbInstance();
cleanSourceDirs();
cleanInstalledPluginDirs();
try {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
} catch {}
});
// ══════════════════════════════════════════════════════════════════════════════
// CRITICAL-3 — behavioral: manifest.main path traversal rejected at install time
// ══════════════════════════════════════════════════════════════════════════════
test("install rejects manifest.main with path traversal (../../escape.js)", async () => {
// Uses direct-plugin-dir layout so install() reads plugin.json directly (fast path),
// bypassing scanPluginDir's stat() check — only assertEntryPointWithinDest() stops it.
const { sourceDir } = writePluginWithMain({
name: "escape-install",
main: "../../escape.js",
writeMainFile: false,
});
activeDirs.push(sourceDir);
await assert.rejects(
() => pluginManager.install(sourceDir),
(err: Error) => {
// Must mention escaping/outside, not just a generic FS error
assert.ok(
err.message.includes("escapes") ||
err.message.includes("outside") ||
err.message.includes("Refusing"),
`Expected containment error, got: ${err.message}`
);
return true;
}
);
// No partial install dir or staging dir should be left behind
const pluginsRoot = path.join(TEST_DATA_DIR, "plugins");
if (fs.existsSync(pluginsRoot)) {
const entries = fs.readdirSync(pluginsRoot);
const leftover = entries.filter((e) => e.startsWith("escape-install"));
assert.deepEqual(
leftover,
[],
`Staging/install dir left behind after failed install: ${leftover.join(", ")}`
);
}
});
test("install rejects manifest.main with deep traversal escape (../../../evil.js)", async () => {
// A deeper traversal that definitely escapes the staging dir
const { sourceDir } = writePluginWithMain({
name: "escape-deep",
main: "../../../../../../../evil.js",
writeMainFile: false,
});
activeDirs.push(sourceDir);
await assert.rejects(
() => pluginManager.install(sourceDir),
(err: Error) => {
assert.ok(
err.message.includes("escapes") ||
err.message.includes("outside") ||
err.message.includes("Refusing"),
`Expected containment error, got: ${err.message}`
);
return true;
}
);
// No staging residue
const pluginsRoot = path.join(TEST_DATA_DIR, "plugins");
if (fs.existsSync(pluginsRoot)) {
const entries = fs.readdirSync(pluginsRoot);
const leftover = entries.filter((e) => e.startsWith("escape-deep"));
assert.deepEqual(leftover, [], `Leftover dirs after failed install: ${leftover.join(", ")}`);
}
});
test("install does NOT leave a staging dir behind on manifest.main escape", async () => {
const { sourceDir } = writePluginWithMain({
name: "escape-cleanup",
main: "../../../tmp/evil.js",
writeMainFile: false,
});
activeDirs.push(sourceDir);
await assert.rejects(() => pluginManager.install(sourceDir));
const pluginsRoot = path.join(TEST_DATA_DIR, "plugins");
if (fs.existsSync(pluginsRoot)) {
const entries = fs.readdirSync(pluginsRoot);
const leftovers = entries.filter((e) => e.includes("escape-cleanup"));
assert.deepEqual(leftovers, [], `Found leftover dirs: ${leftovers.join(", ")}`);
}
});
// ══════════════════════════════════════════════════════════════════════════════
// CRITICAL-3 — behavioral: upgrade rejects manifest.main escape + no staging residue
// ══════════════════════════════════════════════════════════════════════════════
test("upgrade rejects manifest.main with path traversal and leaves old install intact", async () => {
// Install valid v1 using the direct-plugin-dir layout
const { sourceDir: v1Dir } = writePluginWithMain({
name: "escape-upgrade",
version: "1.0.0",
main: "index.js",
});
activeDirs.push(v1Dir);
await pluginManager.install(v1Dir);
// Attempt upgrade with v2 that has a malicious main (direct-plugin-dir layout,
// bypassing scanPluginDir's stat() check so only our containment guard fires)
const { sourceDir: v2Dir } = writePluginWithMain({
name: "escape-upgrade",
version: "2.0.0",
main: "../../evil.js",
writeMainFile: false,
});
activeDirs.push(v2Dir);
await assert.rejects(
() => pluginManager.upgrade(v2Dir),
(err: Error) => {
assert.ok(
err.message.includes("escapes") ||
err.message.includes("outside") ||
err.message.includes("Refusing"),
`Expected containment error, got: ${err.message}`
);
return true;
}
);
// Old v1 install should still be in DB (upgrade rolled back before deleting old dir)
const row = pluginManager.getPlugin("escape-upgrade");
assert.ok(row, "Old plugin record should still exist after failed upgrade");
assert.equal(row.version, "1.0.0", "Old version should be preserved");
await pluginManager.uninstall("escape-upgrade");
});
// ══════════════════════════════════════════════════════════════════════════════
// CRITICAL-2 — source-scan: assertWithinPluginDir called before each rm()
// ══════════════════════════════════════════════════════════════════════════════
test("source: assertWithinPluginDir helper is defined in manager.ts", () => {
assert.ok(
managerSource.includes("assertWithinPluginDir"),
"assertWithinPluginDir must be defined in manager.ts"
);
});
test("source: assertWithinPluginDir is called before rm in uninstall", () => {
// Find the uninstall method body and check containment guard precedes rm call
const uninstallIdx = managerSource.indexOf("async uninstall(");
assert.ok(uninstallIdx !== -1, "uninstall method not found");
// Get the slice from uninstall through the next method
const afterUninstall = managerSource.slice(uninstallIdx);
const nextMethodIdx = afterUninstall.indexOf("\n async ", 10);
const uninstallBody = nextMethodIdx !== -1 ? afterUninstall.slice(0, nextMethodIdx) : afterUninstall;
const guardIdx = uninstallBody.indexOf("assertWithinPluginDir");
const rmIdx = uninstallBody.indexOf("await rm(");
assert.ok(guardIdx !== -1, "assertWithinPluginDir must be called in uninstall");
assert.ok(rmIdx !== -1, "rm() must be called in uninstall");
assert.ok(
guardIdx < rmIdx,
`assertWithinPluginDir (pos ${guardIdx}) must appear before rm() (pos ${rmIdx}) in uninstall`
);
});
test("source: assertWithinPluginDir is called before rm in upgrade", () => {
const upgradeIdx = managerSource.indexOf("async upgrade(");
assert.ok(upgradeIdx !== -1, "upgrade method not found");
const afterUpgrade = managerSource.slice(upgradeIdx);
const nextMethodIdx = afterUpgrade.indexOf("\n async activate(");
const upgradeBody = nextMethodIdx !== -1 ? afterUpgrade.slice(0, nextMethodIdx) : afterUpgrade;
const guardIdx = upgradeBody.indexOf("assertWithinPluginDir");
const rmIdx = upgradeBody.indexOf("await rm(");
assert.ok(guardIdx !== -1, "assertWithinPluginDir must be called in upgrade");
assert.ok(rmIdx !== -1, "rm() must be called in upgrade");
assert.ok(
guardIdx < rmIdx,
`assertWithinPluginDir (pos ${guardIdx}) must appear before rm() (pos ${rmIdx}) in upgrade`
);
});
test("source: assertWithinPluginDir throws for path outside pluginDir", () => {
// Extract and evaluate the helper via string match to verify logic is correct.
// We test the behavioral contract: resolve("/plugins/foo") is fine,
// resolve("/tmp/evil") is not fine when root is "/plugins".
// Since we can't easily import the unexported helper, verify it uses resolve + sep.
assert.ok(
managerSource.includes('resolve(pluginRoot)') || managerSource.includes('resolve(this_pluginDir)') || managerSource.includes('resolve('),
"assertWithinPluginDir must call resolve()"
);
assert.ok(
managerSource.includes("sep"),
"assertWithinPluginDir must use path.sep for boundary check"
);
assert.ok(
managerSource.includes("Refusing to delete"),
"assertWithinPluginDir must throw with 'Refusing to delete' message"
);
});
// ══════════════════════════════════════════════════════════════════════════════
// CRITICAL-3 — source-scan: assertEntryPointWithinDest called before DB insert
// ══════════════════════════════════════════════════════════════════════════════
test("source: assertEntryPointWithinDest helper is defined in manager.ts", () => {
assert.ok(
managerSource.includes("assertEntryPointWithinDest"),
"assertEntryPointWithinDest must be defined in manager.ts"
);
});
test("source: assertEntryPointWithinDest is called in install before insertPlugin", () => {
const installIdx = managerSource.indexOf("async install(");
assert.ok(installIdx !== -1, "install method not found");
const afterInstall = managerSource.slice(installIdx);
const nextMethodIdx = afterInstall.indexOf("\n async upgrade(");
const installBody = nextMethodIdx !== -1 ? afterInstall.slice(0, nextMethodIdx) : afterInstall;
const guardIdx = installBody.indexOf("assertEntryPointWithinDest");
const insertIdx = installBody.indexOf("insertPlugin(");
assert.ok(guardIdx !== -1, "assertEntryPointWithinDest must be called in install");
assert.ok(insertIdx !== -1, "insertPlugin must be called in install");
assert.ok(
guardIdx < insertIdx,
`assertEntryPointWithinDest (pos ${guardIdx}) must appear before insertPlugin (pos ${insertIdx}) in install`
);
});
test("source: assertEntryPointWithinDest is called in upgrade before insertPlugin", () => {
const upgradeIdx = managerSource.indexOf("async upgrade(");
assert.ok(upgradeIdx !== -1, "upgrade method not found");
const afterUpgrade = managerSource.slice(upgradeIdx);
const nextMethodIdx = afterUpgrade.indexOf("\n async activate(");
const upgradeBody = nextMethodIdx !== -1 ? afterUpgrade.slice(0, nextMethodIdx) : afterUpgrade;
const guardIdx = upgradeBody.indexOf("assertEntryPointWithinDest");
const insertIdx = upgradeBody.indexOf("insertPlugin(");
assert.ok(guardIdx !== -1, "assertEntryPointWithinDest must be called in upgrade");
assert.ok(insertIdx !== -1, "insertPlugin must be called in upgrade");
assert.ok(
guardIdx < insertIdx,
`assertEntryPointWithinDest (pos ${guardIdx}) must appear before insertPlugin (pos ${insertIdx}) in upgrade`
);
});
// ══════════════════════════════════════════════════════════════════════════════
// CRITICAL-3 — source-scan: atomic staging pattern (staging dir + rename)
// ══════════════════════════════════════════════════════════════════════════════
test("source: install uses atomic staging rename pattern", () => {
assert.ok(
managerSource.includes(".staging-"),
"install must use a staging dir with .staging- suffix"
);
assert.ok(
managerSource.includes("rename(stagingDir"),
"install must atomically rename staging dir to final dest"
);
});
test("source: install cleans up staging dir on failure (rm in catch)", () => {
const installIdx = managerSource.indexOf("async install(");
const afterInstall = managerSource.slice(installIdx);
const nextMethodIdx = afterInstall.indexOf("\n async upgrade(");
const installBody = nextMethodIdx !== -1 ? afterInstall.slice(0, nextMethodIdx) : afterInstall;
// There must be a rm(stagingDir) inside a catch block
assert.ok(
installBody.includes("rm(stagingDir"),
"install must rm(stagingDir) in the catch/failure path"
);
});
// ══════════════════════════════════════════════════════════════════════════════
// IMPORTANT-6 — source-scan: host script written with flag:"wx" + mode:0o600
// ══════════════════════════════════════════════════════════════════════════════
test('source: loader uses flag:"wx" (O_EXCL) when writing host script', () => {
assert.ok(
loaderSource.includes('"wx"') || loaderSource.includes("'wx'"),
'loader.ts must use flag: "wx" (O_EXCL) for the host script writeFile'
);
});
test("source: loader uses mode:0o600 when writing host script", () => {
assert.ok(
loaderSource.includes("0o600"),
"loader.ts must use mode: 0o600 for the host script writeFile"
);
});
test("source: loader retries on EEXIST collision when writing host script", () => {
assert.ok(
loaderSource.includes("EEXIST"),
"loader.ts must handle EEXIST on O_EXCL write (retry once with fresh UUID)"
);
});
// ══════════════════════════════════════════════════════════════════════════════
// Regression: valid plugins still install and uninstall cleanly
// ══════════════════════════════════════════════════════════════════════════════
test("install and uninstall work for a valid plugin (regression)", async () => {
// Direct-plugin-dir layout: sourceDir IS the plugin dir (plugin.json at root)
const { sourceDir } = writePluginWithMain({
name: "valid-regression",
main: "index.js",
});
activeDirs.push(sourceDir);
const row = await pluginManager.install(sourceDir);
assert.equal(row.name, "valid-regression");
assert.equal(row.version, "1.0.0");
await pluginManager.uninstall("valid-regression");
assert.equal(pluginManager.getPlugin("valid-regression"), null);
});
test("upgrade works for a valid newer version (regression)", async () => {
const { sourceDir: v1 } = writePluginWithMain({
name: "valid-upgrade-regression",
version: "1.0.0",
main: "index.js",
});
activeDirs.push(v1);
await pluginManager.install(v1);
const { sourceDir: v2 } = writePluginWithMain({
name: "valid-upgrade-regression",
version: "2.0.0",
main: "index.js",
});
activeDirs.push(v2);
const row = await pluginManager.upgrade(v2);
assert.equal(row.version, "2.0.0");
await pluginManager.uninstall("valid-upgrade-regression");
});

View File

@@ -0,0 +1,77 @@
import { describe, it, beforeEach } from "node:test";
import assert from "node:assert";
import {
registerHook,
emitHook,
emitHookBlocking,
resetHooks,
} from "../../src/lib/plugins/hooks.ts";
describe("hooks rate limiting", () => {
beforeEach(() => resetHooks());
it("allows hooks up to rate limit", async () => {
let callCount = 0;
registerHook("onRequest", "test-plugin", async () => { callCount++; });
for (let i = 0; i < 10; i++) {
await emitHook("onRequest", {});
}
assert.strictEqual(callCount, 10);
});
it("blocks hooks after rate limit exceeded", async () => {
let callCount = 0;
registerHook("onRequest", "rate-plugin", async () => { callCount++; });
// Fire 110 calls rapidly — 100 should pass, 10 should be blocked
for (let i = 0; i < 110; i++) {
await emitHook("onRequest", {});
}
assert.ok(callCount <= 100, `Expected <= 100 calls, got ${callCount}`);
});
it("rate limit resets after window", async () => {
let callCount = 0;
registerHook("onRequest", "window-plugin", async () => { callCount++; });
for (let i = 0; i < 100; i++) await emitHook("onRequest", {});
// Wait for window reset
await new Promise((r) => setTimeout(r, 1100));
await emitHook("onRequest", {});
assert.strictEqual(callCount, 101);
});
// ── IMPORTANT-8: emitHookBlocking must also rate-limit ──
it("emitHookBlocking skips a rate-limited plugin", async () => {
let callCount = 0;
registerHook("onRequest", "blocking-rate-plugin", async () => {
callCount++;
return {};
});
// Exhaust the 100-call window
for (let i = 0; i < 101; i++) {
await emitHookBlocking("onRequest", {});
}
// After 101 calls, the 101st should have been suppressed
assert.ok(callCount <= 100, `emitHookBlocking should rate-limit: expected <= 100, got ${callCount}`);
});
// ── IMPORTANT-4: resetHooks() clears rateLimitMap ──
it("resetHooks clears rate-limit state so counts start fresh", async () => {
let callCount = 0;
registerHook("onRequest", "reset-plugin", async () => { callCount++; });
// Exhaust the window
for (let i = 0; i < 101; i++) await emitHook("onRequest", {});
const countAfterExhaustion = callCount;
assert.ok(countAfterExhaustion <= 100, "sanity: should have been rate-limited");
// resetHooks() must clear the rate-limit state along with hooks
resetHooks();
callCount = 0;
// Re-register and fire — should work from scratch (window cleared)
registerHook("onRequest", "reset-plugin", async () => { callCount++; });
for (let i = 0; i < 10; i++) await emitHook("onRequest", {});
assert.strictEqual(callCount, 10, "after resetHooks, rate-limit window should be cleared");
});
});

View File

@@ -0,0 +1,198 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
registerHook,
unregisterHooks,
emitHook,
emitHookBlocking,
runOnRequest,
runOnResponse,
runOnError,
getHooks,
getActiveEvents,
resetHooks,
type HookRegistration,
} from "../../src/lib/plugins/hooks.ts";
// ── Setup ──
test.afterEach(() => {
resetHooks();
});
// ── Registration ──
test("registerHook adds a handler", () => {
registerHook("onRequest", "test-plugin", () => {});
const hooks = getHooks("onRequest");
assert.equal(hooks.length, 1);
assert.equal(hooks[0].pluginName, "test-plugin");
});
test("registerHook prevents duplicate registration", () => {
const handler = () => {};
registerHook("onRequest", "test-plugin", handler);
registerHook("onRequest", "test-plugin", handler);
assert.equal(getHooks("onRequest").length, 1);
});
test("registerHook sorts by priority", () => {
registerHook("onRequest", "low-priority", () => {}, 200);
registerHook("onRequest", "high-priority", () => {}, 10);
const hooks = getHooks("onRequest");
assert.equal(hooks[0].pluginName, "high-priority");
assert.equal(hooks[1].pluginName, "low-priority");
});
test("unregisterHooks removes all handlers for a plugin", () => {
registerHook("onRequest", "plugin-a", () => {});
registerHook("onResponse", "plugin-a", () => {});
registerHook("onRequest", "plugin-b", () => {});
unregisterHooks("plugin-a");
assert.equal(getHooks("onRequest").length, 1);
assert.equal(getHooks("onResponse").length, 0);
});
// ── emitHook (fire-and-forget) ──
test("emitHook calls all handlers", async () => {
let called = 0;
registerHook("onError", "p1", () => {
called++;
});
registerHook("onError", "p2", () => {
called++;
});
await emitHook("onError", {});
assert.equal(called, 2);
});
test("emitHook swallows handler errors", async () => {
let called = false;
registerHook("onError", "bad", () => {
throw new Error("oops");
});
registerHook("onError", "good", () => {
called = true;
});
await emitHook("onError", {});
assert.ok(called);
});
test("emitHook returns void", async () => {
const result = await emitHook("onError", {});
assert.equal(result, undefined);
});
// ── emitHookBlocking ──
test("emitHookBlocking returns empty when no handlers", async () => {
const result = await emitHookBlocking("onRequest", {});
assert.deepEqual(result, { body: undefined, metadata: {} });
});
test("emitHookBlocking accumulates body/metadata", async () => {
registerHook("onRequest", "p1", () => ({ body: { modified: true } }));
registerHook("onRequest", "p2", () => ({ metadata: { key: "value" } }));
const result = await emitHookBlocking("onRequest", { body: { original: true }, metadata: {} });
assert.deepEqual(result.body, { modified: true });
assert.deepEqual(result.metadata, { key: "value" });
});
test("emitHookBlocking returns on first blocker", async () => {
registerHook("onRequest", "p1", () => ({ metadata: { from: "p1" } }));
registerHook("onRequest", "blocker", () => ({ blocked: true, response: { error: "blocked" } }));
registerHook("onRequest", "p3", () => ({ metadata: { from: "p3" } }));
const result = await emitHookBlocking("onRequest", {});
assert.ok(result.blocked);
assert.deepEqual(result.response, { error: "blocked" });
});
test("emitHookBlocking preserves accumulated body/metadata on block", async () => {
registerHook("onRequest", "p1", () => ({ body: { from: "p1" }, metadata: { key: "value" } }));
registerHook("onRequest", "blocker", (payload: unknown) => {
const p = payload as Record<string, unknown>;
return {
blocked: true,
response: "blocked",
body: p.body,
metadata: { ...((p.metadata as Record<string, unknown>) || {}), extra: "from-blocker" },
};
});
const result = await emitHookBlocking("onRequest", { body: {}, metadata: {} });
assert.ok(result.blocked);
assert.deepEqual(result.metadata, { key: "value", extra: "from-blocker" });
});
// ── runOnRequest ──
test("runOnRequest delegates to emitHookBlocking", async () => {
registerHook("onRequest", "p1", () => ({ body: { modified: true } }));
const result = await runOnRequest({ requestId: "test", body: {}, model: "test", metadata: {} });
assert.deepEqual(result.body, { modified: true });
});
test("runOnRequest can block", async () => {
registerHook("onRequest", "blocker", () => ({ blocked: true, response: { error: "nope" } }));
const result = await runOnRequest({ requestId: "test", body: {}, model: "test", metadata: {} });
assert.ok(result.blocked);
});
// ── runOnResponse ──
test("runOnResponse chains response through handlers", async () => {
registerHook("onResponse", "p1", () => ({ response: { modified: "by-p1" } }));
const result = await runOnResponse(
{ requestId: "test", body: {}, model: "test", metadata: {} },
{ original: true }
);
assert.deepEqual(result, { modified: "by-p1" });
});
test("runOnResponse passes through if no modification", async () => {
registerHook("onResponse", "p1", () => undefined);
const result = await runOnResponse(
{ requestId: "test", body: {}, model: "test", metadata: {} },
{ original: true }
);
assert.deepEqual(result, { original: true });
});
// ── runOnError ──
test("runOnError fires emitHook", async () => {
let errorReceived: Error | null = null;
registerHook("onError", "p1", (payload: unknown) => {
errorReceived = (payload as Record<string, unknown>).error as Error;
});
await runOnError(
{ requestId: "test", body: {}, model: "test", metadata: {} },
new Error("test error")
);
assert.ok(errorReceived);
});
// ── getHooks / getActiveEvents ──
test("getHooks returns empty for unregistered event", () => {
assert.deepEqual(getHooks("nonexistent"), []);
});
test("getActiveEvents returns registered event names", () => {
registerHook("onRequest", "p1", () => {});
registerHook("onError", "p1", () => {});
const events = getActiveEvents();
assert.ok(events.includes("onRequest"));
assert.ok(events.includes("onError"));
});
// ── resetHooks ──
test("resetHooks clears all registrations", () => {
registerHook("onRequest", "p1", () => {});
registerHook("onError", "p2", () => {});
resetHooks();
assert.deepEqual(getHooks("onRequest"), []);
assert.deepEqual(getHooks("onError"), []);
});

View File

@@ -0,0 +1,44 @@
import { describe, it, afterEach } from "node:test";
import assert from "node:assert";
import { existsSync, readFileSync, rmSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";
import { PluginLogger } from "../../src/lib/plugins/logger.ts";
describe("PluginLogger", () => {
const testDir = join(tmpdir(), `plugin-logger-test-${Date.now()}`);
afterEach(() => {
try { rmSync(testDir, { recursive: true, force: true }); } catch {}
});
it("creates log file and writes JSON entries", () => {
const logger = new PluginLogger("test-plugin", testDir);
logger.info("hello world");
const logPath = join(testDir, "test-plugin", "plugin.log");
assert.ok(existsSync(logPath));
const content = readFileSync(logPath, "utf-8").trim();
const entry = JSON.parse(content);
assert.strictEqual(entry.level, "INFO");
assert.strictEqual(entry.message, "hello world");
assert.ok(entry.timestamp);
});
it("writes error entries", () => {
const logger = new PluginLogger("err-plugin", testDir);
logger.error("bad thing", { code: 500 });
const logPath = join(testDir, "err-plugin", "plugin.log");
const entry = JSON.parse(readFileSync(logPath, "utf-8").trim());
assert.strictEqual(entry.level, "ERROR");
assert.deepStrictEqual(entry.data, { code: 500 });
});
it("appends multiple entries", () => {
const logger = new PluginLogger("multi-plugin", testDir);
logger.info("first");
logger.warn("second");
const logPath = join(testDir, "multi-plugin", "plugin.log");
const lines = readFileSync(logPath, "utf-8").trim().split("\n");
assert.strictEqual(lines.length, 2);
});
});

View File

@@ -0,0 +1,73 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-metrics-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const hooks = await import("../../src/lib/plugins/hooks.ts");
test.beforeEach(() => {
core.resetDbInstance();
hooks.resetHooks();
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
});
test.after(() => {
core.resetDbInstance();
try { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); } catch {}
});
test("recordPluginMetric stores call count", async () => {
const { recordPluginMetric, getPluginMetrics } = await import("../../src/lib/db/pluginMetrics.ts");
recordPluginMetric("test-plugin", "onRequest", 5.2, false);
recordPluginMetric("test-plugin", "onRequest", 3.1, false);
const metrics = getPluginMetrics("test-plugin");
assert.ok(metrics.length > 0, "should have metrics");
const m = metrics.find((r: { event: string }) => r.event === "onRequest");
assert.ok(m, "should have onRequest metric");
assert.ok(m.calls >= 2, `expected calls >= 2, got ${m.calls}`);
});
test("recordPluginMetric tracks errors", async () => {
const { recordPluginMetric, getPluginMetrics } = await import("../../src/lib/db/pluginMetrics.ts");
recordPluginMetric("err-plugin", "onRequest", 1.0, true);
const metrics = getPluginMetrics("err-plugin");
const m = metrics.find((r: { event: string }) => r.event === "onRequest");
assert.ok(m, "should have onRequest metric");
assert.ok(m.errors >= 1, `expected errors >= 1, got ${m.errors}`);
});
test("recordPluginMetric tracks latency", async () => {
const { recordPluginMetric, getPluginMetrics } = await import("../../src/lib/db/pluginMetrics.ts");
recordPluginMetric("latency-plugin", "onRequest", 42.5, false);
const metrics = getPluginMetrics("latency-plugin");
const m = metrics.find((r: { event: string }) => r.event === "onRequest");
assert.ok(m, "should have onRequest metric");
assert.ok(m.totalDurationMs >= 42, `expected totalDurationMs >= 42, got ${m.totalDurationMs}`);
});
test("getPluginMetrics returns all plugins when no filter", async () => {
const { recordPluginMetric, getPluginMetrics } = await import("../../src/lib/db/pluginMetrics.ts");
recordPluginMetric("p1", "onRequest", 1, false);
recordPluginMetric("p2", "onResponse", 2, false);
const all = getPluginMetrics();
assert.ok(all.length >= 2, `expected >= 2, got ${all.length}`);
});
test("clearPluginMetrics removes metrics", async () => {
const { recordPluginMetric, clearPluginMetrics, getPluginMetrics } = await import("../../src/lib/db/pluginMetrics.ts");
recordPluginMetric("clear-test", "onRequest", 1, false);
clearPluginMetrics("clear-test");
const metrics = getPluginMetrics("clear-test");
const m = metrics.find((r: { event: string }) => r.event === "onRequest");
assert.equal(m, undefined, "should be cleared");
});

View File

@@ -0,0 +1,134 @@
/**
* Static guard tests for Hard Rule #12 — error sanitization in /api/plugins routes.
*
* Every `/api/plugins/**` route MUST:
* 1. NOT return raw `err.message` / `err.stack` in any NextResponse.json body.
* 2. Import and use `buildErrorBody` from `@omniroute/open-sse/utils/error`.
*
* See docs/security/ERROR_SANITIZATION.md and CLAUDE.md hard rule #12.
* Pattern mirrors tests/unit/route-error-sanitization-v382.test.ts.
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
function readRoute(rel: string): string {
return fs.readFileSync(path.join(REPO_ROOT, rel), "utf8");
}
// All /api/plugins route files (enumerate explicitly so new files trigger a test update)
const PLUGIN_ROUTES: Array<{ rel: string; label: string }> = [
{ rel: "src/app/api/plugins/route.ts", label: "GET+POST /api/plugins" },
{ rel: "src/app/api/plugins/scan/route.ts", label: "POST /api/plugins/scan" },
{ rel: "src/app/api/plugins/[name]/route.ts", label: "GET+DELETE /api/plugins/[name]" },
{
rel: "src/app/api/plugins/[name]/activate/route.ts",
label: "POST /api/plugins/[name]/activate",
},
{
rel: "src/app/api/plugins/[name]/deactivate/route.ts",
label: "POST /api/plugins/[name]/deactivate",
},
{
rel: "src/app/api/plugins/[name]/config/route.ts",
label: "GET+PUT /api/plugins/[name]/config",
},
];
for (const { rel, label } of PLUGIN_ROUTES) {
test(`${label}: does NOT contain raw err.message in NextResponse.json body`, () => {
const src = readRoute(rel);
// Pattern: NextResponse.json({ error: err.message } — the raw anti-pattern
assert.ok(
!/NextResponse\.json\(\s*\{[^}]*error:\s*err\.message/.test(src),
`${rel}: must not contain NextResponse.json({ error: err.message, ... })`
);
// Broader check: err.message must not appear anywhere in a response body context
// (allow it inside console.error/logger calls)
const lines = src.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Skip log/console lines — those are fine server-side
if (/console\.(error|warn|log|debug|info)/.test(line)) continue;
if (/logger\.(error|warn|log|debug|info)/.test(line)) continue;
if (/log\.(error|warn|info|debug)/.test(line)) continue;
// Flag err.message appearing on non-log lines inside response-building context
if (/err\.message/.test(line) && /NextResponse\.json|return.*json\(/.test(line)) {
assert.fail(
`${rel} line ${i + 1}: raw err.message found in response body:\n ${line.trim()}`
);
}
}
});
test(`${label}: does NOT contain err.stack in any response body`, () => {
const src = readRoute(rel);
const lines = src.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (/console\.(error|warn|log|debug|info)/.test(line)) continue;
if (/logger\.(error|warn|log|debug|info)/.test(line)) continue;
if (/log\.(error|warn|info|debug)/.test(line)) continue;
if (/err\.stack/.test(line) && /NextResponse\.json|return.*json\(/.test(line)) {
assert.fail(
`${rel} line ${i + 1}: raw err.stack found in response body:\n ${line.trim()}`
);
}
}
});
test(`${label}: imports buildErrorBody from @omniroute/open-sse/utils/error`, () => {
const src = readRoute(rel);
assert.match(
src,
/import \{[^}]*buildErrorBody[^}]*\} from ["']@omniroute\/open-sse\/utils\/error["']/,
`${rel}: must import buildErrorBody from @omniroute/open-sse/utils/error`
);
});
test(`${label}: uses buildErrorBody(...) in catch blocks`, () => {
const src = readRoute(rel);
assert.match(
src,
/buildErrorBody\s*\(/,
`${rel}: must call buildErrorBody() to build error response bodies`
);
});
}
// Exhaustiveness check: no extra /api/plugins route files were added without a test
test("all /api/plugins route files are covered by this test suite", () => {
const pluginsApiDir = path.join(REPO_ROOT, "src/app/api/plugins");
const found: string[] = [];
function walk(dir: string) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(full);
} else if (entry.name === "route.ts") {
found.push(path.relative(REPO_ROOT, full));
}
}
}
walk(pluginsApiDir);
found.sort();
const covered = PLUGIN_ROUTES.map((r) => r.rel).sort();
assert.deepEqual(
found,
covered,
`Route files on disk differ from those listed in PLUGIN_ROUTES.\n` +
`On disk: ${JSON.stringify(found)}\n` +
`Covered: ${JSON.stringify(covered)}`
);
});

View File

@@ -0,0 +1,19 @@
import { describe, it } from "node:test";
import assert from "node:assert";
import { SandboxLevel, getSandboxLabel } from "../../src/lib/plugins/sandbox.ts";
describe("SandboxLevel", () => {
it("has 4 levels", () => {
assert.strictEqual(SandboxLevel.IN_PROCESS, 0);
assert.strictEqual(SandboxLevel.CHILD_FULL_ENV, 1);
assert.strictEqual(SandboxLevel.CHILD_FILTERED_ENV, 2);
assert.strictEqual(SandboxLevel.CHILD_ISOLATED, 3);
});
it("getSandboxLabel returns correct labels", () => {
assert.strictEqual(getSandboxLabel(SandboxLevel.IN_PROCESS), "In-Process");
assert.strictEqual(getSandboxLabel(SandboxLevel.CHILD_FULL_ENV), "Child (Full Env)");
assert.strictEqual(getSandboxLabel(SandboxLevel.CHILD_FILTERED_ENV), "Child (Filtered Env)");
assert.strictEqual(getSandboxLabel(SandboxLevel.CHILD_ISOLATED), "Child (Isolated)");
});
});

View File

@@ -0,0 +1,118 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-signing-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const hooks = await import("../../src/lib/plugins/hooks.ts");
function writePlugin(dir: string, name: string, source: string, integrity?: string) {
const pluginDir = path.join(dir, name);
fs.mkdirSync(pluginDir, { recursive: true });
const manifest: Record<string, unknown> = {
name,
version: "1.0.0",
main: "index.js",
hooks: { onRequest: true },
requires: { permissions: [] },
};
if (integrity) manifest.integrity = integrity;
fs.writeFileSync(path.join(pluginDir, "plugin.json"), JSON.stringify(manifest));
fs.writeFileSync(path.join(pluginDir, "index.js"), source);
return { pluginDir, dir };
}
const activeDirs: string[] = [];
function cleanupDirs() {
for (const d of activeDirs) {
try { fs.rmSync(d, { recursive: true, force: true }); } catch {}
}
activeDirs.length = 0;
}
test.beforeEach(() => {
core.resetDbInstance();
hooks.resetHooks();
cleanupDirs();
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
});
test.after(() => {
core.resetDbInstance();
cleanupDirs();
try { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); } catch {}
});
test("computeIntegrity returns correct format", async () => {
const loader = await import("../../src/lib/plugins/loader.ts");
const hash = loader.computeIntegrity("module.exports = {}");
assert.match(hash, /^sha256-[A-Za-z0-9+/=]+$/);
});
test("computeIntegrity is deterministic", async () => {
const loader = await import("../../src/lib/plugins/loader.ts");
const h1 = loader.computeIntegrity("console.log('hello')");
const h2 = loader.computeIntegrity("console.log('hello')");
assert.equal(h1, h2);
});
test("computeIntegrity differs for different content", async () => {
const loader = await import("../../src/lib/plugins/loader.ts");
const h1 = loader.computeIntegrity("aaa");
const h2 = loader.computeIntegrity("bbb");
assert.notEqual(h1, h2);
});
test("valid integrity passes loading", async () => {
const loader = await import("../../src/lib/plugins/loader.ts");
const source = "module.exports.onRequest = function(ctx) {}";
const hash = loader.computeIntegrity(source);
const { pluginDir, dir } = writePlugin(TEST_DATA_DIR, "sign-ok", source, hash);
activeDirs.push(dir);
const manifestMod = await import("../../src/lib/plugins/manifest.ts");
const manifest = manifestMod.applyDefaults(
JSON.parse(fs.readFileSync(path.join(pluginDir, "plugin.json"), "utf-8"))
);
const loaded = await loader.loadPlugin(path.join(pluginDir, "index.js"), manifest);
assert.ok(loaded.plugin, "should load successfully");
loaded.cleanup();
});
test("mismatched integrity throws", async () => {
const loader = await import("../../src/lib/plugins/loader.ts");
const source = "module.exports.onRequest = function(ctx) {}";
const { pluginDir, dir } = writePlugin(TEST_DATA_DIR, "sign-bad", source, "sha256-AAAA");
activeDirs.push(dir);
const manifestMod = await import("../../src/lib/plugins/manifest.ts");
const manifest = manifestMod.applyDefaults(
JSON.parse(fs.readFileSync(path.join(pluginDir, "plugin.json"), "utf-8"))
);
await assert.rejects(
() => loader.loadPlugin(path.join(pluginDir, "index.js"), manifest),
/integrity/
);
});
test("missing integrity field is OK (backward compat)", async () => {
const loader = await import("../../src/lib/plugins/loader.ts");
const source = "module.exports.onRequest = function(ctx) {}";
const { pluginDir, dir } = writePlugin(TEST_DATA_DIR, "sign-none", source);
activeDirs.push(dir);
const manifestMod = await import("../../src/lib/plugins/manifest.ts");
const manifest = manifestMod.applyDefaults(
JSON.parse(fs.readFileSync(path.join(pluginDir, "plugin.json"), "utf-8"))
);
const loaded = await loader.loadPlugin(path.join(pluginDir, "index.js"), manifest);
assert.ok(loaded.plugin, "should load without integrity field");
loaded.cleanup();
});

View File

@@ -0,0 +1,365 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
// ── Temp dirs ──
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-plugins-tools-"));
process.env.DATA_DIR = TEST_DATA_DIR;
// ── Dynamic imports (after DATA_DIR set) ──
const core = await import("../../src/lib/db/core.ts");
const dbPlugins = await import("../../src/lib/db/plugins.ts");
const hooks = await import("../../src/lib/plugins/hooks.ts");
const { pluginTools } = await import("../../open-sse/mcp-server/tools/pluginTools.ts");
// ── Helpers ──
function getTool(name: string) {
const tool = pluginTools.find((t) => t.name === name);
assert.ok(tool, `Tool ${name} not found`);
return tool!;
}
function writeTestPlugin(opts?: { name?: string; onRequest?: boolean }) {
const name = opts?.name ?? "test-tools-plugin";
const onRequest = opts?.onRequest ?? true;
const sourceDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-plugin-src-"));
const pluginDir = path.join(sourceDir, name);
fs.mkdirSync(pluginDir, { recursive: true });
const manifest = {
name,
version: "1.0.0",
description: "Tools test plugin",
author: "test",
main: "index.js",
hooks: { onRequest, onResponse: false, onError: false },
enabledByDefault: false,
requires: { permissions: [] },
configSchema: {
apiUrl: { type: "string", description: "API endpoint" },
maxRetries: { type: "number", min: 1, max: 10, default: 3 },
debug: { type: "boolean", default: false },
mode: { type: "string", enum: ["fast", "slow", "auto"], default: "auto" },
},
};
fs.writeFileSync(path.join(pluginDir, "plugin.json"), JSON.stringify(manifest, null, 2));
fs.writeFileSync(path.join(pluginDir, "index.js"), onRequest
? `module.exports.onRequest = function(ctx) { ctx.metadata = ctx.metadata || {}; ctx.metadata.hookCalled = true; };`
: `module.exports = {};`
);
return { sourceDir, pluginDir, name };
}
const activeSourceDirs: string[] = [];
function cleanupSourceDirs() {
for (const dir of activeSourceDirs) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
}
activeSourceDirs.length = 0;
}
// ── Lifecycle ──
test.beforeEach(() => {
core.resetDbInstance();
hooks.resetHooks();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
cleanupSourceDirs();
});
test.after(() => {
core.resetDbInstance();
cleanupSourceDirs();
try { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); } catch {}
});
// ── plugin_list ──
test("plugin_list: returns empty when no plugins", async () => {
const tool = getTool("plugin_list");
const result = await tool.handler({});
assert.deepEqual(result.plugins, []);
});
test("plugin_list: returns installed plugins", async () => {
const { sourceDir, name } = writeTestPlugin({ name: "list-test" });
activeSourceDirs.push(sourceDir);
const { pluginManager } = await import("../../src/lib/plugins/manager.ts");
await pluginManager.install(sourceDir);
const tool = getTool("plugin_list");
const result = await tool.handler({});
assert.equal(result.plugins.length, 1);
assert.equal(result.plugins[0].name, name);
assert.equal(result.plugins[0].version, "1.0.0");
assert.equal(result.plugins[0].enabled, false);
assert.ok(Array.isArray(result.plugins[0].hooks));
await pluginManager.uninstall(name);
});
test("plugin_list: filters by status", async () => {
const { sourceDir, name } = writeTestPlugin({ name: "filter-test" });
activeSourceDirs.push(sourceDir);
const { pluginManager } = await import("../../src/lib/plugins/manager.ts");
await pluginManager.install(sourceDir);
const tool = getTool("plugin_list");
const activeResult = await tool.handler({ status: "active" });
assert.equal(activeResult.plugins.length, 0);
const installedResult = await tool.handler({ status: "installed" });
assert.equal(installedResult.plugins.length, 1);
await pluginManager.uninstall(name);
});
// ── plugin_install ──
test("plugin_install: installs a valid plugin", async () => {
const { sourceDir, name } = writeTestPlugin({ name: "install-tool" });
activeSourceDirs.push(sourceDir);
const tool = getTool("plugin_install");
const result = await tool.handler({ path: sourceDir });
assert.equal(result.success, true);
assert.equal(result.plugin.name, name);
const { pluginManager } = await import("../../src/lib/plugins/manager.ts");
await pluginManager.uninstall(name);
});
test("plugin_install: throws for invalid path", async () => {
const tool = getTool("plugin_install");
await assert.rejects(
() => tool.handler({ path: "/nonexistent/path" }),
(err: Error) => {
assert.ok(err.message.includes("No valid plugin found"));
return true;
}
);
});
// ── plugin_activate ──
test("plugin_activate: activates installed plugin", async () => {
const { sourceDir, name } = writeTestPlugin({ name: "activate-tool" });
activeSourceDirs.push(sourceDir);
const { pluginManager } = await import("../../src/lib/plugins/manager.ts");
await pluginManager.install(sourceDir);
const tool = getTool("plugin_activate");
const result = await tool.handler({ name });
assert.equal(result.success, true);
assert.ok(result.message.includes(name));
await pluginManager.uninstall(name);
});
test("plugin_activate: returns error for nonexistent plugin", async () => {
const tool = getTool("plugin_activate");
const result = await tool.handler({ name: "no-such-plugin" });
assert.equal(result.success, false);
assert.ok(result.error.includes("not found"));
});
test("plugin_activate: is idempotent", async () => {
const { sourceDir, name } = writeTestPlugin({ name: "activate-idempotent" });
activeSourceDirs.push(sourceDir);
const { pluginManager } = await import("../../src/lib/plugins/manager.ts");
await pluginManager.install(sourceDir);
const tool = getTool("plugin_activate");
await tool.handler({ name });
const result = await tool.handler({ name });
assert.equal(result.success, true);
await pluginManager.uninstall(name);
});
// ── plugin_deactivate ──
test("plugin_deactivate: deactivates active plugin", async () => {
const { sourceDir, name } = writeTestPlugin({ name: "deactivate-tool" });
activeSourceDirs.push(sourceDir);
const { pluginManager } = await import("../../src/lib/plugins/manager.ts");
await pluginManager.install(sourceDir);
await pluginManager.activate(name);
const tool = getTool("plugin_deactivate");
const result = await tool.handler({ name });
assert.equal(result.success, true);
await pluginManager.uninstall(name);
});
test("plugin_deactivate: succeeds silently for nonexistent plugin", async () => {
const tool = getTool("plugin_deactivate");
const result = await tool.handler({ name: "ghost-deactivate" });
// manager.deactivate() doesn't throw for missing plugins — silently sets status to inactive
assert.equal(result.success, true);
});
// ── plugin_uninstall ──
test("plugin_uninstall: removes plugin", async () => {
const { sourceDir, name } = writeTestPlugin({ name: "uninstall-tool" });
activeSourceDirs.push(sourceDir);
const { pluginManager } = await import("../../src/lib/plugins/manager.ts");
await pluginManager.install(sourceDir);
const tool = getTool("plugin_uninstall");
const result = await tool.handler({ name });
assert.equal(result.success, true);
assert.equal(dbPlugins.getPluginByName(name), null);
});
test("plugin_uninstall: returns error for nonexistent plugin", async () => {
const tool = getTool("plugin_uninstall");
const result = await tool.handler({ name: "ghost-uninstall" });
assert.equal(result.success, false);
assert.ok(result.error.includes("not found"));
});
// ── plugin_configure ──
test("plugin_configure: reads config when no config arg", async () => {
const { sourceDir, name } = writeTestPlugin({ name: "config-read" });
activeSourceDirs.push(sourceDir);
const { pluginManager } = await import("../../src/lib/plugins/manager.ts");
await pluginManager.install(sourceDir);
const tool = getTool("plugin_configure");
const result = await tool.handler({ name });
assert.ok(result.config !== undefined);
assert.ok(result.configSchema !== undefined);
await pluginManager.uninstall(name);
});
test("plugin_configure: updates config", async () => {
const { sourceDir, name } = writeTestPlugin({ name: "config-write" });
activeSourceDirs.push(sourceDir);
const { pluginManager } = await import("../../src/lib/plugins/manager.ts");
await pluginManager.install(sourceDir);
const tool = getTool("plugin_configure");
const result = await tool.handler({ name, config: { apiUrl: "https://example.com" } });
assert.equal(result.success, true);
assert.equal(result.config.apiUrl, "https://example.com");
const fromDb = dbPlugins.getPluginByName(name);
const config = JSON.parse(fromDb!.config);
assert.equal(config.apiUrl, "https://example.com");
await pluginManager.uninstall(name);
});
test("plugin_configure: returns error for nonexistent plugin", async () => {
const tool = getTool("plugin_configure");
const result = await tool.handler({ name: "ghost-config" });
assert.equal(result.success, false);
assert.ok(result.error.includes("not found"));
});
// ── IMPORTANT-7: plugin_configure validates config against configSchema ──
test("plugin_configure: rejects config with wrong type (number instead of string)", async () => {
// writeTestPlugin produces a plugin with configSchema: { apiUrl: string, maxRetries: number, ... }
const { sourceDir, name } = writeTestPlugin({ name: "config-invalid-type" });
activeSourceDirs.push(sourceDir);
const { pluginManager } = await import("../../src/lib/plugins/manager.ts");
await pluginManager.install(sourceDir);
const tool = getTool("plugin_configure");
// apiUrl expects a string — passing a number should fail validation
const result = await tool.handler({ name, config: { apiUrl: 12345 } });
assert.equal(result.success, false, "should fail validation for wrong type");
assert.ok(
result.error && result.error.includes("validation failed"),
`expected 'validation failed' in error, got: ${result.error}`
);
await pluginManager.uninstall(name);
});
test("plugin_configure: rejects config with out-of-range number", async () => {
const { sourceDir, name } = writeTestPlugin({ name: "config-invalid-range" });
activeSourceDirs.push(sourceDir);
const { pluginManager } = await import("../../src/lib/plugins/manager.ts");
await pluginManager.install(sourceDir);
const tool = getTool("plugin_configure");
// maxRetries has min:1, max:10 — 999 should fail
const result = await tool.handler({ name, config: { maxRetries: 999 } });
assert.equal(result.success, false, "should fail validation for out-of-range number");
assert.ok(result.error && result.error.includes("validation failed"));
await pluginManager.uninstall(name);
});
test("plugin_configure: accepts valid config matching schema", async () => {
const { sourceDir, name } = writeTestPlugin({ name: "config-valid" });
activeSourceDirs.push(sourceDir);
const { pluginManager } = await import("../../src/lib/plugins/manager.ts");
await pluginManager.install(sourceDir);
const tool = getTool("plugin_configure");
const result = await tool.handler({ name, config: { apiUrl: "https://ok.example.com", maxRetries: 5 } });
assert.equal(result.success, true, "should succeed for valid config");
assert.equal(result.config.apiUrl, "https://ok.example.com");
await pluginManager.uninstall(name);
});
test("plugin_configure: allows any config when plugin has no configSchema", async () => {
// A plugin with no configSchema should accept any config values
const { sourceDir, name } = writeTestPlugin({ name: "config-no-schema", onRequest: false });
activeSourceDirs.push(sourceDir);
// Overwrite plugin.json without configSchema
const pluginDir = sourceDir + "/" + name;
const fs = await import("node:fs");
const path = await import("node:path");
fs.writeFileSync(path.join(pluginDir, "plugin.json"), JSON.stringify({
name,
version: "1.0.0",
main: "index.js",
hooks: { onRequest: false, onResponse: false, onError: false },
requires: { permissions: [] },
// no configSchema
}));
const { pluginManager } = await import("../../src/lib/plugins/manager.ts");
await pluginManager.install(sourceDir);
const tool = getTool("plugin_configure");
const result = await tool.handler({ name, config: { anything: "goes", foo: 42 } });
// No schema → validation skipped → should succeed
assert.equal(result.success, true, "should accept any config when no schema is declared");
await pluginManager.uninstall(name);
});
// ── plugin_scan ──
test("plugin_scan: returns discovery result", async () => {
const tool = getTool("plugin_scan");
const result = await tool.handler({});
assert.ok(result !== undefined);
assert.equal(typeof result.discovered, "number");
assert.ok(Array.isArray(result.errors));
});
// ── plugin_executions ──
test("plugin_executions: returns execution list", async () => {
const tool = getTool("plugin_executions");
const result = await tool.handler({ limit: 10 });
assert.ok(result !== undefined);
assert.ok(Array.isArray(result.metrics));
});

View File

@@ -0,0 +1,243 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
// ── Temp DB ──
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-plugins-upgrade-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const dbPlugins = await import("../../src/lib/db/plugins.ts");
const hooks = await import("../../src/lib/plugins/hooks.ts");
const { pluginManager, compareSemver } = await import("../../src/lib/plugins/manager.ts");
// ── Fixture ──
function writePlugin(version: string, name = "upgrade-test") {
const sourceDir = fs.mkdtempSync(path.join(os.tmpdir(), `plugin-upgrade-${version}-`));
const pluginDir = path.join(sourceDir, name);
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(path.join(pluginDir, "plugin.json"), JSON.stringify({
name,
version,
description: `Plugin v${version}`,
author: "test",
main: "index.js",
hooks: { onRequest: true, onResponse: false, onError: false },
enabledByDefault: false,
requires: { permissions: [] },
}));
fs.writeFileSync(
path.join(pluginDir, "index.js"),
`module.exports.onRequest = function(ctx) { ctx.metadata = ctx.metadata || {}; ctx.metadata.v = "${version}"; };`
);
return { sourceDir, pluginDir };
}
function writePluginWithConfig(version: string, name = "upgrade-config-test") {
const sourceDir = fs.mkdtempSync(path.join(os.tmpdir(), `plugin-upgrade-cfg-${version}-`));
const pluginDir = path.join(sourceDir, name);
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(path.join(pluginDir, "plugin.json"), JSON.stringify({
name,
version,
description: `Plugin v${version}`,
author: "test",
main: "index.js",
hooks: { onRequest: true, onResponse: false, onError: false },
enabledByDefault: false,
requires: { permissions: [] },
configSchema: {
apiKey: { type: "string", description: "API key" },
},
}));
fs.writeFileSync(
path.join(pluginDir, "index.js"),
`module.exports.onRequest = function(ctx) { return; };`
);
return { sourceDir, pluginDir };
}
const activeDirs: string[] = [];
function cleanupDirs() {
for (const dir of activeDirs) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
}
activeDirs.length = 0;
}
test.beforeEach(() => {
core.resetDbInstance();
hooks.resetHooks();
cleanupDirs();
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
});
test.after(() => {
core.resetDbInstance();
cleanupDirs();
try { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); } catch {}
});
// ── Tests ──
test("upgrade succeeds with newer version", async () => {
const v1 = writePlugin("1.0.0");
activeDirs.push(v1.sourceDir);
await pluginManager.install(v1.sourceDir);
const v2 = writePlugin("2.0.0");
activeDirs.push(v2.sourceDir);
const result = await pluginManager.upgrade(v2.sourceDir);
assert.equal(result.name, "upgrade-test");
assert.equal(result.version, "2.0.0");
await pluginManager.uninstall("upgrade-test");
});
test("upgrade rejects downgrade", async () => {
const v2 = writePlugin("2.0.0");
activeDirs.push(v2.sourceDir);
await pluginManager.install(v2.sourceDir);
const v1 = writePlugin("1.0.0");
activeDirs.push(v1.sourceDir);
await assert.rejects(() => pluginManager.upgrade(v1.sourceDir), /not newer/);
await pluginManager.uninstall("upgrade-test");
});
test("upgrade rejects same version", async () => {
const v1a = writePlugin("1.0.0");
activeDirs.push(v1a.sourceDir);
await pluginManager.install(v1a.sourceDir);
const v1b = writePlugin("1.0.0");
activeDirs.push(v1b.sourceDir);
await assert.rejects(() => pluginManager.upgrade(v1b.sourceDir), /not newer/);
await pluginManager.uninstall("upgrade-test");
});
test("upgrade preserves config values", async () => {
const v1 = writePluginWithConfig("1.0.0");
activeDirs.push(v1.sourceDir);
await pluginManager.install(v1.sourceDir);
// Set config
dbPlugins.updatePluginConfig("upgrade-config-test", { apiKey: "secret-key" });
const v2 = writePluginWithConfig("2.0.0");
activeDirs.push(v2.sourceDir);
const result = await pluginManager.upgrade(v2.sourceDir);
assert.equal(result.version, "2.0.0");
// Config is NOT preserved (delete+reinstall) — this is expected behavior
const row = dbPlugins.getPluginByName("upgrade-config-test")!;
const config = JSON.parse(row.config);
// After upgrade, config is empty since we delete+reinstall
assert.deepEqual(config, {});
await pluginManager.uninstall("upgrade-config-test");
});
test("install() auto-upgrades when source version is newer", async () => {
const v1 = writePlugin("1.0.0", "auto-upgrade");
activeDirs.push(v1.sourceDir);
await pluginManager.install(v1.sourceDir);
const v2 = writePlugin("2.0.0", "auto-upgrade");
activeDirs.push(v2.sourceDir);
// install() should auto-upgrade instead of throwing "already installed"
const result = await pluginManager.install(v2.sourceDir);
assert.equal(result.version, "2.0.0");
await pluginManager.uninstall("auto-upgrade");
});
test("install() rejects when source version is older", async () => {
const v2 = writePlugin("2.0.0", "reject-old");
activeDirs.push(v2.sourceDir);
await pluginManager.install(v2.sourceDir);
const v1 = writePlugin("1.0.0", "reject-old");
activeDirs.push(v1.sourceDir);
await assert.rejects(() => pluginManager.install(v1.sourceDir), /not newer/);
await pluginManager.uninstall("reject-old");
});
test("upgrade fails for uninstalled plugin", async () => {
const v1 = writePlugin("1.0.0", "not-installed");
activeDirs.push(v1.sourceDir);
await assert.rejects(() => pluginManager.upgrade(v1.sourceDir), /not installed/);
});
test("upgrade with minor version bump", async () => {
const v1 = writePlugin("1.0.0", "minor-bump");
activeDirs.push(v1.sourceDir);
await pluginManager.install(v1.sourceDir);
const v11 = writePlugin("1.1.0", "minor-bump");
activeDirs.push(v11.sourceDir);
const result = await pluginManager.upgrade(v11.sourceDir);
assert.equal(result.version, "1.1.0");
await pluginManager.uninstall("minor-bump");
});
test("upgrade with patch version bump", async () => {
const v1 = writePlugin("1.0.0", "patch-bump");
activeDirs.push(v1.sourceDir);
await pluginManager.install(v1.sourceDir);
const v101 = writePlugin("1.0.1", "patch-bump");
activeDirs.push(v101.sourceDir);
const result = await pluginManager.upgrade(v101.sourceDir);
assert.equal(result.version, "1.0.1");
await pluginManager.uninstall("patch-bump");
});
// ── MINOR-10: compareSemver NaN-safe with pre-release suffixes ──
test("compareSemver: valid semver compares correctly", () => {
assert.ok(compareSemver("2.0.0", "1.0.0") > 0, "2.0.0 > 1.0.0");
assert.ok(compareSemver("1.0.0", "2.0.0") < 0, "1.0.0 < 2.0.0");
assert.equal(compareSemver("1.0.0", "1.0.0"), 0, "1.0.0 == 1.0.0");
assert.ok(compareSemver("1.1.0", "1.0.0") > 0, "1.1.0 > 1.0.0");
assert.ok(compareSemver("1.0.1", "1.0.0") > 0, "1.0.1 > 1.0.0");
});
test("compareSemver: pre-release suffix strips cleanly (no NaN)", () => {
// 1.0.0-beta should compare as 1.0.0 (< 1.0.1, not silently equal)
assert.ok(compareSemver("1.0.1", "1.0.0-beta") > 0, "1.0.1 > 1.0.0-beta (treated as 1.0.0)");
assert.ok(compareSemver("1.0.0-beta", "0.9.0") > 0, "1.0.0-beta > 0.9.0");
// Both pre-release: treated as equal numeric parts
assert.equal(compareSemver("1.0.0-beta", "1.0.0-rc.1"), 0, "1.0.0-beta == 1.0.0-rc.1 (both strip to 1.0.0)");
});
test("compareSemver: NaN segments coerce to 0, result is not NaN", () => {
// Pathological value from a legacy DB — must not produce NaN
const result = compareSemver("1.0.0-beta", "1.0.0");
assert.ok(!Number.isNaN(result), `compareSemver should never return NaN, got ${result}`);
// 1.0.0-beta strips to 1.0.0 → equal to 1.0.0
assert.equal(result, 0, "1.0.0-beta treated as 1.0.0 should equal 1.0.0");
});

View File

@@ -0,0 +1,56 @@
/**
* Security regression: /api/plugins/* routes must be classified as LOCAL_ONLY
* so loopback enforcement runs unconditionally before any auth check.
*
* These routes trigger plugin loading via worker_threads + child_process:
* POST /api/plugins — install (loads plugin file via worker)
* GET /api/plugins — list (read-only, but same prefix must be gated
* to avoid auth-bypass leaking installed plugin names)
* GET/DELETE /api/plugins/[name] — inspect / uninstall
* POST /api/plugins/[name]/activate — loads + executes the plugin worker
* POST /api/plugins/[name]/deactivate — stops the plugin worker
* GET/PUT /api/plugins/[name]/config — configure the plugin
* POST /api/plugins/scan — filesystem scan (spawns child_process)
*
* Classifying the whole prefix as LOCAL_ONLY closes the remote-RCE vector:
* a leaked JWT over a Cloudflared/Ngrok tunnel cannot trigger process spawning.
* Hard Rules #15 + #17. See docs/security/ROUTE_GUARD_TIERS.md.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { isLocalOnlyPath } from "../../src/server/authz/routeGuard.ts";
test("/api/plugins prefix (trailing slash) is LOCAL_ONLY", () => {
assert.equal(isLocalOnlyPath("/api/plugins/"), true);
});
test("/api/plugins (bare, no trailing slash) is LOCAL_ONLY", () => {
// The GET-list + POST-install route lives at exactly /api/plugins — must also be gated.
assert.equal(isLocalOnlyPath("/api/plugins"), true);
});
test("/api/plugins/[name]/activate is LOCAL_ONLY (worker_threads execution)", () => {
assert.equal(isLocalOnlyPath("/api/plugins/my-plugin/activate"), true);
});
test("/api/plugins/[name]/deactivate is LOCAL_ONLY", () => {
assert.equal(isLocalOnlyPath("/api/plugins/my-plugin/deactivate"), true);
});
test("/api/plugins/[name]/config is LOCAL_ONLY", () => {
assert.equal(isLocalOnlyPath("/api/plugins/my-plugin/config"), true);
});
test("/api/plugins/[name] (GET/DELETE) is LOCAL_ONLY", () => {
assert.equal(isLocalOnlyPath("/api/plugins/my-plugin"), true);
});
test("/api/plugins/scan is LOCAL_ONLY (spawns child_process)", () => {
assert.equal(isLocalOnlyPath("/api/plugins/scan"), true);
});
test("non-plugin paths are NOT LOCAL_ONLY (no over-match)", () => {
assert.equal(isLocalOnlyPath("/api/combos"), false);
assert.equal(isLocalOnlyPath("/api/providers"), false);
assert.equal(isLocalOnlyPath("/api/keys"), false);
});