Skip to content

feat(shellwrap): hydrate login-shell env once at startup (macOS GUI/launchd) — MCP-2751#701

Merged
Dumbris merged 4 commits into
mainfrom
fix/mcp-2751-login-shell-env-hydration
Jun 17, 2026
Merged

feat(shellwrap): hydrate login-shell env once at startup (macOS GUI/launchd) — MCP-2751#701
Dumbris merged 4 commits into
mainfrom
fix/mcp-2751-login-shell-env-hydration

Conversation

@Dumbris

@Dumbris Dumbris commented Jun 17, 2026

Copy link
Copy Markdown
Member

Summary

Fixes the root-cause class behind the recent docker/PATH point-fixes (#697 resolve-absolute docker path, #699 DOCKER_* capture): when mcpproxy is launched from a macOS GUI/launchd context (Launchpad/Dock, the SMAppService login item, or the tray spawning the core), the Go core inherits a launchd-minimal environment. It never sources the user's login shell, so it lacks Homebrew/Docker PATH entries, docker/uvx/npx on PATH, and exported vars like DOCKER_HOST/DOCKER_CONTEXT. Launched from a terminal it's healthy.

This adds a one-time, allow-listed login-shell environment hydration at startup so every spawn path inherits a correct environment without per-spawn $SHELL -lc wrapping — the durable consolidation that supersedes per-var capture.

What changed

  • internal/shellwrap/hydrate.go (new): captureLoginShellEnv() sources $SHELL -l -c 'env -0' once (NUL-delimited so multiline/=-containing values survive; marker-bracketed to strip rc-file banner noise; 5s timeout; sync.Once) and caches the parsed env. LoginShellPATH now reads PATH from this shared capture — one shell fork, not two.
  • HydrateFromLoginShell(logger): gates on macOS and launchd-minimal PATH (no-op on terminal launches and non-macOS), merges PATH login-first, and applies a curated allow-list set-if-empty via os.Setenv:
    • DOCKER_HOST, DOCKER_CONTEXT, DOCKER_CONFIG, DOCKER_CERT_PATH, DOCKER_TLS_VERIFY
    • HTTP_PROXY/HTTPS_PROXY/NO_PROXY (+ lowercase)
    • NVM_DIR, ASDF_DIR, PYENV_ROOT, VOLTA_HOME, HOMEBREW_PREFIX, COLIMA_HOME
  • internal/secureenv/manager.go: extend the default allow-list with the curated keys so the hydrated vars survive the filter into upstream stdio/docker spawns. Scanner MinimalEnv stays narrow (credential-free).
  • cmd/mcpproxy/main.go: call HydrateFromLoginShell after logger setup, before any manager reads os.Environ() — one os.Setenv hydration propagates to all spawn paths.
  • docs: MCPX_STDIO_SPAWN_ENOENT.md updated to describe the automatic startup hydration.

Security / privacy

  • Allow-list, never wholesale env import — secrets (AWS_*, GITHUB_TOKEN, ANTHROPIC_API_KEY, …) are never pulled into the daemon or subprocesses (unit-tested).
  • Set-if-empty — never clobbers an operator-set value. HOME/USER/SHELL never touched.
  • Values never logged — key names + lengths only (privacy unit test).

Relationship to #697 / #699

Tests / verification

  • internal/shellwrap: gate no-op (non-darwin + comprehensive PATH), curated-set hydration, set-if-empty never-clobber, never-touch HOME/USER/SHELL, secret-exclusion, privacy (no values logged), capture-failure no-op. Gate seam (hydrationGOOS) exercises the logic on Linux CI.
  • internal/secureenv: curated DOCKER_*/proxy vars pass the allow-list while secrets stay filtered; existing TestLaunchdMinimalPath_* gate guarantees preserved.
  • go test ./internal/shellwrap/... ./internal/secureenv/... ./internal/upstream/core/... -race — green.
  • golangci-lint v2 (CI config) — 0 issues. Both editions build.
  • Real-binary check: launched with a simulated launchd-minimal PATH → hydration fires and enriches PATH; launched with a comprehensive PATH → no-op (gate confirmed).

Related #699

…aunchd)

