import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { validateConfigObjectWithPlugins } from "./validation.js"; vi.unmock("../version.js"); async function chmodSafeDir(dir: string) { if (process.platform === "win32") { return; } await fs.chmod(dir, 0o755); } async function mkdirSafe(dir: string) { await fs.mkdir(dir, { recursive: true }); await chmodSafeDir(dir); } async function writePluginFixture(params: { dir: string; id: string; schema: Record; channels?: string[]; }) { await mkdirSafe(params.dir); await fs.writeFile( path.join(params.dir, "index.js"), `export default { id: "${params.id}", register() {} };`, "utf-8", ); const manifest: Record = { id: params.id, configSchema: params.schema, }; if (params.channels) { manifest.channels = params.channels; } await fs.writeFile( path.join(params.dir, "openclaw.plugin.json"), JSON.stringify(manifest, null, 2), "utf-8", ); } async function writeBundleFixture(params: { dir: string; format: "codex" | "claude"; name: string; }) { await mkdirSafe(params.dir); const manifestDir = path.join( params.dir, params.format === "codex" ? ".codex-plugin" : ".claude-plugin", ); await mkdirSafe(manifestDir); await fs.writeFile( path.join(manifestDir, "plugin.json"), JSON.stringify({ name: params.name }, null, 2), "utf-8", ); } async function writeManifestlessClaudeBundleFixture(params: { dir: string }) { await mkdirSafe(params.dir); await mkdirSafe(path.join(params.dir, "commands")); await fs.writeFile( path.join(params.dir, "commands", "review.md"), "---\ndescription: fixture\n---\n", "utf-8", ); await fs.writeFile(path.join(params.dir, "settings.json"), '{"hideThinkingBlock":true}', "utf-8"); } function expectRemovedPluginWarnings( result: { ok: boolean; warnings?: Array<{ path: string; message: string }> }, removedId: string, removedLabel: string, ) { expect(result.ok).toBe(true); if (result.ok) { const message = `plugin removed: ${removedLabel} (stale config entry ignored; remove it from plugins config)`; expect(result.warnings).toEqual( expect.arrayContaining([ { path: `plugins.entries.${removedId}`, message }, { path: "plugins.allow", message }, { path: "plugins.deny", message }, { path: "plugins.slots.memory", message }, ]), ); } } describe("config plugin validation", () => { let fixtureRoot = ""; let suiteHome = ""; let badPluginDir = ""; let enumPluginDir = ""; let chatPluginDir = ""; let googleOverridePluginDir = ""; let voiceCallSchemaPluginDir = ""; let bundlePluginDir = ""; let manifestlessClaudeBundleDir = ""; let blockedPluginDir = ""; const suiteEnv = () => ({ HOME: suiteHome, OPENCLAW_HOME: undefined, OPENCLAW_STATE_DIR: path.join(suiteHome, ".openclaw"), OPENCLAW_BUNDLED_PLUGINS_DIR: undefined, OPENCLAW_VERSION: undefined, VITEST: "true", }) satisfies NodeJS.ProcessEnv; const validateInSuite = (raw: unknown) => validateConfigObjectWithPlugins(raw, { env: suiteEnv() }); const validateRemovedPluginConfig = (removedId: string) => validateInSuite({ agents: { list: [{ id: "pi" }] }, plugins: { enabled: false, entries: { [removedId]: { enabled: true } }, allow: [removedId], deny: [removedId], slots: { memory: removedId }, }, }); beforeAll(async () => { fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-config-plugin-validation-")); await chmodSafeDir(fixtureRoot); suiteHome = path.join(fixtureRoot, "home"); await mkdirSafe(suiteHome); badPluginDir = path.join(suiteHome, "bad-plugin"); enumPluginDir = path.join(suiteHome, "enum-plugin"); chatPluginDir = path.join(suiteHome, "chat-plugin"); await writePluginFixture({ dir: badPluginDir, id: "bad-plugin", schema: { type: "object", additionalProperties: false, properties: { value: { type: "boolean" }, }, required: ["value"], }, }); await writePluginFixture({ dir: enumPluginDir, id: "enum-plugin", schema: { type: "object", properties: { fileFormat: { type: "string", enum: ["markdown", "html"], }, }, required: ["fileFormat"], }, }); await writePluginFixture({ dir: chatPluginDir, id: "chat-plugin", channels: ["chat"], schema: { type: "object" }, }); googleOverridePluginDir = path.join(suiteHome, "google"); await writePluginFixture({ dir: googleOverridePluginDir, id: "google", schema: { type: "object", properties: { apiKey: { type: "string" }, }, }, }); bundlePluginDir = path.join(suiteHome, "bundle-plugin"); await writeBundleFixture({ dir: bundlePluginDir, format: "codex", name: "Bundle Fixture", }); manifestlessClaudeBundleDir = path.join(suiteHome, "manifestless-claude-bundle"); await writeManifestlessClaudeBundleFixture({ dir: manifestlessClaudeBundleDir, }); blockedPluginDir = path.join(suiteHome, "blocked-plugin"); await writePluginFixture({ dir: blockedPluginDir, id: "blocked-plugin", schema: { type: "object" }, }); voiceCallSchemaPluginDir = path.join(suiteHome, "voice-call-schema-plugin"); const voiceCallManifestPath = path.join( process.cwd(), "extensions", "voice-call", "openclaw.plugin.json", ); const voiceCallManifest = JSON.parse(await fs.readFile(voiceCallManifestPath, "utf-8")) as { configSchema?: Record; }; if (!voiceCallManifest.configSchema) { throw new Error("voice-call manifest missing configSchema"); } await writePluginFixture({ dir: voiceCallSchemaPluginDir, id: "voice-call-schema-fixture", schema: voiceCallManifest.configSchema, }); }); afterAll(async () => { await fs.rm(fixtureRoot, { recursive: true, force: true }); }); it("reports missing plugin refs across entries and allowlist surfaces", () => { const missingPath = path.join(suiteHome, "missing-plugin-dir"); const res = validateInSuite({ agents: { list: [{ id: "pi" }] }, plugins: { enabled: true, load: { paths: [missingPath] }, entries: { "missing-plugin": { enabled: true } }, allow: ["missing-allow"], deny: ["missing-deny"], slots: { memory: "missing-slot" }, }, }); expect(res.ok).toBe(false); if (!res.ok) { expect(res.issues).toEqual( expect.arrayContaining([ { path: "plugins.deny", message: "plugin not found: missing-deny" }, { path: "plugins.slots.memory", message: "plugin not found: missing-slot" }, ]), ); expect(res.warnings).toContainEqual({ path: "plugins.allow", message: "plugin not found: missing-allow (stale config entry ignored; remove it from plugins config)", }); expect(res.warnings).toContainEqual({ path: "plugins.entries.missing-plugin", message: "plugin not found: missing-plugin (stale config entry ignored; remove it from plugins config)", }); } }); it("reports catalog install hints for missing configured official external plugins", () => { const res = validateConfigObjectWithPlugins( { agents: { list: [{ id: "pi" }] }, plugins: { entries: { brave: { enabled: true } }, allow: ["brave"], }, }, { env: suiteEnv(), pluginMetadataSnapshot: { manifestRegistry: { plugins: [], diagnostics: [], }, }, }, ); expect(res.ok).toBe(true); const message = "plugin not installed: brave — install the official external plugin with: openclaw plugins install @openclaw/brave-plugin"; expect(res.warnings ?? []).toEqual( expect.arrayContaining([ { path: "plugins.entries.brave", message }, { path: "plugins.allow", message }, ]), ); expect( (res.warnings ?? []).some( (warning) => (warning.path === "plugins.entries.brave" || warning.path === "plugins.allow") && warning.message.includes("remove it from plugins config"), ), ).toBe(false); }); it.runIf(process.platform !== "win32")( "reports configured blocked plugins without stale not-found wording", async () => { await fs.chmod(blockedPluginDir, 0o777); try { const res = validateInSuite({ agents: { list: [{ id: "pi" }] }, plugins: { enabled: true, load: { paths: [blockedPluginDir] }, entries: { "blocked-plugin": { enabled: true } }, allow: ["blocked-plugin"], }, }); expect(res.ok).toBe(true); if (!res.ok) { return; } expect(res.warnings).toEqual( expect.arrayContaining([ expect.objectContaining({ path: "plugins.entries.blocked-plugin", message: expect.stringContaining("plugin present but blocked: blocked-plugin"), }), expect.objectContaining({ path: "plugins.allow", message: expect.stringContaining("plugin present but blocked: blocked-plugin"), }), ]), ); expect( res.warnings.some( (warning) => warning.message.includes("plugin not found: blocked-plugin") || warning.message.includes("remove it from plugins config"), ), ).toBe(false); } finally { await chmodSafeDir(blockedPluginDir); } }, ); it("maps legacy blocked diagnostics without plugin ids to configured load paths", () => { const res = validateConfigObjectWithPlugins( { agents: { list: [{ id: "pi" }] }, plugins: { enabled: true, load: { paths: [blockedPluginDir] }, entries: { "blocked-plugin": { enabled: true } }, allow: ["blocked-plugin"], }, }, { env: suiteEnv(), pluginMetadataSnapshot: { manifestRegistry: { plugins: [], diagnostics: [ { level: "warn", source: path.join(blockedPluginDir, "index.js"), message: `blocked plugin candidate: world-writable path (${blockedPluginDir}, mode=0777)`, }, ], }, }, }, ); expect(res.ok).toBe(true); if (!res.ok) { return; } expect(res.warnings).toEqual( expect.arrayContaining([ expect.objectContaining({ path: "plugins.entries.blocked-plugin", message: expect.stringContaining("plugin present but blocked: blocked-plugin"), }), expect.objectContaining({ path: "plugins.allow", message: expect.stringContaining("plugin present but blocked: blocked-plugin"), }), ]), ); expect( res.warnings.some((warning) => warning.message.includes("plugin not found: blocked-plugin")), ).toBe(false); }); it("does not source-match blocked diagnostics that already name a different plugin id", () => { const aliasDir = path.join(suiteHome, "alias-dir"); const res = validateConfigObjectWithPlugins( { agents: { list: [{ id: "pi" }] }, plugins: { enabled: true, load: { paths: [aliasDir] }, entries: { "actual-id": { enabled: true }, "alias-dir": { enabled: true }, }, allow: ["actual-id", "alias-dir"], }, }, { env: suiteEnv(), pluginMetadataSnapshot: { manifestRegistry: { plugins: [], diagnostics: [ { level: "warn", pluginId: "actual-id", source: path.join(aliasDir, "index.js"), message: `blocked plugin candidate: world-writable path (${aliasDir}, mode=0777)`, }, ], }, }, }, ); expect(res.ok).toBe(true); if (!res.ok) { return; } expect(res.warnings).toEqual( expect.arrayContaining([ expect.objectContaining({ path: "plugins.entries.actual-id", message: expect.stringContaining("plugin present but blocked: actual-id"), }), expect.objectContaining({ path: "plugins.allow", message: expect.stringContaining("plugin present but blocked: actual-id"), }), expect.objectContaining({ path: "plugins.entries.alias-dir", message: "plugin not found: alias-dir (stale config entry ignored; remove it from plugins config)", }), expect.objectContaining({ path: "plugins.allow", message: "plugin not found: alias-dir (stale config entry ignored; remove it from plugins config)", }), ]), ); expect( res.warnings.some((warning) => warning.message.includes("plugin present but blocked: alias-dir"), ), ).toBe(false); }); it("warns instead of failing for stale channel config backed by missing plugin refs", () => { const res = validateInSuite({ agents: { list: [{ id: "pi" }] }, channels: { "missing-chat": { token: "stale" }, }, plugins: { allow: ["missing-chat"], entries: { "missing-chat": { enabled: true } }, }, }); expect(res.ok).toBe(true); if (!res.ok) { return; } expect(res.warnings).toContainEqual({ path: "channels.missing-chat", message: "unknown channel id: missing-chat (stale channel plugin config ignored; run openclaw doctor --fix to remove stale config, or install the plugin)", }); expect(res.warnings).toContainEqual({ path: "plugins.allow", message: "plugin not found: missing-chat (stale config entry ignored; remove it from plugins config)", }); expect(res.warnings).toContainEqual({ path: "plugins.entries.missing-chat", message: "plugin not found: missing-chat (stale config entry ignored; remove it from plugins config)", }); }); it("keeps unknown channel typos fatal when there is no stale plugin evidence", () => { const res = validateInSuite({ agents: { list: [{ id: "pi" }] }, channels: { telegarm: { botToken: "typo" }, }, plugins: { allow: ["telegram"], }, }); expect(res.ok).toBe(false); if (res.ok) { return; } expect(res.issues).toContainEqual({ path: "channels.telegarm", message: "unknown channel id: telegarm", }); expect(res.warnings).not.toContainEqual(expect.objectContaining({ path: "channels.telegarm" })); }); it("warns when plugins.allow contains a channel id without a plugin manifest (#76872)", () => { const res = validateConfigObjectWithPlugins( { agents: { list: [{ id: "pi" }] }, channels: { discord: { token: "xxx" }, }, plugins: { allow: ["discord"], }, }, { env: suiteEnv(), pluginMetadataSnapshot: { manifestRegistry: { plugins: [], diagnostics: [], }, }, }, ); expect(res.ok).toBe(true); expect(res.warnings ?? []).toContainEqual({ path: "plugins.allow", message: "plugin not installed: discord — install the official external plugin with: openclaw plugins install @openclaw/discord", }); }); it("uses persisted installed-plugin records as stale channel evidence", async () => { const installedPluginIndexPath = path.join(suiteHome, ".openclaw", "plugins", "installs.json"); await mkdirSafe(path.dirname(installedPluginIndexPath)); await fs.writeFile( installedPluginIndexPath, JSON.stringify( { installRecords: { "missing-sms": { source: "npm", spec: "missing-sms@1.0.0", installedAt: "2026-04-12T00:00:00.000Z", }, }, plugins: [], }, null, 2, ), "utf-8", ); try { const res = validateInSuite({ agents: { list: [{ id: "pi" }] }, channels: { "missing-sms": { token: "stale" }, }, }); expect(res.ok).toBe(true); if (!res.ok) { return; } expect(res.warnings).toContainEqual({ path: "channels.missing-sms", message: "unknown channel id: missing-sms (stale channel plugin config ignored; run openclaw doctor --fix to remove stale config, or install the plugin)", }); } finally { await fs.rm(installedPluginIndexPath, { force: true }); } }); it("warns with actionable guidance when a runtime command name is used in plugins.allow", () => { const res = validateInSuite({ agents: { list: [{ id: "pi" }] }, plugins: { allow: ["dreaming"], entries: { "memory-core": { config: { dreaming: { enabled: true } }, }, }, }, }); // Should not produce the generic "plugin not found" warning. expect( res.warnings?.some( (w) => w.path === "plugins.allow" && w.message.includes("plugin not found: dreaming"), ), ).toBe(false); // Should produce a helpful redirect to the parent plugin. expect( res.warnings?.some( (w) => w.path === "plugins.allow" && w.message.includes('"dreaming" is not a plugin') && w.message.includes("memory-core"), ), ).toBe(true); }); it("does not fail validation for the implicit default memory slot when plugins config is explicit", () => { const res = validateConfigObjectWithPlugins( { agents: { list: [{ id: "pi" }] }, plugins: { entries: { acpx: { enabled: true } }, }, }, { env: { ...suiteEnv(), OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(suiteHome, "missing-bundled-plugins"), }, }, ); expect(res.ok).toBe(true); }); it("warns for removed legacy plugin ids instead of failing validation", () => { const removedId = "google-antigravity-auth"; const res = validateRemovedPluginConfig(removedId); expectRemovedPluginWarnings(res, removedId, removedId); }); it("warns for removed google gemini auth plugin ids instead of failing validation", () => { const removedId = "google-gemini-cli-auth"; const res = validateRemovedPluginConfig(removedId); expectRemovedPluginWarnings(res, removedId, removedId); }); it("does not auto-allow config-loaded overrides of bundled web search plugin ids", () => { const res = validateInSuite({ plugins: { allow: ["imessage", "memory-core"], load: { paths: [googleOverridePluginDir], }, entries: { google: { config: { apiKey: "test-google-key", }, }, }, }, }); expect(res.ok).toBe(true); if (!res.ok) { return; } expect(res.warnings).toContainEqual({ path: "plugins.entries.google", message: "plugin disabled (not in allowlist) but config is present", }); }); it("surfaces plugin config diagnostics", () => { const res = validateInSuite({ agents: { list: [{ id: "pi" }] }, plugins: { enabled: true, load: { paths: [badPluginDir] }, entries: { "bad-plugin": { config: { value: "nope" } } }, }, }); expect(res.ok).toBe(false); if (!res.ok) { const hasIssue = res.issues.some( (issue) => issue.path.startsWith("plugins.entries.bad-plugin.config") && issue.message.includes("invalid config"), ); expect(hasIssue).toBe(true); } }); it("surfaces invalid Codex native plugin marketplaces as config diagnostics", () => { const res = validateConfigObjectWithPlugins( { agents: { list: [{ id: "pi" }] }, plugins: { entries: { codex: { enabled: true, config: { codexPlugins: { enabled: true, plugins: { github: { enabled: true, marketplaceName: "not-openai-curated", pluginName: "github", }, }, }, }, }, }, }, }, { env: { ...suiteEnv(), OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(process.cwd(), "extensions"), }, }, ); expect(res.ok).toBe(false); if (!res.ok) { expect(res.issues).toContainEqual( expect.objectContaining({ path: "plugins.entries.codex.config.codexPlugins.plugins.github.marketplaceName", message: expect.stringContaining("invalid config"), }), ); expect( res.issues.some( (issue) => issue.path === "plugins.entries.codex.config.codexPlugins.plugins.github.marketplaceName" && issue.allowedValues?.includes("openai-curated"), ), ).toBe(true); } }); it("does not require native config schemas for enabled bundle plugins", () => { const res = validateInSuite({ agents: { list: [{ id: "pi" }] }, plugins: { enabled: true, load: { paths: [bundlePluginDir] }, entries: { "bundle-fixture": { enabled: true } }, }, }); expect(res.ok).toBe(true); }); it("accepts enabled manifestless Claude bundles without a native schema", () => { const res = validateInSuite({ agents: { list: [{ id: "pi" }] }, plugins: { enabled: true, load: { paths: [manifestlessClaudeBundleDir] }, entries: { "manifestless-claude-bundle": { enabled: true } }, }, }); expect(res.ok).toBe(true); }); it("surfaces allowed enum values for plugin config diagnostics", () => { const res = validateInSuite({ agents: { list: [{ id: "pi" }] }, plugins: { enabled: true, load: { paths: [enumPluginDir] }, entries: { "enum-plugin": { config: { fileFormat: "txt" } } }, }, }); expect(res.ok).toBe(false); if (!res.ok) { const issue = res.issues.find( (entry) => entry.path === "plugins.entries.enum-plugin.config.fileFormat", ); expect(issue).toMatchObject({ message: expect.stringContaining('allowed: "markdown", "html"'), allowedValues: ["markdown", "html"], allowedValuesHiddenCount: 0, }); } }); it("accepts voice-call webhookSecurity and streaming guard config fields", () => { const res = validateInSuite({ agents: { list: [{ id: "pi" }] }, plugins: { enabled: true, load: { paths: [voiceCallSchemaPluginDir] }, entries: { "voice-call-schema-fixture": { config: { provider: "twilio", webhookSecurity: { allowedHosts: ["voice.example.com"], trustForwardingHeaders: false, trustedProxyIPs: ["127.0.0.1"], }, streaming: { enabled: true, preStartTimeoutMs: 5000, maxPendingConnections: 16, maxPendingConnectionsPerIp: 4, maxConnections: 64, }, staleCallReaperSeconds: 180, }, }, }, }, }); expect(res.ok).toBe(true); }); it("accepts voice-call OpenAI TTS speed, instructions, and baseUrl config fields", () => { const res = validateInSuite({ agents: { list: [{ id: "pi" }] }, plugins: { enabled: true, load: { paths: [voiceCallSchemaPluginDir] }, entries: { "voice-call-schema-fixture": { config: { tts: { providers: { openai: { baseUrl: "http://localhost:8880/v1", voice: "alloy", speed: 1.5, instructions: "Speak in a cheerful tone", }, }, }, }, }, }, }, }); expect(res.ok).toBe(true); }); it("accepts voice-call SecretRef credentials declared by the plugin schema", () => { const res = validateInSuite({ agents: { list: [{ id: "pi" }] }, plugins: { enabled: true, load: { paths: [voiceCallSchemaPluginDir] }, entries: { "voice-call-schema-fixture": { config: { provider: "twilio", twilio: { accountSid: "twilio-account-sid-placeholder", authToken: { source: "env", provider: "default", id: "TWILIO_AUTH_TOKEN" }, }, tts: { providers: { openai: { apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, }, elevenlabs: { apiKey: { source: "env", provider: "default", id: "ELEVENLABS_API_KEY" }, }, }, }, }, }, }, }, }); expect(res.ok).toBe(true); }); it("rejects out-of-range voice-call OpenAI TTS speed values", () => { const res = validateInSuite({ agents: { list: [{ id: "pi" }] }, plugins: { enabled: true, load: { paths: [voiceCallSchemaPluginDir] }, entries: { "voice-call-schema-fixture": { config: { tts: { providers: { openai: { speed: 10, }, }, }, }, }, }, }, }); expect(res.ok).toBe(false); if (!res.ok) { expect( res.issues.some( (issue) => issue.path === "plugins.entries.voice-call-schema-fixture.config.tts.providers.openai.speed", ), ).toBe(true); } }); it("rejects out-of-range voice-call ElevenLabs voice settings", () => { const res = validateInSuite({ agents: { list: [{ id: "pi" }] }, plugins: { enabled: true, load: { paths: [voiceCallSchemaPluginDir] }, entries: { "voice-call-schema-fixture": { config: { tts: { providers: { elevenlabs: { voiceSettings: { stability: 5, }, }, }, }, }, }, }, }, }); expect(res.ok).toBe(false); if (!res.ok) { expect( res.issues.some( (issue) => issue.path === "plugins.entries.voice-call-schema-fixture.config.tts.providers.elevenlabs.voiceSettings.stability", ), ).toBe(true); } }); it("accepts known plugin ids and valid channel/heartbeat enums", () => { const res = validateInSuite({ agents: { defaults: { heartbeat: { target: "last", directPolicy: "block" } }, list: [{ id: "pi", heartbeat: { directPolicy: "allow" } }], }, channels: { modelByChannel: { openai: { whatsapp: "openai/gpt-5.4", }, }, }, plugins: { enabled: false, entries: { discord: { enabled: true } } }, }); expect(res.ok).toBe(true); }); it("accepts plugin heartbeat targets", () => { const res = validateInSuite({ agents: { defaults: { heartbeat: { target: "chat" } }, list: [{ id: "pi" }] }, plugins: { enabled: false, load: { paths: [chatPluginDir] } }, }); expect(res.ok).toBe(true); }); it("rejects unknown heartbeat targets", () => { const res = validateInSuite({ agents: { defaults: { heartbeat: { target: "not-a-channel" } }, list: [{ id: "pi" }], }, }); expect(res.ok).toBe(false); if (!res.ok) { expect(res.issues).toContainEqual({ path: "agents.defaults.heartbeat.target", message: "unknown heartbeat target: not-a-channel", }); } }); it("rejects invalid heartbeat directPolicy values", () => { const res = validateInSuite({ agents: { defaults: { heartbeat: { directPolicy: "maybe" } }, list: [{ id: "pi" }], }, }); expect(res.ok).toBe(false); if (!res.ok) { expect( res.issues.some((issue) => issue.path === "agents.defaults.heartbeat.directPolicy"), ).toBe(true); } }); });