Skip to content

Commit 6ab1b43

Browse files
authored
fix(dotenv): load gateway.env compatibility fallback (openclaw#61084)
* fix(dotenv): load gateway env fallback * fix(dotenv): preserve legacy cli env loading * fix(dotenv): keep gateway fallback scoped to default profile
1 parent 9860db5 commit 6ab1b43

4 files changed

Lines changed: 247 additions & 17 deletions

File tree

docs/help/environment.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ OpenClaw pulls environment variables from multiple sources. The rule is **never
1919
4. **Config `env` block** in `~/.openclaw/openclaw.json` (applied only if missing).
2020
5. **Optional login-shell import** (`env.shellEnv.enabled` or `OPENCLAW_LOAD_SHELL_ENV=1`), applied only for missing expected keys.
2121

22+
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.
23+
2224
If the config file is missing entirely, step 4 is skipped; shell import still runs if enabled.
2325

2426
## Config `env` block

src/cli/dotenv.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
import path from "node:path";
22
import { resolveStateDir } from "../config/paths.js";
3-
import { loadRuntimeDotEnvFile, loadWorkspaceDotEnvFile } from "../infra/dotenv.js";
3+
import { loadGlobalRuntimeDotEnvFiles, loadWorkspaceDotEnvFile } from "../infra/dotenv.js";
44

55
export function loadCliDotEnv(opts?: { quiet?: boolean }) {
66
const quiet = opts?.quiet ?? true;
77
const cwdEnvPath = path.join(process.cwd(), ".env");
88
loadWorkspaceDotEnvFile(cwdEnvPath, { quiet });
99

10-
// Then load the global fallback from the active state dir without overriding
11-
// any env vars that were already set or loaded from CWD.
12-
const globalEnvPath = path.join(resolveStateDir(process.env), ".env");
13-
loadRuntimeDotEnvFile(globalEnvPath, { quiet });
10+
// Then load the global fallback set without overriding any env vars that
11+
// were already set or loaded from CWD. This includes the Ubuntu fresh-install
12+
// gateway.env compatibility path.
13+
loadGlobalRuntimeDotEnvFiles({
14+
quiet,
15+
stateEnvPath: path.join(resolveStateDir(process.env), ".env"),
16+
});
1417
}

src/infra/dotenv.test.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,55 @@ describe("loadDotEnv", () => {
9797
});
9898
});
9999

