Config: narrow hook channel validation

This commit is contained in:
Weston Johnson 2026-03-20 10:08:47 -06:00
parent 0bf51e4dcb
commit 74c249a26a
2 changed files with 84 additions and 20 deletions

View File

@ -2,6 +2,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { clearPluginDiscoveryCache } from "../plugins/discovery.js";
import { clearPluginManifestRegistryCache } from "../plugins/manifest-registry.js";
import { validateConfigObjectWithPlugins } from "./config.js";
@ -43,6 +44,16 @@ async function writePluginFixture(params: {
);
}
async function writeMalformedPluginFixture(params: { dir: string }) {
await mkdirSafe(params.dir);
await fs.writeFile(
path.join(params.dir, "index.js"),
'export default { id: "broken-plugin", register() {} };\n',
"utf-8",
);
await fs.writeFile(path.join(params.dir, "openclaw.plugin.json"), "{", "utf-8");
}
async function writeBundleFixture(params: {
dir: string;
format: "codex" | "claude";
@ -163,6 +174,7 @@ describe("config plugin validation", () => {
schema: voiceCallManifest.configSchema,
});
clearPluginManifestRegistryCache();
clearPluginDiscoveryCache();
// Warm the plugin manifest cache once so path-based validations can reuse
// parsed manifests across test cases.
validateInSuite({
@ -184,6 +196,7 @@ describe("config plugin validation", () => {
afterAll(async () => {
await fs.rm(fixtureRoot, { recursive: true, force: true });
clearPluginManifestRegistryCache();
clearPluginDiscoveryCache();
});
it("reports missing plugin refs across load paths, entries, and allowlist surfaces", async () => {
@ -472,16 +485,40 @@ describe("config plugin validation", () => {
});
it("accepts built-in hook mapping channels without local channel config", async () => {
const malformedAutoPluginDir = path.join(suiteHome, ".openclaw", "extensions", "broken-plugin");
await writeMalformedPluginFixture({ dir: malformedAutoPluginDir });
clearPluginManifestRegistryCache();
clearPluginDiscoveryCache();
try {
const res = validateInSuite({
agents: { list: [{ id: "pi" }] },
hooks: {
mappings: [{ channel: "discord" }],
},
});
expect(res.ok).toBe(true);
} finally {
await fs.rm(malformedAutoPluginDir, { recursive: true, force: true });
clearPluginManifestRegistryCache();
clearPluginDiscoveryCache();
}
});
it("accepts enabled plugin hook mapping channels without channel config", async () => {
const res = validateInSuite({
agents: { list: [{ id: "pi" }] },
hooks: {
mappings: [{ channel: "discord" }],
mappings: [{ channel: "bluebubbles" }],
},
plugins: {
load: { paths: [bluebubblesPluginDir] },
entries: { "bluebubbles-plugin": { enabled: true } },
},
});
expect(res.ok).toBe(true);
});
it("accepts discovered plugin hook mapping channels without channel config", async () => {
it("rejects disabled plugin hook mapping channels", async () => {
const res = validateInSuite({
agents: { list: [{ id: "pi" }] },
hooks: {
@ -489,7 +526,13 @@ describe("config plugin validation", () => {
},
plugins: { enabled: false, load: { paths: [bluebubblesPluginDir] } },
});
expect(res.ok).toBe(true);
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues).toContainEqual({
path: "hooks.mappings.0.channel",
message: "unknown hook mapping channel: bluebubbles",
});
}
});
it("rejects unknown heartbeat targets", async () => {

View File

@ -349,11 +349,11 @@ function validateConfigObjectWithPluginsBase(
type RegistryInfo = {
registry: ReturnType<typeof loadPluginManifestRegistry>;
knownIds?: Set<string>;
normalizedPlugins?: ReturnType<typeof normalizePluginsConfig>;
};
let registryInfo: RegistryInfo | null = null;
let compatConfig: OpenClawConfig | null | undefined;
let memoizedNormalizedPlugins: ReturnType<typeof normalizePluginsConfig> | null = null;
const ensureCompatConfig = (): OpenClawConfig => {
if (compatConfig !== undefined) {
@ -416,11 +416,10 @@ function validateConfigObjectWithPluginsBase(
};
const ensureNormalizedPlugins = (): ReturnType<typeof normalizePluginsConfig> => {
const info = ensureRegistry();
if (!info.normalizedPlugins) {
info.normalizedPlugins = normalizePluginsConfig(ensureCompatConfig().plugins);
if (!memoizedNormalizedPlugins) {
memoizedNormalizedPlugins = normalizePluginsConfig(ensureCompatConfig().plugins);
}
return info.normalizedPlugins;
return memoizedNormalizedPlugins;
};
const allowedChannels = new Set<string>(["defaults", "modelByChannel", ...CHANNEL_IDS]);
@ -496,19 +495,40 @@ function validateConfigObjectWithPluginsBase(
}
}
const allowedHookChannels = new Set<string>(["last"]);
const builtInHookChannels = new Set<string>(["last"]);
for (const channelId of CHANNEL_IDS) {
allowedHookChannels.add(channelId.toLowerCase());
builtInHookChannels.add(channelId.toLowerCase());
}
const { registry: hookChannelRegistry } = ensureRegistry();
for (const record of hookChannelRegistry.plugins) {
for (const channelId of record.channels) {
const normalized = channelId.trim().toLowerCase();
if (normalized) {
allowedHookChannels.add(normalized);
let enabledHookPluginChannels: Set<string> | null = null;
const ensureEnabledHookPluginChannels = (): Set<string> => {
if (enabledHookPluginChannels) {
return enabledHookPluginChannels;
}
enabledHookPluginChannels = new Set<string>();
const normalizedPlugins = ensureNormalizedPlugins();
const { registry: hookChannelRegistry } = ensureRegistry();
for (const record of hookChannelRegistry.plugins) {
const enableState = resolveEffectiveEnableState({
id: record.id,
origin: record.origin,
config: normalizedPlugins,
rootConfig: config,
enabledByDefault: record.enabledByDefault,
});
if (!enableState.enabled) {
continue;
}
for (const channelId of record.channels) {
const normalized = channelId.trim().toLowerCase();
if (normalized) {
enabledHookPluginChannels.add(normalized);
}
}
}
}
return enabledHookPluginChannels;
};
const validateHookMappingChannel = (channel: unknown, path: string) => {
if (channel === undefined) {
@ -524,12 +544,13 @@ function validateConfigObjectWithPluginsBase(
return;
}
const normalized = normalizeChatChannelId(trimmed) ?? trimmed.toLowerCase();
if (normalized === "last") {
if (builtInHookChannels.has(normalized)) {
return;
}
if (!allowedHookChannels.has(normalized)) {
issues.push({ path, message: `unknown hook mapping channel: ${trimmed}` });
if (ensureEnabledHookPluginChannels().has(normalized)) {
return;
}
issues.push({ path, message: `unknown hook mapping channel: ${trimmed}` });
};
if (Array.isArray(config.hooks?.mappings)) {