/** * PluginLoader — discovery, installation, and runtime activation of plugins. * * This service is the entry point for the plugin system's I/O boundary: * * 1. **Discovery** — Scans the local plugin directory * (`~/.paperclip/plugins/`) and `node_modules` for packages matching * the `paperclip-plugin-*` naming convention. Aggregates results with * path-based deduplication. * * 2. **Installation** — `installPlugin()` downloads from npm (or reads a * local path), validates the manifest, checks capability consistency, * and persists the install record. * * 3. **Runtime activation** — `activatePlugin()` wires up a loaded plugin * with all runtime services: resolves its entrypoint, builds * capability-gated host handlers, spawns a worker process, syncs job * declarations, registers event subscriptions, and discovers tools. * * 4. **Shutdown** — `shutdownAll()` gracefully stops all active workers * and unregisters runtime hooks. * * @see PLUGIN_SPEC.md §8 — Plugin Discovery * @see PLUGIN_SPEC.md §10 — Package Contract * @see PLUGIN_SPEC.md §12 — Process Model */ import { existsSync } from "node:fs"; import { readdir, readFile, rm, stat } from "node:fs/promises"; import { execFile } from "node:child_process"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import type { Db } from "@paperclipai/db"; import type { PaperclipPluginManifestV1, PluginLauncherDeclaration, PluginRecord, PluginUiSlotDeclaration, } from "@paperclipai/shared"; import { logger } from "../middleware/logger.js"; import { pluginManifestValidator } from "./plugin-manifest-validator.js"; import { pluginCapabilityValidator } from "./plugin-capability-validator.js"; import { pluginRegistryService } from "./plugin-registry.js"; import type { PluginWorkerManager, WorkerStartOptions, WorkerToHostHandlers } from "./plugin-worker-manager.js"; import type { PluginEventBus } from "./plugin-event-bus.js"; import type { PluginJobScheduler } from "./plugin-job-scheduler.js"; import type { PluginJobStore } from "./plugin-job-store.js"; import type { PluginToolDispatcher } from "./plugin-tool-dispatcher.js"; import type { PluginLifecycleManager } from "./plugin-lifecycle.js"; const execFileAsync = promisify(execFile); const __dirname = path.dirname(fileURLToPath(import.meta.url)); // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- /** * Naming convention for npm-published Paperclip plugins. * Packages matching this pattern are considered Paperclip plugins. * * @see PLUGIN_SPEC.md §10 — Package Contract */ export const NPM_PLUGIN_PACKAGE_PREFIX = "paperclip-plugin-"; /** * Default local plugin directory. The loader scans this directory for * locally-installed plugin packages. * * @see PLUGIN_SPEC.md §8.1 — On-Disk Layout */ export const DEFAULT_LOCAL_PLUGIN_DIR = path.join( os.homedir(), ".paperclip", "plugins", ); const DEV_TSX_LOADER_PATH = path.resolve(__dirname, "../../../cli/node_modules/tsx/dist/loader.mjs"); // --------------------------------------------------------------------------- // Discovery result types // --------------------------------------------------------------------------- /** * A plugin package found during discovery from any source. */ export interface DiscoveredPlugin { /** Absolute path to the root of the npm package directory. */ packagePath: string; /** The npm package name as declared in package.json. */ packageName: string; /** Semver version from package.json. */ version: string; /** Source that found this package. */ source: PluginSource; /** The parsed and validated manifest if available, null if discovery-only. */ manifest: PaperclipPluginManifestV1 | null; } /** * Sources from which plugins can be discovered. * * @see PLUGIN_SPEC.md §8.1 — On-Disk Layout */ export type PluginSource = | "local-filesystem" // ~/.paperclip/plugins/ local directory | "npm" // npm packages matching paperclip-plugin-* convention | "registry"; // future: remote plugin registry URL type ParsedSemver = { major: number; minor: number; patch: number; prerelease: string[]; }; /** * Result of a discovery scan. */ export interface PluginDiscoveryResult { /** Plugins successfully discovered and validated. */ discovered: DiscoveredPlugin[]; /** Packages found but with validation errors. */ errors: Array<{ packagePath: string; packageName: string; error: string }>; /** Source(s) that were scanned. */ sources: PluginSource[]; } function getDeclaredPageRoutePaths(manifest: PaperclipPluginManifestV1): string[] { return (manifest.ui?.slots ?? []) .filter((slot): slot is PluginUiSlotDeclaration => slot.type === "page" && typeof slot.routePath === "string" && slot.routePath.length > 0) .map((slot) => slot.routePath!); } // --------------------------------------------------------------------------- // Loader options // --------------------------------------------------------------------------- /** * Options for the plugin loader service. */ export interface PluginLoaderOptions { /** * Path to the local plugin directory to scan. * Defaults to ~/.paperclip/plugins/ */ localPluginDir?: string; /** * Whether to scan the local filesystem directory for plugins. * Defaults to true. */ enableLocalFilesystem?: boolean; /** * Whether to discover installed npm packages matching the paperclip-plugin-* * naming convention. * Defaults to true. */ enableNpmDiscovery?: boolean; /** * Future: URL of the remote plugin registry to query. * When set, the loader will also fetch available plugins from this endpoint. * Registry support is not yet implemented; this field is reserved. */ registryUrl?: string; } // --------------------------------------------------------------------------- // Install options // --------------------------------------------------------------------------- /** * Options for installing a single plugin package. */ export interface PluginInstallOptions { /** * npm package name to install (e.g. "paperclip-plugin-linear" or "@acme/plugin-linear"). * Either packageName or localPath must be set. */ packageName?: string; /** * Absolute or relative path to a local plugin directory for development installs. * When set, the plugin is loaded from this path without npm install. * Either packageName or localPath must be set. */ localPath?: string; /** * Version specifier passed to npm install (e.g. "^1.2.0", "latest"). * Ignored when localPath is set. */ version?: string; /** * Plugin install directory where packages are managed. * Defaults to the localPluginDir configured on the service. */ installDir?: string; } // --------------------------------------------------------------------------- // Runtime options — services needed for initializing loaded plugins // --------------------------------------------------------------------------- /** * Runtime services passed to the loader for plugin initialization. * * When these are provided, the loader can fully activate plugins (spawn * workers, register event subscriptions, sync jobs, register tools). * When omitted, the loader operates in discovery/install-only mode. * * @see PLUGIN_SPEC.md §8.3 — Install Process * @see PLUGIN_SPEC.md §12 — Process Model */ export interface PluginRuntimeServices { /** Worker process manager for spawning and managing plugin workers. */ workerManager: PluginWorkerManager; /** Event bus for registering plugin event subscriptions. */ eventBus: PluginEventBus; /** Job scheduler for registering plugin cron jobs. */ jobScheduler: PluginJobScheduler; /** Job store for syncing manifest job declarations to the DB. */ jobStore: PluginJobStore; /** Tool dispatcher for registering plugin-contributed agent tools. */ toolDispatcher: PluginToolDispatcher; /** Lifecycle manager for state transitions and worker lifecycle events. */ lifecycleManager: PluginLifecycleManager; /** * Factory that creates worker-to-host RPC handlers for a given plugin. * * The returned handlers service worker→host calls (e.g. state.get, * events.emit, config.get). Each plugin gets its own set of handlers * scoped to its capabilities and plugin ID. */ buildHostHandlers: (pluginId: string, manifest: PaperclipPluginManifestV1) => WorkerToHostHandlers; /** * Host instance information passed to the worker during initialization. * Includes the instance ID and host version. */ instanceInfo: { instanceId: string; hostVersion: string; }; } // --------------------------------------------------------------------------- // Load results // --------------------------------------------------------------------------- /** * Result of activating (loading) a single plugin at runtime. * * Contains the plugin record, activation status, and any error that * occurred during the process. */ export interface PluginLoadResult { /** The plugin record from the database. */ plugin: PluginRecord; /** Whether the plugin was successfully activated. */ success: boolean; /** Error message if activation failed. */ error?: string; /** Which subsystems were registered during activation. */ registered: { /** True if the worker process was started. */ worker: boolean; /** Number of event subscriptions registered (from manifest event declarations). */ eventSubscriptions: number; /** Number of job declarations synced to the database. */ jobs: number; /** Number of webhook endpoints declared in manifest. */ webhooks: number; /** Number of agent tools registered. */ tools: number; }; } /** * Result of activating all ready plugins at server startup. */ export interface PluginLoadAllResult { /** Total number of plugins that were attempted. */ total: number; /** Number of plugins successfully activated. */ succeeded: number; /** Number of plugins that failed to activate. */ failed: number; /** Per-plugin results. */ results: PluginLoadResult[]; } /** * Normalized UI contribution metadata extracted from a plugin manifest. * * The host serves all plugin UI bundles from the manifest's `entrypoints.ui` * directory and currently expects the bundle entry module to be `index.js`. */ export interface PluginUiContributionMetadata { uiEntryFile: string; slots: PluginUiSlotDeclaration[]; launchers: PluginLauncherDeclaration[]; } // --------------------------------------------------------------------------- // Service interface // --------------------------------------------------------------------------- export interface PluginLoader { /** * Discover all available plugins from configured sources. * * This performs a non-destructive scan of all enabled sources and returns * the discovered plugins with their parsed manifests. No installs or DB * writes happen during discovery. * * @param npmSearchDirs - Optional override for node_modules directories to search. * Passed through to discoverFromNpm. When omitted the defaults are used. * * @see PLUGIN_SPEC.md §8.1 — On-Disk Layout * @see PLUGIN_SPEC.md §8.3 — Install Process */ discoverAll(npmSearchDirs?: string[]): Promise; /** * Scan the local filesystem plugin directory for installed plugin packages. * * Reads the plugin directory, attempts to load each subdirectory as an npm * package, and validates the plugin manifest. * * @param dir - Directory to scan (defaults to configured localPluginDir). */ discoverFromLocalFilesystem(dir?: string): Promise; /** * Discover Paperclip plugins installed as npm packages in the current * Node.js environment matching the "paperclip-plugin-*" naming convention. * * Looks for packages in node_modules that match the naming convention. * * @param searchDirs - node_modules directories to search (defaults to process cwd resolution). */ discoverFromNpm(searchDirs?: string[]): Promise; /** * Load and parse the plugin manifest from a package directory. * * Reads the package.json, finds the manifest entrypoint declared under * the "paperclipPlugin.manifest" key, loads the manifest module, and * validates it against the plugin manifest schema. * * Returns null if the package is not a Paperclip plugin. * Throws if the package is a Paperclip plugin but the manifest is invalid. * * @see PLUGIN_SPEC.md §10 — Package Contract */ loadManifest(packagePath: string): Promise; /** * Install a plugin package and register it in the database. * * Follows the install process described in PLUGIN_SPEC.md §8.3: * 1. Resolve npm package / local path. * 2. Install into the plugin directory (npm install). * 3. Read and validate plugin manifest. * 4. Reject incompatible plugin API versions. * 5. Validate manifest capabilities. * 6. Persist install record in Postgres. * 7. Return the discovered plugin for the caller to use. * * Worker spawning and lifecycle management are handled by the caller * (pluginLifecycleManager and the server startup orchestration). * * @see PLUGIN_SPEC.md §8.3 — Install Process */ installPlugin(options: PluginInstallOptions): Promise; /** * Upgrade an already-installed plugin to a newer version. * * Similar to installPlugin, but: * 1. Requires the plugin to already exist in the database. * 2. Uses the existing packageName if not provided in options. * 3. Updates the existing plugin record instead of creating a new one. * 4. Returns the old and new manifests for capability comparison. * * @see PLUGIN_SPEC.md §25.3 — Upgrade Lifecycle */ upgradePlugin(pluginId: string, options: Omit): Promise<{ oldManifest: PaperclipPluginManifestV1; newManifest: PaperclipPluginManifestV1; discovered: DiscoveredPlugin; }>; /** * Check whether a plugin API version is supported by this host. */ isSupportedApiVersion(apiVersion: number): boolean; /** * Remove runtime-managed on-disk install artifacts for a plugin. * * This only cleans files under the managed local plugin directory. Local-path * source checkouts outside that directory are intentionally left alone. */ cleanupInstallArtifacts(plugin: PluginRecord): Promise; /** * Get the local plugin directory this loader is configured to use. */ getLocalPluginDir(): string; // ----------------------------------------------------------------------- // Runtime initialization (requires PluginRuntimeServices) // ----------------------------------------------------------------------- /** * Load and activate all plugins that are in `ready` status. * * This is the main server-startup orchestration method. For each plugin * that is persisted as `ready`, it: * 1. Resolves the worker entrypoint from the manifest. * 2. Spawns the worker process via the worker manager. * 3. Syncs job declarations from the manifest to the `plugin_jobs` table. * 4. Registers the plugin with the job scheduler. * 5. Registers event subscriptions declared in the manifest (scoped via the event bus). * 6. Registers agent tools from the manifest via the tool dispatcher. * * Plugins that fail to activate are marked as `error` in the database. * Activation failures are non-fatal — other plugins continue loading. * * **Requires** `PluginRuntimeServices` to have been provided at construction. * Throws if runtime services are not available. * * @returns Aggregated results for all attempted plugin loads. * * @see PLUGIN_SPEC.md §8.4 — Server-Start Plugin Loading * @see PLUGIN_SPEC.md §12 — Process Model */ loadAll(): Promise; /** * Activate a single plugin that is in `installed` or `ready` status. * * Used after a fresh install (POST /api/plugins/install) or after * enabling a previously disabled plugin. Performs the same subsystem * registration as `loadAll()` but for a single plugin. * * If the plugin is in `installed` status, transitions it to `ready` * via the lifecycle manager before spawning the worker. * * **Requires** `PluginRuntimeServices` to have been provided at construction. * * @param pluginId - UUID of the plugin to activate * @returns The activation result for this plugin * * @see PLUGIN_SPEC.md §8.3 — Install Process */ loadSingle(pluginId: string): Promise; /** * Deactivate a single plugin — stop its worker and unregister all * subsystem registrations (events, jobs, tools). * * Used during plugin disable, uninstall, and before upgrade. Does NOT * change the plugin's status in the database — that is the caller's * responsibility (via the lifecycle manager). * * **Requires** `PluginRuntimeServices` to have been provided at construction. * * @param pluginId - UUID of the plugin to deactivate * @param pluginKey - The plugin key (manifest ID) for scoped cleanup * * @see PLUGIN_SPEC.md §8.5 — Uninstall Process */ unloadSingle(pluginId: string, pluginKey: string): Promise; /** * Stop all managed plugin workers. Called during server shutdown. * * Stops the job scheduler and then stops all workers via the worker * manager. Does NOT change plugin statuses in the database — plugins * remain in `ready` so they are restarted on next boot. * * **Requires** `PluginRuntimeServices` to have been provided at construction. */ shutdownAll(): Promise; /** * Whether runtime services are available for plugin activation. */ hasRuntimeServices(): boolean; } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** * Check whether a package name matches the Paperclip plugin naming convention. * Accepts both the "paperclip-plugin-" prefix and scoped "@scope/plugin-" packages. * * @see PLUGIN_SPEC.md §10 — Package Contract */ export function isPluginPackageName(name: string): boolean { if (name.startsWith(NPM_PLUGIN_PACKAGE_PREFIX)) return true; // Also accept scoped packages like @acme/plugin-linear or @paperclipai/plugin-* if (name.includes("/")) { const localPart = name.split("/")[1] ?? ""; return localPart.startsWith("plugin-"); } return false; } /** * Read and parse a package.json from a directory path. * Returns null if no package.json exists. */ async function readPackageJson( dir: string, ): Promise | null> { const pkgPath = path.join(dir, "package.json"); if (!existsSync(pkgPath)) return null; try { const raw = await readFile(pkgPath, "utf-8"); return JSON.parse(raw) as Record; } catch { return null; } } /** * Resolve the manifest entrypoint from a package.json and package root. * * The spec defines a "paperclipPlugin" key in package.json with a "manifest" * subkey pointing to the manifest module. This helper resolves the path. * * @see PLUGIN_SPEC.md §10 — Package Contract */ function resolveManifestPath( packageRoot: string, pkgJson: Record, ): string | null { const paperclipPlugin = pkgJson["paperclipPlugin"]; if ( paperclipPlugin !== null && typeof paperclipPlugin === "object" && !Array.isArray(paperclipPlugin) ) { const manifestRelPath = (paperclipPlugin as Record)[ "manifest" ]; if (typeof manifestRelPath === "string") { // NOTE: the resolved path is returned as-is even if the file does not yet // exist on disk (e.g. the package has not been built). Callers MUST guard // with existsSync() before passing the path to loadManifestFromPath(). return path.resolve(packageRoot, manifestRelPath); } } // Fallback: look for dist/manifest.js as a convention const conventionalPath = path.join(packageRoot, "dist", "manifest.js"); if (existsSync(conventionalPath)) { return conventionalPath; } // Fallback: look for manifest.js at package root const rootManifestPath = path.join(packageRoot, "manifest.js"); if (existsSync(rootManifestPath)) { return rootManifestPath; } return null; } function parseSemver(version: string): ParsedSemver | null { const match = version.match( /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/, ); if (!match) return null; return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]), prerelease: match[4] ? match[4].split(".") : [], }; } function compareIdentifiers(left: string, right: string): number { const leftIsNumeric = /^\d+$/.test(left); const rightIsNumeric = /^\d+$/.test(right); if (leftIsNumeric && rightIsNumeric) { return Number(left) - Number(right); } if (leftIsNumeric) return -1; if (rightIsNumeric) return 1; return left.localeCompare(right); } function compareSemver(left: string, right: string): number { const leftParsed = parseSemver(left); const rightParsed = parseSemver(right); if (!leftParsed || !rightParsed) { throw new Error(`Invalid semver comparison: '${left}' vs '${right}'`); } const coreOrder = ( ["major", "minor", "patch"] as const ).map((key) => leftParsed[key] - rightParsed[key]).find((delta) => delta !== 0); if (coreOrder) { return coreOrder; } if (leftParsed.prerelease.length === 0 && rightParsed.prerelease.length === 0) { return 0; } if (leftParsed.prerelease.length === 0) return 1; if (rightParsed.prerelease.length === 0) return -1; const maxLength = Math.max(leftParsed.prerelease.length, rightParsed.prerelease.length); for (let index = 0; index < maxLength; index += 1) { const leftId = leftParsed.prerelease[index]; const rightId = rightParsed.prerelease[index]; if (leftId === undefined) return -1; if (rightId === undefined) return 1; const diff = compareIdentifiers(leftId, rightId); if (diff !== 0) return diff; } return 0; } function getMinimumHostVersion(manifest: PaperclipPluginManifestV1): string | undefined { return manifest.minimumHostVersion ?? manifest.minimumPaperclipVersion; } /** * Extract UI contribution metadata from a manifest for route serialization. * * Returns `null` when the plugin does not declare any UI slots or launchers. * Launcher declarations are aggregated from both the legacy top-level * `launchers` field and the preferred `ui.launchers` field. */ export function getPluginUiContributionMetadata( manifest: PaperclipPluginManifestV1, ): PluginUiContributionMetadata | null { const slots = manifest.ui?.slots ?? []; const launchers = [ ...(manifest.launchers ?? []), ...(manifest.ui?.launchers ?? []), ]; if (slots.length === 0 && launchers.length === 0) { return null; } return { uiEntryFile: "index.js", slots, launchers, }; } // --------------------------------------------------------------------------- // Factory // --------------------------------------------------------------------------- /** * Create a PluginLoader service. * * The loader is responsible for plugin discovery, installation, and runtime * activation. It reads plugin packages from the local filesystem and npm, * validates their manifests, registers them in the database, and — when * runtime services are provided — initialises worker processes, event * subscriptions, job schedules, webhook endpoints, and agent tools. * * Usage (discovery & install only): * ```ts * const loader = pluginLoader(db, { enableLocalFilesystem: true }); * * // Discover all available plugins * const result = await loader.discoverAll(); * for (const plugin of result.discovered) { * console.log(plugin.packageName, plugin.manifest?.id); * } * * // Install a specific plugin * const discovered = await loader.installPlugin({ * packageName: "paperclip-plugin-linear", * version: "^1.0.0", * }); * ``` * * Usage (full runtime activation at server startup): * ```ts * const loader = pluginLoader(db, loaderOpts, { * workerManager, * eventBus, * jobScheduler, * jobStore, * toolDispatcher, * lifecycleManager, * buildHostHandlers: (pluginId, manifest) => ({ ... }), * instanceInfo: { instanceId: "inst-1", hostVersion: "1.0.0" }, * }); * * // Load all ready plugins at startup * const loadResult = await loader.loadAll(); * console.log(`Loaded ${loadResult.succeeded}/${loadResult.total} plugins`); * * // Load a single plugin after install * const singleResult = await loader.loadSingle(pluginId); * * // Shutdown all plugin workers on server exit * await loader.shutdownAll(); * ``` * * @see PLUGIN_SPEC.md §8.1 — On-Disk Layout * @see PLUGIN_SPEC.md §8.3 — Install Process * @see PLUGIN_SPEC.md §12 — Process Model */ export function pluginLoader( db: Db, options: PluginLoaderOptions = {}, runtimeServices?: PluginRuntimeServices, ): PluginLoader { const { localPluginDir = DEFAULT_LOCAL_PLUGIN_DIR, enableLocalFilesystem = true, enableNpmDiscovery = true, } = options; const registry = pluginRegistryService(db); const manifestValidator = pluginManifestValidator(); const capabilityValidator = pluginCapabilityValidator(); const log = logger.child({ service: "plugin-loader" }); const hostVersion = runtimeServices?.instanceInfo.hostVersion; async function assertPageRoutePathsAvailable(manifest: PaperclipPluginManifestV1): Promise { const requestedRoutePaths = getDeclaredPageRoutePaths(manifest); if (requestedRoutePaths.length === 0) return; const uniqueRequested = new Set(requestedRoutePaths); if (uniqueRequested.size !== requestedRoutePaths.length) { throw new Error(`Plugin ${manifest.id} declares duplicate page routePath values`); } const installedPlugins = await registry.listInstalled(); for (const plugin of installedPlugins) { if (plugin.pluginKey === manifest.id) continue; const installedManifest = plugin.manifestJson as PaperclipPluginManifestV1 | null; if (!installedManifest) continue; const installedRoutePaths = new Set(getDeclaredPageRoutePaths(installedManifest)); const conflictingRoute = requestedRoutePaths.find((routePath) => installedRoutePaths.has(routePath)); if (conflictingRoute) { throw new Error( `Plugin ${manifest.id} routePath "${conflictingRoute}" conflicts with installed plugin ${plugin.pluginKey}`, ); } } } // ------------------------------------------------------------------------- // Internal helpers // ------------------------------------------------------------------------- /** * Fetch a plugin from npm or local path, then parse and validate its manifest. * * This internal helper encapsulates the core plugin retrieval and validation * logic used by both install and upgrade operations. It handles: * 1. Resolving the package from npm or local filesystem. * 2. Installing the package via npm if necessary. * 3. Reading and parsing the plugin manifest. * 4. Validating API version compatibility. * 5. Validating manifest capabilities. * * @param installOptions - Options specifying the package to fetch. * @returns A `DiscoveredPlugin` object containing the validated manifest. */ async function fetchAndValidate( installOptions: PluginInstallOptions, ): Promise { const { packageName, localPath, version, installDir } = installOptions; if (!packageName && !localPath) { throw new Error("Either packageName or localPath must be provided"); } const targetInstallDir = installDir ?? localPluginDir; // Step 1 & 2: Resolve and install package let resolvedPackagePath: string; let resolvedPackageName: string; if (localPath) { // Local path install — validate the directory exists const absLocalPath = path.resolve(localPath); if (!existsSync(absLocalPath)) { throw new Error(`Local plugin path does not exist: ${absLocalPath}`); } resolvedPackagePath = absLocalPath; const pkgJson = await readPackageJson(absLocalPath); resolvedPackageName = typeof pkgJson?.["name"] === "string" ? pkgJson["name"] : path.basename(absLocalPath); log.info( { localPath: absLocalPath, packageName: resolvedPackageName }, "plugin-loader: fetching plugin from local path", ); } else { // npm install const spec = version ? `${packageName}@${version}` : packageName!; log.info( { spec, installDir: targetInstallDir }, "plugin-loader: fetching plugin from npm", ); try { // Use execFile (not exec) to avoid shell injection from package name/version. // --ignore-scripts prevents preinstall/install/postinstall hooks from // executing arbitrary code on the host before manifest validation. await execFileAsync( "npm", ["install", spec, "--prefix", targetInstallDir, "--save", "--ignore-scripts"], { timeout: 120_000 }, // 2 minute timeout for npm install ); } catch (err) { throw new Error(`npm install failed for ${spec}: ${String(err)}`); } // Resolve the package path after installation const nodeModulesPath = path.join(targetInstallDir, "node_modules"); resolvedPackageName = packageName!; // Handle scoped packages if (resolvedPackageName.startsWith("@")) { const [scope, name] = resolvedPackageName.split("/"); resolvedPackagePath = path.join(nodeModulesPath, scope!, name!); } else { resolvedPackagePath = path.join(nodeModulesPath, resolvedPackageName); } if (!existsSync(resolvedPackagePath)) { throw new Error( `Package directory not found after installation: ${resolvedPackagePath}`, ); } } // Step 3: Read and validate plugin manifest // Note: this.loadManifest (used via current context) const pkgJson = await readPackageJson(resolvedPackagePath); if (!pkgJson) throw new Error(`Missing package.json at ${resolvedPackagePath}`); const manifestPath = resolveManifestPath(resolvedPackagePath, pkgJson); if (!manifestPath || !existsSync(manifestPath)) { throw new Error( `Package ${resolvedPackageName} at ${resolvedPackagePath} does not appear to be a Paperclip plugin (no manifest found).`, ); } const manifest = await loadManifestFromPath(manifestPath); // Step 4: Reject incompatible plugin API versions if (!manifestValidator.getSupportedVersions().includes(manifest.apiVersion)) { throw new Error( `Plugin ${manifest.id} declares apiVersion ${manifest.apiVersion} which is not supported by this host. ` + `Supported versions: ${manifestValidator.getSupportedVersions().join(", ")}`, ); } // Step 5: Validate manifest capabilities are consistent const capResult = capabilityValidator.validateManifestCapabilities(manifest); if (!capResult.allowed) { throw new Error( `Plugin ${manifest.id} manifest has inconsistent capabilities. ` + `Missing required capabilities for declared features: ${capResult.missing.join(", ")}`, ); } await assertPageRoutePathsAvailable(manifest); // Step 6: Reject plugins that require a newer host than the running server const minimumHostVersion = getMinimumHostVersion(manifest); if (minimumHostVersion && hostVersion) { if (compareSemver(hostVersion, minimumHostVersion) < 0) { throw new Error( `Plugin ${manifest.id} requires host version ${minimumHostVersion} or newer, ` + `but this server is running ${hostVersion}`, ); } } // Use the version declared in the manifest (required field per the spec) const resolvedVersion = manifest.version; return { packagePath: resolvedPackagePath, packageName: resolvedPackageName, version: resolvedVersion, source: localPath ? "local-filesystem" : "npm", manifest, }; } /** * Attempt to load and validate a plugin manifest from a resolved path. * Returns the manifest on success or throws with a descriptive error. */ async function loadManifestFromPath( manifestPath: string, ): Promise { let raw: unknown; try { // Dynamic import works for both .js (ESM) and .cjs (CJS) manifests const mod = await import(manifestPath) as Record; // The manifest may be the default export or the module itself raw = mod["default"] ?? mod; } catch (err) { throw new Error( `Failed to load manifest module at ${manifestPath}: ${String(err)}`, ); } return manifestValidator.parseOrThrow(raw); } /** * Build a DiscoveredPlugin from a resolved package directory, or null * if the package is not a Paperclip plugin. */ async function buildDiscoveredPlugin( packagePath: string, source: PluginSource, ): Promise { const pkgJson = await readPackageJson(packagePath); if (!pkgJson) return null; const packageName = typeof pkgJson["name"] === "string" ? pkgJson["name"] : ""; const version = typeof pkgJson["version"] === "string" ? pkgJson["version"] : "0.0.0"; // Determine if this is a plugin package at all const hasPaperclipPlugin = "paperclipPlugin" in pkgJson; const nameMatchesConvention = isPluginPackageName(packageName); if (!hasPaperclipPlugin && !nameMatchesConvention) { return null; } const manifestPath = resolveManifestPath(packagePath, pkgJson); if (!manifestPath || !existsSync(manifestPath)) { // Found a potential plugin package but no manifest entry point — treat // as a discovery-only result with no manifest return { packagePath, packageName, version, source, manifest: null, }; } try { const manifest = await loadManifestFromPath(manifestPath); return { packagePath, packageName, version, source, manifest, }; } catch (err) { // Rethrow with context — callers catch and route to the errors array throw new Error( `Plugin ${packageName}: ${String(err)}`, ); } } // ------------------------------------------------------------------------- // Public API // ------------------------------------------------------------------------- return { // ----------------------------------------------------------------------- // discoverAll // ----------------------------------------------------------------------- async discoverAll(npmSearchDirs?: string[]): Promise { const allDiscovered: DiscoveredPlugin[] = []; const allErrors: Array<{ packagePath: string; packageName: string; error: string }> = []; const sources: PluginSource[] = []; if (enableLocalFilesystem) { sources.push("local-filesystem"); const fsResult = await this.discoverFromLocalFilesystem(); allDiscovered.push(...fsResult.discovered); allErrors.push(...fsResult.errors); } if (enableNpmDiscovery) { sources.push("npm"); const npmResult = await this.discoverFromNpm(npmSearchDirs); // Deduplicate against already-discovered packages (same package path) const existingPaths = new Set(allDiscovered.map((d) => d.packagePath)); for (const plugin of npmResult.discovered) { if (!existingPaths.has(plugin.packagePath)) { allDiscovered.push(plugin); } } allErrors.push(...npmResult.errors); } // Future: registry source (options.registryUrl) if (options.registryUrl) { sources.push("registry"); log.warn( { registryUrl: options.registryUrl }, "plugin-loader: remote registry discovery is not yet implemented", ); } log.info( { discovered: allDiscovered.length, errors: allErrors.length, sources, }, "plugin-loader: discovery complete", ); return { discovered: allDiscovered, errors: allErrors, sources }; }, // ----------------------------------------------------------------------- // discoverFromLocalFilesystem // ----------------------------------------------------------------------- async discoverFromLocalFilesystem(dir?: string): Promise { const scanDir = dir ?? localPluginDir; const discovered: DiscoveredPlugin[] = []; const errors: Array<{ packagePath: string; packageName: string; error: string }> = []; if (!existsSync(scanDir)) { log.debug( { dir: scanDir }, "plugin-loader: local plugin directory does not exist, skipping", ); return { discovered, errors, sources: ["local-filesystem"] }; } let entries: string[]; try { entries = await readdir(scanDir); } catch (err) { log.warn({ dir: scanDir, err }, "plugin-loader: failed to read local plugin directory"); return { discovered, errors, sources: ["local-filesystem"] }; } for (const entry of entries) { const entryPath = path.join(scanDir, entry); // Check if entry is a directory let entryStat; try { entryStat = await stat(entryPath); } catch { continue; } if (!entryStat.isDirectory()) continue; // Handle scoped packages: @scope/plugin-name is a subdirectory if (entry.startsWith("@")) { let scopedEntries: string[]; try { scopedEntries = await readdir(entryPath); } catch { continue; } for (const scopedEntry of scopedEntries) { const scopedPath = path.join(entryPath, scopedEntry); try { const scopedStat = await stat(scopedPath); if (!scopedStat.isDirectory()) continue; const plugin = await buildDiscoveredPlugin(scopedPath, "local-filesystem"); if (plugin) discovered.push(plugin); } catch (err) { errors.push({ packagePath: scopedPath, packageName: `${entry}/${scopedEntry}`, error: String(err), }); } } continue; } try { const plugin = await buildDiscoveredPlugin(entryPath, "local-filesystem"); if (plugin) discovered.push(plugin); } catch (err) { const pkgJson = await readPackageJson(entryPath); const packageName = typeof pkgJson?.["name"] === "string" ? pkgJson["name"] : entry; errors.push({ packagePath: entryPath, packageName, error: String(err) }); } } log.debug( { dir: scanDir, discovered: discovered.length, errors: errors.length }, "plugin-loader: local filesystem scan complete", ); return { discovered, errors, sources: ["local-filesystem"] }; }, // ----------------------------------------------------------------------- // discoverFromNpm // ----------------------------------------------------------------------- async discoverFromNpm(searchDirs?: string[]): Promise { const discovered: DiscoveredPlugin[] = []; const errors: Array<{ packagePath: string; packageName: string; error: string }> = []; // Determine the node_modules directories to search. // When searchDirs is undefined OR empty, fall back to the conventional // defaults (cwd/node_modules and localPluginDir/node_modules). // To search nowhere explicitly, pass a non-empty array of non-existent paths. const dirsToSearch: string[] = searchDirs && searchDirs.length > 0 ? searchDirs : []; if (dirsToSearch.length === 0) { // Default: search node_modules relative to the process working directory // and also the local plugin dir's node_modules const cwdNodeModules = path.join(process.cwd(), "node_modules"); const localNodeModules = path.join(localPluginDir, "node_modules"); if (existsSync(cwdNodeModules)) dirsToSearch.push(cwdNodeModules); if (existsSync(localNodeModules)) dirsToSearch.push(localNodeModules); } for (const nodeModulesDir of dirsToSearch) { if (!existsSync(nodeModulesDir)) continue; let entries: string[]; try { entries = await readdir(nodeModulesDir); } catch { continue; } for (const entry of entries) { const entryPath = path.join(nodeModulesDir, entry); // Handle scoped packages (@scope/*) if (entry.startsWith("@")) { let scopedEntries: string[]; try { scopedEntries = await readdir(entryPath); } catch { continue; } for (const scopedEntry of scopedEntries) { const fullName = `${entry}/${scopedEntry}`; if (!isPluginPackageName(fullName)) continue; const scopedPath = path.join(entryPath, scopedEntry); try { const plugin = await buildDiscoveredPlugin(scopedPath, "npm"); if (plugin) discovered.push(plugin); } catch (err) { errors.push({ packagePath: scopedPath, packageName: fullName, error: String(err), }); } } continue; } // Non-scoped packages: check naming convention if (!isPluginPackageName(entry)) continue; let entryStat; try { entryStat = await stat(entryPath); } catch { continue; } if (!entryStat.isDirectory()) continue; try { const plugin = await buildDiscoveredPlugin(entryPath, "npm"); if (plugin) discovered.push(plugin); } catch (err) { const pkgJson = await readPackageJson(entryPath); const packageName = typeof pkgJson?.["name"] === "string" ? pkgJson["name"] : entry; errors.push({ packagePath: entryPath, packageName, error: String(err) }); } } } log.debug( { searchDirs: dirsToSearch, discovered: discovered.length, errors: errors.length }, "plugin-loader: npm discovery scan complete", ); return { discovered, errors, sources: ["npm"] }; }, // ----------------------------------------------------------------------- // loadManifest // ----------------------------------------------------------------------- async loadManifest(packagePath: string): Promise { const pkgJson = await readPackageJson(packagePath); if (!pkgJson) return null; const hasPaperclipPlugin = "paperclipPlugin" in pkgJson; const packageName = typeof pkgJson["name"] === "string" ? pkgJson["name"] : ""; const nameMatchesConvention = isPluginPackageName(packageName); if (!hasPaperclipPlugin && !nameMatchesConvention) { return null; } const manifestPath = resolveManifestPath(packagePath, pkgJson); if (!manifestPath || !existsSync(manifestPath)) return null; return loadManifestFromPath(manifestPath); }, // ----------------------------------------------------------------------- // installPlugin // ----------------------------------------------------------------------- async installPlugin(installOptions: PluginInstallOptions): Promise { const discovered = await fetchAndValidate(installOptions); // Step 6: Persist install record in Postgres (include packagePath for local installs so the worker can be resolved) await registry.install( { packageName: discovered.packageName, packagePath: discovered.source === "local-filesystem" ? discovered.packagePath : undefined, }, discovered.manifest!, ); log.info( { pluginId: discovered.manifest!.id, packageName: discovered.packageName, version: discovered.version, capabilities: discovered.manifest!.capabilities, }, "plugin-loader: plugin installed successfully", ); return discovered; }, // ----------------------------------------------------------------------- // upgradePlugin // ----------------------------------------------------------------------- /** * Upgrade an already-installed plugin to a newer version. * * This method: * 1. Fetches and validates the new plugin package using `fetchAndValidate`. * 2. Ensures the new manifest ID matches the existing plugin ID for safety. * 3. Updates the plugin record in the registry with the new version and manifest. * * @param pluginId - The UUID of the plugin to upgrade. * @param upgradeOptions - Options for the upgrade (packageName, localPath, version). * @returns The old and new manifests, along with the discovery metadata. * @throws {Error} If the plugin is not found or if the new manifest ID differs. */ async upgradePlugin( pluginId: string, upgradeOptions: Omit, ): Promise<{ oldManifest: PaperclipPluginManifestV1; newManifest: PaperclipPluginManifestV1; discovered: DiscoveredPlugin; }> { const plugin = (await registry.getById(pluginId)) as { id: string; packageName: string; packagePath: string | null; manifestJson: PaperclipPluginManifestV1; } | null; if (!plugin) throw new Error(`Plugin not found: ${pluginId}`); const oldManifest = plugin.manifestJson; const { packageName = plugin.packageName, // For local-path installs, fall back to the stored packagePath so // `upgradePlugin` can re-read the manifest from disk without needing // the caller to re-supply the path every time. localPath = plugin.packagePath ?? undefined, version, } = upgradeOptions; log.info( { pluginId, packageName, version, localPath }, "plugin-loader: upgrading plugin", ); // 1. Fetch/Install the new version const discovered = await fetchAndValidate({ packageName, localPath, version, installDir: localPluginDir, }); const newManifest = discovered.manifest!; // 2. Validate it's the same plugin ID if (newManifest.id !== oldManifest.id) { throw new Error( `Upgrade failed: new manifest ID '${newManifest.id}' does not match existing plugin ID '${oldManifest.id}'`, ); } // 3. Detect capability escalation — new capabilities not in the old manifest const oldCaps = new Set(oldManifest.capabilities ?? []); const newCaps = newManifest.capabilities ?? []; const escalated = newCaps.filter((c) => !oldCaps.has(c)); if (escalated.length > 0) { log.warn( { pluginId, escalated, oldVersion: oldManifest.version, newVersion: newManifest.version }, "plugin-loader: upgrade introduces new capabilities — requires admin approval", ); throw new Error( `Upgrade for "${pluginId}" introduces new capabilities that require approval: ${escalated.join(", ")}. ` + `The previous version declared [${[...oldCaps].join(", ")}]. ` + `Please review and approve the capability escalation before upgrading.`, ); } // 4. Update the existing record await registry.update(pluginId, { packageName: discovered.packageName, version: discovered.version, manifest: newManifest, }); return { oldManifest, newManifest, discovered, }; }, // ----------------------------------------------------------------------- // isSupportedApiVersion // ----------------------------------------------------------------------- isSupportedApiVersion(apiVersion: number): boolean { return manifestValidator.getSupportedVersions().includes(apiVersion); }, // ----------------------------------------------------------------------- // cleanupInstallArtifacts // ----------------------------------------------------------------------- async cleanupInstallArtifacts(plugin: PluginRecord): Promise { const managedTargets = new Set(); const managedNodeModulesDir = resolveManagedInstallPackageDir(localPluginDir, plugin.packageName); const directManagedDir = path.join(localPluginDir, plugin.packageName); managedTargets.add(managedNodeModulesDir); if (isPathInsideDir(directManagedDir, localPluginDir)) { managedTargets.add(directManagedDir); } if (plugin.packagePath && isPathInsideDir(plugin.packagePath, localPluginDir)) { managedTargets.add(path.resolve(plugin.packagePath)); } const packageJsonPath = path.join(localPluginDir, "package.json"); if (existsSync(packageJsonPath)) { try { await execFileAsync( "npm", ["uninstall", plugin.packageName, "--prefix", localPluginDir, "--ignore-scripts"], { timeout: 120_000 }, ); } catch (err) { log.warn( { pluginId: plugin.id, pluginKey: plugin.pluginKey, packageName: plugin.packageName, err: err instanceof Error ? err.message : String(err), }, "plugin-loader: npm uninstall failed during cleanup, falling back to direct removal", ); } } for (const target of managedTargets) { if (!existsSync(target)) continue; await rm(target, { recursive: true, force: true }); } }, // ----------------------------------------------------------------------- // getLocalPluginDir // ----------------------------------------------------------------------- getLocalPluginDir(): string { return localPluginDir; }, // ----------------------------------------------------------------------- // hasRuntimeServices // ----------------------------------------------------------------------- hasRuntimeServices(): boolean { return runtimeServices !== undefined; }, // ----------------------------------------------------------------------- // ----------------------------------------------------------------------- // loadAll // ----------------------------------------------------------------------- /** * loadAll — Loads and activates all plugins that are currently in 'ready' status. * * This method is typically called during server startup. It fetches all ready * plugins from the registry and attempts to activate them in parallel using * Promise.allSettled. Failures in individual plugins do not prevent others from loading. * * @returns A promise that resolves with summary statistics of the load operation. */ async loadAll(): Promise { if (!runtimeServices) { throw new Error( "Cannot loadAll: no PluginRuntimeServices provided. " + "Pass runtime services as the third argument to pluginLoader().", ); } log.info("plugin-loader: loading all ready plugins"); // Fetch all plugins in ready status, ordered by installOrder const readyPlugins = (await registry.listByStatus("ready")) as PluginRecord[]; if (readyPlugins.length === 0) { log.info("plugin-loader: no ready plugins to load"); return { total: 0, succeeded: 0, failed: 0, results: [] }; } log.info( { count: readyPlugins.length }, "plugin-loader: found ready plugins to load", ); // Load plugins in parallel const results = await Promise.allSettled( readyPlugins.map((plugin) => activatePlugin(plugin)) ); const loadResults = results.map((r, i) => { if (r.status === "fulfilled") return r.value; return { plugin: readyPlugins[i]!, success: false, error: String(r.reason), registered: { worker: false, eventSubscriptions: 0, jobs: 0, webhooks: 0, tools: 0 }, }; }); const succeeded = loadResults.filter((r) => r.success).length; const failed = loadResults.filter((r) => !r.success).length; log.info( { total: readyPlugins.length, succeeded, failed, }, "plugin-loader: loadAll complete", ); return { total: readyPlugins.length, succeeded, failed, results: loadResults, }; }, // ----------------------------------------------------------------------- // loadSingle // ----------------------------------------------------------------------- /** * loadSingle — Loads and activates a single plugin by its ID. * * This method retrieves the plugin from the registry, ensures it's in a valid * state, and then calls activatePlugin to start its worker and register its * capabilities (tools, jobs, etc.). * * @param pluginId - The UUID of the plugin to load. * @returns A promise that resolves with the result of the activation. */ async loadSingle(pluginId: string): Promise { if (!runtimeServices) { throw new Error( "Cannot loadSingle: no PluginRuntimeServices provided. " + "Pass runtime services as the third argument to pluginLoader().", ); } const plugin = (await registry.getById(pluginId)) as PluginRecord | null; if (!plugin) { throw new Error(`Plugin not found: ${pluginId}`); } // If the plugin is in 'installed' status, transition it to 'ready' first. // lifecycleManager.load() transitions the status AND activates the plugin // via activateReadyPlugin() → loadSingle() (recursive call with 'ready' // status) → activatePlugin(). We must NOT call activatePlugin() again here, // as that would double-start the worker and duplicate registrations. if (plugin.status === "installed") { await runtimeServices.lifecycleManager.load(pluginId); const updated = (await registry.getById(pluginId)) as PluginRecord | null; if (!updated) throw new Error(`Plugin not found after status update: ${pluginId}`); return { plugin: updated, success: true, registered: { worker: true, eventSubscriptions: 0, jobs: 0, webhooks: 0, tools: 0 }, }; } if (plugin.status !== "ready") { throw new Error( `Cannot load plugin in status '${plugin.status}'. ` + `Plugin must be in 'installed' or 'ready' status.`, ); } return activatePlugin(plugin); }, // ----------------------------------------------------------------------- // unloadSingle // ----------------------------------------------------------------------- async unloadSingle(pluginId: string, pluginKey: string): Promise { if (!runtimeServices) { throw new Error( "Cannot unloadSingle: no PluginRuntimeServices provided.", ); } log.info( { pluginId, pluginKey }, "plugin-loader: unloading single plugin", ); const { workerManager, eventBus, jobScheduler, toolDispatcher, } = runtimeServices; // 1. Unregister from job scheduler (cancels in-flight runs) try { await jobScheduler.unregisterPlugin(pluginId); } catch (err) { log.warn( { pluginId, err: err instanceof Error ? err.message : String(err) }, "plugin-loader: failed to unregister from job scheduler (best-effort)", ); } // 2. Clear event subscriptions eventBus.clearPlugin(pluginKey); // 3. Unregister agent tools toolDispatcher.unregisterPluginTools(pluginKey); // 4. Stop the worker process try { if (workerManager.isRunning(pluginId)) { await workerManager.stopWorker(pluginId); } } catch (err) { log.warn( { pluginId, err: err instanceof Error ? err.message : String(err) }, "plugin-loader: failed to stop worker during unload (best-effort)", ); } log.info( { pluginId, pluginKey }, "plugin-loader: plugin unloaded successfully", ); }, // ----------------------------------------------------------------------- // shutdownAll // ----------------------------------------------------------------------- async shutdownAll(): Promise { if (!runtimeServices) { throw new Error( "Cannot shutdownAll: no PluginRuntimeServices provided.", ); } log.info("plugin-loader: shutting down all plugins"); const { workerManager, jobScheduler } = runtimeServices; // 1. Stop the job scheduler tick loop jobScheduler.stop(); // 2. Stop all worker processes await workerManager.stopAll(); log.info("plugin-loader: all plugins shut down"); }, }; // ------------------------------------------------------------------------- // Internal: activatePlugin — shared logic for loadAll and loadSingle // ------------------------------------------------------------------------- /** * Activate a single plugin: spawn its worker, register event subscriptions, * sync jobs, register tools. * * This is the core orchestration logic shared by `loadAll()` and `loadSingle()`. * Failures are caught and reported in the result; the plugin is marked as * `error` in the database when activation fails. */ async function activatePlugin(plugin: PluginRecord): Promise { const manifest = plugin.manifestJson; const pluginId = plugin.id; const pluginKey = plugin.pluginKey; const registered: PluginLoadResult["registered"] = { worker: false, eventSubscriptions: 0, jobs: 0, webhooks: 0, tools: 0, }; // Guard: runtime services must exist (callers already checked) if (!runtimeServices) { return { plugin, success: false, error: "No runtime services available", registered, }; } const { workerManager, eventBus, jobScheduler, jobStore, toolDispatcher, lifecycleManager, buildHostHandlers, instanceInfo, } = runtimeServices; try { log.info( { pluginId, pluginKey, version: plugin.version }, "plugin-loader: activating plugin", ); // ------------------------------------------------------------------ // 1. Resolve worker entrypoint // ------------------------------------------------------------------ const workerEntrypoint = resolveWorkerEntrypoint(plugin, localPluginDir); // ------------------------------------------------------------------ // 2. Build host handlers for this plugin // ------------------------------------------------------------------ const hostHandlers = buildHostHandlers(pluginId, manifest); // ------------------------------------------------------------------ // 3. Retrieve plugin config (if any) // ------------------------------------------------------------------ let config: Record = {}; try { const configRow = await registry.getConfig(pluginId); if (configRow && typeof configRow === "object" && "configJson" in configRow) { config = (configRow as { configJson: Record }).configJson ?? {}; } } catch { // Config may not exist yet — use empty object log.debug({ pluginId }, "plugin-loader: no config found, using empty config"); } // ------------------------------------------------------------------ // 4. Spawn worker process // ------------------------------------------------------------------ const workerOptions: WorkerStartOptions = { entrypointPath: workerEntrypoint, manifest, config, instanceInfo, apiVersion: manifest.apiVersion, hostHandlers, autoRestart: true, }; // Repo-local plugin installs can resolve workspace TS sources at runtime // (for example @paperclipai/shared exports). Run those workers through // the tsx loader so first-party example plugins work in development. if (plugin.packagePath && existsSync(DEV_TSX_LOADER_PATH)) { workerOptions.execArgv = ["--import", DEV_TSX_LOADER_PATH]; } await workerManager.startWorker(pluginId, workerOptions); registered.worker = true; log.info( { pluginId, pluginKey }, "plugin-loader: worker started", ); // ------------------------------------------------------------------ // 5. Sync job declarations and register with scheduler // ------------------------------------------------------------------ const jobDeclarations = manifest.jobs ?? []; if (jobDeclarations.length > 0) { await jobStore.syncJobDeclarations(pluginId, jobDeclarations); await jobScheduler.registerPlugin(pluginId); registered.jobs = jobDeclarations.length; log.info( { pluginId, pluginKey, jobs: jobDeclarations.length }, "plugin-loader: job declarations synced and plugin registered with scheduler", ); } // ------------------------------------------------------------------ // 6. Register event subscriptions // // Note: Event subscriptions are declared at runtime by the plugin // worker via the SDK's ctx.events.on() calls. The event bus manages // per-plugin subscription scoping. Here we ensure the event bus has // a scoped handle ready for this plugin — the actual subscriptions // are registered by the host handler layer when the worker calls // events.subscribe via RPC. // // The bus.forPlugin() call creates the scoped handle if needed; // any previous subscriptions for this plugin are preserved if the // worker is restarting. // ------------------------------------------------------------------ const _scopedBus = eventBus.forPlugin(pluginKey); registered.eventSubscriptions = eventBus.subscriptionCount(pluginKey); log.debug( { pluginId, pluginKey }, "plugin-loader: event bus scoped handle ready", ); // ------------------------------------------------------------------ // 7. Register webhook endpoints (manifest-declared) // // Webhooks are statically declared in the manifest. The actual // endpoint routing is handled by the plugin routes module which // checks the manifest for declared webhooks. No explicit // registration step is needed here — the manifest is persisted // in the DB and the route handler reads it at request time. // // We track the count for the result reporting. // ------------------------------------------------------------------ const webhookDeclarations = manifest.webhooks ?? []; registered.webhooks = webhookDeclarations.length; if (webhookDeclarations.length > 0) { log.info( { pluginId, pluginKey, webhooks: webhookDeclarations.length }, "plugin-loader: webhook endpoints declared in manifest", ); } // ------------------------------------------------------------------ // 8. Register agent tools // ------------------------------------------------------------------ const toolDeclarations = manifest.tools ?? []; if (toolDeclarations.length > 0) { toolDispatcher.registerPluginTools(pluginKey, manifest); registered.tools = toolDeclarations.length; log.info( { pluginId, pluginKey, tools: toolDeclarations.length }, "plugin-loader: agent tools registered", ); } // ------------------------------------------------------------------ // Done — plugin fully activated // ------------------------------------------------------------------ log.info( { pluginId, pluginKey, version: plugin.version, registered, }, "plugin-loader: plugin activated successfully", ); return { plugin, success: true, registered }; } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); log.error( { pluginId, pluginKey, err: errorMessage }, "plugin-loader: failed to activate plugin", ); // Mark the plugin as errored in the database so it is not retried // automatically on next startup without operator intervention. try { await lifecycleManager.markError(pluginId, `Activation failed: ${errorMessage}`); } catch (markErr) { log.error( { pluginId, err: markErr instanceof Error ? markErr.message : String(markErr), }, "plugin-loader: failed to mark plugin as error after activation failure", ); } return { plugin, success: false, error: errorMessage, registered, }; } } } // --------------------------------------------------------------------------- // Worker entrypoint resolution // --------------------------------------------------------------------------- /** * Resolve the absolute path to a plugin's worker entrypoint from its manifest * and known install locations. * * The manifest `entrypoints.worker` field is relative to the package root. * We check the local plugin directory (where the package was installed) and * also the package directory if it was a local-path install. * * @see PLUGIN_SPEC.md §10 — Package Contract */ function resolveWorkerEntrypoint( plugin: PluginRecord & { packagePath?: string | null }, localPluginDir: string, ): string { const manifest = plugin.manifestJson; const workerRelPath = manifest.entrypoints.worker; // For local-path installs we persist the resolved package path; use it first if (plugin.packagePath && existsSync(plugin.packagePath)) { const entrypoint = path.resolve(plugin.packagePath, workerRelPath); if (entrypoint.startsWith(path.resolve(plugin.packagePath)) && existsSync(entrypoint)) { return entrypoint; } } // Try the local plugin directory (standard npm install location) const packageName = plugin.packageName; let packageDir: string; if (packageName.startsWith("@")) { // Scoped package: @scope/plugin-name → localPluginDir/node_modules/@scope/plugin-name const [scope, name] = packageName.split("/"); packageDir = path.join(localPluginDir, "node_modules", scope!, name!); } else { packageDir = path.join(localPluginDir, "node_modules", packageName); } // Also check if the package exists directly under localPluginDir // (for direct local-path installs or symlinked packages) const directDir = path.join(localPluginDir, packageName); // Try in order: node_modules path, direct path for (const dir of [packageDir, directDir]) { const entrypoint = path.resolve(dir, workerRelPath); // Security: ensure entrypoint is actually inside the directory (prevent path traversal) if (!entrypoint.startsWith(path.resolve(dir))) { continue; } if (existsSync(entrypoint)) { return entrypoint; } } // Fallback: try the worker path as-is (absolute or relative to cwd) // ONLY if it's already an absolute path and we trust the manifest (which we've already validated) if (path.isAbsolute(workerRelPath) && existsSync(workerRelPath)) { return workerRelPath; } throw new Error( `Worker entrypoint not found for plugin "${plugin.pluginKey}". ` + `Checked: ${path.resolve(packageDir, workerRelPath)}, ` + `${path.resolve(directDir, workerRelPath)}`, ); } function resolveManagedInstallPackageDir(localPluginDir: string, packageName: string): string { if (packageName.startsWith("@")) { return path.join(localPluginDir, "node_modules", ...packageName.split("/")); } return path.join(localPluginDir, "node_modules", packageName); } function isPathInsideDir(candidatePath: string, parentDir: string): boolean { const resolvedCandidate = path.resolve(candidatePath); const resolvedParent = path.resolve(parentDir); const relative = path.relative(resolvedParent, resolvedCandidate); return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); }