From ae99e656afd535834855f122dbf3953ba343b6c9 Mon Sep 17 00:00:00 2001 From: Rodrigo Uroz Date: Mon, 9 Feb 2026 19:31:41 -0300 Subject: [PATCH] (fix): .env vars not available during runtime config reloads (healthchecks fail with MissingEnvVarError) (#12748) * Config: reload dotenv before env substitution on runtime loads * Test: isolate config env var regression from host state env * fix: keep dotenv vars resolvable on runtime config reloads (#12748) (thanks @rodrigouroz) --------- Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- CHANGELOG.md | 1 + src/config/config.env-vars.test.ts | 47 ++++++++++++++++++++++++++++++ src/config/io.ts | 12 ++++++++ 3 files changed, 60 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1522443aff..9f654f29b2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai - Tools/web_search: normalize direct Perplexity model IDs while keeping OpenRouter model IDs unchanged. (#12795) Thanks @cdorsey. - Model failover: treat HTTP 400 errors as failover-eligible, enabling automatic model fallback. (#1879) Thanks @orenyomtov. - Errors: prevent false positive context overflow detection when conversation mentions "context overflow" topic. (#2078) Thanks @sbking. +- Config: re-hydrate state-dir `.env` during runtime config loads so `${VAR}` substitutions remain resolvable. (#12748) Thanks @rodrigouroz. - Gateway: no more post-compaction amnesia; injected transcript writes now preserve Pi session `parentId` chain so agents can remember again. (#12283) Thanks @Takhoffman. - Gateway: fix multi-agent sessions.usage discovery. (#11523) Thanks @Takhoffman. - Agents: recover from context overflow caused by oversized tool results (pre-emptive capping + fallback truncation). (#11579) Thanks @tyler6204. diff --git a/src/config/config.env-vars.test.ts b/src/config/config.env-vars.test.ts index 693bb485774..9e9fca6f2aa 100644 --- a/src/config/config.env-vars.test.ts +++ b/src/config/config.env-vars.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { resolveStateDir } from "./paths.js"; import { withEnvOverride, withTempHome } from "./test-helpers.js"; describe("config env vars", () => { @@ -75,4 +76,50 @@ describe("config env vars", () => { }); }); }); + + it("loads ${VAR} substitutions from ~/.openclaw/.env on repeated runtime loads", async () => { + await withTempHome(async (home) => { + await withEnvOverride( + { + OPENCLAW_STATE_DIR: path.join(home, ".openclaw"), + CLAWDBOT_STATE_DIR: undefined, + OPENCLAW_HOME: undefined, + CLAWDBOT_HOME: undefined, + BRAVE_API_KEY: undefined, + OPENCLAW_DISABLE_CONFIG_CACHE: "1", + }, + async () => { + const configDir = resolveStateDir(process.env, () => home); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile( + path.join(configDir, "openclaw.json"), + JSON.stringify( + { + tools: { + web: { + search: { + apiKey: "${BRAVE_API_KEY}", + }, + }, + }, + }, + null, + 2, + ), + "utf-8", + ); + await fs.writeFile(path.join(configDir, ".env"), "BRAVE_API_KEY=from-dotenv\n", "utf-8"); + + const { loadConfig } = await import("./config.js"); + + const first = loadConfig(); + expect(first.tools?.web?.search?.apiKey).toBe("from-dotenv"); + + delete process.env.BRAVE_API_KEY; + const second = loadConfig(); + expect(second.tools?.web?.search?.apiKey).toBe("from-dotenv"); + }, + ); + }); + }); }); diff --git a/src/config/io.ts b/src/config/io.ts index 8cbc218090a..c345e246b9b 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -4,6 +4,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import type { OpenClawConfig, ConfigFileSnapshot, LegacyConfigIssue } from "./types.js"; +import { loadDotEnv } from "../infra/dotenv.js"; import { resolveRequiredHomeDir } from "../infra/home-dir.js"; import { loadShellEnvFallback, @@ -191,6 +192,15 @@ function normalizeDeps(overrides: ConfigIoDeps = {}): Required { }; } +function maybeLoadDotEnvForConfig(env: NodeJS.ProcessEnv): void { + // Only hydrate dotenv for the real process env. Callers using injected env + // objects (tests/diagnostics) should stay isolated. + if (env !== process.env) { + return; + } + loadDotEnv({ quiet: true }); +} + export function parseConfigJson5( raw: string, json5: { parse: (value: string) => unknown } = JSON5, @@ -213,6 +223,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { function loadConfig(): OpenClawConfig { try { + maybeLoadDotEnvForConfig(deps.env); if (!deps.fs.existsSync(configPath)) { if (shouldEnableShellEnvFallback(deps.env) && !shouldDeferShellEnvFallback(deps.env)) { loadShellEnvFallback({ @@ -323,6 +334,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { } async function readConfigFileSnapshot(): Promise { + maybeLoadDotEnvForConfig(deps.env); const exists = deps.fs.existsSync(configPath); if (!exists) { const hash = hashConfigRaw(null);