11import fs from "node:fs" ;
2+ import os from "node:os" ;
23import path from "node:path" ;
34import dotenv from "dotenv" ;
45import { resolveConfigDir } from "../utils.js" ;
6+ import { resolveRequiredHomeDir } from "./home-dir.js" ;
57import {
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
110121export 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
118138export 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
126236export 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