mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-05 14:21:32 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user