qwen_2.5_model / src /modules /plugins /plugins.service.ts
Muhammad Noman
Deploy OpenWA to Hugging Face Spaces
46252cd
Raw
History Blame Contribute Delete
22.8 kB
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<string, Promise<unknown>>();
private serialize<T>(id: string, op: () => Promise<T>): Promise<T> {
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<string, unknown>): { 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<string, unknown>,
): { 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<string, Record<string, unknown>> | undefined,
schema: PluginConfigSchema | undefined,
): Record<string, Record<string, unknown>> | 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<PluginDto> {
const maxBytes = this.configService.get<number>('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<CatalogPlugin[]> {
const url = this.configService.get<string>('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<PluginDto> {
return this.serialize(id, () => this.updatePackageInner(id, buffer));
}
private async updatePackageInner(id: string, buffer: Buffer): Promise<PluginDto> {
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<PluginDto> {
const maxBytes = this.configService.get<number>('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),
};
}
}
}