返回 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 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
412 lines GO