Skip to content

Commit 79849e0

Browse files
authored
fix(docker): resolve docker by absolute path on macOS spawn; honest docker_available (#696) (#697)
* fix(docker): resolve docker by absolute path on macOS spawn; honest docker_available (#696) On macOS, Docker Desktop installed the default way (without the optional, admin-gated "install CLI tools" step) leaves the docker CLI only inside the app bundle at /Applications/Docker.app/Contents/Resources/bin/docker, which is not on any standard PATH dir. mcpproxy spawned isolated servers with bare `docker` on a sanitized PATH that omits the bundle dir, so docker was unresolvable at spawn (~20x `command not found: docker`), while `upstream_servers list` misreported `docker_available: true`. - Spawn (root cause): setupDockerIsolation resolves docker to an absolute path via shellwrap.ResolveDockerPath (mirroring newDockerCmd) before shell-wrapping; falls back to bare "docker" only when resolution fails. - Enhanced spawn PATH (defense in depth): add the Docker Desktop bundle bin dir on darwin; split the candidate list into a pure, unit-testable function. - Honest availability: MCPProxyServer.resolveDockerStatus and Runtime.IsDockerAvailable report available only when the CLI is resolvable AND `docker info` succeeds — no bare-docker fallback that would misreport. Surface the resolved binary as docker_status.docker_path; diagnostics.checkDockerDaemon reports unavailable with an actionable error when unresolvable. - Diagnostics: formatRecentStderr leads a single actionable "Docker CLI not found" hint on `command not found` and collapses identical consecutive lines into "… (repeated N×)", replacing the 20-line wall. macOS-specific paths guarded behind runtime.GOOS=="darwin"; Linux/Windows unaffected. Docs updated for docker_path, telemetry counter semantics, and a new troubleshooting entry. Related #696 * fix(docker): accept /c (Windows cmd) in cidfile insertion + regression test insertCidfileIntoShellDockerCommand checked shellArgs[n-2]=="-c" to distinguish a shell-wrapped command string from an unrecognised format. On Windows with cmd.exe (no bash), WrapWithUserShell returns ["/c", cmdStr] so the flag is "/c", not "-c"; the guard rejected it and silently skipped cidfile insertion, breaking TestSetupDockerIsolationCidfileInsertionWithAbsolutePath on windows-latest CI. Accept both flags ("-c" and "/c") so cidfile insertion works on every platform. Add TestInsertCidfileWindowsCmdFormat (pure unit, no real Docker) to pin the Windows cmd format.
1 parent 11e4bed commit 79849e0

11 files changed

Lines changed: 502 additions & 41 deletions

File tree

docs/docker-isolation.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ Starting in this release, MCPProxy emits a one-time warning in the main log when
123123

124124
When anonymous telemetry is enabled, MCPProxy reports two Docker-related counters at daily cadence:
125125

126-
- `server_docker_available_bool` — whether the host has a reachable Docker daemon. Probed with `docker info --format {{.ServerVersion}}` and cached for up to 15 minutes (5 minutes when the previous probe failed, so a late Docker-Desktop launch is picked up promptly).
126+
- `server_docker_available_bool` — whether Docker is actually invocable. Reported `true` only when the `docker` CLI is **resolvable to an absolute path** *and* `docker info --format {{.ServerVersion}}` succeeds (it does **not** fall back to a bare `docker` PATH probe, which could misreport availability when the binary is only inside the macOS app bundle — see issue #696). Cached for up to 15 minutes (5 minutes when the previous probe failed, so a late Docker-Desktop launch is picked up promptly).
127127
- `server_docker_isolated_count` — how many of your configured stdio servers are **configured** for isolation, i.e. servers for which `ShouldIsolate()` returns true. This is a configuration metric, not a count of running containers; it goes to zero whenever the global flag is off regardless of per-server opt-ins.
128128

129129
## Runtime Detection
@@ -245,6 +245,23 @@ docker stats
245245
- Check container logs for specific error messages
246246
- Verify network access for package repositories
247247

248+
**`command not found: docker` on macOS (Docker Desktop installed):**
249+
- Docker Desktop installed the default way leaves the `docker` CLI only inside
250+
the app bundle at `/Applications/Docker.app/Contents/Resources/bin/docker`
251+
it is **not** on a standard `PATH` dir unless you ran the optional,
252+
admin-gated "install CLI tools" step. When mcpproxy is launched from a
253+
LaunchAgent / tray, the captured login-shell `PATH` may omit this directory.
254+
- mcpproxy resolves the `docker` binary to its **absolute path** before
255+
spawning an isolated server (and also adds the bundle bin dir to the enhanced
256+
spawn `PATH`), so isolation works even without the CLI-tools step. If you
257+
still see this error, confirm the binary exists at the bundle path above, or
258+
run Docker Desktop's "install CLI tools".
259+
- `upstream_servers list` reports `docker_status.docker_path` (the resolved
260+
binary) and reports `docker_status.available` / per-server `docker_available`
261+
as `true` **only** when the CLI is actually resolvable *and* `docker info`
262+
succeeds. A `false` value with `docker_path: ""` means the CLI could not be
263+
resolved on the spawn path.
264+
248265
## Security Considerations
249266

250267
Docker isolation provides strong security boundaries but consider:

internal/management/diagnostics.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,14 @@ func (s *service) checkDockerDaemon() *contracts.DockerStatus {
191191
}
192192
dockerBin, resolveErr := shellwrap.ResolveDockerPath(logger)
193193
if resolveErr != nil || dockerBin == "" {
194-
dockerBin = "docker"
194+
// Honest availability (#696): if the CLI can't be resolved to an
195+
// absolute path, Docker-isolated servers can't spawn it. Report
196+
// unavailable with an actionable error rather than probing a bare
197+
// "docker" that is not the binary used for spawning.
198+
status.Available = false
199+
status.Error = "Docker CLI not found on PATH or well-known install locations (on macOS the CLI ships at /Applications/Docker.app/Contents/Resources/bin/docker even without the optional CLI-tools step)"
200+
s.logger.Debugw("Docker CLI not resolvable; reporting docker unavailable", "error", resolveErr)
201+
return status
195202
}
196203
cmd := exec.CommandContext(ctx, dockerBin, "info", "--format", "{{.ServerVersion}}")
197204
output, err := cmd.Output()

internal/runtime/runtime.go

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2564,12 +2564,20 @@ func (r *Runtime) IsDockerAvailable() bool {
25642564
// tray with a minimal inherited PATH (see issue: tray "Docker daemon not
25652565
// available" warning despite healthy daemon + socket).
25662566
dockerBin, resolveErr := shellwrap.ResolveDockerPath(r.logger)
2567+
var err error
2568+
var newResult bool
25672569
if resolveErr != nil || dockerBin == "" {
2568-
dockerBin = "docker"
2570+
// Honest availability (#696): if the CLI can't be resolved to an
2571+
// absolute path, Docker-isolated servers can't spawn it — report
2572+
// unavailable rather than probing a bare "docker" that is not the
2573+
// binary used for spawning.
2574+
err = resolveErr
2575+
newResult = false
2576+
} else {
2577+
cmd := exec.CommandContext(ctx, dockerBin, "info", "--format", "{{.ServerVersion}}")
2578+
err = cmd.Run()
2579+
newResult = err == nil
25692580
}
2570-
cmd := exec.CommandContext(ctx, dockerBin, "info", "--format", "{{.ServerVersion}}")
2571-
err := cmd.Run()
2572-
newResult := err == nil
25732581

25742582
// Log only on state changes (or first probe) so repeated heartbeats don't
25752583
// spam the log. Rationale: users care about transitions ("Docker just

internal/secureenv/manager.go

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -136,10 +136,26 @@ func (m *Manager) discoverPaths() *PathDiscovery {
136136
return discovery
137137
}
138138

139-
// discoverUnixPaths discovers common Unix/macOS tool paths
139+
// discoverUnixPaths discovers common Unix/macOS tool paths that actually exist.
140140
func (m *Manager) discoverUnixPaths() []string {
141+
// Filter the platform candidate list to only paths that actually exist.
142+
var existingPaths []string
143+
for _, path := range unixCandidatePaths() {
144+
if _, err := os.Stat(path); err == nil {
145+
existingPaths = append(existingPaths, path)
146+
}
147+
}
148+
149+
return existingPaths
150+
}
151+
152+
// unixCandidatePaths returns the ordered list of well-known Unix/macOS tool
153+
// directories to probe for existence. Split out (pure, no I/O) so platform-
154+
// specific inclusion can be unit-tested without depending on what is installed
155+
// on the test host.
156+
func unixCandidatePaths() []string {
141157
commonPaths := []string{
142-
"/usr/local/bin", // Homebrew, Docker Desktop, etc.
158+
"/usr/local/bin", // Homebrew, Docker Desktop symlink, etc.
143159
"/usr/bin", // System binaries
144160
"/bin", // Core system binaries
145161
"/opt/homebrew/bin", // Apple Silicon Homebrew
@@ -148,6 +164,16 @@ func (m *Manager) discoverUnixPaths() []string {
148164
"/sbin", // System admin binaries
149165
}
150166

167+
// macOS (#696): Docker Desktop installed the default way (without the
168+
// optional, admin-gated "install CLI tools" step) leaves the docker CLI
169+
// only inside the app bundle, which is not on any standard PATH dir. Adding
170+
// it (defense in depth for the absolute-path spawn fix) lets nested/child
171+
// docker resolution work for isolated servers. Existence is filtered by the
172+
// caller, so this is a no-op on hosts without Docker Desktop.
173+
if runtime.GOOS == "darwin" {
174+
commonPaths = append(commonPaths, "/Applications/Docker.app/Contents/Resources/bin")
175+
}
176+
151177
// Add user-specific paths
152178
if homeDir, err := os.UserHomeDir(); err == nil {
153179
commonPaths = append(commonPaths,
@@ -159,15 +185,7 @@ func (m *Manager) discoverUnixPaths() []string {
159185
)
160186
}
161187

162-
// Filter to only include paths that actually exist
163-
var existingPaths []string
164-
for _, path := range commonPaths {
165-
if _, err := os.Stat(path); err == nil {
166-
existingPaths = append(existingPaths, path)
167-
}
168-
}
169-
170-
return existingPaths
188+
return commonPaths
171189
}
172190

173191
// discoverWindowsPaths discovers common Windows tool paths
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package secureenv
2+
3+
import (
4+
"runtime"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
)
9+
10+
// dockerBundleBinDir is the macOS Docker Desktop app-bundle bin dir that holds
11+
// the docker CLI even when the optional, admin-gated "install CLI tools" step
12+
// was skipped (issue #696).
13+
const dockerBundleBinDir = "/Applications/Docker.app/Contents/Resources/bin"
14+
15+
// TestUnixCandidatePathsIncludesDockerBundleOnMacOS asserts that the macOS
16+
// Docker Desktop app-bundle bin dir is among the candidate spawn-PATH dirs on
17+
// darwin (defense in depth for the absolute-path docker spawn fix), and is not
18+
// injected on other platforms.
19+
func TestUnixCandidatePathsIncludesDockerBundleOnMacOS(t *testing.T) {
20+
paths := unixCandidatePaths()
21+
22+
if runtime.GOOS == "darwin" {
23+
assert.Contains(t, paths, dockerBundleBinDir,
24+
"macOS candidate paths must include the Docker Desktop bundle bin dir (#696)")
25+
} else {
26+
assert.NotContains(t, paths, dockerBundleBinDir,
27+
"non-macOS candidate paths must not include the macOS-only bundle bin dir")
28+
}
29+
30+
// Existing well-known dirs must be preserved regardless of platform.
31+
assert.Contains(t, paths, "/usr/local/bin")
32+
assert.Contains(t, paths, "/usr/bin")
33+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package server
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"runtime"
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
"go.uber.org/zap"
12+
)
13+
14+
// TestResolveDockerStatusUnresolvableReportsUnavailable verifies the honesty
15+
// fix (#696): when the docker CLI cannot be resolved to an absolute path,
16+
// docker is reported unavailable with an empty path — NOT available via a bare
17+
// "docker" probe that is not the binary used for spawning.
18+
func TestResolveDockerStatusUnresolvableReportsUnavailable(t *testing.T) {
19+
orig := dockerPathResolver
20+
t.Cleanup(func() { dockerPathResolver = orig })
21+
dockerPathResolver = func(_ *zap.Logger) (string, error) {
22+
return "", assert.AnError
23+
}
24+
25+
p := &MCPProxyServer{logger: zap.NewNop()}
26+
available, path := p.resolveDockerStatus()
27+
28+
assert.False(t, available, "docker must be reported unavailable when unresolvable")
29+
assert.Equal(t, "", path, "docker_path must be empty when unresolvable")
30+
}
31+
32+
// TestResolveDockerStatusResolvableAndWorking verifies that a resolvable docker
33+
// whose `docker info` exits 0 is reported available with its resolved path.
34+
func TestResolveDockerStatusResolvableAndWorking(t *testing.T) {
35+
if runtime.GOOS == "windows" {
36+
t.Skip("uses a shell-script fake docker; not portable to Windows")
37+
}
38+
39+
// A fake "docker" that exits 0 for any args (including `info`).
40+
dir := t.TempDir()
41+
fakeDocker := filepath.Join(dir, "docker")
42+
require.NoError(t, os.WriteFile(fakeDocker, []byte("#!/bin/sh\nexit 0\n"), 0o755))
43+
44+
orig := dockerPathResolver
45+
t.Cleanup(func() { dockerPathResolver = orig })
46+
dockerPathResolver = func(_ *zap.Logger) (string, error) { return fakeDocker, nil }
47+
48+
p := &MCPProxyServer{logger: zap.NewNop()}
49+
available, path := p.resolveDockerStatus()
50+
51+
assert.True(t, available, "docker must be reported available when resolvable and `info` succeeds")
52+
assert.Equal(t, fakeDocker, path, "docker_path must surface the resolved binary")
53+
}
54+
55+
// TestResolveDockerStatusResolvableDaemonDown verifies that a resolvable docker
56+
// whose `docker info` fails (daemon down) is reported unavailable but still
57+
// surfaces the resolved path for diagnostics.
58+
func TestResolveDockerStatusResolvableDaemonDown(t *testing.T) {
59+
if runtime.GOOS == "windows" {
60+
t.Skip("uses a shell-script fake docker; not portable to Windows")
61+
}
62+
63+
dir := t.TempDir()
64+
fakeDocker := filepath.Join(dir, "docker")
65+
require.NoError(t, os.WriteFile(fakeDocker, []byte("#!/bin/sh\nexit 1\n"), 0o755))
66+
67+
orig := dockerPathResolver
68+
t.Cleanup(func() { dockerPathResolver = orig })
69+
dockerPathResolver = func(_ *zap.Logger) (string, error) { return fakeDocker, nil }
70+
71+
p := &MCPProxyServer{logger: zap.NewNop()}
72+
available, path := p.resolveDockerStatus()
73+
74+
assert.False(t, available, "docker must be reported unavailable when `info` fails")
75+
assert.Equal(t, fakeDocker, path, "docker_path must surface the resolved binary even when the daemon is down")
76+
}

internal/server/mcp.go

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ type MCPProxyServer struct {
123123

124124
// Docker availability cache
125125
dockerAvailableCache *bool
126+
dockerPathCache string
126127
dockerCacheTime time.Time
127128

128129
// JavaScript runtime pool for code execution
@@ -2889,8 +2890,9 @@ func (p *MCPProxyServer) handleListUpstreams(ctx context.Context) (*mcp.CallTool
28892890
// Check Docker availability only if Docker isolation is globally enabled
28902891
dockerIsolationGlobalEnabled := p.config.DockerIsolation != nil && p.config.DockerIsolation.Enabled
28912892
var dockerAvailable bool
2893+
var dockerPath string
28922894
if dockerIsolationGlobalEnabled {
2893-
dockerAvailable = p.checkDockerAvailable()
2895+
dockerAvailable, dockerPath = p.resolveDockerStatus()
28942896
}
28952897

28962898
// Enhance server list with connection status and Docker isolation info
@@ -3062,16 +3064,17 @@ func (p *MCPProxyServer) handleListUpstreams(ctx context.Context) (*mcp.CallTool
30623064
"total": len(servers),
30633065
"docker_status": map[string]interface{}{
30643066
"available": dockerAvailable,
3067+
"docker_path": dockerPath,
30653068
"global_enabled": dockerIsolationGlobalEnabled,
30663069
"isolation_config": p.config.DockerIsolation,
30673070
},
30683071
}
30693072

30703073
if !dockerAvailable && dockerIsolationGlobalEnabled {
30713074
result["warnings"] = []string{
3072-
"Docker isolation is enabled but Docker daemon is not available",
3075+
"Docker isolation is enabled but the Docker CLI is not resolvable / the daemon is not reachable",
30733076
"Servers configured for isolation will fail to start",
3074-
"Install Docker or disable isolation in config",
3077+
"Install Docker (on macOS the CLI ships at /Applications/Docker.app/Contents/Resources/bin/docker even without the optional CLI-tools step), or disable isolation in config",
30753078
}
30763079
}
30773080

@@ -3189,35 +3192,52 @@ func (p *MCPProxyServer) handleDoctor(ctx context.Context, request mcp.CallToolR
31893192
return mcp.NewToolResultError("Management service not available"), nil
31903193
}
31913194

3192-
// checkDockerAvailable checks if Docker daemon is available with caching
3193-
func (p *MCPProxyServer) checkDockerAvailable() bool {
3195+
// dockerPathResolver resolves the absolute docker path. Indirected through a
3196+
// package var so tests can stub resolution without a real Docker install.
3197+
var dockerPathResolver = shellwrap.ResolveDockerPath
3198+
3199+
// resolveDockerStatus reports whether docker is actually invocable and the
3200+
// absolute path it resolved to (empty when unresolvable). Result cached 30s.
3201+
//
3202+
// Honesty (#696): docker is reported available ONLY when the CLI is RESOLVABLE
3203+
// (ResolveDockerPath succeeds) AND `docker info` succeeds. We deliberately do
3204+
// NOT fall back to a bare "docker" probe when resolution fails — that would
3205+
// report available:true even though Docker-isolated servers (which invoke
3206+
// docker by its resolved absolute path) cannot launch it, exactly the
3207+
// misreport in issue #696.
3208+
func (p *MCPProxyServer) resolveDockerStatus() (available bool, dockerPath string) {
31943209
// Cache result for 30 seconds to avoid repeated expensive checks
31953210
now := time.Now()
31963211
if p.dockerAvailableCache != nil && now.Sub(p.dockerCacheTime) < 30*time.Second {
3197-
return *p.dockerAvailableCache
3212+
return *p.dockerAvailableCache, p.dockerPathCache
31983213
}
31993214

3200-
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
3201-
defer cancel()
32023215
// Resolve docker via shellwrap so tray-launched processes with a minimal
32033216
// inherited PATH still find Docker Desktop / Homebrew / Colima installs.
3204-
dockerBin, resolveErr := shellwrap.ResolveDockerPath(p.logger)
3217+
dockerBin, resolveErr := dockerPathResolver(p.logger)
32053218
if resolveErr != nil || dockerBin == "" {
3206-
dockerBin = "docker"
3219+
p.logger.Debug("Docker CLI not resolvable; reporting docker unavailable", zap.Error(resolveErr))
3220+
unavailable := false
3221+
p.dockerAvailableCache = &unavailable
3222+
p.dockerPathCache = ""
3223+
p.dockerCacheTime = now
3224+
return false, ""
32073225
}
3208-
cmd := exec.CommandContext(ctx, dockerBin, "info")
32093226

3210-
err := cmd.Run()
3211-
available := err == nil
3227+
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
3228+
defer cancel()
3229+
err := exec.CommandContext(ctx, dockerBin, "info").Run()
3230+
available = err == nil
32123231

32133232
// Cache the result
32143233
p.dockerAvailableCache = &available
3234+
p.dockerPathCache = dockerBin
32153235
p.dockerCacheTime = now
32163236

32173237
if !available {
3218-
p.logger.Debug("Docker daemon not available", zap.Error(err))
3238+
p.logger.Debug("Docker daemon not available", zap.String("docker_path", dockerBin), zap.Error(err))
32193239
}
3220-
return available
3240+
return available, dockerBin
32213241
}
32223242

32233243
// getIsolationManager returns the isolation manager for checking settings

0 commit comments

Comments
 (0)