fix(plugins): attribute runtime config deprecations (#81425) (thanks @BKF-Gitty)

Co-authored-by: BKF-Gitty <bandark@mac.com>
This commit is contained in:
Peter Steinberger
2026-05-13 15:37:43 +01:00
parent a40499b21a
commit 4d8aec8210
6 changed files with 195 additions and 8 deletions

View File

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

View File

@@ -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<ReturnType<PluginRuntime["config"]["replaceConfigFile"]>>;
const configRuntime = {
current: vi.fn(() => config),
mutateConfigFile: async <T = void>() => ({
...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",
});
});
});

View File

@@ -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 = <T>(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 {

View File

@@ -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<T>(
/**
* Runs work under the current gateway request scope while attaching plugin identity.
*/
export function withPluginRuntimePluginIdScope<T>(pluginId: string, run: () => T): T {
export function withPluginRuntimePluginScope<T>(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<T>(pluginId: string, run: () => T): T {
return withPluginRuntimePluginScope({ pluginId }, run);
}
/**
* Returns the current plugin gateway request scope when called from a plugin request handler.
*/

View File

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

View File

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