diff --git a/docs/setup.md b/docs/setup.md index 9309e4eb..ab399892 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -334,6 +334,48 @@ goose> Help me search for files related to authentication --- +### 🚀 Google Antigravity + +Google Antigravity is an AI-powered IDE built on VS Code with deep Gemini integration and built-in MCP support. + +**⚠️ Important:** Antigravity uses `serverUrl` (not `url`) for HTTP-based MCP servers. Using `url` will cause a connection error. + +**Config file location:** + +| Platform | Path | +|----------|------| +| macOS | `~/.gemini/antigravity/mcp_config.json` | +| Linux | `~/.gemini/antigravity/mcp_config.json` | +| Windows | `%USERPROFILE%\.gemini\antigravity\mcp_config.json` | + +**Setup via UI:** + +1. Open the Agent Panel (right sidebar) +2. Click **"..."** (More Options) → **MCP Servers** → **Manage MCP Servers** +3. Click **"View raw config"** +4. Add the MCPProxy configuration (see below) +5. Click **Refresh** to apply changes + +**Setup via Configuration File:** + +Edit your `mcp_config.json`: + +```json +{ + "mcpServers": { + "mcpproxy": { + "serverUrl": "http://127.0.0.1:8080/mcp" + } + } +} +``` + +**📝 Note:** MCPProxy's MCP endpoint does not require API key authentication, so no `headers` block is needed. Antigravity does not support `${workspaceFolder}` — use absolute paths in any server configuration. + +**📚 Reference:** [Antigravity MCP Documentation](https://antigravity.google/docs/mcp) + +--- + ## Optional HTTPS Setup MCPProxy supports secure HTTPS connections with automatic certificate generation. **HTTP is enabled by default** for immediate compatibility, but HTTPS provides enhanced security for production use. diff --git a/internal/secureenv/manager.go b/internal/secureenv/manager.go index a260a50b..c6c62b37 100644 --- a/internal/secureenv/manager.go +++ b/internal/secureenv/manager.go @@ -165,19 +165,45 @@ func (m *Manager) discoverUnixPaths() []string { // discoverWindowsPaths discovers common Windows tool paths func (m *Manager) discoverWindowsPaths() []string { + // CRITICAL FIX for Issue #302: + // Try to read PATH from Windows registry first. + // This is necessary because when mcpproxy is launched via installer/service, + // it doesn't inherit the user's PATH environment variable. + // The registry is the source of truth for Windows PATH configuration. + if registryPaths := discoverWindowsPathsFromRegistry(); len(registryPaths) > 0 { + return registryPaths + } + + // Fallback to hardcoded discovery if registry read fails + // This expanded list includes more common tool locations + homeDir, _ := os.UserHomeDir() + commonPaths := []string{ + // System paths `C:\Windows\System32`, `C:\Windows`, + + // Common development tools `C:\Program Files\Docker\Docker\resources\bin`, `C:\Program Files\Git\bin`, + `C:\Program Files\Git\cmd`, `C:\Program Files\nodejs`, + `C:\Program Files\Go\bin`, + + // User-specific tool paths (if homeDir is available) } - // Add user-specific paths - if homeDir, err := os.UserHomeDir(); err == nil { + if homeDir != "" { commonPaths = append(commonPaths, - homeDir+`\AppData\Local\Programs\Python\Python311\Scripts`, - homeDir+`\AppData\Roaming\npm`, + homeDir+`\.cargo\bin`, // Rust tools (cargo, uv) + homeDir+`\.local\bin`, // Python user scripts + homeDir+`\go\bin`, // Go binaries + homeDir+`\AppData\Roaming\npm`, // npm globals + homeDir+`\scoop\shims`, // Scoop packages + homeDir+`\AppData\Local\Programs\Python\Python313\Scripts`, // Python 3.13 + homeDir+`\AppData\Local\Programs\Python\Python312\Scripts`, // Python 3.12 + homeDir+`\AppData\Local\Programs\Python\Python311\Scripts`, // Python 3.11 + homeDir+`\AppData\Local\Programs\Python\Python310\Scripts`, // Python 3.10 ) } diff --git a/internal/secureenv/manager_unix.go b/internal/secureenv/manager_unix.go new file mode 100644 index 00000000..925538be --- /dev/null +++ b/internal/secureenv/manager_unix.go @@ -0,0 +1,11 @@ +//go:build !windows + +package secureenv + +// discoverWindowsPathsFromRegistry is a stub for non-Windows platforms +// On Unix/macOS, this function is never called because discoverWindowsPaths() +// is only called when runtime.GOOS == "windows" +func discoverWindowsPathsFromRegistry() []string { + // This should never be called on non-Windows platforms + return nil +} diff --git a/internal/secureenv/manager_windows.go b/internal/secureenv/manager_windows.go new file mode 100644 index 00000000..54fcda85 --- /dev/null +++ b/internal/secureenv/manager_windows.go @@ -0,0 +1,97 @@ +//go:build windows + +package secureenv + +import ( + "os" + "strings" + + "golang.org/x/sys/windows/registry" +) + +// expandWindowsEnvVars expands Windows-style %VAR% environment variables. +// os.ExpandEnv only handles $VAR/${VAR} syntax, NOT Windows %VAR%. +// registry.ExpandString calls the Windows ExpandEnvironmentStrings API. +func expandWindowsEnvVars(s string) string { + expanded, err := registry.ExpandString(s) + if err != nil { + return s // fallback to unexpanded + } + return expanded +} + +// readWindowsRegistryPath reads the PATH environment variable from Windows registry +// This is necessary because when mcpproxy is launched via installer/service, +// it doesn't inherit the user's PATH environment variable. +// The registry is the source of truth for Windows PATH configuration. +func readWindowsRegistryPath() (string, error) { + var paths []string + + // 1. Read USER PATH from HKEY_CURRENT_USER\Environment\Path + // This contains user-specific PATH additions (e.g., .cargo\bin, go\bin) + userKey, err := registry.OpenKey(registry.CURRENT_USER, + `Environment`, registry.QUERY_VALUE) + if err == nil { + defer userKey.Close() + + userPath, _, err := userKey.GetStringValue("Path") + if err == nil && userPath != "" { + // CRITICAL: Expand Windows %VAR% environment variables + // Registry stores paths as REG_EXPAND_SZ with embedded %USERPROFILE% etc. + paths = append(paths, expandWindowsEnvVars(userPath)) + } + } + + // 2. Read SYSTEM PATH from HKEY_LOCAL_MACHINE\...\Environment\Path + // This contains system-wide PATH (e.g., C:\Windows\System32, Program Files) + sysKey, err := registry.OpenKey(registry.LOCAL_MACHINE, + `SYSTEM\CurrentControlSet\Control\Session Manager\Environment`, + registry.QUERY_VALUE) + if err == nil { + defer sysKey.Close() + + systemPath, _, err := sysKey.GetStringValue("Path") + if err == nil && systemPath != "" { + paths = append(paths, expandWindowsEnvVars(systemPath)) + } + } + + // Combine User PATH + System PATH (user takes precedence) + fullPath := strings.Join(paths, string(os.PathListSeparator)) + + if fullPath == "" { + // If both registry reads failed, return error + return "", registry.ErrNotExist + } + + return fullPath, nil +} + +// discoverWindowsPathsFromRegistry reads PATH from registry and returns as slice +// This replaces the hardcoded discovery list when registry is available +func discoverWindowsPathsFromRegistry() []string { + registryPath, err := readWindowsRegistryPath() + if err != nil { + // Registry read failed, return empty slice (caller will use hardcoded fallback) + return nil + } + + // Split the combined PATH into individual directories + parts := strings.Split(registryPath, string(os.PathListSeparator)) + + // Filter to only existing directories + var existingPaths []string + for _, path := range parts { + path = strings.TrimSpace(path) + if path == "" { + continue + } + + // Check if directory exists + if info, err := os.Stat(path); err == nil && info.IsDir() { + existingPaths = append(existingPaths, path) + } + } + + return existingPaths +} diff --git a/internal/secureenv/manager_windows_test.go b/internal/secureenv/manager_windows_test.go new file mode 100644 index 00000000..0e113b8c --- /dev/null +++ b/internal/secureenv/manager_windows_test.go @@ -0,0 +1,206 @@ +//go:build windows + +package secureenv + +import ( + "os" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReadWindowsRegistryPath(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows-only test") + } + + path, err := readWindowsRegistryPath() + + // Registry read should succeed + require.NoError(t, err, "Reading Windows registry PATH should not fail") + require.NotEmpty(t, path, "Registry PATH should not be empty") + + // Path should contain system directories + assert.Contains(t, strings.ToLower(path), `c:\windows\system32`, + "Registry PATH should contain System32") + + // Path should be expanded (no %USERPROFILE% etc.) + assert.NotContains(t, path, "%USERPROFILE%", + "PATH should have %USERPROFILE% expanded") + assert.NotContains(t, path, "%APPDATA%", + "PATH should have %APPDATA% expanded") + assert.NotContains(t, path, "%LOCALAPPDATA%", + "PATH should have %LOCALAPPDATA% expanded") + + t.Logf("Registry PATH length: %d characters", len(path)) + pathParts := strings.Split(path, string(os.PathListSeparator)) + t.Logf("Registry PATH contains %d directories", len(pathParts)) + + // Log first few paths for debugging + for i, part := range pathParts { + if i >= 5 { + break + } + t.Logf(" [%d] %s", i, part) + } +} + +func TestDiscoverWindowsPathsFromRegistry(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows-only test") + } + + paths := discoverWindowsPathsFromRegistry() + + // Should return at least some paths + assert.NotEmpty(t, paths, "Should discover at least some paths from registry") + + // All returned paths should exist + for _, path := range paths { + info, err := os.Stat(path) + assert.NoError(t, err, "Path should exist: %s", path) + if err == nil { + assert.True(t, info.IsDir(), "Path should be a directory: %s", path) + } + } + + // Should contain common system paths + hasSystem32 := false + for _, path := range paths { + if strings.Contains(strings.ToLower(path), `system32`) { + hasSystem32 = true + break + } + } + assert.True(t, hasSystem32, "Should contain System32 directory") + + t.Logf("Discovered %d paths from registry", len(paths)) +} + +func TestWindowsPathExpansion(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows-only test") + } + + tests := []struct { + name string + input string + contains string + }{ + { + name: "USERPROFILE expansion", + input: `%USERPROFILE%\.cargo\bin`, + contains: `\Users\`, + }, + { + name: "APPDATA expansion", + input: `%APPDATA%\npm`, + contains: `\AppData\Roaming\`, + }, + { + name: "LOCALAPPDATA expansion", + input: `%LOCALAPPDATA%\Programs`, + contains: `\AppData\Local\`, + }, + { + name: "PROGRAMFILES expansion", + input: `%PROGRAMFILES%\Git`, + contains: `\Program Files\`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + expanded := expandWindowsEnvVars(tt.input) + + // Should not contain % after expansion + assert.NotContains(t, expanded, "%", + "Expanded path should not contain %%: %s", expanded) + + // Should contain expected substring + assert.Contains(t, expanded, tt.contains, + "Expanded path should contain %s: %s", tt.contains, expanded) + + t.Logf("Input: %s", tt.input) + t.Logf("Output: %s", expanded) + }) + } +} + +func TestDiscoverWindowsPathsWithEmptyEnvironment(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows-only test") + } + + // Save original PATH + originalPath := os.Getenv("PATH") + defer os.Setenv("PATH", originalPath) + + // Simulate empty PATH scenario (installer/service launch) + os.Setenv("PATH", "") + + // Create a manager + manager := NewManager(nil) + + // Discovery should still work via registry + paths := manager.pathDiscovery.DiscoveredPaths + assert.NotEmpty(t, paths, + "Should discover paths from registry even when PATH env is empty") + + // Should contain system paths + hasSystemPath := false + for _, path := range paths { + lowerPath := strings.ToLower(path) + if strings.Contains(lowerPath, "system32") || strings.Contains(lowerPath, "windows") { + hasSystemPath = true + break + } + } + assert.True(t, hasSystemPath, + "Should contain Windows system paths even when PATH env is empty") + + t.Logf("Discovered %d paths with empty PATH env", len(paths)) +} + +func TestManagerBuildSecureEnvironmentWithRegistryPaths(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows-only test") + } + + // Save original PATH + originalPath := os.Getenv("PATH") + defer os.Setenv("PATH", originalPath) + + // Simulate minimal PATH scenario + os.Setenv("PATH", `C:\Windows\System32`) + + // Create manager with EnhancePath enabled (matches production: core/client.go sets this) + manager := NewManager(&EnvConfig{ + InheritSystemSafe: true, + AllowedSystemVars: DefaultEnvConfig().AllowedSystemVars, + CustomVars: make(map[string]string), + EnhancePath: true, + }) + env := manager.BuildSecureEnvironment() + + // Extract PATH from environment + var builtPath string + for _, envVar := range env { + if strings.HasPrefix(envVar, "PATH=") { + builtPath = strings.TrimPrefix(envVar, "PATH=") + break + } + } + + assert.NotEmpty(t, builtPath, "Built environment should have PATH") + + // PATH should be more comprehensive than minimal input + pathParts := strings.Split(builtPath, string(os.PathListSeparator)) + assert.Greater(t, len(pathParts), 5, + "Built PATH should have more than 5 directories (got %d)", len(pathParts)) + + t.Logf("Built PATH has %d directories", len(pathParts)) +} diff --git a/internal/upstream/core/connection_stdio.go b/internal/upstream/core/connection_stdio.go index e3a88d6f..de04be16 100644 --- a/internal/upstream/core/connection_stdio.go +++ b/internal/upstream/core/connection_stdio.go @@ -390,8 +390,26 @@ func shellescape(s string) string { if !strings.ContainsAny(s, " \t\n\r\"&|<>()^%") { return s } - // For Windows, use double quotes and escape internal double quotes - return `"` + strings.ReplaceAll(s, `"`, `\"`) + `"` + + // IMPORTANT: cmd.exe uses DIFFERENT escaping than Unix shells + // - Backslash is NOT an escape character in cmd.exe + // - To include a literal quote in a quoted string, you must: + // 1. End the quoted string + // 2. Add an escaped quote (\") + // 3. Start a new quoted string + // + // However, for our use case with cmd /c "command args", we wrap + // the entire command line once, and individual components should + // NOT contain quotes. If they do, we need special handling. + // + // Strategy: If string already contains quotes, strip them first + // This handles the case where users put quotes in JSON config + cleaned := strings.Trim(s, `"`) + + // Now wrap in quotes for cmd.exe + // Any remaining internal quotes are from the actual path and will cause issues + // For now, we just quote the cleaned string + return `"` + cleaned + `"` } // Unix shell special characters if !strings.ContainsAny(s, " \t\n\r\"'\\$`;&|<>(){}[]?*~") {