From db76dbc546682b2b46df07eaf0596775f418089f Mon Sep 17 00:00:00 2001 From: Shakker Date: Thu, 2 Apr 2026 12:49:27 +0100 Subject: [PATCH] test: split plugin loader coverage by concern --- .../bundled-capability-metadata.test.ts | 2 + src/plugins/loader.bundle.test.ts | 202 ++++ src/plugins/loader.cli-metadata.test.ts | 417 ++++++++ src/plugins/loader.runtime-registry.test.ts | 189 ++++ src/plugins/loader.test-fixtures.ts | 127 +++ src/plugins/loader.test.ts | 888 +----------------- 6 files changed, 955 insertions(+), 870 deletions(-) create mode 100644 src/plugins/loader.bundle.test.ts create mode 100644 src/plugins/loader.cli-metadata.test.ts create mode 100644 src/plugins/loader.runtime-registry.test.ts create mode 100644 src/plugins/loader.test-fixtures.ts diff --git a/src/plugins/bundled-capability-metadata.test.ts b/src/plugins/bundled-capability-metadata.test.ts index 4ac98f331e6..5bcfd4c5872 100644 --- a/src/plugins/bundled-capability-metadata.test.ts +++ b/src/plugins/bundled-capability-metadata.test.ts @@ -32,6 +32,7 @@ describe("bundled capability metadata", () => { manifest.contracts?.mediaUnderstandingProviders, ), imageGenerationProviderIds: uniqueStrings(manifest.contracts?.imageGenerationProviders), + webFetchProviderIds: uniqueStrings(manifest.contracts?.webFetchProviders), webSearchProviderIds: uniqueStrings(manifest.contracts?.webSearchProviders), toolNames: uniqueStrings(manifest.contracts?.tools), })) @@ -42,6 +43,7 @@ describe("bundled capability metadata", () => { entry.speechProviderIds.length > 0 || entry.mediaUnderstandingProviderIds.length > 0 || entry.imageGenerationProviderIds.length > 0 || + entry.webFetchProviderIds.length > 0 || entry.webSearchProviderIds.length > 0 || entry.toolNames.length > 0, ) diff --git a/src/plugins/loader.bundle.test.ts b/src/plugins/loader.bundle.test.ts new file mode 100644 index 00000000000..4eb8cf8307a --- /dev/null +++ b/src/plugins/loader.bundle.test.ts @@ -0,0 +1,202 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterAll, afterEach, describe, expect, it } from "vitest"; +import { withEnv } from "../test-utils/env.js"; +import { loadOpenClawPlugins } from "./loader.js"; +import { + cleanupPluginLoaderFixturesForTest, + loadBundleFixture, + makeTempDir, + mkdirSafe, + resetPluginLoaderTestStateForTest, + useNoBundledPlugins, +} from "./loader.test-fixtures.js"; + +function expectNoUnwiredBundleDiagnostic( + registry: ReturnType, + pluginId: string, +) { + expect( + registry.diagnostics.some( + (diag) => + diag.pluginId === pluginId && + diag.message.includes("bundle capability detected but not wired"), + ), + ).toBe(false); +} + +afterEach(() => { + resetPluginLoaderTestStateForTest(); +}); + +afterAll(() => { + cleanupPluginLoaderFixturesForTest(); +}); + +describe("bundle plugins", () => { + it("reports Codex bundles as loaded bundle plugins without importing runtime code", () => { + useNoBundledPlugins(); + const workspaceDir = makeTempDir(); + const stateDir = makeTempDir(); + const bundleRoot = path.join(workspaceDir, ".openclaw", "extensions", "sample-bundle"); + mkdirSafe(path.join(bundleRoot, ".codex-plugin")); + mkdirSafe(path.join(bundleRoot, "skills")); + fs.writeFileSync( + path.join(bundleRoot, ".codex-plugin", "plugin.json"), + JSON.stringify({ + name: "Sample Bundle", + description: "Codex bundle fixture", + skills: "skills", + }), + "utf-8", + ); + fs.writeFileSync( + path.join(bundleRoot, "skills", "SKILL.md"), + "---\ndescription: fixture\n---\n", + ); + + const registry = withEnv({ OPENCLAW_STATE_DIR: stateDir }, () => + loadOpenClawPlugins({ + workspaceDir, + onlyPluginIds: ["sample-bundle"], + config: { + plugins: { + entries: { + "sample-bundle": { + enabled: true, + }, + }, + }, + }, + cache: false, + }), + ); + + const plugin = registry.plugins.find((entry) => entry.id === "sample-bundle"); + expect(plugin?.status).toBe("loaded"); + expect(plugin?.format).toBe("bundle"); + expect(plugin?.bundleFormat).toBe("codex"); + expect(plugin?.bundleCapabilities).toContain("skills"); + }); + + it.each([ + { + name: "treats Claude command roots and settings as supported bundle surfaces", + pluginId: "claude-skills", + expectedFormat: "claude", + expectedCapabilities: ["skills", "commands", "settings"], + build: (bundleRoot: string) => { + mkdirSafe(path.join(bundleRoot, "commands")); + fs.writeFileSync( + path.join(bundleRoot, "commands", "review.md"), + "---\ndescription: fixture\n---\n", + ); + fs.writeFileSync( + path.join(bundleRoot, "settings.json"), + '{"hideThinkingBlock":true}', + "utf-8", + ); + }, + }, + { + name: "treats bundle MCP as a supported bundle surface", + pluginId: "claude-mcp", + expectedFormat: "claude", + expectedCapabilities: ["mcpServers"], + build: (bundleRoot: string) => { + mkdirSafe(path.join(bundleRoot, ".claude-plugin")); + fs.writeFileSync( + path.join(bundleRoot, ".claude-plugin", "plugin.json"), + JSON.stringify({ + name: "Claude MCP", + }), + "utf-8", + ); + fs.writeFileSync( + path.join(bundleRoot, ".mcp.json"), + JSON.stringify({ + mcpServers: { + probe: { + command: "node", + args: ["./probe.mjs"], + }, + }, + }), + "utf-8", + ); + }, + }, + { + name: "treats Cursor command roots as supported bundle skill surfaces", + pluginId: "cursor-skills", + expectedFormat: "cursor", + expectedCapabilities: ["skills", "commands"], + build: (bundleRoot: string) => { + mkdirSafe(path.join(bundleRoot, ".cursor-plugin")); + mkdirSafe(path.join(bundleRoot, ".cursor", "commands")); + fs.writeFileSync( + path.join(bundleRoot, ".cursor-plugin", "plugin.json"), + JSON.stringify({ + name: "Cursor Skills", + }), + "utf-8", + ); + fs.writeFileSync( + path.join(bundleRoot, ".cursor", "commands", "review.md"), + "---\ndescription: fixture\n---\n", + ); + }, + }, + ])("$name", ({ pluginId, expectedFormat, expectedCapabilities, build }) => { + const registry = loadBundleFixture({ pluginId, build }); + const plugin = registry.plugins.find((entry) => entry.id === pluginId); + + expect(plugin?.status).toBe("loaded"); + expect(plugin?.bundleFormat).toBe(expectedFormat); + expect(plugin?.bundleCapabilities).toEqual(expect.arrayContaining(expectedCapabilities)); + expectNoUnwiredBundleDiagnostic(registry, pluginId); + }); + + it("warns when bundle MCP only declares unsupported non-stdio transports", () => { + const stateDir = makeTempDir(); + const registry = loadBundleFixture({ + pluginId: "claude-mcp-url", + env: { + OPENCLAW_HOME: stateDir, + }, + build: (bundleRoot) => { + mkdirSafe(path.join(bundleRoot, ".claude-plugin")); + fs.writeFileSync( + path.join(bundleRoot, ".claude-plugin", "plugin.json"), + JSON.stringify({ + name: "Claude MCP URL", + }), + "utf-8", + ); + fs.writeFileSync( + path.join(bundleRoot, ".mcp.json"), + JSON.stringify({ + mcpServers: { + remoteProbe: { + url: "http://127.0.0.1:8787/mcp", + }, + }, + }), + "utf-8", + ); + }, + }); + + const plugin = registry.plugins.find((entry) => entry.id === "claude-mcp-url"); + expect(plugin?.status).toBe("loaded"); + expect(plugin?.bundleCapabilities).toEqual(expect.arrayContaining(["mcpServers"])); + expect( + registry.diagnostics.some( + (diag) => + diag.pluginId === "claude-mcp-url" && + diag.message.includes("stdio only today") && + diag.message.includes("remoteProbe"), + ), + ).toBe(true); + }); +}); diff --git a/src/plugins/loader.cli-metadata.test.ts b/src/plugins/loader.cli-metadata.test.ts new file mode 100644 index 00000000000..30df6fdd9a8 --- /dev/null +++ b/src/plugins/loader.cli-metadata.test.ts @@ -0,0 +1,417 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterAll, afterEach, describe, expect, it } from "vitest"; +import { loadOpenClawPluginCliRegistry, loadOpenClawPlugins } from "./loader.js"; +import { + cleanupPluginLoaderFixturesForTest, + EMPTY_PLUGIN_SCHEMA, + makeTempDir, + resetPluginLoaderTestStateForTest, + useNoBundledPlugins, + writePlugin, +} from "./loader.test-fixtures.js"; + +afterEach(() => { + resetPluginLoaderTestStateForTest(); +}); + +afterAll(() => { + cleanupPluginLoaderFixturesForTest(); +}); + +describe("plugin loader CLI metadata", () => { + it("passes validated plugin config into non-activating CLI metadata loads", async () => { + useNoBundledPlugins(); + const plugin = writePlugin({ + id: "config-cli", + filename: "config-cli.cjs", + body: `module.exports = { + id: "config-cli", + register(api) { + if (!api.pluginConfig || api.pluginConfig.token !== "ok") { + throw new Error("missing plugin config"); + } + api.registerCli(() => {}, { + descriptors: [ + { + name: "cfg", + description: "Config-backed CLI command", + hasSubcommands: true, + }, + ], + }); + }, +};`, + }); + fs.writeFileSync( + path.join(plugin.dir, "openclaw.plugin.json"), + JSON.stringify( + { + id: "config-cli", + configSchema: { + type: "object", + additionalProperties: false, + properties: { + token: { type: "string" }, + }, + required: ["token"], + }, + }, + null, + 2, + ), + "utf-8", + ); + + const registry = await loadOpenClawPluginCliRegistry({ + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: ["config-cli"], + entries: { + "config-cli": { + config: { + token: "ok", + }, + }, + }, + }, + }, + }); + + expect(registry.cliRegistrars.flatMap((entry) => entry.commands)).toContain("cfg"); + expect(registry.plugins.find((entry) => entry.id === "config-cli")?.status).toBe("loaded"); + }); + + it("uses the real channel entry in cli-metadata mode for CLI metadata capture", async () => { + useNoBundledPlugins(); + const pluginDir = makeTempDir(); + const fullMarker = path.join(pluginDir, "full-loaded.txt"); + const modeMarker = path.join(pluginDir, "registration-mode.txt"); + const runtimeMarker = path.join(pluginDir, "runtime-set.txt"); + + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify( + { + name: "@openclaw/cli-metadata-channel", + openclaw: { extensions: ["./index.cjs"], setupEntry: "./setup-entry.cjs" }, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify( + { + id: "cli-metadata-channel", + configSchema: EMPTY_PLUGIN_SCHEMA, + channels: ["cli-metadata-channel"], + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "index.cjs"), + `const { defineChannelPluginEntry } = require("openclaw/plugin-sdk/core"); +require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8"); +module.exports = { + ...defineChannelPluginEntry({ + id: "cli-metadata-channel", + name: "CLI Metadata Channel", + description: "cli metadata channel", + setRuntime() { + require("node:fs").writeFileSync(${JSON.stringify(runtimeMarker)}, "loaded", "utf-8"); + }, + plugin: { + id: "cli-metadata-channel", + meta: { + id: "cli-metadata-channel", + label: "CLI Metadata Channel", + selectionLabel: "CLI Metadata Channel", + docsPath: "/channels/cli-metadata-channel", + blurb: "cli metadata channel", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({ accountId: "default" }), + }, + outbound: { deliveryMode: "direct" }, + }, + registerCliMetadata(api) { + require("node:fs").writeFileSync( + ${JSON.stringify(modeMarker)}, + String(api.registrationMode), + "utf-8", + ); + api.registerCli(() => {}, { + descriptors: [ + { + name: "cli-metadata-channel", + description: "Channel CLI metadata", + hasSubcommands: true, + }, + ], + }); + }, + registerFull() { + throw new Error("full channel entry should not run during CLI metadata capture"); + }, + }), +};`, + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "setup-entry.cjs"), + `throw new Error("setup entry should not load during CLI metadata capture");`, + "utf-8", + ); + + const registry = await loadOpenClawPluginCliRegistry({ + config: { + plugins: { + load: { paths: [pluginDir] }, + allow: ["cli-metadata-channel"], + }, + }, + }); + + expect(fs.existsSync(fullMarker)).toBe(true); + expect(fs.existsSync(runtimeMarker)).toBe(false); + expect(fs.readFileSync(modeMarker, "utf-8")).toBe("cli-metadata"); + expect(registry.cliRegistrars.flatMap((entry) => entry.commands)).toContain( + "cli-metadata-channel", + ); + }); + + it("collects channel CLI metadata during full plugin loads", () => { + useNoBundledPlugins(); + const pluginDir = makeTempDir(); + const modeMarker = path.join(pluginDir, "registration-mode.txt"); + const fullMarker = path.join(pluginDir, "full-loaded.txt"); + + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify( + { + name: "@openclaw/full-cli-metadata-channel", + openclaw: { extensions: ["./index.cjs"] }, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify( + { + id: "full-cli-metadata-channel", + configSchema: EMPTY_PLUGIN_SCHEMA, + channels: ["full-cli-metadata-channel"], + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "index.cjs"), + `const { defineChannelPluginEntry } = require("openclaw/plugin-sdk/core"); +module.exports = { + ...defineChannelPluginEntry({ + id: "full-cli-metadata-channel", + name: "Full CLI Metadata Channel", + description: "full cli metadata channel", + plugin: { + id: "full-cli-metadata-channel", + meta: { + id: "full-cli-metadata-channel", + label: "Full CLI Metadata Channel", + selectionLabel: "Full CLI Metadata Channel", + docsPath: "/channels/full-cli-metadata-channel", + blurb: "full cli metadata channel", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({ accountId: "default" }), + }, + outbound: { deliveryMode: "direct" }, + }, + registerCliMetadata(api) { + require("node:fs").writeFileSync( + ${JSON.stringify(modeMarker)}, + String(api.registrationMode), + "utf-8", + ); + api.registerCli(() => {}, { + descriptors: [ + { + name: "full-cli-metadata-channel", + description: "Full-load channel CLI metadata", + hasSubcommands: true, + }, + ], + }); + }, + registerFull() { + require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8"); + }, + }), +};`, + "utf-8", + ); + + const registry = loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + load: { paths: [pluginDir] }, + allow: ["full-cli-metadata-channel"], + }, + }, + }); + + expect(fs.readFileSync(modeMarker, "utf-8")).toBe("full"); + expect(fs.existsSync(fullMarker)).toBe(true); + expect(registry.cliRegistrars.flatMap((entry) => entry.commands)).toContain( + "full-cli-metadata-channel", + ); + }); + + it("awaits async plugin registration when collecting CLI metadata", async () => { + useNoBundledPlugins(); + const plugin = writePlugin({ + id: "async-cli", + filename: "async-cli.cjs", + body: `module.exports = { + id: "async-cli", + async register(api) { + await Promise.resolve(); + api.registerCli(() => {}, { + descriptors: [ + { + name: "async-cli", + description: "Async CLI metadata", + hasSubcommands: true, + }, + ], + }); + }, +};`, + }); + + const registry = await loadOpenClawPluginCliRegistry({ + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: ["async-cli"], + }, + }, + }); + + expect(registry.cliRegistrars.flatMap((entry) => entry.commands)).toContain("async-cli"); + expect( + registry.diagnostics.some((entry) => entry.message.includes("async registration is ignored")), + ).toBe(false); + }); + + it("applies memory slot gating to non-bundled CLI metadata loads", async () => { + useNoBundledPlugins(); + const plugin = writePlugin({ + id: "memory-external", + filename: "memory-external.cjs", + body: `module.exports = { + id: "memory-external", + kind: "memory", + register(api) { + api.registerCli(() => {}, { + descriptors: [ + { + name: "memory-external", + description: "External memory CLI metadata", + hasSubcommands: true, + }, + ], + }); + }, +};`, + }); + fs.writeFileSync( + path.join(plugin.dir, "openclaw.plugin.json"), + JSON.stringify( + { + id: "memory-external", + kind: "memory", + configSchema: EMPTY_PLUGIN_SCHEMA, + }, + null, + 2, + ), + "utf-8", + ); + + const registry = await loadOpenClawPluginCliRegistry({ + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: ["memory-external"], + slots: { memory: "memory-other" }, + }, + }, + }); + + expect(registry.cliRegistrars.flatMap((entry) => entry.commands)).not.toContain( + "memory-external", + ); + const memory = registry.plugins.find((entry) => entry.id === "memory-external"); + expect(memory?.status).toBe("disabled"); + expect(String(memory?.error ?? "")).toContain('memory slot set to "memory-other"'); + }); + + it("re-evaluates memory slot gating after resolving exported plugin kind", async () => { + useNoBundledPlugins(); + const plugin = writePlugin({ + id: "memory-export-only", + filename: "memory-export-only.cjs", + body: `module.exports = { + id: "memory-export-only", + kind: "memory", + register(api) { + api.registerCli(() => {}, { + descriptors: [ + { + name: "memory-export-only", + description: "Export-only memory CLI metadata", + hasSubcommands: true, + }, + ], + }); + }, +};`, + }); + + const registry = await loadOpenClawPluginCliRegistry({ + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: ["memory-export-only"], + slots: { memory: "memory-other" }, + }, + }, + }); + + expect(registry.cliRegistrars.flatMap((entry) => entry.commands)).not.toContain( + "memory-export-only", + ); + const memory = registry.plugins.find((entry) => entry.id === "memory-export-only"); + expect(memory?.status).toBe("disabled"); + expect(String(memory?.error ?? "")).toContain('memory slot set to "memory-other"'); + }); +}); diff --git a/src/plugins/loader.runtime-registry.test.ts b/src/plugins/loader.runtime-registry.test.ts new file mode 100644 index 00000000000..8b5e665cadd --- /dev/null +++ b/src/plugins/loader.runtime-registry.test.ts @@ -0,0 +1,189 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { __testing, clearPluginLoaderCache, resolveRuntimePluginRegistry } from "./loader.js"; +import { resetPluginLoaderTestStateForTest } from "./loader.test-fixtures.js"; +import { + getMemoryEmbeddingProvider, + registerMemoryEmbeddingProvider, +} from "./memory-embedding-providers.js"; +import { + buildMemoryPromptSection, + getMemoryRuntime, + registerMemoryFlushPlanResolver, + registerMemoryPromptSection, + registerMemoryRuntime, + resolveMemoryFlushPlan, +} from "./memory-state.js"; +import { createEmptyPluginRegistry } from "./registry.js"; +import { setActivePluginRegistry } from "./runtime.js"; + +afterEach(() => { + resetPluginLoaderTestStateForTest(); +}); + +describe("getCompatibleActivePluginRegistry", () => { + it("reuses the active registry only when the load context cache key matches", () => { + const registry = createEmptyPluginRegistry(); + const loadOptions = { + config: { + plugins: { + allow: ["demo"], + load: { paths: ["/tmp/demo.js"] }, + }, + }, + workspaceDir: "/tmp/workspace-a", + runtimeOptions: { + allowGatewaySubagentBinding: true, + }, + }; + const { cacheKey } = __testing.resolvePluginLoadCacheContext(loadOptions); + setActivePluginRegistry(registry, cacheKey); + + expect(__testing.getCompatibleActivePluginRegistry(loadOptions)).toBe(registry); + expect( + __testing.getCompatibleActivePluginRegistry({ + ...loadOptions, + workspaceDir: "/tmp/workspace-b", + }), + ).toBeUndefined(); + expect( + __testing.getCompatibleActivePluginRegistry({ + ...loadOptions, + onlyPluginIds: ["demo"], + }), + ).toBeUndefined(); + expect( + __testing.getCompatibleActivePluginRegistry({ + ...loadOptions, + runtimeOptions: undefined, + }), + ).toBeUndefined(); + }); + + it("does not embed activation secrets in the loader cache key", () => { + const { cacheKey } = __testing.resolvePluginLoadCacheContext({ + config: { + plugins: { + allow: ["telegram"], + }, + }, + activationSourceConfig: { + plugins: { + allow: ["telegram"], + }, + channels: { + telegram: { + enabled: true, + botToken: "secret-token", + }, + }, + }, + autoEnabledReasons: { + telegram: ["telegram configured"], + }, + }); + + expect(cacheKey).not.toContain("secret-token"); + expect(cacheKey).not.toContain("botToken"); + expect(cacheKey).not.toContain("telegram configured"); + }); + + it("falls back to the current active runtime when no compatibility-shaping inputs are supplied", () => { + const registry = createEmptyPluginRegistry(); + setActivePluginRegistry(registry, "startup-registry"); + + expect(__testing.getCompatibleActivePluginRegistry()).toBe(registry); + }); + + it("does not reuse the active registry when core gateway method names differ", () => { + const registry = createEmptyPluginRegistry(); + const loadOptions = { + config: { + plugins: { + allow: ["demo"], + load: { paths: ["/tmp/demo.js"] }, + }, + }, + workspaceDir: "/tmp/workspace-a", + coreGatewayHandlers: { + "sessions.get": () => undefined, + }, + }; + const { cacheKey } = __testing.resolvePluginLoadCacheContext(loadOptions); + setActivePluginRegistry(registry, cacheKey); + + expect(__testing.getCompatibleActivePluginRegistry(loadOptions)).toBe(registry); + expect( + __testing.getCompatibleActivePluginRegistry({ + ...loadOptions, + coreGatewayHandlers: { + "sessions.get": () => undefined, + "sessions.list": () => undefined, + }, + }), + ).toBeUndefined(); + }); +}); + +describe("resolveRuntimePluginRegistry", () => { + it("reuses the compatible active registry before attempting a fresh load", () => { + const registry = createEmptyPluginRegistry(); + const loadOptions = { + config: { + plugins: { + allow: ["demo"], + }, + }, + workspaceDir: "/tmp/workspace-a", + }; + const { cacheKey } = __testing.resolvePluginLoadCacheContext(loadOptions); + setActivePluginRegistry(registry, cacheKey); + + expect(resolveRuntimePluginRegistry(loadOptions)).toBe(registry); + }); + + it("falls back to the current active runtime when no explicit load context is provided", () => { + const registry = createEmptyPluginRegistry(); + setActivePluginRegistry(registry, "startup-registry"); + + expect(resolveRuntimePluginRegistry()).toBe(registry); + }); +}); + +describe("clearPluginLoaderCache", () => { + it("resets registered memory plugin registries", () => { + registerMemoryEmbeddingProvider({ + id: "stale", + create: async () => ({ provider: null }), + }); + registerMemoryPromptSection(() => ["stale memory section"]); + registerMemoryFlushPlanResolver(() => ({ + softThresholdTokens: 1, + forceFlushTranscriptBytes: 2, + reserveTokensFloor: 3, + prompt: "stale", + systemPrompt: "stale", + relativePath: "memory/stale.md", + })); + registerMemoryRuntime({ + async getMemorySearchManager() { + return { manager: null }; + }, + resolveMemoryBackendConfig() { + return { backend: "builtin" as const }; + }, + }); + expect(buildMemoryPromptSection({ availableTools: new Set() })).toEqual([ + "stale memory section", + ]); + expect(resolveMemoryFlushPlan({})?.relativePath).toBe("memory/stale.md"); + expect(getMemoryRuntime()).toBeDefined(); + expect(getMemoryEmbeddingProvider("stale")).toBeDefined(); + + clearPluginLoaderCache(); + + expect(buildMemoryPromptSection({ availableTools: new Set() })).toEqual([]); + expect(resolveMemoryFlushPlan({})).toBeNull(); + expect(getMemoryRuntime()).toBeUndefined(); + expect(getMemoryEmbeddingProvider("stale")).toBeUndefined(); + }); +}); diff --git a/src/plugins/loader.test-fixtures.ts b/src/plugins/loader.test-fixtures.ts new file mode 100644 index 00000000000..301985527c4 --- /dev/null +++ b/src/plugins/loader.test-fixtures.ts @@ -0,0 +1,127 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { resetDiagnosticEventsForTest } from "../infra/diagnostic-events.js"; +import { withEnv } from "../test-utils/env.js"; +import { clearPluginDiscoveryCache } from "./discovery.js"; +import { clearPluginLoaderCache, loadOpenClawPlugins } from "./loader.js"; +import { clearPluginManifestRegistryCache } from "./manifest-registry.js"; +import { resetPluginRuntimeStateForTest } from "./runtime.js"; + +export type TempPlugin = { dir: string; file: string; id: string }; +export type PluginLoadConfig = NonNullable[0]>["config"]; +export type PluginRegistry = ReturnType; + +function chmodSafeDir(dir: string) { + if (process.platform === "win32") { + return; + } + fs.chmodSync(dir, 0o755); +} + +function mkdtempSafe(prefix: string) { + const dir = fs.mkdtempSync(prefix); + chmodSafeDir(dir); + return dir; +} + +export function mkdirSafe(dir: string) { + fs.mkdirSync(dir, { recursive: true }); + chmodSafeDir(dir); +} + +const fixtureRoot = mkdtempSafe(path.join(os.tmpdir(), "openclaw-plugin-")); +let tempDirIndex = 0; +const prevBundledDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; + +export const EMPTY_PLUGIN_SCHEMA = { + type: "object", + additionalProperties: false, + properties: {}, +}; + +export function makeTempDir() { + const dir = path.join(fixtureRoot, `case-${tempDirIndex++}`); + mkdirSafe(dir); + return dir; +} + +export function writePlugin(params: { + id: string; + body: string; + dir?: string; + filename?: string; +}): TempPlugin { + const dir = params.dir ?? makeTempDir(); + const filename = params.filename ?? `${params.id}.cjs`; + mkdirSafe(dir); + const file = path.join(dir, filename); + fs.writeFileSync(file, params.body, "utf-8"); + fs.writeFileSync( + path.join(dir, "openclaw.plugin.json"), + JSON.stringify( + { + id: params.id, + configSchema: EMPTY_PLUGIN_SCHEMA, + }, + null, + 2, + ), + "utf-8", + ); + return { dir, file, id: params.id }; +} + +export function useNoBundledPlugins() { + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; +} + +export function loadBundleFixture(params: { + pluginId: string; + build: (bundleRoot: string) => void; + env?: NodeJS.ProcessEnv; + onlyPluginIds?: string[]; +}) { + useNoBundledPlugins(); + const workspaceDir = makeTempDir(); + const stateDir = makeTempDir(); + const bundleRoot = path.join(workspaceDir, ".openclaw", "extensions", params.pluginId); + params.build(bundleRoot); + return withEnv({ OPENCLAW_STATE_DIR: stateDir, ...params.env }, () => + loadOpenClawPlugins({ + workspaceDir, + onlyPluginIds: params.onlyPluginIds ?? [params.pluginId], + config: { + plugins: { + entries: { + [params.pluginId]: { + enabled: true, + }, + }, + }, + }, + cache: false, + }), + ); +} + +export function resetPluginLoaderTestStateForTest() { + clearPluginLoaderCache(); + clearPluginDiscoveryCache(); + clearPluginManifestRegistryCache(); + resetPluginRuntimeStateForTest(); + resetDiagnosticEventsForTest(); + if (prevBundledDir === undefined) { + delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; + } else { + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = prevBundledDir; + } +} + +export function cleanupPluginLoaderFixturesForTest() { + try { + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + } catch { + // ignore cleanup failures in tests + } +} diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 67d59571d76..1ce95411da7 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -1,25 +1,27 @@ import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; import { afterAll, afterEach, describe, expect, it } from "vitest"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import { clearInternalHooks, getRegisteredEventKeys } from "../hooks/internal-hooks.js"; -import { emitDiagnosticEvent, resetDiagnosticEventsForTest } from "../infra/diagnostic-events.js"; +import { emitDiagnosticEvent } from "../infra/diagnostic-events.js"; import { withEnv } from "../test-utils/env.js"; import { clearPluginCommands, getPluginCommandSpecs } from "./command-registry-state.js"; -import { clearPluginDiscoveryCache } from "./discovery.js"; import { getGlobalHookRunner, resetGlobalHookRunner } from "./hook-runner-global.js"; import { createHookRunner } from "./hooks.js"; +import { __testing, clearPluginLoaderCache, loadOpenClawPlugins } from "./loader.js"; import { - __testing, - clearPluginLoaderCache, - loadOpenClawPluginCliRegistry, - loadOpenClawPlugins, - resolveRuntimePluginRegistry, -} from "./loader.js"; -import { clearPluginManifestRegistryCache } from "./manifest-registry.js"; + cleanupPluginLoaderFixturesForTest, + EMPTY_PLUGIN_SCHEMA, + makeTempDir, + mkdirSafe, + type PluginLoadConfig, + type PluginRegistry, + resetPluginLoaderTestStateForTest, + type TempPlugin, + useNoBundledPlugins, + writePlugin, +} from "./loader.test-fixtures.js"; import { - getMemoryEmbeddingProvider, listMemoryEmbeddingProviders, registerMemoryEmbeddingProvider, } from "./memory-embedding-providers.js"; @@ -39,33 +41,6 @@ import { resetPluginRuntimeStateForTest, setActivePluginRegistry, } from "./runtime.js"; - -type TempPlugin = { dir: string; file: string; id: string }; -type PluginLoadConfig = NonNullable[0]>["config"]; -type PluginRegistry = ReturnType; - -function chmodSafeDir(dir: string) { - if (process.platform === "win32") { - return; - } - fs.chmodSync(dir, 0o755); -} - -function mkdtempSafe(prefix: string) { - const dir = fs.mkdtempSync(prefix); - chmodSafeDir(dir); - return dir; -} - -function mkdirSafe(dir: string) { - fs.mkdirSync(dir, { recursive: true }); - chmodSafeDir(dir); -} - -const fixtureRoot = mkdtempSafe(path.join(os.tmpdir(), "openclaw-plugin-")); -let tempDirIndex = 0; -const prevBundledDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; -const EMPTY_PLUGIN_SCHEMA = { type: "object", additionalProperties: false, properties: {} }; let cachedBundledTelegramDir = ""; let cachedBundledMemoryDir = ""; const BUNDLED_TELEGRAM_PLUGIN_BODY = `module.exports = { @@ -92,38 +67,6 @@ const BUNDLED_TELEGRAM_PLUGIN_BODY = `module.exports = { }, };`; -function makeTempDir() { - const dir = path.join(fixtureRoot, `case-${tempDirIndex++}`); - mkdirSafe(dir); - return dir; -} - -function writePlugin(params: { - id: string; - body: string; - dir?: string; - filename?: string; -}): TempPlugin { - const dir = params.dir ?? makeTempDir(); - const filename = params.filename ?? `${params.id}.cjs`; - mkdirSafe(dir); - const file = path.join(dir, filename); - fs.writeFileSync(file, params.body, "utf-8"); - fs.writeFileSync( - path.join(dir, "openclaw.plugin.json"), - JSON.stringify( - { - id: params.id, - configSchema: EMPTY_PLUGIN_SCHEMA, - }, - null, - 2, - ), - "utf-8", - ); - return { dir, file, id: params.id }; -} - function simplePluginBody(id: string) { return `module.exports = { id: ${JSON.stringify(id)}, register() {} };`; } @@ -261,10 +204,6 @@ function expectTelegramLoaded(registry: ReturnType) expect(registry.channels.some((entry) => entry.plugin.id === "telegram")).toBe(true); } -function useNoBundledPlugins() { - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins"; -} - function loadRegistryFromSinglePlugin(params: { plugin: TempPlugin; pluginConfig?: Record; @@ -516,48 +455,6 @@ function createEscapingEntryFixture(params: { id: string; sourceBody: string }) return { pluginDir, outsideEntry, linkedEntry }; } -function loadBundleFixture(params: { - pluginId: string; - build: (bundleRoot: string) => void; - env?: NodeJS.ProcessEnv; - onlyPluginIds?: string[]; -}) { - useNoBundledPlugins(); - const workspaceDir = makeTempDir(); - const stateDir = makeTempDir(); - const bundleRoot = path.join(workspaceDir, ".openclaw", "extensions", params.pluginId); - params.build(bundleRoot); - return withEnv({ OPENCLAW_STATE_DIR: stateDir, ...params.env }, () => - loadOpenClawPlugins({ - workspaceDir, - onlyPluginIds: params.onlyPluginIds ?? [params.pluginId], - config: { - plugins: { - entries: { - [params.pluginId]: { - enabled: true, - }, - }, - }, - }, - cache: false, - }), - ); -} - -function expectNoUnwiredBundleDiagnostic( - registry: ReturnType, - pluginId: string, -) { - expect( - registry.diagnostics.some( - (diag) => - diag.pluginId === pluginId && - diag.message.includes("bundle capability detected but not wired"), - ), - ).toBe(false); -} - function resolveLoadedPluginSource( registry: ReturnType, pluginId: string, @@ -770,195 +667,13 @@ function expectEscapingEntryRejected(params: { } afterEach(() => { - clearPluginLoaderCache(); - clearPluginDiscoveryCache(); - clearPluginManifestRegistryCache(); - resetPluginRuntimeStateForTest(); - resetDiagnosticEventsForTest(); - if (prevBundledDir === undefined) { - delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; - } else { - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = prevBundledDir; - } -}); - -describe("bundle plugins", () => { - it("reports Codex bundles as loaded bundle plugins without importing runtime code", () => { - useNoBundledPlugins(); - const workspaceDir = makeTempDir(); - const stateDir = makeTempDir(); - const bundleRoot = path.join(workspaceDir, ".openclaw", "extensions", "sample-bundle"); - mkdirSafe(path.join(bundleRoot, ".codex-plugin")); - mkdirSafe(path.join(bundleRoot, "skills")); - fs.writeFileSync( - path.join(bundleRoot, ".codex-plugin", "plugin.json"), - JSON.stringify({ - name: "Sample Bundle", - description: "Codex bundle fixture", - skills: "skills", - }), - "utf-8", - ); - fs.writeFileSync( - path.join(bundleRoot, "skills", "SKILL.md"), - "---\ndescription: fixture\n---\n", - ); - - const registry = withEnv({ OPENCLAW_STATE_DIR: stateDir }, () => - loadOpenClawPlugins({ - workspaceDir, - onlyPluginIds: ["sample-bundle"], - config: { - plugins: { - entries: { - "sample-bundle": { - enabled: true, - }, - }, - }, - }, - cache: false, - }), - ); - - const plugin = registry.plugins.find((entry) => entry.id === "sample-bundle"); - expect(plugin?.status).toBe("loaded"); - expect(plugin?.format).toBe("bundle"); - expect(plugin?.bundleFormat).toBe("codex"); - expect(plugin?.bundleCapabilities).toContain("skills"); - }); - - it.each([ - { - name: "treats Claude command roots and settings as supported bundle surfaces", - pluginId: "claude-skills", - expectedFormat: "claude", - expectedCapabilities: ["skills", "commands", "settings"], - build: (bundleRoot: string) => { - mkdirSafe(path.join(bundleRoot, "commands")); - fs.writeFileSync( - path.join(bundleRoot, "commands", "review.md"), - "---\ndescription: fixture\n---\n", - ); - fs.writeFileSync( - path.join(bundleRoot, "settings.json"), - '{"hideThinkingBlock":true}', - "utf-8", - ); - }, - }, - { - name: "treats bundle MCP as a supported bundle surface", - pluginId: "claude-mcp", - expectedFormat: "claude", - expectedCapabilities: ["mcpServers"], - build: (bundleRoot: string) => { - mkdirSafe(path.join(bundleRoot, ".claude-plugin")); - fs.writeFileSync( - path.join(bundleRoot, ".claude-plugin", "plugin.json"), - JSON.stringify({ - name: "Claude MCP", - }), - "utf-8", - ); - fs.writeFileSync( - path.join(bundleRoot, ".mcp.json"), - JSON.stringify({ - mcpServers: { - probe: { - command: "node", - args: ["./probe.mjs"], - }, - }, - }), - "utf-8", - ); - }, - }, - { - name: "treats Cursor command roots as supported bundle skill surfaces", - pluginId: "cursor-skills", - expectedFormat: "cursor", - expectedCapabilities: ["skills", "commands"], - build: (bundleRoot: string) => { - mkdirSafe(path.join(bundleRoot, ".cursor-plugin")); - mkdirSafe(path.join(bundleRoot, ".cursor", "commands")); - fs.writeFileSync( - path.join(bundleRoot, ".cursor-plugin", "plugin.json"), - JSON.stringify({ - name: "Cursor Skills", - }), - "utf-8", - ); - fs.writeFileSync( - path.join(bundleRoot, ".cursor", "commands", "review.md"), - "---\ndescription: fixture\n---\n", - ); - }, - }, - ])("$name", ({ pluginId, expectedFormat, expectedCapabilities, build }) => { - const registry = loadBundleFixture({ pluginId, build }); - const plugin = registry.plugins.find((entry) => entry.id === pluginId); - - expect(plugin?.status).toBe("loaded"); - expect(plugin?.bundleFormat).toBe(expectedFormat); - expect(plugin?.bundleCapabilities).toEqual(expect.arrayContaining(expectedCapabilities)); - expectNoUnwiredBundleDiagnostic(registry, pluginId); - }); - - it("warns when bundle MCP only declares unsupported non-stdio transports", () => { - const stateDir = makeTempDir(); - const registry = loadBundleFixture({ - pluginId: "claude-mcp-url", - env: { - OPENCLAW_HOME: stateDir, - }, - build: (bundleRoot) => { - mkdirSafe(path.join(bundleRoot, ".claude-plugin")); - fs.writeFileSync( - path.join(bundleRoot, ".claude-plugin", "plugin.json"), - JSON.stringify({ - name: "Claude MCP URL", - }), - "utf-8", - ); - fs.writeFileSync( - path.join(bundleRoot, ".mcp.json"), - JSON.stringify({ - mcpServers: { - remoteProbe: { - url: "http://127.0.0.1:8787/mcp", - }, - }, - }), - "utf-8", - ); - }, - }); - - const plugin = registry.plugins.find((entry) => entry.id === "claude-mcp-url"); - expect(plugin?.status).toBe("loaded"); - expect(plugin?.bundleCapabilities).toEqual(expect.arrayContaining(["mcpServers"])); - expect( - registry.diagnostics.some( - (diag) => - diag.pluginId === "claude-mcp-url" && - diag.message.includes("stdio only today") && - diag.message.includes("remoteProbe"), - ), - ).toBe(true); - }); + resetPluginLoaderTestStateForTest(); }); afterAll(() => { - try { - fs.rmSync(fixtureRoot, { recursive: true, force: true }); - } catch { - // ignore cleanup failures - } finally { - cachedBundledTelegramDir = ""; - cachedBundledMemoryDir = ""; - } + cleanupPluginLoaderFixturesForTest(); + cachedBundledTelegramDir = ""; + cachedBundledMemoryDir = ""; }); describe("loadOpenClawPlugins", () => { @@ -1593,11 +1308,7 @@ module.exports = { id: "throws-after-import", register() {} };`, }); it("can scope bundled provider loads to deepseek without hanging", () => { - if (prevBundledDir === undefined) { - delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; - } else { - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = prevBundledDir; - } + resetPluginLoaderTestStateForTest(); const scoped = loadOpenClawPlugins({ cache: false, @@ -2986,401 +2697,6 @@ module.exports = { expect(registry.channels).toHaveLength(expectedChannels); }); - it("passes validated plugin config into non-activating CLI metadata loads", async () => { - useNoBundledPlugins(); - const plugin = writePlugin({ - id: "config-cli", - filename: "config-cli.cjs", - body: `module.exports = { - id: "config-cli", - register(api) { - if (!api.pluginConfig || api.pluginConfig.token !== "ok") { - throw new Error("missing plugin config"); - } - api.registerCli(() => {}, { - descriptors: [ - { - name: "cfg", - description: "Config-backed CLI command", - hasSubcommands: true, - }, - ], - }); - }, -};`, - }); - fs.writeFileSync( - path.join(plugin.dir, "openclaw.plugin.json"), - JSON.stringify( - { - id: "config-cli", - configSchema: { - type: "object", - additionalProperties: false, - properties: { - token: { type: "string" }, - }, - required: ["token"], - }, - }, - null, - 2, - ), - "utf-8", - ); - - const registry = await loadOpenClawPluginCliRegistry({ - config: { - plugins: { - load: { paths: [plugin.file] }, - allow: ["config-cli"], - entries: { - "config-cli": { - config: { - token: "ok", - }, - }, - }, - }, - }, - }); - - expect(registry.cliRegistrars.flatMap((entry) => entry.commands)).toContain("cfg"); - expect(registry.plugins.find((entry) => entry.id === "config-cli")?.status).toBe("loaded"); - }); - - it("uses the real channel entry in cli-metadata mode for CLI metadata capture", async () => { - useNoBundledPlugins(); - const pluginDir = makeTempDir(); - const fullMarker = path.join(pluginDir, "full-loaded.txt"); - const modeMarker = path.join(pluginDir, "registration-mode.txt"); - const runtimeMarker = path.join(pluginDir, "runtime-set.txt"); - - fs.writeFileSync( - path.join(pluginDir, "package.json"), - JSON.stringify( - { - name: "@openclaw/cli-metadata-channel", - openclaw: { extensions: ["./index.cjs"], setupEntry: "./setup-entry.cjs" }, - }, - null, - 2, - ), - "utf-8", - ); - fs.writeFileSync( - path.join(pluginDir, "openclaw.plugin.json"), - JSON.stringify( - { - id: "cli-metadata-channel", - configSchema: EMPTY_PLUGIN_SCHEMA, - channels: ["cli-metadata-channel"], - }, - null, - 2, - ), - "utf-8", - ); - fs.writeFileSync( - path.join(pluginDir, "index.cjs"), - `const { defineChannelPluginEntry } = require("openclaw/plugin-sdk/core"); -require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8"); -module.exports = { - ...defineChannelPluginEntry({ - id: "cli-metadata-channel", - name: "CLI Metadata Channel", - description: "cli metadata channel", - setRuntime() { - require("node:fs").writeFileSync(${JSON.stringify(runtimeMarker)}, "loaded", "utf-8"); - }, - plugin: { - id: "cli-metadata-channel", - meta: { - id: "cli-metadata-channel", - label: "CLI Metadata Channel", - selectionLabel: "CLI Metadata Channel", - docsPath: "/channels/cli-metadata-channel", - blurb: "cli metadata channel", - }, - capabilities: { chatTypes: ["direct"] }, - config: { - listAccountIds: () => [], - resolveAccount: () => ({ accountId: "default" }), - }, - outbound: { deliveryMode: "direct" }, - }, - registerCliMetadata(api) { - require("node:fs").writeFileSync( - ${JSON.stringify(modeMarker)}, - String(api.registrationMode), - "utf-8", - ); - api.registerCli(() => {}, { - descriptors: [ - { - name: "cli-metadata-channel", - description: "Channel CLI metadata", - hasSubcommands: true, - }, - ], - }); - }, - registerFull() { - throw new Error("full channel entry should not run during CLI metadata capture"); - }, - }), -};`, - "utf-8", - ); - fs.writeFileSync( - path.join(pluginDir, "setup-entry.cjs"), - `throw new Error("setup entry should not load during CLI metadata capture");`, - "utf-8", - ); - - const registry = await loadOpenClawPluginCliRegistry({ - config: { - plugins: { - load: { paths: [pluginDir] }, - allow: ["cli-metadata-channel"], - }, - }, - }); - - expect(fs.existsSync(fullMarker)).toBe(true); - expect(fs.existsSync(runtimeMarker)).toBe(false); - expect(fs.readFileSync(modeMarker, "utf-8")).toBe("cli-metadata"); - expect(registry.cliRegistrars.flatMap((entry) => entry.commands)).toContain( - "cli-metadata-channel", - ); - }); - - it("collects channel CLI metadata during full plugin loads", () => { - useNoBundledPlugins(); - const pluginDir = makeTempDir(); - const modeMarker = path.join(pluginDir, "registration-mode.txt"); - const fullMarker = path.join(pluginDir, "full-loaded.txt"); - - fs.writeFileSync( - path.join(pluginDir, "package.json"), - JSON.stringify( - { - name: "@openclaw/full-cli-metadata-channel", - openclaw: { extensions: ["./index.cjs"] }, - }, - null, - 2, - ), - "utf-8", - ); - fs.writeFileSync( - path.join(pluginDir, "openclaw.plugin.json"), - JSON.stringify( - { - id: "full-cli-metadata-channel", - configSchema: EMPTY_PLUGIN_SCHEMA, - channels: ["full-cli-metadata-channel"], - }, - null, - 2, - ), - "utf-8", - ); - fs.writeFileSync( - path.join(pluginDir, "index.cjs"), - `const { defineChannelPluginEntry } = require("openclaw/plugin-sdk/core"); -module.exports = { - ...defineChannelPluginEntry({ - id: "full-cli-metadata-channel", - name: "Full CLI Metadata Channel", - description: "full cli metadata channel", - plugin: { - id: "full-cli-metadata-channel", - meta: { - id: "full-cli-metadata-channel", - label: "Full CLI Metadata Channel", - selectionLabel: "Full CLI Metadata Channel", - docsPath: "/channels/full-cli-metadata-channel", - blurb: "full cli metadata channel", - }, - capabilities: { chatTypes: ["direct"] }, - config: { - listAccountIds: () => [], - resolveAccount: () => ({ accountId: "default" }), - }, - outbound: { deliveryMode: "direct" }, - }, - registerCliMetadata(api) { - require("node:fs").writeFileSync( - ${JSON.stringify(modeMarker)}, - String(api.registrationMode), - "utf-8", - ); - api.registerCli(() => {}, { - descriptors: [ - { - name: "full-cli-metadata-channel", - description: "Full-load channel CLI metadata", - hasSubcommands: true, - }, - ], - }); - }, - registerFull() { - require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8"); - }, - }), -};`, - "utf-8", - ); - - const registry = loadOpenClawPlugins({ - cache: false, - config: { - plugins: { - load: { paths: [pluginDir] }, - allow: ["full-cli-metadata-channel"], - }, - }, - }); - - expect(fs.readFileSync(modeMarker, "utf-8")).toBe("full"); - expect(fs.existsSync(fullMarker)).toBe(true); - expect(registry.cliRegistrars.flatMap((entry) => entry.commands)).toContain( - "full-cli-metadata-channel", - ); - }); - - it("awaits async plugin registration when collecting CLI metadata", async () => { - useNoBundledPlugins(); - const plugin = writePlugin({ - id: "async-cli", - filename: "async-cli.cjs", - body: `module.exports = { - id: "async-cli", - async register(api) { - await Promise.resolve(); - api.registerCli(() => {}, { - descriptors: [ - { - name: "async-cli", - description: "Async CLI metadata", - hasSubcommands: true, - }, - ], - }); - }, -};`, - }); - - const registry = await loadOpenClawPluginCliRegistry({ - config: { - plugins: { - load: { paths: [plugin.file] }, - allow: ["async-cli"], - }, - }, - }); - - expect(registry.cliRegistrars.flatMap((entry) => entry.commands)).toContain("async-cli"); - expect( - registry.diagnostics.some((entry) => entry.message.includes("async registration is ignored")), - ).toBe(false); - }); - - it("applies memory slot gating to non-bundled CLI metadata loads", async () => { - useNoBundledPlugins(); - const plugin = writePlugin({ - id: "memory-external", - filename: "memory-external.cjs", - body: `module.exports = { - id: "memory-external", - kind: "memory", - register(api) { - api.registerCli(() => {}, { - descriptors: [ - { - name: "memory-external", - description: "External memory CLI metadata", - hasSubcommands: true, - }, - ], - }); - }, -};`, - }); - fs.writeFileSync( - path.join(plugin.dir, "openclaw.plugin.json"), - JSON.stringify( - { - id: "memory-external", - kind: "memory", - configSchema: EMPTY_PLUGIN_SCHEMA, - }, - null, - 2, - ), - "utf-8", - ); - - const registry = await loadOpenClawPluginCliRegistry({ - config: { - plugins: { - load: { paths: [plugin.file] }, - allow: ["memory-external"], - slots: { memory: "memory-other" }, - }, - }, - }); - - expect(registry.cliRegistrars.flatMap((entry) => entry.commands)).not.toContain( - "memory-external", - ); - const memory = registry.plugins.find((entry) => entry.id === "memory-external"); - expect(memory?.status).toBe("disabled"); - expect(String(memory?.error ?? "")).toContain('memory slot set to "memory-other"'); - }); - - it("re-evaluates memory slot gating after resolving exported plugin kind", async () => { - useNoBundledPlugins(); - const plugin = writePlugin({ - id: "memory-export-only", - filename: "memory-export-only.cjs", - body: `module.exports = { - id: "memory-export-only", - kind: "memory", - register(api) { - api.registerCli(() => {}, { - descriptors: [ - { - name: "memory-export-only", - description: "Export-only memory CLI metadata", - hasSubcommands: true, - }, - ], - }); - }, -};`, - }); - - const registry = await loadOpenClawPluginCliRegistry({ - config: { - plugins: { - load: { paths: [plugin.file] }, - allow: ["memory-export-only"], - slots: { memory: "memory-other" }, - }, - }, - }); - - expect(registry.cliRegistrars.flatMap((entry) => entry.commands)).not.toContain( - "memory-export-only", - ); - const memory = registry.plugins.find((entry) => entry.id === "memory-export-only"); - expect(memory?.status).toBe("disabled"); - expect(String(memory?.error ?? "")).toContain('memory slot set to "memory-other"'); - }); - it("blocks before_prompt_build but preserves legacy model overrides when prompt injection is disabled", async () => { useNoBundledPlugins(); const plugin = writePlugin({ @@ -4358,171 +3674,3 @@ export const runtimeValue = helperValue;`, expect(record?.status).toBe("loaded"); }); }); - -describe("getCompatibleActivePluginRegistry", () => { - it("reuses the active registry only when the load context cache key matches", () => { - const registry = createEmptyPluginRegistry(); - const loadOptions = { - config: { - plugins: { - allow: ["demo"], - load: { paths: ["/tmp/demo.js"] }, - }, - }, - workspaceDir: "/tmp/workspace-a", - runtimeOptions: { - allowGatewaySubagentBinding: true, - }, - }; - const { cacheKey } = __testing.resolvePluginLoadCacheContext(loadOptions); - setActivePluginRegistry(registry, cacheKey); - - expect(__testing.getCompatibleActivePluginRegistry(loadOptions)).toBe(registry); - expect( - __testing.getCompatibleActivePluginRegistry({ - ...loadOptions, - workspaceDir: "/tmp/workspace-b", - }), - ).toBeUndefined(); - expect( - __testing.getCompatibleActivePluginRegistry({ - ...loadOptions, - onlyPluginIds: ["demo"], - }), - ).toBeUndefined(); - expect( - __testing.getCompatibleActivePluginRegistry({ - ...loadOptions, - runtimeOptions: undefined, - }), - ).toBeUndefined(); - }); - - it("does not embed activation secrets in the loader cache key", () => { - const { cacheKey } = __testing.resolvePluginLoadCacheContext({ - config: { - plugins: { - allow: ["telegram"], - }, - }, - activationSourceConfig: { - plugins: { - allow: ["telegram"], - }, - channels: { - telegram: { - enabled: true, - botToken: "secret-token", - }, - }, - }, - autoEnabledReasons: { - telegram: ["telegram configured"], - }, - }); - - expect(cacheKey).not.toContain("secret-token"); - expect(cacheKey).not.toContain("botToken"); - expect(cacheKey).not.toContain("telegram configured"); - }); - - it("falls back to the current active runtime when no compatibility-shaping inputs are supplied", () => { - const registry = createEmptyPluginRegistry(); - setActivePluginRegistry(registry, "startup-registry"); - - expect(__testing.getCompatibleActivePluginRegistry()).toBe(registry); - }); - - it("does not reuse the active registry when core gateway method names differ", () => { - const registry = createEmptyPluginRegistry(); - const loadOptions = { - config: { - plugins: { - allow: ["demo"], - load: { paths: ["/tmp/demo.js"] }, - }, - }, - workspaceDir: "/tmp/workspace-a", - coreGatewayHandlers: { - "sessions.get": () => undefined, - }, - }; - const { cacheKey } = __testing.resolvePluginLoadCacheContext(loadOptions); - setActivePluginRegistry(registry, cacheKey); - - expect(__testing.getCompatibleActivePluginRegistry(loadOptions)).toBe(registry); - expect( - __testing.getCompatibleActivePluginRegistry({ - ...loadOptions, - coreGatewayHandlers: { - "sessions.get": () => undefined, - "sessions.list": () => undefined, - }, - }), - ).toBeUndefined(); - }); -}); - -describe("resolveRuntimePluginRegistry", () => { - it("reuses the compatible active registry before attempting a fresh load", () => { - const registry = createEmptyPluginRegistry(); - const loadOptions = { - config: { - plugins: { - allow: ["demo"], - }, - }, - workspaceDir: "/tmp/workspace-a", - }; - const { cacheKey } = __testing.resolvePluginLoadCacheContext(loadOptions); - setActivePluginRegistry(registry, cacheKey); - - expect(resolveRuntimePluginRegistry(loadOptions)).toBe(registry); - }); - - it("falls back to the current active runtime when no explicit load context is provided", () => { - const registry = createEmptyPluginRegistry(); - setActivePluginRegistry(registry, "startup-registry"); - - expect(resolveRuntimePluginRegistry()).toBe(registry); - }); -}); - -describe("clearPluginLoaderCache", () => { - it("resets registered memory plugin registries", () => { - registerMemoryEmbeddingProvider({ - id: "stale", - create: async () => ({ provider: null }), - }); - registerMemoryPromptSection(() => ["stale memory section"]); - registerMemoryFlushPlanResolver(() => ({ - softThresholdTokens: 1, - forceFlushTranscriptBytes: 2, - reserveTokensFloor: 3, - prompt: "stale", - systemPrompt: "stale", - relativePath: "memory/stale.md", - })); - registerMemoryRuntime({ - async getMemorySearchManager() { - return { manager: null }; - }, - resolveMemoryBackendConfig() { - return { backend: "builtin" as const }; - }, - }); - expect(buildMemoryPromptSection({ availableTools: new Set() })).toEqual([ - "stale memory section", - ]); - expect(resolveMemoryFlushPlan({})?.relativePath).toBe("memory/stale.md"); - expect(getMemoryRuntime()).toBeDefined(); - expect(getMemoryEmbeddingProvider("stale")).toBeDefined(); - - clearPluginLoaderCache(); - - expect(buildMemoryPromptSection({ availableTools: new Set() })).toEqual([]); - expect(resolveMemoryFlushPlan({})).toBeNull(); - expect(getMemoryRuntime()).toBeUndefined(); - expect(getMemoryEmbeddingProvider("stale")).toBeUndefined(); - }); -});