mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 23:40:45 +00:00
1443 lines
43 KiB
TypeScript
1443 lines
43 KiB
TypeScript
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
|
|
import { withEnv } from "../test-utils/env.js";
|
|
import { getGlobalHookRunner, resetGlobalHookRunner } from "./hook-runner-global.js";
|
|
import { createHookRunner } from "./hooks.js";
|
|
|
|
vi.unmock("jiti");
|
|
const { __testing, loadOpenClawPlugins } = await import("./loader.js");
|
|
|
|
type TempPlugin = { dir: string; file: string; id: string };
|
|
|
|
const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-"));
|
|
let tempDirIndex = 0;
|
|
const prevBundledDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
|
|
const EMPTY_PLUGIN_SCHEMA = { type: "object", additionalProperties: false, properties: {} };
|
|
let cachedBundledTelegramDir = "";
|
|
let cachedBundledMemoryDir = "";
|
|
const BUNDLED_TELEGRAM_PLUGIN_BODY = `module.exports = {
|
|
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" },
|
|
},
|
|
});
|
|
},
|
|
};`;
|
|
|
|
function makeTempDir() {
|
|
const dir = path.join(fixtureRoot, `case-${tempDirIndex++}`);
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
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}.cjs`;
|
|
const file = path.join(dir, filename);
|
|
fs.writeFileSync(file, params.body, "utf-8");
|
|
fs.writeFileSync(
|
|
path.join(dir, "openclaw.plugin.json"),
|
|
JSON.stringify(
|
|
{
|
|
id: params.id,
|
|
configSchema: EMPTY_PLUGIN_SCHEMA,
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"utf-8",
|
|
);
|
|
return { dir, file, id: params.id };
|
|
}
|
|
|
|
function loadBundledMemoryPluginRegistry(options?: {
|
|
packageMeta?: { name: string; version: string; description?: string };
|
|
pluginBody?: string;
|
|
pluginFilename?: string;
|
|
}) {
|
|
if (!options && cachedBundledMemoryDir) {
|
|
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = cachedBundledMemoryDir;
|
|
return loadOpenClawPlugins({
|
|
cache: false,
|
|
workspaceDir: cachedBundledMemoryDir,
|
|
config: {
|
|
plugins: {
|
|
slots: {
|
|
memory: "memory-core",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
const bundledDir = makeTempDir();
|
|
let pluginDir = bundledDir;
|
|
let pluginFilename = options?.pluginFilename ?? "memory-core.cjs";
|
|
|
|
if (options?.packageMeta) {
|
|
pluginDir = path.join(bundledDir, "memory-core");
|
|
pluginFilename = options.pluginFilename ?? "index.js";
|
|
fs.mkdirSync(pluginDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(pluginDir, "package.json"),
|
|
JSON.stringify(
|
|
{
|
|
name: options.packageMeta.name,
|
|
version: options.packageMeta.version,
|
|
description: options.packageMeta.description,
|
|
openclaw: { extensions: [`./${pluginFilename}`] },
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"utf-8",
|
|
);
|
|
}
|
|
|
|
writePlugin({
|
|
id: "memory-core",
|
|
body:
|
|
options?.pluginBody ??
|
|
`module.exports = { id: "memory-core", kind: "memory", register() {} };`,
|
|
dir: pluginDir,
|
|
filename: pluginFilename,
|
|
});
|
|
if (!options) {
|
|
cachedBundledMemoryDir = bundledDir;
|
|
}
|
|
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir;
|
|
|
|
return loadOpenClawPlugins({
|
|
cache: false,
|
|
workspaceDir: bundledDir,
|
|
config: {
|
|
plugins: {
|
|
slots: {
|
|
memory: "memory-core",
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
function setupBundledTelegramPlugin() {
|
|
if (!cachedBundledTelegramDir) {
|
|
cachedBundledTelegramDir = makeTempDir();
|
|
writePlugin({
|
|
id: "telegram",
|
|
body: BUNDLED_TELEGRAM_PLUGIN_BODY,
|
|
dir: cachedBundledTelegramDir,
|
|
filename: "telegram.cjs",
|
|
});
|
|
}
|
|
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = cachedBundledTelegramDir;
|
|
}
|
|
|
|
function expectTelegramLoaded(registry: ReturnType<typeof loadOpenClawPlugins>) {
|
|
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);
|
|
}
|
|
|
|
function useNoBundledPlugins() {
|
|
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
|
|
}
|
|
|
|
function loadRegistryFromSinglePlugin(params: {
|
|
plugin: TempPlugin;
|
|
pluginConfig?: Record<string, unknown>;
|
|
includeWorkspaceDir?: boolean;
|
|
options?: Omit<Parameters<typeof loadOpenClawPlugins>[0], "cache" | "workspaceDir" | "config">;
|
|
}) {
|
|
const pluginConfig = params.pluginConfig ?? {};
|
|
return loadOpenClawPlugins({
|
|
cache: false,
|
|
...(params.includeWorkspaceDir === false ? {} : { workspaceDir: params.plugin.dir }),
|
|
...params.options,
|
|
config: {
|
|
plugins: {
|
|
load: { paths: [params.plugin.file] },
|
|
...pluginConfig,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
function createWarningLogger(warnings: string[]) {
|
|
return {
|
|
info: () => {},
|
|
warn: (msg: string) => warnings.push(msg),
|
|
error: () => {},
|
|
};
|
|
}
|
|
|
|
function createErrorLogger(errors: string[]) {
|
|
return {
|
|
info: () => {},
|
|
warn: () => {},
|
|
error: (msg: string) => errors.push(msg),
|
|
debug: () => {},
|
|
};
|
|
}
|
|
|
|
function createEscapingEntryFixture(params: { id: string; sourceBody: string }) {
|
|
const pluginDir = makeTempDir();
|
|
const outsideDir = makeTempDir();
|
|
const outsideEntry = path.join(outsideDir, "outside.cjs");
|
|
const linkedEntry = path.join(pluginDir, "entry.cjs");
|
|
fs.writeFileSync(outsideEntry, params.sourceBody, "utf-8");
|
|
fs.writeFileSync(
|
|
path.join(pluginDir, "openclaw.plugin.json"),
|
|
JSON.stringify(
|
|
{
|
|
id: params.id,
|
|
configSchema: EMPTY_PLUGIN_SCHEMA,
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"utf-8",
|
|
);
|
|
return { pluginDir, outsideEntry, linkedEntry };
|
|
}
|
|
|
|
function createPluginSdkAliasFixture(params?: {
|
|
srcFile?: string;
|
|
distFile?: string;
|
|
srcBody?: string;
|
|
distBody?: string;
|
|
}) {
|
|
const root = makeTempDir();
|
|
const srcFile = path.join(root, "src", "plugin-sdk", params?.srcFile ?? "index.ts");
|
|
const distFile = path.join(root, "dist", "plugin-sdk", params?.distFile ?? "index.js");
|
|
fs.mkdirSync(path.dirname(srcFile), { recursive: true });
|
|
fs.mkdirSync(path.dirname(distFile), { recursive: true });
|
|
fs.writeFileSync(srcFile, params?.srcBody ?? "export {};\n", "utf-8");
|
|
fs.writeFileSync(distFile, params?.distBody ?? "export {};\n", "utf-8");
|
|
return { root, srcFile, distFile };
|
|
}
|
|
|
|
afterEach(() => {
|
|
if (prevBundledDir === undefined) {
|
|
delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR;
|
|
} else {
|
|
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = prevBundledDir;
|
|
}
|
|
});
|
|
|
|
afterAll(() => {
|
|
try {
|
|
fs.rmSync(fixtureRoot, { recursive: true, force: true });
|
|
} catch {
|
|
// ignore cleanup failures
|
|
} finally {
|
|
cachedBundledTelegramDir = "";
|
|
cachedBundledMemoryDir = "";
|
|
}
|
|
});
|
|
|
|
describe("loadOpenClawPlugins", () => {
|
|
it("disables bundled plugins by default", () => {
|
|
const bundledDir = makeTempDir();
|
|
writePlugin({
|
|
id: "bundled",
|
|
body: `module.exports = { id: "bundled", register() {} };`,
|
|
dir: bundledDir,
|
|
filename: "bundled.cjs",
|
|
});
|
|
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir;
|
|
|
|
const registry = loadOpenClawPlugins({
|
|
cache: false,
|
|
config: {
|
|
plugins: {
|
|
allow: ["bundled"],
|
|
},
|
|
},
|
|
});
|
|
|
|
const bundled = registry.plugins.find((entry) => entry.id === "bundled");
|
|
expect(bundled?.status).toBe("disabled");
|
|
});
|
|
|
|
it("loads bundled telegram plugin when enabled", () => {
|
|
setupBundledTelegramPlugin();
|
|
|
|
const registry = loadOpenClawPlugins({
|
|
cache: false,
|
|
workspaceDir: cachedBundledTelegramDir,
|
|
config: {
|
|
plugins: {
|
|
allow: ["telegram"],
|
|
entries: {
|
|
telegram: { enabled: true },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expectTelegramLoaded(registry);
|
|
});
|
|
|
|
it("loads bundled channel plugins when channels.<id>.enabled=true", () => {
|
|
setupBundledTelegramPlugin();
|
|
|
|
const registry = loadOpenClawPlugins({
|
|
cache: false,
|
|
workspaceDir: cachedBundledTelegramDir,
|
|
config: {
|
|
channels: {
|
|
telegram: {
|
|
enabled: true,
|
|
},
|
|
},
|
|
plugins: {
|
|
enabled: true,
|
|
},
|
|
},
|
|
});
|
|
|
|
expectTelegramLoaded(registry);
|
|
});
|
|
|
|
it("still respects explicit disable via plugins.entries for bundled channels", () => {
|
|
setupBundledTelegramPlugin();
|
|
|
|
const registry = loadOpenClawPlugins({
|
|
cache: false,
|
|
workspaceDir: cachedBundledTelegramDir,
|
|
config: {
|
|
channels: {
|
|
telegram: {
|
|
enabled: true,
|
|
},
|
|
},
|
|
plugins: {
|
|
entries: {
|
|
telegram: { enabled: false },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const telegram = registry.plugins.find((entry) => entry.id === "telegram");
|
|
expect(telegram?.status).toBe("disabled");
|
|
expect(telegram?.error).toBe("disabled in config");
|
|
});
|
|
|
|
it("preserves package.json metadata for bundled memory plugins", () => {
|
|
const registry = loadBundledMemoryPluginRegistry({
|
|
packageMeta: {
|
|
name: "@openclaw/memory-core",
|
|
version: "1.2.3",
|
|
description: "Memory plugin package",
|
|
},
|
|
pluginBody:
|
|
'module.exports = { id: "memory-core", kind: "memory", name: "Memory (Core)", register() {} };',
|
|
});
|
|
|
|
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.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
|
|
const plugin = writePlugin({
|
|
id: "allowed",
|
|
filename: "allowed.cjs",
|
|
body: `module.exports = {
|
|
id: "allowed",
|
|
register(api) {
|
|
api.registerGatewayMethod("allowed.ping", ({ respond }) => respond(true, { ok: true }));
|
|
},
|
|
};`,
|
|
});
|
|
|
|
const registry = loadOpenClawPlugins({
|
|
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("re-initializes global hook runner when serving registry from cache", () => {
|
|
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
|
|
const plugin = writePlugin({
|
|
id: "cache-hook-runner",
|
|
filename: "cache-hook-runner.cjs",
|
|
body: `module.exports = { id: "cache-hook-runner", register() {} };`,
|
|
});
|
|
|
|
const options = {
|
|
workspaceDir: plugin.dir,
|
|
config: {
|
|
plugins: {
|
|
load: { paths: [plugin.file] },
|
|
allow: ["cache-hook-runner"],
|
|
},
|
|
},
|
|
};
|
|
|
|
const first = loadOpenClawPlugins(options);
|
|
expect(getGlobalHookRunner()).not.toBeNull();
|
|
|
|
resetGlobalHookRunner();
|
|
expect(getGlobalHookRunner()).toBeNull();
|
|
|
|
const second = loadOpenClawPlugins(options);
|
|
expect(second).toBe(first);
|
|
expect(getGlobalHookRunner()).not.toBeNull();
|
|
|
|
resetGlobalHookRunner();
|
|
});
|
|
|
|
it("loads plugins when source and root differ only by realpath alias", () => {
|
|
useNoBundledPlugins();
|
|
const plugin = writePlugin({
|
|
id: "alias-safe",
|
|
filename: "alias-safe.cjs",
|
|
body: `module.exports = { id: "alias-safe", register() {} };`,
|
|
});
|
|
const realRoot = fs.realpathSync(plugin.dir);
|
|
if (realRoot === plugin.dir) {
|
|
return;
|
|
}
|
|
|
|
const registry = loadRegistryFromSinglePlugin({
|
|
plugin,
|
|
pluginConfig: {
|
|
allow: ["alias-safe"],
|
|
},
|
|
});
|
|
|
|
const loaded = registry.plugins.find((entry) => entry.id === "alias-safe");
|
|
expect(loaded?.status).toBe("loaded");
|
|
});
|
|
|
|
it("denylist disables plugins even if allowed", () => {
|
|
useNoBundledPlugins();
|
|
const plugin = writePlugin({
|
|
id: "blocked",
|
|
body: `module.exports = { id: "blocked", register() {} };`,
|
|
});
|
|
|
|
const registry = loadRegistryFromSinglePlugin({
|
|
plugin,
|
|
pluginConfig: {
|
|
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", () => {
|
|
useNoBundledPlugins();
|
|
const plugin = writePlugin({
|
|
id: "configurable",
|
|
filename: "configurable.cjs",
|
|
body: `module.exports = { id: "configurable", register() {} };`,
|
|
});
|
|
|
|
const registry = loadRegistryFromSinglePlugin({
|
|
plugin,
|
|
pluginConfig: {
|
|
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", () => {
|
|
useNoBundledPlugins();
|
|
const plugin = writePlugin({
|
|
id: "channel-demo",
|
|
filename: "channel-demo.cjs",
|
|
body: `module.exports = { 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 = loadRegistryFromSinglePlugin({
|
|
plugin,
|
|
pluginConfig: {
|
|
allow: ["channel-demo"],
|
|
},
|
|
});
|
|
|
|
const channel = registry.channels.find((entry) => entry.plugin.id === "demo");
|
|
expect(channel).toBeDefined();
|
|
});
|
|
|
|
it("registers http routes with auth and match options", () => {
|
|
useNoBundledPlugins();
|
|
const plugin = writePlugin({
|
|
id: "http-demo",
|
|
filename: "http-demo.cjs",
|
|
body: `module.exports = { id: "http-demo", register(api) {
|
|
api.registerHttpRoute({
|
|
path: "/webhook",
|
|
auth: "plugin",
|
|
match: "prefix",
|
|
handler: async () => false
|
|
});
|
|
} };`,
|
|
});
|
|
|
|
const registry = loadRegistryFromSinglePlugin({
|
|
plugin,
|
|
pluginConfig: {
|
|
allow: ["http-demo"],
|
|
},
|
|
});
|
|
|
|
const route = registry.httpRoutes.find((entry) => entry.pluginId === "http-demo");
|
|
expect(route).toBeDefined();
|
|
expect(route?.path).toBe("/webhook");
|
|
expect(route?.auth).toBe("plugin");
|
|
expect(route?.match).toBe("prefix");
|
|
const httpPlugin = registry.plugins.find((entry) => entry.id === "http-demo");
|
|
expect(httpPlugin?.httpRoutes).toBe(1);
|
|
});
|
|
|
|
it("registers http routes", () => {
|
|
useNoBundledPlugins();
|
|
const plugin = writePlugin({
|
|
id: "http-route-demo",
|
|
filename: "http-route-demo.cjs",
|
|
body: `module.exports = { id: "http-route-demo", register(api) {
|
|
api.registerHttpRoute({ path: "/demo", auth: "gateway", handler: async (_req, res) => { res.statusCode = 200; res.end("ok"); } });
|
|
} };`,
|
|
});
|
|
|
|
const registry = loadRegistryFromSinglePlugin({
|
|
plugin,
|
|
pluginConfig: {
|
|
allow: ["http-route-demo"],
|
|
},
|
|
});
|
|
|
|
const route = registry.httpRoutes.find((entry) => entry.pluginId === "http-route-demo");
|
|
expect(route).toBeDefined();
|
|
expect(route?.path).toBe("/demo");
|
|
expect(route?.auth).toBe("gateway");
|
|
expect(route?.match).toBe("exact");
|
|
const httpPlugin = registry.plugins.find((entry) => entry.id === "http-route-demo");
|
|
expect(httpPlugin?.httpRoutes).toBe(1);
|
|
});
|
|
|
|
it("rewrites removed registerHttpHandler failures into migration diagnostics", () => {
|
|
useNoBundledPlugins();
|
|
const plugin = writePlugin({
|
|
id: "http-handler-legacy",
|
|
filename: "http-handler-legacy.cjs",
|
|
body: `module.exports = { id: "http-handler-legacy", register(api) {
|
|
api.registerHttpHandler({ path: "/legacy", handler: async () => true });
|
|
} };`,
|
|
});
|
|
|
|
const errors: string[] = [];
|
|
const registry = loadRegistryFromSinglePlugin({
|
|
plugin,
|
|
pluginConfig: {
|
|
allow: ["http-handler-legacy"],
|
|
},
|
|
options: {
|
|
logger: createErrorLogger(errors),
|
|
},
|
|
});
|
|
|
|
const loaded = registry.plugins.find((entry) => entry.id === "http-handler-legacy");
|
|
expect(loaded?.status).toBe("error");
|
|
expect(loaded?.error).toContain("api.registerHttpHandler(...) was removed");
|
|
expect(loaded?.error).toContain("api.registerHttpRoute(...)");
|
|
expect(loaded?.error).toContain("registerPluginHttpRoute(...)");
|
|
expect(
|
|
registry.diagnostics.some((diag) =>
|
|
String(diag.message).includes("api.registerHttpHandler(...) was removed"),
|
|
),
|
|
).toBe(true);
|
|
expect(errors.some((entry) => entry.includes("api.registerHttpHandler(...) was removed"))).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it("does not rewrite unrelated registerHttpHandler helper failures", () => {
|
|
useNoBundledPlugins();
|
|
const plugin = writePlugin({
|
|
id: "http-handler-local-helper",
|
|
filename: "http-handler-local-helper.cjs",
|
|
body: `module.exports = { id: "http-handler-local-helper", register() {
|
|
const registerHttpHandler = undefined;
|
|
registerHttpHandler();
|
|
} };`,
|
|
});
|
|
|
|
const registry = loadRegistryFromSinglePlugin({
|
|
plugin,
|
|
pluginConfig: {
|
|
allow: ["http-handler-local-helper"],
|
|
},
|
|
});
|
|
|
|
const loaded = registry.plugins.find((entry) => entry.id === "http-handler-local-helper");
|
|
expect(loaded?.status).toBe("error");
|
|
expect(loaded?.error).not.toContain("api.registerHttpHandler(...) was removed");
|
|
});
|
|
|
|
it("rejects plugin http routes missing explicit auth", () => {
|
|
useNoBundledPlugins();
|
|
const plugin = writePlugin({
|
|
id: "http-route-missing-auth",
|
|
filename: "http-route-missing-auth.cjs",
|
|
body: `module.exports = { id: "http-route-missing-auth", register(api) {
|
|
api.registerHttpRoute({ path: "/demo", handler: async () => true });
|
|
} };`,
|
|
});
|
|
|
|
const registry = loadRegistryFromSinglePlugin({
|
|
plugin,
|
|
pluginConfig: {
|
|
allow: ["http-route-missing-auth"],
|
|
},
|
|
});
|
|
|
|
expect(registry.httpRoutes.find((entry) => entry.pluginId === "http-route-missing-auth")).toBe(
|
|
undefined,
|
|
);
|
|
expect(
|
|
registry.diagnostics.some((diag) =>
|
|
String(diag.message).includes("http route registration missing or invalid auth"),
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("allows explicit replaceExisting for same-plugin http route overrides", () => {
|
|
useNoBundledPlugins();
|
|
const plugin = writePlugin({
|
|
id: "http-route-replace-self",
|
|
filename: "http-route-replace-self.cjs",
|
|
body: `module.exports = { id: "http-route-replace-self", register(api) {
|
|
api.registerHttpRoute({ path: "/demo", auth: "plugin", handler: async () => false });
|
|
api.registerHttpRoute({ path: "/demo", auth: "plugin", replaceExisting: true, handler: async () => true });
|
|
} };`,
|
|
});
|
|
|
|
const registry = loadRegistryFromSinglePlugin({
|
|
plugin,
|
|
pluginConfig: {
|
|
allow: ["http-route-replace-self"],
|
|
},
|
|
});
|
|
|
|
const routes = registry.httpRoutes.filter(
|
|
(entry) => entry.pluginId === "http-route-replace-self",
|
|
);
|
|
expect(routes).toHaveLength(1);
|
|
expect(routes[0]?.path).toBe("/demo");
|
|
expect(registry.diagnostics).toEqual([]);
|
|
});
|
|
|
|
it("rejects http route replacement when another plugin owns the route", () => {
|
|
useNoBundledPlugins();
|
|
const first = writePlugin({
|
|
id: "http-route-owner-a",
|
|
filename: "http-route-owner-a.cjs",
|
|
body: `module.exports = { id: "http-route-owner-a", register(api) {
|
|
api.registerHttpRoute({ path: "/demo", auth: "plugin", handler: async () => false });
|
|
} };`,
|
|
});
|
|
const second = writePlugin({
|
|
id: "http-route-owner-b",
|
|
filename: "http-route-owner-b.cjs",
|
|
body: `module.exports = { id: "http-route-owner-b", register(api) {
|
|
api.registerHttpRoute({ path: "/demo", auth: "plugin", replaceExisting: true, handler: async () => true });
|
|
} };`,
|
|
});
|
|
|
|
const registry = loadOpenClawPlugins({
|
|
cache: false,
|
|
config: {
|
|
plugins: {
|
|
load: { paths: [first.file, second.file] },
|
|
allow: ["http-route-owner-a", "http-route-owner-b"],
|
|
},
|
|
},
|
|
});
|
|
|
|
const route = registry.httpRoutes.find((entry) => entry.path === "/demo");
|
|
expect(route?.pluginId).toBe("http-route-owner-a");
|
|
expect(
|
|
registry.diagnostics.some((diag) =>
|
|
String(diag.message).includes("http route replacement rejected"),
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("rejects mixed-auth overlapping http routes", () => {
|
|
useNoBundledPlugins();
|
|
const plugin = writePlugin({
|
|
id: "http-route-overlap",
|
|
filename: "http-route-overlap.cjs",
|
|
body: `module.exports = { id: "http-route-overlap", register(api) {
|
|
api.registerHttpRoute({ path: "/plugin/secure", auth: "gateway", match: "prefix", handler: async () => true });
|
|
api.registerHttpRoute({ path: "/plugin/secure/report", auth: "plugin", match: "exact", handler: async () => true });
|
|
} };`,
|
|
});
|
|
|
|
const registry = loadRegistryFromSinglePlugin({
|
|
plugin,
|
|
pluginConfig: {
|
|
allow: ["http-route-overlap"],
|
|
},
|
|
});
|
|
|
|
const routes = registry.httpRoutes.filter((entry) => entry.pluginId === "http-route-overlap");
|
|
expect(routes).toHaveLength(1);
|
|
expect(routes[0]?.path).toBe("/plugin/secure");
|
|
expect(
|
|
registry.diagnostics.some((diag) =>
|
|
String(diag.message).includes("http route overlap rejected"),
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("allows same-auth overlapping http routes", () => {
|
|
useNoBundledPlugins();
|
|
const plugin = writePlugin({
|
|
id: "http-route-overlap-same-auth",
|
|
filename: "http-route-overlap-same-auth.cjs",
|
|
body: `module.exports = { id: "http-route-overlap-same-auth", register(api) {
|
|
api.registerHttpRoute({ path: "/plugin/public", auth: "plugin", match: "prefix", handler: async () => true });
|
|
api.registerHttpRoute({ path: "/plugin/public/report", auth: "plugin", match: "exact", handler: async () => true });
|
|
} };`,
|
|
});
|
|
|
|
const registry = loadRegistryFromSinglePlugin({
|
|
plugin,
|
|
pluginConfig: {
|
|
allow: ["http-route-overlap-same-auth"],
|
|
},
|
|
});
|
|
|
|
const routes = registry.httpRoutes.filter(
|
|
(entry) => entry.pluginId === "http-route-overlap-same-auth",
|
|
);
|
|
expect(routes).toHaveLength(2);
|
|
expect(registry.diagnostics).toEqual([]);
|
|
});
|
|
|
|
it("respects explicit disable in config", () => {
|
|
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
|
|
const plugin = writePlugin({
|
|
id: "config-disable",
|
|
body: `module.exports = { id: "config-disable", register() {} };`,
|
|
});
|
|
|
|
const registry = loadOpenClawPlugins({
|
|
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("blocks before_prompt_build but preserves legacy model overrides when prompt injection is disabled", async () => {
|
|
useNoBundledPlugins();
|
|
const plugin = writePlugin({
|
|
id: "hook-policy",
|
|
filename: "hook-policy.cjs",
|
|
body: `module.exports = { id: "hook-policy", register(api) {
|
|
api.on("before_prompt_build", () => ({ prependContext: "prepend" }));
|
|
api.on("before_agent_start", () => ({
|
|
prependContext: "legacy",
|
|
modelOverride: "gpt-4o",
|
|
providerOverride: "anthropic",
|
|
}));
|
|
api.on("before_model_resolve", () => ({ providerOverride: "openai" }));
|
|
} };`,
|
|
});
|
|
|
|
const registry = loadRegistryFromSinglePlugin({
|
|
plugin,
|
|
pluginConfig: {
|
|
allow: ["hook-policy"],
|
|
entries: {
|
|
"hook-policy": {
|
|
hooks: {
|
|
allowPromptInjection: false,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(registry.plugins.find((entry) => entry.id === "hook-policy")?.status).toBe("loaded");
|
|
expect(registry.typedHooks.map((entry) => entry.hookName)).toEqual([
|
|
"before_agent_start",
|
|
"before_model_resolve",
|
|
]);
|
|
const runner = createHookRunner(registry);
|
|
const legacyResult = await runner.runBeforeAgentStart({ prompt: "hello", messages: [] }, {});
|
|
expect(legacyResult).toEqual({
|
|
modelOverride: "gpt-4o",
|
|
providerOverride: "anthropic",
|
|
});
|
|
const blockedDiagnostics = registry.diagnostics.filter((diag) =>
|
|
String(diag.message).includes(
|
|
"blocked by plugins.entries.hook-policy.hooks.allowPromptInjection=false",
|
|
),
|
|
);
|
|
expect(blockedDiagnostics).toHaveLength(1);
|
|
const constrainedDiagnostics = registry.diagnostics.filter((diag) =>
|
|
String(diag.message).includes(
|
|
"prompt fields constrained by plugins.entries.hook-policy.hooks.allowPromptInjection=false",
|
|
),
|
|
);
|
|
expect(constrainedDiagnostics).toHaveLength(1);
|
|
});
|
|
|
|
it("keeps prompt-injection typed hooks enabled by default", () => {
|
|
useNoBundledPlugins();
|
|
const plugin = writePlugin({
|
|
id: "hook-policy-default",
|
|
filename: "hook-policy-default.cjs",
|
|
body: `module.exports = { id: "hook-policy-default", register(api) {
|
|
api.on("before_prompt_build", () => ({ prependContext: "prepend" }));
|
|
api.on("before_agent_start", () => ({ prependContext: "legacy" }));
|
|
} };`,
|
|
});
|
|
|
|
const registry = loadRegistryFromSinglePlugin({
|
|
plugin,
|
|
pluginConfig: {
|
|
allow: ["hook-policy-default"],
|
|
},
|
|
});
|
|
|
|
expect(registry.typedHooks.map((entry) => entry.hookName)).toEqual([
|
|
"before_prompt_build",
|
|
"before_agent_start",
|
|
]);
|
|
});
|
|
|
|
it("ignores unknown typed hooks from plugins and keeps loading", () => {
|
|
useNoBundledPlugins();
|
|
const plugin = writePlugin({
|
|
id: "hook-unknown",
|
|
filename: "hook-unknown.cjs",
|
|
body: `module.exports = { id: "hook-unknown", register(api) {
|
|
api.on("totally_unknown_hook_name", () => ({ foo: "bar" }));
|
|
api.on(123, () => ({ foo: "baz" }));
|
|
api.on("before_model_resolve", () => ({ providerOverride: "openai" }));
|
|
} };`,
|
|
});
|
|
|
|
const registry = loadRegistryFromSinglePlugin({
|
|
plugin,
|
|
pluginConfig: {
|
|
allow: ["hook-unknown"],
|
|
},
|
|
});
|
|
|
|
expect(registry.plugins.find((entry) => entry.id === "hook-unknown")?.status).toBe("loaded");
|
|
expect(registry.typedHooks.map((entry) => entry.hookName)).toEqual(["before_model_resolve"]);
|
|
const unknownHookDiagnostics = registry.diagnostics.filter((diag) =>
|
|
String(diag.message).includes('unknown typed hook "'),
|
|
);
|
|
expect(unknownHookDiagnostics).toHaveLength(2);
|
|
expect(
|
|
unknownHookDiagnostics.some((diag) =>
|
|
String(diag.message).includes('unknown typed hook "totally_unknown_hook_name" ignored'),
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
unknownHookDiagnostics.some((diag) =>
|
|
String(diag.message).includes('unknown typed hook "123" ignored'),
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("enforces memory slot selection", () => {
|
|
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
|
|
const memoryA = writePlugin({
|
|
id: "memory-a",
|
|
body: `module.exports = { id: "memory-a", kind: "memory", register() {} };`,
|
|
});
|
|
const memoryB = writePlugin({
|
|
id: "memory-b",
|
|
body: `module.exports = { id: "memory-b", kind: "memory", register() {} };`,
|
|
});
|
|
|
|
const registry = loadOpenClawPlugins({
|
|
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("skips importing bundled memory plugins that are disabled by memory slot", () => {
|
|
const bundledDir = makeTempDir();
|
|
const memoryADir = path.join(bundledDir, "memory-a");
|
|
const memoryBDir = path.join(bundledDir, "memory-b");
|
|
fs.mkdirSync(memoryADir, { recursive: true });
|
|
fs.mkdirSync(memoryBDir, { recursive: true });
|
|
writePlugin({
|
|
id: "memory-a",
|
|
dir: memoryADir,
|
|
filename: "index.cjs",
|
|
body: `throw new Error("memory-a should not be imported when slot selects memory-b");`,
|
|
});
|
|
writePlugin({
|
|
id: "memory-b",
|
|
dir: memoryBDir,
|
|
filename: "index.cjs",
|
|
body: `module.exports = { id: "memory-b", kind: "memory", register() {} };`,
|
|
});
|
|
fs.writeFileSync(
|
|
path.join(memoryADir, "openclaw.plugin.json"),
|
|
JSON.stringify(
|
|
{
|
|
id: "memory-a",
|
|
kind: "memory",
|
|
configSchema: EMPTY_PLUGIN_SCHEMA,
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"utf-8",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(memoryBDir, "openclaw.plugin.json"),
|
|
JSON.stringify(
|
|
{
|
|
id: "memory-b",
|
|
kind: "memory",
|
|
configSchema: EMPTY_PLUGIN_SCHEMA,
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"utf-8",
|
|
);
|
|
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir;
|
|
|
|
const registry = loadOpenClawPlugins({
|
|
cache: false,
|
|
config: {
|
|
plugins: {
|
|
allow: ["memory-a", "memory-b"],
|
|
slots: { memory: "memory-b" },
|
|
entries: {
|
|
"memory-a": { enabled: true },
|
|
"memory-b": { enabled: true },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const a = registry.plugins.find((entry) => entry.id === "memory-a");
|
|
const b = registry.plugins.find((entry) => entry.id === "memory-b");
|
|
expect(a?.status).toBe("disabled");
|
|
expect(String(a?.error ?? "")).toContain('memory slot set to "memory-b"');
|
|
expect(b?.status).toBe("loaded");
|
|
});
|
|
|
|
it("disables memory plugins when slot is none", () => {
|
|
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
|
|
const memory = writePlugin({
|
|
id: "memory-off",
|
|
body: `module.exports = { id: "memory-off", kind: "memory", register() {} };`,
|
|
});
|
|
|
|
const registry = loadOpenClawPlugins({
|
|
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: `module.exports = { id: "shadow", register() {} };`,
|
|
dir: bundledDir,
|
|
filename: "shadow.cjs",
|
|
});
|
|
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir;
|
|
|
|
const override = writePlugin({
|
|
id: "shadow",
|
|
body: `module.exports = { id: "shadow", register() {} };`,
|
|
});
|
|
|
|
const registry = loadOpenClawPlugins({
|
|
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");
|
|
});
|
|
|
|
it("prefers bundled plugin over auto-discovered global duplicate ids", () => {
|
|
const bundledDir = makeTempDir();
|
|
writePlugin({
|
|
id: "feishu",
|
|
body: `module.exports = { id: "feishu", register() {} };`,
|
|
dir: bundledDir,
|
|
filename: "index.cjs",
|
|
});
|
|
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir;
|
|
|
|
const stateDir = makeTempDir();
|
|
withEnv({ OPENCLAW_STATE_DIR: stateDir, CLAWDBOT_STATE_DIR: undefined }, () => {
|
|
const globalDir = path.join(stateDir, "extensions", "feishu");
|
|
fs.mkdirSync(globalDir, { recursive: true });
|
|
writePlugin({
|
|
id: "feishu",
|
|
body: `module.exports = { id: "feishu", register() {} };`,
|
|
dir: globalDir,
|
|
filename: "index.cjs",
|
|
});
|
|
|
|
const registry = loadOpenClawPlugins({
|
|
cache: false,
|
|
config: {
|
|
plugins: {
|
|
allow: ["feishu"],
|
|
entries: {
|
|
feishu: { enabled: true },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const entries = registry.plugins.filter((entry) => entry.id === "feishu");
|
|
const loaded = entries.find((entry) => entry.status === "loaded");
|
|
const overridden = entries.find((entry) => entry.status === "disabled");
|
|
expect(loaded?.origin).toBe("bundled");
|
|
expect(overridden?.origin).toBe("global");
|
|
expect(overridden?.error).toContain("overridden by bundled plugin");
|
|
});
|
|
});
|
|
|
|
it("warns when plugins.allow is empty and non-bundled plugins are discoverable", () => {
|
|
useNoBundledPlugins();
|
|
const plugin = writePlugin({
|
|
id: "warn-open-allow",
|
|
body: `module.exports = { id: "warn-open-allow", register() {} };`,
|
|
});
|
|
const warnings: string[] = [];
|
|
loadOpenClawPlugins({
|
|
cache: false,
|
|
logger: createWarningLogger(warnings),
|
|
config: {
|
|
plugins: {
|
|
load: { paths: [plugin.file] },
|
|
},
|
|
},
|
|
});
|
|
expect(
|
|
warnings.some((msg) => msg.includes("plugins.allow is empty") && msg.includes(plugin.id)),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("warns when loaded non-bundled plugin has no install/load-path provenance", () => {
|
|
useNoBundledPlugins();
|
|
const stateDir = makeTempDir();
|
|
withEnv({ OPENCLAW_STATE_DIR: stateDir, CLAWDBOT_STATE_DIR: undefined }, () => {
|
|
const globalDir = path.join(stateDir, "extensions", "rogue");
|
|
fs.mkdirSync(globalDir, { recursive: true });
|
|
writePlugin({
|
|
id: "rogue",
|
|
body: `module.exports = { id: "rogue", register() {} };`,
|
|
dir: globalDir,
|
|
filename: "index.cjs",
|
|
});
|
|
|
|
const warnings: string[] = [];
|
|
const registry = loadOpenClawPlugins({
|
|
cache: false,
|
|
logger: createWarningLogger(warnings),
|
|
config: {
|
|
plugins: {
|
|
allow: ["rogue"],
|
|
},
|
|
},
|
|
});
|
|
|
|
const rogue = registry.plugins.find((entry) => entry.id === "rogue");
|
|
expect(rogue?.status).toBe("loaded");
|
|
expect(
|
|
warnings.some(
|
|
(msg) =>
|
|
msg.includes("rogue") && msg.includes("loaded without install/load-path provenance"),
|
|
),
|
|
).toBe(true);
|
|
});
|
|
});
|
|
|
|
it("rejects plugin entry files that escape plugin root via symlink", () => {
|
|
useNoBundledPlugins();
|
|
const { outsideEntry, linkedEntry } = createEscapingEntryFixture({
|
|
id: "symlinked",
|
|
sourceBody:
|
|
'module.exports = { id: "symlinked", register() { throw new Error("should not run"); } };',
|
|
});
|
|
try {
|
|
fs.symlinkSync(outsideEntry, linkedEntry);
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
const registry = loadOpenClawPlugins({
|
|
cache: false,
|
|
config: {
|
|
plugins: {
|
|
load: { paths: [linkedEntry] },
|
|
allow: ["symlinked"],
|
|
},
|
|
},
|
|
});
|
|
|
|
const record = registry.plugins.find((entry) => entry.id === "symlinked");
|
|
expect(record?.status).not.toBe("loaded");
|
|
expect(registry.diagnostics.some((entry) => entry.message.includes("escapes"))).toBe(true);
|
|
});
|
|
|
|
it("rejects plugin entry files that escape plugin root via hardlink", () => {
|
|
if (process.platform === "win32") {
|
|
return;
|
|
}
|
|
useNoBundledPlugins();
|
|
const { outsideEntry, linkedEntry } = createEscapingEntryFixture({
|
|
id: "hardlinked",
|
|
sourceBody:
|
|
'module.exports = { id: "hardlinked", register() { throw new Error("should not run"); } };',
|
|
});
|
|
try {
|
|
fs.linkSync(outsideEntry, linkedEntry);
|
|
} catch (err) {
|
|
if ((err as NodeJS.ErrnoException).code === "EXDEV") {
|
|
return;
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
const registry = loadOpenClawPlugins({
|
|
cache: false,
|
|
config: {
|
|
plugins: {
|
|
load: { paths: [linkedEntry] },
|
|
allow: ["hardlinked"],
|
|
},
|
|
},
|
|
});
|
|
|
|
const record = registry.plugins.find((entry) => entry.id === "hardlinked");
|
|
expect(record?.status).not.toBe("loaded");
|
|
expect(registry.diagnostics.some((entry) => entry.message.includes("escapes"))).toBe(true);
|
|
});
|
|
|
|
it("allows bundled plugin entry files that are hardlinked aliases", () => {
|
|
if (process.platform === "win32") {
|
|
return;
|
|
}
|
|
const bundledDir = makeTempDir();
|
|
const pluginDir = path.join(bundledDir, "hardlinked-bundled");
|
|
fs.mkdirSync(pluginDir, { recursive: true });
|
|
|
|
const outsideDir = makeTempDir();
|
|
const outsideEntry = path.join(outsideDir, "outside.cjs");
|
|
fs.writeFileSync(
|
|
outsideEntry,
|
|
'module.exports = { id: "hardlinked-bundled", register() {} };',
|
|
"utf-8",
|
|
);
|
|
const plugin = writePlugin({
|
|
id: "hardlinked-bundled",
|
|
body: 'module.exports = { id: "hardlinked-bundled", register() {} };',
|
|
dir: pluginDir,
|
|
filename: "index.cjs",
|
|
});
|
|
fs.rmSync(plugin.file);
|
|
try {
|
|
fs.linkSync(outsideEntry, plugin.file);
|
|
} catch (err) {
|
|
if ((err as NodeJS.ErrnoException).code === "EXDEV") {
|
|
return;
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir;
|
|
const registry = loadOpenClawPlugins({
|
|
cache: false,
|
|
workspaceDir: bundledDir,
|
|
config: {
|
|
plugins: {
|
|
entries: {
|
|
"hardlinked-bundled": { enabled: true },
|
|
},
|
|
allow: ["hardlinked-bundled"],
|
|
},
|
|
},
|
|
});
|
|
|
|
const record = registry.plugins.find((entry) => entry.id === "hardlinked-bundled");
|
|
expect(record?.status).toBe("loaded");
|
|
expect(registry.diagnostics.some((entry) => entry.message.includes("unsafe plugin path"))).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
it("preserves runtime reflection semantics when runtime is lazily initialized", () => {
|
|
useNoBundledPlugins();
|
|
const plugin = writePlugin({
|
|
id: "runtime-introspection",
|
|
filename: "runtime-introspection.cjs",
|
|
body: `module.exports = { id: "runtime-introspection", register(api) {
|
|
const runtime = api.runtime ?? {};
|
|
const keys = Object.keys(runtime);
|
|
if (!keys.includes("channel")) {
|
|
throw new Error("runtime channel key missing");
|
|
}
|
|
if (!("channel" in runtime)) {
|
|
throw new Error("runtime channel missing from has check");
|
|
}
|
|
if (!Object.getOwnPropertyDescriptor(runtime, "channel")) {
|
|
throw new Error("runtime channel descriptor missing");
|
|
}
|
|
} };`,
|
|
});
|
|
|
|
const registry = loadRegistryFromSinglePlugin({
|
|
plugin,
|
|
pluginConfig: {
|
|
allow: ["runtime-introspection"],
|
|
},
|
|
});
|
|
|
|
const record = registry.plugins.find((entry) => entry.id === "runtime-introspection");
|
|
expect(record?.status).toBe("loaded");
|
|
});
|
|
|
|
it("supports legacy plugins importing monolithic plugin-sdk root", () => {
|
|
useNoBundledPlugins();
|
|
const plugin = writePlugin({
|
|
id: "legacy-root-import",
|
|
filename: "legacy-root-import.cjs",
|
|
body: `module.exports = {
|
|
id: "legacy-root-import",
|
|
configSchema: (require("openclaw/plugin-sdk").emptyPluginConfigSchema)(),
|
|
register() {},
|
|
};`,
|
|
});
|
|
|
|
const registry = loadRegistryFromSinglePlugin({
|
|
plugin,
|
|
pluginConfig: {
|
|
allow: ["legacy-root-import"],
|
|
},
|
|
});
|
|
|
|
const record = registry.plugins.find((entry) => entry.id === "legacy-root-import");
|
|
expect({ status: record?.status, error: record?.error }).toMatchObject({ status: "loaded" });
|
|
});
|
|
|
|
it("prefers dist plugin-sdk alias when loader runs from dist", () => {
|
|
const { root, distFile } = createPluginSdkAliasFixture();
|
|
|
|
const resolved = __testing.resolvePluginSdkAliasFile({
|
|
srcFile: "index.ts",
|
|
distFile: "index.js",
|
|
modulePath: path.join(root, "dist", "plugins", "loader.js"),
|
|
});
|
|
expect(resolved).toBe(distFile);
|
|
});
|
|
|
|
it("prefers dist candidates first for production src runtime", () => {
|
|
const { root, srcFile, distFile } = createPluginSdkAliasFixture();
|
|
|
|
const candidates = withEnv({ NODE_ENV: "production", VITEST: undefined }, () =>
|
|
__testing.listPluginSdkAliasCandidates({
|
|
srcFile: "index.ts",
|
|
distFile: "index.js",
|
|
modulePath: path.join(root, "src", "plugins", "loader.ts"),
|
|
}),
|
|
);
|
|
|
|
expect(candidates.indexOf(distFile)).toBeLessThan(candidates.indexOf(srcFile));
|
|
});
|
|
|
|
it("prefers src plugin-sdk alias when loader runs from src in non-production", () => {
|
|
const { root, srcFile } = createPluginSdkAliasFixture();
|
|
|
|
const resolved = withEnv({ NODE_ENV: undefined }, () =>
|
|
__testing.resolvePluginSdkAliasFile({
|
|
srcFile: "index.ts",
|
|
distFile: "index.js",
|
|
modulePath: path.join(root, "src", "plugins", "loader.ts"),
|
|
}),
|
|
);
|
|
expect(resolved).toBe(srcFile);
|
|
});
|
|
|
|
it("prefers src candidates first for non-production src runtime", () => {
|
|
const { root, srcFile, distFile } = createPluginSdkAliasFixture();
|
|
|
|
const candidates = withEnv({ NODE_ENV: undefined }, () =>
|
|
__testing.listPluginSdkAliasCandidates({
|
|
srcFile: "index.ts",
|
|
distFile: "index.js",
|
|
modulePath: path.join(root, "src", "plugins", "loader.ts"),
|
|
}),
|
|
);
|
|
|
|
expect(candidates.indexOf(srcFile)).toBeLessThan(candidates.indexOf(distFile));
|
|
});
|
|
|
|
it("falls back to src plugin-sdk alias when dist is missing in production", () => {
|
|
const { root, srcFile, distFile } = createPluginSdkAliasFixture();
|
|
fs.rmSync(distFile);
|
|
|
|
const resolved = withEnv({ NODE_ENV: "production", VITEST: undefined }, () =>
|
|
__testing.resolvePluginSdkAliasFile({
|
|
srcFile: "index.ts",
|
|
distFile: "index.js",
|
|
modulePath: path.join(root, "src", "plugins", "loader.ts"),
|
|
}),
|
|
);
|
|
expect(resolved).toBe(srcFile);
|
|
});
|
|
|
|
it("prefers dist root-alias shim when loader runs from dist", () => {
|
|
const { root, distFile } = createPluginSdkAliasFixture({
|
|
srcFile: "root-alias.cjs",
|
|
distFile: "root-alias.cjs",
|
|
srcBody: "module.exports = {};\n",
|
|
distBody: "module.exports = {};\n",
|
|
});
|
|
|
|
const resolved = __testing.resolvePluginSdkAliasFile({
|
|
srcFile: "root-alias.cjs",
|
|
distFile: "root-alias.cjs",
|
|
modulePath: path.join(root, "dist", "plugins", "loader.js"),
|
|
});
|
|
expect(resolved).toBe(distFile);
|
|
});
|
|
|
|
it("prefers src root-alias shim when loader runs from src in non-production", () => {
|
|
const { root, srcFile } = createPluginSdkAliasFixture({
|
|
srcFile: "root-alias.cjs",
|
|
distFile: "root-alias.cjs",
|
|
srcBody: "module.exports = {};\n",
|
|
distBody: "module.exports = {};\n",
|
|
});
|
|
|
|
const resolved = withEnv({ NODE_ENV: undefined }, () =>
|
|
__testing.resolvePluginSdkAliasFile({
|
|
srcFile: "root-alias.cjs",
|
|
distFile: "root-alias.cjs",
|
|
modulePath: path.join(root, "src", "plugins", "loader.ts"),
|
|
}),
|
|
);
|
|
expect(resolved).toBe(srcFile);
|
|
});
|
|
});
|