@@ -162,59 +162,46 @@ func resetLoginShellEnvCacheForTest() {
162162// Linux CI runners. Defaults to the real GOOS.
163163var hydrationGOOS = runtime .GOOS
164164
165- // curatedSingleKeys is the allow-list of single-spelling environment variables
166- // hydrated from the login shell. It deliberately EXCLUDES secrets (AWS_*,
167- // GITHUB_TOKEN, ANTHROPIC_API_KEY, …): wholesale import would pull developer
168- // credentials into the daemon and every MCP subprocess. We import only PATH
169- // (handled separately), container, and tool-home configuration. HOME / USER /
170- // SHELL are never touched. Proxy vars are handled separately (proxyVarPairs)
171- // because they are case-insensitive to clients.
165+ // curatedSingleKeys is the allow-list of environment variables hydrated from
166+ // the login shell. It deliberately EXCLUDES secrets (AWS_*, GITHUB_TOKEN,
167+ // ANTHROPIC_API_KEY, …) and proxy vars (HTTP_PROXY/HTTPS_PROXY — proxy
168+ // forwarding is out of scope here; filed as a separate opt-in follow-up).
169+ // HOME / USER / SHELL are never touched.
172170var curatedSingleKeys = []string {
173171 // Docker / container engine selection (supersedes the per-spawn capture in #699).
174172 "DOCKER_HOST" , "DOCKER_CONTEXT" , "DOCKER_CONFIG" , "DOCKER_CERT_PATH" , "DOCKER_TLS_VERIFY" ,
175173 // Tool-home roots that node/python version managers rely on.
176174 "NVM_DIR" , "ASDF_DIR" , "PYENV_ROOT" , "VOLTA_HOME" , "HOMEBREW_PREFIX" , "COLIMA_HOME" ,
177175}
178176
179- // proxyVarPairs groups each logical proxy variable's UPPER/lower spellings.
180- // HTTP clients (Go's net/http, curl, docker, …) honor EITHER spelling, so the
181- // two spellings are one logical var: if the operator set either one (even to
182- // empty, to disable an inherited proxy), hydrating the other spelling from the
183- // login shell would override that intent. We therefore skip BOTH spellings when
184- // EITHER is already present.
185- var proxyVarPairs = [][2 ]string {
186- {"HTTP_PROXY" , "http_proxy" },
187- {"HTTPS_PROXY" , "https_proxy" },
188- {"NO_PROXY" , "no_proxy" },
189- }
190-
191177// HydrateFromLoginShell performs a one-time, allow-listed merge of the user's
192178// login-shell environment into the current process environment via os.Setenv,
193179// so that every downstream spawn path (docker lifecycle, stdio servers,
194180// uvx/npx, secureenv.BuildSecureEnvironment, ResolveDockerPath) inherits a
195181// correct PATH and curated vars with no call-site changes.
196182//
197- // It is a no-op (applied=false) unless mcpproxy was launched into a degraded
198- // environment: macOS only, AND the ambient PATH looks launchd-minimal (lacks
199- // /usr/local/bin and /opt/homebrew/bin). Terminal launches are left untouched.
183+ // Hydration is triggered on macOS when:
184+ // - the ambient PATH looks launchd-minimal (lacks /usr/local/bin or
185+ // /opt/homebrew/bin), OR
186+ // - any DOCKER_* curated var is absent (a GUI launcher may pre-seed PATH
187+ // via /etc/paths yet still not export DOCKER_HOST from rc files).
200188//
201- // PATH is merged login-first (enriching, never shrinking). Curated keys are
202- // applied set-if-unset only — any operator-set value (including an explicitly
203- // empty value used to disable an inherited one) is never clobbered. The
204- // returned snapshot maps each applied key to its value for diagnostics; this
205- // function never logs values (key names + lengths only).
189+ // PATH is merged login-first (enriching, never shrinking) only when the PATH
190+ // is launchd-minimal. Curated keys are applied set-if-unset whenever hydration
191+ // triggers. The returned snapshot maps each applied key to its value for
192+ // diagnostics; this function never logs values (key names + lengths only).
206193func HydrateFromLoginShell (logger * zap.Logger ) (applied bool , snapshot map [string ]string ) {
207194 snapshot = make (map [string ]string )
208195
209196 if hydrationGOOS != osDarwin {
210- // Linux LaunchAgent-equivalent is systemd (Environment=/EnvironmentFile=),
211- // not shell sourcing. Windows uses the registry PATH and has no login
212- // shell. Gate off both.
197+ // Linux: systemd Environment=/EnvironmentFile=. Windows: registry PATH.
198+ // Neither uses login-shell sourcing.
213199 return false , snapshot
214200 }
215- if ! looksLikeLaunchdMinimalPath (os .Getenv ("PATH" )) {
216- // Healthy interactive launch — preserve the zero-cost / no-pollution
217- // guarantee (mirrors secureenv TestLaunchdMinimalPath_AlreadyComprehensive).
201+
202+ pathIsMinimal := looksLikeLaunchdMinimalPath (os .Getenv ("PATH" ))
203+ if ! pathIsMinimal && ! anyDockerVarMissing () {
204+ // Healthy interactive launch with all docker vars present — no-op.
218205 return false , snapshot
219206 }
220207
@@ -225,38 +212,26 @@ func HydrateFromLoginShell(logger *zap.Logger) (applied bool, snapshot map[strin
225212
226213 sep := string (os .PathListSeparator )
227214
228- // PATH: always merge login-first so docker / uvx / npx and their credential
229- // helpers resolve. Enrich only — never shrink an already-broader PATH.
230- if loginPath := env ["PATH" ]; loginPath != "" {
231- current := os .Getenv ("PATH" )
232- merged := mergePathUnique (loginPath , current , sep )
233- if merged != current {
234- _ = os .Setenv ("PATH" , merged )
235- snapshot ["PATH" ] = merged
215+ // PATH: merge login-first so docker / uvx / npx resolve correctly.
216+ // Only merge when PATH looks launchd-minimal — a comprehensive PATH is left
217+ // untouched even if curated vars triggered hydration.
218+ if pathIsMinimal {
219+ if loginPath := env ["PATH" ]; loginPath != "" {
220+ current := os .Getenv ("PATH" )
221+ merged := mergePathUnique (loginPath , current , sep )
222+ if merged != current {
223+ _ = os .Setenv ("PATH" , merged )
224+ snapshot ["PATH" ] = merged
225+ }
236226 }
237227 }
238228
239- // Single-spelling curated keys: set-if-unset only, never clobber an
240- // operator-set value (LookupEnv, so an explicit empty value is preserved).
229+ // Curated keys: set-if-unset only, never clobber an operator-set value
230+ // (LookupEnv so an explicit empty value is preserved).
241231 for _ , key := range curatedSingleKeys {
242232 setEnvIfUnset (env , key , snapshot )
243233 }
244234
245- // Proxy trio: alias-aware. If EITHER spelling is already present (even
246- // intentionally empty, e.g. `https_proxy=` to disable), skip hydrating BOTH
247- // — clients honor either spelling so importing the opposite case would
248- // override operator intent.
249- for _ , pair := range proxyVarPairs {
250- if _ , up := os .LookupEnv (pair [0 ]); up {
251- continue
252- }
253- if _ , lo := os .LookupEnv (pair [1 ]); lo {
254- continue
255- }
256- setEnvIfUnset (env , pair [0 ], snapshot )
257- setEnvIfUnset (env , pair [1 ], snapshot )
258- }
259-
260235 if len (snapshot ) == 0 {
261236 return false , snapshot
262237 }
@@ -275,6 +250,22 @@ func HydrateFromLoginShell(logger *zap.Logger) (applied bool, snapshot map[strin
275250 return true , snapshot
276251}
277252
253+ // anyDockerVarMissing reports whether any DOCKER_* entry from curatedSingleKeys
254+ // is absent from the current process environment. Used as a secondary hydration
255+ // trigger when PATH is already comprehensive but Docker connectivity vars are
256+ // absent (common when a GUI launcher pre-seeds PATH via /etc/paths but rc files
257+ // set DOCKER_HOST only in login-shell context).
258+ func anyDockerVarMissing () bool {
259+ for _ , key := range curatedSingleKeys {
260+ if strings .HasPrefix (key , "DOCKER_" ) {
261+ if _ , present := os .LookupEnv (key ); ! present {
262+ return true
263+ }
264+ }
265+ }
266+ return false
267+ }
268+
278269// setEnvIfUnset hydrates a single key from the captured login-shell env when it
279270// provides a non-empty value and the key is not already present in the process
280271// env. Records applied keys in snapshot. Uses LookupEnv so an explicitly
0 commit comments