(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>
This commit is contained in:
Rodrigo Uroz
2026-02-09 19:31:41 -03:00
committed by GitHub
parent b40a7771e5
commit ae99e656af
3 changed files with 60 additions and 0 deletions

View File

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

View File

@@ -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");
},
);
});
});
});

View File

@@ -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<ConfigIoDeps> {
};
}
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<ConfigFileSnapshot> {
maybeLoadDotEnvForConfig(deps.env);
const exists = deps.fs.existsSync(configPath);
if (!exists) {
const hash = hashConfigRaw(null);