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) {