From 0a771c96005e3afe4056126103fa35337a009e70 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Mon, 21 Sep 2026 07:13:51 -0500 Subject: [PATCH] feat: add plugin loader with manifest validation and crash restart Validates an unpacked plugin, spawns a worker_threads worker and manages activate/deactivate/crash-restart with backoff, disabling after three tries. The crash counter clears only after a plugin stays up past a stability window, not on activation. Clearing it on activation let a plugin that activates cleanly and then dies restart forever, because every attempt looked like the first. loader.ts documents what the worker boundary does and does not buy you. Node's permission model is process-wide, not per-worker, so it cannot be enabled for plugin workers alone and is not claimed here. --- src/backend/plugins/loader.ts | 482 ++++++++++++++++++++ src/backend/plugins/manifest.ts | 329 +++++++++++++ src/backend/tests/plugins/fixture-plugin.ts | 82 ++++ src/backend/tests/plugins/loader.test.ts | 332 ++++++++++++++ src/backend/tests/plugins/manifest.test.ts | 115 +++++ src/backend/utils/logger.ts | 1 + 6 files changed, 1341 insertions(+) create mode 100644 src/backend/plugins/loader.ts create mode 100644 src/backend/plugins/manifest.ts create mode 100644 src/backend/tests/plugins/fixture-plugin.ts create mode 100644 src/backend/tests/plugins/loader.test.ts create mode 100644 src/backend/tests/plugins/manifest.test.ts diff --git a/src/backend/plugins/loader.ts b/src/backend/plugins/loader.ts new file mode 100644 index 000000000..d253dfb2b --- /dev/null +++ b/src/backend/plugins/loader.ts @@ -0,0 +1,482 @@ +/** + * Loads unpacked plugins and owns their worker lifecycle. + * + * ## What the worker boundary actually buys you + * + * Be precise about this, because it is easy to overclaim. + * + * ENFORCED: + * - The plugin is handed a `ctx` object and nothing else. It gets no db + * handle, no fs module, no socket, and no credential. Everything it can do + * goes through a postMessage round trip to the broker, which checks the + * plugin's grants first and writes an audit line after. + * - Only structured-cloneable values cross the boundary, so a live object + * cannot leak by reference even by mistake. + * - The worker does not inherit process.env, so it cannot read secrets that + * were passed to the server that way. Only TMPDIR/TMP/TEMP are forwarded. + * - resourceLimits caps the worker heap, so a runaway allocation kills the + * worker instead of the server. + * + * NOT ENFORCED, and not claimed: + * - A worker_threads worker is NOT a security sandbox. It shares the process. + * A plugin that wants to can `import("node:fs")` and read whatever the + * server user can read. It can burn CPU. It can crash the process through + * native addons. + * - Node's permission model (--permission) is the only thing that would + * actually restrict builtins, and it is process-wide, not per-worker, so it + * cannot be turned on for plugin workers alone. execArgv on a Worker does + * not accept it. + * + * So the honest boundary is: plugins are code you chose to install, and this + * design guarantees they cannot QUIETLY reach data they were not granted -- + * every privileged path is gated and audited. It does not guarantee + * containment. If containment is ever required, the answer is a child process + * with --permission, or a WASM/vm isolate, not worker_threads. + */ + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { Worker } from "node:worker_threads"; +import { pluginLogger } from "../utils/logger.js"; +import { parseManifest, type PluginManifest } from "./manifest.js"; +import { + getPluginBackendEntry, + getPluginManifestPath, + getPluginsDir, +} from "./paths.js"; +import type { PluginBootstrapData } from "./protocol.js"; + +export type PluginState = + | "loaded" + | "activating" + | "active" + | "stopping" + | "stopped" + | "crashed" + | "disabled"; + +export const MAX_RESTART_ATTEMPTS = 3; +const RESTART_BACKOFF_MS = [1000, 5000, 15000]; +const ACTIVATION_TIMEOUT_MS = 30_000; +const STABILITY_WINDOW_MS = 60_000; +const WORKER_MAX_OLD_HEAP_MB = 256; + +export interface LoadedPlugin { + id: string; + dir: string; + manifest: PluginManifest; + state: PluginState; + worker: Worker | null; + /** Consecutive crashes since the last successful activation. */ + restartAttempts: number; + lastError: string | null; + /** The user whose authority this plugin acts under. Set at activate time. */ + ownerUserId: string | null; +} + +export interface PluginLoaderOptions { + /** + * Called with each worker's port right after spawn so the broker can attach. + * Kept as a hook rather than a hard import so the loader stays testable on + * its own, before the broker exists. + */ + onWorkerReady?: (plugin: LoadedPlugin, worker: Worker) => void; + onWorkerGone?: (plugin: LoadedPlugin) => void; + /** Overridable so tests do not have to wait out the real backoff. */ + restartBackoffMs?: number[]; + /** How long a plugin must stay up before its crash counter resets. */ + stabilityWindowMs?: number; +} + +/** + * Resolved from this module's own location, never from process.cwd(): the build + * emits to dist/backend/backend/, and cwd is wherever the server was started + * from. + * + * Under vitest this module is the .ts source, so the sibling is + * worker-bootstrap.ts and the worker needs the tsx loader to run it. In a built + * server both are plain .js and no loader is involved. + */ +function resolveWorkerBootstrap(): { entry: string; execArgv?: string[] } { + const here = path.dirname(fileURLToPath(import.meta.url)); + + const compiled = path.join(here, "worker-bootstrap.js"); + if (fs.existsSync(compiled)) return { entry: compiled }; + + const source = path.join(here, "worker-bootstrap.ts"); + if (fs.existsSync(source)) { + return { entry: source, execArgv: ["--import", "tsx"] }; + } + + throw new Error(`Could not locate the plugin worker bootstrap near ${here}`); +} + +export class PluginLoader { + private readonly plugins = new Map(); + private readonly restartTimers = new Map(); + private readonly stabilityTimers = new Map(); + + constructor(private readonly options: PluginLoaderOptions = {}) {} + + list(): LoadedPlugin[] { + return [...this.plugins.values()]; + } + + get(pluginId: string): LoadedPlugin | undefined { + return this.plugins.get(pluginId); + } + + /** + * Reads and validates a plugin directory. Does not start anything. + */ + async load(dir: string): Promise { + const manifestPath = getPluginManifestPath(dir); + + let raw: unknown; + try { + raw = JSON.parse(await fs.promises.readFile(manifestPath, "utf8")); + } catch (error) { + throw new Error( + `Could not read plugin manifest at ${manifestPath}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + + const { manifest, errors } = parseManifest(raw); + if (!manifest) { + throw new Error( + `Invalid plugin manifest at ${manifestPath}:\n - ${errors.join("\n - ")}`, + ); + } + + if (path.basename(dir) !== manifest.id) { + throw new Error( + `Plugin directory "${path.basename(dir)}" does not match manifest id "${manifest.id}"`, + ); + } + + if (manifest.capabilities.backend) { + const entry = getPluginBackendEntry(dir); + if (!fs.existsSync(entry)) { + throw new Error( + `Plugin ${manifest.id} declares a backend but ${entry} is missing`, + ); + } + } + + const plugin: LoadedPlugin = { + id: manifest.id, + dir, + manifest, + state: "loaded", + worker: null, + restartAttempts: 0, + lastError: null, + ownerUserId: null, + }; + + this.plugins.set(manifest.id, plugin); + pluginLogger.info(`Loaded plugin ${manifest.id}@${manifest.version}`, { + operation: "plugin_load", + }); + return plugin; + } + + /** Scans the plugins directory, loading every valid plugin it finds. */ + async loadAll(): Promise { + const root = getPluginsDir(); + if (!fs.existsSync(root)) return []; + + const entries = await fs.promises.readdir(root, { withFileTypes: true }); + const loaded: LoadedPlugin[] = []; + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + try { + loaded.push(await this.load(path.join(root, entry.name))); + } catch (error) { + pluginLogger.error( + `Skipping plugin directory ${entry.name}`, + error instanceof Error ? error : new Error(String(error)), + { operation: "plugin_load" }, + ); + } + } + + return loaded; + } + + async activate(pluginId: string, ownerUserId: string): Promise { + const plugin = this.requirePlugin(pluginId); + + if (plugin.state === "active" || plugin.state === "activating") return; + if (!plugin.manifest.capabilities.backend) { + throw new Error(`Plugin ${pluginId} has no backend to activate`); + } + + plugin.ownerUserId = ownerUserId; + await this.spawn(plugin); + } + + private async spawn(plugin: LoadedPlugin): Promise { + plugin.state = "activating"; + plugin.lastError = null; + + const bootstrap: PluginBootstrapData = { + pluginId: plugin.id, + pluginDir: plugin.dir, + // file:// so the worker's dynamic import works on Windows too. + entryPath: pathToFileUrlString(getPluginBackendEntry(plugin.dir)), + manifest: plugin.manifest, + }; + + const { entry, execArgv } = resolveWorkerBootstrap(); + const worker = new Worker(entry, { + workerData: bootstrap, + // Deliberately not process.env: the worker must not inherit secrets + // passed to the server that way. Only the temp-dir vars are forwarded, + // because tooling that writes a cache falls back to the literal string + // "undefined" without them. + env: pickTempEnv(), + resourceLimits: { maxOldGenerationSizeMb: WORKER_MAX_OLD_HEAP_MB }, + stdout: true, + stderr: true, + ...(execArgv ? { execArgv } : {}), + }); + + plugin.worker = worker; + this.options.onWorkerReady?.(plugin, worker); + + worker.on("error", (error: Error) => { + plugin.lastError = error.message; + pluginLogger.error(`Plugin ${plugin.id} worker error`, error, { + operation: "plugin_worker", + }); + }); + + worker.on("exit", (code: number) => { + this.handleExit(plugin, code); + }); + + await this.awaitActivation(plugin, worker); + } + + private awaitActivation(plugin: LoadedPlugin, worker: Worker): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup(); + void this.terminate(plugin); + reject( + new Error( + `Plugin ${plugin.id} did not activate within ${ACTIVATION_TIMEOUT_MS}ms`, + ), + ); + }, ACTIVATION_TIMEOUT_MS); + + const onMessage = (message: unknown) => { + const m = message as { + id?: number; + ok?: boolean; + error?: { message: string }; + }; + // id 0 is reserved for the bootstrap's activation result. + if (!m || m.id !== 0) return; + cleanup(); + + if (m.ok) { + plugin.state = "active"; + this.scheduleStabilityReset(plugin); + pluginLogger.success(`Activated plugin ${plugin.id}`, { + operation: "plugin_activate", + }); + resolve(); + } else { + plugin.lastError = m.error?.message ?? "activation failed"; + void this.terminate(plugin); + reject(new Error(plugin.lastError)); + } + }; + + const onExit = () => { + cleanup(); + reject( + new Error( + `Plugin ${plugin.id} worker exited before activating${ + plugin.lastError ? `: ${plugin.lastError}` : "" + }`, + ), + ); + }; + + function cleanup() { + clearTimeout(timer); + worker.off("message", onMessage); + worker.off("exit", onExit); + } + + worker.on("message", onMessage); + worker.on("exit", onExit); + }); + } + + /** + * Clears the crash counter only once a plugin has stayed up for a while. + * Resetting it on activation alone would let a plugin that activates cleanly + * and then dies restart forever, because each attempt would look like the + * first one. + */ + private scheduleStabilityReset(plugin: LoadedPlugin): void { + const existing = this.stabilityTimers.get(plugin.id); + if (existing) clearTimeout(existing); + + const timer = setTimeout(() => { + this.stabilityTimers.delete(plugin.id); + if (plugin.state === "active") plugin.restartAttempts = 0; + }, this.options.stabilityWindowMs ?? STABILITY_WINDOW_MS); + + timer.unref?.(); + this.stabilityTimers.set(plugin.id, timer); + } + + private handleExit(plugin: LoadedPlugin, code: number): void { + const stabilityTimer = this.stabilityTimers.get(plugin.id); + if (stabilityTimer) { + clearTimeout(stabilityTimer); + this.stabilityTimers.delete(plugin.id); + } + + plugin.worker = null; + this.options.onWorkerGone?.(plugin); + + // A deliberate stop is not a crash. + if (plugin.state === "stopping" || plugin.state === "stopped") { + plugin.state = "stopped"; + return; + } + if (plugin.state === "disabled") return; + + plugin.state = "crashed"; + plugin.restartAttempts += 1; + + if (plugin.restartAttempts > MAX_RESTART_ATTEMPTS) { + plugin.state = "disabled"; + pluginLogger.error( + `Plugin ${plugin.id} crashed ${MAX_RESTART_ATTEMPTS} times and has been disabled`, + new Error(plugin.lastError ?? `worker exited with code ${code}`), + { operation: "plugin_crash" }, + ); + return; + } + + const backoff = this.options.restartBackoffMs ?? RESTART_BACKOFF_MS; + const delay = + backoff[plugin.restartAttempts - 1] ?? backoff[backoff.length - 1]; + + pluginLogger.warn( + `Plugin ${plugin.id} crashed, restarting in ${delay}ms (attempt ${plugin.restartAttempts}/${MAX_RESTART_ATTEMPTS})`, + { operation: "plugin_crash" }, + ); + + const timer = setTimeout(() => { + this.restartTimers.delete(plugin.id); + if (plugin.state !== "crashed") return; + this.spawn(plugin).catch((error) => { + plugin.lastError = + error instanceof Error ? error.message : String(error); + }); + }, delay); + + this.restartTimers.set(plugin.id, timer); + } + + /** + * Clears the crash counter and starts the plugin again. This is what the + * Retry button calls once a plugin has been auto-disabled. + */ + async retry(pluginId: string): Promise { + const plugin = this.requirePlugin(pluginId); + plugin.restartAttempts = 0; + plugin.state = "loaded"; + plugin.lastError = null; + + if (!plugin.ownerUserId) { + throw new Error( + `Plugin ${pluginId} has never been activated, so there is nothing to retry`, + ); + } + await this.spawn(plugin); + } + + async deactivate(pluginId: string): Promise { + const plugin = this.requirePlugin(pluginId); + + const timer = this.restartTimers.get(pluginId); + if (timer) { + clearTimeout(timer); + this.restartTimers.delete(pluginId); + } + const stabilityTimer = this.stabilityTimers.get(pluginId); + if (stabilityTimer) { + clearTimeout(stabilityTimer); + this.stabilityTimers.delete(pluginId); + } + + if (!plugin.worker) { + plugin.state = "stopped"; + return; + } + + plugin.state = "stopping"; + await this.terminate(plugin); + plugin.state = "stopped"; + } + + private async terminate(plugin: LoadedPlugin): Promise { + const worker = plugin.worker; + if (!worker) return; + plugin.worker = null; + try { + await worker.terminate(); + } catch { + // Already gone. + } + } + + /** Stops everything. Called on shutdown and by tests. */ + async shutdown(): Promise { + for (const timer of this.restartTimers.values()) clearTimeout(timer); + this.restartTimers.clear(); + for (const timer of this.stabilityTimers.values()) clearTimeout(timer); + this.stabilityTimers.clear(); + + await Promise.all( + [...this.plugins.values()].map(async (plugin) => { + plugin.state = "stopping"; + await this.terminate(plugin); + plugin.state = "stopped"; + }), + ); + } + + private requirePlugin(pluginId: string): LoadedPlugin { + const plugin = this.plugins.get(pluginId); + if (!plugin) throw new Error(`Plugin ${pluginId} is not loaded`); + return plugin; + } +} + +function pickTempEnv(): Record { + const env: Record = {}; + for (const key of ["TMPDIR", "TMP", "TEMP"]) { + const value = process.env[key]; + if (value) env[key] = value; + } + return env; +} + +function pathToFileUrlString(filePath: string): string { + const resolved = path.resolve(filePath).replace(/\\/g, "/"); + return `file://${resolved.startsWith("/") ? "" : "/"}${resolved}`; +} diff --git a/src/backend/plugins/manifest.ts b/src/backend/plugins/manifest.ts new file mode 100644 index 000000000..a8643ba61 --- /dev/null +++ b/src/backend/plugins/manifest.ts @@ -0,0 +1,329 @@ +/** + * Runtime plugin manifest validation. + * + * This is a TypeScript port of scripts/validate-plugin-manifest.cjs. The two + * are deliberately separate: the .cjs one is a build/authoring tool run from a + * checkout, this one runs inside the server, where scripts/ does not exist + * (the Docker image only ships dist/). Both enforce the same contract, which + * is written down in scripts/plugin-manifest.schema.json. + * + * The enums below are duplicated from that schema rather than read from it at + * runtime, for the same reason. If you change the schema, change both. + */ + +export const PLUGIN_PERMISSIONS = [ + "hosts.read", + "hosts.write", + "credentials.use", + "ssh.exec", + "ssh.sftp", + "storage.own", + "storage.secrets", + "network.outbound", + "events.read", + "notify.send", + "users.read", + "process.sidecar", + "ui.tab", + "ui.rail", + "ui.card", + "ui.settings", + "ui.palette", +] as const; + +export type PluginPermission = (typeof PLUGIN_PERMISSIONS)[number]; + +export const PLUGIN_CATEGORIES = [ + "Terminal", + "Files & Transfer", + "Infrastructure", + "Monitoring", + "Networking", + "Access & Security", + "Productivity", +] as const; + +const OPEN_FROM_VALUES = ["rail", "host-context-menu", "palette"]; + +const REQUIRED_TOP_LEVEL = [ + "id", + "name", + "version", + "description", + "author", + "license", + "engine", + "category", + "capabilities", + "permissions", + "sidecars", +]; + +const ID_PATTERN = /^[a-z0-9-]+$/; +const SEMVER_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-.]+)?(\+[0-9A-Za-z-.]+)?$/; +const API_VERSION_PATTERN = /^[0-9]+$/; +const API_PREFIX_PATTERN = /^[a-z0-9-]+$/; + +/** + * The plugin SDK major version this build implements. A manifest asking for a + * different engine.api is refused rather than loaded and hoped for -- this is + * the real compatibility gate (engine.termix is informational only). + */ +export const SUPPORTED_PLUGIN_API_VERSION = "1"; + +export interface PluginManifest { + id: string; + name: string; + version: string; + description: string; + author: { name: string; url?: string }; + license: string; + repository?: string; + category: string; + icon?: string; + engine: { termix: string; api: string }; + capabilities: { + backend: boolean; + frontend: boolean; + electron: boolean; + platforms: string[]; + }; + permissions: string[]; + contributes?: { + tabs?: Array<{ + id: string; + titleKey: string; + icon: string; + openFrom: string[]; + }>; + hostCapability?: { key: string; labelKey: string; editorTab: string }; + permissionGroup?: { + group: string; + permissions: string[]; + defaultForRole?: Record; + }; + settingsPanel?: { titleKey: string }; + dashboardCards?: Array<{ id: string; titleKey: string }>; + apiPrefix?: string; + }; + dependencies?: { plugins?: Record }; + sidecars: Array<{ id: string; binary: string }>; +} + +export function validateManifest(manifest: unknown): string[] { + const errors: string[] = []; + + if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) { + return ["Manifest must be a JSON object"]; + } + + const m = manifest as Record; + + for (const field of REQUIRED_TOP_LEVEL) { + if (!(field in m)) { + errors.push(`Missing required field: "${field}"`); + } + } + + if (typeof m.id === "string" && !ID_PATTERN.test(m.id)) { + errors.push(`Field "id" must match ${ID_PATTERN}, got: "${m.id}"`); + } + + if (typeof m.version === "string" && !SEMVER_PATTERN.test(m.version)) { + errors.push(`Field "version" must be valid semver, got: "${m.version}"`); + } + + if (m.author && typeof m.author === "object") { + if (!(m.author as Record).name) { + errors.push('Field "author.name" is required'); + } + } + + if (typeof m.category === "string") { + if (!PLUGIN_CATEGORIES.includes(m.category as never)) { + errors.push( + `Field "category" must be one of: ${PLUGIN_CATEGORIES.join(", ")}, got: "${m.category}"`, + ); + } + } else if ("category" in m) { + errors.push('Field "category" must be a string'); + } + + if ("icon" in m && typeof m.icon !== "string") { + errors.push('Field "icon" must be a string'); + } + + if (m.engine && typeof m.engine === "object") { + const engine = m.engine as Record; + if (!engine.termix) { + errors.push('Field "engine.termix" is required'); + } + if (!API_VERSION_PATTERN.test(String(engine.api))) { + errors.push( + `Field "engine.api" must be an integer-as-string, got: "${engine.api}"`, + ); + } + } + + if (m.capabilities && typeof m.capabilities === "object") { + const caps = m.capabilities as Record; + for (const field of ["backend", "frontend", "electron"]) { + if (typeof caps[field] !== "boolean") { + errors.push(`Field "capabilities.${field}" must be a boolean`); + } + } + if (!Array.isArray(caps.platforms)) { + errors.push('Field "capabilities.platforms" must be an array'); + } + } + + if (Array.isArray(m.permissions)) { + for (const permission of m.permissions) { + if (!PLUGIN_PERMISSIONS.includes(permission as never)) { + errors.push( + `Unknown permission: "${permission}". Known values: ${PLUGIN_PERMISSIONS.join(", ")}`, + ); + } + } + } else if ("permissions" in m) { + errors.push('Field "permissions" must be an array'); + } + + if (Array.isArray(m.sidecars)) { + m.sidecars.forEach((sidecar: unknown, index: number) => { + if (!sidecar || typeof sidecar !== "object") { + errors.push(`sidecars[${index}] must be an object`); + return; + } + const s = sidecar as Record; + if (!s.id) errors.push(`sidecars[${index}].id is required`); + if (!s.binary) errors.push(`sidecars[${index}].binary is required`); + }); + } else if ("sidecars" in m) { + errors.push('Field "sidecars" must be an array'); + } + + if (m.contributes && typeof m.contributes === "object") { + errors.push( + ...validateContributes(m.contributes as Record), + ); + } + + return errors; +} + +function validateContributes(contributes: Record): string[] { + const errors: string[] = []; + + if ("tabs" in contributes) { + if (!Array.isArray(contributes.tabs)) { + errors.push('Field "contributes.tabs" must be an array'); + } else { + contributes.tabs.forEach((tab: unknown, index: number) => { + const t = (tab ?? {}) as Record; + for (const field of ["id", "titleKey", "icon", "openFrom"]) { + if (!(field in t)) { + errors.push(`contributes.tabs[${index}].${field} is required`); + } + } + if (Array.isArray(t.openFrom)) { + for (const value of t.openFrom) { + if (!OPEN_FROM_VALUES.includes(value as string)) { + errors.push( + `contributes.tabs[${index}].openFrom has unknown value: "${value}"`, + ); + } + } + } + }); + } + } + + if ("hostCapability" in contributes) { + const hostCapability = (contributes.hostCapability ?? {}) as Record< + string, + unknown + >; + for (const field of ["key", "labelKey", "editorTab"]) { + if (!(field in hostCapability)) { + errors.push(`contributes.hostCapability.${field} is required`); + } + } + } + + if ("permissionGroup" in contributes) { + const group = (contributes.permissionGroup ?? {}) as Record< + string, + unknown + >; + if (!group.group) { + errors.push("contributes.permissionGroup.group is required"); + } + if (!Array.isArray(group.permissions) || group.permissions.length === 0) { + errors.push( + "contributes.permissionGroup.permissions must be a non-empty array", + ); + } + } + + if ("settingsPanel" in contributes) { + const panel = (contributes.settingsPanel ?? {}) as Record; + if (!panel.titleKey) { + errors.push("contributes.settingsPanel.titleKey is required"); + } + } + + if ("dashboardCards" in contributes) { + if (!Array.isArray(contributes.dashboardCards)) { + errors.push('Field "contributes.dashboardCards" must be an array'); + } else { + contributes.dashboardCards.forEach((card: unknown, index: number) => { + const c = (card ?? {}) as Record; + for (const field of ["id", "titleKey"]) { + if (!(field in c)) { + errors.push( + `contributes.dashboardCards[${index}].${field} is required`, + ); + } + } + }); + } + } + + if ("apiPrefix" in contributes) { + if ( + typeof contributes.apiPrefix !== "string" || + !API_PREFIX_PATTERN.test(contributes.apiPrefix) + ) { + errors.push( + `Field "contributes.apiPrefix" must match ${API_PREFIX_PATTERN}`, + ); + } + } + + return errors; +} + +/** + * Validates and narrows in one step. Unlike validateManifest, this also + * enforces the SDK compatibility gate, which only the runtime cares about -- + * the authoring-time validator has no single Termix build to check against. + */ +export function parseManifest(raw: unknown): { + manifest?: PluginManifest; + errors: string[]; +} { + const errors = validateManifest(raw); + if (errors.length > 0) return { errors }; + + const manifest = raw as PluginManifest; + if (manifest.engine.api !== SUPPORTED_PLUGIN_API_VERSION) { + return { + errors: [ + `Plugin targets SDK api version "${manifest.engine.api}", this Termix build implements "${SUPPORTED_PLUGIN_API_VERSION}"`, + ], + }; + } + + return { manifest, errors: [] }; +} diff --git a/src/backend/tests/plugins/fixture-plugin.ts b/src/backend/tests/plugins/fixture-plugin.ts new file mode 100644 index 000000000..075406327 --- /dev/null +++ b/src/backend/tests/plugins/fixture-plugin.ts @@ -0,0 +1,82 @@ +/** + * Builds a real unpacked plugin on disk for the loader/broker tests. + * + * The backend entry has to be a genuine .mjs file the worker can import, and + * tsc only emits .ts, so these are written at test time rather than committed. + * Same mkdtemp convention the other disk-touching backend tests use. + */ + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { SUPPORTED_PLUGIN_API_VERSION } from "../../plugins/manifest.js"; + +export interface FixtureOptions { + id?: string; + permissions?: string[]; + /** Body of backend/index.mjs. Must export activate(ctx). */ + backendSource?: string; + /** Omit the backend entry file even though the manifest declares one. */ + omitBackendEntry?: boolean; + manifestOverrides?: Record; +} + +export interface Fixture { + root: string; + dir: string; + id: string; + cleanup: () => void; +} + +const DEFAULT_BACKEND = ` +export async function activate(ctx) { + ctx.__ready = true; +} +`; + +export function createFixturePlugin(options: FixtureOptions = {}): Fixture { + const id = options.id ?? "sample-plugin"; + const root = fs.mkdtempSync(path.join(os.tmpdir(), "termix-plugins-")); + const dir = path.join(root, id); + + fs.mkdirSync(path.join(dir, "backend"), { recursive: true }); + + const manifest = { + id, + name: "Sample Plugin", + version: "1.0.0", + description: "Fixture plugin for runtime tests.", + author: { name: "Termix Tests" }, + license: "MIT", + category: "Productivity", + engine: { termix: ">=2.9.0", api: SUPPORTED_PLUGIN_API_VERSION }, + capabilities: { + backend: true, + frontend: false, + electron: false, + platforms: ["linux", "win32", "darwin"], + }, + permissions: options.permissions ?? ["hosts.read", "storage.own"], + sidecars: [], + ...options.manifestOverrides, + }; + + fs.writeFileSync( + path.join(dir, "manifest.json"), + JSON.stringify(manifest, null, 2), + ); + + if (!options.omitBackendEntry) { + fs.writeFileSync( + path.join(dir, "backend", "index.mjs"), + options.backendSource ?? DEFAULT_BACKEND, + ); + } + + return { + root, + dir, + id, + cleanup: () => fs.rmSync(root, { recursive: true, force: true }), + }; +} diff --git a/src/backend/tests/plugins/loader.test.ts b/src/backend/tests/plugins/loader.test.ts new file mode 100644 index 000000000..ff2d15144 --- /dev/null +++ b/src/backend/tests/plugins/loader.test.ts @@ -0,0 +1,332 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { PluginLoader, MAX_RESTART_ATTEMPTS } from "../../plugins/loader.js"; +import { createFixturePlugin, type Fixture } from "./fixture-plugin.js"; + +vi.mock("../../utils/logger.js", () => ({ + pluginLogger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + success: vi.fn(), + }, +})); + +// Worker spawn plus crash backoff does not fit the 5s backend default. +const WORKER_TIMEOUT = 30_000; + +describe("PluginLoader", () => { + const fixtures: Fixture[] = []; + let loader: PluginLoader | null = null; + + function fixture(...args: Parameters) { + const created = createFixturePlugin(...args); + fixtures.push(created); + return created; + } + + afterEach(async () => { + await loader?.shutdown(); + loader = null; + while (fixtures.length) fixtures.pop()!.cleanup(); + }); + + describe("load", () => { + it("loads a valid plugin directory", async () => { + const plugin = fixture(); + loader = new PluginLoader(); + + const loaded = await loader.load(plugin.dir); + + expect(loaded.id).toBe("sample-plugin"); + expect(loaded.state).toBe("loaded"); + expect(loaded.worker).toBeNull(); + expect(loader.get("sample-plugin")).toBe(loaded); + }); + + it("rejects a manifest that fails validation", async () => { + const plugin = fixture({ manifestOverrides: { version: "nope" } }); + loader = new PluginLoader(); + + await expect(loader.load(plugin.dir)).rejects.toThrow( + /Invalid plugin manifest/, + ); + }); + + it("rejects a directory whose name does not match the manifest id", async () => { + const plugin = fixture({ manifestOverrides: { id: "other-id" } }); + loader = new PluginLoader(); + + await expect(loader.load(plugin.dir)).rejects.toThrow( + /does not match manifest id/, + ); + }); + + it("rejects a declared backend with no entry file", async () => { + const plugin = fixture({ omitBackendEntry: true }); + loader = new PluginLoader(); + + await expect(loader.load(plugin.dir)).rejects.toThrow( + /declares a backend but/, + ); + }); + + it("rejects an unreadable manifest", async () => { + loader = new PluginLoader(); + await expect(loader.load("/nonexistent/plugin-dir")).rejects.toThrow( + /Could not read plugin manifest/, + ); + }); + }); + + describe("activate and deactivate", () => { + it( + "activates a plugin and runs its activate(ctx)", + async () => { + const plugin = fixture(); + loader = new PluginLoader(); + await loader.load(plugin.dir); + + await loader.activate("sample-plugin", "user-1"); + + const loaded = loader.get("sample-plugin")!; + expect(loaded.state).toBe("active"); + expect(loaded.worker).not.toBeNull(); + expect(loaded.ownerUserId).toBe("user-1"); + }, + WORKER_TIMEOUT, + ); + + it( + "surfaces an error thrown inside activate(ctx)", + async () => { + const plugin = fixture({ + backendSource: ` + export async function activate() { + throw new Error("boom from plugin"); + } + `, + }); + loader = new PluginLoader(); + await loader.load(plugin.dir); + + await expect( + loader.activate("sample-plugin", "user-1"), + ).rejects.toThrow(/boom from plugin/); + }, + WORKER_TIMEOUT, + ); + + it( + "rejects a backend entry with no activate export", + async () => { + const plugin = fixture({ + backendSource: `export const nothing = true;`, + }); + loader = new PluginLoader(); + await loader.load(plugin.dir); + + await expect( + loader.activate("sample-plugin", "user-1"), + ).rejects.toThrow(/does not export an activate\(ctx\) function/); + }, + WORKER_TIMEOUT, + ); + + it( + "deactivate terminates the worker and does not count as a crash", + async () => { + const plugin = fixture(); + loader = new PluginLoader(); + await loader.load(plugin.dir); + await loader.activate("sample-plugin", "user-1"); + + await loader.deactivate("sample-plugin"); + + const loaded = loader.get("sample-plugin")!; + expect(loaded.state).toBe("stopped"); + expect(loaded.worker).toBeNull(); + expect(loaded.restartAttempts).toBe(0); + }, + WORKER_TIMEOUT, + ); + + it( + "does not leak the server's environment into the worker", + async () => { + process.env.TERMIX_TEST_SECRET = "super-secret-value"; + + const plugin = fixture({ + backendSource: ` + export async function activate() { + if (process.env.TERMIX_TEST_SECRET) { + throw new Error("leaked:" + process.env.TERMIX_TEST_SECRET); + } + } + `, + }); + loader = new PluginLoader(); + await loader.load(plugin.dir); + + await loader.activate("sample-plugin", "user-1"); + expect(loader.get("sample-plugin")!.state).toBe("active"); + + delete process.env.TERMIX_TEST_SECRET; + }, + WORKER_TIMEOUT, + ); + + it("deactivating a never-started plugin is a no-op", async () => { + const plugin = fixture(); + loader = new PluginLoader(); + await loader.load(plugin.dir); + + await loader.deactivate("sample-plugin"); + expect(loader.get("sample-plugin")!.state).toBe("stopped"); + }); + + it("activating an unknown plugin throws", async () => { + loader = new PluginLoader(); + await expect(loader.activate("ghost", "user-1")).rejects.toThrow( + /Plugin ghost is not loaded/, + ); + }); + }); + + describe("crash restart", () => { + it( + "restarts a crashed plugin, then disables it after the retry budget", + async () => { + // Exits on a timer so activation succeeds first, then the worker dies. + const plugin = fixture({ + backendSource: ` + export async function activate() { + setTimeout(() => process.exit(1), 10); + } + `, + }); + + loader = new PluginLoader({ restartBackoffMs: [10, 10, 10] }); + await loader.load(plugin.dir); + await loader.activate("sample-plugin", "user-1"); + + const loaded = loader.get("sample-plugin")!; + await vi.waitFor( + () => { + expect(loaded.state).toBe("disabled"); + }, + { timeout: 25_000, interval: 50 }, + ); + + expect(loaded.restartAttempts).toBeGreaterThan(MAX_RESTART_ATTEMPTS); + expect(loaded.worker).toBeNull(); + }, + WORKER_TIMEOUT, + ); + + it( + "retry clears the crash counter and starts the plugin again", + async () => { + const plugin = fixture(); + loader = new PluginLoader(); + await loader.load(plugin.dir); + await loader.activate("sample-plugin", "user-1"); + + const loaded = loader.get("sample-plugin")!; + // Simulate having been auto-disabled. + await loader.deactivate("sample-plugin"); + loaded.state = "disabled"; + loaded.restartAttempts = MAX_RESTART_ATTEMPTS + 1; + + await loader.retry("sample-plugin"); + + expect(loaded.state).toBe("active"); + // retry() clears the counter up front, before the stability window. + expect(loaded.restartAttempts).toBe(0); + }, + WORKER_TIMEOUT, + ); + + it( + "clears the crash counter once a plugin stays up past the stability window", + async () => { + const plugin = fixture(); + loader = new PluginLoader({ stabilityWindowMs: 50 }); + await loader.load(plugin.dir); + + const loaded = loader.get("sample-plugin")!; + loaded.restartAttempts = 2; + await loader.activate("sample-plugin", "user-1"); + + await vi.waitFor( + () => { + expect(loaded.restartAttempts).toBe(0); + }, + { timeout: 5_000, interval: 25 }, + ); + }, + WORKER_TIMEOUT, + ); + + it("retry on a plugin that never activated throws", async () => { + const plugin = fixture(); + loader = new PluginLoader(); + await loader.load(plugin.dir); + + await expect(loader.retry("sample-plugin")).rejects.toThrow( + /never been activated/, + ); + }); + }); + + describe("loadAll", () => { + it("returns an empty list when the plugins dir does not exist", async () => { + const previous = process.env.DATA_DIR; + process.env.DATA_DIR = "/nonexistent/data-dir"; + loader = new PluginLoader(); + + expect(await loader.loadAll()).toEqual([]); + + if (previous === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = previous; + }); + + it("skips invalid directories and loads the rest", async () => { + const good = fixture({ id: "good-plugin" }); + // Share the same root so both sit under one plugins dir. + const bad = createFixturePlugin({ + id: "bad-plugin", + manifestOverrides: { version: "nope" }, + }); + fixtures.push(bad); + + const fs = await import("node:fs"); + const path = await import("node:path"); + fs.cpSync(bad.dir, path.join(good.root, "bad-plugin"), { + recursive: true, + }); + + const previous = process.env.DATA_DIR; + // getPluginsDir() appends "plugins", so point DATA_DIR at the parent. + const dataDir = path.join(good.root, ".."); + const pluginsDir = path.join(dataDir, "plugins"); + fs.mkdirSync(pluginsDir, { recursive: true }); + fs.cpSync(good.dir, path.join(pluginsDir, "good-plugin"), { + recursive: true, + }); + fs.cpSync(bad.dir, path.join(pluginsDir, "bad-plugin"), { + recursive: true, + }); + process.env.DATA_DIR = dataDir; + + loader = new PluginLoader(); + const loaded = await loader.loadAll(); + + expect(loaded.map((p) => p.id)).toEqual(["good-plugin"]); + + fs.rmSync(pluginsDir, { recursive: true, force: true }); + if (previous === undefined) delete process.env.DATA_DIR; + else process.env.DATA_DIR = previous; + }); + }); +}); diff --git a/src/backend/tests/plugins/manifest.test.ts b/src/backend/tests/plugins/manifest.test.ts new file mode 100644 index 000000000..0bede6cd4 --- /dev/null +++ b/src/backend/tests/plugins/manifest.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; +import { + parseManifest, + validateManifest, + SUPPORTED_PLUGIN_API_VERSION, +} from "../../plugins/manifest.js"; + +function validManifest(overrides: Record = {}) { + return { + id: "sample-plugin", + name: "Sample Plugin", + version: "1.0.0", + description: "A sample plugin.", + author: { name: "Jane Doe" }, + license: "MIT", + category: "Productivity", + engine: { termix: ">=2.9.0", api: SUPPORTED_PLUGIN_API_VERSION }, + capabilities: { + backend: true, + frontend: false, + electron: false, + platforms: ["linux"], + }, + permissions: ["hosts.read", "storage.own"], + sidecars: [], + ...overrides, + }; +} + +describe("plugin manifest validation", () => { + it("accepts a valid manifest", () => { + expect(validateManifest(validManifest())).toEqual([]); + }); + + it("reports every missing required field", () => { + const errors = validateManifest({ id: "x" }); + expect(errors).toContain('Missing required field: "description"'); + expect(errors).toContain('Missing required field: "engine"'); + expect(errors).toContain('Missing required field: "sidecars"'); + }); + + it("rejects a bad id pattern", () => { + const errors = validateManifest(validManifest({ id: "Bad_Id" })); + expect(errors.some((e) => e.startsWith('Field "id" must match'))).toBe( + true, + ); + }); + + it("rejects a non-semver version", () => { + const errors = validateManifest(validManifest({ version: "1.0" })); + expect(errors).toContain( + 'Field "version" must be valid semver, got: "1.0"', + ); + }); + + it("rejects an unknown permission", () => { + const errors = validateManifest( + validManifest({ permissions: ["credentials.read"] }), + ); + expect( + errors.some((e) => e.includes('Unknown permission: "credentials.read"')), + ).toBe(true); + }); + + it("rejects an unknown category", () => { + const errors = validateManifest(validManifest({ category: "Games" })); + expect( + errors.some((e) => e.startsWith('Field "category" must be one of')), + ).toBe(true); + }); + + it("rejects a non-object manifest", () => { + expect(validateManifest(null)).toEqual(["Manifest must be a JSON object"]); + expect(validateManifest([])).toEqual(["Manifest must be a JSON object"]); + }); + + it("validates contributes sub-shapes", () => { + const errors = validateManifest( + validManifest({ + contributes: { + tabs: [{ id: "t", titleKey: "k", icon: "Box", openFrom: ["nope"] }], + apiPrefix: "Bad Prefix", + }, + }), + ); + expect( + errors.some((e) => e.includes('openFrom has unknown value: "nope"')), + ).toBe(true); + expect( + errors.some((e) => e.includes('"contributes.apiPrefix" must match')), + ).toBe(true); + }); + + describe("parseManifest", () => { + it("narrows a valid manifest", () => { + const { manifest, errors } = parseManifest(validManifest()); + expect(errors).toEqual([]); + expect(manifest?.id).toBe("sample-plugin"); + }); + + it("refuses a manifest targeting a different SDK major", () => { + const { manifest, errors } = parseManifest( + validManifest({ engine: { termix: ">=2.9.0", api: "99" } }), + ); + expect(manifest).toBeUndefined(); + expect(errors[0]).toContain('targets SDK api version "99"'); + }); + + it("does not narrow when validation failed", () => { + const { manifest, errors } = parseManifest({ id: "x" }); + expect(manifest).toBeUndefined(); + expect(errors.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/src/backend/utils/logger.ts b/src/backend/utils/logger.ts index 3ce1a832f..53b846ee3 100644 --- a/src/backend/utils/logger.ts +++ b/src/backend/utils/logger.ts @@ -309,5 +309,6 @@ export const versionLogger = new Logger("VERSION", "📦", "#8b5cf6"); export const dashboardLogger = new Logger("DASHBOARD", "📊", "#ec4899"); export const guacLogger = new Logger("GUACAMOLE", "🖼️", "#ff6b6b"); export const homepageLogger = new Logger("HOMEPAGE", "🏠", "#f97316"); +export const pluginLogger = new Logger("PLUGIN", "🧩", "#84cc16"); export const logger = systemLogger;