From 4d8aec82106a8ea6f03ded7964e90fd2789a1add Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 13 May 2026 15:37:43 +0100 Subject: [PATCH] fix(plugins): attribute runtime config deprecations (#81425) (thanks @BKF-Gitty) Co-authored-by: BKF-Gitty --- CHANGELOG.md | 1 + src/plugins/registry.runtime-config.test.ts | 67 ++++++++++++++++++++ src/plugins/registry.ts | 22 ++++++- src/plugins/runtime/gateway-request-scope.ts | 24 ++++++- src/plugins/runtime/runtime-config.test.ts | 58 ++++++++++++++++- src/plugins/runtime/runtime-config.ts | 31 ++++++++- 6 files changed, 195 insertions(+), 8 deletions(-) create mode 100644 src/plugins/registry.runtime-config.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a9b12050bbd..9e0633c882b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -85,6 +85,7 @@ Docs: https://docs.openclaw.ai - CLI/commitments: write `--json` output to stdout instead of diagnostic logs so automation can parse commitment list and dismiss results. (#81215) Thanks @giodl73-repo. - Update: allow pnpm GitHub-source OpenClaw updates to approve the OpenClaw package build, so source installs complete their prepare/prepack lifecycle. (#81294) Thanks @fuller-stack-dev. - Test state: seed isolated auth-profile secret keys for generated homes, preventing helper-backed proof runs from falling back to host Keychain secrets. (#81393) Thanks @altaywtf. +- Plugins/runtime: attribute deprecated runtime config load/write warnings to the plugin id and source that triggered them so logs and plugin doctor runs are actionable. Refs #81394. (#81425) Thanks @BKF-Gitty. ### Changes diff --git a/src/plugins/registry.runtime-config.test.ts b/src/plugins/registry.runtime-config.test.ts new file mode 100644 index 00000000000..6f86c239381 --- /dev/null +++ b/src/plugins/registry.runtime-config.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { createPluginRecord } from "./loader-records.js"; +import { createPluginRegistry } from "./registry.js"; +import { getPluginRuntimeGatewayRequestScope } from "./runtime/gateway-request-scope.js"; +import type { PluginRuntime } from "./runtime/types.js"; + +function createTestRegistry(runtime: PluginRuntime) { + return createPluginRegistry({ + logger: { + info() {}, + warn() {}, + error() {}, + debug() {}, + }, + runtime, + activateGlobalSideEffects: false, + }); +} + +describe("plugin registry runtime config scope", () => { + it("runs deprecated config helpers with the owning plugin scope", async () => { + let loadScope = getPluginRuntimeGatewayRequestScope(); + let writeScope = getPluginRuntimeGatewayRequestScope(); + const config = {} as OpenClawConfig; + const replaceResult = { + previousHash: null, + nextHash: "next", + } as unknown as Awaited>; + const configRuntime = { + current: vi.fn(() => config), + mutateConfigFile: async () => ({ + ...replaceResult, + result: undefined as T | undefined, + }), + replaceConfigFile: async () => replaceResult, + loadConfig: vi.fn(() => { + loadScope = getPluginRuntimeGatewayRequestScope(); + return config; + }), + writeConfigFile: vi.fn(async () => { + writeScope = getPluginRuntimeGatewayRequestScope(); + }), + } satisfies PluginRuntime["config"]; + const pluginRegistry = createTestRegistry({ config: configRuntime } as PluginRuntime); + const record = createPluginRecord({ + id: "legacy-plugin", + name: "Legacy Plugin", + source: "/plugins/legacy-plugin/index.js", + origin: "global", + enabled: true, + }); + const api = pluginRegistry.createApi(record, { config }); + + expect(api.runtime.config.loadConfig()).toBe(config); + await api.runtime.config.writeConfigFile(config); + + expect(loadScope).toMatchObject({ + pluginId: "legacy-plugin", + pluginSource: "/plugins/legacy-plugin/index.js", + }); + expect(writeScope).toMatchObject({ + pluginId: "legacy-plugin", + pluginSource: "/plugins/legacy-plugin/index.js", + }); + }); +}); diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index de6c442aa1c..c1a4624f43b 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -124,7 +124,10 @@ export type { PluginSessionExtensionRegistryRegistration, } from "./registry-types.js"; import { getActivePluginRegistry } from "./runtime.js"; -import { withPluginRuntimePluginIdScope } from "./runtime/gateway-request-scope.js"; +import { + withPluginRuntimePluginIdScope, + withPluginRuntimePluginScope, +} from "./runtime/gateway-request-scope.js"; import type { PluginRuntime } from "./runtime/types.js"; import { validateJsonSchemaValue, type JsonSchemaValue } from "./schema-validator.js"; import { normalizeSessionEntrySlotKey } from "./session-entry-slot-keys.js"; @@ -2367,6 +2370,14 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { } const runtime = new Proxy(registryParams.runtime, { get(target, prop, receiver) { + const runWithPluginScope = (run: () => T): T => { + const record = + pluginRuntimeRecordById.get(pluginId) ?? + registry.plugins.find((entry) => entry.id === pluginId); + return record?.source + ? withPluginRuntimePluginScope({ pluginId, pluginSource: record.source }, run) + : withPluginRuntimePluginScope({ pluginId }, run); + }; if (prop === "state") { const baseState = Reflect.get(target, prop, receiver); return { @@ -2384,6 +2395,15 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { }, } satisfies PluginRuntime["state"]; } + if (prop === "config") { + const config = Reflect.get(target, prop, receiver) as PluginRuntime["config"]; + return { + ...config, + loadConfig: () => runWithPluginScope(() => config.loadConfig()), + writeConfigFile: (cfg, options) => + runWithPluginScope(() => config.writeConfigFile(cfg, options)), + } satisfies PluginRuntime["config"]; + } if (prop === "llm") { const llm = Reflect.get(target, prop, receiver); return { diff --git a/src/plugins/runtime/gateway-request-scope.ts b/src/plugins/runtime/gateway-request-scope.ts index 85f32f06a27..fbe8c1b22b6 100644 --- a/src/plugins/runtime/gateway-request-scope.ts +++ b/src/plugins/runtime/gateway-request-scope.ts @@ -10,6 +10,12 @@ export type PluginRuntimeGatewayRequestScope = { client?: GatewayRequestOptions["client"]; isWebchatConnect: GatewayRequestOptions["isWebchatConnect"]; pluginId?: string; + pluginSource?: string; +}; + +export type PluginRuntimePluginScope = { + pluginId: string; + pluginSource?: string; }; const PLUGIN_RUNTIME_GATEWAY_REQUEST_SCOPE_KEY: unique symbol = Symbol.for( @@ -36,17 +42,29 @@ export function withPluginRuntimeGatewayRequestScope( /** * Runs work under the current gateway request scope while attaching plugin identity. */ -export function withPluginRuntimePluginIdScope(pluginId: string, run: () => T): T { +export function withPluginRuntimePluginScope(scope: PluginRuntimePluginScope, run: () => T): T { const current = pluginRuntimeGatewayRequestScope.getStore(); const scoped: PluginRuntimeGatewayRequestScope = current - ? { ...current, pluginId } + ? { ...current, pluginId: scope.pluginId } : { - pluginId, + pluginId: scope.pluginId, isWebchatConnect: () => false, }; + if (scope.pluginSource !== undefined) { + scoped.pluginSource = scope.pluginSource; + } else { + delete scoped.pluginSource; + } return pluginRuntimeGatewayRequestScope.run(scoped, run); } +/** + * Runs work under the current gateway request scope while attaching plugin identity. + */ +export function withPluginRuntimePluginIdScope(pluginId: string, run: () => T): T { + return withPluginRuntimePluginScope({ pluginId }, run); +} + /** * Returns the current plugin gateway request scope when called from a plugin request handler. */ diff --git a/src/plugins/runtime/runtime-config.test.ts b/src/plugins/runtime/runtime-config.test.ts index 53a5a748697..17967351d56 100644 --- a/src/plugins/runtime/runtime-config.test.ts +++ b/src/plugins/runtime/runtime-config.test.ts @@ -19,11 +19,14 @@ vi.mock("../../logger.js", () => ({ logWarn: (...args: unknown[]) => logWarnMock(...args), })); -const { createRuntimeConfig } = await import("./runtime-config.js"); +const { withPluginRuntimePluginScope } = await import("./gateway-request-scope.js"); +const { createRuntimeConfig, resetRuntimeConfigDeprecationWarningStateForTest } = + await import("./runtime-config.js"); const deprecatedConfigCode = "runtime-config-load-write"; describe("createRuntimeConfig", () => { beforeEach(() => { + resetRuntimeConfigDeprecationWarningStateForTest(); getRuntimeConfigMock.mockReset(); mutateConfigFileMock.mockReset(); replaceConfigFileMock.mockReset(); @@ -46,6 +49,40 @@ describe("createRuntimeConfig", () => { ); }); + it("attributes deprecated loadConfig warnings to the active plugin scope", () => { + const runtimeConfig = { plugins: { entries: {} } }; + getRuntimeConfigMock.mockReturnValue(runtimeConfig); + const configApi = createRuntimeConfig(); + + const loaded = withPluginRuntimePluginScope( + { pluginId: "legacy-plugin", pluginSource: "/plugins/legacy-plugin/index.js" }, + () => configApi.loadConfig(), + ); + + expect(loaded).toBe(runtimeConfig); + expect(logWarnMock).toHaveBeenCalledWith( + `plugin "legacy-plugin" runtime config.loadConfig() is deprecated (${deprecatedConfigCode}); use config.current(). Source: /plugins/legacy-plugin/index.js`, + ); + }); + + it("keeps deprecated loadConfig warning attribution per plugin", () => { + const configApi = createRuntimeConfig(); + + withPluginRuntimePluginScope({ pluginId: "first" }, () => configApi.loadConfig()); + withPluginRuntimePluginScope({ pluginId: "first" }, () => configApi.loadConfig()); + withPluginRuntimePluginScope({ pluginId: "second" }, () => configApi.loadConfig()); + + expect(logWarnMock).toHaveBeenCalledTimes(2); + expect(logWarnMock).toHaveBeenNthCalledWith( + 1, + `plugin "first" runtime config.loadConfig() is deprecated (${deprecatedConfigCode}); use config.current().`, + ); + expect(logWarnMock).toHaveBeenNthCalledWith( + 2, + `plugin "second" runtime config.loadConfig() is deprecated (${deprecatedConfigCode}); use config.current().`, + ); + }); + it("routes deprecated writeConfigFile through replaceConfigFile with afterWrite", async () => { const configApi = createRuntimeConfig(); const nextConfig = { plugins: { entries: {} } } as OpenClawConfig; @@ -62,6 +99,25 @@ describe("createRuntimeConfig", () => { }); }); + it("attributes deprecated writeConfigFile warnings to the active plugin scope", async () => { + const configApi = createRuntimeConfig(); + const nextConfig = { plugins: { entries: {} } } as OpenClawConfig; + + await withPluginRuntimePluginScope( + { pluginId: "legacy-plugin", pluginSource: "/plugins/legacy-plugin/index.js" }, + async () => await configApi.writeConfigFile(nextConfig), + ); + + expect(logWarnMock).toHaveBeenCalledWith( + `plugin "legacy-plugin" runtime config.writeConfigFile() is deprecated (${deprecatedConfigCode}); use config.mutateConfigFile(...) or config.replaceConfigFile(...). Source: /plugins/legacy-plugin/index.js`, + ); + expect(replaceConfigFileMock).toHaveBeenCalledWith({ + nextConfig, + afterWrite: { mode: "auto" }, + writeOptions: undefined, + }); + }); + it("preserves explicit afterWrite intent for deprecated writeConfigFile", async () => { const configApi = createRuntimeConfig(); const nextConfig = { plugins: { entries: {} } } as OpenClawConfig; diff --git a/src/plugins/runtime/runtime-config.ts b/src/plugins/runtime/runtime-config.ts index e782d663c79..03c5bdcd5fc 100644 --- a/src/plugins/runtime/runtime-config.ts +++ b/src/plugins/runtime/runtime-config.ts @@ -4,25 +4,50 @@ import { replaceConfigFile as replaceConfigFileInternal, } from "../../config/mutate.js"; import { logWarn } from "../../logger.js"; +import { getPluginRuntimeGatewayRequestScope } from "./gateway-request-scope.js"; import type { PluginRuntime } from "./types.js"; const RUNTIME_CONFIG_LOAD_WRITE_COMPAT_CODE = "runtime-config-load-write"; const warnedDeprecatedConfigApis = new Set(); +function formatDeprecatedConfigApiSubject(name: "loadConfig" | "writeConfigFile"): string { + const scope = getPluginRuntimeGatewayRequestScope(); + if (!scope?.pluginId) { + return `plugin runtime config.${name}()`; + } + return `plugin "${scope.pluginId}" runtime config.${name}()`; +} + +function formatDeprecatedConfigApiSource(): string { + const scope = getPluginRuntimeGatewayRequestScope(); + return scope?.pluginSource ? ` Source: ${scope.pluginSource}` : ""; +} + +function formatDeprecatedConfigApiWarningKey(name: "loadConfig" | "writeConfigFile"): string { + const scope = getPluginRuntimeGatewayRequestScope(); + return `${name}:${scope?.pluginId ?? "anonymous"}`; +} + function warnDeprecatedConfigApiOnce( name: "loadConfig" | "writeConfigFile", replacement: string, ): void { - if (warnedDeprecatedConfigApis.has(name)) { + const warningKey = formatDeprecatedConfigApiWarningKey(name); + if (warnedDeprecatedConfigApis.has(warningKey)) { return; } - warnedDeprecatedConfigApis.add(name); + warnedDeprecatedConfigApis.add(warningKey); logWarn( - `plugin runtime config.${name}() is deprecated (${RUNTIME_CONFIG_LOAD_WRITE_COMPAT_CODE}); use ${replacement}.`, + `${formatDeprecatedConfigApiSubject(name)} is deprecated (${RUNTIME_CONFIG_LOAD_WRITE_COMPAT_CODE}); use ${replacement}.${formatDeprecatedConfigApiSource()}`, ); } +/** @internal Test-only reset for the runtime config compatibility warning cache. */ +export function resetRuntimeConfigDeprecationWarningStateForTest(): void { + warnedDeprecatedConfigApis.clear(); +} + export function createRuntimeConfig(): PluginRuntime["config"] { return { current: getRuntimeConfig,