Skip to content

Commit 6998910

Browse files
authored
fix(upstream): prepend docker bundle dir to child PATH for credential helper (#716)
Docker-isolated (and user-supplied `docker run`) upstreams direct-exec the absolute docker binary (#696), but the spawned docker inherits mcpproxy's sanitized PATH, which omits the Docker Desktop bundle dir. So docker cannot exec the sibling binaries it shells out to — most importantly the credential helper from ~/.docker/config.json (`credsStore: desktop` → docker-credential-desktop). Any docker-isolated server whose isolation image isn't already cached fails to pull with: error getting credentials - err: exec: "docker-credential-desktop": executable file not found in $PATH resolveDockerSpawn / setupDockerIsolation now also return the resolved docker binary's bundle directory, and both stdio spawn paths plus the launcher prepend it to the child PATH via prependDockerDirToPath (dedup-aware, os.PathListSeparator, no-op when docker did not resolve to an absolute path). This generalizes the #696 fix from "find docker" to "let docker find its own tooling". Related #715
1 parent 5cc9eee commit 6998910

8 files changed

Lines changed: 259 additions & 29 deletions

docs/docker-isolation.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,26 @@ docker stats
277277
succeeds. A `false` value with `docker_path: ""` means the CLI could not be
278278
resolved on the spawn path.
279279

280+
**`error getting credentials … docker-credential-desktop … not found in $PATH` on macOS (image not yet cached):**
281+
- Docker Desktop's default `~/.docker/config.json` sets `"credsStore": "desktop"`,
282+
so `docker` shells out to `docker-credential-desktop` for **every** registry
283+
operation — even an anonymous pull of a public image. That helper lives in the
284+
**same bundle dir** as the docker CLI
285+
(`/Applications/Docker.app/Contents/Resources/bin/`), which mcpproxy's
286+
sanitized spawn `PATH` omits. When the isolation image isn't cached locally,
287+
the pull invokes the helper and fails; a pre-pulled image sidesteps it because
288+
`docker run` then performs no registry op (which is why direct-exec alone
289+
looked complete on cached images — issue #715 / MCP-2877).
290+
- mcpproxy now **prepends the resolved docker binary's bundle dir to the child
291+
`PATH`** whenever `docker` resolves to an absolute path, so the spawned docker
292+
can exec its sibling tooling (`docker-credential-*`, `docker-compose`,
293+
`docker-buildx`) exactly as it would from a normal Docker Desktop shell. This
294+
is applied on every docker spawn path (isolated uvx/npx servers and
295+
user-supplied `docker run …` upstreams) and is a no-op when `docker` did not
296+
resolve to an absolute path. If you still see this error, confirm the helper
297+
exists at the bundle path above, or pre-pull the image with
298+
`docker pull <image>`.
299+
280300
## Security Considerations
281301

282302
Docker isolation provides strong security boundaries but consider:

internal/upstream/core/connection_docker.go

Lines changed: 74 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,22 @@ import (
1818
var resolveDockerBinary = shellwrap.ResolveDockerPath
1919

2020
// setupDockerIsolation configures Docker isolation for the MCP server process.
21-
// Returns the docker command, arguments, and whether the returned command was
22-
// wrapped in the user's login shell. shellWrapped governs how the caller
23-
// inserts --cidfile: a direct-exec spawn (shellWrapped == false) uses the
24-
// args-based insertCidfileIntoDockerArgs, while the login-shell fallback
25-
// (shellWrapped == true) uses the string-based insertCidfileIntoShellDockerCommand.
26-
func (c *Client) setupDockerIsolation(command string, args []string) (dockerCommand string, dockerArgs []string, shellWrapped bool) {
21+
// Returns the docker command, arguments, whether the returned command was
22+
// wrapped in the user's login shell, and the docker binary's bundle directory
23+
// (empty unless docker resolved to a verified absolute executable).
24+
//
25+
// shellWrapped governs how the caller inserts --cidfile: a direct-exec spawn
26+
// (shellWrapped == false) uses the args-based insertCidfileIntoDockerArgs, while
27+
// the login-shell fallback (shellWrapped == true) uses the string-based
28+
// insertCidfileIntoShellDockerCommand.
29+
//
30+
// dockerDir is the directory of the resolved absolute docker binary (e.g.
31+
// /Applications/Docker.app/Contents/Resources/bin). The caller must prepend it
32+
// to the child PATH (prependDockerDirToPath) so the spawned docker can exec its
33+
// sibling tooling — most importantly the docker-credential-* helper named by
34+
// ~/.docker/config.json's credsStore, which Docker Desktop installs into the
35+
// same bundle dir and which docker shells out to for every registry op (#715).
36+
func (c *Client) setupDockerIsolation(command string, args []string) (dockerCommand string, dockerArgs []string, shellWrapped bool, dockerDir string) {
2737
// Detect the runtime type from the command
2838
runtimeType := c.isolationManager.DetectRuntimeType(command)
2939
c.logger.Debug("Detected runtime type for Docker isolation",
@@ -38,7 +48,7 @@ func (c *Client) setupDockerIsolation(command string, args []string) (dockerComm
3848
zap.String("server", c.config.Name),
3949
zap.Error(err))
4050
shellCmd, shellArgs := c.wrapWithUserShell(command, args)
41-
return shellCmd, shellArgs, true
51+
return shellCmd, shellArgs, true, ""
4252
}
4353

4454
// Extract container name from Docker args for tracking
@@ -95,6 +105,14 @@ func (c *Client) setupDockerIsolation(command string, args []string) (dockerComm
95105
// the login-shell fallback (shellWrapped == true) uses the string-based
96106
// insertCidfileIntoShellDockerCommand.
97107
//
108+
// dockerDir is the directory of the resolved absolute docker binary (empty
109+
// unless docker resolved to a verified absolute executable). The caller must
110+
// prepend it to the child PATH (prependDockerDirToPath) so the spawned docker
111+
// can exec its sibling tooling — most importantly the docker-credential-*
112+
// helper named by ~/.docker/config.json's credsStore, which Docker Desktop
113+
// installs into the same bundle dir and which docker shells out to for every
114+
// registry op (#715 / MCP-2877).
115+
//
98116
// CRITICAL FIX (#696 / MCP-2744 / MCP-2753 / MCP-2868): resolve `docker` to an
99117
// ABSOLUTE path and exec it DIRECTLY — no login-shell wrap. Docker Desktop
100118
// installed the default way on macOS (without the optional, admin-gated "install
@@ -133,7 +151,7 @@ func (c *Client) setupDockerIsolation(command string, args []string) (dockerComm
133151
// When we fall back to the shell wrap we still prefer the resolved absolute path
134152
// as the wrapped command (it sidesteps the login shell's PATH re-derivation),
135153
// dropping to bare "docker" only when resolution failed.
136-
func (c *Client) resolveDockerSpawn(finalArgs []string) (dockerCommand string, dockerArgs []string, shellWrapped bool) {
154+
func (c *Client) resolveDockerSpawn(finalArgs []string) (dockerCommand string, dockerArgs []string, shellWrapped bool, dockerDir string) {
137155
resolved, resErr := resolveDockerBinary(c.logger)
138156
switch {
139157
case resErr == nil && isDirectExecutable(resolved) && dockerDaemonEnvGuaranteed():
@@ -145,7 +163,7 @@ func (c *Client) resolveDockerSpawn(finalArgs []string) (dockerCommand string, d
145163
zap.String("server", c.config.Name),
146164
zap.String("docker_path", resolved),
147165
zap.Bool("shell_wrapped", false))
148-
return resolved, finalArgs, false
166+
return resolved, finalArgs, false, filepath.Dir(resolved)
149167
case resErr != nil:
150168
c.logger.Warn("Could not resolve docker to an absolute path; falling back to bare 'docker' via login shell (isolated server may fail if docker is not on the spawn PATH)",
151169
zap.String("server", c.config.Name),
@@ -164,8 +182,14 @@ func (c *Client) resolveDockerSpawn(finalArgs []string) (dockerCommand string, d
164182
}
165183

166184
dockerBin := cmdDocker
185+
// Seed the bundle dir for PATH augmentation whenever docker resolved to a
186+
// verified absolute executable — even though we keep the login-shell wrap
187+
// here. It is harmless on the shell-wrap path and still helps if the login
188+
// shell preserves the inherited PATH (#715).
189+
var resolvedDir string
167190
if isDirectExecutable(resolved) {
168191
dockerBin = resolved
192+
resolvedDir = filepath.Dir(resolved)
169193
}
170194
// INFO: the login-shell fallback is the ONLY path that can produce #696's
171195
// `command not found: docker`, so surface that we took it (and whether we at
@@ -176,7 +200,47 @@ func (c *Client) resolveDockerSpawn(finalArgs []string) (dockerCommand string, d
176200
zap.Bool("docker_resolved", isDirectExecutable(resolved)),
177201
zap.Bool("shell_wrapped", true))
178202
shellCmd, shellArgs := c.wrapWithUserShell(dockerBin, finalArgs)
179-
return shellCmd, shellArgs, true
203+
return shellCmd, shellArgs, true, resolvedDir
204+
}
205+
206+
// prependDockerDirToPath returns env with dockerDir prepended to its PATH entry
207+
// so a spawned docker can resolve the sibling binaries it depends on — the
208+
// docker-credential-* helper named by ~/.docker/config.json's credsStore,
209+
// docker-compose, docker-buildx — which Docker Desktop installs into the same
210+
// bundle dir as the docker CLI but which mcpproxy's sanitized PATH omits (#715).
211+
//
212+
// dockerDir is prepended (existing entries preserved) and deduped: if it is
213+
// already present anywhere on PATH the env is returned unchanged. An empty
214+
// dockerDir (docker not resolved to an absolute path) is a no-op. The input
215+
// slice is never mutated. os.PathListSeparator keeps it correct on Windows.
216+
func prependDockerDirToPath(env []string, dockerDir string) []string {
217+
if dockerDir == "" {
218+
return env
219+
}
220+
for i, kv := range env {
221+
name, val, ok := strings.Cut(kv, "=")
222+
if !ok || name != "PATH" {
223+
continue
224+
}
225+
// Dedup: leave PATH untouched if the bundle dir is already on it.
226+
for _, p := range filepath.SplitList(val) {
227+
if p == dockerDir {
228+
return env
229+
}
230+
}
231+
out := make([]string, len(env))
232+
copy(out, env)
233+
if val == "" {
234+
out[i] = "PATH=" + dockerDir
235+
} else {
236+
out[i] = "PATH=" + dockerDir + string(os.PathListSeparator) + val
237+
}
238+
return out
239+
}
240+
// No PATH entry present — add one so docker still finds its bundle dir.
241+
out := make([]string, len(env), len(env)+1)
242+
copy(out, env)
243+
return append(out, "PATH="+dockerDir)
180244
}
181245

182246
// isDirectExecutable reports whether path is safe to hand to exec directly

internal/upstream/core/connection_docker_exec_e2e_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ func TestE2E_DockerSpawnExecsAbsoluteBundlePath(t *testing.T) {
6969
t.Setenv("SHELL", "/nonexistent/shell-must-not-be-invoked")
7070

7171
c := newIsolatedTestClient()
72-
cmd, args, shellWrapped := c.setupDockerIsolation(c.config.Command, c.config.Args)
72+
cmd, args, shellWrapped, _ := c.setupDockerIsolation(c.config.Command, c.config.Args)
7373
require.False(t, shellWrapped, "#696: bundle-resolved docker must be direct-exec'd")
7474

7575
// Exec it exactly as connection_stdio would (exec.CommandContext(cmd, args...)).

internal/upstream/core/connection_docker_integration_test.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ func TestSpawnDecisionIsObservable_DirectExec(t *testing.T) {
4242
t.Setenv("SHELL", "/nonexistent/shell-must-not-be-invoked")
4343

4444
c, recorded := newObservedIsolatedClient(t)
45-
_, _, shellWrapped := c.setupDockerIsolation(c.config.Command, c.config.Args)
45+
_, _, shellWrapped, _ := c.setupDockerIsolation(c.config.Command, c.config.Args)
4646
require.False(t, shellWrapped)
4747

4848
entries := recorded.FilterMessage("Docker spawn: direct-exec of resolved docker binary").All()
@@ -63,7 +63,7 @@ func TestSpawnDecisionIsObservable_ShellWrapFallback(t *testing.T) {
6363
}
6464

6565
c, recorded := newObservedIsolatedClient(t)
66-
_, _, shellWrapped := c.setupDockerIsolation(c.config.Command, c.config.Args)
66+
_, _, shellWrapped, _ := c.setupDockerIsolation(c.config.Command, c.config.Args)
6767
require.True(t, shellWrapped)
6868

6969
entries := recorded.FilterMessage("Docker spawn: login-shell wrap fallback").All()
@@ -122,7 +122,7 @@ func TestIntegration_DockerOnlyAtBundlePath_DirectExecs(t *testing.T) {
122122
t.Setenv("SHELL", "/nonexistent/shell-must-not-be-invoked")
123123

124124
c := newIsolatedTestClient()
125-
cmd, args, shellWrapped := c.setupDockerIsolation(c.config.Command, c.config.Args)
125+
cmd, args, shellWrapped, _ := c.setupDockerIsolation(c.config.Command, c.config.Args)
126126

127127
require.False(t, shellWrapped,
128128
"#696: docker resolved at the bundle path MUST be direct-exec'd, not shell-wrapped with bare docker")
@@ -154,7 +154,7 @@ func TestIntegration_DockerOnPath_DirectExecs(t *testing.T) {
154154
t.Setenv("SHELL", "/nonexistent/shell-must-not-be-invoked")
155155

156156
c := newIsolatedTestClient()
157-
cmd, args, shellWrapped := c.setupDockerIsolation(c.config.Command, c.config.Args)
157+
cmd, args, shellWrapped, _ := c.setupDockerIsolation(c.config.Command, c.config.Args)
158158

159159
require.False(t, shellWrapped, "docker on PATH must resolve to an absolute path and direct-exec")
160160
assert.Equal(t, pathDocker, cmd, "spawn must exec the LookPath-resolved absolute path, got %q", cmd)
@@ -183,7 +183,7 @@ func TestIntegration_DockerUnresolvable_FallsBackToBareDocker(t *testing.T) {
183183
t.Setenv("SHELL", "/nonexistent/shell-must-not-be-invoked")
184184

185185
c := newIsolatedTestClient()
186-
_, shellArgs, shellWrapped := c.setupDockerIsolation(c.config.Command, c.config.Args)
186+
_, shellArgs, shellWrapped, _ := c.setupDockerIsolation(c.config.Command, c.config.Args)
187187

188188
require.True(t, shellWrapped, "unresolvable docker must fall back to the login-shell wrap")
189189
require.NotEmpty(t, shellArgs)
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package core
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
"go.uber.org/zap"
12+
)
13+
14+
// TestSetupDockerIsolationReturnsBundleDirOnDirectExec is the root-cause
15+
// assertion for #715 / MCP-2877. When docker resolves to a verified absolute
16+
// bundle-dir binary and is direct-exec'd, setupDockerIsolation must report that
17+
// binary's directory so the caller can prepend it to the child PATH. Without
18+
// the bundle dir on PATH, docker cannot exec its sibling credential helper
19+
// (docker-credential-desktop, named by ~/.docker/config.json's credsStore) and
20+
// any registry pull of an uncached isolation image fails.
21+
func TestSetupDockerIsolationReturnsBundleDirOnDirectExec(t *testing.T) {
22+
fakeDocker := writeFakeDockerExecutable(t)
23+
forceDockerDaemonEnvGOOS(t, osDarwin)
24+
25+
orig := resolveDockerBinary
26+
t.Cleanup(func() { resolveDockerBinary = orig })
27+
resolveDockerBinary = func(_ *zap.Logger) (string, error) { return fakeDocker, nil }
28+
29+
c := newIsolatedTestClient()
30+
cmd, _, shellWrapped, dockerDir := c.setupDockerIsolation(c.config.Command, c.config.Args)
31+
32+
require.False(t, shellWrapped)
33+
require.Equal(t, fakeDocker, cmd)
34+
assert.Equal(t, filepath.Dir(fakeDocker), dockerDir,
35+
"setupDockerIsolation must report the resolved docker binary's bundle dir for PATH augmentation")
36+
}
37+
38+
// TestSetupDockerIsolationReturnsBundleDirOnShellWrapFallback verifies the dir
39+
// is reported even when we keep the login-shell wrap (non-Darwin, no DOCKER_HOST
40+
// in env): the issue specifies seeding the env PATH is harmless on the shell-wrap
41+
// path and helps if the login shell preserves the inherited PATH. The gate is
42+
// "resolved to a verified absolute executable", not "direct-exec'd".
43+
func TestSetupDockerIsolationReturnsBundleDirOnShellWrapFallback(t *testing.T) {
44+
fakeDocker := writeFakeDockerExecutable(t)
45+
forceDockerDaemonEnvGOOS(t, "linux")
46+
t.Setenv("DOCKER_HOST", "")
47+
t.Setenv("DOCKER_CONTEXT", "")
48+
49+
orig := resolveDockerBinary
50+
t.Cleanup(func() { resolveDockerBinary = orig })
51+
resolveDockerBinary = func(_ *zap.Logger) (string, error) { return fakeDocker, nil }
52+
53+
c := newIsolatedTestClient()
54+
_, _, shellWrapped, dockerDir := c.setupDockerIsolation(c.config.Command, c.config.Args)
55+
56+
require.True(t, shellWrapped, "non-Darwin without DOCKER_HOST keeps the shell wrap")
57+
assert.Equal(t, filepath.Dir(fakeDocker), dockerDir,
58+
"a verified absolute resolved path must report its bundle dir even on the shell-wrap fallback")
59+
}
60+
61+
// TestSetupDockerIsolationNoBundleDirWhenUnresolved verifies no dir is reported
62+
// when docker did not resolve to a verified absolute path (bare-'docker'
63+
// fallback) — there is no filepath.Dir to take, and augmenting PATH would be
64+
// meaningless / unsafe.
65+
func TestSetupDockerIsolationNoBundleDirWhenUnresolved(t *testing.T) {
66+
orig := resolveDockerBinary
67+
t.Cleanup(func() { resolveDockerBinary = orig })
68+
resolveDockerBinary = func(_ *zap.Logger) (string, error) { return "docker", nil }
69+
70+
c := newIsolatedTestClient()
71+
_, _, shellWrapped, dockerDir := c.setupDockerIsolation(c.config.Command, c.config.Args)
72+
73+
require.True(t, shellWrapped)
74+
assert.Empty(t, dockerDir, "a non-absolute resolved value must not report a bundle dir")
75+
}
76+
77+
// TestPrependDockerDirToPath unit-tests the pure PATH-augmentation helper.
78+
func TestPrependDockerDirToPath(t *testing.T) {
79+
sep := string(os.PathListSeparator)
80+
dir := filepath.FromSlash("/Applications/Docker.app/Contents/Resources/bin")
81+
82+
t.Run("prepends to existing PATH preserving entries", func(t *testing.T) {
83+
env := []string{"FOO=bar", "PATH=/usr/bin" + sep + "/bin"}
84+
got := prependDockerDirToPath(env, dir)
85+
var path string
86+
for _, kv := range got {
87+
if strings.HasPrefix(kv, "PATH=") {
88+
path = strings.TrimPrefix(kv, "PATH=")
89+
}
90+
}
91+
parts := filepath.SplitList(path)
92+
require.NotEmpty(t, parts)
93+
assert.Equal(t, dir, parts[0], "bundle dir must be prepended (front), got: %s", path)
94+
assert.Contains(t, parts, "/usr/bin", "existing entries preserved")
95+
assert.Contains(t, parts, "/bin", "existing entries preserved")
96+
assert.Contains(t, got, "FOO=bar", "unrelated env vars untouched")
97+
})
98+
99+
t.Run("dedup: already present leaves PATH unchanged", func(t *testing.T) {
100+
env := []string{"PATH=/usr/bin" + sep + dir + sep + "/bin"}
101+
got := prependDockerDirToPath(env, dir)
102+
assert.Equal(t, env, got, "must not re-add a dir already on PATH")
103+
})
104+
105+
t.Run("empty dir is a no-op", func(t *testing.T) {
106+
env := []string{"PATH=/usr/bin"}
107+
got := prependDockerDirToPath(env, "")
108+
assert.Equal(t, env, got)
109+
})
110+
111+
t.Run("adds PATH when none present", func(t *testing.T) {
112+
env := []string{"FOO=bar"}
113+
got := prependDockerDirToPath(env, dir)
114+
assert.Contains(t, got, "PATH="+dir)
115+
assert.Contains(t, got, "FOO=bar")
116+
})
117+
118+
t.Run("empty PATH value yields just the dir", func(t *testing.T) {
119+
env := []string{"PATH="}
120+
got := prependDockerDirToPath(env, dir)
121+
assert.Contains(t, got, "PATH="+dir)
122+
})
123+
124+
t.Run("does not mutate the input slice", func(t *testing.T) {
125+
env := []string{"PATH=/usr/bin"}
126+
_ = prependDockerDirToPath(env, dir)
127+
assert.Equal(t, "PATH=/usr/bin", env[0], "input slice must not be mutated")
128+
})
129+
}

0 commit comments

Comments
 (0)