From bb07cbc2be202c45640dd7371bb6ad69b7317df7 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 16 May 2026 10:56:03 +0800 Subject: [PATCH] fix(config): sanitize plugin session hydration --- CHANGELOG.md | 1 + src/config/sessions/sessions.test.ts | 43 ++++++++++ src/config/sessions/store-load.ts | 120 ++++++++++++++++++++++++++- 3 files changed, 162 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff7c0ea8ccb..1484dbed294 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ Docs: https://docs.openclaw.ai - Gateway/update: keep shutdown hook-runner imports on a stable dist entry and ship a legacy chunk alias so package swaps do not strand running gateways on missing shutdown chunks. Fixes #81819. Thanks @najef1979-code. - Config persistence: ignore malformed array/scalar auth profile, cron job state, and session store entries instead of hydrating them into numeric profile ids, crashed cron rows, or invalid session records. - Config persistence: strip malformed pending final-delivery session fields on load so replay/recovery paths skip poisoned reply metadata instead of crashing on raw objects. +- Config persistence: strip malformed plugin extension state and promoted session-slot ownership on load so corrupted session rows do not leak poisoned plugin metadata into replay/projection paths. - Providers: reject malformed successful Runway, BytePlus, and Ollama embedding responses with provider-owned errors instead of raw parser/type failures, silent bad vectors, or long bogus polling. - Providers/images: reject malformed successful OpenAI-compatible, OpenAI, Google, fal, and OpenRouter image responses with provider-owned errors instead of raw shape failures, silent invalid base64 skips, or empty image results. - Providers/videos: reject malformed successful xAI, OpenRouter, and fal video create, poll, and result responses with provider-owned errors instead of raw parser failures or long bogus polling. diff --git a/src/config/sessions/sessions.test.ts b/src/config/sessions/sessions.test.ts index ac8e0621af0..5b167a7ae92 100644 --- a/src/config/sessions/sessions.test.ts +++ b/src/config/sessions/sessions.test.ts @@ -410,6 +410,49 @@ describe("session store writer queue", () => { }); }); + it("strips malformed plugin extension state on load", async () => { + const { storePath } = await makeTmpStore({ + "agent:main:plugins": { + sessionId: "s-plugins", + updatedAt: 100, + pluginExtensions: { + " workflow-plugin ": { + " approval ": { state: "waiting" }, + scalar: true, + huge: "x".repeat(70 * 1024), + emptyNamespace: undefined, + }, + "bad-shape": "not-a-namespace-record", + "array-shape": [{ workflow: { state: "bad" } }], + "": { workflow: { state: "bad" } }, + }, + pluginExtensionSlotKeys: { + " workflow-plugin ": { + " approval ": " approvalSnapshot ", + reserved: "sessionId", + dotted: "approval.snapshot", + empty: "", + }, + "bad-shape": "approvalSnapshot", + "": { workflow: "ignoredSlot" }, + }, + }, + } as unknown as Record); + + const store = loadSessionStore(storePath, { skipCache: true }); + expect(store["agent:main:plugins"]?.pluginExtensions).toEqual({ + "workflow-plugin": { + approval: { state: "waiting" }, + scalar: true, + }, + }); + expect(store["agent:main:plugins"]?.pluginExtensionSlotKeys).toEqual({ + "workflow-plugin": { + approval: "approvalSnapshot", + }, + }); + }); + it("skips session store disk writes when payload is unchanged", async () => { const key = "agent:main:no-op-save"; const { storePath } = await makeTmpStore({ diff --git a/src/config/sessions/store-load.ts b/src/config/sessions/store-load.ts index 08e341e6a97..4275a6f8d3c 100644 --- a/src/config/sessions/store-load.ts +++ b/src/config/sessions/store-load.ts @@ -1,5 +1,7 @@ import fs from "node:fs"; import { createSubsystemLogger } from "../../logging/subsystem.js"; +import { isPluginJsonValue, type PluginJsonValue } from "../../plugins/host-hook-json.js"; +import { normalizeSessionEntrySlotKey } from "../../plugins/session-entry-slot-keys.js"; import { normalizeDeliveryContext, normalizeSessionDeliveryFields, @@ -59,6 +61,11 @@ function normalizeOptionalStringOrNull(value: unknown): string | null | undefine return undefined; } +function normalizeRecordKey(value: string): string | undefined { + const key = value.trim(); + return key.length > 0 ? key : undefined; +} + function normalizeOptionalDeliveryContext( value: unknown, ): SessionEntry["pendingFinalDeliveryContext"] { @@ -138,6 +145,111 @@ function normalizePendingFinalDeliveryFields(entry: SessionEntry): SessionEntry return next; } +function normalizePluginExtensions(entry: SessionEntry): SessionEntry { + if (entry.pluginExtensions === undefined) { + return entry; + } + if (!isRecord(entry.pluginExtensions)) { + const next = { ...entry }; + delete next.pluginExtensions; + return next; + } + + let changed = false; + const normalizedExtensions: Record> = {}; + for (const [rawPluginId, rawPluginState] of Object.entries(entry.pluginExtensions)) { + const pluginId = normalizeRecordKey(rawPluginId); + if (!pluginId || !isRecord(rawPluginState)) { + changed = true; + continue; + } + if (pluginId !== rawPluginId) { + changed = true; + } + const normalizedPluginState: Record = {}; + for (const [rawNamespace, rawValue] of Object.entries(rawPluginState)) { + const namespace = normalizeRecordKey(rawNamespace); + if (!namespace || !isPluginJsonValue(rawValue)) { + changed = true; + continue; + } + if (namespace !== rawNamespace) { + changed = true; + } + normalizedPluginState[namespace] = rawValue; + } + if (Object.keys(normalizedPluginState).length === 0) { + changed = true; + continue; + } + normalizedExtensions[pluginId] = normalizedPluginState; + } + + if (!changed) { + return entry; + } + const next = { ...entry }; + if (Object.keys(normalizedExtensions).length > 0) { + next.pluginExtensions = normalizedExtensions; + } else { + delete next.pluginExtensions; + } + return next; +} + +function normalizePluginExtensionSlotKeys(entry: SessionEntry): SessionEntry { + if (entry.pluginExtensionSlotKeys === undefined) { + return entry; + } + if (!isRecord(entry.pluginExtensionSlotKeys)) { + const next = { ...entry }; + delete next.pluginExtensionSlotKeys; + return next; + } + + let changed = false; + const normalizedSlotKeys: Record> = {}; + for (const [rawPluginId, rawPluginSlots] of Object.entries(entry.pluginExtensionSlotKeys)) { + const pluginId = normalizeRecordKey(rawPluginId); + if (!pluginId || !isRecord(rawPluginSlots)) { + changed = true; + continue; + } + if (pluginId !== rawPluginId) { + changed = true; + } + const normalizedPluginSlots: Record = {}; + for (const [rawNamespace, rawSlotKey] of Object.entries(rawPluginSlots)) { + const namespace = normalizeRecordKey(rawNamespace); + const slotKey = normalizeSessionEntrySlotKey(rawSlotKey); + if (!namespace || !slotKey.ok) { + changed = true; + continue; + } + if (namespace !== rawNamespace || slotKey.key !== rawSlotKey) { + changed = true; + } + normalizedPluginSlots[namespace] = slotKey.key; + } + if (Object.keys(normalizedPluginSlots).length === 0) { + changed = true; + continue; + } + normalizedSlotKeys[pluginId] = normalizedPluginSlots; + } + + if (!changed) { + return entry; + } + const next = { ...entry }; + if (Object.keys(normalizedSlotKeys).length > 0) { + next.pluginExtensionSlotKeys = normalizedSlotKeys; + } else { + delete next.pluginExtensionSlotKeys; + } + return next; +} + function normalizeSessionEntryDelivery(entry: SessionEntry): SessionEntry { const normalized = normalizeSessionDeliveryFields({ channel: entry.channel, @@ -195,8 +307,12 @@ export function normalizeSessionStore(store: Record): bool continue; } const normalized = stripPersistedSkillsCache( - normalizePendingFinalDeliveryFields( - normalizeSessionEntryDelivery(normalizeSessionRuntimeModelFields(entry)), + normalizePluginExtensionSlotKeys( + normalizePluginExtensions( + normalizePendingFinalDeliveryFields( + normalizeSessionEntryDelivery(normalizeSessionRuntimeModelFields(entry)), + ), + ), ), ); if (normalized !== entry) {