fix(dotenv): load gateway.env compatibility fallback (#61084)

* fix(dotenv): load gateway env fallback

* fix(dotenv): preserve legacy cli env loading

* fix(dotenv): keep gateway fallback scoped to default profile
This commit is contained in:
scoootscooob
2026-04-04 18:24:29 -07:00
committed by GitHub
parent 9860db5cea
commit 6ab1b43081
4 changed files with 250 additions and 20 deletions

View File

@@ -19,6 +19,8 @@ OpenClaw pulls environment variables from multiple sources. The rule is **never
4. **Config `env` block** in `~/.openclaw/openclaw.json` (applied only if missing).
5. **Optional login-shell import** (`env.shellEnv.enabled` or `OPENCLAW_LOAD_SHELL_ENV=1`), applied only for missing expected keys.
On Ubuntu fresh installs that use the default state dir, OpenClaw also treats `~/.config/openclaw/gateway.env` as a compatibility fallback after the global `.env`. If both files exist and disagree, OpenClaw keeps `~/.openclaw/.env` and prints a warning.
If the config file is missing entirely, step 4 is skipped; shell import still runs if enabled.
## Config `env` block

View File

@@ -1,14 +1,17 @@
import path from "node:path";
import { resolveStateDir } from "../config/paths.js";
import { loadRuntimeDotEnvFile, loadWorkspaceDotEnvFile } from "../infra/dotenv.js";
import { loadGlobalRuntimeDotEnvFiles, loadWorkspaceDotEnvFile } from "../infra/dotenv.js";
export function loadCliDotEnv(opts?: { quiet?: boolean }) {
const quiet = opts?.quiet ?? true;
const cwdEnvPath = path.join(process.cwd(), ".env");
loadWorkspaceDotEnvFile(cwdEnvPath, { quiet });
// Then load the global fallback from the active state dir without overriding
// any env vars that were already set or loaded from CWD.
const globalEnvPath = path.join(resolveStateDir(process.env), ".env");
loadRuntimeDotEnvFile(globalEnvPath, { quiet });
// Then load the global fallback set without overriding any env vars that
// were already set or loaded from CWD. This includes the Ubuntu fresh-install
// gateway.env compatibility path.
loadGlobalRuntimeDotEnvFiles({
quiet,
stateEnvPath: path.join(resolveStateDir(process.env), ".env"),
});
}

View File

@@ -97,6 +97,55 @@ describe("loadDotEnv", () => {
});
});
it("loads the Ubuntu gateway.env compatibility fallback after ~/.openclaw/.env", async () => {
await withIsolatedEnvAndCwd(async () => {
await withDotEnvFixture(async ({ base, cwdDir }) => {
process.env.HOME = base;
const defaultStateDir = path.join(base, ".openclaw");
process.env.OPENCLAW_STATE_DIR = defaultStateDir;
await writeEnvFile(path.join(defaultStateDir, ".env"), "FOO=from-global\n");
await writeEnvFile(
path.join(base, ".config", "openclaw", "gateway.env"),
["FOO=from-gateway", "BAR=from-gateway"].join("\n"),
);
vi.spyOn(process, "cwd").mockReturnValue(cwdDir);
delete process.env.FOO;
delete process.env.BAR;
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
loadDotEnv({ quiet: true });
expect(process.env.FOO).toBe("from-global");
expect(process.env.BAR).toBe("from-gateway");
expect(warn).toHaveBeenCalledWith(expect.stringContaining("Conflicting values in"));
expect(warn).toHaveBeenCalledWith(expect.stringContaining("gateway.env"));
});
});
});
it("does not warn about dotenv conflicts when the key is already set", async () => {
await withIsolatedEnvAndCwd(async () => {
await withDotEnvFixture(async ({ base, cwdDir, stateDir }) => {
process.env.HOME = base;
process.env.FOO = "from-shell";
await writeEnvFile(path.join(stateDir, ".env"), "FOO=from-global\n");
await writeEnvFile(
path.join(base, ".config", "openclaw", "gateway.env"),
"FOO=from-gateway\n",
);
vi.spyOn(process, "cwd").mockReturnValue(cwdDir);
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
loadDotEnv({ quiet: true });
expect(process.env.FOO).toBe("from-shell");
expect(warn).not.toHaveBeenCalled();
});
});
});
it("blocks dangerous and workspace-control vars from CWD .env", async () => {
await withIsolatedEnvAndCwd(async () => {
await withDotEnvFixture(async ({ cwdDir, stateDir }) => {
@@ -427,6 +476,73 @@ describe("loadCliDotEnv", () => {
});
});
it("loads the gateway.env compatibility fallback during CLI startup", async () => {
await withIsolatedEnvAndCwd(async () => {
await withDotEnvFixture(async ({ base, cwdDir }) => {
process.env.HOME = base;
const defaultStateDir = path.join(base, ".openclaw");
process.env.OPENCLAW_STATE_DIR = defaultStateDir;
await writeEnvFile(path.join(defaultStateDir, ".env"), "FOO=from-global\n");
await writeEnvFile(
path.join(base, ".config", "openclaw", "gateway.env"),
"BAR=from-gateway\n",
);
vi.spyOn(process, "cwd").mockReturnValue(cwdDir);
delete process.env.FOO;
delete process.env.BAR;
loadCliDotEnv({ quiet: true });
expect(process.env.FOO).toBe("from-global");
expect(process.env.BAR).toBe("from-gateway");
});
});
});
it("does not load gateway.env when OPENCLAW_STATE_DIR is explicitly set", async () => {
await withIsolatedEnvAndCwd(async () => {
await withDotEnvFixture(async ({ base, cwdDir }) => {
const customStateDir = path.join(base, "custom-state");
process.env.HOME = base;
process.env.OPENCLAW_STATE_DIR = customStateDir;
await writeEnvFile(
path.join(base, ".config", "openclaw", "gateway.env"),
"FOO=from-gateway\n",
);
vi.spyOn(process, "cwd").mockReturnValue(cwdDir);
delete process.env.FOO;
loadCliDotEnv({ quiet: true });
expect(process.env.FOO).toBeUndefined();
expect(process.env.OPENCLAW_STATE_DIR).toBe(customStateDir);
expect(process.env.BAR).toBeUndefined();
});
});
});
it("keeps the legacy state-dir fallback for CLI dotenv loading", async () => {
await withIsolatedEnvAndCwd(async () => {
const base = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-dotenv-legacy-"));
const cwdDir = path.join(base, "cwd");
const legacyStateDir = path.join(base, ".clawdbot");
process.env.HOME = base;
delete process.env.OPENCLAW_STATE_DIR;
delete process.env.OPENCLAW_TEST_FAST;
await fs.mkdir(cwdDir, { recursive: true });
await writeEnvFile(path.join(legacyStateDir, ".env"), "LEGACY_ONLY=from-legacy\n");
vi.spyOn(process, "cwd").mockReturnValue(cwdDir);
delete process.env.LEGACY_ONLY;
loadCliDotEnv({ quiet: true });
expect(process.env.LEGACY_ONLY).toBe("from-legacy");
});
});
it("blocks bundled trust-root vars from workspace .env during CLI startup", async () => {
await withIsolatedEnvAndCwd(async () => {
await withDotEnvFixture(async ({ cwdDir }) => {

View File

@@ -1,7 +1,9 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import dotenv from "dotenv";
import { resolveConfigDir } from "../utils.js";
import { resolveRequiredHomeDir } from "./home-dir.js";
import {
isDangerousHostEnvOverrideVarName,
isDangerousHostEnvVarName,
@@ -67,11 +69,21 @@ function shouldBlockWorkspaceDotEnvKey(key: string): boolean {
);
}
function loadDotEnvFile(params: {
type DotEnvEntry = {
key: string;
value: string;
};
type LoadedDotEnvFile = {
filePath: string;
entries: DotEnvEntry[];
};
function readDotEnvFile(params: {
filePath: string;
shouldBlockKey: (key: string) => boolean;
quiet?: boolean;
}) {
}): LoadedDotEnvFile | null {
let content: string;
try {
content = fs.readFileSync(params.filePath, "utf8");
@@ -83,7 +95,7 @@ function loadDotEnvFile(params: {
console.warn(`[dotenv] Failed to read ${params.filePath}: ${String(error)}`);
}
}
return;
return null;
}
let parsed: Record<string, string>;
@@ -93,13 +105,29 @@ function loadDotEnvFile(params: {
if (!params.quiet) {
console.warn(`[dotenv] Failed to parse ${params.filePath}: ${String(error)}`);
}
return;
return null;
}
const entries: DotEnvEntry[] = [];
for (const [rawKey, value] of Object.entries(parsed)) {
const key = normalizeEnvVarKey(rawKey, { portable: true });
if (!key || params.shouldBlockKey(key)) {
continue;
}
entries.push({ key, value });
}
return { filePath: params.filePath, entries };
}
export function loadRuntimeDotEnvFile(filePath: string, opts?: { quiet?: boolean }) {
const parsed = readDotEnvFile({
filePath,
shouldBlockKey: shouldBlockRuntimeDotEnvKey,
quiet: opts?.quiet ?? true,
});
if (!parsed) {
return;
}
for (const { key, value } of parsed.entries) {
if (process.env[key] !== undefined) {
continue;
}
@@ -107,20 +135,102 @@ function loadDotEnvFile(params: {
}
}
export function loadRuntimeDotEnvFile(filePath: string, opts?: { quiet?: boolean }) {
loadDotEnvFile({
filePath,
shouldBlockKey: shouldBlockRuntimeDotEnvKey,
quiet: opts?.quiet ?? true,
});
}
export function loadWorkspaceDotEnvFile(filePath: string, opts?: { quiet?: boolean }) {
loadDotEnvFile({
const parsed = readDotEnvFile({
filePath,
shouldBlockKey: shouldBlockWorkspaceDotEnvKey,
quiet: opts?.quiet ?? true,
});
if (!parsed) {
return;
}
for (const { key, value } of parsed.entries) {
if (process.env[key] !== undefined) {
continue;
}
process.env[key] = value;
}
}
function loadParsedDotEnvFiles(files: LoadedDotEnvFile[]) {
const preExistingKeys = new Set(Object.keys(process.env));
const conflicts = new Map<string, { keptPath: string; ignoredPath: string; keys: Set<string> }>();
const firstSeen = new Map<string, { value: string; filePath: string }>();
for (const file of files) {
for (const { key, value } of file.entries) {
if (preExistingKeys.has(key)) {
continue;
}
const previous = firstSeen.get(key);
if (previous) {
if (previous.value !== value) {
const conflictKey = `${previous.filePath}\u0000${file.filePath}`;
const existing = conflicts.get(conflictKey);
if (existing) {
existing.keys.add(key);
} else {
conflicts.set(conflictKey, {
keptPath: previous.filePath,
ignoredPath: file.filePath,
keys: new Set([key]),
});
}
}
continue;
}
firstSeen.set(key, { value, filePath: file.filePath });
if (process.env[key] === undefined) {
process.env[key] = value;
}
}
}
for (const conflict of conflicts.values()) {
const keys = [...conflict.keys].toSorted();
if (keys.length === 0) {
continue;
}
console.warn(
`[dotenv] Conflicting values in ${conflict.keptPath} and ${conflict.ignoredPath} for ${keys.join(", ")}; keeping ${conflict.keptPath}.`,
);
}
}
export function loadGlobalRuntimeDotEnvFiles(opts?: { quiet?: boolean; stateEnvPath?: string }) {
const quiet = opts?.quiet ?? true;
const stateEnvPath = opts?.stateEnvPath ?? path.join(resolveConfigDir(process.env), ".env");
const defaultStateEnvPath = path.join(
resolveRequiredHomeDir(process.env, os.homedir),
".openclaw",
".env",
);
const hasExplicitNonDefaultStateDir =
process.env.OPENCLAW_STATE_DIR?.trim() !== undefined &&
path.resolve(stateEnvPath) !== path.resolve(defaultStateEnvPath);
const parsedFiles = [
readDotEnvFile({
filePath: stateEnvPath,
shouldBlockKey: shouldBlockRuntimeDotEnvKey,
quiet,
}),
];
if (!hasExplicitNonDefaultStateDir) {
parsedFiles.push(
readDotEnvFile({
filePath: path.join(
resolveRequiredHomeDir(process.env, os.homedir),
".config",
"openclaw",
"gateway.env",
),
shouldBlockKey: shouldBlockRuntimeDotEnvKey,
quiet,
}),
);
}
const parsed = parsedFiles.filter((file): file is LoadedDotEnvFile => file !== null);
loadParsedDotEnvFiles(parsed);
}
export function loadDotEnv(opts?: { quiet?: boolean }) {
@@ -130,6 +240,5 @@ export function loadDotEnv(opts?: { quiet?: boolean }) {
// Then load global fallback: ~/.openclaw/.env (or OPENCLAW_STATE_DIR/.env),
// without overriding any env vars already present.
const globalEnvPath = path.join(resolveConfigDir(process.env), ".env");
loadRuntimeDotEnvFile(globalEnvPath, { quiet });
loadGlobalRuntimeDotEnvFiles({ quiet });
}