Files
openclaw/src/plugins/loader.base.test-utils.ts
Peter Steinberger f81f9d8570 chore: enforce max-lines suppression ratchet (#107315)
* 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
2026-07-14 09:27:02 -07:00

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. */