Skip to content

Commit 4d8aec8

Browse files
steipeteBKF-Gitty
andcommitted
fix(plugins): attribute runtime config deprecations (#81425) (thanks @BKF-Gitty)
Co-authored-by: BKF-Gitty <bandark@mac.com>
1 parent a40499b commit 4d8aec8

6 files changed

Lines changed: 195 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ Docs: https://docs.openclaw.ai
8585
- CLI/commitments: write `--json` output to stdout instead of diagnostic logs so automation can parse commitment list and dismiss results. (#81215) Thanks @giodl73-repo.
8686
- 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.
8787
- 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.
88+
- 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.
8889

8990
### Changes
9091

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import type { OpenClawConfig } from "../config/types.openclaw.js";
3+
import { createPluginRecord } from "./loader-records.js";
4+
import { createPluginRegistry } from "./registry.js";
5+
import { getPluginRuntimeGatewayRequestScope } from "./runtime/gateway-request-scope.js";
6+
import type { PluginRuntime } from "./runtime/types.js";
7+
8+
function createTestRegistry(runtime: PluginRuntime) {
9+
return createPluginRegistry({
10+
logger: {
11+
info() {},
12+
warn() {},
13+
error() {},
14+
debug() {},
15+
},
16+
runtime,
17+
activateGlobalSideEffects: false,
18+
});
19+
}
20+
21+
describe("plugin registry runtime config scope", () => {
22+
it("runs deprecated config helpers with the owning plugin scope", async () => {
23+
let loadScope = getPluginRuntimeGatewayRequestScope();
24+
let writeScope = getPluginRuntimeGatewayRequestScope();
25+
const config = {} as OpenClawConfig;
26+
const replaceResult = {
27+
previousHash: null,
28+
nextHash: "next",
29+
} as unknown as Awaited<ReturnType<PluginRuntime["config"]["replaceConfigFile"]>>;
30+
const configRuntime = {
31+
current: vi.fn(() => config),
32+
mutateConfigFile: async <T = void>() => ({
33+
...replaceResult,
34+
result: undefined as T | undefined,
35+
}),
36+
replaceConfigFile: async () => replaceResult,
37+
loadConfig: vi.fn(() => {
38+
loadScope = getPluginRuntimeGatewayRequestScope();
39+
return config;
40+
}),
41+
writeConfigFile: vi.fn(async () => {
42+
writeScope = getPluginRuntimeGatewayRequestScope();
43+
}),
44+
} satisfies PluginRuntime["config"];
45+
const pluginRegistry = createTestRegistry({ config: configRuntime } as PluginRuntime);
46+
const record = createPluginRecord({
47+
id: "legacy-plugin",
48+
name: "Legacy Plugin",
49+
source: "/plugins/legacy-plugin/index.js",
50+
origin: "global",
51+
enabled: true,
52+
});
53+
const api = pluginRegistry.createApi(record, { config });
54+
55+
expect(api.runtime.config.loadConfig()).toBe(config);
56+
await api.runtime.config.writeConfigFile(config);
57+
58+
expect(loadScope).toMatchObject({
59+
pluginId: "legacy-plugin",
60+
pluginSource: "/plugins/legacy-plugin/index.js",
61+
});
62+
expect(writeScope).toMatchObject({
63+
pluginId: "legacy-plugin",
64+
pluginSource: "/plugins/legacy-plugin/index.js",
65+
});
66+
});
67+
});

src/plugins/registry.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,10 @@ export type {
124124
PluginSessionExtensionRegistryRegistration,
125125
} from "./registry-types.js";
126126
import { getActivePluginRegistry } from "./runtime.js";
127-
import { withPluginRuntimePluginIdScope } from "./runtime/gateway-request-scope.js";
127+
import {
128+
withPluginRuntimePluginIdScope,
129+
withPluginRuntimePluginScope,
130+
} from "./runtime/gateway-request-scope.js";
128131
import type { PluginRuntime } from "./runtime/types.js";
129132
import { validateJsonSchemaValue, type JsonSchemaValue } from "./schema-validator.js";
130133
import { normalizeSessionEntrySlotKey } from "./session-entry-slot-keys.js";
@@ -2367,6 +2370,14 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
23672370
}
23682371
const runtime = new Proxy(registryParams.runtime, {
23692372
get(target, prop, receiver) {
2373+
const runWithPluginScope = <T>(run: () => T): T => {
2374+
const record =
2375+
pluginRuntimeRecordById.get(pluginId) ??
2376+
registry.plugins.find((entry) => entry.id === pluginId);
2377+
return record?.source
2378+
? withPluginRuntimePluginScope({ pluginId, pluginSource: record.source }, run)
2379+
: withPluginRuntimePluginScope({ pluginId }, run);
2380+
};
23702381
if (prop === "state") {
23712382
const baseState = Reflect.get(target, prop, receiver);
23722383
return {
@@ -2384,6 +2395,15 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
23842395
},
23852396
} satisfies PluginRuntime["state"];
23862397
}
2398+
if (prop === "config") {
2399+
const config = Reflect.get(target, prop, receiver) as PluginRuntime["config"];
2400+
return {
2401+
...config,
2402+
loadConfig: () => runWithPluginScope(() => config.loadConfig()),
2403+
writeConfigFile: (cfg, options) =>
2404+
runWithPluginScope(() => config.writeConfigFile(cfg, options)),
2405+
} satisfies PluginRuntime["config"];
2406+
}
23872407
if (prop === "llm") {
23882408
const llm = Reflect.get(target, prop, receiver);
23892409
return {

src/plugins/runtime/gateway-request-scope.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ export type PluginRuntimeGatewayRequestScope = {
1010
client?: GatewayRequestOptions["client"];
1111
isWebchatConnect: GatewayRequestOptions["isWebchatConnect"];
1212
pluginId?: string;
13+
pluginSource?: string;
14+
};
15+
16+
export type PluginRuntimePluginScope = {
17+
pluginId: string;
18+
pluginSource?: string;
1319
};
1420

1521
const PLUGIN_RUNTIME_GATEWAY_REQUEST_SCOPE_KEY: unique symbol = Symbol.for(
@@ -36,17 +42,29 @@ export function withPluginRuntimeGatewayRequestScope<T>(
3642
/**
3743
* Runs work under the current gateway request scope while attaching plugin identity.
3844
*/
39-
export function withPluginRuntimePluginIdScope<T>(pluginId: string, run: () => T): T {
45+
export function withPluginRuntimePluginScope<T>(scope: PluginRuntimePluginScope, run: () => T): T {
4046
const current = pluginRuntimeGatewayRequestScope.getStore();
4147
const scoped: PluginRuntimeGatewayRequestScope = current
42-
? { ...current, pluginId }
48+
? { ...current, pluginId: scope.pluginId }
4349
: {
44-
pluginId,
50+
pluginId: scope.pluginId,
4551
isWebchatConnect: () => false,
4652
};
53+
if (scope.pluginSource !== undefined) {
54+
scoped.pluginSource = scope.pluginSource;
55+
} else {
56+
delete scoped.pluginSource;
57+
}
4758
return pluginRuntimeGatewayRequestScope.run(scoped, run);
4859
}
4960

61+
/**
62+
* Runs work under the current gateway request scope while attaching plugin identity.
63+
*/
64+
export function withPluginRuntimePluginIdScope<T>(pluginId: string, run: () => T): T {
65+
return withPluginRuntimePluginScope({ pluginId }, run);
66+
}
67+
5068
/**
5169
* Returns the current plugin gateway request scope when called from a plugin request handler.
5270
*/

src/plugins/runtime/runtime-config.test.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,14 @@ vi.mock("../../logger.js", () => ({
1919
logWarn: (...args: unknown[]) => logWarnMock(...args),
2020
}));
2121

22-
const { createRuntimeConfig } = await import("./runtime-config.js");
22+
const { withPluginRuntimePluginScope } = await import("./gateway-request-scope.js");
23+
const { createRuntimeConfig, resetRuntimeConfigDeprecationWarningStateForTest } =
24+
await import("./runtime-config.js");
2325
const deprecatedConfigCode = "runtime-config-load-write";
2426

2527
describe("createRuntimeConfig", () => {
2628
beforeEach(() => {
29+
resetRuntimeConfigDeprecationWarningStateForTest();
2730
getRuntimeConfigMock.mockReset();
2831
mutateConfigFileMock.mockReset();
2932
replaceConfigFileMock.mockReset();
@@ -46,6 +49,40 @@ describe("createRuntimeConfig", () => {
4649
);
4750
});
4851

52+
it("attributes deprecated loadConfig warnings to the active plugin scope", () => {
53+
const runtimeConfig = { plugins: { entries: {} } };
54+
getRuntimeConfigMock.mockReturnValue(runtimeConfig);
55+
const configApi = createRuntimeConfig();
56+
57+
const loaded = withPluginRuntimePluginScope(
58+
{ pluginId: "legacy-plugin", pluginSource: "/plugins/legacy-plugin/index.js" },
59+
() => configApi.loadConfig(),
60+
);
61+
62+
expect(loaded).toBe(runtimeConfig);
63+
expect(logWarnMock).toHaveBeenCalledWith(
64+
`plugin "legacy-plugin" runtime config.loadConfig() is deprecated (${deprecatedConfigCode}); use config.current(). Source: /plugins/legacy-plugin/index.js`,
65+
);
66+
});
67+
68+
it("keeps deprecated loadConfig warning attribution per plugin", () => {
69+
const configApi = createRuntimeConfig();
70+
71+
withPluginRuntimePluginScope({ pluginId: "first" }, () => configApi.loadConfig());
72+
withPluginRuntimePluginScope({ pluginId: "first" }, () => configApi.loadConfig());
73+
withPluginRuntimePluginScope({ pluginId: "second" }, () => configApi.loadConfig());
74+
75+
expect(logWarnMock).toHaveBeenCalledTimes(2);
76+
expect(logWarnMock).toHaveBeenNthCalledWith(
77+
1,
78+
`plugin "first" runtime config.loadConfig() is deprecated (${deprecatedConfigCode}); use config.current().`,
79+
);
80+
expect(logWarnMock).toHaveBeenNthCalledWith(
81+
2,
82+
`plugin "second" runtime config.loadConfig() is deprecated (${deprecatedConfigCode}); use config.current().`,
83+
);
84+
});
85+
4986
it("routes deprecated writeConfigFile through replaceConfigFile with afterWrite", async () => {
5087
const configApi = createRuntimeConfig();
5188
const nextConfig = { plugins: { entries: {} } } as OpenClawConfig;
@@ -62,6 +99,25 @@ describe("createRuntimeConfig", () => {
6299
});
63100
});
64101

102+
it("attributes deprecated writeConfigFile warnings to the active plugin scope", async () => {
103+
const configApi = createRuntimeConfig();
104+
const nextConfig = { plugins: { entries: {} } } as OpenClawConfig;
105+
106+
await withPluginRuntimePluginScope(
107+
{ pluginId: "legacy-plugin", pluginSource: "/plugins/legacy-plugin/index.js" },
108+
async () => await configApi.writeConfigFile(nextConfig),
109+
);
110+
111+
expect(logWarnMock).toHaveBeenCalledWith(
112+
`plugin "legacy-plugin" runtime config.writeConfigFile() is deprecated (${deprecatedConfigCode}); use config.mutateConfigFile(...) or config.replaceConfigFile(...). Source: /plugins/legacy-plugin/index.js`,
113+
);
114+
expect(replaceConfigFileMock).toHaveBeenCalledWith({
115+
nextConfig,
116+
afterWrite: { mode: "auto" },
117+
writeOptions: undefined,
118+
});
119+
});
120+
65121
it("preserves explicit afterWrite intent for deprecated writeConfigFile", async () => {
66122
const configApi = createRuntimeConfig();
67123
const nextConfig = { plugins: { entries: {} } } as OpenClawConfig;

src/plugins/runtime/runtime-config.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,50 @@ import {
44
replaceConfigFile as replaceConfigFileInternal,
55
} from "../../config/mutate.js";
66
import { logWarn } from "../../logger.js";
7+
import { getPluginRuntimeGatewayRequestScope } from "./gateway-request-scope.js";
78
import type { PluginRuntime } from "./types.js";
89

910
const RUNTIME_CONFIG_LOAD_WRITE_COMPAT_CODE = "runtime-config-load-write";
1011

1112
const warnedDeprecatedConfigApis = new Set<string>();
1213

14+
function formatDeprecatedConfigApiSubject(name: "loadConfig" | "writeConfigFile"): string {
15+
const scope = getPluginRuntimeGatewayRequestScope();
16+
if (!scope?.pluginId) {
17+
return `plugin runtime config.${name}()`;
18+
}
19+
return `plugin "${scope.pluginId}" runtime config.${name}()`;
20+
}
21+
22+
function formatDeprecatedConfigApiSource(): string {
23+
const scope = getPluginRuntimeGatewayRequestScope();
24+
return scope?.pluginSource ? ` Source: ${scope.pluginSource}` : "";
25+
}
26+
27+
function formatDeprecatedConfigApiWarningKey(name: "loadConfig" | "writeConfigFile"): string {
28+
const scope = getPluginRuntimeGatewayRequestScope();
29+
return `${name}:${scope?.pluginId ?? "anonymous"}`;
30+
}
31+
1332
function warnDeprecatedConfigApiOnce(
1433
name: "loadConfig" | "writeConfigFile",
1534
replacement: string,
1635
): void {
17-
if (warnedDeprecatedConfigApis.has(name)) {
36+
const warningKey = formatDeprecatedConfigApiWarningKey(name);
37+
if (warnedDeprecatedConfigApis.has(warningKey)) {
1838
return;
1939
}
20-
warnedDeprecatedConfigApis.add(name);
40+
warnedDeprecatedConfigApis.add(warningKey);
2141
logWarn(
22-
`plugin runtime config.${name}() is deprecated (${RUNTIME_CONFIG_LOAD_WRITE_COMPAT_CODE}); use ${replacement}.`,
42+
`${formatDeprecatedConfigApiSubject(name)} is deprecated (${RUNTIME_CONFIG_LOAD_WRITE_COMPAT_CODE}); use ${replacement}.${formatDeprecatedConfigApiSource()}`,
2343
);
2444
}
2545

46+
/** @internal Test-only reset for the runtime config compatibility warning cache. */
47+
export function resetRuntimeConfigDeprecationWarningStateForTest(): void {
48+
warnedDeprecatedConfigApis.clear();
49+
}
50+
2651
export function createRuntimeConfig(): PluginRuntime["config"] {
2752
return {
2853
current: getRuntimeConfig,

0 commit comments

Comments
 (0)