Files
openclaw/src/infra/dotenv.ts
scoootscooob 6ab1b43081 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
2026-04-04 18:24:29 -07:00

245 lines
7.1 KiB
TypeScript

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,
normalizeEnvVarKey,
} from "./host-env-security.js";
const BLOCKED_WORKSPACE_DOTENV_KEYS = new Set([
"ALL_PROXY",
"ANTHROPIC_API_KEY",
"ANTHROPIC_OAUTH_TOKEN",
"HTTP_PROXY",
"HTTPS_PROXY",
"NODE_TLS_REJECT_UNAUTHORIZED",
"NO_PROXY",
"OPENCLAW_AGENT_DIR",
"OPENCLAW_BUNDLED_HOOKS_DIR",
"OPENCLAW_BUNDLED_PLUGINS_DIR",
"OPENCLAW_BUNDLED_SKILLS_DIR",
"OPENCLAW_CONFIG_PATH",
"OPENCLAW_GATEWAY_PASSWORD",
"OPENCLAW_GATEWAY_SECRET",
"OPENCLAW_GATEWAY_TOKEN",
"OPENCLAW_HOME",
"OPENCLAW_LIVE_ANTHROPIC_KEY",
"OPENCLAW_LIVE_ANTHROPIC_KEYS",
"OPENCLAW_LIVE_GEMINI_KEY",
"OPENCLAW_LIVE_OPENAI_KEY",
"OPENCLAW_OAUTH_DIR",
"OPENCLAW_PINNED_PYTHON",
"OPENCLAW_PINNED_WRITE_PYTHON",
"OPENCLAW_PROFILE",
"OPENCLAW_STATE_DIR",
"OPENCLAW_TEST_TAILSCALE_BINARY",
"OPENAI_API_KEY",
"OPENAI_API_KEYS",
"PI_CODING_AGENT_DIR",
"UV_PYTHON",
]);
const BLOCKED_WORKSPACE_DOTENV_SUFFIXES = ["_BASE_URL"];
const BLOCKED_WORKSPACE_DOTENV_PREFIXES = ["ANTHROPIC_API_KEY_", "OPENAI_API_KEY_"];
function shouldBlockWorkspaceRuntimeDotEnvKey(key: string): boolean {
return isDangerousHostEnvVarName(key) || isDangerousHostEnvOverrideVarName(key);
}
function shouldBlockRuntimeDotEnvKey(key: string): boolean {
// The global ~/.openclaw/.env (or OPENCLAW_STATE_DIR/.env) is a trusted
// operator-controlled runtime surface. Workspace .env is untrusted and gets
// the strict blocklist, but the trusted global fallback is allowed to set
// runtime vars like proxy/base-url/auth values.
void key;
return false;
}
function shouldBlockWorkspaceDotEnvKey(key: string): boolean {
const upper = key.toUpperCase();
return (
shouldBlockWorkspaceRuntimeDotEnvKey(upper) ||
BLOCKED_WORKSPACE_DOTENV_KEYS.has(upper) ||
BLOCKED_WORKSPACE_DOTENV_PREFIXES.some((prefix) => upper.startsWith(prefix)) ||
BLOCKED_WORKSPACE_DOTENV_SUFFIXES.some((suffix) => upper.endsWith(suffix))
);
}
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");
} catch (error) {
if (!params.quiet) {
const code =
error && typeof error === "object" && "code" in error ? String(error.code) : undefined;
if (code !== "ENOENT") {
console.warn(`[dotenv] Failed to read ${params.filePath}: ${String(error)}`);
}
}
return null;
}
let parsed: Record<string, string>;
try {
parsed = dotenv.parse(content);
} catch (error) {
if (!params.quiet) {
console.warn(`[dotenv] Failed to parse ${params.filePath}: ${String(error)}`);
}
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;
}
process.env[key] = value;
}
}
export function loadWorkspaceDotEnvFile(filePath: string, opts?: { quiet?: boolean }) {
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 }) {
const quiet = opts?.quiet ?? true;
const cwdEnvPath = path.join(process.cwd(), ".env");
loadWorkspaceDotEnvFile(cwdEnvPath, { quiet });
// Then load global fallback: ~/.openclaw/.env (or OPENCLAW_STATE_DIR/.env),
// without overriding any env vars already present.
loadGlobalRuntimeDotEnvFiles({ quiet });
}