Merge 74c249a26afc45384006e31c15806b0a5661df84 into d78e13f545136fcbba1feceecc5e0485a06c33a6
This commit is contained in:
commit
02c4361af2
@ -613,6 +613,29 @@ describe("config cli", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid hook mapping channels in JSON dry-run mode", async () => {
|
||||
const resolved: OpenClawConfig = {
|
||||
gateway: { port: 18789 },
|
||||
};
|
||||
setSnapshot(resolved, resolved);
|
||||
|
||||
await expect(
|
||||
runConfigCommand([
|
||||
"config",
|
||||
"set",
|
||||
"hooks.mappings[0].channel",
|
||||
'"not-a-real-channel"',
|
||||
"--strict-json",
|
||||
"--dry-run",
|
||||
]),
|
||||
).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(mockWriteConfigFile).not.toHaveBeenCalled();
|
||||
expect(mockError).toHaveBeenCalledWith(
|
||||
expect.stringContaining("unknown hook mapping channel: not-a-real-channel"),
|
||||
);
|
||||
});
|
||||
|
||||
it("logs a dry-run note when value mode performs no validation checks", async () => {
|
||||
const resolved: OpenClawConfig = {
|
||||
gateway: { port: 18789 },
|
||||
|
||||
@ -15,7 +15,7 @@ import {
|
||||
type SecretRef,
|
||||
type SecretRefSource,
|
||||
} from "../config/types.secrets.js";
|
||||
import { validateConfigObjectRaw } from "../config/validation.js";
|
||||
import { validateConfigObjectRawWithPlugins } from "../config/validation.js";
|
||||
import { SecretProviderSchema } from "../config/zod-schema.core.js";
|
||||
import { danger, info, success } from "../globals.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
@ -920,7 +920,7 @@ function selectDryRunRefsForResolution(params: { refs: SecretRef[]; allowExecInD
|
||||
}
|
||||
|
||||
function collectDryRunSchemaErrors(config: OpenClawConfig): ConfigSetDryRunError[] {
|
||||
const validated = validateConfigObjectRaw(config);
|
||||
const validated = validateConfigObjectRawWithPlugins(config);
|
||||
if (validated.ok) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@ -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 () => {
|
||||
@ -471,6 +484,57 @@ describe("config plugin validation", () => {
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
|
||||
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: "bluebubbles" }],
|
||||
},
|
||||
plugins: {
|
||||
load: { paths: [bluebubblesPluginDir] },
|
||||
entries: { "bluebubbles-plugin": { enabled: true } },
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects disabled plugin hook mapping channels", async () => {
|
||||
const res = validateInSuite({
|
||||
agents: { list: [{ id: "pi" }] },
|
||||
hooks: {
|
||||
mappings: [{ channel: "bluebubbles" }],
|
||||
},
|
||||
plugins: { enabled: false, load: { paths: [bluebubblesPluginDir] } },
|
||||
});
|
||||
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 () => {
|
||||
const res = validateInSuite({
|
||||
agents: {
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
import type { ChannelId } from "../channels/plugins/types.js";
|
||||
import type { InstallRecordBase } from "./types.installs.js";
|
||||
|
||||
export type HookMappingMatch = {
|
||||
path?: string;
|
||||
source?: string;
|
||||
@ -22,17 +25,7 @@ export type HookMappingConfig = {
|
||||
deliver?: boolean;
|
||||
/** DANGEROUS: Disable external content safety wrapping for this hook. */
|
||||
allowUnsafeExternalContent?: boolean;
|
||||
channel?:
|
||||
| "last"
|
||||
| "whatsapp"
|
||||
| "telegram"
|
||||
| "discord"
|
||||
| "irc"
|
||||
| "googlechat"
|
||||
| "slack"
|
||||
| "signal"
|
||||
| "imessage"
|
||||
| "msteams";
|
||||
channel?: ChannelId | "last";
|
||||
to?: string;
|
||||
/** Override model for this hook (provider/model or alias). */
|
||||
model?: string;
|
||||
@ -139,4 +132,3 @@ export type HooksConfig = {
|
||||
/** Internal agent event hooks */
|
||||
internal?: InternalHooksConfig;
|
||||
};
|
||||
import type { InstallRecordBase } from "./types.installs.js";
|
||||
|
||||
@ -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,6 +495,76 @@ function validateConfigObjectWithPluginsBase(
|
||||
}
|
||||
}
|
||||
|
||||
const builtInHookChannels = new Set<string>(["last"]);
|
||||
for (const channelId of CHANNEL_IDS) {
|
||||
builtInHookChannels.add(channelId.toLowerCase());
|
||||
}
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
if (typeof channel !== "string") {
|
||||
issues.push({ path, message: "hooks.mappings[].channel must be a string" });
|
||||
return;
|
||||
}
|
||||
const trimmed = channel.trim();
|
||||
if (!trimmed) {
|
||||
issues.push({ path, message: "hooks.mappings[].channel must not be empty" });
|
||||
return;
|
||||
}
|
||||
const normalized = normalizeChatChannelId(trimmed) ?? trimmed.toLowerCase();
|
||||
if (builtInHookChannels.has(normalized)) {
|
||||
return;
|
||||
}
|
||||
if (ensureEnabledHookPluginChannels().has(normalized)) {
|
||||
return;
|
||||
}
|
||||
issues.push({ path, message: `unknown hook mapping channel: ${trimmed}` });
|
||||
};
|
||||
|
||||
if (Array.isArray(config.hooks?.mappings)) {
|
||||
for (const [index, mapping] of config.hooks.mappings.entries()) {
|
||||
if (!mapping || typeof mapping !== "object") {
|
||||
continue;
|
||||
}
|
||||
validateHookMappingChannel(
|
||||
(mapping as { channel?: unknown }).channel,
|
||||
`hooks.mappings.${index}.channel`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasExplicitPluginsConfig) {
|
||||
if (issues.length > 0) {
|
||||
return { ok: false, issues, warnings };
|
||||
|
||||
@ -49,19 +49,7 @@ export const HookMappingSchema = z
|
||||
textTemplate: z.string().optional(),
|
||||
deliver: z.boolean().optional(),
|
||||
allowUnsafeExternalContent: z.boolean().optional(),
|
||||
channel: z
|
||||
.union([
|
||||
z.literal("last"),
|
||||
z.literal("whatsapp"),
|
||||
z.literal("telegram"),
|
||||
z.literal("discord"),
|
||||
z.literal("irc"),
|
||||
z.literal("slack"),
|
||||
z.literal("signal"),
|
||||
z.literal("imessage"),
|
||||
z.literal("msteams"),
|
||||
])
|
||||
.optional(),
|
||||
channel: z.string().optional(),
|
||||
to: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
thinking: z.string().optional(),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user