From f693ac1b652776e69613bca610aa49a73a19a582 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 14 Jul 2026 01:01:54 +0100 Subject: [PATCH] refactor(config): share canonical deep merge --- CHANGELOG.md | 1 + .../.generated/plugin-sdk-api-baseline.sha256 | 4 +- docs/plugins/sdk-migration.md | 2 +- docs/plugins/sdk-runtime.md | 2 +- docs/plugins/sdk-subpaths.md | 2 +- extensions/voice-call/npm-shrinkwrap.json | 7 -- extensions/voice-call/package.json | 1 - extensions/voice-call/src/config.ts | 4 +- extensions/voice-call/src/deep-merge.test.ts | 47 ---------- extensions/voice-call/src/deep-merge.ts | 59 ------------- extensions/voice-call/src/telephony-tts.ts | 4 +- npm-shrinkwrap.json | 7 -- package.json | 1 - pnpm-lock.yaml | 6 -- scripts/plugin-sdk-surface-report.mjs | 4 +- src/infra/deep-merge.ts | 85 +++++-------------- src/plugin-sdk/plugin-config-runtime.ts | 1 + 17 files changed, 36 insertions(+), 201 deletions(-) delete mode 100644 extensions/voice-call/src/deep-merge.test.ts delete mode 100644 extensions/voice-call/src/deep-merge.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bfbf10b238f..a23d21ab5f39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Changes +- **Plugin config merging:** expose the canonical deep-merge policy through the plugin SDK, migrate Voice Call to it, and remove the duplicate merge engine and direct `defu` dependency. - **Skill Workshop history review:** add a manual, newest-first session scan that progressively searches older substantial work for conservative skill ideas, stores only SQLite cursor metadata, and leaves up to three results as pending proposals even when autonomous self-learning is disabled. (#106182) - **SQLite snapshots:** add `openclaw backup sqlite create|list|verify|restore` for compact, verified global and per-agent database artifacts with fresh-target-only restore. (#94805) Thanks @giodl73-repo. - **GPT-5.6 Ultra and runtime switching:** support Sol, Terra, and Luna across OpenClaw and Codex engines; keep model, runtime, and thinking selection atomic through `/model` and fallback; and add live matrix coverage for both harnesses. (#98021) Thanks @anyech. diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 8801f6b1d585..9a7100da46a6 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -9bc00d1b67cf1902b3deb36a79553684cccd0cf5d405e9bf0373065ec1e1005f plugin-sdk-api-baseline.json -517cab00f614e15bf68b68468b0398c9faae6a8035dbfd7512e6527d815046c5 plugin-sdk-api-baseline.jsonl +e511874a61fa05dc67613e171fb9617102525db3efbe4c7240da0d3e84c270d8 plugin-sdk-api-baseline.json +575271760452cbd7da8fb57eef8ffce527254ccb876aaefd850c13b9522dd744 plugin-sdk-api-baseline.jsonl diff --git a/docs/plugins/sdk-migration.md b/docs/plugins/sdk-migration.md index 0b73ae91d532..ac445648793b 100644 --- a/docs/plugins/sdk-migration.md +++ b/docs/plugins/sdk-migration.md @@ -153,7 +153,7 @@ SDK. | Need | Import | | --- | --- | | Config types such as `OpenClawConfig` | `openclaw/plugin-sdk/config-contracts` | - | Already-loaded config assertions and plugin-entry config lookup | `openclaw/plugin-sdk/plugin-config-runtime` | + | Already-loaded config assertions, plugin-entry config lookup, and config merging | `openclaw/plugin-sdk/plugin-config-runtime` | | Current runtime snapshot reads | `openclaw/plugin-sdk/runtime-config-snapshot` | | Config writes | `openclaw/plugin-sdk/config-mutation` | | Session store helpers | `openclaw/plugin-sdk/session-store-runtime` | diff --git a/docs/plugins/sdk-runtime.md b/docs/plugins/sdk-runtime.md index 6b3259645c99..eb89aabbe810 100644 --- a/docs/plugins/sdk-runtime.md +++ b/docs/plugins/sdk-runtime.md @@ -47,7 +47,7 @@ The mutation helpers return `afterWrite` plus a typed `followUp` summary so call `api.runtime.config.loadConfig()` and `api.runtime.config.writeConfigFile(...)` are deprecated. They warn once per plugin at runtime and remain available only for old external plugins during the migration window. Bundled plugins must not use them: an internal config boundary guard fails the build if plugin code calls them or imports those helpers from plugin SDK subpaths. Use `current()`, a passed-in `cfg`, `mutateConfigFile(...)`, or `replaceConfigFile(...)` instead. -For direct SDK imports, prefer the focused config subpaths over the broad `openclaw/plugin-sdk/config-runtime` compatibility barrel: `config-contracts` for types, `plugin-config-runtime` for already-loaded config assertions and plugin entry lookup, `runtime-config-snapshot` for current process snapshots, and `config-mutation` for writes. Bundled plugin tests should mock these focused subpaths directly instead of mocking the broad compatibility barrel. +For direct SDK imports, prefer the focused config subpaths over the broad `openclaw/plugin-sdk/config-runtime` compatibility barrel: `config-contracts` for types, `plugin-config-runtime` for already-loaded config assertions, plugin entry lookup, and canonical config merging, `runtime-config-snapshot` for current process snapshots, and `config-mutation` for writes. Bundled plugin tests should mock these focused subpaths directly instead of mocking the broad compatibility barrel. Internal OpenClaw runtime code follows the same direction: load config once at the CLI, gateway, or process boundary, then pass that value through. Successful mutation writes refresh the process runtime snapshot and advance its internal revision; long-lived caches should key off the runtime-owned cache key instead of serializing config locally. Long-lived runtime modules have a zero-tolerance scanner for ambient `loadConfig()` calls; use a passed `cfg`, a request `context.getRuntimeConfig()`, or `getRuntimeConfig()` at an explicit process boundary. diff --git a/docs/plugins/sdk-subpaths.md b/docs/plugins/sdk-subpaths.md index eebb60e10e6b..a0cc92cece75 100644 --- a/docs/plugins/sdk-subpaths.md +++ b/docs/plugins/sdk-subpaths.md @@ -263,7 +263,7 @@ usage endpoint failed or returned no usable usage data. | `plugin-sdk/gateway-method-runtime` | Reserved Gateway method dispatch helper for plugin HTTP routes that declare `contracts.gatewayMethodDispatch: ["authenticated-request"]` | | `plugin-sdk/gateway-runtime` | Gateway client, event-loop-ready client start helper, gateway CLI RPC, gateway protocol errors, advertised LAN host resolution, and channel-status patch helpers | | `plugin-sdk/config-contracts` | Focused type-only config surface for plugin config shapes such as `OpenClawConfig` and channel/provider config types | - | `plugin-sdk/plugin-config-runtime` | Runtime plugin-config lookup helpers such as `requireRuntimeConfig`, `resolvePluginConfigObject`, and `resolveLivePluginConfigObject` | + | `plugin-sdk/plugin-config-runtime` | Runtime plugin-config helpers such as `mergeDeep`, `requireRuntimeConfig`, `resolvePluginConfigObject`, and `resolveLivePluginConfigObject` | | `plugin-sdk/config-mutation` | Transactional config mutation helpers such as `mutateConfigFile`, `replaceConfigFile`, and `logConfigUpdated` | | `plugin-sdk/message-tool-delivery-hints` | Shared message-tool delivery metadata hint strings | | `plugin-sdk/runtime-config-snapshot` | Current process config snapshot helpers such as `getRuntimeConfig`, `getRuntimeConfigSnapshot`, and test snapshot setters | diff --git a/extensions/voice-call/npm-shrinkwrap.json b/extensions/voice-call/npm-shrinkwrap.json index 7de1357c5c47..7871b739c50f 100644 --- a/extensions/voice-call/npm-shrinkwrap.json +++ b/extensions/voice-call/npm-shrinkwrap.json @@ -8,7 +8,6 @@ "name": "@openclaw/voice-call", "version": "2026.7.2", "dependencies": { - "defu": "6.1.5", "typebox": "1.3.3", "ws": "8.21.0", "zod": "4.4.3" @@ -22,12 +21,6 @@ } } }, - "node_modules/defu": { - "version": "6.1.5", - "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.5.tgz", - "integrity": "sha512-pwdBJxJuJXmqrLO6s0VBmfbRz+G7FUzkjldAsdi9Yrv86mPyzq0ll1o8+8gB4Gsr6GJHbK1Lh3ngllgTInDCjA==", - "license": "MIT" - }, "node_modules/typebox": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.3.tgz", diff --git a/extensions/voice-call/package.json b/extensions/voice-call/package.json index f84ad8f6ae31..4444a99c0cce 100644 --- a/extensions/voice-call/package.json +++ b/extensions/voice-call/package.json @@ -8,7 +8,6 @@ }, "type": "module", "dependencies": { - "defu": "6.1.5", "typebox": "1.3.3", "ws": "8.21.0", "zod": "4.4.3" diff --git a/extensions/voice-call/src/config.ts b/extensions/voice-call/src/config.ts index 4739e2c71153..12b1fc5ec6e6 100644 --- a/extensions/voice-call/src/config.ts +++ b/extensions/voice-call/src/config.ts @@ -1,4 +1,5 @@ // Voice Call helper module supports config behavior. +import { mergeDeep } from "openclaw/plugin-sdk/plugin-config-runtime"; import { REALTIME_VOICE_AGENT_CONSULT_TOOL_POLICIES } from "openclaw/plugin-sdk/realtime-voice"; import { normalizeAgentId, parseAgentSessionKey } from "openclaw/plugin-sdk/routing"; import { @@ -14,7 +15,6 @@ import { import { normalizeWebhookPath } from "openclaw/plugin-sdk/webhook-ingress"; import { z } from "zod"; import { TtsConfigSchema } from "../api.js"; -import { deepMergeDefined } from "./deep-merge.js"; import { TWILIO_REGIONS } from "./providers/twilio-region.js"; import { DEFAULT_VOICE_CALL_REALTIME_INSTRUCTIONS } from "./realtime-defaults.js"; @@ -550,7 +550,7 @@ function normalizeVoiceCallTtsConfig( return undefined; } - return TtsConfigSchema.parse(deepMergeDefined(defaults ?? {}, overrides ?? {})); + return TtsConfigSchema.parse(mergeDeep(defaults ?? {}, overrides ?? {})); } function normalizePhoneRouteKey(phone: string | undefined): string { diff --git a/extensions/voice-call/src/deep-merge.test.ts b/extensions/voice-call/src/deep-merge.test.ts deleted file mode 100644 index 80396912e14d..000000000000 --- a/extensions/voice-call/src/deep-merge.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -// Voice Call tests cover deep merge plugin behavior. -import { describe, expect, it } from "vitest"; -import { deepMergeDefined } from "./deep-merge.js"; - -describe("deepMergeDefined", () => { - it("deep merges nested plain objects and preserves base values for undefined overrides", () => { - expect( - deepMergeDefined( - { - provider: { voice: "alloy", language: "en" }, - enabled: true, - }, - { - provider: { voice: "echo", language: undefined, nullable: null }, - enabled: undefined, - introduced: { nullable: null }, - }, - ), - ).toEqual({ - provider: { voice: "echo", language: "en", nullable: null }, - enabled: true, - introduced: { nullable: null }, - }); - }); - - it("replaces non-objects directly and blocks dangerous prototype keys", () => { - expect(deepMergeDefined(["a"], ["b"])).toEqual(["b"]); - expect(deepMergeDefined({ values: ["a"] }, { values: ["b"] })).toEqual({ values: ["b"] }); - expect(deepMergeDefined("base", undefined)).toBe("base"); - expect( - deepMergeDefined( - { safe: { keep: true } }, - { - safe: { next: true }, - __proto__: { polluted: true }, - constructor: { polluted: true }, - prototype: { polluted: true }, - }, - ), - ).toEqual({ - safe: { keep: true, next: true }, - }); - expect(deepMergeDefined({ value: ["base"] }, { value: { enabled: true } })).toEqual({ - value: { enabled: true }, - }); - }); -}); diff --git a/extensions/voice-call/src/deep-merge.ts b/extensions/voice-call/src/deep-merge.ts deleted file mode 100644 index e6e0e11be99f..000000000000 --- a/extensions/voice-call/src/deep-merge.ts +++ /dev/null @@ -1,59 +0,0 @@ -// Voice Call plugin module implements deep merge behavior. -import defu from "defu"; -import { isRecord as isPlainObject } from "openclaw/plugin-sdk/string-coerce-runtime"; - -// Prototype-safe deep merge for config overrides that ignores undefined values. - -const BLOCKED_MERGE_KEYS = new Set(["__proto__", "prototype", "constructor"]); -const NULL_OVERRIDE = Symbol("null-override"); - -class ArrayOverride { - constructor(readonly value: unknown[]) {} -} - -function prepareObject( - value: Record, - encodeNull: boolean, -): Record { - const prepared: Record = {}; - for (const [key, entry] of Object.entries(value)) { - if (BLOCKED_MERGE_KEYS.has(key)) { - continue; - } - if (encodeNull && entry === null) { - prepared[key] = NULL_OVERRIDE; - } else if (encodeNull && Array.isArray(entry)) { - // Defu concatenates arrays. A non-plain wrapper preserves the plugin's - // existing replacement policy until the merged result is decoded. - prepared[key] = new ArrayOverride(entry); - } else if (isPlainObject(entry)) { - prepared[key] = prepareObject(entry, encodeNull); - } else { - prepared[key] = entry; - } - } - return prepared; -} - -function decodeNullOverrides(value: unknown): unknown { - if (value instanceof ArrayOverride) { - return value.value; - } - if (value === NULL_OVERRIDE) { - return null; - } - if (!isPlainObject(value)) { - return value; - } - return Object.fromEntries( - Object.entries(value).map(([key, entry]) => [key, decodeNullOverrides(entry)]), - ); -} - -/** Deep-merge plain objects, keeping base values when overrides are undefined. */ -export function deepMergeDefined(base: unknown, override: unknown): unknown { - if (!isPlainObject(base) || !isPlainObject(override)) { - return override === undefined ? base : override; - } - return decodeNullOverrides(defu(prepareObject(override, true), prepareObject(base, false))); -} diff --git a/extensions/voice-call/src/telephony-tts.ts b/extensions/voice-call/src/telephony-tts.ts index 7f296e535c8d..016e2ee4e468 100644 --- a/extensions/voice-call/src/telephony-tts.ts +++ b/extensions/voice-call/src/telephony-tts.ts @@ -1,5 +1,6 @@ // Voice Call plugin module implements telephony tts behavior. import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime"; +import { mergeDeep } from "openclaw/plugin-sdk/plugin-config-runtime"; import { parseTtsDirectives, type SpeechModelOverridePolicy, @@ -8,7 +9,6 @@ import { } from "openclaw/plugin-sdk/speech"; import type { VoiceCallTtsConfig } from "./config.js"; import type { CoreConfig } from "./core-bridge.js"; -import { deepMergeDefined } from "./deep-merge.js"; import { convertPcmToMulaw8k } from "./telephony-audio.js"; // Telephony TTS adapter that applies voice-call overrides and emits 8kHz mulaw audio. @@ -150,7 +150,7 @@ function mergeTtsConfig( if (!base) { return override; } - return deepMergeDefined(base, override) as VoiceCallTtsConfig; + return mergeDeep(base, override) as VoiceCallTtsConfig; } /** Resolve directive override policy for telephony synthesis. */ diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 57fa5219aa5d..9942195919ca 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -31,7 +31,6 @@ "clawpdf": "0.3.0", "commander": "15.0.0", "croner": "10.0.1", - "defu": "6.1.5", "diff": "9.0.0", "dotenv": "17.4.2", "entities": "8.0.0", @@ -1119,12 +1118,6 @@ "node": ">=0.10.0" } }, - "node_modules/defu": { - "version": "6.1.5", - "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.5.tgz", - "integrity": "sha512-pwdBJxJuJXmqrLO6s0VBmfbRz+G7FUzkjldAsdi9Yrv86mPyzq0ll1o8+8gB4Gsr6GJHbK1Lh3ngllgTInDCjA==", - "license": "MIT" - }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", diff --git a/package.json b/package.json index de421ebca610..fe53363a3da8 100644 --- a/package.json +++ b/package.json @@ -2024,7 +2024,6 @@ "clawpdf": "0.3.0", "commander": "15.0.0", "croner": "10.0.1", - "defu": "6.1.5", "diff": "9.0.0", "dotenv": "17.4.2", "entities": "8.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d705ab732c8..44d7c726aa6d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -109,9 +109,6 @@ importers: croner: specifier: 10.0.1 version: 10.0.1 - defu: - specifier: 6.1.5 - version: 6.1.5 diff: specifier: 9.0.0 version: 9.0.0 @@ -1803,9 +1800,6 @@ importers: extensions/voice-call: dependencies: - defu: - specifier: 6.1.5 - version: 6.1.5 typebox: specifier: 1.3.3 version: 1.3.3 diff --git a/scripts/plugin-sdk-surface-report.mjs b/scripts/plugin-sdk-surface-report.mjs index 2c0027fd7858..5bdeb6090f8f 100644 --- a/scripts/plugin-sdk-surface-report.mjs +++ b/scripts/plugin-sdk-surface-report.mjs @@ -204,12 +204,12 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { ), publicExports: readPluginSdkSurfaceBudgetEnv( "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", - 10648, + 10649, env, ), publicFunctionExports: readPluginSdkSurfaceBudgetEnv( "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", - 5360, + 5361, env, ), publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv( diff --git a/src/infra/deep-merge.ts b/src/infra/deep-merge.ts index 031c78d391fd..bf2a07c424b7 100644 --- a/src/infra/deep-merge.ts +++ b/src/infra/deep-merge.ts @@ -1,4 +1,3 @@ -import defu from "defu"; import { isPlainObject } from "./plain-object.js"; import { isBlockedObjectKey } from "./prototype-keys.js"; @@ -7,67 +6,15 @@ type DeepMergeOptions = { undefinedValues?: "skip" | "replace"; }; -const NULL_OVERRIDE = Symbol("null-override"); -const UNDEFINED_OVERRIDE = Symbol("undefined-override"); - -class ArrayOverride { - constructor(readonly value: unknown[]) {} -} - -function prepareObject( - value: Record, - undefinedValues: "skip" | "replace", - encodeOverrides: boolean, - arrays: "replace" | "concat", - base?: Record, -): Record { - const prepared: Record = {}; +function sanitizePlainObject(value: Record): Record { + const sanitized: Record = {}; for (const [key, entry] of Object.entries(value)) { if (isBlockedObjectKey(key)) { continue; } - const baseEntry = base?.[key]; - if (encodeOverrides && entry === null) { - prepared[key] = NULL_OVERRIDE; - } else if (encodeOverrides && entry === undefined && undefinedValues === "replace") { - prepared[key] = UNDEFINED_OVERRIDE; - } else if (encodeOverrides && Array.isArray(entry)) { - // Defu concatenates arrays source-first. A non-plain wrapper carries the - // caller's replacement or base-first concatenation policy through recursion. - prepared[key] = new ArrayOverride( - arrays === "concat" && Array.isArray(baseEntry) ? [...baseEntry, ...entry] : entry, - ); - } else if (isPlainObject(entry)) { - prepared[key] = prepareObject( - entry, - undefinedValues, - encodeOverrides, - arrays, - isPlainObject(baseEntry) ? baseEntry : undefined, - ); - } else { - prepared[key] = entry; - } + sanitized[key] = isPlainObject(entry) ? sanitizePlainObject(entry) : entry; } - return prepared; -} - -function decodeOverrides(value: unknown): unknown { - if (value instanceof ArrayOverride) { - return value.value; - } - if (value === NULL_OVERRIDE) { - return null; - } - if (value === UNDEFINED_OVERRIDE) { - return undefined; - } - if (!isPlainObject(value)) { - return value; - } - return Object.fromEntries( - Object.entries(value).map(([key, entry]) => [key, decodeOverrides(entry)]), - ); + return sanitized; } /** Merge plain objects while preserving OpenClaw's null, undefined, and array policies. */ @@ -86,9 +33,23 @@ export function mergeDeep( return override === undefined && undefinedValues === "skip" ? base : override; } - // defu intentionally treats null/undefined as absent. Sentinels retain explicit - // overrides while its recursive merge remains the single merge implementation. - const preparedBase = prepareObject(base, undefinedValues, false, arrays); - const preparedOverride = prepareObject(override, undefinedValues, true, arrays, base); - return decodeOverrides(defu(preparedOverride, preparedBase)); + // Clone nested records before merging so base-only and override-only branches + // enforce the same blocked-key boundary. + const merged = sanitizePlainObject(base); + for (const [key, value] of Object.entries(override)) { + if (isBlockedObjectKey(key) || (value === undefined && undefinedValues === "skip")) { + continue; + } + const current = merged[key]; + if (isPlainObject(value)) { + merged[key] = isPlainObject(current) + ? mergeDeep(current, value, options) + : sanitizePlainObject(value); + } else if (arrays === "concat" && Array.isArray(current) && Array.isArray(value)) { + merged[key] = [...current, ...value]; + } else { + merged[key] = value; + } + } + return merged; } diff --git a/src/plugin-sdk/plugin-config-runtime.ts b/src/plugin-sdk/plugin-config-runtime.ts index eba56722bc18..665394aa0815 100644 --- a/src/plugin-sdk/plugin-config-runtime.ts +++ b/src/plugin-sdk/plugin-config-runtime.ts @@ -3,6 +3,7 @@ import type { OpenClawConfig } from "../config/types.js"; import { normalizePluginsConfig, resolveEffectiveEnableState } from "../plugins/config-state.js"; export { normalizePluginsConfig, resolveEffectiveEnableState }; +export { mergeDeep } from "../infra/deep-merge.js"; /** Requires an already-resolved runtime config at plugin runtime boundaries. */ export function requireRuntimeConfig(config: OpenClawConfig, context: string): OpenClawConfig {