mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 20:32:20 +03:00
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>
519 lines
18 KiB
TypeScript
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();
|