| 1 | package sandbox |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "os/exec" |
| 6 | "path/filepath" |
| 7 | "runtime" |
| 8 | "strings" |
| 9 | "sync" |
| 10 | "time" |
| 11 | ) |
| 12 | |
| 13 | // Shell capability identifiers as surfaced to hosts (doctor, desktop settings). |
| 14 | const ( |
| 15 | HostCapabilityGit = "git" |
| 16 | ShellCapabilityBash = "bash" |
| 17 | ShellCapabilityGitBash = "git-bash" |
| 18 | ShellCapabilityPowerShell = "powershell" |
| 19 | ShellCapabilityPwsh = "pwsh" |
| 20 | ShellCapabilityZsh = "zsh" |
| 21 | ShellCapabilitySh = "sh" |
| 22 | ) |
| 23 | |
| 24 | // How a discovered shell was found. Discovery walks these in the fixed |
| 25 | // priority order documented on buildShellSnapshot; the winner's source is |
| 26 | // reported so settings surfaces can explain the resolution. |
| 27 | const ( |
| 28 | ShellSourceConfig = "config" // user-configured absolute path |
| 29 | ShellSourcePath = "path" // executable found on PATH |
| 30 | ShellSourceGitDerived = "git-derived" // sibling of git.exe / git-bash.exe |
| 31 | ShellSourceRegistry = "registry" // Git for Windows InstallPath |
| 32 | ShellSourceStandard = "standard-path" // Program Files and friends |
| 33 | ) |
| 34 | |
| 35 | // ExecutableCapability is one discovered host executable: whether it is |
| 36 | // usable, where it lives, how discovery found it, and why not when unavailable. |
| 37 | type ExecutableCapability struct { |
| 38 | ID string `json:"id"` |
| 39 | Variant string `json:"variant,omitempty"` |
| 40 | Available bool `json:"available"` |
| 41 | Path string `json:"path,omitempty"` |
| 42 | Source string `json:"source,omitempty"` |
| 43 | Reason string `json:"reason,omitempty"` |
| 44 | } |
| 45 | |
| 46 | // ShellCapability keeps the interpreter inventory API source-compatible while |
| 47 | // Git is modeled separately through GitCapabilityForConfig. |
| 48 | type ShellCapability = ExecutableCapability |
| 49 | |
| 50 | // shellInventoryTTL bounds how long a discovery snapshot stays trusted. A |
| 51 | // manual repair changes the filesystem without changing config, so the |
| 52 | // desktop's explicit re-detect action invalidates the inventory immediately |
| 53 | // instead of waiting for this expiry. |
| 54 | const shellInventoryTTL = 30 * time.Second |
| 55 | |
| 56 | // shellSnapshot is one discovery pass: the environment inputs ResolveShell |
| 57 | // consumes (candidate lists, probe results) plus the capability report. It is |
| 58 | // immutable once built, so concurrent resolutions share the same probe cache |
| 59 | // instead of each spawning `bash -c true` health checks. |
| 60 | type shellSnapshot struct { |
| 61 | key string |
| 62 | builtAt time.Time |
| 63 | goos string |
| 64 | lookPath func(string) (string, error) |
| 65 | exists func(string) bool |
| 66 | isWSL func(string) bool |
| 67 | bashCands []string |
| 68 | psCands []string |
| 69 | sources map[string]string |
| 70 | caps []ShellCapability |
| 71 | gitOnce sync.Once |
| 72 | git ExecutableCapability |
| 73 | gitPreflight func(string) bool |
| 74 | gitProbe func(string) bool |
| 75 | probeFunc func(string) bool |
| 76 | probeMu sync.Mutex |
| 77 | probeCache map[string]bool |
| 78 | } |
| 79 | |
| 80 | func (s *shellSnapshot) probe(path string) bool { |
| 81 | s.probeMu.Lock() |
| 82 | defer s.probeMu.Unlock() |
| 83 | if v, ok := s.probeCache[path]; ok { |
| 84 | return v |
| 85 | } |
| 86 | probe := s.probeFunc |
| 87 | if probe == nil { |
| 88 | probe = probeBash |
| 89 | } |
| 90 | v := probe(path) |
| 91 | s.probeCache[path] = v |
| 92 | return v |
| 93 | } |
| 94 | |
| 95 | // shellInventory is the process-wide single-entry, singleflight discovery |
| 96 | // cache. The entry is keyed by (GOOS, preference, configured shell path): a |
| 97 | // different preference or path misses and rebuilds rather than serving |
| 98 | // candidates attributed to the wrong shell kind. build is the discovery pass |
| 99 | // (injectable so the cache contract itself is testable). |
| 100 | type shellInventory struct { |
| 101 | mu sync.Mutex |
| 102 | current *shellSnapshot |
| 103 | refreshing chan struct{} |
| 104 | generation uint64 |
| 105 | build func(goos, prefer, configPath string) *shellSnapshot |
| 106 | } |
| 107 | |
| 108 | func newShellInventory() *shellInventory { |
| 109 | return &shellInventory{build: buildShellSnapshot} |
| 110 | } |
| 111 | |
| 112 | var defaultShellInventory = newShellInventory() |
| 113 | |
| 114 | // InvalidateShellInventory drops the cached discovery snapshot. Call after a |
| 115 | // helper install finishes (or the user asks to re-detect) so the next |
| 116 | // ResolveShell re-probes instead of trusting the pre-install result. |
| 117 | func InvalidateShellInventory() { |
| 118 | defaultShellInventory.invalidate() |
| 119 | } |
| 120 | |
| 121 | func (inv *shellInventory) invalidate() { |
| 122 | inv.mu.Lock() |
| 123 | defer inv.mu.Unlock() |
| 124 | inv.current = nil |
| 125 | // Bump the generation so a refresh that started before the invalidation |
| 126 | // cannot republish its already-stale result as the new snapshot. |
| 127 | inv.generation++ |
| 128 | } |
| 129 | |
| 130 | // snapshot returns a discovery result for (goos, prefer, configPath), building |
| 131 | // one when the cached entry is missing, keyed differently, or older than the TTL. |
| 132 | // Concurrent callers coalesce onto the in-flight build (singleflight): the |
| 133 | // first caller builds, the rest wait and then re-check the cache. |
| 134 | func (inv *shellInventory) snapshot(goos, prefer, configPath string) *shellSnapshot { |
| 135 | key := shellInventoryKey(goos, prefer, configPath) |
| 136 | for { |
| 137 | inv.mu.Lock() |
| 138 | if inv.current != nil && inv.current.key == key && time.Since(inv.current.builtAt) < shellInventoryTTL { |
| 139 | snap := inv.current |
| 140 | inv.mu.Unlock() |
| 141 | return snap |
| 142 | } |
| 143 | if inv.refreshing != nil { |
| 144 | wait := inv.refreshing |
| 145 | inv.mu.Unlock() |
| 146 | <-wait |
| 147 | continue |
| 148 | } |
| 149 | inv.refreshing = make(chan struct{}) |
| 150 | generation := inv.generation |
| 151 | inv.mu.Unlock() |
| 152 | |
| 153 | snap := inv.build(goos, prefer, configPath) |
| 154 | |
| 155 | inv.mu.Lock() |
| 156 | done := inv.refreshing |
| 157 | inv.refreshing = nil |
| 158 | if inv.generation == generation { |
| 159 | inv.current = snap |
| 160 | } |
| 161 | close(done) |
| 162 | inv.mu.Unlock() |
| 163 | return snap |
| 164 | } |
| 165 | } |
| 166 | |
| 167 | func shellInventoryKey(goos, prefer, configPath string) string { |
| 168 | return goos + "\x00" + strings.ToLower(strings.TrimSpace(prefer)) + "\x00" + strings.ToLower(filepath.Clean(strings.TrimSpace(configPath))) |
| 169 | } |
| 170 | |
| 171 | // buildShellSnapshot performs one discovery pass. Windows candidate priority: |
| 172 | // a compatible configured Bash path first, then bash.exe on PATH (checked by |
| 173 | // resolveShell before any candidate), then bash.exe derived from the installed |
| 174 | // git.exe / git-bash.exe, then the Git for Windows registry InstallPath, then |
| 175 | // the standard install roots. Auto selection prefers native PowerShell; this |
| 176 | // ordering applies when Bash is explicitly requested or no native shell exists. |
| 177 | func buildShellSnapshot(goos, prefer, configPath string) *shellSnapshot { |
| 178 | snap := &shellSnapshot{ |
| 179 | key: shellInventoryKey(goos, prefer, configPath), |
| 180 | builtAt: time.Now(), |
| 181 | goos: goos, |
| 182 | lookPath: exec.LookPath, |
| 183 | exists: fileExists, |
| 184 | isWSL: isWindowsWSLBash, |
| 185 | gitPreflight: gitCandidatePreflight, |
| 186 | gitProbe: probeGit, |
| 187 | probeFunc: probeBash, |
| 188 | sources: map[string]string{}, |
| 189 | probeCache: map[string]bool{}, |
| 190 | } |
| 191 | if goos == "windows" { |
| 192 | snap.bashCands, snap.sources = windowsBashCandidateSources(prefer, configPath, exec.LookPath, fileExists) |
| 193 | snap.psCands = windowsPowerShellCandidates() |
| 194 | snap.caps = windowsShellCapabilities(snap) |
| 195 | } else { |
| 196 | snap.caps = unixShellCapabilities(snap) |
| 197 | } |
| 198 | return snap |
| 199 | } |
| 200 | |
| 201 | // ShellCapabilitiesForConfig reports the discovered interpreter inventory for |
| 202 | // a complete [tools.shell] selection. The preference is part of the cache key |
| 203 | // because a retained PowerShell path must never become an auto-detected Bash. |
| 204 | func ShellCapabilitiesForConfig(prefer, configPath string) []ShellCapability { |
| 205 | snap := defaultShellInventory.snapshot(runtime.GOOS, prefer, configPath) |
| 206 | out := make([]ShellCapability, len(snap.caps)) |
| 207 | copy(out, snap.caps) |
| 208 | return out |
| 209 | } |
| 210 | |
| 211 | // ShellCapabilitiesForPath is the legacy path-scoped inventory entry point. |
| 212 | // Windows keeps the path in the shared snapshot for Git discovery, while the |
| 213 | // returned Agent runtime list remains limited to native PowerShell. |
| 214 | func ShellCapabilitiesForPath(configPath string) []ShellCapability { |
| 215 | return ShellCapabilitiesForConfig("bash", configPath) |
| 216 | } |
| 217 | |
| 218 | // ShellCapabilities is the config-free inventory used by callers such as |
| 219 | // doctor that do not own a loaded desktop configuration. |
| 220 | func ShellCapabilities() []ShellCapability { |
| 221 | return ShellCapabilitiesForConfig("", "") |
| 222 | } |
| 223 | |
| 224 | // GitCapabilityForConfig reports Git independently from the shell inventory. |
| 225 | // The full shell selection prevents a retained PowerShell path from being used |
| 226 | // as the root for Git-for-Windows discovery. |
| 227 | func GitCapabilityForConfig(prefer, configPath string) ExecutableCapability { |
| 228 | snap := defaultShellInventory.snapshot(runtime.GOOS, prefer, configPath) |
| 229 | snap.gitOnce.Do(func() { snap.git = discoverGitCapability(snap) }) |
| 230 | return snap.git |
| 231 | } |
| 232 | |
| 233 | // GitCapabilityForPath reports Git independently from the shell inventory. |
| 234 | // configPath only helps portable Git for Windows discovery; Git never changes |
| 235 | // the configured or resolved shell. |
| 236 | func GitCapabilityForPath(configPath string) ExecutableCapability { |
| 237 | return GitCapabilityForConfig("bash", configPath) |
| 238 | } |
| 239 | |
| 240 | // shellCandidate pairs an ordered discovery path with the source bucket it |
| 241 | // came from, so the winning capability can explain itself. |
| 242 | type shellCandidate struct { |
| 243 | path string |
| 244 | source string |
| 245 | } |
| 246 | |
| 247 | // windowsBashCandidateSources lists Git-for-Windows bash.exe candidates in |
| 248 | // discovery priority order with their sources, deduplicated case-insensitively |
| 249 | // and with the WSL launcher excluded: the only bash.exe under %SystemRoot% is |
| 250 | // the WSL bootstrapper, and a native Windows workspace must never be routed |
| 251 | // into the Linux VM's /mnt/* view of itself. lookPath and exists are injected |
| 252 | // so the ordering is testable on any host. |
| 253 | func windowsBashCandidateSources(prefer, configPath string, lookPath func(string) (string, error), exists func(string) bool) ([]string, map[string]string) { |
| 254 | var ordered []shellCandidate |
| 255 | seen := map[string]bool{} |
| 256 | push := func(path, source string) { |
| 257 | if path == "" { |
| 258 | return |
| 259 | } |
| 260 | path = filepath.Clean(path) |
| 261 | if isWindowsWSLBash(path) { |
| 262 | return |
| 263 | } |
| 264 | key := strings.ToLower(path) |
| 265 | if seen[key] { |
| 266 | return |
| 267 | } |
| 268 | seen[key] = true |
| 269 | ordered = append(ordered, shellCandidate{path: path, source: source}) |
| 270 | } |
| 271 | |
| 272 | // 1. The user-configured absolute path, with git-bash.exe rewritten to the |
| 273 | // real console binary bin\bash.exe (never MinTTY). |
| 274 | if path := configuredWindowsBashPath(prefer, configPath, exists); path != "" { |
| 275 | push(path, ShellSourceConfig) |
| 276 | } |
| 277 | // 2. bash.exe on PATH is resolved by resolveShell ahead of every candidate, |
| 278 | // so it needs no entry here — only the capability attribution below. |
| 279 | // 3. Derive the install root from a git.exe / git-bash.exe that IS on PATH. |
| 280 | for _, name := range []string{"git-bash.exe", "git.exe", "git"} { |
| 281 | if bin, err := lookPath(name); err == nil { |
| 282 | for _, derived := range bashCandidatesFromGitBinary(bin) { |
| 283 | push(derived, ShellSourceGitDerived) |
| 284 | } |
| 285 | } |
| 286 | } |
| 287 | // 4. Git for Windows registry InstallPath (HKLM and HKCU, native + WOW64). |
| 288 | for _, root := range windowsRegistryGitRoots() { |
| 289 | push(filepath.Join(root, "bin", "bash.exe"), ShellSourceRegistry) |
| 290 | push(filepath.Join(root, "usr", "bin", "bash.exe"), ShellSourceRegistry) |
| 291 | } |
| 292 | // 5. Standard install roots: Program Files, per-user Programs, and the |
| 293 | // fixed layouts Scoop and Chocolatey use. |
| 294 | for _, root := range windowsStandardGitRoots() { |
| 295 | push(filepath.Join(root, "bin", "bash.exe"), ShellSourceStandard) |
| 296 | push(filepath.Join(root, "usr", "bin", "bash.exe"), ShellSourceStandard) |
| 297 | } |
| 298 | |
| 299 | paths := make([]string, len(ordered)) |
| 300 | sources := map[string]string{} |
| 301 | for i, c := range ordered { |
| 302 | paths[i] = c.path |
| 303 | sources[strings.ToLower(c.path)] = c.source |
| 304 | } |
| 305 | return paths, sources |
| 306 | } |
| 307 | |
| 308 | // configuredWindowsBashPath admits an arbitrary explicit path only when the |
| 309 | // user forced Bash. Auto-detection accepts well-known Bash executable names but |
| 310 | // rejects a retained PowerShell path from an earlier preference. |
| 311 | func configuredWindowsBashPath(prefer, path string, exists func(string) bool) string { |
| 312 | if strings.TrimSpace(path) == "" { |
| 313 | return "" |
| 314 | } |
| 315 | switch strings.ToLower(strings.TrimSpace(prefer)) { |
| 316 | case "bash": |
| 317 | return configuredShellPath("windows", ShellBash, path, exists, isWindowsWSLBash) |
| 318 | case "powershell", "pwsh": |
| 319 | return "" |
| 320 | } |
| 321 | base := strings.ToLower(strings.TrimSuffix(pathBase(path), ".exe")) |
| 322 | if base != "bash" && base != "git-bash" { |
| 323 | return "" |
| 324 | } |
| 325 | return configuredShellPath("windows", ShellBash, path, exists, isWindowsWSLBash) |
| 326 | } |
| 327 | |
| 328 | // bashCandidatesFromGitBinary maps a git.exe or git-bash.exe location to the |
| 329 | // bash.exe files shipped beside it. Git for Windows keeps bash.exe in |
| 330 | // <root>\bin and <root>\usr\bin while git.exe lives in <root>\cmd, |
| 331 | // <root>\bin, <root>\mingw64\bin, or the root itself (portable layouts), so |
| 332 | // probe the binary's directory and its first two ancestors. |
| 333 | func bashCandidatesFromGitBinary(bin string) []string { |
| 334 | var out []string |
| 335 | dir := pathDir(bin) |
| 336 | for range 3 { |
| 337 | if dir == "" || dir == "." { |
| 338 | break |
| 339 | } |
| 340 | out = append(out, |
| 341 | filepath.Join(dir, "bin", "bash.exe"), |
| 342 | filepath.Join(dir, "usr", "bin", "bash.exe"), |
| 343 | ) |
| 344 | next := pathDir(dir) |
| 345 | if next == dir { |
| 346 | break |
| 347 | } |
| 348 | dir = next |
| 349 | } |
| 350 | return out |
| 351 | } |
| 352 | |
| 353 | // windowsStandardGitRoots lists directories that contain a "Git" subdirectory |
| 354 | // (winget/msi defaults and per-user installs) plus roots that already sit at a |
| 355 | // Git install tree (Scoop, Chocolatey). |
| 356 | func windowsStandardGitRoots() []string { |
| 357 | var withGitSubdir []string |
| 358 | for _, env := range []string{"ProgramFiles", "ProgramW6432", "ProgramFiles(x86)"} { |
| 359 | if v := os.Getenv(env); v != "" { |
| 360 | withGitSubdir = append(withGitSubdir, filepath.Join(v, "Git")) |
| 361 | } |
| 362 | } |
| 363 | if v := os.Getenv("LOCALAPPDATA"); v != "" { |
| 364 | withGitSubdir = append(withGitSubdir, filepath.Join(v, "Programs", "Git")) |
| 365 | } |
| 366 | var atGitRoot []string |
| 367 | if v := os.Getenv("ProgramData"); v != "" { |
| 368 | atGitRoot = append(atGitRoot, filepath.Join(v, "chocolatey", "lib", "git", "tools")) |
| 369 | } |
| 370 | if v := os.Getenv("USERPROFILE"); v != "" { |
| 371 | atGitRoot = append(atGitRoot, filepath.Join(v, "scoop", "apps", "git", "current")) |
| 372 | } |
| 373 | return append(withGitSubdir, atGitRoot...) |
| 374 | } |
| 375 | |
| 376 | // windowsShellCapabilities reports the native runtimes available to the Windows |
| 377 | // Agent. Git Bash discovery is retained for Git compatibility, but it is not an |
| 378 | // Agent shell capability now that the Windows provider always exposes pwsh. |
| 379 | func windowsShellCapabilities(snap *shellSnapshot) []ShellCapability { |
| 380 | caps := make([]ShellCapability, 0, 2) |
| 381 | caps = append(caps, windowsPowerShellCapability(snap, ShellCapabilityPwsh, []string{"pwsh", "pwsh.exe"}, "pwsh")) |
| 382 | caps = append(caps, windowsPowerShellCapability(snap, ShellCapabilityPowerShell, []string{"powershell", "powershell.exe"}, "powershell")) |
| 383 | return caps |
| 384 | } |
| 385 | |
| 386 | func windowsPowerShellCapability(snap *shellSnapshot, id string, names []string, base string) ShellCapability { |
| 387 | cap := ShellCapability{ID: id} |
| 388 | for _, p := range snap.psCands { |
| 389 | fileBase := strings.ToLower(pathBase(p)) |
| 390 | fileBase = strings.TrimSuffix(fileBase, ".exe") |
| 391 | if fileBase != base { |
| 392 | continue |
| 393 | } |
| 394 | if snap.exists(p) { |
| 395 | cap.Available = true |
| 396 | cap.Path = p |
| 397 | cap.Source = ShellSourceStandard |
| 398 | return cap |
| 399 | } |
| 400 | } |
| 401 | for _, name := range names { |
| 402 | if p, err := snap.lookPath(name); err == nil { |
| 403 | cap.Available = true |
| 404 | cap.Path = p |
| 405 | cap.Source = ShellSourcePath |
| 406 | return cap |
| 407 | } |
| 408 | } |
| 409 | cap.Reason = "not-installed" |
| 410 | return cap |
| 411 | } |
| 412 |