Spaces:
Runtime error
Runtime error
| /** | |
| * 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, emitHook, type HookHandler, type Plugin } 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"); | |
| type LifecycleHookName = Extract< | |
| keyof Plugin, | |
| | "onRequest" | |
| | "onResponse" | |
| | "onError" | |
| | "onInstall" | |
| | "onActivate" | |
| | "onDeactivate" | |
| | "onUninstall" | |
| >; | |
| /** | |
| * 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", | |
| manifest.hooks.onInstall && "onInstall", | |
| manifest.hooks.onActivate && "onActivate", | |
| manifest.hooks.onDeactivate && "onDeactivate", | |
| manifest.hooks.onUninstall && "onUninstall", | |
| ].filter(Boolean) as string[], | |
| permissions: manifest.requires.permissions, | |
| pluginDir: destDir, | |
| enabled: manifest.enabledByDefault, | |
| }); | |
| log.info("manager.installed", { name, version: manifest.version }); | |
| // Fire onInstall lifecycle hook | |
| if (manifest.hooks.onInstall) { | |
| await emitHook("onInstall", { name, version: manifest.version, manifest }); | |
| } | |
| // 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", | |
| manifest.hooks.onInstall && "onInstall", | |
| manifest.hooks.onActivate && "onActivate", | |
| manifest.hooks.onDeactivate && "onDeactivate", | |
| manifest.hooks.onUninstall && "onUninstall", | |
| ].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); | |
| const hookNames: LifecycleHookName[] = [ | |
| "onRequest", | |
| "onResponse", | |
| "onError", | |
| "onInstall", | |
| "onActivate", | |
| "onDeactivate", | |
| "onUninstall", | |
| ]; | |
| for (const hookName of hookNames) { | |
| const handler = loaded.plugin[hookName]; | |
| if (typeof handler === "function") { | |
| registerHook(hookName, name, handler as HookHandler); | |
| } | |
| } | |
| this.loadedPlugins.set(name, loaded); | |
| updatePluginStatus(name, "active"); | |
| // Fire onActivate lifecycle hook | |
| if (manifest.hooks.onActivate) { | |
| await emitHook("onActivate", { name, version: manifest.version, manifest }); | |
| } | |
| 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 — fire onDeactivate, unregister hooks, update DB. | |
| * | |
| * IMPORTANT: onDeactivate MUST fire BEFORE unregisterHooks(name) so the | |
| * plugin's own onDeactivate handler is still registered and can execute | |
| * cleanup logic. See PR #3473 review finding. | |
| */ | |
| async deactivate(name: string): Promise<void> { | |
| const row = getPluginByName(name); | |
| const manifest = row ? (JSON.parse(row.manifest) as PluginManifestWithDefaults) : null; | |
| // Fire onDeactivate lifecycle hook BEFORE unregistering — plugin's handlers | |
| // are still registered at this point so its own onDeactivate can run. | |
| if (manifest?.hooks.onDeactivate) { | |
| await emitHook("onDeactivate", { name, version: manifest.version, manifest }); | |
| } | |
| 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`); | |
| const manifest = JSON.parse(row.manifest) as PluginManifestWithDefaults; | |
| // Deactivate first if active | |
| if (row.status === "active") { | |
| await this.deactivate(name); | |
| } | |
| // Fire onUninstall lifecycle hook (before deleting files) | |
| if (manifest.hooks.onUninstall) { | |
| await emitHook("onUninstall", { name, version: manifest.version, manifest }); | |
| } | |
| // 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", | |
| discovered.manifest.hooks.onInstall && "onInstall", | |
| discovered.manifest.hooks.onActivate && "onActivate", | |
| discovered.manifest.hooks.onDeactivate && "onDeactivate", | |
| discovered.manifest.hooks.onUninstall && "onUninstall", | |
| ].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(); | |