diff --git a/docs/help/environment.md b/docs/help/environment.md index 2938368d6a8..66578b5ce89 100644 --- a/docs/help/environment.md +++ b/docs/help/environment.md @@ -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 diff --git a/src/cli/dotenv.ts b/src/cli/dotenv.ts index fcdd0340c3f..0481c281971 100644 --- a/src/cli/dotenv.ts +++ b/src/cli/dotenv.ts @@ -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"), + }); } diff --git a/src/infra/dotenv.test.ts b/src/infra/dotenv.test.ts index 1940b3710b1..ea82f227ad2 100644 --- a/src/infra/dotenv.test.ts +++ b/src/infra/dotenv.test.ts @@ -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 }) => { diff --git a/src/infra/dotenv.ts b/src/infra/dotenv.ts index e82b5b2ae3d..1c3589db8c3 100644 --- a/src/infra/dotenv.ts +++ b/src/infra/dotenv.ts @@ -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; @@ -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 }>(); + const firstSeen = new Map(); + + 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 }); }