From da720541c25b994beff4845fbbe5a8944463b74a Mon Sep 17 00:00:00 2001 From: Peter Lindsey Date: Sat, 30 May 2026 10:50:51 +0800 Subject: [PATCH] fix(plugins): resolve ${ENV_VAR} references in plugin config before handoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugin entry config (`plugins.entries.*.config`) could reach the plugin with `${VAR}` references unresolved, while provider config resolved them correctly. A provider plugin whose API key was supplied as `apiKey: "${MY_KEY}"` therefore authenticated with the literal placeholder and every upstream request was rejected — surfacing as a "provider down" / silent model failover with no indication why. Resolve `${VAR}` references in `validatePluginConfig` (the single point all plugin entry config passes through on its way to the plugin) so plugins always receive resolved values, matching provider-config behaviour. Missing vars are preserved as their literal placeholder rather than throwing, matching read-time config substitution; the call is a no-op when the config is already resolved. Co-Authored-By: Claude Opus 4.8 --- src/plugins/loader.test.ts | 29 +++++++++++++++++++++++++++++ src/plugins/loader.ts | 27 +++++++++++++++++++-------- 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 6c0a44a501d..7f8872ab447 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -1023,6 +1023,35 @@ describe("loadOpenClawPlugins", () => { expect(metrics.loadAndRegisterMs).toEqual(expect.any(Number)); }); + it("resolves ${ENV_VAR} references in plugin config before handing config to the plugin", () => { + useNoBundledPlugins(); + const plugin = writePlugin({ + id: "env-config-probe", + filename: "env-config-probe.cjs", + body: `module.exports = { + id: "env-config-probe", + register(api) { + globalThis.__ENV_CONFIG_PROBE = api.pluginConfig; + }, +};`, + }); + const probe = globalThis as unknown as Record; + delete probe.__ENV_CONFIG_PROBE; + withEnv({ ENV_CONFIG_PROBE_SECRET: "resolved-secret-value" }, () => { + loadRegistryFromSinglePlugin({ + plugin, + pluginConfig: { + allow: ["env-config-probe"], + entries: { + "env-config-probe": { config: { apiKey: "${ENV_CONFIG_PROBE_SECRET}" } }, + }, + }, + }); + }); + // Before the fix, the plugin received the literal "${ENV_CONFIG_PROBE_SECRET}". + expect(probe.__ENV_CONFIG_PROBE).toMatchObject({ apiKey: "resolved-secret-value" }); + }); + it("emits loader startup trace failure counts for load and register failures", () => { useNoBundledPlugins(); const loadFailPlugin = writePlugin({ diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index ac7da66d37c..b3703689228 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -6,6 +6,7 @@ import { listRegisteredAgentHarnesses, restoreRegisteredAgentHarnesses, } from "../agents/harness/registry.js"; +import { resolveConfigEnvVars } from "../config/env-substitution.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; import type { GatewayRequestHandler } from "../gateway/server-methods/types.js"; @@ -1494,21 +1495,31 @@ function validatePluginConfig(params: { cacheKey?: string; value?: unknown; }): { ok: boolean; value?: Record; errors?: string[] } { + // Resolve ${ENV_VAR} references in plugin config before validation and handoff. + // Depending on the config-delivery path, plugin entry config can reach this + // point with ${VAR} references unresolved; substitute here so plugins always + // receive resolved values (parity with provider config). Missing vars are left + // as their literal placeholder rather than throwing, matching read-time config + // substitution; this is a no-op when the config is already resolved. + const value = + params.value === undefined + ? undefined + : resolveConfigEnvVars(params.value, process.env, { onMissing: () => undefined }); const schema = params.schema; if (!schema) { - return { ok: true, value: params.value as Record | undefined }; + return { ok: true, value: value as Record | undefined }; } if (isEmptyPluginConfigJsonSchema(schema)) { if ( - params.value === undefined || - (params.value && - typeof params.value === "object" && - !Array.isArray(params.value) && - Object.keys(params.value).length === 0) + value === undefined || + (value && + typeof value === "object" && + !Array.isArray(value) && + Object.keys(value).length === 0) ) { return { ok: true, value: {} }; } - if (!params.value || typeof params.value !== "object" || Array.isArray(params.value)) { + if (!value || typeof value !== "object" || Array.isArray(value)) { return { ok: false, errors: [": must be object"] }; } return { ok: false, errors: [": config must be empty"] }; @@ -1517,7 +1528,7 @@ function validatePluginConfig(params: { const result = validateJsonSchemaValue({ schema, cacheKey, - value: params.value ?? {}, + value: value ?? {}, applyDefaults: true, }); if (result.ok) {