/** * 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 = 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 { // 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, configSchema: manifest.configSchema as unknown as Record, 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 { // 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, configSchema: manifest.configSchema as unknown as Record, 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 { 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); } } 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 { 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 { 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, configSchema: discovered.manifest.configSchema as unknown as Record, 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 { 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();