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;