mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 17:10:49 +00:00
fix(plugins): harden manifest channel metadata
This commit is contained in:
@@ -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, {
|
||||
|
||||
@@ -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`,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user