diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index bd8b2bf8606..192fd0f4a3f 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -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, { diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index 04e544f5fa9..1da837c777f 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -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 | 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 = 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`, }); } diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index c912d5b2a5c..0a280c0844e 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -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 | u if (!isRecord(value)) { return undefined; } - const normalized: Record = {}; + const normalized: Record = 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 | undefin if (!isRecord(value)) { return undefined; } - const normalized: Record = {}; + const normalized: Record = 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 = {}; + const normalized: Record = + 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 = {}; + const normalized: Record = 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;