100+
it("loads the Ubuntu gateway.env compatibility fallback after ~/.openclaw/.env", async () => {
101+
await withIsolatedEnvAndCwd(async () => {
102+
await withDotEnvFixture(async ({ base, cwdDir }) => {
103+
process.env.HOME = base;
104+
const defaultStateDir = path.join(base, ".openclaw");
105+
process.env.OPENCLAW_STATE_DIR = defaultStateDir;
106+
await writeEnvFile(path.join(defaultStateDir, ".env"), "FOO=from-global\n");
107+
await writeEnvFile(
108+
path.join(base, ".config", "openclaw", "gateway.env"),
109+
["FOO=from-gateway", "BAR=from-gateway"].join("\n"),
110+
);
111+
112+
vi.spyOn(process, "cwd").mockReturnValue(cwdDir);
113+
delete process.env.FOO;
114+
delete process.env.BAR;
115+
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
116+
117+
loadDotEnv({ quiet: true });
118+
119+
expect(process.env.FOO).toBe("from-global");
120+
expect(process.env.BAR).toBe("from-gateway");
121+
expect(warn).toHaveBeenCalledWith(expect.stringContaining("Conflicting values in"));
122+
expect(warn).toHaveBeenCalledWith(expect.stringContaining("gateway.env"));
123+
});
124+
});
125+
});
126+
127+
it("does not warn about dotenv conflicts when the key is already set", async () => {
128+
await withIsolatedEnvAndCwd(async () => {
129+
await withDotEnvFixture(async ({ base, cwdDir, stateDir }) => {
130+
process.env.HOME = base;
131+
process.env.FOO = "from-shell";
132+
await writeEnvFile(path.join(stateDir, ".env"), "FOO=from-global\n");
133+
await writeEnvFile(
134+
path.join(base, ".config", "openclaw", "gateway.env"),
135+
"FOO=from-gateway\n",
136+
);
137+
138+
vi.spyOn(process, "cwd").mockReturnValue(cwdDir);
139+
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
140+
141+
loadDotEnv({ quiet: true });
142+
143+
expect(process.env.FOO).toBe("from-shell");
144+
expect(warn).not.toHaveBeenCalled();
145+
});
146+
});
147+
});
148+
100149
it("blocks dangerous and workspace-control vars from CWD .env", async () => {
101150
await withIsolatedEnvAndCwd(async () => {
102151
await withDotEnvFixture(async ({ cwdDir, stateDir }) => {
@@ -427,6 +476,73 @@ describe("loadCliDotEnv", () => {
427476
});
428477
});
429478

479+
it("loads the gateway.env compatibility fallback during CLI startup", async () => {
480+
await withIsolatedEnvAndCwd(async () => {
481+
await withDotEnvFixture(async ({ base, cwdDir }) => {
482+
process.env.HOME = base;
483+
const defaultStateDir = path.join(base, ".openclaw");
484+
process.env.OPENCLAW_STATE_DIR = defaultStateDir;
485+
await writeEnvFile(path.join(defaultStateDir, ".env"), "FOO=from-global\n");
486+
await writeEnvFile(
487+
path.join(base, ".config", "openclaw", "gateway.env"),
488+
"BAR=from-gateway\n",
489+
);
490+
491+
vi.spyOn(process, "cwd").mockReturnValue(cwdDir);
492+
delete process.env.FOO;
493+
delete process.env.BAR;
494+
495+
loadCliDotEnv({ quiet: true });
496+
497+
expect(process.env.FOO).toBe("from-global");
498+
expect(process.env.BAR).toBe("from-gateway");
499+
});
500+
});
501+
});
502+
503+
it("does not load gateway.env when OPENCLAW_STATE_DIR is explicitly set", async () => {
504+
await withIsolatedEnvAndCwd(async () => {
505+
await withDotEnvFixture(async ({ base, cwdDir }) => {
506+
const customStateDir = path.join(base, "custom-state");
507+
process.env.HOME = base;
508+
process.env.OPENCLAW_STATE_DIR = customStateDir;
509+
await writeEnvFile(
510+
path.join(base, ".config", "openclaw", "gateway.env"),
511+
"FOO=from-gateway\n",
512+
);
513+
514+
vi.spyOn(process, "cwd").mockReturnValue(cwdDir);
515+
delete process.env.FOO;
516+
517+
loadCliDotEnv({ quiet: true });
518+
519+
expect(process.env.FOO).toBeUndefined();
520+
expect(process.env.OPENCLAW_STATE_DIR).toBe(customStateDir);
521+
expect(process.env.BAR).toBeUndefined();
522+
});
523+
});
524+
});
525+
526+
it("keeps the legacy state-dir fallback for CLI dotenv loading", async () => {
527+
await withIsolatedEnvAndCwd(async () => {
528+
const base = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-dotenv-legacy-"));
529+
const cwdDir = path.join(base, "cwd");
530+
const legacyStateDir = path.join(base, ".clawdbot");
531+
process.env.HOME = base;
532+
delete process.env.OPENCLAW_STATE_DIR;
533+
delete process.env.OPENCLAW_TEST_FAST;
534+
await fs.mkdir(cwdDir, { recursive: true });
535+
await writeEnvFile(path.join(legacyStateDir, ".env"), "LEGACY_ONLY=from-legacy\n");
536+
537+
vi.spyOn(process, "cwd").mockReturnValue(cwdDir);
538+
delete process.env.LEGACY_ONLY;
539+
540+
loadCliDotEnv({ quiet: true });
541+
542+
expect(process.env.LEGACY_ONLY).toBe("from-legacy");
543+
});
544+
});
545+
430546
it("blocks bundled trust-root vars from workspace .env during CLI startup", async () => {
431547
await withIsolatedEnvAndCwd(async () => {
432548
await withDotEnvFixture(async ({ cwdDir }) => {

src/infra/dotenv.ts

Lines changed: 121 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import fs from "node:fs";
2+
import os from "node:os";
23
import path from "node:path";
34
import dotenv from "dotenv";
45
import { resolveConfigDir } from "../utils.js";
6+
import { resolveRequiredHomeDir } from "./home-dir.js";
57
import {
68
isDangerousHostEnvOverrideVarName,
79
isDangerousHostEnvVarName,
@@ -67,11 +69,21 @@ function shouldBlockWorkspaceDotEnvKey(key: string): boolean {
6769
);
6870
}
6971

70-
function loadDotEnvFile(params: {
72+
type DotEnvEntry = {
73+
key: string;
74+
value: string;
75+
};
76+
77+
type LoadedDotEnvFile = {
78+
filePath: string;
79+
entries: DotEnvEntry[];
80+
};
81+
82+
function readDotEnvFile(params: {
7183
filePath: string;
7284
shouldBlockKey: (key: string) => boolean;
7385
quiet?: boolean;
74-
}) {
86+
}): LoadedDotEnvFile | null {
7587
let content: string;
7688
try {
7789
content = fs.readFileSync(params.filePath, "utf8");
@@ -83,7 +95,7 @@ function loadDotEnvFile(params: {
8395
console.warn(`[dotenv] Failed to read ${params.filePath}: ${String(error)}`);
8496
}
8597
}
86-
return;
98+
return null;
8799
}
88100

89101
let parsed: Record<string, string>;
@@ -93,34 +105,132 @@ function loadDotEnvFile(params: {
93105
if (!params.quiet) {
94106
console.warn(`[dotenv] Failed to parse ${params.filePath}: ${String(error)}`);
95107
}
96-
return;
108+
return null;
97109
}
110+
const entries: DotEnvEntry[] = [];
98111
for (const [rawKey, value] of Object.entries(parsed)) {
99112
const key = normalizeEnvVarKey(rawKey, { portable: true });
100113
if (!key || params.shouldBlockKey(key)) {
101114
continue;
102115
}
103-
if (process.env[key] !== undefined) {
104-
continue;
105-
}
106-
process.env[key] = value;
116+
entries.push({ key, value });
107117
}
118+
return { filePath: params.filePath, entries };
108119
}
109120

110121
export function loadRuntimeDotEnvFile(filePath: string, opts?: { quiet?: boolean }) {
111-
loadDotEnvFile({
122+
const parsed = readDotEnvFile({
112123
filePath,
113124
shouldBlockKey: shouldBlockRuntimeDotEnvKey,
114125
quiet: opts?.quiet ?? true,
115126
});
127+
if (!parsed) {
128+
return;
129+
}
130+
for (const { key, value } of parsed.entries) {
131+
if (process.env[key] !== undefined) {
132+
continue;
133+
}
134+
process.env[key] = value;
135+
}
116136
}
117137

118138
export function loadWorkspaceDotEnvFile(filePath: string, opts?: { quiet?: boolean }) {
119-
loadDotEnvFile({
139+
const parsed = readDotEnvFile({
120140
filePath,
121141
shouldBlockKey: shouldBlockWorkspaceDotEnvKey,
122142
quiet: opts?.quiet ?? true,
123143
});
144+
if (!parsed) {
145+
return;
146+
}
147+
for (const { key, value } of parsed.entries) {
148+
if (process.env[key] !== undefined) {
149+
continue;
150+
}
151+
process.env[key] = value;
152+
}
153+
}
154+
155+
function loadParsedDotEnvFiles(files: LoadedDotEnvFile[]) {
156+
const preExistingKeys = new Set(Object.keys(process.env));
157+
const conflicts = new Map<string, { keptPath: string; ignoredPath: string; keys: Set<string> }>();
158+
const firstSeen = new Map<string, { value: string; filePath: string }>();
159+
160+
for (const file of files) {
161+
for (const { key, value } of file.entries) {
162+
if (preExistingKeys.has(key)) {
163+
continue;
164+
}
165+
const previous = firstSeen.get(key);
166+
if (previous) {
167+
if (previous.value !== value) {
168+
const conflictKey = `${previous.filePath}\u0000${file.filePath}`;
169+
const existing = conflicts.get(conflictKey);
170+
if (existing) {
171+
existing.keys.add(key);
172+
} else {
173+
conflicts.set(conflictKey, {
174+
keptPath: previous.filePath,
175+
ignoredPath: file.filePath,
176+
keys: new Set([key]),
177+
});
178+
}
179+
}
180+
continue;
181+
}
182+
firstSeen.set(key, { value, filePath: file.filePath });
183+
if (process.env[key] === undefined) {
184+
process.env[key] = value;
185+
}
186+
}
187+
}
188+
189+
for (const conflict of conflicts.values()) {
190+
const keys = [...conflict.keys].toSorted();
191+
if (keys.length === 0) {
192+
continue;
193+
}
194+
console.warn(
195+
`[dotenv] Conflicting values in ${conflict.keptPath} and ${conflict.ignoredPath} for ${keys.join(", ")}; keeping ${conflict.keptPath}.`,
196+
);
197+
}
198+
}
199+
200+
export function loadGlobalRuntimeDotEnvFiles(opts?: { quiet?: boolean; stateEnvPath?: string }) {
201+
const quiet = opts?.quiet ?? true;
202+
const stateEnvPath = opts?.stateEnvPath ?? path.join(resolveConfigDir(process.env), ".env");
203+
const defaultStateEnvPath = path.join(
204+
resolveRequiredHomeDir(process.env, os.homedir),
205+
".openclaw",
206+
".env",
207+
);
208+
const hasExplicitNonDefaultStateDir =
209+
process.env.OPENCLAW_STATE_DIR?.trim() !== undefined &&
210+
path.resolve(stateEnvPath) !== path.resolve(defaultStateEnvPath);
211+
const parsedFiles = [
212+
readDotEnvFile({
213+
filePath: stateEnvPath,
214+
shouldBlockKey: shouldBlockRuntimeDotEnvKey,
215+
quiet,
216+
}),
217+
];
218+
if (!hasExplicitNonDefaultStateDir) {
219+
parsedFiles.push(
220+
readDotEnvFile({
221+
filePath: path.join(
222+
resolveRequiredHomeDir(process.env, os.homedir),
223+
".config",
224+
"openclaw",
225+
"gateway.env",
226+
),
227+
shouldBlockKey: shouldBlockRuntimeDotEnvKey,
228+
quiet,
229+
}),
230+
);
231+
}
232+
const parsed = parsedFiles.filter((file): file is LoadedDotEnvFile => file !== null);
233+
loadParsedDotEnvFiles(parsed);
124234
}
125235

126236
export function loadDotEnv(opts?: { quiet?: boolean }) {
@@ -130,6 +240,5 @@ export function loadDotEnv(opts?: { quiet?: boolean }) {
130240

131241
// Then load global fallback: ~/.openclaw/.env (or OPENCLAW_STATE_DIR/.env),
132242
// without overriding any env vars already present.
133-
const globalEnvPath = path.join(resolveConfigDir(process.env), ".env");
134-
loadRuntimeDotEnvFile(globalEnvPath, { quiet });
243+
loadGlobalRuntimeDotEnvFiles({ quiet });
135244
}

0 commit comments

Comments
 (0)