Skip to content

Commit 90f6e7f

Browse files
feat(shellwrap): hydrate login-shell env once at startup (macOS GUI/launchd) — MCP-2751 (#701)
* feat(shellwrap): hydrate login-shell env once at startup (macOS GUI/launchd) 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 * fix(shellwrap): preserve intentionally-empty operator vars during hydration 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> * fix(shellwrap): alias-aware set-if-unset for proxy var spellings 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> * fix(shellwrap): drop proxy vars, fix launch gate for pre-seeded PATH 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> --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
1 parent 6b1b346 commit 90f6e7f

8 files changed

Lines changed: 722 additions & 81 deletions

File tree

cmd/mcpproxy/main.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import (
4747
"github.com/smart-mcp-proxy/mcpproxy-go/internal/logs"
4848
"github.com/smart-mcp-proxy/mcpproxy-go/internal/registries"
4949
"github.com/smart-mcp-proxy/mcpproxy-go/internal/server"
50+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/shellwrap"
5051
"github.com/smart-mcp-proxy/mcpproxy-go/internal/storage"
5152
"github.com/smart-mcp-proxy/mcpproxy-go/internal/telemetry"
5253
_ "github.com/smart-mcp-proxy/mcpproxy-go/oas" // Import generated swagger docs
@@ -499,6 +500,17 @@ func runServer(cmd *cobra.Command, _ []string) error {
499500
zap.String("log_level", cmdLogLevel),
500501
zap.Bool("log_to_file", cmdLogToFile))
501502

503+
// MCP-2751: when launched from a macOS GUI/launchd context (Launchpad, the
504+
// SMAppService login item, or the tray spawning the core), the process
505+
// inherits a launchd-minimal environment and never sources the user's
506+
// login shell — so it lacks Homebrew/Docker PATH entries and exported vars
507+
// like DOCKER_HOST. Hydrate a curated allow-list (PATH + DOCKER_*/proxy/
508+
// tool-home) once, before any manager reads os.Environ(), so every spawn
509+
// path (docker, stdio servers, uvx/npx, ResolveDockerPath,
510+
// secureenv.BuildSecureEnvironment) inherits a correct environment with no
511+
// call-site changes. No-op on terminal launches and non-macOS.
512+
shellwrap.HydrateFromLoginShell(logger)
513+
502514
// Pass edition and version to internal packages
503515
httpapi.SetEdition(Edition)
504516
server.SetMCPServerVersion(version)

docs/errors/MCPX_STDIO_SPAWN_ENOENT.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,17 @@ sudo apt install nodejs npm # Debian/Ubuntu
5555

5656
### 2. Make the GUI inherit the shell PATH (macOS)
5757

58-
The macOS tray launches mcpproxy with the user's login `PATH`, which is shorter
59-
than your interactive shell `PATH`. Either:
58+
When launched from a macOS GUI/launchd context (Launchpad, the login item, or
59+
the tray spawning the core), mcpproxy inherits a launchd-minimal environment
60+
rather than your interactive shell `PATH`. Since the daemon now **hydrates the
61+
login-shell environment once at startup** — sourcing your login shell to merge
62+
in `PATH` (and curated `DOCKER_*`/proxy/tool-home vars) — `uvx`/`npx` installed
63+
in `/opt/homebrew/bin`, `~/.local/bin`, or a version-manager shim directory are
64+
normally found automatically. (Hydration is a no-op when mcpproxy is started
65+
from a terminal whose `PATH` is already comprehensive.)
66+
67+
If the tool still isn't found — e.g. it lives in a directory your login shell
68+
doesn't export, or your rc files don't run non-interactively — either:
6069

6170
- Move the binary to a system directory: `sudo ln -s "$(which uvx)" /usr/local/bin/uvx`, or
6271
- Set an absolute path in the upstream config: `"command": "/Users/you/.local/bin/uvx"`.

internal/secureenv/launchd_path_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,54 @@ func TestLaunchdMinimalPath_AlreadyComprehensive(t *testing.T) {
215215
"comprehensive PATH must be returned unchanged — terminal-launched processes should not be polluted by login-shell capture")
216216
}
217217

218+
// TestBuildSecureEnvironment_AllowsHydratedDockerVars verifies the allow-list
219+
// extension for MCP-2751: once shellwrap.HydrateFromLoginShell has placed
220+
// curated DOCKER_*/tool-home vars into the process env, the default allow-list
221+
// must pass them through to spawned upstreams — while secrets remain filtered.
222+
// Proxy vars are intentionally excluded from the allow-list (separate follow-up).
223+
func TestBuildSecureEnvironment_AllowsHydratedDockerVars(t *testing.T) {
224+
if runtime.GOOS == "windows" {
225+
t.Skip("uses unix PATH semantics")
226+
}
227+
228+
originalEnv := os.Environ()
229+
defer func() {
230+
os.Clearenv()
231+
for _, env := range originalEnv {
232+
parts := strings.SplitN(env, "=", 2)
233+
if len(parts) == 2 {
234+
os.Setenv(parts[0], parts[1])
235+
}
236+
}
237+
}()
238+
239+
t.Cleanup(withFakeLoginShellPath(""))
240+
241+
os.Clearenv()
242+
os.Setenv("PATH", "/usr/bin:/bin:/usr/sbin:/sbin")
243+
os.Setenv("HOME", "/tmp/test-home")
244+
os.Setenv("DOCKER_HOST", "unix:///Users/me/.docker/run/docker.sock")
245+
os.Setenv("DOCKER_CONTEXT", "desktop-linux")
246+
os.Setenv("HOMEBREW_PREFIX", "/opt/homebrew")
247+
// Genuine secrets that must still be filtered out by the allow-list.
248+
os.Setenv("AWS_ACCESS_KEY_ID", "AKIA_test_dummy_value_00000000")
249+
os.Setenv("GITHUB_TOKEN", "ghp_dummy_test_token_1234567890abcdef")
250+
// Proxy vars are excluded from the allow-list.
251+
os.Setenv("HTTPS_PROXY", "http://proxy.corp:8080")
252+
253+
manager := NewManager(DefaultEnvConfig())
254+
joined := strings.Join(manager.BuildSecureEnvironment(), "\n")
255+
256+
assert.Contains(t, joined, "DOCKER_HOST=unix:///Users/me/.docker/run/docker.sock",
257+
"curated DOCKER_HOST must survive the allow-list")
258+
assert.Contains(t, joined, "DOCKER_CONTEXT=desktop-linux")
259+
assert.Contains(t, joined, "HOMEBREW_PREFIX=/opt/homebrew")
260+
261+
assert.NotContains(t, joined, "HTTPS_PROXY", "proxy vars must be filtered (out of scope)")
262+
assert.NotContains(t, joined, "AWS_ACCESS_KEY_ID", "secrets must still be filtered out")
263+
assert.NotContains(t, joined, "GITHUB_TOKEN", "secrets must still be filtered out")
264+
}
265+
218266
// --- test helpers --------------------------------------------------------
219267

220268
// withFakeLoginShellPath swaps loginShellPATHFn for a stub returning `path`.

internal/secureenv/manager.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,16 @@ func DefaultEnvConfig() *EnvConfig {
8686
}
8787
allowedVars = append(allowedVars, localeVars...)
8888

89+
// Add container / tool-home passthrough variables (MCP-2751). These are NOT
90+
// secrets; they mirror the curated set hydrated by shellwrap.HydrateFromLoginShell
91+
// so the now-present vars survive this allow-list filter and reach upstream
92+
// stdio/docker spawns. Proxy vars (HTTP_PROXY etc.) are intentionally excluded
93+
// — proxy forwarding is a separate opt-in concern.
94+
allowedVars = append(allowedVars,
95+
"DOCKER_HOST", "DOCKER_CONTEXT", "DOCKER_CONFIG", "DOCKER_CERT_PATH", "DOCKER_TLS_VERIFY",
96+
"NVM_DIR", "ASDF_DIR", "PYENV_ROOT", "VOLTA_HOME", "HOMEBREW_PREFIX", "COLIMA_HOME",
97+
)
98+
8999
return &EnvConfig{
90100
InheritSystemSafe: true,
91101
AllowedSystemVars: allowedVars,

0 commit comments

Comments
 (0)