fix(plugins): harden manifest channel metadata

This commit is contained in:
Vincent Koc
2026-04-24 17:57:56 -07:00
parent b33eb93aac
commit 3a14a95085
3 changed files with 148 additions and 24 deletions

View File

@@ -510,6 +510,34 @@ describe("loadPluginManifestRegistry", () => {
);
});
it("sanitizes manifest-controlled fields in provider auth compatibility diagnostics", () => {
const dir = makeTempDir();
const lineBreak = String.fromCharCode(10);
const ansiRed = `${String.fromCharCode(27)}[31m`;
writeManifest(dir, {
id: `external${lineBreak}openai${ansiRed}`,
providers: ["openai"],
providerAuthEnvVars: {
[`openai${lineBreak}${ansiRed}`]: ["OPENAI_API_KEY"],
},
configSchema: { type: "object" },
});
const registry = loadSingleCandidateRegistry({
idHint: "external-openai",
rootDir: dir,
origin: "global",
});
const diagnostic = registry.diagnostics.find((entry) =>
entry.message.includes("providerAuthEnvVars is deprecated compatibility metadata"),
);
expect(diagnostic?.pluginId).toBe("externalopenai");
expect(diagnostic?.message).toContain("openai");
expect(diagnostic?.message).not.toContain(lineBreak);
expect(diagnostic?.message).not.toContain(ansiRed);
});
it("reports non-bundled channel manifests without channel config descriptors", () => {
const dir = makeTempDir();
writeManifest(dir, {
@@ -535,6 +563,31 @@ describe("loadPluginManifestRegistry", () => {
);
});
it("sanitizes manifest-controlled fields in channel config descriptor diagnostics", () => {
const dir = makeTempDir();
const lineBreak = String.fromCharCode(10);
const ansiRed = `${String.fromCharCode(27)}[31m`;
writeManifest(dir, {
id: `external${lineBreak}chat${ansiRed}`,
channels: [`external${lineBreak}channel${ansiRed}`],
configSchema: { type: "object" },
});
const registry = loadSingleCandidateRegistry({
idHint: "external-chat",
rootDir: dir,
origin: "global",
});
const diagnostic = registry.diagnostics.find((entry) =>
entry.message.includes("without channelConfigs metadata"),
);
expect(diagnostic?.pluginId).toBe("externalchat");
expect(diagnostic?.message).toContain("externalchannel");
expect(diagnostic?.message).not.toContain(lineBreak);
expect(diagnostic?.message).not.toContain(ansiRed);
});
it("accepts non-bundled channel manifests with channel config descriptors", () => {
const dir = makeTempDir();
writeManifest(dir, {
@@ -571,6 +624,58 @@ describe("loadPluginManifestRegistry", () => {
).toBe(false);
});
it("drops prototype-polluting channel config keys from plugin manifests", () => {
const dir = makeTempDir();
writeTextFile(
dir,
"openclaw.plugin.json",
JSON.stringify({
id: "external-chat",
channels: ["safe-chat"],
configSchema: { type: "object" },
channelConfigs: {
["__proto__"]: {
schema: {
type: "object",
properties: {
polluted: { const: true },
},
},
},
constructor: {
schema: { type: "object" },
},
prototype: {
schema: { type: "object" },
},
"safe-chat": {
schema: {
type: "object",
additionalProperties: false,
},
},
},
}),
);
const registry = loadSingleCandidateRegistry({
idHint: "external-chat",
rootDir: dir,
origin: "global",
});
const channelConfigs = registry.plugins[0]?.channelConfigs;
expect(channelConfigs).toBeDefined();
expect(Object.getPrototypeOf(channelConfigs)).toBe(null);
expect(Object.prototype.hasOwnProperty.call(channelConfigs, "__proto__")).toBe(false);
expect(Object.prototype.hasOwnProperty.call(channelConfigs, "constructor")).toBe(false);
expect(Object.prototype.hasOwnProperty.call(channelConfigs, "prototype")).toBe(false);
expect(channelConfigs?.["safe-chat"]?.schema).toMatchObject({
type: "object",
additionalProperties: false,
});
});
it("falls back providerDiscoverySource from .ts to emitted .js files", () => {
const dir = makeTempDir();
writeManifest(dir, {

View File

@@ -1,11 +1,13 @@
import fs from "node:fs";
import path from "node:path";
import type { OpenClawConfig } from "../config/types.js";
import { isBlockedObjectKey } from "../infra/prototype-keys.js";
import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "../shared/string-coerce.js";
import { normalizeOptionalTrimmedStringList } from "../shared/string-normalization.js";
import { sanitizeForLog } from "../terminal/ansi.js";
import { resolveUserPath } from "../utils.js";
import { resolveCompatibilityHostVersion } from "../version.js";
import { loadBundleManifest } from "./bundle-manifest.js";
@@ -306,26 +308,38 @@ function mergePackageChannelMetaIntoChannelConfigs(params: {
packageChannel?: OpenClawPackageManifest["channel"];
}): Record<string, PluginManifestChannelConfig> | undefined {
const channelId = params.packageChannel?.id?.trim();
if (!channelId || !params.channelConfigs?.[channelId]) {
if (
!channelId ||
isBlockedObjectKey(channelId) ||
!params.channelConfigs ||
!Object.prototype.hasOwnProperty.call(params.channelConfigs, channelId)
) {
return params.channelConfigs;
}
const existing = params.channelConfigs[channelId];
if (!existing) {
return params.channelConfigs;
}
const label = existing.label ?? normalizeOptionalString(params.packageChannel?.label) ?? "";
const description =
existing.description ?? normalizeOptionalString(params.packageChannel?.blurb) ?? "";
const preferOver =
existing.preferOver ?? normalizePreferredPluginIds(params.packageChannel?.preferOver);
return {
...params.channelConfigs,
[channelId]: {
...existing,
...(label ? { label } : {}),
...(description ? { description } : {}),
...(preferOver?.length ? { preferOver } : {}),
},
const merged: Record<string, PluginManifestChannelConfig> = Object.create(null);
for (const [key, value] of Object.entries(params.channelConfigs)) {
if (!isBlockedObjectKey(key)) {
merged[key] = value;
}
}
merged[channelId] = {
...existing,
...(label ? { label } : {}),
...(description ? { description } : {}),
...(preferOver?.length ? { preferOver } : {}),
};
return merged;
}
function buildRecord(params: {
@@ -468,9 +482,9 @@ function pushProviderAuthEnvVarsCompatDiagnostic(params: {
}
params.diagnostics.push({
level: "warn",
pluginId: params.record.id,
source: params.record.manifestPath,
message: `providerAuthEnvVars is deprecated compatibility metadata for provider env-var lookup; mirror ${providerIds.join(", ")} env vars to setup.providers[].envVars before the deprecation window closes`,
pluginId: sanitizeForLog(params.record.id),
source: sanitizeForLog(params.record.manifestPath),
message: `providerAuthEnvVars is deprecated compatibility metadata for provider env-var lookup; mirror ${providerIds.map(sanitizeForLog).join(", ")} env vars to setup.providers[].envVars before the deprecation window closes`,
});
}
@@ -488,15 +502,18 @@ function pushNonBundledChannelConfigDescriptorDiagnostic(params: {
return;
}
const channelConfigs = params.record.channelConfigs ?? {};
const missingChannels = declaredChannels.filter((channelId) => !channelConfigs[channelId]);
const missingChannels = declaredChannels.filter(
(channelId) => !Object.prototype.hasOwnProperty.call(channelConfigs, channelId),
);
if (missingChannels.length === 0) {
return;
}
const safeMissingChannels = missingChannels.map(sanitizeForLog);
params.diagnostics.push({
level: "warn",
pluginId: params.record.id,
source: params.record.manifestPath,
message: `channel plugin manifest declares ${missingChannels.join(", ")} without channelConfigs metadata; add openclaw.plugin.json#channelConfigs so config schema and setup surfaces work before runtime loads`,
pluginId: sanitizeForLog(params.record.id),
source: sanitizeForLog(params.record.manifestPath),
message: `channel plugin manifest declares ${safeMissingChannels.join(", ")} without channelConfigs metadata; add openclaw.plugin.json#channelConfigs so config schema and setup surfaces work before runtime loads`,
});
}

View File

@@ -4,6 +4,7 @@ import JSON5 from "json5";
import type { ChannelConfigRuntimeSchema } from "../channels/plugins/types.config.js";
import { MANIFEST_KEY } from "../compat/legacy-names.js";
import { matchBoundaryFileOpenFailure, openBoundaryFileSync } from "../infra/boundary-file-read.js";
import { isBlockedObjectKey } from "../infra/prototype-keys.js";
import type { JsonSchemaObject } from "../shared/json-schema.types.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import { normalizeTrimmedStringList } from "../shared/string-normalization.js";
@@ -312,10 +313,10 @@ function normalizeStringListRecord(value: unknown): Record<string, string[]> | u
if (!isRecord(value)) {
return undefined;
}
const normalized: Record<string, string[]> = {};
const normalized: Record<string, string[]> = Object.create(null);
for (const [key, rawValues] of Object.entries(value)) {
const providerId = normalizeOptionalString(key) ?? "";
if (!providerId) {
if (!providerId || isBlockedObjectKey(providerId)) {
continue;
}
const values = normalizeTrimmedStringList(rawValues);
@@ -331,11 +332,11 @@ function normalizeStringRecord(value: unknown): Record<string, string> | undefin
if (!isRecord(value)) {
return undefined;
}
const normalized: Record<string, string> = {};
const normalized: Record<string, string> = Object.create(null);
for (const [rawKey, rawValue] of Object.entries(value)) {
const key = normalizeOptionalString(rawKey) ?? "";
const value = normalizeOptionalString(rawValue) ?? "";
if (!key || !value) {
if (!key || isBlockedObjectKey(key) || !value) {
continue;
}
normalized[key] = value;
@@ -404,10 +405,11 @@ function normalizeMediaUnderstandingProviderMetadata(
if (!isRecord(value)) {
return undefined;
}
const normalized: Record<string, PluginManifestMediaUnderstandingProviderMetadata> = {};
const normalized: Record<string, PluginManifestMediaUnderstandingProviderMetadata> =
Object.create(null);
for (const [rawProviderId, rawMetadata] of Object.entries(value)) {
const providerId = normalizeOptionalString(rawProviderId) ?? "";
if (!providerId || !isRecord(rawMetadata)) {
if (!providerId || isBlockedObjectKey(providerId) || !isRecord(rawMetadata)) {
continue;
}
const capabilities = normalizeMediaUnderstandingCapabilities(rawMetadata.capabilities);
@@ -769,10 +771,10 @@ function normalizeChannelConfigs(
if (!isRecord(value)) {
return undefined;
}
const normalized: Record<string, PluginManifestChannelConfig> = {};
const normalized: Record<string, PluginManifestChannelConfig> = Object.create(null);
for (const [key, rawEntry] of Object.entries(value)) {
const channelId = normalizeOptionalString(key) ?? "";
if (!channelId || !isRecord(rawEntry)) {
if (!channelId || isBlockedObjectKey(channelId) || !isRecord(rawEntry)) {
continue;
}
const schema = isRecord(rawEntry.schema) ? rawEntry.schema : null;