返回 DeepSeek-Reasonix
shell_inventory.go
根目录 / internal / sandbox / shell_inventory.go
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 prefer string
65 configPath string
66 lookPath func(string) (string, error)
67 exists func(string) bool
68 isWSL func(string) bool
69 bashCands []string
70 psCands []string
71 sources map[string]string
72 caps []ShellCapability
73 gitOnce sync.Once
74 git ExecutableCapability
75 gitPreflight func(string) bool
76 gitProbe func(string) bool
77 probeFunc func(string) bool
78 probeMu sync.Mutex
79 probeCache map[string]bool
80 }
81
82 func (s *shellSnapshot) probe(path string) bool {
83 s.probeMu.Lock()
84 defer s.probeMu.Unlock()
85 if v, ok := s.probeCache[path]; ok {
86 return v
87 }
88 probe := s.probeFunc
89 if probe == nil {
90 probe = probeBash
91 }
92 v := probe(path)
93 s.probeCache[path] = v
94 return v
95 }
96
97 // shellInventory is the process-wide single-entry, singleflight discovery
98 // cache. The entry is keyed by (GOOS, preference, configured shell path): a
99 // different preference or path misses and rebuilds rather than serving
100 // candidates attributed to the wrong shell kind. build is the discovery pass
101 // (injectable so the cache contract itself is testable).
102 type shellInventory struct {
103 mu sync.Mutex
104 current *shellSnapshot
105 refreshing chan struct{}
106 generation uint64
107 build func(goos, prefer, configPath string) *shellSnapshot
108 }
109
110 func newShellInventory() *shellInventory {
111 return &shellInventory{build: buildShellSnapshot}
112 }
113
114 var defaultShellInventory = newShellInventory()
115
116 // InvalidateShellInventory drops the cached discovery snapshot. Call after a
117 // helper install finishes (or the user asks to re-detect) so the next
118 // ResolveShell re-probes instead of trusting the pre-install result.
119 func InvalidateShellInventory() {
120 defaultShellInventory.invalidate()
121 aliasLaunches.reset()
122 }
123
124 func (inv *shellInventory) invalidate() {
125 inv.mu.Lock()
126 defer inv.mu.Unlock()
127 inv.current = nil
128 // Bump the generation so a refresh that started before the invalidation
129 // cannot republish its already-stale result as the new snapshot.
130 inv.generation++
131 }
132
133 // snapshot returns a discovery result for (goos, prefer, configPath), building
134 // one when the cached entry is missing, keyed differently, or older than the TTL.
135 // Concurrent callers coalesce onto the in-flight build (singleflight): the
136 // first caller builds, the rest wait and then re-check the cache.
137 func (inv *shellInventory) snapshot(goos, prefer, configPath string) *shellSnapshot {
138 key := shellInventoryKey(goos, prefer, configPath)
139 for {
140 inv.mu.Lock()
141 if inv.current != nil && inv.current.key == key && time.Since(inv.current.builtAt) < shellInventoryTTL {
142 snap := inv.current
143 inv.mu.Unlock()
144 return snap
145 }
146 if inv.refreshing != nil {
147 wait := inv.refreshing
148 inv.mu.Unlock()
149 <-wait
150 continue
151 }
152 inv.refreshing = make(chan struct{})
153 generation := inv.generation
154 inv.mu.Unlock()
155
156 snap := inv.build(goos, prefer, configPath)
157
158 inv.mu.Lock()
159 done := inv.refreshing
160 inv.refreshing = nil
161 if inv.generation == generation {
162 inv.current = snap
163 }
164 close(done)
165 inv.mu.Unlock()
166 return snap
167 }
168 }
169
170 func shellInventoryKey(goos, prefer, configPath string) string {
171 path := filepath.Clean(strings.TrimSpace(configPath))
172 if goos == "windows" {
173 path = strings.ToLower(path)
174 }
175 return goos + "\x00" + strings.ToLower(strings.TrimSpace(prefer)) + "\x00" + path
176 }
177
178 // buildShellSnapshot performs one discovery pass. Windows candidate priority:
179 // a compatible configured Bash path first, then bash.exe on PATH (checked by
180 // resolveShell before any candidate), then bash.exe derived from the installed
181 // git.exe / git-bash.exe, then the Git for Windows registry InstallPath, then
182 // the standard install roots. Auto selection prefers native PowerShell; this
183 // ordering applies when Bash is explicitly requested.
184 func buildShellSnapshot(goos, prefer, configPath string) *shellSnapshot {
185 snap := &shellSnapshot{
186 key: shellInventoryKey(goos, prefer, configPath),
187 builtAt: time.Now(),
188 goos: goos,
189 prefer: prefer,
190 configPath: configPath,
191 lookPath: exec.LookPath,
192 exists: fileExists,
193 isWSL: isWindowsWSLBash,
194 gitPreflight: gitCandidatePreflight,
195 gitProbe: probeGit,
196 probeFunc: probeBash,
197 sources: map[string]string{},
198 probeCache: map[string]bool{},
199 }
200 if goos == "windows" {
201 snap.bashCands, snap.sources = windowsBashCandidateSources(prefer, configPath, exec.LookPath, fileExists)
202 snap.lookPath = launchablePowerShellLookPath(exec.LookPath, powerShellLaunches)
203 snap.psCands = launchablePowerShells(windowsPowerShellCandidates(), powerShellLaunches)
204 snap.caps = windowsShellCapabilities(snap)
205 } else {
206 snap.caps = unixShellCapabilities(snap)
207 }
208 return snap
209 }
210
211 // ShellCapabilitiesForConfig reports the discovered interpreter inventory for
212 // a complete [tools.shell] selection. The preference is part of the cache key
213 // because a retained PowerShell path must never become an auto-detected Bash.
214 func ShellCapabilitiesForConfig(prefer, configPath string) []ShellCapability {
215 snap := defaultShellInventory.snapshot(runtime.GOOS, prefer, configPath)
216 out := make([]ShellCapability, len(snap.caps))
217 copy(out, snap.caps)
218 return out
219 }
220
221 // ShellCapabilitiesForPath is the legacy path-scoped inventory entry point.
222 // Windows keeps the path in the shared snapshot for Git Bash discovery.
223 func ShellCapabilitiesForPath(configPath string) []ShellCapability {
224 return ShellCapabilitiesForConfig("bash", configPath)
225 }
226
227 // ShellCapabilities is the config-free inventory used by callers such as
228 // doctor that do not own a loaded desktop configuration.
229 func ShellCapabilities() []ShellCapability {
230 return ShellCapabilitiesForConfig("", "")
231 }
232
233 // GitCapabilityForConfig reports Git independently from the shell inventory.
234 // The full shell selection prevents a retained PowerShell path from being used
235 // as the root for Git-for-Windows discovery.
236 func GitCapabilityForConfig(prefer, configPath string) ExecutableCapability {
237 snap := defaultShellInventory.snapshot(runtime.GOOS, prefer, configPath)
238 snap.gitOnce.Do(func() { snap.git = discoverGitCapability(snap) })
239 return snap.git
240 }
241
242 // GitCapabilityForPath reports Git independently from the shell inventory.
243 // configPath only helps portable Git for Windows discovery; Git never changes
244 // the configured or resolved shell.
245 func GitCapabilityForPath(configPath string) ExecutableCapability {
246 return GitCapabilityForConfig("bash", configPath)
247 }
248
249 // shellCandidate pairs an ordered discovery path with the source bucket it
250 // came from, so the winning capability can explain itself.
251 type shellCandidate struct {
252 path string
253 source string
254 }
255
256 // windowsBashCandidateSources lists Git-for-Windows bash.exe candidates in
257 // discovery priority order with their sources, deduplicated case-insensitively
258 // and with WSL launchers and execution aliases excluded: a native Windows workspace must never be routed
259 // into the Linux VM's /mnt/* view of itself. lookPath and exists are injected
260 // so the ordering is testable on any host.
261 func windowsBashCandidateSources(prefer, configPath string, lookPath func(string) (string, error), exists func(string) bool) ([]string, map[string]string) {
262 var ordered []shellCandidate
263 seen := map[string]bool{}
264 push := func(path, source string) {
265 if path == "" {
266 return
267 }
268 path = filepath.Clean(path)
269 if isWindowsWSLBash(path) {
270 return
271 }
272 key := strings.ToLower(path)
273 if seen[key] {
274 return
275 }
276 seen[key] = true
277 ordered = append(ordered, shellCandidate{path: path, source: source})
278 }
279
280 // 1. The user-configured absolute path, with git-bash.exe rewritten to the
281 // real console binary bin\bash.exe (never MinTTY).
282 if path := configuredWindowsBashPath(prefer, configPath, exists); path != "" {
283 push(path, ShellSourceConfig)
284 }
285 // 2. bash.exe on PATH is resolved by resolveShell ahead of every candidate,
286 // so it needs no entry here — only the capability attribution below.
287 // 3. Derive the install root from a git.exe / git-bash.exe that IS on PATH.
288 for _, name := range []string{"git-bash.exe", "git.exe", "git"} {
289 if bin, err := lookPath(name); err == nil {
290 for _, derived := range bashCandidatesFromGitBinary(bin) {
291 push(derived, ShellSourceGitDerived)
292 }
293 }
294 }
295 // 4. Git for Windows registry InstallPath (HKLM and HKCU, native + WOW64).
296 for _, root := range windowsRegistryGitRoots() {
297 push(filepath.Join(root, "bin", "bash.exe"), ShellSourceRegistry)
298 push(filepath.Join(root, "usr", "bin", "bash.exe"), ShellSourceRegistry)
299 }
300 // 5. Standard install roots: Program Files, per-user Programs, and the
301 // fixed layouts Scoop and Chocolatey use.
302 for _, root := range windowsStandardGitRoots() {
303 push(filepath.Join(root, "bin", "bash.exe"), ShellSourceStandard)
304 push(filepath.Join(root, "usr", "bin", "bash.exe"), ShellSourceStandard)
305 }
306
307 paths := make([]string, len(ordered))
308 sources := map[string]string{}
309 for i, c := range ordered {
310 paths[i] = c.path
311 sources[strings.ToLower(c.path)] = c.source
312 }
313 return paths, sources
314 }
315
316 // configuredWindowsBashPath admits an arbitrary explicit path only when the
317 // user forced Bash. Auto-detection accepts well-known Bash executable names but
318 // rejects a retained PowerShell path from an earlier preference.
319 func configuredWindowsBashPath(prefer, path string, exists func(string) bool) string {
320 if strings.TrimSpace(path) == "" {
321 return ""
322 }
323 switch strings.ToLower(strings.TrimSpace(prefer)) {
324 case "bash":
325 return configuredShellPath("windows", ShellBash, path, exists, isWindowsWSLBash)
326 case "powershell", "pwsh":
327 return ""
328 }
329 base := strings.ToLower(strings.TrimSuffix(pathBase(path), ".exe"))
330 if base != "bash" && base != "git-bash" {
331 return ""
332 }
333 return configuredShellPath("windows", ShellBash, path, exists, isWindowsWSLBash)
334 }
335
336 // bashCandidatesFromGitBinary maps a git.exe or git-bash.exe location to the
337 // bash.exe files shipped beside it. Git for Windows keeps bash.exe in
338 // <root>\bin and <root>\usr\bin while git.exe lives in <root>\cmd,
339 // <root>\bin, <root>\mingw64\bin, or the root itself (portable layouts), so
340 // probe the binary's directory and its first two ancestors.
341 func bashCandidatesFromGitBinary(bin string) []string {
342 var out []string
343 dir := pathDir(bin)
344 for range 3 {
345 if dir == "" || dir == "." {
346 break
347 }
348 out = append(out,
349 filepath.Join(dir, "bin", "bash.exe"),
350 filepath.Join(dir, "usr", "bin", "bash.exe"),
351 )
352 next := pathDir(dir)
353 if next == dir {
354 break
355 }
356 dir = next
357 }
358 return out
359 }
360
361 // windowsStandardGitRoots lists directories that contain a "Git" subdirectory
362 // (winget/msi defaults and per-user installs) plus roots that already sit at a
363 // Git install tree (Scoop, Chocolatey).
364 func windowsStandardGitRoots() []string {
365 var withGitSubdir []string
366 for _, env := range []string{"ProgramFiles", "ProgramW6432", "ProgramFiles(x86)"} {
367 if v := os.Getenv(env); v != "" {
368 withGitSubdir = append(withGitSubdir, filepath.Join(v, "Git"))
369 }
370 }
371 if v := os.Getenv("LOCALAPPDATA"); v != "" {
372 withGitSubdir = append(withGitSubdir, filepath.Join(v, "Programs", "Git"))
373 }
374 var atGitRoot []string
375 if v := os.Getenv("ProgramData"); v != "" {
376 atGitRoot = append(atGitRoot, filepath.Join(v, "chocolatey", "lib", "git", "tools"))
377 }
378 if v := os.Getenv("USERPROFILE"); v != "" {
379 atGitRoot = append(atGitRoot, filepath.Join(v, "scoop", "apps", "git", "current"))
380 }
381 return append(withGitSubdir, atGitRoot...)
382 }
383
384 // windowsShellCapabilities reports Git Bash and both native PowerShell runtimes.
385 // Bash discovery follows the explicit preference's configured-path, PATH, and
386 // install-location order; automatic selection still prefers PowerShell.
387 func windowsShellCapabilities(snap *shellSnapshot) []ShellCapability {
388 caps := make([]ShellCapability, 0, 3)
389 gitBash := ShellCapability{ID: ShellCapabilityGitBash, Variant: "git-for-windows"}
390 acceptCandidate := func(path, source string) bool {
391 if path == "" || !snap.exists(path) || snap.isWSL(path) || !snap.probe(path) {
392 return false
393 }
394 gitBash.Available = true
395 gitBash.Path = path
396 gitBash.Source = source
397 return true
398 }
399 for _, path := range snap.bashCands {
400 if snap.sources[strings.ToLower(path)] == ShellSourceConfig && acceptCandidate(path, ShellSourceConfig) {
401 break
402 }
403 }
404 if !gitBash.Available {
405 if path, err := snap.lookPath("bash"); err == nil {
406 acceptCandidate(path, ShellSourcePath)
407 }
408 }
409 if !gitBash.Available {
410 for _, path := range snap.bashCands {
411 source := snap.sources[strings.ToLower(path)]
412 if source != ShellSourceConfig && acceptCandidate(path, source) {
413 break
414 }
415 }
416 }
417 if !gitBash.Available {
418 gitBash.Reason = "not-installed"
419 }
420 caps = append(caps, gitBash)
421 caps = append(caps, windowsPowerShellCapability(snap, ShellCapabilityPwsh, []string{"pwsh", "pwsh.exe"}, "pwsh"))
422 caps = append(caps, windowsPowerShellCapability(snap, ShellCapabilityPowerShell, []string{"powershell", "powershell.exe"}, "powershell"))
423 return caps
424 }
425
426 func windowsPowerShellCapability(snap *shellSnapshot, id string, names []string, base string) ShellCapability {
427 cap := ShellCapability{ID: id}
428 prefer := strings.ToLower(strings.TrimSpace(snap.prefer))
429 if prefer == "" || prefer == "auto" {
430 // Auto honors only named native PowerShell paths, just as resolution does.
431 prefer = strings.TrimSuffix(strings.ToLower(pathBase(strings.TrimSpace(snap.configPath))), ".exe")
432 }
433 if prefer == "powershell" || prefer == "pwsh" {
434 p := configuredShellPathForPreference("windows", prefer, snap.configPath, snap.exists, snap.isWSL)
435 sh := Shell{Kind: ShellPowerShell, Path: p}
436 if p != "" && snap.exists(p) && sh.SupportsChaining() == (id == ShellCapabilityPwsh) {
437 cap.Available, cap.Path, cap.Source = true, p, ShellSourceConfig
438 return cap
439 }
440 }
441 for _, p := range snap.psCands {
442 fileBase := strings.ToLower(pathBase(p))
443 fileBase = strings.TrimSuffix(fileBase, ".exe")
444 if fileBase != base {
445 continue
446 }
447 if snap.exists(p) {
448 cap.Available = true
449 cap.Path = p
450 cap.Source = ShellSourceStandard
451 return cap
452 }
453 }
454 for _, name := range names {
455 if p, err := snap.lookPath(name); err == nil {
456 cap.Available = true
457 cap.Path = p
458 cap.Source = ShellSourcePath
459 return cap
460 }
461 }
462 cap.Reason = "not-installed"
463 return cap
464 }
465
465 lines GO