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