fix(config): sanitize plugin session hydration

This commit is contained in:
Vincent Koc
2026-05-16 10:56:03 +08:00
parent 72581fbb48
commit bb07cbc2be
3 changed files with 162 additions and 2 deletions

View File

@@ -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.

View File

@@ -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<string, SessionEntry>);
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({

View File

@@ -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<string, Record<string, PluginJsonValue>> = {};
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<string, PluginJsonValue> = {};
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<string, Record<string, string>> = {};
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<string, string> = {};
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<string, SessionEntry>): bool
continue;
}
const normalized = stripPersistedSkillsCache(
normalizePendingFinalDeliveryFields(
normalizeSessionEntryDelivery(normalizeSessionRuntimeModelFields(entry)),
normalizePluginExtensionSlotKeys(
normalizePluginExtensions(
normalizePendingFinalDeliveryFields(
normalizeSessionEntryDelivery(normalizeSessionRuntimeModelFields(entry)),
),
),
),
);
if (normalized !== entry) {