mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-23 14:11:18 +00:00
* ci: enforce max-lines suppression ratchet * chore: prune stale max-lines suppression * fix: close max-lines ratchet enforcement gaps * fix: harden max-lines ratchet checks * fix(ci): satisfy max-lines ratchet checks * style: format max-lines declarations * fix(ci): match oxlint suppression grammar * test: isolate max-lines git fixtures * chore: prune resolved max-lines debt * test: skip newline path fixture on Windows * fix: harden max-lines suppression ratchet * chore: refresh max-lines baseline * fix: close max-lines ratchet bypasses * fix: derive ratchet base from PR merge tree * fix: support older Git in staged ratchet * fix: align max-lines declarations and baseline * chore: refresh max-lines baseline for current main * fix: exclude generated wizard locales from max-lines * chore: prune resolved max-lines debt
1886 lines
60 KiB
TypeScript
1886 lines
60 KiB
TypeScript
// Imported by loader.test.ts to keep its mocked suite in one Vitest module graph.
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
|
|
import { applyBootstrapHookOverrides } from "../agents/bootstrap-hooks.js";
|
|
import type { WorkspaceBootstrapFile } from "../agents/workspace.js";
|
|
import { resolveConfigEnvVars } from "../config/env-substitution.js";
|
|
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
|
|
import {
|
|
clearInternalHooks,
|
|
createInternalHookEvent,
|
|
getRegisteredEventKeys,
|
|
triggerInternalHook,
|
|
} from "../hooks/internal-hooks.js";
|
|
import { withEnv } from "../test-utils/env.js";
|
|
import { clearPluginCommands } from "./command-registry-state.js";
|
|
import { getPluginCommandSpecs } from "./command-specs.js";
|
|
import { getGlobalHookRunner, resetGlobalHookRunner } from "./hook-runner-global.js";
|
|
import { writePersistedInstalledPluginIndexInstallRecordsSync } from "./installed-plugin-index-records.js";
|
|
import {
|
|
clearPluginInteractiveHandlers,
|
|
resolvePluginInteractiveNamespaceMatch,
|
|
} from "./interactive-registry.js";
|
|
import { loadOpenClawPlugins, resolveRuntimePluginRegistry } from "./loader.js";
|
|
import {
|
|
EMPTY_PLUGIN_SCHEMA,
|
|
makeTempDir,
|
|
type PluginLoadConfig,
|
|
useNoBundledPlugins,
|
|
writePlugin,
|
|
} from "./loader.test-fixtures.js";
|
|
import {
|
|
cachedBundledTelegramDir,
|
|
listRegisteredAgentHarnessIdsForTest,
|
|
countMatching,
|
|
updatePluginManifest,
|
|
RESERVED_ADMIN_PLUGIN_METHOD,
|
|
RESERVED_ADMIN_SCOPE_WARNING,
|
|
writeBundledPlugin,
|
|
loadBundledMemoryPluginRegistry,
|
|
setupBundledTelegramPlugin,
|
|
expectTelegramLoaded,
|
|
loadRegistryFromSinglePlugin,
|
|
loadRegistryFromAllowedPlugins,
|
|
expectDiagnosticContaining,
|
|
expectNoDiagnosticContaining,
|
|
createStartupTraceRecorder,
|
|
collectStartupTraceMetrics,
|
|
globalAfterEach0,
|
|
globalAfterAll1,
|
|
} from "./loader.test-harness.js";
|
|
import { loadPluginManifestRegistry } from "./manifest-registry.js";
|
|
import { createEmptyPluginRegistry } from "./registry.js";
|
|
import {
|
|
getActivePluginRegistry,
|
|
getActivePluginRegistryKey,
|
|
listImportedRuntimePluginIds,
|
|
setActivePluginRegistry,
|
|
} from "./runtime.js";
|
|
|
|
afterEach(globalAfterEach0);
|
|
afterAll(globalAfterAll1);
|
|
|
|
describe("loadOpenClawPlugins", () => {
|
|
it("emits loader startup trace timings for normal plugin load and register", () => {
|
|
useNoBundledPlugins();
|
|
const plugin = writePlugin({
|
|
id: "trace-plugin",
|
|
filename: "trace-plugin.cjs",
|
|
body: `module.exports = { id: "trace-plugin", register() {} };`,
|
|
});
|
|
const { details, startupTrace } = createStartupTraceRecorder();
|
|
|
|
loadRegistryFromSinglePlugin({
|
|
plugin,
|
|
pluginConfig: {
|
|
allow: ["trace-plugin"],
|
|
},
|
|
options: {
|
|
startupTrace,
|
|
},
|
|
});
|
|
|
|
const metrics = collectStartupTraceMetrics(details, "plugins.gateway-load.plugin.trace-plugin");
|
|
expect(metrics.loadMs).toEqual(expect.any(Number));
|
|
expect(metrics.loadFailedCount).toBe(0);
|
|
expect(metrics.registerMs).toEqual(expect.any(Number));
|
|
expect(metrics.registerFailedCount).toBe(0);
|
|
expect(metrics.loadAndRegisterMs).toEqual(expect.any(Number));
|
|
});
|
|
|
|
it("resolves ${ENV_VAR} references in plugin config before handing config to the plugin", () => {
|
|
useNoBundledPlugins();
|
|
const plugin = writePlugin({
|
|
id: "env-config-probe",
|
|
filename: "env-config-probe.cjs",
|
|
// Schema must permit apiKey, otherwise the empty-schema guard rejects the
|
|
// config before register() runs and the env substitution is never exercised.
|
|
configSchema: {
|
|
type: "object",
|
|
additionalProperties: false,
|
|
properties: { apiKey: { type: "string" } },
|
|
},
|
|
body: `module.exports = {
|
|
id: "env-config-probe",
|
|
register(api) {
|
|
globalThis.envConfigProbeResult = api.pluginConfig;
|
|
},
|
|
};`,
|
|
});
|
|
const probe = globalThis as unknown as Record<string, unknown>;
|
|
const entries = {
|
|
"env-config-probe": { config: { apiKey: "${ENV_CONFIG_PROBE_SECRET}" } },
|
|
};
|
|
|
|
// Case 1: the referenced variable is present in process.env.
|
|
delete probe.envConfigProbeResult;
|
|
withEnv({ ENV_CONFIG_PROBE_SECRET: "process-env-secret" }, () => {
|
|
loadRegistryFromSinglePlugin({
|
|
plugin,
|
|
pluginConfig: { allow: ["env-config-probe"], entries },
|
|
options: { resolveRawConfigEnvVars: true },
|
|
});
|
|
});
|
|
// Before the fix, the plugin received the literal "${ENV_CONFIG_PROBE_SECRET}".
|
|
expect(probe.envConfigProbeResult).toMatchObject({ apiKey: "process-env-secret" });
|
|
|
|
// Case 2: the referenced variable lives only in the loader's explicit env,
|
|
// not in process.env — proving the substitution honors the per-load env.
|
|
delete probe.envConfigProbeResult;
|
|
expect(process.env.ENV_CONFIG_PROBE_SECRET).toBeUndefined();
|
|
loadRegistryFromSinglePlugin({
|
|
plugin,
|
|
pluginConfig: { allow: ["env-config-probe"], entries },
|
|
options: {
|
|
env: { ...process.env, ENV_CONFIG_PROBE_SECRET: "explicit-env-secret" },
|
|
resolveRawConfigEnvVars: true,
|
|
},
|
|
});
|
|
expect(probe.envConfigProbeResult).toMatchObject({ apiKey: "explicit-env-secret" });
|
|
|
|
// Case 3: config.env.vars participates in the same effective env as config IO.
|
|
delete probe.envConfigProbeResult;
|
|
withEnv({ ENV_CONFIG_PROBE_SECRET: undefined }, () => {
|
|
loadOpenClawPlugins({
|
|
cache: false,
|
|
workspaceDir: plugin.dir,
|
|
config: {
|
|
env: {
|
|
vars: {
|
|
ENV_CONFIG_PROBE_PLUGIN_FILE: plugin.file,
|
|
ENV_CONFIG_PROBE_SECRET: "config-env-secret",
|
|
},
|
|
},
|
|
plugins: {
|
|
load: { paths: ["${ENV_CONFIG_PROBE_PLUGIN_FILE}"] },
|
|
allow: ["env-config-probe"],
|
|
entries,
|
|
},
|
|
},
|
|
resolveRawConfigEnvVars: true,
|
|
});
|
|
});
|
|
expect(probe.envConfigProbeResult).toMatchObject({ apiKey: "config-env-secret" });
|
|
|
|
// Case 4: config that already went through read-time substitution must not
|
|
// be processed again. Escaped placeholders intentionally become literals.
|
|
delete probe.envConfigProbeResult;
|
|
const resolvedEscapedEntries = resolveConfigEnvVars(
|
|
{
|
|
"env-config-probe": { config: { apiKey: "$${ENV_CONFIG_PROBE_SECRET}" } },
|
|
},
|
|
{ ENV_CONFIG_PROBE_SECRET: "should-not-leak" } as NodeJS.ProcessEnv,
|
|
) as typeof entries;
|
|
withEnv({ ENV_CONFIG_PROBE_SECRET: "process-env-secret" }, () => {
|
|
loadRegistryFromSinglePlugin({
|
|
plugin,
|
|
pluginConfig: {
|
|
allow: ["env-config-probe"],
|
|
entries: structuredClone(resolvedEscapedEntries),
|
|
},
|
|
});
|
|
});
|
|
expect(probe.envConfigProbeResult).toMatchObject({
|
|
apiKey: "${ENV_CONFIG_PROBE_SECRET}",
|
|
});
|
|
});
|
|
|
|
it("emits loader startup trace failure counts for load and register failures", () => {
|
|
useNoBundledPlugins();
|
|
const loadFailPlugin = writePlugin({
|
|
id: "trace-load-fail",
|
|
filename: "trace-load-fail.cjs",
|
|
body: `throw new Error("load boom");`,
|
|
});
|
|
const registerFailPlugin = writePlugin({
|
|
id: "trace-register-fail",
|
|
filename: "trace-register-fail.cjs",
|
|
body: `module.exports = { id: "trace-register-fail", register() { throw new Error("register boom"); } };`,
|
|
});
|
|
const { details, startupTrace } = createStartupTraceRecorder();
|
|
|
|
loadOpenClawPlugins({
|
|
cache: false,
|
|
config: {
|
|
plugins: {
|
|
load: { paths: [loadFailPlugin.file, registerFailPlugin.file] },
|
|
allow: ["trace-load-fail", "trace-register-fail"],
|
|
},
|
|
},
|
|
startupTrace,
|
|
});
|
|
|
|
const loadFailMetrics = collectStartupTraceMetrics(
|
|
details,
|
|
"plugins.gateway-load.plugin.trace-load-fail",
|
|
);
|
|
expect(loadFailMetrics.loadMs).toEqual(expect.any(Number));
|
|
expect(loadFailMetrics.loadFailedCount).toBe(1);
|
|
expect(loadFailMetrics.registerMs).toBeUndefined();
|
|
|
|
const registerFailMetrics = collectStartupTraceMetrics(
|
|
details,
|
|
"plugins.gateway-load.plugin.trace-register-fail",
|
|
);
|
|
expect(registerFailMetrics.loadFailedCount).toBe(0);
|
|
expect(registerFailMetrics.registerMs).toEqual(expect.any(Number));
|
|
expect(registerFailMetrics.registerFailedCount).toBe(1);
|
|
expect(registerFailMetrics.loadAndRegisterMs).toEqual(expect.any(Number));
|
|
});
|
|
|
|
it("rolls back trusted policies when plugin register fails", () => {
|
|
useNoBundledPlugins();
|
|
const plugin = writePlugin({
|
|
id: "trusted-policy-register-fail",
|
|
filename: "trusted-policy-register-fail.cjs",
|
|
body: `module.exports = { id: "trusted-policy-register-fail", register(api) {
|
|
api.registerTrustedToolPolicy({
|
|
id: "failed-policy",
|
|
description: "Must be removed after register failure",
|
|
evaluate: () => ({ block: true, blockReason: "stale failed policy" })
|
|
});
|
|
throw new Error("register boom");
|
|
} };`,
|
|
});
|
|
updatePluginManifest(plugin, {
|
|
contracts: { trustedToolPolicies: ["failed-policy"] },
|
|
});
|
|
|
|
const registry = loadRegistryFromSinglePlugin({
|
|
plugin,
|
|
pluginConfig: {
|
|
allow: ["trusted-policy-register-fail"],
|
|
},
|
|
});
|
|
|
|
expect(registry.trustedToolPolicies).toHaveLength(0);
|
|
const loaded = registry.plugins.find((entry) => entry.id === "trusted-policy-register-fail");
|
|
expect(loaded?.status).toBe("error");
|
|
expect(loaded?.error).toContain("register boom");
|
|
expectDiagnosticContaining({
|
|
registry,
|
|
level: "error",
|
|
pluginId: "trusted-policy-register-fail",
|
|
message: "plugin failed during register: Error: register boom",
|
|
});
|
|
});
|
|
|
|
it("rolls back worker providers when plugin register fails", () => {
|
|
useNoBundledPlugins();
|
|
const plugin = writePlugin({
|
|
id: "worker-provider-register-fail",
|
|
filename: "worker-provider-register-fail.cjs",
|
|
body: `module.exports = { id: "worker-provider-register-fail", register(api) {
|
|
api.registerWorkerProvider({
|
|
id: "failed-worker",
|
|
provision: async () => { throw new Error("not called"); },
|
|
inspect: async () => ({ status: "unknown" }),
|
|
destroy: async () => {}
|
|
});
|
|
throw new Error("register boom");
|
|
} };`,
|
|
});
|
|
updatePluginManifest(plugin, {
|
|
contracts: { workerProviders: ["failed-worker"] },
|
|
});
|
|
|
|
const registry = loadRegistryFromSinglePlugin({
|
|
plugin,
|
|
pluginConfig: {
|
|
allow: ["worker-provider-register-fail"],
|
|
},
|
|
});
|
|
|
|
expect(registry.workerProviders.size).toBe(0);
|
|
const loaded = registry.plugins.find((entry) => entry.id === "worker-provider-register-fail");
|
|
expect(loaded?.status).toBe("error");
|
|
expect(loaded?.error).toContain("register boom");
|
|
});
|
|
|
|
it("loads declared installed trusted policies through plugin manifests", () => {
|
|
useNoBundledPlugins();
|
|
const plugin = writePlugin({
|
|
id: "trusted-policy-success",
|
|
filename: "trusted-policy-success.cjs",
|
|
body: `module.exports = { id: "trusted-policy-success", register(api) {
|
|
api.registerTrustedToolPolicy({
|
|
id: "declared-policy",
|
|
description: "Declared installed policy",
|
|
evaluate: () => ({ allow: true })
|
|
});
|
|
} };`,
|
|
});
|
|
updatePluginManifest(plugin, {
|
|
contracts: { trustedToolPolicies: ["declared-policy"] },
|
|
});
|
|
|
|
const registry = loadRegistryFromSinglePlugin({
|
|
plugin,
|
|
pluginConfig: {
|
|
allow: ["trusted-policy-success"],
|
|
},
|
|
});
|
|
|
|
expect(registry.trustedToolPolicies.map((entry) => [entry.pluginId, entry.policy.id])).toEqual([
|
|
["trusted-policy-success", "declared-policy"],
|
|
]);
|
|
expect(registry.diagnostics).not.toContainEqual(
|
|
expect.objectContaining({
|
|
pluginId: "trusted-policy-success",
|
|
message: "plugin must declare contracts.trustedToolPolicies for: declared-policy",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("keeps earlier trusted policies when a later plugin register fails", () => {
|
|
useNoBundledPlugins();
|
|
const stablePlugin = writePlugin({
|
|
id: "stable-trusted-policy",
|
|
filename: "stable-trusted-policy.cjs",
|
|
body: `module.exports = { id: "stable-trusted-policy", register(api) {
|
|
api.registerTrustedToolPolicy({
|
|
id: "stable-policy",
|
|
description: "Stable policy must survive later failures",
|
|
evaluate: () => ({ allow: true })
|
|
});
|
|
} };`,
|
|
});
|
|
updatePluginManifest(stablePlugin, {
|
|
contracts: { trustedToolPolicies: ["stable-policy"] },
|
|
});
|
|
const failingPlugin = writePlugin({
|
|
id: "later-trusted-policy-register-fail",
|
|
filename: "later-trusted-policy-register-fail.cjs",
|
|
body: `module.exports = { id: "later-trusted-policy-register-fail", register(api) {
|
|
api.registerTrustedToolPolicy({
|
|
id: "failed-policy",
|
|
description: "Must be removed after register failure",
|
|
evaluate: () => ({ block: true, blockReason: "stale failed policy" })
|
|
});
|
|
throw new Error("register boom");
|
|
} };`,
|
|
});
|
|
updatePluginManifest(failingPlugin, {
|
|
contracts: { trustedToolPolicies: ["failed-policy"] },
|
|
});
|
|
|
|
const registry = loadRegistryFromAllowedPlugins([stablePlugin, failingPlugin]);
|
|
|
|
expect(registry.trustedToolPolicies.map((entry) => [entry.pluginId, entry.policy.id])).toEqual([
|
|
["stable-trusted-policy", "stable-policy"],
|
|
]);
|
|
const failed = registry.plugins.find(
|
|
(entry) => entry.id === "later-trusted-policy-register-fail",
|
|
);
|
|
expect(failed?.status).toBe("error");
|
|
expect(failed?.error).toContain("register boom");
|
|
expectDiagnosticContaining({
|
|
registry,
|
|
level: "error",
|
|
pluginId: "later-trusted-policy-register-fail",
|
|
message: "plugin failed during register: Error: register boom",
|
|
});
|
|
});
|
|
|
|
it("can load scoped plugins from a supplied manifest registry without rereading manifests", () => {
|
|
useNoBundledPlugins();
|
|
const plugin = writePlugin({
|
|
id: "supplied-manifest",
|
|
body: `module.exports = { id: "supplied-manifest", register() {} };`,
|
|
});
|
|
const config = {
|
|
plugins: {
|
|
load: { paths: [plugin.file] },
|
|
allow: [plugin.id],
|
|
},
|
|
};
|
|
const manifestRegistry = loadPluginManifestRegistry({ config });
|
|
fs.rmSync(path.join(plugin.dir, "openclaw.plugin.json"));
|
|
|
|
const registry = loadOpenClawPlugins({
|
|
cache: false,
|
|
config,
|
|
manifestRegistry,
|
|
onlyPluginIds: [plugin.id],
|
|
});
|
|
|
|
expect(registry.plugins.find((entry) => entry.id === plugin.id)?.status).toBe("loaded");
|
|
});
|
|
|
|
it("loads installed plugin packages discovered from persisted install records", () => {
|
|
useNoBundledPlugins();
|
|
const stateDir = makeTempDir();
|
|
const plugin = writePlugin({
|
|
id: "installed-record-plugin",
|
|
body: `module.exports = { id: "installed-record-plugin", register() {} };`,
|
|
});
|
|
writePersistedInstalledPluginIndexInstallRecordsSync(
|
|
{
|
|
[plugin.id]: {
|
|
source: "git",
|
|
spec: "git:file:///tmp/installed-record-plugin.git@abc123",
|
|
installPath: plugin.dir,
|
|
gitUrl: "file:///tmp/installed-record-plugin.git",
|
|
gitCommit: "abc123",
|
|
},
|
|
},
|
|
{ stateDir },
|
|
);
|
|
|
|
const registry = withEnv({ OPENCLAW_STATE_DIR: stateDir }, () =>
|
|
loadOpenClawPlugins({
|
|
cache: false,
|
|
config: {
|
|
plugins: {
|
|
entries: {
|
|
[plugin.id]: { enabled: true },
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
const record = registry.plugins.find((entry) => entry.id === plugin.id);
|
|
expect(record?.id).toBe(plugin.id);
|
|
expect(record?.status).toBe("loaded");
|
|
expect(record?.rootDir).toBe(fs.realpathSync.native(plugin.dir));
|
|
});
|
|
|
|
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 plugins with plugin-sdk imports from a package dist root", () => {
|
|
const packageRoot = makeTempDir();
|
|
const bundledDir = path.join(packageRoot, "dist", "extensions");
|
|
const pluginRoot = path.join(bundledDir, "discord");
|
|
fs.mkdirSync(path.join(packageRoot, "dist", "plugin-sdk"), { recursive: true });
|
|
fs.mkdirSync(pluginRoot, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(packageRoot, "package.json"),
|
|
JSON.stringify({ name: "openclaw", version: "2026.4.22", type: "module" }),
|
|
"utf-8",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(packageRoot, "dist", "plugin-sdk", "string-coerce-runtime.js"),
|
|
"export const normalizeLowercaseStringOrEmpty = (value) => String(value).toLowerCase();\n",
|
|
"utf-8",
|
|
);
|
|
const aliasRoot = path.join(bundledDir, "node_modules", "openclaw");
|
|
const aliasPluginSdkDir = path.join(aliasRoot, "plugin-sdk");
|
|
fs.mkdirSync(aliasPluginSdkDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(aliasRoot, "package.json"),
|
|
JSON.stringify({
|
|
name: "openclaw",
|
|
type: "module",
|
|
exports: {
|
|
"./plugin-sdk/string-coerce-runtime": "./plugin-sdk/string-coerce-runtime.js",
|
|
},
|
|
}),
|
|
"utf-8",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(aliasPluginSdkDir, "string-coerce-runtime.js"),
|
|
"export * from '../../../../plugin-sdk/string-coerce-runtime.js';\n",
|
|
"utf-8",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(pluginRoot, "index.js"),
|
|
[
|
|
`import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";`,
|
|
`export default {`,
|
|
` id: "discord",`,
|
|
` register(api) {`,
|
|
` api.registerCommand({ name: normalizeLowercaseStringOrEmpty("DISCORD"), handler: () => "ok" });`,
|
|
` },`,
|
|
`};`,
|
|
"",
|
|
].join("\n"),
|
|
"utf-8",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(pluginRoot, "package.json"),
|
|
JSON.stringify(
|
|
{
|
|
name: "@openclaw/discord",
|
|
version: "1.0.0",
|
|
type: "module",
|
|
openclaw: { extensions: ["./index.js"] },
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"utf-8",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(pluginRoot, "openclaw.plugin.json"),
|
|
JSON.stringify(
|
|
{
|
|
id: "discord",
|
|
enabledByDefault: true,
|
|
configSchema: EMPTY_PLUGIN_SCHEMA,
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"utf-8",
|
|
);
|
|
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir;
|
|
|
|
const registry = loadOpenClawPlugins({
|
|
cache: false,
|
|
config: {
|
|
plugins: {
|
|
enabled: true,
|
|
},
|
|
},
|
|
});
|
|
|
|
const record = registry.plugins.find((entry) => entry.id === "discord");
|
|
expect(
|
|
record?.status,
|
|
JSON.stringify({ error: record?.error, diagnostics: registry.diagnostics }, null, 2),
|
|
).toBe("loaded");
|
|
});
|
|
|
|
it("registers standalone text transforms", () => {
|
|
useNoBundledPlugins();
|
|
const plugin = writePlugin({
|
|
id: "text-shim",
|
|
filename: "text-shim.cjs",
|
|
body: `module.exports = {
|
|
id: "text-shim",
|
|
register(api) {
|
|
api.registerTextTransforms({
|
|
input: [{ from: /red basket/g, to: "blue basket" }],
|
|
output: [{ from: /blue basket/g, to: "red basket" }],
|
|
});
|
|
},
|
|
};`,
|
|
});
|
|
|
|
const registry = loadRegistryFromSinglePlugin({
|
|
plugin,
|
|
pluginConfig: { allow: ["text-shim"] },
|
|
});
|
|
|
|
expect(registry.textTransforms).toHaveLength(1);
|
|
const transformRegistration = registry.textTransforms[0];
|
|
expect(transformRegistration?.pluginId).toBe("text-shim");
|
|
expect(transformRegistration?.transforms.input).toEqual([
|
|
{ from: /red basket/g, to: "blue basket" },
|
|
]);
|
|
expect(transformRegistration?.transforms.output).toEqual([
|
|
{ from: /blue basket/g, to: "red basket" },
|
|
]);
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: "loads bundled telegram plugin when enabled",
|
|
config: {
|
|
plugins: {
|
|
allow: ["telegram"],
|
|
entries: {
|
|
telegram: { enabled: true },
|
|
},
|
|
},
|
|
} satisfies PluginLoadConfig,
|
|
assert: (registry: ReturnType<typeof loadOpenClawPlugins>) => {
|
|
expectTelegramLoaded(registry);
|
|
},
|
|
},
|
|
{
|
|
name: "loads bundled channel plugins when channels.<id>.enabled=true",
|
|
config: {
|
|
channels: {
|
|
telegram: {
|
|
enabled: true,
|
|
},
|
|
},
|
|
plugins: {
|
|
enabled: true,
|
|
},
|
|
} satisfies PluginLoadConfig,
|
|
assert: (registry: ReturnType<typeof loadOpenClawPlugins>) => {
|
|
expectTelegramLoaded(registry);
|
|
},
|
|
},
|
|
{
|
|
name: "lets explicit bundled channel enablement bypass restrictive allowlists",
|
|
config: {
|
|
channels: {
|
|
telegram: {
|
|
enabled: true,
|
|
},
|
|
},
|
|
plugins: {
|
|
allow: ["browser"],
|
|
},
|
|
} satisfies PluginLoadConfig,
|
|
assert: (registry: ReturnType<typeof loadOpenClawPlugins>) => {
|
|
const telegram = registry.plugins.find((entry) => entry.id === "telegram");
|
|
expect(telegram?.status).toBe("loaded");
|
|
expect(telegram?.error).toBeUndefined();
|
|
expect(telegram?.explicitlyEnabled).toBe(true);
|
|
},
|
|
},
|
|
{
|
|
name: "still respects explicit disable via plugins.entries for bundled channels",
|
|
config: {
|
|
channels: {
|
|
telegram: {
|
|
enabled: true,
|
|
},
|
|
},
|
|
plugins: {
|
|
entries: {
|
|
telegram: { enabled: false },
|
|
},
|
|
},
|
|
} satisfies PluginLoadConfig,
|
|
assert: (registry: ReturnType<typeof loadOpenClawPlugins>) => {
|
|
const telegram = registry.plugins.find((entry) => entry.id === "telegram");
|
|
expect(telegram?.status).toBe("disabled");
|
|
expect(telegram?.error).toBe("disabled in config");
|
|
},
|
|
},
|
|
] as const)(
|
|
"handles bundled telegram plugin enablement and override rules: $name",
|
|
({ config, assert }) => {
|
|
setupBundledTelegramPlugin();
|
|
const registry = loadOpenClawPlugins({
|
|
cache: false,
|
|
workspaceDir: cachedBundledTelegramDir,
|
|
config,
|
|
});
|
|
assert(registry);
|
|
},
|
|
);
|
|
|
|
it("marks auto-enabled bundled channels as activated but not explicitly enabled", () => {
|
|
setupBundledTelegramPlugin();
|
|
const rawConfig = {
|
|
channels: {
|
|
telegram: {
|
|
botToken: "x",
|
|
},
|
|
},
|
|
plugins: {
|
|
enabled: true,
|
|
},
|
|
} satisfies PluginLoadConfig;
|
|
const autoEnabled = applyPluginAutoEnable({
|
|
config: rawConfig,
|
|
env: {},
|
|
});
|
|
|
|
const registry = loadOpenClawPlugins({
|
|
cache: false,
|
|
workspaceDir: cachedBundledTelegramDir,
|
|
config: autoEnabled.config,
|
|
activationSourceConfig: rawConfig,
|
|
autoEnabledReasons: autoEnabled.autoEnabledReasons,
|
|
});
|
|
|
|
const telegram = registry.plugins.find((entry) => entry.id === "telegram");
|
|
expect(telegram?.explicitlyEnabled).toBe(false);
|
|
expect(telegram?.activated).toBe(true);
|
|
expect(telegram?.activationSource).toBe("auto");
|
|
expect(telegram?.activationReason).toBe("telegram configured");
|
|
});
|
|
|
|
it("materializes auto-enabled bundled channels into restrictive allowlists", () => {
|
|
setupBundledTelegramPlugin();
|
|
const rawConfig = {
|
|
channels: {
|
|
telegram: {
|
|
botToken: "x",
|
|
},
|
|
},
|
|
plugins: {
|
|
allow: ["browser"],
|
|
},
|
|
} satisfies PluginLoadConfig;
|
|
const autoEnabled = applyPluginAutoEnable({
|
|
config: rawConfig,
|
|
env: {},
|
|
});
|
|
|
|
const registry = loadOpenClawPlugins({
|
|
cache: false,
|
|
workspaceDir: cachedBundledTelegramDir,
|
|
config: autoEnabled.config,
|
|
activationSourceConfig: rawConfig,
|
|
autoEnabledReasons: autoEnabled.autoEnabledReasons,
|
|
});
|
|
|
|
const telegram = registry.plugins.find((entry) => entry.id === "telegram");
|
|
expect(autoEnabled.config.plugins?.allow).toEqual(["browser", "telegram"]);
|
|
expect(telegram?.status).toBe("loaded");
|
|
expect(telegram?.error).toBeUndefined();
|
|
expect(telegram?.explicitlyEnabled).toBe(false);
|
|
expect(telegram?.activated).toBe(true);
|
|
expect(telegram?.activationSource).toBe("auto");
|
|
expect(telegram?.activationReason).toBe("telegram configured");
|
|
});
|
|
|
|
it("preserves all auto-enable reasons in activation metadata", () => {
|
|
setupBundledTelegramPlugin();
|
|
const rawConfig = {
|
|
channels: {
|
|
telegram: {
|
|
botToken: "x",
|
|
},
|
|
},
|
|
plugins: {
|
|
enabled: true,
|
|
},
|
|
} satisfies PluginLoadConfig;
|
|
|
|
const registry = loadOpenClawPlugins({
|
|
cache: false,
|
|
workspaceDir: cachedBundledTelegramDir,
|
|
config: {
|
|
...rawConfig,
|
|
plugins: {
|
|
enabled: true,
|
|
entries: {
|
|
telegram: {
|
|
enabled: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
activationSourceConfig: rawConfig,
|
|
autoEnabledReasons: {
|
|
telegram: ["telegram configured", "telegram selected for startup"],
|
|
},
|
|
});
|
|
|
|
const telegram = registry.plugins.find((entry) => entry.id === "telegram");
|
|
expect(telegram?.explicitlyEnabled).toBe(false);
|
|
expect(telegram?.activated).toBe(true);
|
|
expect(telegram?.activationSource).toBe("auto");
|
|
expect(telegram?.activationReason).toBe("telegram configured; telegram selected for startup");
|
|
});
|
|
|
|
it("keeps explicit plugin enablement distinct from derived activation", () => {
|
|
const { bundledDir } = writeBundledPlugin({
|
|
id: "demo",
|
|
});
|
|
const config = {
|
|
plugins: {
|
|
entries: {
|
|
demo: {
|
|
enabled: true,
|
|
},
|
|
},
|
|
},
|
|
} satisfies PluginLoadConfig;
|
|
|
|
const registry = loadOpenClawPlugins({
|
|
cache: false,
|
|
workspaceDir: bundledDir,
|
|
config,
|
|
activationSourceConfig: config,
|
|
});
|
|
|
|
const demo = registry.plugins.find((entry) => entry.id === "demo");
|
|
expect(demo?.explicitlyEnabled).toBe(true);
|
|
expect(demo?.activated).toBe(true);
|
|
expect(demo?.activationSource).toBe("explicit");
|
|
expect(demo?.activationReason).toBe("enabled 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.each([
|
|
{
|
|
label: "loads plugins from config paths",
|
|
run: () => {
|
|
useNoBundledPlugins();
|
|
const plugin = writePlugin({
|
|
id: "allowed-config-path",
|
|
filename: "allowed-config-path.cjs",
|
|
body: `module.exports = {
|
|
id: "allowed-config-path",
|
|
register(api) {
|
|
api.registerGatewayMethod("allowed-config-path.ping", ({ respond }) => respond(true, { ok: true }));
|
|
},
|
|
};`,
|
|
});
|
|
|
|
const registry = loadOpenClawPlugins({
|
|
cache: false,
|
|
workspaceDir: plugin.dir,
|
|
config: {
|
|
plugins: {
|
|
load: { paths: [plugin.file] },
|
|
allow: ["allowed-config-path"],
|
|
},
|
|
},
|
|
});
|
|
|
|
const loaded = registry.plugins.find((entry) => entry.id === "allowed-config-path");
|
|
expect(loaded?.status).toBe("loaded");
|
|
expect(Object.keys(registry.gatewayHandlers)).toContain("allowed-config-path.ping");
|
|
expect(registry.gatewayMethodDescriptors).toMatchObject([
|
|
{
|
|
name: "allowed-config-path.ping",
|
|
owner: { kind: "plugin", pluginId: "allowed-config-path" },
|
|
},
|
|
]);
|
|
},
|
|
},
|
|
{
|
|
label: "adapts returned plugin gateway method values into responses",
|
|
run: async () => {
|
|
useNoBundledPlugins();
|
|
const plugin = writePlugin({
|
|
id: "returned-gateway-method",
|
|
filename: "returned-gateway-method.cjs",
|
|
body: `module.exports = {
|
|
id: "returned-gateway-method",
|
|
register(api) {
|
|
api.registerGatewayMethod("returned-gateway-method.status", async () => ({ ok: true, status: "ready" }));
|
|
},
|
|
};`,
|
|
});
|
|
|
|
const registry = loadOpenClawPlugins({
|
|
cache: false,
|
|
workspaceDir: plugin.dir,
|
|
config: {
|
|
plugins: {
|
|
load: { paths: [plugin.file] },
|
|
allow: ["returned-gateway-method"],
|
|
},
|
|
},
|
|
});
|
|
|
|
const responses: unknown[] = [];
|
|
await registry.gatewayHandlers["returned-gateway-method.status"]?.({
|
|
params: {},
|
|
respond: (ok: boolean, payload: unknown, error?: unknown) =>
|
|
responses.push({ ok, payload, error }),
|
|
} as never);
|
|
|
|
expect(responses).toEqual([
|
|
{ ok: true, payload: { ok: true, status: "ready" }, error: undefined },
|
|
]);
|
|
},
|
|
},
|
|
{
|
|
label: "keeps explicit plugin gateway responses authoritative",
|
|
run: async () => {
|
|
useNoBundledPlugins();
|
|
const plugin = writePlugin({
|
|
id: "explicit-gateway-method",
|
|
filename: "explicit-gateway-method.cjs",
|
|
body: `module.exports = {
|
|
id: "explicit-gateway-method",
|
|
register(api) {
|
|
api.registerGatewayMethod("explicit-gateway-method.status", ({ respond }) => {
|
|
respond(true, { status: "responded" });
|
|
return { status: "returned" };
|
|
});
|
|
},
|
|
};`,
|
|
});
|
|
|
|
const registry = loadOpenClawPlugins({
|
|
cache: false,
|
|
workspaceDir: plugin.dir,
|
|
config: {
|
|
plugins: {
|
|
load: { paths: [plugin.file] },
|
|
allow: ["explicit-gateway-method"],
|
|
},
|
|
},
|
|
});
|
|
|
|
const responses: unknown[] = [];
|
|
await registry.gatewayHandlers["explicit-gateway-method.status"]?.({
|
|
params: {},
|
|
respond: (ok: boolean, payload: unknown, error?: unknown) =>
|
|
responses.push({ ok, payload, error }),
|
|
} as never);
|
|
|
|
expect(responses).toEqual([
|
|
{ ok: true, payload: { status: "responded" }, error: undefined },
|
|
]);
|
|
},
|
|
},
|
|
{
|
|
label: "coerces reserved gateway method namespaces to operator.admin",
|
|
run: () => {
|
|
useNoBundledPlugins();
|
|
const plugin = writePlugin({
|
|
id: "reserved-gateway-scope",
|
|
filename: "reserved-gateway-scope.cjs",
|
|
body: `module.exports = {
|
|
id: "reserved-gateway-scope",
|
|
register(api) {
|
|
api.registerGatewayMethod(
|
|
${JSON.stringify(RESERVED_ADMIN_PLUGIN_METHOD)},
|
|
({ respond }) => respond(true, { ok: true }),
|
|
{ scope: "operator.read" },
|
|
);
|
|
},
|
|
};`,
|
|
});
|
|
|
|
const registry = loadOpenClawPlugins({
|
|
cache: false,
|
|
workspaceDir: plugin.dir,
|
|
config: {
|
|
plugins: {
|
|
load: { paths: [plugin.file] },
|
|
allow: ["reserved-gateway-scope"],
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(Object.keys(registry.gatewayHandlers)).toContain(RESERVED_ADMIN_PLUGIN_METHOD);
|
|
expect(registry.gatewayMethodDescriptors[0]?.scope).toBe("operator.admin");
|
|
expectDiagnosticContaining({
|
|
registry,
|
|
message: `${RESERVED_ADMIN_SCOPE_WARNING}: ${RESERVED_ADMIN_PLUGIN_METHOD}`,
|
|
});
|
|
},
|
|
},
|
|
{
|
|
label: "rejects gateway methods that collide with hidden core methods",
|
|
run: () => {
|
|
useNoBundledPlugins();
|
|
const plugin = writePlugin({
|
|
id: "hidden-core-collision",
|
|
filename: "hidden-core-collision.cjs",
|
|
body: `module.exports = {
|
|
id: "hidden-core-collision",
|
|
register(api) {
|
|
api.registerGatewayMethod("config.openFile", ({ respond }) => respond(true, { ok: true }));
|
|
},
|
|
};`,
|
|
});
|
|
|
|
const registry = loadOpenClawPlugins({
|
|
cache: false,
|
|
workspaceDir: plugin.dir,
|
|
coreGatewayMethodNames: ["config.openFile"],
|
|
config: {
|
|
plugins: {
|
|
load: { paths: [plugin.file] },
|
|
allow: ["hidden-core-collision"],
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(Object.keys(registry.gatewayHandlers)).not.toContain("config.openFile");
|
|
expectDiagnosticContaining({
|
|
registry,
|
|
level: "error",
|
|
pluginId: "hidden-core-collision",
|
|
message: "gateway method already registered: config.openFile",
|
|
});
|
|
},
|
|
},
|
|
{
|
|
label: "rejects async register functions instead of silently loading them",
|
|
run: () => {
|
|
useNoBundledPlugins();
|
|
const plugin = writePlugin({
|
|
id: "async-register",
|
|
filename: "async-register.cjs",
|
|
body: `module.exports = {
|
|
id: "async-register",
|
|
async register(api) {
|
|
await Promise.resolve();
|
|
api.registerGatewayMethod("async-register.ping", ({ respond }) => respond(true, { ok: true }));
|
|
},
|
|
};`,
|
|
});
|
|
|
|
const registry = loadOpenClawPlugins({
|
|
cache: false,
|
|
config: {
|
|
plugins: {
|
|
load: { paths: [plugin.file] },
|
|
allow: ["async-register"],
|
|
},
|
|
},
|
|
});
|
|
|
|
const loaded = registry.plugins.find((entry) => entry.id === "async-register");
|
|
expect(loaded?.status).toBe("error");
|
|
expect(loaded?.failurePhase).toBe("register");
|
|
expect(loaded?.error).toContain("plugin register must be synchronous");
|
|
expect(Object.keys(registry.gatewayHandlers)).not.toContain("async-register.ping");
|
|
},
|
|
},
|
|
{
|
|
label: "limits imports to the requested plugin ids",
|
|
run: () => {
|
|
useNoBundledPlugins();
|
|
const allowed = writePlugin({
|
|
id: "allowed-scoped-only",
|
|
filename: "allowed-scoped-only.cjs",
|
|
body: `module.exports = { id: "allowed-scoped-only", register() {} };`,
|
|
});
|
|
const skippedMarker = path.join(makeTempDir(), "skipped-loaded.txt");
|
|
const skipped = writePlugin({
|
|
id: "skipped-scoped-only",
|
|
filename: "skipped-scoped-only.cjs",
|
|
body: `require("node:fs").writeFileSync(${JSON.stringify(skippedMarker)}, "loaded", "utf-8");
|
|
module.exports = { id: "skipped-scoped-only", register() { throw new Error("skipped plugin should not load"); } };`,
|
|
});
|
|
|
|
const registry = loadOpenClawPlugins({
|
|
cache: false,
|
|
config: {
|
|
plugins: {
|
|
load: { paths: [allowed.file, skipped.file] },
|
|
allow: ["allowed-scoped-only", "skipped-scoped-only"],
|
|
},
|
|
},
|
|
onlyPluginIds: ["allowed-scoped-only"],
|
|
});
|
|
|
|
expect(registry.plugins.map((entry) => entry.id)).toEqual(["allowed-scoped-only"]);
|
|
expect(fs.existsSync(skippedMarker)).toBe(false);
|
|
},
|
|
},
|
|
{
|
|
label: "can build a manifest-only snapshot without importing plugin modules",
|
|
run: () => {
|
|
useNoBundledPlugins();
|
|
const importedMarker = path.join(makeTempDir(), "manifest-only-imported.txt");
|
|
const plugin = writePlugin({
|
|
id: "manifest-only-plugin",
|
|
filename: "manifest-only-plugin.cjs",
|
|
body: `require("node:fs").writeFileSync(${JSON.stringify(importedMarker)}, "loaded", "utf-8");
|
|
module.exports = { id: "manifest-only-plugin", register() { throw new Error("manifest-only snapshot should not register"); } };`,
|
|
});
|
|
|
|
const registry = loadOpenClawPlugins({
|
|
cache: false,
|
|
activate: false,
|
|
loadModules: false,
|
|
config: {
|
|
plugins: {
|
|
load: { paths: [plugin.file] },
|
|
allow: ["manifest-only-plugin"],
|
|
entries: {
|
|
"manifest-only-plugin": { enabled: true },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(fs.existsSync(importedMarker)).toBe(false);
|
|
const record = registry.plugins.find((entry) => entry.id === "manifest-only-plugin");
|
|
expect(record?.status).toBe("loaded");
|
|
},
|
|
},
|
|
{
|
|
label: "includes manifest-owned surfaces in manifest-only snapshots",
|
|
run: () => {
|
|
useNoBundledPlugins();
|
|
const importedMarker = path.join(makeTempDir(), "manifest-surfaces-imported.txt");
|
|
const plugin = writePlugin({
|
|
id: "manifest-surfaces-plugin",
|
|
filename: "manifest-surfaces-plugin.cjs",
|
|
body: `require("node:fs").writeFileSync(${JSON.stringify(importedMarker)}, "loaded", "utf-8");
|
|
module.exports = { id: "manifest-surfaces-plugin", register() { throw new Error("manifest-only snapshot should not register"); } };`,
|
|
});
|
|
fs.writeFileSync(
|
|
path.join(plugin.dir, "openclaw.plugin.json"),
|
|
JSON.stringify(
|
|
{
|
|
id: "manifest-surfaces-plugin",
|
|
configSchema: EMPTY_PLUGIN_SCHEMA,
|
|
channels: ["manifest-surfaces-channel"],
|
|
providers: ["manifest-surfaces-provider"],
|
|
cliBackends: ["manifest-surfaces-cli"],
|
|
setup: { cliBackends: ["manifest-surfaces-setup-cli"] },
|
|
commandAliases: [{ name: "manifest-surfaces-command" }],
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"utf-8",
|
|
);
|
|
|
|
const registry = loadOpenClawPlugins({
|
|
cache: false,
|
|
activate: false,
|
|
loadModules: false,
|
|
config: {
|
|
plugins: {
|
|
load: { paths: [plugin.file] },
|
|
allow: ["manifest-surfaces-plugin"],
|
|
entries: {
|
|
"manifest-surfaces-plugin": { enabled: true },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const record = registry.plugins.find((entry) => entry.id === "manifest-surfaces-plugin");
|
|
expect(fs.existsSync(importedMarker)).toBe(false);
|
|
expect(record?.channelIds).toEqual(["manifest-surfaces-channel"]);
|
|
expect(record?.providerIds).toEqual(["manifest-surfaces-provider"]);
|
|
expect(record?.cliBackendIds).toEqual([
|
|
"manifest-surfaces-cli",
|
|
"manifest-surfaces-setup-cli",
|
|
]);
|
|
expect(record?.commands).toEqual(["manifest-surfaces-command"]);
|
|
},
|
|
},
|
|
{
|
|
label: "marks a selected memory slot as matched during manifest-only snapshots",
|
|
run: () => {
|
|
useNoBundledPlugins();
|
|
const memoryPlugin = writePlugin({
|
|
id: "memory-demo",
|
|
filename: "memory-demo.cjs",
|
|
body: `module.exports = {
|
|
id: "memory-demo",
|
|
kind: "memory",
|
|
register() {},
|
|
};`,
|
|
});
|
|
fs.writeFileSync(
|
|
path.join(memoryPlugin.dir, "openclaw.plugin.json"),
|
|
JSON.stringify(
|
|
{
|
|
id: "memory-demo",
|
|
kind: "memory",
|
|
configSchema: EMPTY_PLUGIN_SCHEMA,
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"utf-8",
|
|
);
|
|
|
|
const registry = loadOpenClawPlugins({
|
|
cache: false,
|
|
activate: false,
|
|
loadModules: false,
|
|
config: {
|
|
plugins: {
|
|
load: { paths: [memoryPlugin.file] },
|
|
allow: ["memory-demo"],
|
|
slots: { memory: "memory-demo" },
|
|
entries: {
|
|
"memory-demo": { enabled: true },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
expectNoDiagnosticContaining({
|
|
registry,
|
|
message: "memory slot plugin not found or not marked as memory: memory-demo",
|
|
});
|
|
const record = registry.plugins.find((entry) => entry.id === "memory-demo");
|
|
expect(record?.memorySlotSelected).toBe(true);
|
|
},
|
|
},
|
|
{
|
|
label: "tracks plugins as imported when module evaluation throws after top-level execution",
|
|
run: () => {
|
|
useNoBundledPlugins();
|
|
const importMarker = "__openclaw_loader_import_throw_marker";
|
|
Reflect.deleteProperty(globalThis, importMarker);
|
|
|
|
const plugin = writePlugin({
|
|
id: "throws-after-import",
|
|
filename: "throws-after-import.cjs",
|
|
body: `globalThis.${importMarker} = (globalThis.${importMarker} ?? 0) + 1;
|
|
throw new Error("boom after import");
|
|
module.exports = { id: "throws-after-import", register() {} };`,
|
|
});
|
|
|
|
const registry = loadOpenClawPlugins({
|
|
cache: false,
|
|
activate: false,
|
|
config: {
|
|
plugins: {
|
|
load: { paths: [plugin.file] },
|
|
allow: ["throws-after-import"],
|
|
},
|
|
},
|
|
});
|
|
|
|
try {
|
|
const record = registry.plugins.find((entry) => entry.id === "throws-after-import");
|
|
expect(record?.status).toBe("error");
|
|
expect(listImportedRuntimePluginIds()).toContain("throws-after-import");
|
|
expect(Number(Reflect.get(globalThis, importMarker) ?? 0)).toBeGreaterThan(0);
|
|
} finally {
|
|
Reflect.deleteProperty(globalThis, importMarker);
|
|
}
|
|
},
|
|
},
|
|
{
|
|
label: "fails loudly when a plugin reenters the same snapshot load during register",
|
|
run: () => {
|
|
useNoBundledPlugins();
|
|
const marker = "__openclaw_loader_reentry_error";
|
|
const reenterFnMarker = "__openclaw_loader_reentry_fn";
|
|
Reflect.deleteProperty(globalThis, marker);
|
|
Reflect.set(
|
|
globalThis,
|
|
reenterFnMarker,
|
|
(options: Parameters<typeof loadOpenClawPlugins>[0]) => loadOpenClawPlugins(options),
|
|
);
|
|
const pluginDir = makeTempDir();
|
|
const pluginFile = path.join(pluginDir, "reentrant-snapshot.cjs");
|
|
const nestedOptions = {
|
|
cache: false,
|
|
activate: false,
|
|
workspaceDir: pluginDir,
|
|
config: {
|
|
plugins: {
|
|
load: { paths: [pluginFile] },
|
|
allow: ["reentrant-snapshot"],
|
|
},
|
|
},
|
|
} satisfies Parameters<typeof loadOpenClawPlugins>[0];
|
|
writePlugin({
|
|
id: "reentrant-snapshot",
|
|
dir: pluginDir,
|
|
filename: "reentrant-snapshot.cjs",
|
|
body: `module.exports = {
|
|
id: "reentrant-snapshot",
|
|
register() {
|
|
try {
|
|
globalThis.${reenterFnMarker}(${JSON.stringify(nestedOptions)});
|
|
} catch (error) {
|
|
globalThis.${marker} = {
|
|
name: error?.name,
|
|
message: String(error?.message ?? error),
|
|
};
|
|
throw error;
|
|
}
|
|
},
|
|
};`,
|
|
});
|
|
|
|
const registry = loadOpenClawPlugins(nestedOptions);
|
|
|
|
try {
|
|
const reentryError = Reflect.get(globalThis, marker) as
|
|
| { name?: unknown; message?: unknown }
|
|
| undefined;
|
|
expect(reentryError?.name).toBe("PluginLoadReentryError");
|
|
expect(String(reentryError?.message)).toContain("plugin load reentry detected");
|
|
const record = registry.plugins.find((entry) => entry.id === "reentrant-snapshot");
|
|
expect(record?.status).toBe("error");
|
|
expect(record?.error).toContain("plugin load reentry detected");
|
|
expect(record?.failurePhase).toBe("register");
|
|
} finally {
|
|
Reflect.deleteProperty(globalThis, marker);
|
|
Reflect.deleteProperty(globalThis, reenterFnMarker);
|
|
}
|
|
},
|
|
},
|
|
{
|
|
label: "lets resolveRuntimePluginRegistry short-circuit during same snapshot load",
|
|
run: () => {
|
|
useNoBundledPlugins();
|
|
const marker = "__openclaw_runtime_registry_reentry_marker";
|
|
const resolverMarker = "__openclaw_runtime_registry_reentry_fn";
|
|
Reflect.deleteProperty(globalThis, marker);
|
|
Reflect.set(
|
|
globalThis,
|
|
resolverMarker,
|
|
(options: Parameters<typeof resolveRuntimePluginRegistry>[0]) =>
|
|
resolveRuntimePluginRegistry(options),
|
|
);
|
|
const pluginDir = makeTempDir();
|
|
const pluginFile = path.join(pluginDir, "runtime-registry-reentry.cjs");
|
|
const nestedOptions = {
|
|
cache: false,
|
|
activate: false,
|
|
workspaceDir: pluginDir,
|
|
config: {
|
|
plugins: {
|
|
load: { paths: [pluginFile] },
|
|
allow: ["runtime-registry-reentry"],
|
|
},
|
|
},
|
|
} satisfies Parameters<typeof loadOpenClawPlugins>[0];
|
|
writePlugin({
|
|
id: "runtime-registry-reentry",
|
|
dir: pluginDir,
|
|
filename: "runtime-registry-reentry.cjs",
|
|
body: `module.exports = {
|
|
id: "runtime-registry-reentry",
|
|
register() {
|
|
const registry = globalThis.${resolverMarker}(${JSON.stringify(nestedOptions)});
|
|
globalThis.${marker} = registry === undefined ? "undefined" : "loaded";
|
|
},
|
|
};`,
|
|
});
|
|
|
|
const registry = loadOpenClawPlugins(nestedOptions);
|
|
|
|
try {
|
|
expect(Reflect.get(globalThis, marker)).toBe("undefined");
|
|
const record = registry.plugins.find((entry) => entry.id === "runtime-registry-reentry");
|
|
expect(record?.status).toBe("loaded");
|
|
} finally {
|
|
Reflect.deleteProperty(globalThis, marker);
|
|
Reflect.deleteProperty(globalThis, resolverMarker);
|
|
}
|
|
},
|
|
},
|
|
{
|
|
label: "keeps scoped plugin loads in a separate cache entry",
|
|
run: () => {
|
|
useNoBundledPlugins();
|
|
const allowed = writePlugin({
|
|
id: "allowed-cache-scope",
|
|
filename: "allowed-cache-scope.cjs",
|
|
body: `module.exports = { id: "allowed-cache-scope", register() {} };`,
|
|
});
|
|
const extra = writePlugin({
|
|
id: "extra-cache-scope",
|
|
filename: "extra-cache-scope.cjs",
|
|
body: `module.exports = { id: "extra-cache-scope", register() {} };`,
|
|
});
|
|
const options = {
|
|
config: {
|
|
plugins: {
|
|
load: { paths: [allowed.file, extra.file] },
|
|
allow: ["allowed-cache-scope", "extra-cache-scope"],
|
|
},
|
|
},
|
|
};
|
|
|
|
const full = loadOpenClawPlugins(options);
|
|
const scoped = loadOpenClawPlugins({
|
|
...options,
|
|
onlyPluginIds: ["allowed-cache-scope"],
|
|
});
|
|
const scopedAgain = loadOpenClawPlugins({
|
|
...options,
|
|
onlyPluginIds: ["allowed-cache-scope"],
|
|
});
|
|
|
|
expect(full.plugins.map((entry) => entry.id).toSorted()).toEqual([
|
|
"allowed-cache-scope",
|
|
"extra-cache-scope",
|
|
]);
|
|
expect(scoped).not.toBe(full);
|
|
expect(scoped.plugins.map((entry) => entry.id)).toEqual(["allowed-cache-scope"]);
|
|
expect(scopedAgain).toBe(scoped);
|
|
},
|
|
},
|
|
{
|
|
label: "can load a scoped registry without replacing the active global registry",
|
|
run: () => {
|
|
useNoBundledPlugins();
|
|
const plugin = writePlugin({
|
|
id: "allowed-nonactivating-scope",
|
|
filename: "allowed-nonactivating-scope.cjs",
|
|
body: `module.exports = { id: "allowed-nonactivating-scope", register() {} };`,
|
|
});
|
|
const previousRegistry = createEmptyPluginRegistry();
|
|
setActivePluginRegistry(previousRegistry, "existing-registry");
|
|
resetGlobalHookRunner();
|
|
|
|
const scoped = loadOpenClawPlugins({
|
|
cache: false,
|
|
activate: false,
|
|
workspaceDir: plugin.dir,
|
|
config: {
|
|
plugins: {
|
|
load: { paths: [plugin.file] },
|
|
allow: ["allowed-nonactivating-scope"],
|
|
},
|
|
},
|
|
onlyPluginIds: ["allowed-nonactivating-scope"],
|
|
});
|
|
|
|
expect(scoped.plugins.map((entry) => entry.id)).toEqual(["allowed-nonactivating-scope"]);
|
|
expect(getActivePluginRegistry()).toBe(previousRegistry);
|
|
expect(getActivePluginRegistryKey()).toBe("existing-registry");
|
|
expect(getGlobalHookRunner()).toBeNull();
|
|
},
|
|
},
|
|
] as const)("handles config-path and scoped plugin loads: $label", async ({ run }) => {
|
|
await run();
|
|
});
|
|
|
|
it("treats an explicit empty plugin scope as scoped-empty instead of unscoped", () => {
|
|
useNoBundledPlugins();
|
|
const allowed = writePlugin({
|
|
id: "allowed-empty-scope",
|
|
filename: "allowed-empty-scope.cjs",
|
|
body: `module.exports = { id: "allowed-empty-scope", register() {} };`,
|
|
});
|
|
const extra = writePlugin({
|
|
id: "extra-empty-scope",
|
|
filename: "extra-empty-scope.cjs",
|
|
body: `module.exports = { id: "extra-empty-scope", register() {} };`,
|
|
});
|
|
|
|
const registry = loadOpenClawPlugins({
|
|
cache: false,
|
|
activate: false,
|
|
config: {
|
|
plugins: {
|
|
load: { paths: [allowed.file, extra.file] },
|
|
allow: ["allowed-empty-scope", "extra-empty-scope"],
|
|
},
|
|
},
|
|
onlyPluginIds: [],
|
|
});
|
|
|
|
expect(registry.plugins).toStrictEqual([]);
|
|
});
|
|
|
|
it("skips discovery and manifest registry loading entirely when onlyPluginIds is an explicit empty array", async () => {
|
|
useNoBundledPlugins();
|
|
const allowed = writePlugin({
|
|
id: "allowed-empty-scope",
|
|
filename: "allowed-empty-scope.cjs",
|
|
body: `module.exports = { id: "allowed-empty-scope", register() {} };`,
|
|
});
|
|
|
|
const discovery = await import("./discovery.js");
|
|
const manifestRegistry = await import("./manifest-registry.js");
|
|
const discoverySpy = vi.spyOn(discovery, "discoverOpenClawPlugins");
|
|
const manifestSpy = vi.spyOn(manifestRegistry, "loadPluginManifestRegistry");
|
|
|
|
const registry = loadOpenClawPlugins({
|
|
cache: false,
|
|
activate: false,
|
|
config: {
|
|
plugins: {
|
|
load: { paths: [allowed.file] },
|
|
allow: ["allowed-empty-scope"],
|
|
},
|
|
},
|
|
onlyPluginIds: [],
|
|
});
|
|
|
|
expect(registry.plugins).toStrictEqual([]);
|
|
expect(discoverySpy).not.toHaveBeenCalled();
|
|
expect(manifestSpy).not.toHaveBeenCalled();
|
|
|
|
discoverySpy.mockRestore();
|
|
manifestSpy.mockRestore();
|
|
});
|
|
|
|
it("only publishes command and interactive globals during activating loads", () => {
|
|
useNoBundledPlugins();
|
|
const plugin = writePlugin({
|
|
id: "command-plugin",
|
|
filename: "command-plugin.cjs",
|
|
body: `module.exports = {
|
|
id: "command-plugin",
|
|
register(api) {
|
|
api.registerCommand({
|
|
name: "pair",
|
|
description: "Pair device",
|
|
acceptsArgs: true,
|
|
handler: async ({ args }) => ({ text: \`paired:\${args ?? ""}\` }),
|
|
});
|
|
api.registerInteractiveHandler({
|
|
channel: "telegram",
|
|
namespace: "pair",
|
|
handle: async () => ({ handled: true }),
|
|
});
|
|
},
|
|
};`,
|
|
});
|
|
clearPluginCommands();
|
|
clearPluginInteractiveHandlers();
|
|
|
|
const scoped = loadOpenClawPlugins({
|
|
cache: false,
|
|
activate: false,
|
|
workspaceDir: plugin.dir,
|
|
config: {
|
|
plugins: {
|
|
load: { paths: [plugin.file] },
|
|
allow: ["command-plugin"],
|
|
},
|
|
},
|
|
onlyPluginIds: ["command-plugin"],
|
|
});
|
|
|
|
expect(scoped.plugins.find((entry) => entry.id === "command-plugin")?.status).toBe("loaded");
|
|
expect(scoped.commands.map((entry) => entry.command.name)).toEqual(["pair"]);
|
|
expect(scoped.interactiveHandlers).toEqual([
|
|
expect.objectContaining({
|
|
channel: "telegram",
|
|
namespace: "pair",
|
|
pluginId: "command-plugin",
|
|
}),
|
|
]);
|
|
expect(getPluginCommandSpecs("telegram")).toStrictEqual([]);
|
|
expect(resolvePluginInteractiveNamespaceMatch("telegram", "pair:device")).toBeNull();
|
|
|
|
const active = loadOpenClawPlugins({
|
|
cache: false,
|
|
workspaceDir: plugin.dir,
|
|
config: {
|
|
plugins: {
|
|
load: { paths: [plugin.file] },
|
|
allow: ["command-plugin"],
|
|
},
|
|
},
|
|
onlyPluginIds: ["command-plugin"],
|
|
});
|
|
|
|
expect(active.plugins.find((entry) => entry.id === "command-plugin")?.status).toBe("loaded");
|
|
expect(getPluginCommandSpecs()).toEqual([
|
|
{
|
|
name: "pair",
|
|
description: "Pair device",
|
|
acceptsArgs: true,
|
|
},
|
|
]);
|
|
expect(resolvePluginInteractiveNamespaceMatch("telegram", "pair:device")).toMatchObject({
|
|
namespace: "pair",
|
|
payload: "device",
|
|
registration: {
|
|
pluginId: "command-plugin",
|
|
},
|
|
});
|
|
|
|
clearPluginCommands();
|
|
clearPluginInteractiveHandlers();
|
|
});
|
|
|
|
it("clears plugin agent harnesses during activating reloads", () => {
|
|
useNoBundledPlugins();
|
|
const plugin = writePlugin({
|
|
id: "codex-harness",
|
|
filename: "codex-harness.cjs",
|
|
body: `module.exports = {
|
|
id: "codex-harness",
|
|
register(api) {
|
|
api.registerAgentHarness({
|
|
id: "codex",
|
|
label: "Codex",
|
|
supports: () => ({ supported: true }),
|
|
runAttempt: async () => ({ ok: false, error: "unused" }),
|
|
});
|
|
},
|
|
};`,
|
|
});
|
|
|
|
loadOpenClawPlugins({
|
|
cache: false,
|
|
workspaceDir: plugin.dir,
|
|
config: {
|
|
plugins: {
|
|
load: { paths: [plugin.file] },
|
|
allow: ["codex-harness"],
|
|
},
|
|
},
|
|
onlyPluginIds: ["codex-harness"],
|
|
});
|
|
expect(listRegisteredAgentHarnessIdsForTest()).toEqual(["codex"]);
|
|
|
|
loadOpenClawPlugins({
|
|
cache: false,
|
|
workspaceDir: makeTempDir(),
|
|
config: {
|
|
plugins: {
|
|
allow: [],
|
|
},
|
|
},
|
|
});
|
|
expect(listRegisteredAgentHarnessIdsForTest()).toStrictEqual([]);
|
|
});
|
|
|
|
it("rejects malformed plugin agent harness registrations", () => {
|
|
useNoBundledPlugins();
|
|
const plugin = writePlugin({
|
|
id: "bad-harness",
|
|
filename: "bad-harness.cjs",
|
|
body: `module.exports = {
|
|
id: "bad-harness",
|
|
register(api) {
|
|
api.registerAgentHarness({
|
|
id: "broken",
|
|
label: "Broken",
|
|
});
|
|
},
|
|
};`,
|
|
});
|
|
|
|
const registry = loadOpenClawPlugins({
|
|
cache: false,
|
|
workspaceDir: plugin.dir,
|
|
config: {
|
|
plugins: {
|
|
load: { paths: [plugin.file] },
|
|
allow: ["bad-harness"],
|
|
},
|
|
},
|
|
onlyPluginIds: ["bad-harness"],
|
|
});
|
|
|
|
expect(listRegisteredAgentHarnessIdsForTest()).toStrictEqual([]);
|
|
const diagnostic = registry.diagnostics.find(
|
|
(entry) =>
|
|
entry.level === "error" &&
|
|
entry.pluginId === "bad-harness" &&
|
|
entry.message === 'agent harness "broken" registration missing required runtime methods',
|
|
);
|
|
if (!diagnostic) {
|
|
throw new Error("Expected bad-harness runtime methods diagnostic");
|
|
}
|
|
});
|
|
|
|
it("does not register internal hooks globally during non-activating loads", () => {
|
|
useNoBundledPlugins();
|
|
const plugin = writePlugin({
|
|
id: "internal-hook-snapshot",
|
|
filename: "internal-hook-snapshot.cjs",
|
|
body: `module.exports = {
|
|
id: "internal-hook-snapshot",
|
|
register(api) {
|
|
api.registerHook("gateway:startup", () => {}, { name: "snapshot-hook" });
|
|
},
|
|
};`,
|
|
});
|
|
|
|
clearInternalHooks();
|
|
const scoped = loadOpenClawPlugins({
|
|
cache: false,
|
|
activate: false,
|
|
workspaceDir: plugin.dir,
|
|
config: {
|
|
plugins: {
|
|
load: { paths: [plugin.file] },
|
|
allow: ["internal-hook-snapshot"],
|
|
},
|
|
},
|
|
onlyPluginIds: ["internal-hook-snapshot"],
|
|
});
|
|
|
|
expect(scoped.plugins.find((entry) => entry.id === "internal-hook-snapshot")?.status).toBe(
|
|
"loaded",
|
|
);
|
|
expect(scoped.hooks.map((entry) => entry.entry.hook.name)).toEqual(["snapshot-hook"]);
|
|
expect(getRegisteredEventKeys()).toStrictEqual([]);
|
|
|
|
clearInternalHooks();
|
|
});
|
|
|
|
it("replaces prior plugin hook registrations on activating reloads", async () => {
|
|
useNoBundledPlugins();
|
|
const plugin = writePlugin({
|
|
id: "internal-hook-reload",
|
|
filename: "internal-hook-reload.cjs",
|
|
body: `module.exports = {
|
|
id: "internal-hook-reload",
|
|
register(api) {
|
|
api.registerHook(
|
|
"gateway:startup",
|
|
(event) => {
|
|
event.messages.push("reload-hook-fired");
|
|
},
|
|
{ name: "reload-hook" },
|
|
);
|
|
},
|
|
};`,
|
|
});
|
|
|
|
clearInternalHooks();
|
|
|
|
const loadOptions = {
|
|
cache: false,
|
|
workspaceDir: plugin.dir,
|
|
config: {
|
|
plugins: {
|
|
load: { paths: [plugin.file] },
|
|
allow: ["internal-hook-reload"],
|
|
},
|
|
},
|
|
onlyPluginIds: ["internal-hook-reload"],
|
|
};
|
|
|
|
loadOpenClawPlugins(loadOptions);
|
|
loadOpenClawPlugins(loadOptions);
|
|
|
|
const event = createInternalHookEvent("gateway", "startup", "gateway:startup");
|
|
await triggerInternalHook(event);
|
|
expect(countMatching(event.messages, (message) => message === "reload-hook-fired")).toBe(1);
|
|
|
|
clearInternalHooks();
|
|
});
|
|
|
|
it("injects plugin config into internal hook event context", async () => {
|
|
useNoBundledPlugins();
|
|
const plugin = writePlugin({
|
|
id: "hook-config-context",
|
|
filename: "hook-config-context.cjs",
|
|
body: `module.exports = {
|
|
id: "hook-config-context",
|
|
register(api) {
|
|
api.registerHook(
|
|
"gateway:startup",
|
|
(event) => {
|
|
event.messages.push(event.context.pluginConfig?.marker);
|
|
},
|
|
{ name: "hook-config-context" },
|
|
);
|
|
},
|
|
};`,
|
|
});
|
|
fs.writeFileSync(
|
|
path.join(plugin.dir, "openclaw.plugin.json"),
|
|
JSON.stringify(
|
|
{
|
|
id: "hook-config-context",
|
|
configSchema: { type: "object" },
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
"utf-8",
|
|
);
|
|
|
|
clearInternalHooks();
|
|
|
|
loadOpenClawPlugins({
|
|
cache: false,
|
|
workspaceDir: plugin.dir,
|
|
config: {
|
|
plugins: {
|
|
load: { paths: [plugin.file] },
|
|
allow: ["hook-config-context"],
|
|
entries: {
|
|
"hook-config-context": {
|
|
config: {
|
|
marker: "plugin-config-visible",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
onlyPluginIds: ["hook-config-context"],
|
|
});
|
|
|
|
const event = createInternalHookEvent("gateway", "startup", "gateway:startup");
|
|
await triggerInternalHook(event);
|
|
expect(event.messages).toEqual(["plugin-config-visible"]);
|
|
expect(event.context).toStrictEqual({});
|
|
|
|
clearInternalHooks();
|
|
});
|
|
|
|
it("preserves plugin hook context mutations for bootstrap hooks", async () => {
|
|
useNoBundledPlugins();
|
|
const plugin = writePlugin({
|
|
id: "hook-bootstrap-mutation",
|
|
filename: "hook-bootstrap-mutation.cjs",
|
|
body: `module.exports = {
|
|
id: "hook-bootstrap-mutation",
|
|
register(api) {
|
|
api.registerHook(
|
|
"agent:bootstrap",
|
|
(event) => {
|
|
event.context.bootstrapFiles = [
|
|
{
|
|
name: "AGENTS.md",
|
|
path: "/tmp/override-AGENTS.md",
|
|
content: "override bootstrap rules",
|
|
missing: false,
|
|
},
|
|
];
|
|
},
|
|
{ name: "hook-bootstrap-mutation" },
|
|
);
|
|
},
|
|
};`,
|
|
});
|
|
|
|
clearInternalHooks();
|
|
|
|
loadRegistryFromSinglePlugin({
|
|
plugin,
|
|
pluginConfig: {
|
|
allow: ["hook-bootstrap-mutation"],
|
|
},
|
|
options: {
|
|
onlyPluginIds: ["hook-bootstrap-mutation"],
|
|
},
|
|
});
|
|
|
|
const updated = await applyBootstrapHookOverrides({
|
|
files: [
|
|
{
|
|
name: "AGENTS.md",
|
|
path: "/tmp/base-AGENTS.md",
|
|
content: "base bootstrap rules",
|
|
missing: false,
|
|
} satisfies WorkspaceBootstrapFile,
|
|
],
|
|
workspaceDir: "/tmp",
|
|
sessionKey: "agent:main:subagent:test-bootstrap",
|
|
});
|
|
|
|
expect(updated).toEqual([
|
|
{
|
|
name: "AGENTS.md",
|
|
path: "/tmp/override-AGENTS.md",
|
|
content: "override bootstrap rules",
|
|
missing: false,
|
|
},
|
|
]);
|
|
|
|
clearInternalHooks();
|
|
});
|
|
});
|
|
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|