Spaces:
Runtime error
Runtime error
| import { Injectable, OnApplicationBootstrap, OnModuleInit, OnModuleDestroy, Optional } from '@nestjs/common'; | |
| import { ConfigService } from '@nestjs/config'; | |
| import { ModuleRef } from '@nestjs/core'; | |
| import { toNeutralJid, userPart } from '../../engine/identity/wa-id'; | |
| import { LidMappingStoreService } from '../../engine/identity/lid-mapping-store.service'; | |
| import { AsyncLocalStorage } from 'async_hooks'; | |
| import * as fs from 'fs'; | |
| import * as path from 'path'; | |
| import { createLogger } from '../../common/services/logger.service'; | |
| import { HookManager, HookEvent, KNOWN_HOOK_EVENTS, isKnownHookEvent } from '../hooks'; | |
| import { | |
| PluginCapabilityError, | |
| PluginCapabilityPermission, | |
| PluginEngineReadCapability, | |
| PluginManifest, | |
| PluginMessagingCapability, | |
| PluginNetCapability, | |
| PluginConversationsCapability, | |
| PluginHandoverCapability, | |
| PluginMappingsCapability, | |
| PluginInstance, | |
| PluginStatus, | |
| PluginContext, | |
| IPlugin, | |
| PluginType, | |
| PluginLogger, | |
| PluginConfigSchema, | |
| validateIngressManifest, | |
| warnUnauthenticatedIngressRoutes, | |
| } from './plugin.interfaces'; | |
| import { effectiveNetAllow, isNetHostAllowed, performPluginFetch } from './plugin-net'; | |
| import { PluginStorageService } from './plugin-storage.service'; | |
| import { isPluginActiveForSession, resolvePluginConfig } from './plugin-activation'; | |
| import { PluginWorkerHost } from './sandbox/plugin-worker-host'; | |
| import { WorkerThreadChannel } from './sandbox/worker-thread-channel'; | |
| import { dispatchCapabilityVerb } from './sandbox/capability-router'; | |
| import { PluginLogLevel } from './sandbox/protocol'; | |
| import { buildConversationSendFacade, ConversationMediaType } from './conversation-send-facade'; | |
| import { shouldDispatchToPlugin } from './handover-gate'; | |
| import { makeOnWebhookSubscribe } from './webhook-subscribe.util'; | |
| import { registerPluginSearchProvider, unregisterPluginSearchProvider } from './search-provider-registration.util'; | |
| import { INGRESS_DISPATCH_TIMEOUT_MS } from '../../modules/integration/integration.constants'; | |
| import type { MessageService } from '../../modules/message/message.service'; | |
| import type { SessionService } from '../../modules/session/session.service'; | |
| import type { IWhatsAppEngine } from '../../engine/interfaces/whatsapp-engine.interface'; | |
| import type { ConversationMappingService } from '../../modules/integration/conversation-mapping.service'; | |
| import type { PluginInstanceService } from '../../modules/integration/plugin-instance.service'; | |
| import type { IngressJobData } from '../../modules/queue/processors/ingress.processor'; | |
| import type { SearchProviderRegistry } from '../../modules/search/search-provider.registry'; | |
| /** Default per-plugin heap cap for the sandbox worker; an OOM terminates the worker, not the host. */ | |
| const SANDBOX_MAX_OLD_GEN_MB = 256; | |
| /** Time budget for a sandboxed plugin's hook handler before the chain proceeds without it. */ | |
| const SANDBOX_HOOK_TIMEOUT_MS = 5000; | |
| /** A sandboxed plugin's healthCheck must answer within this, else it's reported unhealthy (not hung). */ | |
| const SANDBOX_HEALTH_TIMEOUT_MS = 5000; | |
| /** A sandboxed plugin's search handler must answer within this, else /search fails fast (not hung). */ | |
| const SANDBOX_SEARCH_TIMEOUT_MS = 10000; | |
| /** | |
| * A sandboxed plugin's load()/onLoad/onEnable/onDisable must complete within this, else the worker is | |
| * torn down and the operation fails — a wedged lifecycle can't hang the enable/disable request (and | |
| * the ADMIN HTTP call behind it) forever. Generous on purpose: a slow-but-valid onEnable that opens | |
| * connections should still finish well under it. | |
| */ | |
| const SANDBOX_LIFECYCLE_TIMEOUT_MS = 30000; | |
| /** | |
| * Max concurrent worker-initiated capability calls per sandboxed plugin. A burst beyond this is rejected | |
| * (the plugin sees a thrown Error) rather than amplified into unbounded host-side sends/fetches/writes. | |
| */ | |
| const SANDBOX_MAX_INFLIGHT_CAPS = 32; | |
| /** | |
| * Host process.env keys an untrusted plugin worker is allowed to see. Everything else — secrets like | |
| * API_MASTER_KEY, API_KEY_PEPPER, the DATABASE_/REDIS_ vars, DOCKER_HOST — is withheld. The worker is | |
| * a thread, so it needs no PATH to start and require() resolves via module paths, not env. | |
| */ | |
| const SANDBOX_ENV_ALLOWLIST = ['NODE_ENV', 'NODE_EXTRA_CA_CERTS', 'TZ'] as const; | |
| /** | |
| * Resolve a plugin's `main` entry to an absolute path, asserting it stays inside | |
| * <pluginsDir>/<pluginId>. `main` comes from a user-supplied manifest, so a | |
| * value like '../../etc/passwd' (or an absolute path) must be rejected BEFORE require(). | |
| */ | |
| export function resolvePluginMainPath(pluginsDir: string, pluginId: string, main: string): string { | |
| const base = path.resolve(pluginsDir, pluginId); | |
| const mainPath = path.resolve(base, main); | |
| if (mainPath !== base && !mainPath.startsWith(base + path.sep)) { | |
| throw new Error(`Plugin ${pluginId} main path escapes the plugin directory`); | |
| } | |
| return mainPath; | |
| } | |
| /** | |
| * Build the minimal, allowlisted env for an untrusted plugin worker so it never inherits host secrets. | |
| * Only {@link SANDBOX_ENV_ALLOWLIST} keys are forwarded (unset keys are omitted, not emitted as | |
| * `undefined`), and NODE_ENV defaults to 'production' when the host has none. | |
| */ | |
| export function buildSandboxWorkerEnv(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { | |
| const env: NodeJS.ProcessEnv = {}; | |
| for (const key of SANDBOX_ENV_ALLOWLIST) { | |
| if (source[key] !== undefined) env[key] = source[key]; | |
| } | |
| env.NODE_ENV = source.NODE_ENV ?? 'production'; | |
| return env; | |
| } | |
| /** | |
| * Translate a normalized conversation media send into the concrete MessageService media method for the | |
| * envelope's type. Kept pure (no `this`) so the loader binds it directly and it can be unit-tested in | |
| * isolation. The switch is exhaustive over ConversationMediaType — adding a type without a case is a | |
| * compile error here rather than a silent runtime fall-through. | |
| */ | |
| export function dispatchConversationMedia( | |
| svc: Pick<MessageService, 'sendImage' | 'sendVideo' | 'sendAudio' | 'sendDocument'>, | |
| sessionId: string, | |
| opts: { chatId: string; url: string; type: ConversationMediaType; caption?: string }, | |
| ): Promise<unknown> { | |
| const dto = { chatId: opts.chatId, url: opts.url, caption: opts.caption }; | |
| switch (opts.type) { | |
| case 'image': | |
| return svc.sendImage(sessionId, dto); | |
| case 'video': | |
| return svc.sendVideo(sessionId, dto); | |
| case 'audio': | |
| return svc.sendAudio(sessionId, dto); | |
| case 'voice': | |
| // A voice envelope is a PTT note: sendAudio with ptt classifies it as 'voice' and defaults the | |
| // codec to audio/ogg;opus, so it renders as a WhatsApp voice bubble rather than an audio file. | |
| return svc.sendAudio(sessionId, { ...dto, ptt: true }); | |
| case 'file': | |
| return svc.sendDocument(sessionId, dto); | |
| } | |
| } | |
| // Plugin ids whose bundled-extension code was permanently removed (v0.7 — superseded by the | |
| // marketplace chat-flow / group-translate; also reserved in plugin-installer). A leftover | |
| // directory without a manifest marks them as deleted on disk, so the stale registry entry (which | |
| // still reports them installed/enabled) is pruned on boot. Scoped to these known ids so a | |
| // temporarily-unreadable plugin dir (e.g. an unmounted volume) never loses its persisted config. | |
| const LEGACY_REMOVED_PLUGIN_IDS = new Set(['auto-reply', 'translation']); | |
| /** | |
| * Fill config keys the schema declares a `default` for and that are absent (undefined) in the | |
| * stored config. Seeding happens at LOAD time (fresh installs and every boot), so a plugin whose | |
| * schema fields carry defaults never runs its lifecycle with them missing — the failure class of | |
| * "enable throws: <field> is required/has no value" for defaulted fields. Explicit values — even | |
| * null — are never overwritten, and object/array defaults are deep-cloned so the seeded runtime | |
| * config and the persisted entry can't share a mutable reference. Required fields WITHOUT a | |
| * declared default stay absent on purpose: those need real operator input, not an invented value. | |
| */ | |
| export function seedConfigDefaults( | |
| schema: PluginConfigSchema | undefined, | |
| config: Record<string, unknown>, | |
| ): Record<string, unknown> { | |
| const properties = schema?.properties; | |
| if (!properties) return config; | |
| let seeded: Record<string, unknown> | undefined; | |
| for (const [key, field] of Object.entries(properties)) { | |
| if (config[key] !== undefined || field === null || typeof field !== 'object') continue; | |
| const value = field.default; | |
| if (value === undefined) continue; | |
| if (!seeded) seeded = { ...config }; | |
| seeded[key] = value !== null && typeof value === 'object' ? structuredClone(value) : value; | |
| } | |
| return seeded ?? config; | |
| } | |
| () | |
| export class PluginLoaderService implements OnModuleInit, OnApplicationBootstrap, OnModuleDestroy { | |
| private readonly logger = createLogger('PluginLoaderService'); | |
| private readonly plugins = new Map<string, PluginInstance>(); | |
| /** Plugin ids whose enable() is in flight — a synchronous lock so concurrent enables can't double-run. */ | |
| private readonly enabling = new Set<string>(); | |
| // Live worker host per enabled sandboxed (untrusted) plugin. Built-ins are not in here. | |
| private readonly sandboxHosts = new Map<string, PluginWorkerHost>(); | |
| // Carries the firing event's sessionId across an in-process hook handler so ctx.config (a getter) | |
| // resolves the per-session slice. Per async call tree, so concurrent sessions don't cross over. | |
| private readonly hookSession = new AsyncLocalStorage<{ sessionId?: string }>(); | |
| private readonly pluginsDir: string; | |
| constructor( | |
| private readonly configService: ConfigService, | |
| private readonly hookManager: HookManager, | |
| private readonly pluginStorage: PluginStorageService, | |
| // Resolves MessageService/SessionService lazily inside capability verbs. ModuleRef is used | |
| // instead of constructor injection to avoid the provider cycle | |
| // PluginLoaderService -> SessionService -> EngineFactory -> PluginLoaderService. | |
| private readonly moduleRef: ModuleRef, | |
| // Shared lid->phone table (EngineModule is @Global and exports it). Optional so the many unit tests | |
| // that construct this service with the 4 prior args still compile; when absent, canonicalChatId | |
| // degrades to identity (no @lid resolution). | |
| () private readonly lidMappingStore?: LidMappingStoreService, | |
| ) { | |
| this.pluginsDir = this.configService.get<string>('plugins.dir') ?? './plugins'; | |
| } | |
| onModuleInit(): void { | |
| // Load built-in plugins first (synchronous registration) | |
| this.loadBuiltInPlugins(); | |
| // Then load user plugins if directory exists | |
| if (fs.existsSync(this.pluginsDir)) { | |
| this.loadPluginsFromDirectory(this.pluginsDir); | |
| } | |
| this.logger.log(`Loaded ${this.plugins.size} plugins`, { | |
| action: 'plugins_loaded', | |
| count: this.plugins.size, | |
| }); | |
| } | |
| /** | |
| * Re-enable the plugins the operator had enabled (#856). `status` cannot carry that across a restart | |
| * — it describes the runtime, and loading never runs a plugin — so the decision is read from the | |
| * separately persisted `enabledByOperator`. Without this, every restart (an upgrade, a host reboot, a | |
| * Docker restart policy) silently switched off every extension, and a relay simply stopped relaying. | |
| * | |
| * Runs at bootstrap rather than in onModuleInit so the rest of the app is wired before any plugin | |
| * code executes. Built-ins are skipped: an engine is enabled by EngineFactory against the configured | |
| * engine.type, and enabling a non-active engine here would be rejected anyway. | |
| * | |
| * Best-effort and sequential, like the shutdown teardown: a plugin that cannot come back is logged | |
| * and left in ERROR, and never holds up the gateway. | |
| */ | |
| async onApplicationBootstrap(): Promise<void> { | |
| const restorable = this.getAllPlugins().filter( | |
| p => !p.builtIn && this.pluginStorage.getPluginEntry(p.manifest.id)?.enabledByOperator === true, | |
| ); | |
| for (const plugin of restorable) { | |
| const pluginId = plugin.manifest.id; | |
| try { | |
| await this.enablePlugin(pluginId); | |
| } catch (error) { | |
| this.logger.error( | |
| `Failed to restore plugin ${pluginId} on startup; it stays disabled until re-enabled`, | |
| error instanceof Error ? error.message : String(error), | |
| { pluginId, action: 'plugin_restore_failed' }, | |
| ); | |
| } | |
| } | |
| } | |
| /** | |
| * Graceful shutdown (SIGTERM → app.close()): run onDisable for every enabled plugin so it can flush | |
| * buffers, close connections, and persist state. Previously onDisable only ran via the REST disable | |
| * and uninstall paths, so a normal restart/deploy/scale-down skipped it and stateful plugins lost | |
| * in-flight work. Best-effort and sequential: one plugin's failure must not block the others. | |
| */ | |
| async onModuleDestroy(): Promise<void> { | |
| const enabled = this.getAllPlugins().filter(p => p.status === PluginStatus.ENABLED); | |
| for (const plugin of enabled) { | |
| try { | |
| await this.disablePlugin(plugin.manifest.id); | |
| } catch (error) { | |
| this.logger.error( | |
| `Failed to disable plugin ${plugin.manifest.id} during shutdown`, | |
| error instanceof Error ? error.message : String(error), | |
| { pluginId: plugin.manifest.id, action: 'plugin_shutdown_disable_failed' }, | |
| ); | |
| } | |
| } | |
| } | |
| private loadBuiltInPlugins(): void { | |
| // Built-in plugins are registered programmatically | |
| // This will be used by Phase 4 to register engine plugins | |
| this.logger.debug('Built-in plugins loading point (Phase 4)', { | |
| action: 'builtin_plugins_init', | |
| }); | |
| } | |
| private loadPluginsFromDirectory(dir: string): void { | |
| const entries = fs.readdirSync(dir, { withFileTypes: true }); | |
| for (const entry of entries) { | |
| // Skip non-directories and dot-prefixed dirs (e.g. a crash-leftover `.<id>.bak` update backup), | |
| // so a half-finished update can't be re-loaded as a duplicate-id plugin on the next boot. | |
| if (!entry.isDirectory() || entry.name.startsWith('.')) continue; | |
| const pluginPath = path.join(dir, entry.name); | |
| const manifestPath = path.join(pluginPath, 'manifest.json'); | |
| if (!fs.existsSync(manifestPath)) { | |
| this.logger.warn(`Plugin ${entry.name} missing manifest.json`, { | |
| pluginPath, | |
| action: 'manifest_missing', | |
| }); | |
| if (LEGACY_REMOVED_PLUGIN_IDS.has(entry.name)) { | |
| this.pluginStorage.deletePluginEntry(entry.name); | |
| this.logger.log(`Pruned stale registry entry for removed built-in plugin: ${entry.name}`, { | |
| action: 'registry_ghost_pruned', | |
| }); | |
| } | |
| continue; | |
| } | |
| try { | |
| this.loadPlugin(pluginPath); | |
| } catch (error) { | |
| this.logger.error( | |
| `Failed to load plugin ${entry.name}`, | |
| error instanceof Error ? error.message : String(error), | |
| { pluginPath, action: 'plugin_load_failed' }, | |
| ); | |
| } | |
| } | |
| } | |
| loadPlugin(pluginPath: string): PluginInstance { | |
| const manifestPath = path.join(pluginPath, 'manifest.json'); | |
| const manifestContent = fs.readFileSync(manifestPath, 'utf-8'); | |
| const manifest = JSON.parse(manifestContent) as PluginManifest; | |
| // Validate manifest | |
| if (!manifest.id || !manifest.name || !manifest.version || !manifest.type || !manifest.main) { | |
| throw new Error(`Invalid manifest: missing required fields`); | |
| } | |
| // Reject a malformed ingress declaration (SDK-major mismatch, missing webhook:ingress permission, | |
| // duplicate/empty routes, non-positive toleranceSec) at load time instead of letting it silently | |
| // load and become provisionable. No-op for plugins that declare no ingress. | |
| validateIngressManifest(manifest); | |
| // Surface a loud warning for any ingress route that skips signature verification — a scheme:'none' | |
| // route is a fully-unauthenticated public endpoint that can trigger WhatsApp sends. Additive (a | |
| // warning, not a refusal) so a legit scheme:'none' deployment still boots. | |
| warnUnauthenticatedIngressRoutes(manifest, this.logger); | |
| // Check if plugin already loaded | |
| if (this.plugins.has(manifest.id)) { | |
| throw new Error(`Plugin ${manifest.id} is already loaded`); | |
| } | |
| // Load any persisted config + per-session activation + per-session config so an operator's choices | |
| // survive a restart. | |
| const storedConfig = this.pluginStorage.getPluginConfig(manifest.id) ?? {}; | |
| const storedSessions = this.pluginStorage.getPluginSessions(manifest.id) ?? undefined; | |
| const storedSessionConfig = this.pluginStorage.getPluginSessionConfig(manifest.id) ?? undefined; | |
| const pluginInstance: PluginInstance = { | |
| manifest, | |
| status: PluginStatus.INSTALLED, | |
| // Seed schema-declared defaults under the stored config, so a defaulted field is never | |
| // missing when the plugin later runs (explicit values are never overwritten). | |
| config: seedConfigDefaults(manifest.configSchema, storedConfig), | |
| instance: null, | |
| loadedAt: new Date(), | |
| builtIn: false, | |
| activeSessions: storedSessions, | |
| sessionConfig: storedSessionConfig, | |
| }; | |
| this.plugins.set(manifest.id, pluginInstance); | |
| // Ensure a registry entry exists so later enable/disable/config writes persist. | |
| this.ensureRegistryEntry(manifest, false); | |
| this.logger.log(`Plugin loaded: ${manifest.name} v${manifest.version}`, { | |
| pluginId: manifest.id, | |
| type: manifest.type, | |
| action: 'plugin_loaded', | |
| }); | |
| return pluginInstance; | |
| } | |
| /** | |
| * Ensure a freshly-loaded plugin has a persisted registry entry, so later enable/disable/config | |
| * writes (which only update an EXISTING entry) actually persist instead of silently no-op'ing. | |
| * Creates a complete INSTALLED entry when none exists; an existing entry's persisted status/config | |
| * is left untouched. Best-effort (saveRegistry swallows fs errors, so a disk failure never turns a | |
| * load into a 500). Does NOT enable or run the plugin — boot never auto-executes plugin code. | |
| */ | |
| private ensureRegistryEntry(manifest: PluginManifest, builtIn: boolean): void { | |
| // Reconcile the persisted entry with the freshly-loaded runtime: loading never runs the plugin, so | |
| // the entry's status is (re)set to INSTALLED to match the runtime. Enabling is a separate step that | |
| // runs the lifecycle — at bootstrap for a plugin the operator had enabled (see | |
| // onApplicationBootstrap), or on an explicit ADMIN action. The operator's persisted config and | |
| // enable decision are preserved so settings/secrets and the decision itself survive. Best-effort: | |
| // saveRegistry swallows fs errors, so a disk failure never turns a load into a 500. | |
| const existing = this.pluginStorage.getPluginEntry(manifest.id); | |
| // The operator's standing enable decision (#856). `status` below is deliberately reset, so intent | |
| // has to live in its own field or a restart loses it. A pre-#856 row has no such field: adopt it | |
| // from a status of ENABLED, which can only have been written by an explicit enable since the last | |
| // boot (every boot rewrites the status to INSTALLED), so it is a faithful record of the intent. | |
| const enabledByOperator = existing?.enabledByOperator ?? existing?.status === PluginStatus.ENABLED; | |
| this.pluginStorage.setPluginEntry({ | |
| id: manifest.id, | |
| type: manifest.type, | |
| name: manifest.name, | |
| version: manifest.version, | |
| status: PluginStatus.INSTALLED, | |
| // The operator's persisted config survives, with schema-declared defaults seeded under it so | |
| // the persisted entry matches the seeded runtime config (see loadPlugin). | |
| config: seedConfigDefaults(manifest.configSchema, existing?.config ?? {}), | |
| builtIn, | |
| installedAt: existing?.installedAt ?? new Date(), | |
| updatedAt: new Date(), | |
| // setPluginEntry REPLACES the entry, so the operator's per-session activation + config must be | |
| // carried over or every boot wipes them from disk (lost after the second restart). | |
| activeSessions: existing?.activeSessions, | |
| sessionConfig: existing?.sessionConfig, | |
| enabledByOperator, | |
| }); | |
| } | |
| /** | |
| * Record that the operator wants this plugin on (or off), so bootstrap can restore it (#856). | |
| * | |
| * Call this ONLY from an operator-facing action. In particular it must never be called from | |
| * disablePlugin: onModuleDestroy disables every running plugin during a graceful shutdown, and | |
| * treating that as "the operator turned it off" would erase the decision on the way out — which is | |
| * the very bug this exists to fix, just moved somewhere harder to see. | |
| */ | |
| setOperatorEnabled(pluginId: string, enabled: boolean): void { | |
| this.pluginStorage.setPluginEnabledByOperator(pluginId, enabled); | |
| } | |
| async enablePlugin(pluginId: string): Promise<void> { | |
| const plugin = this.plugins.get(pluginId); | |
| if (!plugin) { | |
| throw new Error(`Plugin ${pluginId} not found`); | |
| } | |
| if (plugin.status === PluginStatus.ENABLED) { | |
| return; // Already enabled | |
| } | |
| // Engines are mutually exclusive and pinned to the deployment's engine.type config (the factory | |
| // reads that, not plugin status). Enabling a second engine at runtime would show two "active" | |
| // engines and desync the factory, so reject anything but the configured active engine. | |
| if (plugin.manifest.type === PluginType.ENGINE) { | |
| const activeEngine = this.configService.get<string>('engine.type') ?? 'whatsapp-web.js'; | |
| if (pluginId !== activeEngine) { | |
| throw new Error( | |
| `Engine "${pluginId}" is not the active engine ("${activeEngine}"). Set engine.type and restart to switch engines.`, | |
| ); | |
| } | |
| } | |
| // Concurrency guard: status flips to ENABLED only AFTER the awaits below, so two concurrent enable | |
| // calls would both pass the check above, both run onEnable, and both register the plugin's hooks | |
| // (duplicate side effects). Claim the enable synchronously here so a racing caller is rejected | |
| // before any await; released in finally. | |
| if (this.enabling.has(pluginId)) { | |
| throw new Error(`Plugin ${pluginId} is already being enabled`); | |
| } | |
| this.enabling.add(pluginId); | |
| try { | |
| if (plugin.builtIn === false) { | |
| await this.enableSandboxed(pluginId, plugin); | |
| } else { | |
| await this.enableInProcess(pluginId, plugin); | |
| } | |
| plugin.status = PluginStatus.ENABLED; | |
| plugin.enabledAt = new Date(); | |
| plugin.error = undefined; | |
| // Persist status | |
| this.pluginStorage.setPluginStatus(pluginId, PluginStatus.ENABLED); | |
| this.logger.log(`Plugin enabled: ${plugin.manifest.name}`, { | |
| pluginId, | |
| action: 'plugin_enabled', | |
| }); | |
| } catch (error) { | |
| plugin.status = PluginStatus.ERROR; | |
| plugin.error = error instanceof Error ? error.message : String(error); | |
| this.pluginStorage.setPluginStatus(pluginId, PluginStatus.ERROR); | |
| // A plugin that subscribed hooks before its onLoad/onEnable threw would otherwise leave those | |
| // registrations live: a later successful enable re-registers them, so each event then dispatches | |
| // to the plugin once per failed attempt. Drop them here. Safe on this path only — an | |
| // already-enabled plugin returns early above, so the catch only runs for an enable that never | |
| // went live, which owns no hooks worth keeping. (Idempotent: no-ops when none were registered.) | |
| this.hookManager.unregisterPlugin(pluginId); | |
| throw error; | |
| } finally { | |
| this.enabling.delete(pluginId); | |
| } | |
| } | |
| async disablePlugin(pluginId: string): Promise<void> { | |
| const plugin = this.plugins.get(pluginId); | |
| if (!plugin) { | |
| throw new Error(`Plugin ${pluginId} not found`); | |
| } | |
| if (plugin.status !== PluginStatus.ENABLED) { | |
| return; // Not enabled | |
| } | |
| try { | |
| const host = this.sandboxHosts.get(pluginId); | |
| if (host) { | |
| // Disable is a force-teardown: even if the plugin's onDisable hangs (now bounded) or throws, | |
| // we still kill the worker and drop the reference, so a misbehaving plugin can never block a | |
| // disable or leak its worker thread. | |
| try { | |
| await host.runLifecycle('onDisable', SANDBOX_LIFECYCLE_TIMEOUT_MS); | |
| } catch (error) { | |
| this.logger.warn(`Sandboxed plugin ${pluginId} onDisable failed during disable; terminating anyway`, { | |
| pluginId, | |
| action: 'sandbox_disable_lifecycle_failed', | |
| error: error instanceof Error ? error.message : String(error), | |
| }); | |
| } | |
| await host.terminate().catch(() => undefined); | |
| this.sandboxHosts.delete(pluginId); | |
| } else { | |
| const context = this.createPluginContext(plugin); | |
| if (plugin.instance?.onDisable) { | |
| await plugin.instance.onDisable(context); | |
| } | |
| } | |
| // Unregister all hooks for this plugin | |
| this.hookManager.unregisterPlugin(pluginId); | |
| // Drop the plugin's search-provider entry (if any) so queries don't route to a terminated worker. | |
| unregisterPluginSearchProvider(this.getSearchRegistry(), pluginId); | |
| plugin.status = PluginStatus.DISABLED; | |
| this.pluginStorage.setPluginStatus(pluginId, PluginStatus.DISABLED); | |
| this.logger.log(`Plugin disabled: ${plugin.manifest.name}`, { | |
| pluginId, | |
| action: 'plugin_disabled', | |
| }); | |
| } catch (error) { | |
| plugin.status = PluginStatus.ERROR; | |
| plugin.error = error instanceof Error ? error.message : String(error); | |
| throw error; | |
| } | |
| } | |
| async unloadPlugin(pluginId: string): Promise<void> { | |
| const plugin = this.plugins.get(pluginId); | |
| if (!plugin) { | |
| throw new Error(`Plugin ${pluginId} not found`); | |
| } | |
| // Disable first if enabled | |
| if (plugin.status === PluginStatus.ENABLED) { | |
| await this.disablePlugin(pluginId); | |
| } | |
| // Call onUnload | |
| if (plugin.instance?.onUnload) { | |
| const context = this.createPluginContext(plugin); | |
| await plugin.instance.onUnload(context); | |
| } | |
| this.plugins.delete(pluginId); | |
| this.logger.log(`Plugin unloaded: ${plugin.manifest.name}`, { | |
| pluginId, | |
| action: 'plugin_unloaded', | |
| }); | |
| } | |
| /** Absolute path of the directory user plugins are loaded from (used by install/uninstall). */ | |
| getPluginsDir(): string { | |
| return this.pluginsDir; | |
| } | |
| /** Whether a plugin is a first-party built-in (engine / bundled extension) vs an installed user plugin. */ | |
| isBuiltIn(pluginId: string): boolean { | |
| return this.pluginStorage.getPluginEntry(pluginId)?.builtIn ?? false; | |
| } | |
| /** | |
| * Fully remove an installed user plugin: disable + unload from the runtime, drop its persisted | |
| * registry entry, and delete its directory from disk. Built-ins (engines, bundled extensions) are | |
| * registered programmatically with no on-disk dir and must never be removable. | |
| */ | |
| async uninstallPlugin(pluginId: string): Promise<void> { | |
| if (this.pluginStorage.getPluginEntry(pluginId)?.builtIn) { | |
| throw new Error(`Cannot uninstall built-in plugin ${pluginId}`); | |
| } | |
| if (this.plugins.has(pluginId)) { | |
| await this.unloadPlugin(pluginId); | |
| } | |
| this.pluginStorage.deletePluginEntry(pluginId); | |
| // Delete the plugin's directory, guarding against a traversal id escaping the plugins dir. | |
| const base = path.resolve(this.pluginsDir); | |
| const dir = path.resolve(base, pluginId); | |
| if (dir !== base && dir.startsWith(base + path.sep) && fs.existsSync(dir)) { | |
| fs.rmSync(dir, { recursive: true, force: true }); | |
| } | |
| this.logger.log(`Plugin uninstalled: ${pluginId}`, { pluginId, action: 'plugin_uninstalled' }); | |
| } | |
| updatePluginConfig(pluginId: string, config: Record<string, unknown>): void { | |
| const plugin = this.plugins.get(pluginId); | |
| if (!plugin) { | |
| throw new Error(`Plugin ${pluginId} not found`); | |
| } | |
| plugin.config = { ...plugin.config, ...config }; | |
| // Persist config | |
| this.pluginStorage.setPluginConfig(pluginId, plugin.config); | |
| // Notify the running plugin of the config change (fire and forget). A sandboxed plugin's | |
| // onConfigChange lives in the worker (plugin.instance is null), so route it through the live worker | |
| // host so it refreshes ctx.config too; built-ins go through the in-process instance. | |
| if (plugin.status === PluginStatus.ENABLED) { | |
| const sandboxHost = this.sandboxHosts.get(pluginId); | |
| if (sandboxHost) { | |
| sandboxHost.sendConfigChange(plugin.config); | |
| } else if (plugin.instance?.onConfigChange) { | |
| const context = this.createPluginContext(plugin); | |
| void plugin.instance.onConfigChange(context, plugin.config); | |
| } | |
| } | |
| this.logger.debug(`Plugin config updated: ${pluginId}`, { | |
| pluginId, | |
| action: 'plugin_config_updated', | |
| }); | |
| } | |
| /** | |
| * Set the sessions a session-scoped plugin is activated for. `['*']` = all numbers (system-wide), | |
| * an explicit list scopes it to those sessions, `[]` deactivates it everywhere. Takes effect on the | |
| * next hook event (the gate reads plugin.activeSessions live) and survives a restart. | |
| */ | |
| setPluginSessions(pluginId: string, sessions: string[]): PluginInstance { | |
| const plugin = this.plugins.get(pluginId); | |
| if (!plugin) { | |
| throw new Error(`Plugin ${pluginId} not found`); | |
| } | |
| if (plugin.manifest.sessionScoped === false) { | |
| throw new Error(`Plugin ${pluginId} is global (not session-scoped) and cannot be activated per session`); | |
| } | |
| plugin.activeSessions = sessions; | |
| this.pluginStorage.setPluginSessions(pluginId, sessions); | |
| this.logger.log(`Plugin active sessions updated: ${pluginId}`, { | |
| pluginId, | |
| action: 'plugin_sessions_updated', | |
| sessions, | |
| }); | |
| return plugin; | |
| } | |
| /** | |
| * Set (or clear) a plugin's per-session config override for `sessionId`. Hooks for that session then | |
| * see the override shallow-merged over the base via ctx.config — applied on the next event | |
| * (resolution reads plugin.sessionConfig live) and persisted across restart. An empty override | |
| * removes it (the session falls back to the base). Global plugins have no per-session config. | |
| */ | |
| setPluginSessionConfig(pluginId: string, sessionId: string, config: Record<string, unknown>): PluginInstance { | |
| const plugin = this.plugins.get(pluginId); | |
| if (!plugin) { | |
| throw new Error(`Plugin ${pluginId} not found`); | |
| } | |
| if (plugin.manifest.sessionScoped === false) { | |
| throw new Error(`Plugin ${pluginId} is global (not session-scoped) and has no per-session config`); | |
| } | |
| const next = { ...(plugin.sessionConfig ?? {}) }; | |
| if (config && Object.keys(config).length > 0) { | |
| next[sessionId] = config; | |
| } else { | |
| delete next[sessionId]; | |
| } | |
| plugin.sessionConfig = next; | |
| this.pluginStorage.setPluginSessionConfig(pluginId, next); | |
| this.logger.debug(`Plugin session config updated: ${pluginId}`, { | |
| pluginId, | |
| action: 'plugin_session_config_updated', | |
| sessionId, | |
| }); | |
| return plugin; | |
| } | |
| /** | |
| * Run a plugin's healthCheck across both tiers. A sandboxed plugin's healthCheck lives in the worker | |
| * (plugin.instance is null), so route to the live worker host (time-bounded); built-ins use the | |
| * in-process instance. Returns the default "healthy" when the plugin implements no health check. | |
| */ | |
| async checkPluginHealth(pluginId: string): Promise<{ healthy: boolean; message?: string }> { | |
| const sandboxHost = this.sandboxHosts.get(pluginId); | |
| if (sandboxHost) { | |
| return sandboxHost.healthCheck(SANDBOX_HEALTH_TIMEOUT_MS); | |
| } | |
| const plugin = this.plugins.get(pluginId); | |
| if (plugin?.instance?.healthCheck) { | |
| return plugin.instance.healthCheck(); | |
| } | |
| return { healthy: true, message: 'Plugin does not implement health check' }; | |
| } | |
| /** | |
| * Dispatch a queued ingress job into its plugin's live sandbox worker. Called from IngressProcessor, | |
| * mirroring checkPluginHealth's sandboxHosts lookup. Throws when the plugin has no live | |
| * worker (disabled/crashed since the job was enqueued) or when the worker's handler itself reports | |
| * failure (`!result.ok`, e.g. a 502/504/500) — either way BullMQ's retry/DLQ machinery takes over. | |
| */ | |
| async dispatchWebhookForInstance(d: IngressJobData): Promise<void> { | |
| const host = this.sandboxHosts.get(d.pluginId); | |
| if (!host) { | |
| throw new Error('no live sandbox host for plugin ' + d.pluginId); | |
| } | |
| // Resolve this instance's per-session config (the base merged with the sessionScope override that | |
| // provisioning wrote) so the ingress handler reads it as ctx.config — this is what makes a minted | |
| // instance multi-tenant. Best-effort: an unresolved plugin just yields undefined (base config only). | |
| const plugin = this.plugins.get(d.pluginId); | |
| const route = plugin?.manifest.ingress?.find(candidate => candidate.route === d.route); | |
| // Reaching dispatch means every authenticating scheme already passed host verification. A route | |
| // explicitly configured with scheme:none is unauthenticated and must never be labelled verified. | |
| // Missing/hot-swapped route metadata fails closed. | |
| const verified = route ? route.signature.scheme !== 'none' : false; | |
| const instance = await this.getPluginInstanceService().resolve(d.pluginId, d.instanceId); | |
| const config = plugin | |
| ? resolvePluginConfig( | |
| plugin.config, | |
| plugin.sessionConfig, | |
| instance?.sessionScope ?? undefined, | |
| plugin.manifest.sessionScoped !== false, | |
| ) | |
| : undefined; | |
| const result = await host.dispatchWebhook({ | |
| instanceId: d.instanceId, | |
| route: d.route, | |
| method: d.method ?? 'POST', | |
| headers: d.payload.headers, | |
| query: d.payload.query, | |
| body: d.payload.body, | |
| rawBody: d.payload.rawBody, | |
| verified, | |
| deliveryId: d.deliveryId, | |
| sessionId: d.sessionId, | |
| config, | |
| timeoutMs: INGRESS_DISPATCH_TIMEOUT_MS, | |
| }); | |
| if (!result.ok) { | |
| throw new Error(result.error ?? 'ingress dispatch failed with status ' + result.status); | |
| } | |
| } | |
| /** | |
| * Resolve MessageService at call time via a lazy require so plugin-loader creates NO top-level | |
| * module-load edge to message.service. A static import closes the cycle | |
| * plugin-loader -> message -> session -> engine.factory -> core/plugins barrel -> plugin-loader, | |
| * which corrupts MessageService's constructor paramtype metadata (SessionService -> undefined) at boot. | |
| */ | |
| private getMessageService(): MessageService { | |
| const mod = | |
| // eslint-disable-next-line @typescript-eslint/no-require-imports | |
| require('../../modules/message/message.service') as typeof import('../../modules/message/message.service'); | |
| return this.moduleRef.get(mod.MessageService, { strict: false }); | |
| } | |
| private getSessionService(): SessionService { | |
| const mod = | |
| // eslint-disable-next-line @typescript-eslint/no-require-imports | |
| require('../../modules/session/session.service') as typeof import('../../modules/session/session.service'); | |
| return this.moduleRef.get(mod.SessionService, { strict: false }); | |
| } | |
| /** | |
| * Same lazy-require pattern as getMessageService/getSessionService: a static import of the | |
| * integration module would add a top-level edge back into plugin-loader's own module graph. | |
| */ | |
| private getConversationMappingService(): ConversationMappingService { | |
| const mod = | |
| // eslint-disable-next-line @typescript-eslint/no-require-imports | |
| require('../../modules/integration/conversation-mapping.service') as typeof import('../../modules/integration/conversation-mapping.service'); | |
| return this.moduleRef.get(mod.ConversationMappingService, { strict: false }); | |
| } | |
| private getPluginInstanceService(): PluginInstanceService { | |
| const mod = | |
| // eslint-disable-next-line @typescript-eslint/no-require-imports | |
| require('../../modules/integration/plugin-instance.service') as typeof import('../../modules/integration/plugin-instance.service'); | |
| return this.moduleRef.get(mod.PluginInstanceService, { strict: false }); | |
| } | |
| /** | |
| * Resolve the SearchProviderRegistry lazily — search is conditionally loaded (SEARCH_ENABLED=false omits | |
| * SearchModule), so the registry may not be registered. Mirrors the lazy-require pattern for | |
| * MessageService/SessionService to avoid a static module edge and a DI cycle. Returns undefined when | |
| * search is disabled, so the loader can no-op search-provider registration without throwing. | |
| */ | |
| private getSearchRegistry(): SearchProviderRegistry | undefined { | |
| try { | |
| const mod = | |
| // eslint-disable-next-line @typescript-eslint/no-require-imports | |
| require('../../modules/search/search-provider.registry') as typeof import('../../modules/search/search-provider.registry'); | |
| return this.moduleRef.get(mod.SearchProviderRegistry, { strict: false }); | |
| } catch { | |
| return undefined; | |
| } | |
| } | |
| /** | |
| * Enforce a plugin's declared manifest permissions at the capability boundary. A plugin may only | |
| * use a capability whose permission string it declares in `manifest.permissions`; anything else | |
| * (including a manifest with no permissions) is denied. Runs first in each capability verb so a | |
| * missing grant fails fast and uniformly as a PluginCapabilityError. | |
| */ | |
| private assertPermission(manifest: PluginManifest, permission: PluginCapabilityPermission): void { | |
| if (!(manifest.permissions ?? []).includes(permission)) { | |
| throw new PluginCapabilityError( | |
| `Plugin ${manifest.id} is missing the '${permission}' permission required for this capability`, | |
| ); | |
| } | |
| } | |
| /** | |
| * Enforce a plugin's manifest session scope. Runs BEFORE any engine/message resolution — | |
| * sessionId is supplied by the plugin, so this is the security boundary. Absent = ['*']. | |
| */ | |
| private assertSessionAllowed(manifest: PluginManifest, sessionId: string): void { | |
| const allowed = manifest.sessions ?? ['*']; | |
| if (!allowed.includes('*') && !allowed.includes(sessionId)) { | |
| throw new PluginCapabilityError(`Plugin ${manifest.id} is not permitted to act on session ${sessionId}`); | |
| } | |
| } | |
| /** Per-session activation gate: is this plugin currently activated for `sessionId`'s event? */ | |
| private isHookActive(plugin: PluginInstance, sessionId: string | undefined): boolean { | |
| return isPluginActiveForSession(plugin.manifest.sessionScoped ?? true, plugin.activeSessions ?? ['*'], sessionId); | |
| } | |
| /** | |
| * The capability session gate. A plugin may act on `sessionId` only if BOTH hold: its manifest scope | |
| * allows the session (the static author boundary, assertSessionAllowed) AND the operator has activated | |
| * the plugin for that session (the dynamic boundary, the same gate hook dispatch uses). manifest.sessions | |
| * alone is not enough — a general adapter ships `['*']` and is scoped by operator activation, so without | |
| * the activeSessions check a plugin activated for one session could reach another's engine/mappings/ | |
| * handover. Defaults (`activeSessions ?? ['*']`, `sessionScoped:false`) preserve every unrestricted flow. | |
| */ | |
| private assertSessionActive(plugin: PluginInstance, sessionId: string): void { | |
| this.assertSessionAllowed(plugin.manifest, sessionId); | |
| if (!this.isHookActive(plugin, sessionId)) { | |
| throw new PluginCapabilityError(`Plugin ${plugin.manifest.id} is not activated for session ${sessionId}`); | |
| } | |
| } | |
| /** | |
| * Scope-check, then resolve the live engine for a session. getEngine returns undefined for an | |
| * unknown OR unstarted session (no throw), so guard it into a defined PluginCapabilityError. | |
| * A present-but-not-READY engine throws EngineNotReadyError from the adapter on use (→ 409). | |
| */ | |
| private resolveEngine(plugin: PluginInstance, sessionId: string): IWhatsAppEngine { | |
| this.assertSessionActive(plugin, sessionId); | |
| const engine = this.getSessionService().getEngine(sessionId); | |
| if (!engine) { | |
| throw new PluginCapabilityError(`Session ${sessionId} has no active engine (unknown or not started)`); | |
| } | |
| return engine; | |
| } | |
| /** Engine read capabilities: require the `engine:read` permission, then resolve the live engine. */ | |
| private resolveEngineRead(plugin: PluginInstance, sessionId: string): IWhatsAppEngine { | |
| this.assertPermission(plugin.manifest, PluginCapabilityPermission.ENGINE_READ); | |
| return this.resolveEngine(plugin, sessionId); | |
| } | |
| /** | |
| * Build a worker host for a sandboxed (untrusted) plugin. Overridable so tests can inject a fake | |
| * instead of spawning a real OS thread. Production loads the compiled worker bootstrap from dist. | |
| */ | |
| protected createSandboxHost( | |
| capDispatcher?: (verb: string, args: unknown[]) => Promise<unknown>, | |
| onHookSubscribe?: (event: string, priority?: number) => void, | |
| onWebhookSubscribe?: (route: string) => void, | |
| onLog?: (level: PluginLogLevel, message: string, meta?: Record<string, unknown>) => void, | |
| runWithHookGuard?: (inFlightEvents: string[], run: () => Promise<unknown>) => Promise<unknown>, | |
| onSearchProviderRegister?: () => void, | |
| onWorkerExit?: (code: number, intentional: boolean) => void, | |
| ): PluginWorkerHost { | |
| const workerEntry = path.join(__dirname, 'sandbox', 'worker-bootstrap.js'); | |
| return new PluginWorkerHost( | |
| new WorkerThreadChannel({ | |
| workerEntry, | |
| maxOldGenerationSizeMb: SANDBOX_MAX_OLD_GEN_MB, | |
| // Withhold host secrets: the worker gets a minimal allowlisted env, not a copy of process.env. | |
| env: buildSandboxWorkerEnv(), | |
| }), | |
| capDispatcher, | |
| onHookSubscribe, | |
| onWebhookSubscribe, | |
| onLog, | |
| runWithHookGuard, | |
| SANDBOX_MAX_INFLIGHT_CAPS, | |
| onSearchProviderRegister, | |
| onWorkerExit, | |
| ); | |
| } | |
| /** Built-in (trusted) enable: require + run the lifecycle in-process with the live capability context. */ | |
| private async enableInProcess(pluginId: string, plugin: PluginInstance): Promise<void> { | |
| const context = this.createPluginContext(plugin); | |
| if (!plugin.instance) { | |
| // Containment guard: reject a manifest.main that escapes the plugin dir. | |
| const mainPath = resolvePluginMainPath(this.pluginsDir, pluginId, plugin.manifest.main); | |
| // eslint-disable-next-line @typescript-eslint/no-require-imports | |
| const pluginModule = require(mainPath) as { default?: new () => IPlugin }; | |
| if (pluginModule.default) { | |
| plugin.instance = new pluginModule.default(); | |
| } else { | |
| throw new Error(`Plugin ${pluginId} does not export a default class`); | |
| } | |
| } | |
| if (plugin.instance.onLoad) { | |
| await plugin.instance.onLoad(context); | |
| } | |
| if (plugin.instance.onEnable) { | |
| await plugin.instance.onEnable(context); | |
| } | |
| } | |
| /** | |
| * Untrusted enable: load the plugin in an isolated worker and drive its lifecycle there. Capability | |
| * calls and hooks round-trip to the host, which enforces permission + session scope. A failure | |
| * tears the worker back down. | |
| */ | |
| private async enableSandboxed(pluginId: string, plugin: PluginInstance): Promise<void> { | |
| // Containment guard: reject a manifest.main that escapes the plugin dir. | |
| const mainPath = resolvePluginMainPath(this.pluginsDir, pluginId, plugin.manifest.main); | |
| // The capability dispatcher runs a worker request through the SAME context an in-process plugin | |
| // gets, so permission + session-scope checks (assertPermission / assertSessionActive) apply | |
| // identically. The worker can only ask; the host is the gatekeeper. | |
| const context = this.createPluginContext(plugin); | |
| // When the worker subscribes to a hook, register a shim with the hook manager that dispatches the | |
| // event into the worker (time-bounded, so a wedged plugin can't stall the chain). The shim looks | |
| // the host up at fire time, so disabling the plugin (which removes it + unregisters hooks) stops it. | |
| // Harden the IPC boundary against an untrusted worker flooding the host hook registry. HookEvent is | |
| // a type-only union and the wire payload is an arbitrary string, so a hostile/buggy worker can post | |
| // 'hook-subscribe' with (a) the same event repeatedly and (b) unbounded fabricated event names | |
| // ('x:0','x:1',…). Without guards each call adds a live host-side registration (unbounded host-heap | |
| // growth + an O(n log n) re-sort). Three guards, all local to this enableSandboxed call (dropped on | |
| // disable): reject unknown events (bounds growth to the finite known set + drops events that can | |
| // never fire), dedup per event, and a belt-and-suspenders size cap. | |
| const subscribedEvents = new Set<HookEvent>(); | |
| let unknownEventWarned = false; | |
| const onHookSubscribe = (event: string, priority?: number): void => { | |
| if (!isKnownHookEvent(event)) { | |
| if (!unknownEventWarned) { | |
| unknownEventWarned = true; // warn at most once per plugin so a flood isn't a log-flood vector | |
| this.logger.warn(`Sandboxed plugin ${pluginId} subscribed to an unknown hook event; ignoring`, { | |
| pluginId, | |
| event, | |
| action: 'sandbox_unknown_hook_event', | |
| }); | |
| } | |
| return; | |
| } | |
| if (subscribedEvents.has(event)) return; | |
| if (subscribedEvents.size >= KNOWN_HOOK_EVENTS.size) return; // can't exceed the known set | |
| subscribedEvents.add(event); | |
| this.hookManager.register( | |
| pluginId, | |
| event, | |
| async hookCtx => { | |
| const liveHost = this.sandboxHosts.get(pluginId); | |
| if (!liveHost) return { continue: true }; | |
| // Per-session activation gate: a session-scoped plugin only sees events for the sessions | |
| // it is activated for. Pass-through (don't dispatch into the worker) otherwise. | |
| if (!this.isHookActive(plugin, hookCtx.sessionId)) return { continue: true }; | |
| // Handover gate: once a human has taken over (or closed) a conversation, the bot stops | |
| // seeing its inbound messages. Scoped to message:received only — every other hook event is | |
| // unaffected. Best-effort + fail-open: a lookup failure (or an event/mapping shape the gate | |
| // can't resolve) must never block a normal message from reaching the adapter. | |
| if (event === 'message:received') { | |
| try { | |
| const chatId = (hookCtx.data as { chatId?: string } | undefined)?.chatId; | |
| if (chatId && hookCtx.sessionId) { | |
| const handover = await this.getConversationMappingService().findHandoverForChat( | |
| hookCtx.sessionId, | |
| chatId, | |
| ); | |
| if (!shouldDispatchToPlugin(handover, pluginId)) return { continue: true }; | |
| } | |
| } catch (error) { | |
| this.logger.debug(`Handover gate lookup failed for plugin ${pluginId}; dispatching normally`, { | |
| pluginId, | |
| event, | |
| error: error instanceof Error ? error.message : String(error), | |
| action: 'handover_gate_fail_open', | |
| }); | |
| } | |
| } | |
| return liveHost | |
| .dispatchHook({ | |
| event, | |
| data: hookCtx.data, | |
| sessionId: hookCtx.sessionId, | |
| source: hookCtx.source, | |
| // The host resolves the per-session slice (real secrets — the worker is the plugin's | |
| // trusted execution context) and ships it; the worker exposes it as ctx.config. | |
| config: resolvePluginConfig( | |
| plugin.config, | |
| plugin.sessionConfig, | |
| hookCtx.sessionId, | |
| plugin.manifest.sessionScoped !== false, | |
| ), | |
| timeoutMs: SANDBOX_HOOK_TIMEOUT_MS, | |
| onTimeout: () => | |
| this.logger.warn(`Sandboxed plugin ${pluginId} hook '${event}' timed out`, { | |
| pluginId, | |
| event, | |
| action: 'sandbox_hook_timeout', | |
| }), | |
| }) | |
| .then(result => ({ continue: result.continue, data: result.data })); | |
| }, | |
| priority, | |
| ); | |
| }; | |
| // When the worker claims an ingress route, record it against the manifest-declared routes so the | |
| // host knows which routes this worker will handle. Same hardening as onHookSubscribe (the wire | |
| // `route` is an arbitrary untrusted string): drop when the manifest lacks 'webhook:ingress', drop | |
| // an undeclared route (warn once), dedup, and cap. subscribedRoutes is local to this enable call, | |
| // so it is dropped on disable exactly as subscribedEvents is. | |
| const subscribedRoutes = new Set<string>(); | |
| const declaredRoutes = new Set((plugin.manifest.ingress ?? []).map(r => r.route)); | |
| const onWebhookSubscribe = makeOnWebhookSubscribe({ | |
| pluginId, | |
| declaredRoutes, | |
| hasPermission: (plugin.manifest.permissions ?? []).includes(PluginCapabilityPermission.WEBHOOK_INGRESS), | |
| subscribed: subscribedRoutes, | |
| maxRoutes: declaredRoutes.size, | |
| warn: (message, meta) => this.logger.warn(message, meta), | |
| }); | |
| // Route the worker plugin's ctx.logger.* calls to the same per-plugin logger an in-process plugin | |
| // uses, so sandboxed plugins log identically (prefixed + structured) instead of bare stdout. | |
| const onLog = (level: PluginLogLevel, message: string, meta?: Record<string, unknown>): void => { | |
| if (level === 'error') context.logger.error(message, undefined, meta); | |
| else context.logger[level](message, meta); | |
| }; | |
| // When the worker declares itself a search provider (ctx.registerSearchProvider → | |
| // search-provider-register), register a PluginSearchProvider in the SearchProviderRegistry. The host | |
| // is in sandboxHosts by the time registration fires (during onLoad/onEnable), so look it up lazily | |
| // like onHookSubscribe. Search disabled (no registry, or SEARCH_PROVIDER=none) → the util skips. | |
| const onSearchProviderRegister = (): void => { | |
| const liveHost = this.sandboxHosts.get(pluginId); | |
| if (!liveHost) return; | |
| registerPluginSearchProvider({ | |
| pluginId, | |
| label: `${plugin.manifest.name} (plugin)`, | |
| transport: liveHost, | |
| timeoutMs: SANDBOX_SEARCH_TIMEOUT_MS, | |
| registry: this.getSearchRegistry(), | |
| mode: this.configService.get<string>('search.provider', 'auto'), | |
| }); | |
| }; | |
| // A worker that crashes AFTER a successful enable is otherwise invisible to the loader (handleExit only | |
| // drains in-flight calls). Drop the plugin's search-provider entry so the registry falls back to | |
| // builtin-fts instead of routing every /search to a dead worker (auto mode would otherwise pin the dead | |
| // provider ACTIVE). Mirrors the enable-failure cleanup. Broader crash-lifecycle cleanup (status, hooks) | |
| // is a pre-existing gap for all bridges and out of scope here. | |
| const onWorkerExit = (code: number, intentional: boolean): void => { | |
| // Always release the search-provider slot so the registry can fall back to builtin-fts. On a crash | |
| // this is the only cleanup; on a deliberate disable/enable-failure the explicit unregister already | |
| // ran, making this a harmless no-op. | |
| unregisterPluginSearchProvider(this.getSearchRegistry(), pluginId); | |
| if (intentional) return; // routine disable/enable-failure already logged and expected | |
| // Unexpected crash after a successful enable: the worker is gone. Drop the dead host + | |
| // unregister the hook shims (so they don't keep dispatching into the dead worker) + mark the | |
| // plugin ERROR so the dashboard reflects reality. The dispatchHook/dispatchWebhook dead-checks | |
| // fail-fast; this cleanup is the root-cause fix (it also makes the shim's !liveHost guard fire). | |
| const crashed = this.plugins.get(pluginId); | |
| if (crashed) { | |
| crashed.status = PluginStatus.ERROR; | |
| crashed.error = `worker exited unexpectedly (code ${code})`; | |
| this.pluginStorage.setPluginStatus(pluginId, PluginStatus.ERROR); | |
| } | |
| this.hookManager.unregisterPlugin(pluginId); | |
| this.sandboxHosts.delete(pluginId); | |
| this.logger.warn(`Sandboxed plugin ${pluginId} worker exited unexpectedly (code ${code})`, { | |
| pluginId, | |
| code, | |
| action: 'sandbox_worker_exit', | |
| }); | |
| }; | |
| const host = this.createSandboxHost( | |
| (verb, args) => dispatchCapabilityVerb(context, verb, args), | |
| onHookSubscribe, | |
| onWebhookSubscribe, | |
| onLog, | |
| // Re-establish the in-flight hook context for worker-initiated capability calls, so a sandboxed | |
| // plugin that sends from within a send hook can't loop the event back into itself unboundedly. | |
| (events, run) => this.hookManager.runInFlight(events as HookEvent[], run), | |
| onSearchProviderRegister, | |
| onWorkerExit, | |
| ); | |
| this.sandboxHosts.set(pluginId, host); | |
| try { | |
| await host.load(mainPath, { pluginId, config: plugin.config }, SANDBOX_LIFECYCLE_TIMEOUT_MS); | |
| await host.runLifecycle('onLoad', SANDBOX_LIFECYCLE_TIMEOUT_MS); | |
| await host.runLifecycle('onEnable', SANDBOX_LIFECYCLE_TIMEOUT_MS); | |
| } catch (error) { | |
| this.sandboxHosts.delete(pluginId); | |
| // Drop a search provider registered mid-onEnable before the failure: without this, a plugin that | |
| // registers then throws leaves a dead provider as the ACTIVE registry entry in auto mode, so every | |
| // /search routes to a terminated worker → outage. Mirrors disablePlugin's cleanup. | |
| unregisterPluginSearchProvider(this.getSearchRegistry(), pluginId); | |
| await host.terminate().catch(() => undefined); | |
| throw error; | |
| } | |
| } | |
| private createPluginContext(plugin: PluginInstance): PluginContext { | |
| const pluginLogger: PluginLogger = { | |
| log: (message, meta) => | |
| this.logger.log(`[${plugin.manifest.id}] ${message}`, { ...meta, pluginId: plugin.manifest.id }), | |
| debug: (message, meta) => | |
| this.logger.debug(`[${plugin.manifest.id}] ${message}`, { ...meta, pluginId: plugin.manifest.id }), | |
| warn: (message, meta) => | |
| this.logger.warn(`[${plugin.manifest.id}] ${message}`, { ...meta, pluginId: plugin.manifest.id }), | |
| error: (message, error, meta) => | |
| this.logger.error( | |
| `[${plugin.manifest.id}] ${message}`, | |
| error instanceof Error ? error.message : String(error), | |
| { ...meta, pluginId: plugin.manifest.id }, | |
| ), | |
| }; | |
| const hookSession = this.hookSession; | |
| return { | |
| pluginId: plugin.manifest.id, | |
| manifest: plugin.manifest, | |
| // Per-session: inside a hook, returns the override merged over the base for the firing session; | |
| // outside a hook (lifecycle), the base config. A getter so it reflects live config edits too. | |
| get config() { | |
| return resolvePluginConfig( | |
| plugin.config, | |
| plugin.sessionConfig, | |
| hookSession.getStore()?.sessionId, | |
| plugin.manifest.sessionScoped !== false, | |
| ); | |
| }, | |
| hookManager: this.hookManager, | |
| logger: pluginLogger, | |
| storage: this.pluginStorage.createPluginStorage(plugin.manifest.id), | |
| registerHook: (event, handler, priority) => { | |
| // Wrap with the per-session activation gate so an in-process plugin only handles events for | |
| // the sessions it is activated for (mirrors the sandboxed shim), and scope the firing | |
| // sessionId so ctx.config resolves the right per-session slice for the handler. | |
| this.hookManager.register( | |
| plugin.manifest.id, | |
| event, | |
| async hookCtx => { | |
| if (!this.isHookActive(plugin, hookCtx.sessionId)) return { continue: true }; | |
| return this.hookSession.run({ sessionId: hookCtx.sessionId }, () => handler(hookCtx)); | |
| }, | |
| priority, | |
| ); | |
| }, | |
| // In-process built-ins are not reached by the ingress pipeline (it dispatches to sandbox hosts), | |
| // so fail loud rather than silently never firing. Sandboxed plugins get a real registerWebhook | |
| // from the worker bootstrap. | |
| registerWebhook: () => { | |
| throw new PluginCapabilityError( | |
| `Plugin ${plugin.manifest.id}: registerWebhook (ingress) is only available to sandboxed plugins`, | |
| ); | |
| }, | |
| messages: { | |
| sendText: async (sessionId, chatId, text) => { | |
| // Validate permission + scope + that the session has a live engine BEFORE MessageService | |
| // persists a pending row: a missing grant / dead session must fail with | |
| // PluginCapabilityError, not a raw TypeError + orphaned row. resolveEngine also runs | |
| // assertSessionActive. | |
| this.assertPermission(plugin.manifest, PluginCapabilityPermission.MESSAGES_SEND); | |
| this.resolveEngine(plugin, sessionId); | |
| return this.getMessageService().sendText(sessionId, { chatId, text }); | |
| }, | |
| reply: async (sessionId, chatId, quotedMessageId, text) => { | |
| this.assertPermission(plugin.manifest, PluginCapabilityPermission.MESSAGES_SEND); | |
| this.resolveEngine(plugin, sessionId); | |
| return this.getMessageService().reply(sessionId, { chatId, quotedMessageId, text }); | |
| }, | |
| } satisfies PluginMessagingCapability, | |
| engine: { | |
| getGroupInfo: async (sessionId, groupId) => this.resolveEngineRead(plugin, sessionId).getGroupInfo(groupId), | |
| getContacts: async sessionId => this.resolveEngineRead(plugin, sessionId).getContacts(), | |
| getContactById: async (sessionId, contactId) => | |
| this.resolveEngineRead(plugin, sessionId).getContactById(contactId), | |
| checkNumberExists: async (sessionId, phone) => | |
| this.resolveEngineRead(plugin, sessionId).checkNumberExists(phone), | |
| getChats: async sessionId => this.resolveEngineRead(plugin, sessionId).getChats(), | |
| getChatHistory: async (sessionId, chatId, limit, includeMedia) => | |
| this.resolveEngineRead(plugin, sessionId).getChatHistory( | |
| chatId, | |
| // Clamp to the REST non-deep ceiling (MessageService.MAX_CHAT_HISTORY_LIMIT = 100) so an | |
| // untrusted plugin can't request an unbounded history fetch. | |
| Math.min(Math.max(Math.trunc(limit ?? 50), 1), 100), | |
| includeMedia ?? false, | |
| ), | |
| canonicalChatId: (sessionId, chatId) => { | |
| // resolveEngineRead is the gate only (engine:read permission + live session); the resolution | |
| // itself is a synchronous host lid->phone lookup, not an engine call, mirroring the webhook | |
| // from-filter. Not `async` (nothing to await) — a resolved promise satisfies the signature. | |
| this.resolveEngineRead(plugin, sessionId); | |
| return Promise.resolve(toNeutralJid(chatId, jid => this.lidMappingStore?.getCached(userPart(jid)) ?? null)); | |
| }, | |
| } satisfies PluginEngineReadCapability, | |
| net: { | |
| fetch: async (url, init) => { | |
| // Two gates: the declared permission, then the effective host allowlist = manifest net.allow | |
| // UNION the hosts of net.allowConfigHosts keys across the base config AND every per-session | |
| // override. The host gate has no firing-session context for a sandboxed plugin's cap round-trip, | |
| // so admit every operator-configured tenant host (all public + still SSRF-guarded at connect) | |
| // rather than resolving a single, possibly wrong (base-only), one. The SSRF guard inside | |
| // performPluginFetch still blocks internal IPs even when the host is allowlisted. | |
| this.assertPermission(plugin.manifest, PluginCapabilityPermission.NET_FETCH); | |
| const netConfigs = [plugin.config ?? {}, ...Object.values(plugin.sessionConfig ?? {})]; | |
| const allow = [ | |
| ...new Set( | |
| netConfigs.flatMap(cfg => | |
| effectiveNetAllow(plugin.manifest.net?.allow, plugin.manifest.net?.allowConfigHosts, cfg), | |
| ), | |
| ), | |
| ]; | |
| if (!isNetHostAllowed(allow, url)) { | |
| throw new PluginCapabilityError( | |
| `Plugin ${plugin.manifest.id} may not fetch ${url} — add its host to net.allow or net.allowConfigHosts`, | |
| ); | |
| } | |
| return performPluginFetch(url, init); | |
| }, | |
| } satisfies PluginNetCapability, | |
| conversations: buildConversationSendFacade({ | |
| manifest: plugin.manifest, | |
| assertPermission: this.assertPermission.bind(this), | |
| assertSessionActive: (sessionId: string) => this.assertSessionActive(plugin, sessionId), | |
| resolveChatId: async env => { | |
| if (!env.instanceId || !env.source?.externalConversationId) { | |
| throw new PluginCapabilityError( | |
| `Plugin ${plugin.manifest.id}: conversation.send requires chatId, or both instanceId and source to resolve one`, | |
| ); | |
| } | |
| const mapping = await this.getConversationMappingService().getByProvider( | |
| plugin.manifest.id, | |
| env.instanceId, | |
| env.source.externalConversationId, | |
| ); | |
| if (!mapping) { | |
| throw new PluginCapabilityError( | |
| `Plugin ${plugin.manifest.id}: no conversation mapping for instance ${env.instanceId} / ${env.source.externalConversationId}`, | |
| ); | |
| } | |
| return mapping.chatId; | |
| }, | |
| // Re-establish the in-flight hook context around the downstream send so an adapter that calls | |
| // conversation.send from within its own ingress handling can't echo-loop back into itself via | |
| // its own outbound message:sending hook. Gate on an ALREADY-in-flight event (mirrors the | |
| // worker-cap wrap's `inFlight.length > 0` check): a plain top-level send must NOT suppress | |
| // message:sending for unrelated observers (audit/moderation) — only genuine re-entrancy does. | |
| runGuarded: (events, run) => | |
| (events as HookEvent[]).some(e => this.hookManager.isInFlight(e)) | |
| ? this.hookManager.runInFlight(events as HookEvent[], run) | |
| : run(), | |
| sendText: (sessionId, opts) => this.getMessageService().sendText(sessionId, opts), | |
| reply: (sessionId, opts) => this.getMessageService().reply(sessionId, opts), | |
| sendMedia: (sessionId, opts) => dispatchConversationMedia(this.getMessageService(), sessionId, opts), | |
| } satisfies Parameters<typeof buildConversationSendFacade>[0]) satisfies PluginConversationsCapability, | |
| handover: { | |
| set: async (key, state) => { | |
| // Same gate as conversation.send: flipping handover is part of owning the conversation, so | |
| // it reuses CONVERSATION_SEND rather than adding a new permission. | |
| this.assertPermission(plugin.manifest, PluginCapabilityPermission.CONVERSATION_SEND); | |
| this.assertSessionActive(plugin, key.sessionId); | |
| const mapping = await this.getConversationMappingService().get({ | |
| sessionId: key.sessionId, | |
| chatId: key.chatId, | |
| pluginId: plugin.manifest.id, | |
| instanceId: key.instanceId, | |
| }); | |
| if (!mapping) { | |
| throw new PluginCapabilityError( | |
| `Plugin ${plugin.manifest.id}: no conversation mapping for session ${key.sessionId} / chat ${key.chatId} / instance ${key.instanceId}`, | |
| ); | |
| } | |
| await this.getConversationMappingService().setHandover(mapping.id, state); | |
| }, | |
| } satisfies PluginHandoverCapability, | |
| mappings: { | |
| upsert: async (key, providerConversationId) => { | |
| this.assertPermission(plugin.manifest, PluginCapabilityPermission.CONVERSATION_SEND); | |
| this.assertSessionActive(plugin, key.sessionId); | |
| await this.getConversationMappingService().upsert( | |
| { sessionId: key.sessionId, chatId: key.chatId, pluginId: plugin.manifest.id, instanceId: key.instanceId }, | |
| providerConversationId, | |
| ); | |
| }, | |
| get: async key => { | |
| this.assertPermission(plugin.manifest, PluginCapabilityPermission.CONVERSATION_SEND); | |
| this.assertSessionActive(plugin, key.sessionId); | |
| const m = await this.getConversationMappingService().get({ | |
| sessionId: key.sessionId, | |
| chatId: key.chatId, | |
| pluginId: plugin.manifest.id, | |
| instanceId: key.instanceId, | |
| }); | |
| return m ? { providerConversationId: m.providerConversationId, handoverState: m.handoverState } : null; | |
| }, | |
| getByProvider: async (instanceId, providerConversationId) => { | |
| this.assertPermission(plugin.manifest, PluginCapabilityPermission.CONVERSATION_SEND); | |
| const m = await this.getConversationMappingService().getByProvider( | |
| plugin.manifest.id, | |
| instanceId, | |
| providerConversationId, | |
| ); | |
| // Parity with get/upsert: a plugin may only read a mapping for a session it is activated for. | |
| if (m) this.assertSessionActive(plugin, m.sessionId); | |
| return m ? { sessionId: m.sessionId, chatId: m.chatId, handoverState: m.handoverState } : null; | |
| }, | |
| } satisfies PluginMappingsCapability, | |
| }; | |
| } | |
| // ============================================================================ | |
| // Query Methods | |
| // ============================================================================ | |
| getPlugin(pluginId: string): PluginInstance | undefined { | |
| return this.plugins.get(pluginId); | |
| } | |
| getAllPlugins(): PluginInstance[] { | |
| return Array.from(this.plugins.values()); | |
| } | |
| getPluginsByType(type: PluginType): PluginInstance[] { | |
| return this.getAllPlugins().filter(p => p.manifest.type === type); | |
| } | |
| getEnabledPlugins(): PluginInstance[] { | |
| return this.getAllPlugins().filter(p => p.status === PluginStatus.ENABLED); | |
| } | |
| isPluginEnabled(pluginId: string): boolean { | |
| const plugin = this.plugins.get(pluginId); | |
| return plugin?.status === PluginStatus.ENABLED; | |
| } | |
| // ============================================================================ | |
| // Built-in Plugin Registration (for Phase 4) | |
| // ============================================================================ | |
| registerBuiltInPlugin(manifest: PluginManifest, instance: IPlugin, config: Record<string, unknown> = {}): void { | |
| // Merge: env-derived defaults stay live each boot (so a changed .env wins), while an operator's | |
| // persisted overrides win for the keys they actually set. Engine config is wholly env-derived | |
| // (no persisted overrides), so it is never frozen to a first-boot snapshot. | |
| const effectiveConfig = { ...config, ...(this.pluginStorage.getPluginConfig(manifest.id) ?? {}) }; | |
| const pluginInstance: PluginInstance = { | |
| manifest, | |
| status: PluginStatus.INSTALLED, | |
| config: effectiveConfig, | |
| instance, | |
| loadedAt: new Date(), | |
| builtIn: true, | |
| // Read persisted per-session activation + config back into the runtime, like loadPlugin — | |
| // otherwise the delivery gate falls back to all-sessions/base-config after every restart for a | |
| // session-scoped built-in the operator had restricted. | |
| activeSessions: this.pluginStorage.getPluginSessions(manifest.id) ?? undefined, | |
| sessionConfig: this.pluginStorage.getPluginSessionConfig(manifest.id) ?? undefined, | |
| }; | |
| this.plugins.set(manifest.id, pluginInstance); | |
| // Ensure a registry entry exists so later enable/disable/config writes persist. | |
| this.ensureRegistryEntry(manifest, true); | |
| this.logger.debug(`Built-in plugin registered: ${manifest.name}`, { | |
| pluginId: manifest.id, | |
| action: 'builtin_plugin_registered', | |
| }); | |
| } | |
| } | |