When mcpproxy is launched from a macOS GUI/launchd context (Launchpad, the
SMAppService login item, or the tray spawning the core), the Go core inherits a
launchd-minimal environment: it never sources the user's login shell, so it
lacks Homebrew/Docker PATH entries and exported vars like DOCKER_HOST. This is
the root-cause class behind the recent docker/PATH point-fixes (#697, #699).

Add a one-time, allow-listed login-shell environment hydration at startup so
every spawn path (docker lifecycle, stdio servers, uvx/npx, ResolveDockerPath,
secureenv.BuildSecureEnvironment) inherits a correct environment with no
call-site changes:

- internal/shellwrap/hydrate.go: captureLoginShellEnv() sources `$SHELL -l -c
  'env -0'` once (NUL-delimited, marker-bracketed, 5s timeout, sync.Once) and
  caches the parsed env. LoginShellPATH now reads PATH from this shared capture
  — one shell fork, not two.
- HydrateFromLoginShell() gates on macOS + launchd-minimal PATH (no-op on
  terminal launches and non-macOS), merges PATH login-first, and applies a
  curated allow-list (DOCKER_*, HTTP(S)/NO_PROXY, NVM_DIR/ASDF_DIR/PYENV_ROOT/
  VOLTA_HOME/HOMEBREW_PREFIX/COLIMA_HOME) set-if-empty via os.Setenv. Secrets
  are never imported; HOME/USER/SHELL are never touched; values are never
  logged (key names + lengths only).
- secureenv: extend the default allow-list with the curated DOCKER_*/proxy/
  tool-home keys so the hydrated vars survive the filter into upstream spawns.
  Scanner MinimalEnv stays narrow (credential-free).
- cmd/mcpproxy: call HydrateFromLoginShell after logger setup, before any
  manager reads os.Environ().

Supersedes the per-spawn DOCKER_* env capture; the absolute-path docker probe
(#697) and negative-cache fix (#699) remain as complementary fallbacks.

Related #699
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 17, 2026

Copy link
Copy Markdown

Deploying mcpproxy-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: 5f0f861
Status: ✅  Deploy successful!
Preview URL: https://c42d0d9b.mcpproxy-docs.pages.dev
Branch Preview URL: https://fix-mcp-2751-login-shell-env.mcpproxy-docs.pages.dev

View logs

@codecov-commenter

codecov-commenter commented Jun 17, 2026

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 85.15625% with 19 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/shellwrap/hydrate.go 84.74% 11 Missing and 7 partials ⚠️
cmd/mcpproxy/main.go 0.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@github-actions

github-actions Bot commented Jun 17, 2026

Copy link
Copy Markdown

📦 Build Artifacts

Workflow Run: View Run
Branch: fix/mcp-2751-login-shell-env-hydration

Available Artifacts

  • archive-darwin-amd64 (28 MB)
  • archive-darwin-arm64 (25 MB)
  • archive-linux-amd64 (16 MB)
  • archive-linux-arm64 (14 MB)
  • archive-windows-amd64 (28 MB)
  • archive-windows-arm64 (25 MB)
  • frontend-dist-pr (0 MB)
  • installer-dmg-darwin-amd64 (21 MB)
  • installer-dmg-darwin-arm64 (19 MB)

How to Download

Option 1: GitHub Web UI (easiest)

  1. Go to the workflow run page linked above
  2. Scroll to the bottom "Artifacts" section
  3. Click on the artifact you want to download

Option 2: GitHub CLI

gh run download 27684497751 --repo smart-mcp-proxy/mcpproxy-go

Note: Artifacts expire in 14 days.

@Dumbris

Dumbris commented Jun 17, 2026

Copy link
Copy Markdown
Member Author

🤖 Codex review — REQUEST_CHANGES (1×P2)

[P2] Respect explicitly-empty operator env vars — internal/shellwrap/hydrate.go:234-236

The set-if-empty guard treats an explicitly-set empty var the same as unset, so an operator who sets HTTPS_PROXY= or DOCKER_HOST= to disable an inherited login-shell value will have it overwritten from the login shell — violating the "never clobber operator-set values" contract. Use os.LookupEnv to distinguish unset from intentionally-empty before hydrating.

Reviewed via codex exec review --base origin/main.

…ration

Codex review (PR #701, P2): the curated-key set-if-empty guard used
`os.Getenv(key) != ""`, which treats an explicitly set-empty value the same
as unset. An operator who sets `HTTPS_PROXY=` or `DOCKER_HOST=` to DISABLE an
inherited value would have it overwritten from the login shell — violating the
never-clobber-operator-values contract.

Use os.LookupEnv to distinguish unset from intentionally-empty: any present
key (even empty) is operator intent and is preserved. launchd never sets these
to empty, so present-but-empty is always deliberate. Adds
TestHydrate_PreservesIntentionallyEmptyVar.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
@Dumbris

Dumbris commented Jun 17, 2026

Copy link
Copy Markdown
Member Author

Addressed — Codex P2 (set-if-empty vs intentionally-empty)

Valid catch, fixed in e42f22f. The curated-key guard used os.Getenv(key) != "", which can't distinguish unset from explicitly set-empty — so an operator who sets HTTPS_PROXY= / DOCKER_HOST= to disable an inherited value would have it clobbered from the login shell.

Fix: switched the guard to os.LookupEnv(key) — any present key (even empty) is treated as operator intent and preserved. launchd never sets these to empty, so present-but-empty is always deliberate.

Test: added TestHydrate_PreservesIntentionallyEmptyVar — sets HTTPS_PROXY="", has the login shell export a non-empty proxy, and asserts the var stays present-and-empty (not overwritten) and is absent from the diagnostic snapshot. (Under the old guard this test fails.)

go test ./internal/shellwrap/... -race green; golangci-lint v2 (CI config) 0 issues.

@Dumbris

Dumbris commented Jun 17, 2026

Copy link
Copy Markdown
Member Author

🤖 Codex re-review (round 2) — REQUEST_CHANGES (1×P2)

[P2] Treat proxy spellings as aliases before hydrating — internal/shellwrap/hydrate.go:241-242

When the daemon already has a proxy var in the opposite case (e.g. https_proxy= to disable, or https_proxy=http://operator), the exact-key check for HTTPS_PROXY misses it and imports the login-shell variant. HTTP clients honor either spelling, so a downstream MCP server can end up using the login-shell proxy despite an operator-set value. Check all case variants of the logical proxy var before hydrating either spelling.

Reviewed via codex exec review --base origin/main on e42f22fd.

Codex round-2 (PR #701, P2): proxy vars (HTTP_PROXY/HTTPS_PROXY/NO_PROXY) are
case-insensitive to HTTP clients, so the per-key LookupEnv guard could still
clobber operator intent — e.g. operator sets `https_proxy=` (lowercase, to
disable) but the login shell exports `HTTPS_PROXY=...`; the exact-key check on
the uppercase spelling missed the lowercase presence and imported it anyway.

Split the curated allow-list into single-spelling keys (DOCKER_*, tool-home)
and an alias-aware proxy trio: for each logical proxy var, if EITHER spelling
is already present in the process env (including intentionally empty), skip
hydrating BOTH spellings. Adds TestHydrate_ProxyCaseAliasingPreservesOperatorIntent
(daemon https_proxy='' + login HTTPS_PROXY=http://x → neither imported).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
@Dumbris

Dumbris commented Jun 17, 2026

Copy link
Copy Markdown
Member Author

Addressed — Codex round-2 P2 (proxy case-aliasing)

Correct again, fixed in 3983b7e. Since HTTP clients honor either spelling, the two case variants of each proxy var are one logical var. The per-key LookupEnv still missed the opposite-case operator value.

Fix: split the allow-list into single-spelling keys (DOCKER_, tool-home) and an alias-aware proxy trio. For each logical proxy var I now check both UPPER and lower via os.LookupEnv before hydrating; if either is present (including intentionally empty), I skip hydrating both spellings. DOCKER_/tool-home keep the single-spelling set-if-unset path.

Test: added TestHydrate_ProxyCaseAliasingPreservesOperatorIntent — daemon has https_proxy='' (lowercase empty), login shell exports HTTPS_PROXY=http://x → neither spelling imported, operator's empty lowercase preserved. (Fails under the previous per-key guard.)

go test ./internal/shellwrap/... ./internal/secureenv/... -race green; golangci-lint v2 (CI config) 0 issues; both editions build.

@Dumbris

Dumbris commented Jun 17, 2026

Copy link
Copy Markdown
Member Author

🤖 Codex re-review (round 3) — REQUEST_CHANGES (2×P2)

[P2] Don't gate hydration on PATH alone — internal/shellwrap/hydrate.go:212-217

A GUI launch can have PATH pre-seeded (e.g. launchctl config user path includes /opt/homebrew/bin) yet still lack DOCKER_HOST/tool-home vars; this early-return then skips hydration and leaves the non-PATH part unresolved. Trigger hydration when curated vars are missing, not only when PATH looks minimal.

[P2] Credential leak — proxy vars in the default allow-list — internal/secureenv/manager.go:95-98

Adding HTTP_PROXY/HTTPS_PROXY to the default allow-list forwards credential-bearing proxy URLs (http://user:pass@proxy) to every stdio upstream on all platforms.

Resolution (scope reduction): drop proxy vars from this PR entirely — remove HTTP_PROXY/HTTPS_PROXY/NO_PROXY from both hydration and the secureenv allow-list. Proxy forwarding is out of scope for MCP-2751 (it's the source of the round-2 alias churn and this credential leak) and will be a separate opt-in feature. Keep PATH + DOCKER_* + tool-homes, and fix the launch gate above.

Reviewed via codex exec review --base origin/main on 3983b7e3.

Codex round-3 scope reduction:
- Remove HTTP_PROXY/HTTPS_PROXY/NO_PROXY from hydration and from
  secureenv DefaultEnvConfig allow-list. Proxy forwarding to every
  stdio upstream is out of scope; filed as a separate opt-in follow-up.
- Fix the launch gate: hydration now triggers when PATH looks launchd-
  minimal OR any DOCKER_* curated var is absent. A GUI launcher can
  pre-seed PATH via /etc/paths yet still not export DOCKER_HOST from
  rc files, so PATH-comprehensiveness alone is insufficient.
- PATH-merge stays gated on pathIsMinimal only — a comprehensive PATH
  is not widened even when the DOCKER_* gate fires.

Tests:
- Rename GateNoOpOnComprehensivePath → GateNoOpOnComprehensivePathAndAllDockerPresent
  (now requires both conditions, so all 5 DOCKER_* vars are pre-set)
- Add ComprehensivePathStillHydratesDockerIfMissing — verifies the new
  secondary gate: comprehensive PATH, missing DOCKER_HOST → hydrates
  curated vars but leaves PATH unmodified
- Drop PreservesIntentionallyEmptyVar and ProxyCaseAliasingPreservesOperatorIntent
  (proxy vars are no longer hydrated)
- Update MinimalPathHydratesCuratedSet to assert proxy vars are NOT imported

Co-Authored-By: Paperclip <noreply@paperclip.ing>
@Dumbris

Dumbris commented Jun 17, 2026

Copy link
Copy Markdown
Member Author

Round-3 fix pushed (5f0f8617)

Two changes as directed:

1. Proxy vars dropped entirely

  • Removed HTTP_PROXY/HTTPS_PROXY/NO_PROXY and the whole proxyVarPairs alias logic from hydration
  • Removed them from secureenv.DefaultEnvConfig allow-list (manager.go)
  • Removed tests: PreservesIntentionallyEmptyVar, ProxyCaseAliasingPreservesOperatorIntent
  • Added negative assertion in MinimalPathHydratesCuratedSet that proxy vars are NOT imported

2. Launch gate fixed for pre-seeded PATH

  • Gate now: pathIsMinimal || anyDockerVarMissing()
  • PATH-merge stays gated on pathIsMinimal only (comprehensive PATH is not widened)
  • Curated-var capture runs whenever either condition triggers
  • anyDockerVarMissing() checks only DOCKER_\* prefixed entries in curatedSingleKeys
  • Renamed GateNoOpOnComprehensivePathGateNoOpOnComprehensivePathAndAllDockerPresent (now pre-sets all 5 DOCKER_* vars as the true no-op condition)
  • Added ComprehensivePathStillHydratesDockerIfMissing — comprehensive PATH + missing DOCKER_HOST → hydrates docker vars but leaves PATH unmodified

Scope: PATH + DOCKER_ + tool-homes only.* Local: go test -race ./internal/shellwrap/... ./internal/secureenv/... green, golangci-lint v2 0 issues.

@Dumbris

Dumbris commented Jun 17, 2026

Copy link
Copy Markdown
Member Author

🤖 Codex review — ACCEPT ✅

The changed code is internally consistent, covered by targeted tests, and I did not identify any discrete regression that would break existing behavior.

No P0–P2 findings after the scope reduction (proxy vars dropped → no credential-leak surface; launch gate no longer relies on PATH alone). Login-shell hydration of PATH + DOCKER_* + tool-homes for macOS GUI/launchd launches. Proxy forwarding split to a separate opt-in follow-up (MCP-2769).

Reviewed via codex exec review --base origin/main on 5f0f8617.

@mcpproxy-gatekeeper mcpproxy-gatekeeper Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved via Claude Code review (Codex quota exhausted).

@Dumbris Dumbris merged commit 90f6e7f into main Jun 17, 2026
37 checks passed
Dumbris added a commit that referenced this pull request Jun 17, 2026
…704)

* feat(secureenv): opt-in proxy env forwarding to upstreams (MCP-2769)

Split out of MCP-2751 login-shell hydration: forwarding HTTP(S)/proxy env
vars to every stdio upstream by default leaks credential-bearing proxy URLs
(http://user:pass@proxy). Keep proxy vars OUT of the default allow-list and
gate forwarding behind an explicit opt-in.

- config: new global `forward_proxy_env` (default false).
- secureenv: when opted in, BuildSecureEnvironment forwards the ambient proxy
  vars (HTTP_PROXY/HTTPS_PROXY/NO_PROXY/ALL_PROXY/FTP_PROXY, both upper- and
  lower-case spellings) to spawned stdio upstreams. userinfo (credentials) is
  redacted from each value; the proxy host/port is preserved. Case-alias aware:
  an explicitly configured spelling suppresses forwarding of the ambient
  alias. Forwarding is independent of inherit_system_safe.
- upstream/core: thread the flag into the per-server env config for stdio spawns.
- docs/configuration.md: document the opt-in + the never-by-default rationale.

Tests: redaction unit table, default-off leak guard, opt-in redacted forward,
explicit-wins-over-alias, and an end-to-end wiring test through managed.NewClient.

Related #701

* fix(secureenv): redact credentials from schemeless proxy values (MCP-2769)

Codex PR #704 review (P2, security): redactProxyCredentials missed schemeless
proxy values like "user:pass@proxy:8080" (no scheme). url.Parse reads "user" as
the scheme and leaves Userinfo nil, so the original credential-bearing value was
forwarded to upstreams despite the redaction guarantee.

Fix: when the value has no "://" but contains "@", re-parse with a dummy
"http://" scheme so url.Parse recognizes the userinfo, strip it, then remove the
dummy scheme. An "@" inside a path (host/path@x) keeps Userinfo nil and is left
intact. Adds table cases (schemeless user:pass / user-only / no-userinfo /
at-in-path) and an end-to-end forwarding test asserting no creds reach upstream.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Dumbris added a commit that referenced this pull request Jun 17, 2026
…CP-2753) (#703)

* fix(docker): direct-exec resolved docker binary for isolated spawn (MCP-2753)

PR #697 resolved the docker CLI to an absolute path but still routed the
isolated spawn through `$SHELL -l -c "<docker> run …"`, where the login shell
re-derives $PATH from rc files and can drop the Docker Desktop bundle dir. The
absolute path was only a token in the -c string, so docker-isolated servers
still failed with `command not found: docker` when the CLI was not on the
LaunchAgent/tray spawn PATH — even though `upstream_servers list` reported a
good docker_path.

On successful resolution to a VERIFIED absolute executable, setupDockerIsolation
now returns the resolved binary as the exec command with raw docker argv (no
shell wrap), mirroring the management path's newDockerCmd. It returns a
shellWrapped flag so callers pick the matching cidfile helper: the new
args-based insertCidfileIntoDockerArgs inserts `--cidfile` after the `run`
token (platform-agnostic, sidestepping the -c vs /c quirk), while the
login-shell fallback keeps the string-based insertCidfileIntoShellDockerCommand.

The direct-exec branch is gated by isDirectExecutable (filepath.IsAbs + os.Stat
+ exec-bit). ResolveDockerPath's last resort runs `command -v docker` in the
login shell, which can emit a shell function/alias/bare name; such a value is
rejected and shell-wrapped instead of failing a direct exec. Login-shell wrap of
bare `docker` is also kept on the resolution-failure fallback.

The DOCKER_* daemon-env half of MCP-2753 is already satisfied by the startup
login-shell hydration shipped in MCP-2751 (#701): DOCKER_* are hydrated into
os.Environ() and carried through BuildSecureEnvironment's default allow-list, so
the direct-exec'd docker process still reaches Colima/rootless/remote daemons
without a per-spawn shell capture.

Related #699
Related #696

* fix(docker): gate direct-exec on guaranteed daemon env (non-Darwin rootless regression)

Codex round-4 (PR #703 REQUEST_CHANGES, P2): the direct-exec branch regressed
non-Darwin rootless/remote Docker. Dropping the `$SHELL -l` wrap also drops
rc-file env inheritance, and HydrateFromLoginShell (MCP-2751) is Darwin-only
while BuildSecureEnvironment only forwards DOCKER_* already in os.Environ(). So
on Linux a daemon whose DOCKER_HOST/DOCKER_CONTEXT lives only in .profile would
be lost by direct-exec — the same DOCKER_* loss that made #699 keep the shell
wrap.

Add a second precondition (dockerDaemonEnvGuaranteed) alongside the verified-
absolute-executable check: direct-exec only when the daemon env is guaranteed
without the login shell — macOS (startup hydration captured DOCKER_* into
os.Environ()) OR DOCKER_HOST/DOCKER_CONTEXT already present in the process env on
any platform. Otherwise keep the `$SHELL -l` wrap so `docker run` inherits the
rc-file daemon config; the wrap now prefers the resolved absolute path (still
sidestepping the login shell's PATH re-derivation) and drops to bare "docker"
only when resolution failed.

dockerDaemonEnvGOOS is a package var so the Darwin branch is testable on a
non-Darwin CI host. New tests: non-Darwin + DOCKER_HOST-only-in-rc stays
shell-wrapped with the absolute path; non-Darwin + DOCKER_HOST in env direct-
execs; and a table test for the gate.

Related #703
Related #699

Co-Authored-By: Paperclip <noreply@paperclip.ing>

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants