Spaces:
Configuration error
Configuration error
| import { randomUUID } from "node:crypto"; | |
| import fs from "node:fs"; | |
| import os from "node:os"; | |
| import path from "node:path"; | |
| import { afterEach, describe, expect, it } from "vitest"; | |
| import { loadMoltbotPlugins } from "./loader.js"; | |
| type TempPlugin = { dir: string; file: string; id: string }; | |
| const tempDirs: string[] = []; | |
| const prevBundledDir = process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR; | |
| const EMPTY_PLUGIN_SCHEMA = { type: "object", additionalProperties: false, properties: {} }; | |
| function makeTempDir() { | |
| const dir = path.join(os.tmpdir(), `moltbot-plugin-${randomUUID()}`); | |
| fs.mkdirSync(dir, { recursive: true }); | |
| tempDirs.push(dir); | |
| return dir; | |
| } | |
| function writePlugin(params: { | |
| id: string; | |
| body: string; | |
| dir?: string; | |
| filename?: string; | |
| }): TempPlugin { | |
| const dir = params.dir ?? makeTempDir(); | |
| const filename = params.filename ?? `${params.id}.js`; | |
| const file = path.join(dir, filename); | |
| fs.writeFileSync(file, params.body, "utf-8"); | |
| fs.writeFileSync( | |
| path.join(dir, "moltbot.plugin.json"), | |
| JSON.stringify( | |
| { | |
| id: params.id, | |
| configSchema: EMPTY_PLUGIN_SCHEMA, | |
| }, | |
| null, | |
| 2, | |
| ), | |
| "utf-8", | |
| ); | |
| return { dir, file, id: params.id }; | |
| } | |
| afterEach(() => { | |
| for (const dir of tempDirs.splice(0)) { | |
| try { | |
| fs.rmSync(dir, { recursive: true, force: true }); | |
| } catch { | |
| // ignore cleanup failures | |
| } | |
| } | |
| if (prevBundledDir === undefined) { | |
| delete process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR; | |
| } else { | |
| process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = prevBundledDir; | |
| } | |
| }); | |
| describe("loadMoltbotPlugins", () => { | |
| it("disables bundled plugins by default", () => { | |
| const bundledDir = makeTempDir(); | |
| writePlugin({ | |
| id: "bundled", | |
| body: `export default { id: "bundled", register() {} };`, | |
| dir: bundledDir, | |
| filename: "bundled.ts", | |
| }); | |
| process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = bundledDir; | |
| const registry = loadMoltbotPlugins({ | |
| cache: false, | |
| config: { | |
| plugins: { | |
| allow: ["bundled"], | |
| }, | |
| }, | |
| }); | |
| const bundled = registry.plugins.find((entry) => entry.id === "bundled"); | |
| expect(bundled?.status).toBe("disabled"); | |
| const enabledRegistry = loadMoltbotPlugins({ | |
| cache: false, | |
| config: { | |
| plugins: { | |
| allow: ["bundled"], | |
| entries: { | |
| bundled: { enabled: true }, | |
| }, | |
| }, | |
| }, | |
| }); | |
| const enabled = enabledRegistry.plugins.find((entry) => entry.id === "bundled"); | |
| expect(enabled?.status).toBe("loaded"); | |
| }); | |
| it("loads bundled telegram plugin when enabled", () => { | |
| const bundledDir = makeTempDir(); | |
| writePlugin({ | |
| id: "telegram", | |
| body: `export default { id: "telegram", register(api) { | |
| api.registerChannel({ | |
| plugin: { | |
| id: "telegram", | |
| meta: { | |
| id: "telegram", | |
| label: "Telegram", | |
| selectionLabel: "Telegram", | |
| docsPath: "/channels/telegram", | |
| blurb: "telegram channel" | |
| }, | |
| capabilities: { chatTypes: ["direct"] }, | |
| config: { | |
| listAccountIds: () => [], | |
| resolveAccount: () => ({ accountId: "default" }) | |
| }, | |
| outbound: { deliveryMode: "direct" } | |
| } | |
| }); | |
| } };`, | |
| dir: bundledDir, | |
| filename: "telegram.ts", | |
| }); | |
| process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = bundledDir; | |
| const registry = loadMoltbotPlugins({ | |
| cache: false, | |
| config: { | |
| plugins: { | |
| allow: ["telegram"], | |
| entries: { | |
| telegram: { enabled: true }, | |
| }, | |
| }, | |
| }, | |
| }); | |
| const telegram = registry.plugins.find((entry) => entry.id === "telegram"); | |
| expect(telegram?.status).toBe("loaded"); | |
| expect(registry.channels.some((entry) => entry.plugin.id === "telegram")).toBe(true); | |
| }); | |
| it("enables bundled memory plugin when selected by slot", () => { | |
| const bundledDir = makeTempDir(); | |
| writePlugin({ | |
| id: "memory-core", | |
| body: `export default { id: "memory-core", kind: "memory", register() {} };`, | |
| dir: bundledDir, | |
| filename: "memory-core.ts", | |
| }); | |
| process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = bundledDir; | |
| const registry = loadMoltbotPlugins({ | |
| cache: false, | |
| config: { | |
| plugins: { | |
| slots: { | |
| memory: "memory-core", | |
| }, | |
| }, | |
| }, | |
| }); | |
| const memory = registry.plugins.find((entry) => entry.id === "memory-core"); | |
| expect(memory?.status).toBe("loaded"); | |
| }); | |
| it("preserves package.json metadata for bundled memory plugins", () => { | |
| const bundledDir = makeTempDir(); | |
| const pluginDir = path.join(bundledDir, "memory-core"); | |
| fs.mkdirSync(pluginDir, { recursive: true }); | |
| fs.writeFileSync( | |
| path.join(pluginDir, "package.json"), | |
| JSON.stringify({ | |
| name: "@moltbot/memory-core", | |
| version: "1.2.3", | |
| description: "Memory plugin package", | |
| moltbot: { extensions: ["./index.ts"] }, | |
| }), | |
| "utf-8", | |
| ); | |
| writePlugin({ | |
| id: "memory-core", | |
| body: `export default { id: "memory-core", kind: "memory", name: "Memory (Core)", register() {} };`, | |
| dir: pluginDir, | |
| filename: "index.ts", | |
| }); | |
| process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = bundledDir; | |
| const registry = loadMoltbotPlugins({ | |
| cache: false, | |
| config: { | |
| plugins: { | |
| slots: { | |
| memory: "memory-core", | |
| }, | |
| }, | |
| }, | |
| }); | |
| const memory = registry.plugins.find((entry) => entry.id === "memory-core"); | |
| expect(memory?.status).toBe("loaded"); | |
| expect(memory?.origin).toBe("bundled"); | |
| expect(memory?.name).toBe("Memory (Core)"); | |
| expect(memory?.version).toBe("1.2.3"); | |
| }); | |
| it("loads plugins from config paths", () => { | |
| process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; | |
| const plugin = writePlugin({ | |
| id: "allowed", | |
| body: `export default { id: "allowed", register(api) { api.registerGatewayMethod("allowed.ping", ({ respond }) => respond(true, { ok: true })); } };`, | |
| }); | |
| const registry = loadMoltbotPlugins({ | |
| cache: false, | |
| workspaceDir: plugin.dir, | |
| config: { | |
| plugins: { | |
| load: { paths: [plugin.file] }, | |
| allow: ["allowed"], | |
| }, | |
| }, | |
| }); | |
| const loaded = registry.plugins.find((entry) => entry.id === "allowed"); | |
| expect(loaded?.status).toBe("loaded"); | |
| expect(Object.keys(registry.gatewayHandlers)).toContain("allowed.ping"); | |
| }); | |
| it("denylist disables plugins even if allowed", () => { | |
| process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; | |
| const plugin = writePlugin({ | |
| id: "blocked", | |
| body: `export default { id: "blocked", register() {} };`, | |
| }); | |
| const registry = loadMoltbotPlugins({ | |
| cache: false, | |
| workspaceDir: plugin.dir, | |
| config: { | |
| plugins: { | |
| load: { paths: [plugin.file] }, | |
| allow: ["blocked"], | |
| deny: ["blocked"], | |
| }, | |
| }, | |
| }); | |
| const blocked = registry.plugins.find((entry) => entry.id === "blocked"); | |
| expect(blocked?.status).toBe("disabled"); | |
| }); | |
| it("fails fast on invalid plugin config", () => { | |
| process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; | |
| const plugin = writePlugin({ | |
| id: "configurable", | |
| body: `export default { id: "configurable", register() {} };`, | |
| }); | |
| const registry = loadMoltbotPlugins({ | |
| cache: false, | |
| workspaceDir: plugin.dir, | |
| config: { | |
| plugins: { | |
| load: { paths: [plugin.file] }, | |
| entries: { | |
| configurable: { | |
| config: "nope" as unknown as Record<string, unknown>, | |
| }, | |
| }, | |
| }, | |
| }, | |
| }); | |
| const configurable = registry.plugins.find((entry) => entry.id === "configurable"); | |
| expect(configurable?.status).toBe("error"); | |
| expect(registry.diagnostics.some((d) => d.level === "error")).toBe(true); | |
| }); | |
| it("registers channel plugins", () => { | |
| process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; | |
| const plugin = writePlugin({ | |
| id: "channel-demo", | |
| body: `export default { id: "channel-demo", register(api) { | |
| api.registerChannel({ | |
| plugin: { | |
| id: "demo", | |
| meta: { | |
| id: "demo", | |
| label: "Demo", | |
| selectionLabel: "Demo", | |
| docsPath: "/channels/demo", | |
| blurb: "demo channel" | |
| }, | |
| capabilities: { chatTypes: ["direct"] }, | |
| config: { | |
| listAccountIds: () => [], | |
| resolveAccount: () => ({ accountId: "default" }) | |
| }, | |
| outbound: { deliveryMode: "direct" } | |
| } | |
| }); | |
| } };`, | |
| }); | |
| const registry = loadMoltbotPlugins({ | |
| cache: false, | |
| workspaceDir: plugin.dir, | |
| config: { | |
| plugins: { | |
| load: { paths: [plugin.file] }, | |
| allow: ["channel-demo"], | |
| }, | |
| }, | |
| }); | |
| const channel = registry.channels.find((entry) => entry.plugin.id === "demo"); | |
| expect(channel).toBeDefined(); | |
| }); | |
| it("registers http handlers", () => { | |
| process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; | |
| const plugin = writePlugin({ | |
| id: "http-demo", | |
| body: `export default { id: "http-demo", register(api) { | |
| api.registerHttpHandler(async () => false); | |
| } };`, | |
| }); | |
| const registry = loadMoltbotPlugins({ | |
| cache: false, | |
| workspaceDir: plugin.dir, | |
| config: { | |
| plugins: { | |
| load: { paths: [plugin.file] }, | |
| allow: ["http-demo"], | |
| }, | |
| }, | |
| }); | |
| const handler = registry.httpHandlers.find((entry) => entry.pluginId === "http-demo"); | |
| expect(handler).toBeDefined(); | |
| const httpPlugin = registry.plugins.find((entry) => entry.id === "http-demo"); | |
| expect(httpPlugin?.httpHandlers).toBe(1); | |
| }); | |
| it("registers http routes", () => { | |
| process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; | |
| const plugin = writePlugin({ | |
| id: "http-route-demo", | |
| body: `export default { id: "http-route-demo", register(api) { | |
| api.registerHttpRoute({ path: "/demo", handler: async (_req, res) => { res.statusCode = 200; res.end("ok"); } }); | |
| } };`, | |
| }); | |
| const registry = loadMoltbotPlugins({ | |
| cache: false, | |
| workspaceDir: plugin.dir, | |
| config: { | |
| plugins: { | |
| load: { paths: [plugin.file] }, | |
| allow: ["http-route-demo"], | |
| }, | |
| }, | |
| }); | |
| const route = registry.httpRoutes.find((entry) => entry.pluginId === "http-route-demo"); | |
| expect(route).toBeDefined(); | |
| expect(route?.path).toBe("/demo"); | |
| const httpPlugin = registry.plugins.find((entry) => entry.id === "http-route-demo"); | |
| expect(httpPlugin?.httpHandlers).toBe(1); | |
| }); | |
| it("respects explicit disable in config", () => { | |
| process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; | |
| const plugin = writePlugin({ | |
| id: "config-disable", | |
| body: `export default { id: "config-disable", register() {} };`, | |
| }); | |
| const registry = loadMoltbotPlugins({ | |
| cache: false, | |
| config: { | |
| plugins: { | |
| load: { paths: [plugin.file] }, | |
| entries: { | |
| "config-disable": { enabled: false }, | |
| }, | |
| }, | |
| }, | |
| }); | |
| const disabled = registry.plugins.find((entry) => entry.id === "config-disable"); | |
| expect(disabled?.status).toBe("disabled"); | |
| }); | |
| it("enforces memory slot selection", () => { | |
| process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; | |
| const memoryA = writePlugin({ | |
| id: "memory-a", | |
| body: `export default { id: "memory-a", kind: "memory", register() {} };`, | |
| }); | |
| const memoryB = writePlugin({ | |
| id: "memory-b", | |
| body: `export default { id: "memory-b", kind: "memory", register() {} };`, | |
| }); | |
| const registry = loadMoltbotPlugins({ | |
| cache: false, | |
| config: { | |
| plugins: { | |
| load: { paths: [memoryA.file, memoryB.file] }, | |
| slots: { memory: "memory-b" }, | |
| }, | |
| }, | |
| }); | |
| const a = registry.plugins.find((entry) => entry.id === "memory-a"); | |
| const b = registry.plugins.find((entry) => entry.id === "memory-b"); | |
| expect(b?.status).toBe("loaded"); | |
| expect(a?.status).toBe("disabled"); | |
| }); | |
| it("disables memory plugins when slot is none", () => { | |
| process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; | |
| const memory = writePlugin({ | |
| id: "memory-off", | |
| body: `export default { id: "memory-off", kind: "memory", register() {} };`, | |
| }); | |
| const registry = loadMoltbotPlugins({ | |
| cache: false, | |
| config: { | |
| plugins: { | |
| load: { paths: [memory.file] }, | |
| slots: { memory: "none" }, | |
| }, | |
| }, | |
| }); | |
| const entry = registry.plugins.find((item) => item.id === "memory-off"); | |
| expect(entry?.status).toBe("disabled"); | |
| }); | |
| it("prefers higher-precedence plugins with the same id", () => { | |
| const bundledDir = makeTempDir(); | |
| writePlugin({ | |
| id: "shadow", | |
| body: `export default { id: "shadow", register() {} };`, | |
| dir: bundledDir, | |
| filename: "shadow.js", | |
| }); | |
| process.env.CLAWDBOT_BUNDLED_PLUGINS_DIR = bundledDir; | |
| const override = writePlugin({ | |
| id: "shadow", | |
| body: `export default { id: "shadow", register() {} };`, | |
| }); | |
| const registry = loadMoltbotPlugins({ | |
| cache: false, | |
| config: { | |
| plugins: { | |
| load: { paths: [override.file] }, | |
| entries: { | |
| shadow: { enabled: true }, | |
| }, | |
| }, | |
| }, | |
| }); | |
| const entries = registry.plugins.filter((entry) => entry.id === "shadow"); | |
| const loaded = entries.find((entry) => entry.status === "loaded"); | |
| const overridden = entries.find((entry) => entry.status === "disabled"); | |
| expect(loaded?.origin).toBe("config"); | |
| expect(overridden?.origin).toBe("bundled"); | |
| }); | |
| }); | |