返回 DeepSeek-Reasonix
shell.go
根目录 / internal / sandbox / shell.go
1 package sandbox
2
3 import (
4 "context"
5 "fmt"
6 "io"
7 "os"
8 "path/filepath"
9 "runtime"
10 "strings"
11 "time"
12
13 "reasonix/internal/proc"
14 "reasonix/internal/secrets"
15 )
16
17 // psUTF8Prologue forces PowerShell to emit UTF-8 instead of the host's OEM code
18 // page (e.g. CP936 on a Chinese Windows), so non-ASCII command output and error
19 // text come back as valid UTF-8 rather than mojibake.
20 const psUTF8Prologue = "$OutputEncoding=[Console]::OutputEncoding=[System.Text.Encoding]::UTF8;"
21
22 // PowerShellUTF8Script prepares a PowerShell script for captured execution.
23 // Setting both encodings keeps PowerShell's own output and native child-process
24 // output UTF-8 across Windows console code pages.
25 func PowerShellUTF8Script(command string) string {
26 return psUTF8Prologue + command
27 }
28
29 // ShellKind is the interpreter a shell command runs under.
30 type ShellKind int
31
32 const (
33 ShellBash ShellKind = iota
34 ShellPowerShell
35 ShellZsh
36 ShellSh
37 )
38
39 func (k ShellKind) String() string {
40 names := [...]string{"bash", "powershell", "zsh", "sh"}
41 if int(k) >= 0 && int(k) < len(names) {
42 return names[k]
43 }
44 return "bash"
45 }
46
47 // IsPOSIX reports whether this interpreter accepts the POSIX-family command
48 // path used by the bash tool. zsh and sh are macOS fallbacks, not PowerShell.
49 func (k ShellKind) IsPOSIX() bool { return k != ShellPowerShell }
50
51 // Shell is the resolved interpreter the bash tool executes commands with: a kind
52 // (so callers can adapt prompts) and the executable to invoke.
53 type Shell struct {
54 Kind ShellKind
55 Path string
56 }
57
58 // ResolveShell picks the interpreter the shell tool runs commands under. With
59 // prefer "auto"/"" it favours Bash on POSIX and native PowerShell on Windows.
60 // prefer "bash" or
61 // "powershell"/"pwsh" forces that interpreter (path overrides the PATH lookup),
62 // warning to warn and falling back to auto-detection if the forced one is
63 // missing — so a typo or an uninstalled shell can never leave the tool broken.
64 // Discovery (candidate ordering, probing) is served by the process-wide shell
65 // inventory snapshot, so repeated calls share one probe pass for 30 seconds.
66 func ResolveShell(prefer, path string, warn io.Writer) Shell {
67 snap := defaultShellInventory.snapshot(runtime.GOOS, prefer, path)
68 return resolveShell(prefer, path, warn, snap.goos, snap.lookPath, snap.exists, snap.bashCands, snap.psCands, snap.probe, snap.isWSL)
69 }
70
71 // ResolveExplicitBash preserves the dialect of user-authored POSIX hooks.
72 // Agent interpreter policy must not reinterpret an explicit hook command.
73 func ResolveExplicitBash(path string) (Shell, bool) {
74 snap := defaultShellInventory.snapshot(runtime.GOOS, "bash", path)
75 return resolveExplicitBash(snap, path)
76 }
77
78 func resolveExplicitBash(snap *shellSnapshot, path string) (Shell, bool) {
79 path = configuredShellPath(snap.goos, ShellBash, path, snap.exists, snap.isWSL)
80 candidates := []string{path}
81 if found, err := snap.lookPath("bash"); err == nil {
82 candidates = append(candidates, found)
83 }
84 candidates = append(candidates, snap.bashCands...)
85 for _, candidate := range candidates {
86 if candidate != "" && !snap.isWSL(candidate) && snap.exists(candidate) && snap.probe(candidate) {
87 return Shell{Kind: ShellBash, Path: candidate}, true
88 }
89 }
90 return Shell{}, false
91 }
92
93 // resolveShell is ResolveShell with its environment lookups injected — including
94 // the Git-for-Windows bash candidates, which derive from %ProgramFiles% and so
95 // are empty off Windows — so the decision table is deterministically testable on
96 // any host.
97 func resolveShell(prefer, path string, warn io.Writer, goos string, lookPath func(string) (string, error), exists func(string) bool, winBashCandidates []string, winPowerShellCandidates []string, probe func(string) bool, isWSL func(string) bool) Shell {
98 findPOSIX := func(name string, kind ShellKind) (Shell, bool) {
99 if p, err := lookPath(name); err == nil && !isWSL(p) && probe(p) {
100 return Shell{Kind: kind, Path: p}, true
101 }
102 if goos != "windows" {
103 for _, p := range []string{"/bin/" + name, "/usr/bin/" + name} {
104 if exists(p) && probe(p) {
105 return Shell{Kind: kind, Path: p}, true
106 }
107 }
108 }
109 return Shell{}, false
110 }
111 findBash := func() (Shell, bool) {
112 if sh, ok := findPOSIX("bash", ShellBash); ok {
113 return sh, true
114 }
115 for _, p := range winBashCandidates {
116 if exists(p) && probe(p) {
117 return Shell{Kind: ShellBash, Path: p}, true
118 }
119 }
120 return Shell{}, false
121 }
122 findPowerShell := func(order []string) (Shell, bool) {
123 for _, name := range order {
124 for _, p := range winPowerShellCandidates {
125 base := strings.ToLower(pathBase(p))
126 if base != strings.ToLower(name) && strings.TrimSuffix(base, ".exe") != strings.ToLower(name) {
127 continue
128 }
129 if exists(p) {
130 return Shell{Kind: ShellPowerShell, Path: p}, true
131 }
132 }
133 if p, err := lookPath(name); err == nil {
134 return Shell{Kind: ShellPowerShell, Path: p}, true
135 }
136 }
137 return Shell{}, false
138 }
139 auto := func() Shell { return autoDetectedShell(goos, findBash, findPOSIX, findPowerShell) }
140 prefer = effectiveShellPreference(goos, prefer, warn)
141
142 switch strings.ToLower(strings.TrimSpace(prefer)) {
143 case "", "auto":
144 return autoShellWithConfiguredPath(goos, path, exists, probe, isWSL, auto)
145 case "bash":
146 path = configuredShellPath(goos, ShellBash, path, exists, isWSL)
147 if path != "" && exists(path) && probe(path) {
148 return Shell{Kind: ShellBash, Path: path}
149 }
150 if sh, ok := findBash(); ok {
151 return sh
152 }
153 warnMissingShell(warn, prefer)
154 return auto()
155 case "powershell", "pwsh":
156 path = configuredShellPath(goos, ShellPowerShell, path, exists, isWSL)
157 if path != "" && exists(path) {
158 return Shell{Kind: ShellPowerShell, Path: path}
159 }
160 order := []string{"pwsh", "powershell"}
161 if strings.EqualFold(strings.TrimSpace(prefer), "powershell") {
162 order = []string{"powershell", "pwsh"}
163 }
164 if sh, ok := findPowerShell(order); ok {
165 return sh
166 }
167 warnMissingShell(warn, prefer)
168 return auto()
169 default:
170 if warn != nil {
171 fmt.Fprintf(warn, "warning: [tools.shell] prefer=%q is not recognised (use auto/bash/powershell); using auto-detection\n", prefer)
172 }
173 return auto()
174 }
175 }
176
177 func effectiveShellPreference(goos, prefer string, warn io.Writer) string {
178 legacy := goos == "windows" && strings.EqualFold(strings.TrimSpace(prefer), "bash")
179 if legacy && warn != nil {
180 fmt.Fprintln(warn, "Windows Agent now uses native PowerShell; the saved Bash preference is retained for older versions.")
181 }
182 if legacy {
183 return "auto"
184 }
185 return prefer
186 }
187
188 // Auto accepts native PowerShell paths on Windows. A persisted Git Bash path
189 // cannot silently opt an auto-configured host back into the MSYS runtime.
190 func autoShellWithConfiguredPath(goos, path string, exists, probe, isWSL func(string) bool, fallback func() Shell) Shell {
191 if goos == "windows" {
192 base := strings.TrimSuffix(strings.ToLower(pathBase(strings.TrimSpace(path))), ".exe")
193 if base == "pwsh" || base == "powershell" {
194 configured := configuredShellPath(goos, ShellPowerShell, path, exists, isWSL)
195 if configured != "" && exists(configured) {
196 return Shell{Kind: ShellPowerShell, Path: configured}
197 }
198 }
199 }
200 return fallback()
201 }
202
203 func autoDetectedShell(goos string, findBash func() (Shell, bool), findPOSIX func(string, ShellKind) (Shell, bool), findPowerShell func([]string) (Shell, bool)) Shell {
204 if goos == "windows" {
205 if sh, ok := findPowerShell([]string{"pwsh", "powershell"}); ok {
206 return sh
207 }
208 // Keep the dialect native even when it is missing: launch preflight
209 // reports the missing dependency instead of silently selecting Bash.
210 return Shell{Kind: ShellPowerShell, Path: "pwsh"}
211 }
212 if sh, ok := findBash(); ok {
213 return sh
214 }
215 if goos == "darwin" {
216 for _, fallback := range []struct {
217 name string
218 kind ShellKind
219 }{{"zsh", ShellZsh}, {"sh", ShellSh}} {
220 if sh, ok := findPOSIX(fallback.name, fallback.kind); ok {
221 return sh
222 }
223 }
224 }
225 return Shell{Kind: ShellBash, Path: "bash"}
226 }
227
228 func warnMissingShell(warn io.Writer, prefer string) {
229 if warn != nil {
230 fmt.Fprintf(warn, "warning: [tools.shell] prefer=%q but that shell was not found; using auto-detection\n", prefer)
231 }
232 }
233
234 // isWindowsWSLBash reports whether a resolved bash path is the WSL launcher
235 // Windows ships under %SystemRoot% (e.g. C:\Windows\System32\bash.exe). With WSL
236 // installed it runs commands inside the Linux VM — where the Windows workspace is
237 // a /mnt/<drive> path — so it must never be chosen for a native Windows workspace;
238 // the only bash.exe Microsoft places under the Windows dir is that launcher.
239 func isWindowsWSLBash(path string) bool {
240 if runtime.GOOS != "windows" || path == "" {
241 return false
242 }
243 win := os.Getenv("SystemRoot")
244 if win == "" {
245 win = os.Getenv("windir")
246 }
247 if win == "" {
248 return false
249 }
250 p := strings.ToLower(filepath.Clean(path))
251 root := strings.ToLower(filepath.Clean(win)) + string(filepath.Separator)
252 return strings.HasPrefix(p, root)
253 }
254
255 // Windows ships a bash.exe launcher stub in %SystemRoot% that opens the WSL
256 // install prompt instead of running anything, so confirm bash actually works
257 // before trusting it. Timeout-bounded in case the stub blocks on that prompt.
258 func probeBash(path string) bool {
259 if runtime.GOOS != "windows" {
260 return true
261 }
262 ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
263 defer cancel()
264 cmd := proc.CommandContext(ctx, path, "-c", "true")
265 cmd.Env = secrets.ProcessEnv()
266 proc.HideWindow(cmd)
267 return cmd.Run() == nil
268 }
269
270 func fileExists(p string) bool {
271 fi, err := os.Stat(p)
272 return err == nil && !fi.IsDir()
273 }
274
275 func pathBase(p string) string {
276 if i := strings.LastIndexAny(p, `/\\`); i >= 0 {
277 return p[i+1:]
278 }
279 return p
280 }
281
282 func pathDir(p string) string {
283 if i := strings.LastIndexAny(p, `/\`); i >= 0 {
284 return p[:i]
285 }
286 return "."
287 }
288
289 // ConfiguredShellPathForPreference returns a configured executable only when
290 // it is compatible with the forced interpreter. The path remains persisted
291 // even when rejected here, so changing preferences never destroys the user's
292 // custom setting while runtime consumers avoid launching it with the wrong
293 // argv contract.
294 func ConfiguredShellPathForPreference(prefer, path string) string {
295 var kind ShellKind
296 switch strings.ToLower(strings.TrimSpace(prefer)) {
297 case "bash":
298 kind = ShellBash
299 case "powershell", "pwsh":
300 kind = ShellPowerShell
301 default:
302 return ""
303 }
304 return configuredShellPath(runtime.GOOS, kind, path, fileExists, isWindowsWSLBash)
305 }
306
307 // configuredShellPath is the shared safety boundary for every consumer of
308 // [tools.shell].path. Known cross-kind executables are ignored instead of being
309 // relabeled, while unknown names remain available for intentional wrappers.
310 func configuredShellPath(goos string, kind ShellKind, path string, exists func(string) bool, isWSL func(string) bool) string {
311 path = strings.TrimSpace(path)
312 if path == "" {
313 return ""
314 }
315 if kind == ShellBash && goos == "windows" {
316 path = sanitizeWindowsBashPath(path, exists)
317 if isWSL != nil && isWSL(path) {
318 return ""
319 }
320 }
321 base := strings.TrimSuffix(strings.ToLower(pathBase(path)), ".exe")
322 switch kind {
323 case ShellBash:
324 if base == "git-bash" || base == "powershell" || base == "pwsh" || base == "zsh" || base == "sh" {
325 return ""
326 }
327 case ShellPowerShell:
328 if base == "bash" || base == "git-bash" || base == "zsh" || base == "sh" {
329 return ""
330 }
331 }
332 return path
333 }
334
335 func sanitizeWindowsBashPath(path string, exists func(string) bool) string {
336 if path == "" {
337 return path
338 }
339 base := strings.ToLower(pathBase(path))
340 if base == "git-bash.exe" || base == "git-bash" {
341 dir := pathDir(path)
342 sep := "/"
343 if strings.Contains(path, `\`) {
344 sep = `\`
345 }
346 parent := pathDir(dir)
347 for _, sub := range []string{
348 dir + sep + "bin" + sep + "bash.exe",
349 dir + sep + "usr" + sep + "bin" + sep + "bash.exe",
350 parent + sep + "bin" + sep + "bash.exe",
351 parent + sep + "usr" + sep + "bin" + sep + "bash.exe",
352 } {
353 if exists(sub) {
354 return sub
355 }
356 }
357 }
358 return path
359 }
360
361 // windowsPowerShellCandidates lists common PowerShell executables that are not
362 // always present on PATH, especially PowerShell 7's default MSI install path.
363 func windowsPowerShellCandidates() []string {
364 var roots []string
365 for _, env := range []string{"ProgramFiles", "ProgramW6432", "ProgramFiles(x86)"} {
366 if v := os.Getenv(env); v != "" {
367 roots = append(roots, v)
368 }
369 }
370 var out []string
371 for _, r := range roots {
372 out = append(out, filepath.Join(r, "PowerShell", "7", "pwsh.exe"))
373 }
374 if v := os.Getenv("SystemRoot"); v != "" {
375 out = append(out, filepath.Join(v, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"))
376 } else if v := os.Getenv("windir"); v != "" {
377 out = append(out, filepath.Join(v, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"))
378 }
379 return out
380 }
381
382 // normalizeNullRedirects rewrites null-device redirect aliases to sink
383 // ("/dev/null" for bash, "$null" for PowerShell), so permission-approved
384 // null-sink commands discard output under the resolved shell. It handles
385 // cmd.exe-style `nul`, PowerShell `$null`, and POSIX `/dev/null` while avoiding
386 // quoted/escaped text.
387 func normalizeNullRedirects(command, sink string) string {
388 var (
389 out strings.Builder
390 quote byte
391 )
392 write := func(c byte) {
393 out.WriteByte(c)
394 }
395 for i := 0; i < len(command); {
396 c := command[i]
397 if quote != 0 {
398 write(c)
399 i++
400 if c == '\\' && quote == '"' && i < len(command) {
401 write(command[i])
402 i++
403 continue
404 }
405 if c == '`' && i < len(command) {
406 write(command[i])
407 i++
408 continue
409 }
410 if c == quote {
411 quote = 0
412 }
413 continue
414 }
415 switch c {
416 case '\'', '"':
417 quote = c
418 write(c)
419 i++
420 case '\\', '`':
421 write(c)
422 i++
423 if i < len(command) {
424 write(command[i])
425 i++
426 }
427 default:
428 if replacement, next, ok := consumeNullRedirect(command, i, sink); ok {
429 out.WriteString(replacement)
430 i = next
431 continue
432 }
433 write(c)
434 i++
435 }
436 }
437 return out.String()
438 }
439
440 func consumeNullRedirect(s string, start int, sink string) (string, int, bool) {
441 i := start
442 if i >= len(s) {
443 return "", start, false
444 }
445 if s[i] == '&' {
446 i++
447 if i < len(s) && s[i] == '>' {
448 i++
449 if i < len(s) && s[i] == '>' {
450 i++
451 }
452 } else {
453 return "", start, false
454 }
455 } else {
456 for i < len(s) && s[i] >= '0' && s[i] <= '9' {
457 i++
458 }
459 if i >= len(s) || s[i] != '>' {
460 return "", start, false
461 }
462 i++
463 if i < len(s) && s[i] == '>' {
464 i++
465 }
466 }
467 opEnd := i
468 for i < len(s) && (s[i] == ' ' || s[i] == '\t') {
469 i++
470 }
471 next, ok := consumeNullSink(s, i)
472 if !ok {
473 return "", start, false
474 }
475 return s[start:opEnd] + sink, next, true
476 }
477
478 func consumeNullSink(s string, i int) (int, bool) {
479 for _, sink := range []string{"/dev/null", "$null", "nul"} {
480 if i+len(sink) > len(s) {
481 continue
482 }
483 got := s[i : i+len(sink)]
484 if sink == "/dev/null" {
485 if got != sink {
486 continue
487 }
488 } else if !strings.EqualFold(got, sink) {
489 continue
490 }
491 next := i + len(sink)
492 if next < len(s) && !isNullRedirectWordEnd(s[next]) {
493 return next, false
494 }
495 return next, true
496 }
497 return i, false
498 }
499
500 func isNullRedirectWordEnd(c byte) bool {
501 return c == ' ' || c == '\t' || c == '\n' || c == '\r' || strings.ContainsRune(";&|<>)]", rune(c))
502 }
503
504 // argv builds the exec argv that runs command under this shell.
505 func (s Shell) argv(command string) []string {
506 path := s.Path
507 if path == "" {
508 path = s.Kind.String()
509 }
510 if s.Kind == ShellPowerShell {
511 return []string{path, "-NoProfile", "-NonInteractive", "-Command", PowerShellUTF8Script(normalizeNullRedirects(command, "$null"))}
512 }
513 return []string{path, "-c", normalizeNullRedirects(command, "/dev/null")}
514 }
515
516 // SupportsChaining reports whether the shell parses '&&' / '||'. bash does;
517 // Windows PowerShell 5.1 (powershell.exe) does not — only PowerShell 7+ (pwsh).
518 func (s Shell) SupportsChaining() bool {
519 if s.Kind != ShellPowerShell {
520 return true
521 }
522 base := strings.ToLower(pathBase(s.Path))
523 return base == "pwsh" || base == "pwsh.exe"
524 }
525
525 lines GO