Files
OmniRoute/src/lib/plugins/manager.ts
Paijo 9e7f3cad10 feat(plugins): plugin hook wiring + comprehensive test suite + welcome-banner example (#3045)
Follow-up to the plugins framework (#3041): wires the plugin hooks end-to-end and adds full test coverage.

- Wires `onRequest` / `onResponse` / `onError` hooks into the chat pipeline (`chatCore` now imports from the unified `hooks` registry).
- Loads active plugins on server startup (`pluginManager.loadAll()` in `server-init`) so they survive restarts.
- Ships a `welcome-banner` example plugin (`examples/plugins/`) + test fixture.
- Adds a comprehensive plugin test suite (manifest, db, config, hooks, manager lifecycle, loader IPC, permissions, scanner, welcome-banner e2e).

Integration fixes applied during review:
- `manager.install` now removes an orphaned `destDir` (DB row gone but files left on disk) before the atomic rename, guarded by path containment — it previously failed with `ENOTEMPTY` (a regression surfaced by the new lifecycle test).
- `plugins-db` / `plugins-manager-lifecycle` tests now initialize the DB via the real migration `076` (`getDbInstance`) rather than relying on ambient state, so a missing/renumbered migration fails loudly instead of being masked.

348/348 plugin tests pass; typecheck / cycles clean.

Co-authored-by: oyi77 <14921983+oyi77@users.noreply.github.com>
2026-06-01 16:20:11 -03:00

519 lines
18 KiB
TypeScript

/**
* Plugin manager — lifecycle management for plugins.
*
* Singleton that coordinates scanner, loader, DB, and hook registry.
* Handles install, activate, deactivate, uninstall, scan, and startup loading.
*
* @module plugins/manager
*/
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";
import { loadPlugin, type LoadedPlugin } from "./loader";
import { registerHook, unregisterHooks } from "./hooks";
import {
insertPlugin,
getPluginByName,
listPlugins as dbListPlugins,
updatePluginStatus,
updatePluginConfig,
deletePlugin as dbDeletePlugin,
pluginExists,
type PluginRow,
} from "../db/plugins";
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();
private pluginDir: string;
private constructor() {
this.pluginDir = getDefaultPluginDir();
}
static getInstance(): PluginManager {
if (!PluginManager.instance) {
PluginManager.instance = new PluginManager();
}
return PluginManager.instance;
}
/**
* Install a plugin from a source directory.
* 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)
const { safeValidateManifest } = await import("./manifest");
const { readFile: readFileFs } = await import("fs/promises");
let directPlugin: {
name: string;
manifest: any;
pluginDir: string;
entryPoint: string;
} | null = null;
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) {
const entryPoint = join(sourceDir, result.data.main);
directPlugin = {
name: result.data.name,
manifest: result.data,
pluginDir: sourceDir,
entryPoint,
};
}
} catch {}
const { plugins, errors } = directPlugin
? { plugins: [directPlugin], errors: [] }
: await scanPluginDir(sourceDir);
if (plugins.length === 0) {
throw new Error(
`No valid plugin found in ${sourceDir}: ${errors.map((e) => e.error).join(", ")}`
);
}
const discovered = plugins[0];
const { name, manifest, pluginDir: srcDir } = discovered;
// If already installed, auto-upgrade when source is strictly newer; reject otherwise.
if (pluginExists(name)) {
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`
);
}
// 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 });
// Reaching here means the plugin is not DB-registered (the pluginExists()
// guard above returns/throws otherwise). A destDir still present on disk is
// therefore orphaned (e.g. a crash mid-uninstall left files behind) and would
// make the atomic rename below fail with ENOTEMPTY — remove it first, guarded
// by path containment so we never rm outside the plugin directory.
assertWithinPluginDir(this.pluginDir, destDir);
await rm(destDir, { recursive: true, force: true }).catch(() => {});
await cp(srcDir, stagingDir, { recursive: true });
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,
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.installed", { name, version: manifest.version });
// Auto-activate if enabledByDefault
if (manifest.enabledByDefault) {
await this.activate(name);
}
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.
*/
async activate(name: string): Promise<void> {
const row = getPluginByName(name);
if (!row) throw new Error(`Plugin '${name}' not found`);
if (row.status === "active") return;
const manifest = JSON.parse(row.manifest) as PluginManifestWithDefaults;
// Path traversal guard: use realpath to resolve symlinks
const entryPoint = join(row.pluginDir, manifest.main);
let resolvedPluginDir: string;
try {
resolvedPluginDir = await realpath(row.pluginDir);
} catch {
throw new Error(`Plugin directory '${row.pluginDir}' does not exist`);
}
const resolvedEntry = await realpath(entryPoint).catch(() => null);
if (
!resolvedEntry ||
(!resolvedEntry.startsWith(resolvedPluginDir + "/") && resolvedEntry !== resolvedPluginDir)
) {
throw new Error(`Plugin '${name}' entry point escapes plugin directory`);
}
try {
const loaded = await loadPlugin(entryPoint, manifest);
// Register hooks individually via registerHook
const hookNames = ["onRequest", "onResponse", "onError"] as const;
for (const hookName of hookNames) {
const handler = loaded.plugin[hookName];
if (typeof handler === "function") {
registerHook(hookName, name, handler as (payload: unknown) => void | Promise<void>);
}
}
this.loadedPlugins.set(name, loaded);
updatePluginStatus(name, "active");
log.info("manager.activated", { name });
} catch (err: any) {
updatePluginStatus(name, "error", err.message);
log.error("manager.activate_failed", { name, error: err.message });
throw err;
}
}
/**
* Deactivate a plugin — unregister hooks, update DB.
*/
async deactivate(name: string): Promise<void> {
const loaded = this.loadedPlugins.get(name);
if (loaded) {
unregisterHooks(name);
loaded.cleanup();
this.loadedPlugins.delete(name);
}
updatePluginStatus(name, "inactive");
log.info("manager.deactivated", { name });
}
/**
* Uninstall a plugin — deactivate, delete directory (containment-checked), remove from DB.
*/
async uninstall(name: string): Promise<void> {
const row = getPluginByName(name);
if (!row) throw new Error(`Plugin '${name}' not found`);
// Deactivate first if active
if (row.status === "active") {
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 });
} catch (err: any) {
log.warn("manager.uninstall_dir_error", { name, error: err.message });
}
// Remove from DB
dbDeletePlugin(name);
log.info("manager.uninstalled", { name });
}
/**
* Scan plugin directory and sync with DB.
* Discovers new plugins and marks missing ones.
*/
async scan(): Promise<{ discovered: number; errors: Array<{ name: string; error: string }> }> {
const { plugins, errors } = await scanPluginDir(this.pluginDir);
// Register newly discovered plugins that aren't in DB
for (const discovered of plugins) {
if (!pluginExists(discovered.name)) {
try {
insertPlugin({
id: randomUUID(),
name: discovered.name,
version: discovered.manifest.version,
description: discovered.manifest.description,
author: discovered.manifest.author,
license: discovered.manifest.license,
main: discovered.manifest.main,
source: discovered.manifest.source,
tags: discovered.manifest.tags,
manifest: discovered.manifest as unknown as Record<string, unknown>,
configSchema: discovered.manifest.configSchema as unknown as Record<string, unknown>,
hooks: [
discovered.manifest.hooks.onRequest && "onRequest",
discovered.manifest.hooks.onResponse && "onResponse",
discovered.manifest.hooks.onError && "onError",
].filter(Boolean) as string[],
permissions: discovered.manifest.requires.permissions,
pluginDir: discovered.pluginDir,
enabled: discovered.manifest.enabledByDefault,
});
} catch (err: any) {
errors.push({ name: discovered.name, error: `DB insert failed: ${err.message}` });
}
}
}
return { discovered: plugins.length, errors };
}
/**
* Load all active plugins on startup.
*/
async loadAll(): Promise<void> {
const rows = dbListPlugins("active");
log.info("manager.loadAll", { count: rows.length });
for (const row of rows) {
try {
await this.activate(row.name);
} catch (err: any) {
log.error("manager.loadAll_failed", { name: row.name, error: err.message });
}
}
}
/**
* Get a loaded plugin by name.
*/
getLoaded(name: string): LoadedPlugin | undefined {
return this.loadedPlugins.get(name);
}
/**
* List all plugins from DB.
*/
listAll(): PluginRow[] {
return dbListPlugins();
}
/**
* Get plugin by name from DB.
*/
getPlugin(name: string): PluginRow | null {
return getPluginByName(name);
}
}
export const pluginManager = PluginManager.getInstance();