diff --git a/CHANGELOG.md b/CHANGELOG.md index 286311a16d3..aeec5340289 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Google Meet: interrupt Realtime provider output when local barge-in clears playback, so command-pair audio stops model speech instead of only restarting Chrome playback. Fixes #73850. (#73834) Thanks @shhtheonlyperson. +- Gateway/config: cap oversized plugin-owned schemas in the full `config.schema` response so large installed plugin sets cannot balloon Gateway RSS or crash schema clients. Thanks @vincentkoc. - Gateway/sessions: use bounded tail reads for sessions-list transcript usage fallbacks, keeping large session stores responsive when rows request derived previews. Thanks @vincentkoc. - Gateway/chat: bound chat-history transcript reads to the requested display window so large session logs no longer OOM the Gateway when clients ask for a small history page. Thanks @vincentkoc. - Voice Call/Twilio: honor stored pre-connect TwiML before realtime webhook shortcuts and reject DTMF sequences outside conversation mode, so Meet PIN entry cannot be skipped or silently dropped. Thanks @donkeykong91 and @PfanP. diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 77ee73c8696..bdfd9557ae5 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -179,6 +179,63 @@ describe("config schema", () => { expect(res.uiHints["channels.matrix.accessToken"]?.sensitive).toBe(true); }); + it("omits a single oversized plugin schema from the full schema response", () => { + const res = buildConfigSchema({ + cache: false, + plugins: [ + { + id: "huge", + name: "Huge", + configSchema: { + type: "object", + properties: { + huge: { + type: "string", + description: `oversized-marker-${"x".repeat(300_000)}`, + }, + }, + }, + }, + ], + }); + + const serialized = JSON.stringify(res); + expect(serialized).not.toContain("oversized-marker"); + const lookup = lookupConfigSchema(res, "plugins.entries.huge.config"); + expect(lookup?.schema).toMatchObject({ + type: "object", + additionalProperties: true, + description: expect.stringContaining("omitted"), + }); + }); + + it("omits later plugin schemas after the aggregate extension schema budget is exhausted", () => { + const res = buildConfigSchema({ + cache: false, + plugins: Array.from({ length: 40 }, (_, index) => ({ + id: `plugin-${index}`, + configSchema: { + type: "object", + properties: { + value: { + type: "string", + description: `schema-${index}-${"x".repeat(60_000)}`, + }, + }, + }, + })), + }); + + const first = lookupConfigSchema(res, "plugins.entries.plugin-0.config.value"); + const last = lookupConfigSchema(res, "plugins.entries.plugin-39.config"); + expect(first?.schema).toMatchObject({ type: "string" }); + expect(last?.schema).toMatchObject({ + type: "object", + additionalProperties: true, + description: expect.stringContaining("omitted"), + }); + }); + it("looks up plugin config paths for slash-delimited plugin ids", () => { const res = buildConfigSchema({ plugins: [ diff --git a/src/config/schema.ts b/src/config/schema.ts index ead14626230..7d754409678 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -138,6 +138,71 @@ export type ChannelUiMetadata = { configUiHints?: Record; }; +const EXTENSION_SCHEMA_MAX_BYTES = 256 * 1024; +const EXTENSION_SCHEMA_TOTAL_MAX_BYTES = 2 * 1024 * 1024; +const EXTENSION_SCHEMA_MAX_ITEMS = 256; + +function schemaJsonBytes(schema: JsonSchemaNode): number { + try { + return Buffer.byteLength(JSON.stringify(schema), "utf-8"); + } catch { + return Number.POSITIVE_INFINITY; + } +} + +function buildOmittedExtensionConfigSchema(kind: "plugin" | "channel", id: string): JsonSchemaNode { + return { + type: "object", + additionalProperties: true, + description: `${kind} config schema for ${id} was omitted from the full config.schema response because installed extension schemas exceeded the Gateway response budget.`, + }; +} + +function limitExtensionSchemas(params: { + plugins: PluginUiMetadata[]; + channels: ChannelUiMetadata[]; +}): { plugins: PluginUiMetadata[]; channels: ChannelUiMetadata[] } { + let totalBytes = 0; + let includedItems = 0; + + const keepSchema = (schema: JsonSchemaNode): boolean => { + const bytes = schemaJsonBytes(schema); + if ( + !Number.isFinite(bytes) || + bytes > EXTENSION_SCHEMA_MAX_BYTES || + totalBytes + bytes > EXTENSION_SCHEMA_TOTAL_MAX_BYTES || + includedItems >= EXTENSION_SCHEMA_MAX_ITEMS + ) { + return false; + } + totalBytes += bytes; + includedItems += 1; + return true; + }; + + const plugins = params.plugins.map((plugin) => { + if (!plugin.configSchema || keepSchema(plugin.configSchema)) { + return plugin; + } + return { + ...plugin, + configSchema: buildOmittedExtensionConfigSchema("plugin", plugin.id), + }; + }); + + const channels = params.channels.map((channel) => { + if (!channel.configSchema || keepSchema(channel.configSchema)) { + return channel; + } + return { + ...channel, + configSchema: buildOmittedExtensionConfigSchema("channel", channel.id), + }; + }); + + return { plugins, channels }; +} + function collectExtensionHintKeys( hints: ConfigUiHints, plugins: PluginUiMetadata[], @@ -487,8 +552,10 @@ export function buildConfigSchema(params?: { cache?: boolean; }): ConfigSchemaResponse { const base = buildBaseConfigSchema(); - const plugins = params?.plugins ?? []; - const channels = params?.channels ?? []; + const { plugins, channels } = limitExtensionSchemas({ + plugins: params?.plugins ?? [], + channels: params?.channels ?? [], + }); if (plugins.length === 0 && channels.length === 0) { return base; }