import { Injectable, NotFoundException, BadRequestException, ConflictException, ForbiddenException, HttpException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as fs from 'fs'; import * as path from 'path'; import { PluginLoaderService, PluginStatus, resolvePluginMainPath } from '../../core/plugins'; import type { PluginConfigSchema } from '../../core/plugins'; import { PluginDto } from './dto/plugin.dto'; import { redactSecretConfig, restoreSecretConfig } from './redact-config'; import { parsePluginPackage } from './plugin-installer'; import { fetchSafeBuffer } from './plugin-download'; import { annotateCatalog, CatalogEntry, CatalogPlugin } from './catalog'; import { redactSsrfError } from '../../common/security/ssrf-guard'; import { createLogger } from '../../common/services/logger.service'; /** Cap on the catalog JSON download (the catalog is small; this bounds a hostile response). */ const CATALOG_MAX_BYTES = 1 * 1024 * 1024; /** * Module-level logger so the SSRF redactor can log the full blocked-address detail server-side before * returning the generic message (the service has no `this.logger` and its methods are sync/void-returning * around the download calls). */ const logger = createLogger('PluginsService'); /** A plugin can host provisioned instances iff it declares an ingress route AND the webhook:ingress * permission — mirrors IntegrationInstanceController.assertIngressCapable. */ export function isIngressCapable(manifest: { ingress?: unknown[]; permissions?: string[] }): boolean { return (manifest.ingress?.length ?? 0) > 0 && (manifest.permissions ?? []).includes('webhook:ingress'); } @Injectable() export class PluginsService { constructor( private readonly pluginLoader: PluginLoaderService, private readonly configService: ConfigService, ) {} // Serialize the directory/lifecycle-mutating operations (enable/disable/uninstall/update/install) for a // given plugin id so two of them on the SAME id can't interleave (e.g. enable racing uninstall, or two // updates racing on the backup dir). Mirrors the promise-chain serializer in session.service.ts. private readonly opChains = new Map>(); private serialize(id: string, op: () => Promise): Promise { const prior = this.opChains.get(id) ?? Promise.resolve(); const next = prior.catch(() => undefined).then(op); this.opChains.set(id, next); void next .catch(() => undefined) .finally(() => { if (this.opChains.get(id) === next) this.opChains.delete(id); }); return next; } findAll(): PluginDto[] { const plugins = this.pluginLoader.getAllPlugins(); return plugins.map(plugin => ({ id: plugin.manifest.id, name: plugin.manifest.name, version: plugin.manifest.version, type: plugin.manifest.type, description: plugin.manifest.description, author: plugin.manifest.author, status: plugin.status, config: redactSecretConfig(plugin.config, plugin.manifest.configSchema), builtIn: this.pluginLoader.isBuiltIn(plugin.manifest.id), provides: plugin.manifest.provides ?? [], ingressCapable: isIngressCapable(plugin.manifest), configSchema: plugin.manifest.configSchema, configUi: plugin.manifest.configUi, i18n: plugin.manifest.i18n, sessionConfig: this.redactSessionConfig(plugin.sessionConfig, plugin.manifest.configSchema), sessionScoped: plugin.manifest.sessionScoped !== false, activeSessions: plugin.activeSessions ?? ['*'], loadedAt: plugin.loadedAt?.toISOString(), enabledAt: plugin.enabledAt?.toISOString(), error: plugin.error, })); } findOne(id: string): PluginDto { const plugin = this.pluginLoader.getPlugin(id); if (!plugin) { throw new NotFoundException(`Plugin ${id} not found`); } return { id: plugin.manifest.id, name: plugin.manifest.name, version: plugin.manifest.version, type: plugin.manifest.type, description: plugin.manifest.description, author: plugin.manifest.author, status: plugin.status, config: redactSecretConfig(plugin.config, plugin.manifest.configSchema), builtIn: this.pluginLoader.isBuiltIn(plugin.manifest.id), provides: plugin.manifest.provides ?? [], ingressCapable: isIngressCapable(plugin.manifest), configSchema: plugin.manifest.configSchema, configUi: plugin.manifest.configUi, i18n: plugin.manifest.i18n, sessionConfig: this.redactSessionConfig(plugin.sessionConfig, plugin.manifest.configSchema), sessionScoped: plugin.manifest.sessionScoped !== false, activeSessions: plugin.activeSessions ?? ['*'], loadedAt: plugin.loadedAt?.toISOString(), enabledAt: plugin.enabledAt?.toISOString(), error: plugin.error, }; } enable(id: string): Promise<{ success: boolean; message: string }> { return this.serialize(id, () => this.enableInner(id)); } private async enableInner(id: string): Promise<{ success: boolean; message: string }> { const plugin = this.pluginLoader.getPlugin(id); if (!plugin) { throw new NotFoundException(`Plugin ${id} not found`); } if (plugin.status === PluginStatus.ENABLED) { // Converge the persisted decision even on the no-op path, so a plugin left running by an older // build (which had no such field) is still restored after the next restart. this.pluginLoader.setOperatorEnabled(id, true); return { success: true, message: `Plugin ${id} is already enabled` }; } try { await this.pluginLoader.enablePlugin(id); // Only after the lifecycle actually succeeded: a plugin that failed to enable must not be // restored on every boot just to fail again. this.pluginLoader.setOperatorEnabled(id, true); return { success: true, message: `Plugin ${id} enabled successfully` }; } catch (error) { return { success: false, message: error instanceof Error ? error.message : String(error), }; } } disable(id: string): Promise<{ success: boolean; message: string }> { return this.serialize(id, () => this.disableInner(id)); } private async disableInner(id: string): Promise<{ success: boolean; message: string }> { const plugin = this.pluginLoader.getPlugin(id); if (!plugin) { throw new NotFoundException(`Plugin ${id} not found`); } if (plugin.status !== PluginStatus.ENABLED) { // Clear the decision here too: a plugin sitting in ERROR after a failed restore is not ENABLED, // and disabling it must stop the gateway retrying it on every boot. this.pluginLoader.setOperatorEnabled(id, false); return { success: true, message: `Plugin ${id} is not enabled` }; } try { await this.pluginLoader.disablePlugin(id); this.pluginLoader.setOperatorEnabled(id, false); return { success: true, message: `Plugin ${id} disabled successfully` }; } catch (error) { return { success: false, message: error instanceof Error ? error.message : String(error), }; } } updateSessions(id: string, sessions: string[], allowedSessions?: string[] | null): PluginDto { const plugin = this.pluginLoader.getPlugin(id); if (!plugin) { throw new NotFoundException(`Plugin ${id} not found`); } // A session-restricted key (non-empty allowedSessions) may only activate the plugin for sessions // in its own scope — never '*' (all) or another tenant's session. An unrestricted key (null/empty) // is the normal dashboard/admin path and may activate for any session, including '*'. if (allowedSessions && allowedSessions.length > 0) { const outOfScope = sessions.filter(s => s === '*' || !allowedSessions.includes(s)); if (outOfScope.length > 0) { throw new ForbiddenException(`API key not authorized for session(s): ${outOfScope.join(', ')}`); } } try { this.pluginLoader.setPluginSessions(id, sessions); } catch (error) { throw new BadRequestException(error instanceof Error ? error.message : String(error)); } return this.findOne(id); } updateConfig(id: string, config: Record): { success: boolean; message: string } { const plugin = this.pluginLoader.getPlugin(id); if (!plugin) { throw new NotFoundException(`Plugin ${id} not found`); } try { // The dashboard PUTs the whole (redacted) config back, so a sentinel secret means "unchanged": // restore the stored value instead of overwriting the real secret with the mask. const merged = restoreSecretConfig(config, plugin.config, plugin.manifest.configSchema); this.pluginLoader.updatePluginConfig(id, merged); return { success: true, message: `Plugin ${id} configuration updated` }; } catch (error) { return { success: false, message: error instanceof Error ? error.message : String(error), }; } } /** * Set a plugin's per-session config override for `sessionId`. Like updateConfig, the dashboard PUTs * the whole (redacted) slice back, so a sentinel secret restores the stored per-session value. An * empty slice clears the override (the session falls back to the base config). */ updateSessionConfig( id: string, sessionId: string, config: Record, ): { success: boolean; message: string } { const plugin = this.pluginLoader.getPlugin(id); if (!plugin) { throw new NotFoundException(`Plugin ${id} not found`); } if (plugin.manifest.sessionScoped === false) { // A global plugin has no per-session config — reject with 400 (mirrors PUT /:id/sessions). throw new BadRequestException(`Plugin ${id} is global (not session-scoped) and has no per-session config`); } try { const existing = plugin.sessionConfig?.[sessionId]; const merged = restoreSecretConfig(config, existing, plugin.manifest.configSchema); this.pluginLoader.setPluginSessionConfig(id, sessionId, merged); return { success: true, message: `Plugin ${id} configuration for session ${sessionId} updated` }; } catch (error) { return { success: false, message: error instanceof Error ? error.message : String(error), }; } } /** Redact secrets in every per-session config slice for the DTO (mirrors the base config redaction). */ private redactSessionConfig( sessionConfig: Record> | undefined, schema: PluginConfigSchema | undefined, ): Record> | undefined { if (!sessionConfig) return undefined; return Object.fromEntries( Object.entries(sessionConfig).map(([sid, cfg]) => [sid, redactSecretConfig(cfg, schema)]), ); } /** * Read a plugin's sandboxed config-UI entry HTML (manifest `configUi.entry`). The dashboard fetches * this with the API key and injects it as an iframe `srcdoc`, so the file must be self-contained. * Path is escape-guarded against the plugin directory; the entry is plugin-author-supplied. */ getConfigUiHtml(id: string): string { const plugin = this.pluginLoader.getPlugin(id); if (!plugin) { throw new NotFoundException(`Plugin ${id} not found`); } const entry = plugin.manifest.configUi?.entry; // `entry` is untrusted manifest JSON — a non-string (or escaping) value is treated as "no config // UI" (404), never a raw 500. if (!entry || typeof entry !== 'string') { throw new NotFoundException(`Plugin ${id} has no config UI`); } const base = path.resolve(this.pluginLoader.getPluginsDir(), id); let file: string; try { file = resolvePluginMainPath(this.pluginLoader.getPluginsDir(), id, entry); } catch { throw new NotFoundException(`Config UI entry not found for plugin ${id}`); } if (!fs.existsSync(file) || !fs.statSync(file).isFile()) { throw new NotFoundException(`Config UI entry not found for plugin ${id}`); } // Defense-in-depth: the lexical guard above is symlink-blind; resolve links on BOTH the file and // the plugin dir (so a symlinked tmp root like macOS /var→/private/var doesn't false-positive) and // re-check containment before reading an arbitrary host file into the main process and serving it. const real = fs.realpathSync(file); const realBase = fs.realpathSync(base); if (real !== realBase && !real.startsWith(realBase + path.sep)) { throw new NotFoundException(`Config UI entry not found for plugin ${id}`); } return fs.readFileSync(real, 'utf-8'); } /** Install a plugin from an uploaded .zip: validate the package, write it to the plugins dir, and load it. */ install(file?: { buffer?: Buffer }): PluginDto { if (!file?.buffer?.length) { throw new BadRequestException('No plugin file uploaded'); } const { manifest, entries } = parsePluginPackage(file.buffer); if (this.pluginLoader.getPlugin(manifest.id)) { throw new ConflictException(`Plugin "${manifest.id}" is already installed`); } const dir = path.join(this.pluginLoader.getPluginsDir(), manifest.id); if (fs.existsSync(dir)) { throw new ConflictException(`A plugin directory "${manifest.id}" already exists`); } // Write the validated entries then load; roll back the directory on any failure so a bad // package never leaves a half-installed plugin behind. try { for (const entry of entries) { const dest = path.join(dir, entry.relPath); fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.writeFileSync(dest, entry.data); } this.pluginLoader.loadPlugin(dir); } catch (error) { fs.rmSync(dir, { recursive: true, force: true }); if (error instanceof HttpException) throw error; throw new BadRequestException( `Failed to install plugin: ${error instanceof Error ? error.message : String(error)}`, ); } return this.findOne(manifest.id); } /** * Install a plugin from an HTTP(S) URL: download the .zip through the SSRF guard (host validated, * connection pinned, redirects refused, size-capped), then run the exact same validate-write-load * pipeline as an uploaded package. The downloaded buffer is treated as untrusted, identical to an upload. */ async installFromUrl(url: string): Promise { const maxBytes = this.configService.get('plugins.downloadMaxBytes') ?? 5 * 1024 * 1024; let buffer: Buffer; try { buffer = await fetchSafeBuffer(url, { maxBytes }); } catch (error) { throw new BadRequestException( `Failed to download plugin from URL: ${redactSsrfError(error, logger, 'plugin download')}`, ); } // Peek the id (the SSRF download stays outside the lock) so the install — which writes the plugin // directory — is serialized against any concurrent uninstall/update of the same id. const { manifest } = parsePluginPackage(buffer); return this.serialize(manifest.id, () => Promise.resolve(this.install({ buffer }))); } /** * Fetch the configured remote catalog (a plugins.json array) through the SSRF guard and annotate each * entry with this instance's install state (installed / installedVersion / updateAvailable). */ async getCatalog(): Promise { const url = this.configService.get('plugins.catalogUrl'); if (!url) return []; let raw: Buffer; try { raw = await fetchSafeBuffer(url, { maxBytes: CATALOG_MAX_BYTES }); } catch (error) { throw new BadRequestException( `Failed to fetch plugin catalog: ${redactSsrfError(error, logger, 'plugin catalog download')}`, ); } let entries: CatalogEntry[]; try { const parsed: unknown = JSON.parse(raw.toString('utf8')); if (!Array.isArray(parsed)) throw new Error('catalog is not a JSON array'); entries = parsed as CatalogEntry[]; } catch (error) { throw new BadRequestException( `Invalid plugin catalog JSON: ${error instanceof Error ? error.message : String(error)}`, ); } const installed = this.pluginLoader.getAllPlugins().map(p => ({ id: p.manifest.id, version: p.manifest.version })); return annotateCatalog(entries, installed); } /** * Update an installed plugin in place from a validated package buffer, preserving operator config and * the enabled state. The package id must match the installed id. Config survives because `unloadPlugin` * drops the plugin from memory but keeps its registry entry (config); `loadPlugin` re-reads it. The old * directory is backed up and restored if the swap or reload of the new version fails, so a bad update * never leaves the plugin broken. */ updatePackage(id: string, buffer: Buffer): Promise { return this.serialize(id, () => this.updatePackageInner(id, buffer)); } private async updatePackageInner(id: string, buffer: Buffer): Promise { const plugin = this.pluginLoader.getPlugin(id); if (!plugin) { throw new NotFoundException(`Plugin ${id} not found`); } if (this.pluginLoader.isBuiltIn(id)) { throw new BadRequestException(`Cannot update built-in plugin ${id}`); } // Validate the new package BEFORE touching the running plugin. An update must be the same plugin. const { manifest, entries } = parsePluginPackage(buffer); if (manifest.id !== id) { throw new BadRequestException(`Package id "${manifest.id}" does not match the plugin being updated ("${id}")`); } const wasEnabled = plugin.status === PluginStatus.ENABLED; const dir = path.join(this.pluginLoader.getPluginsDir(), id); // Dot-prefixed sibling inside pluginsDir: same filesystem (so the rename stays EXDEV-safe) but // skipped by the loader's directory scan, so a crash mid-update can't leave it loaded as a duplicate. const backup = path.join(this.pluginLoader.getPluginsDir(), `.${id}.bak`); // Stop the running plugin (terminates its sandbox worker) but keep its registry entry so config survives. await this.pluginLoader.unloadPlugin(id); fs.rmSync(backup, { recursive: true, force: true }); fs.renameSync(dir, backup); try { for (const entry of entries) { const dest = path.join(dir, entry.relPath); fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.writeFileSync(dest, entry.data); } // ctx.storage files share the package directory under shipped defaults. Restore service-owned // state from the backup unless the new package explicitly supplied that exact path. Copy (rather // than move) so the rollback below still has a complete original directory. const packagePaths = new Set(entries.map(entry => entry.relPath)); for (const entry of fs.readdirSync(backup, { withFileTypes: true })) { if (!entry.isFile() || !/^key-[A-Za-z0-9_-]+\.json$/.test(entry.name) || packagePaths.has(entry.name)) { continue; } const stateFile = path.join(dir, entry.name); fs.copyFileSync(path.join(backup, entry.name), stateFile); fs.chmodSync(stateFile, 0o600); } this.pluginLoader.loadPlugin(dir); if (wasEnabled) { await this.pluginLoader.enablePlugin(id); } fs.rmSync(backup, { recursive: true, force: true }); } catch (error) { // Roll back to the previous version: restore the backed-up directory and reload it. // The failed forward path may have left the NEW version in the loader map (loadPlugin // succeeded; enablePlugin failed with status=ERROR but did NOT remove it), so drop it first — // otherwise the restore's loadPlugin() hits the "already loaded" guard and the runtime stays // desynced from disk (new manifest in memory, old files on disk). unloadPlugin throws when // nothing is loaded (the loadPlugin-itself-failed case), hence the catch. await this.pluginLoader.unloadPlugin(id).catch(() => undefined); fs.rmSync(dir, { recursive: true, force: true }); fs.renameSync(backup, dir); try { this.pluginLoader.loadPlugin(dir); if (wasEnabled) await this.pluginLoader.enablePlugin(id); } catch { /* best-effort restore; surface the original failure below */ } if (error instanceof HttpException) throw error; throw new BadRequestException( `Failed to update plugin: ${error instanceof Error ? error.message : String(error)}`, ); } return this.findOne(id); } /** Update an installed plugin by downloading the new package from a URL (SSRF-guarded), then in place. */ async updateFromUrl(id: string, url: string): Promise { const maxBytes = this.configService.get('plugins.downloadMaxBytes') ?? 5 * 1024 * 1024; let buffer: Buffer; try { buffer = await fetchSafeBuffer(url, { maxBytes }); } catch (error) { throw new BadRequestException( `Failed to download plugin from URL: ${redactSsrfError(error, logger, 'plugin download')}`, ); } return this.updatePackage(id, buffer); } /** Uninstall an installed user plugin: disable, unload, and delete its files. Built-ins are protected. */ uninstall(id: string): Promise<{ success: boolean; message: string }> { return this.serialize(id, () => this.uninstallInner(id)); } private async uninstallInner(id: string): Promise<{ success: boolean; message: string }> { const plugin = this.pluginLoader.getPlugin(id); if (!plugin) { throw new NotFoundException(`Plugin ${id} not found`); } try { await this.pluginLoader.uninstallPlugin(id); return { success: true, message: `Plugin ${id} uninstalled successfully` }; } catch (error) { throw new BadRequestException(error instanceof Error ? error.message : String(error)); } } async healthCheck(id: string): Promise<{ healthy: boolean; message?: string }> { const plugin = this.pluginLoader.getPlugin(id); if (!plugin) { throw new NotFoundException(`Plugin ${id} not found`); } try { // Delegate to the loader so a sandboxed plugin's healthCheck (which runs in the worker, where // plugin.instance is null) is reached too — the old plugin.instance check always returned the // default "healthy" for sandboxed plugins, blinding health monitoring. return await this.pluginLoader.checkPluginHealth(id); } catch (error) { return { healthy: false, message: error instanceof Error ? error.message : String(error), }; } } }