返回 DeepSeek-Reasonix
shell.go
根目录 / internal / sandbox / shell.go
1 package sandbox
2
3 import (
4 "context"
5 "fmt"
6 "io"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "runtime"
11 "strings"
12 "time"
13
14 "reasonix/internal/proc"
15 "reasonix/internal/secrets"
16 )
17
18 // psUTF8Prologue forces PowerShell to emit UTF-8 instead of the host's OEM code
19 // page (e.g. CP936 on a Chinese Windows), so non-ASCII command output and error
20 // text come back as valid UTF-8 rather than mojibake.
21 const psUTF8Prologue = "$OutputEncoding=[Console]::OutputEncoding=[System.Text.Encoding]::UTF8;"
22
23 // PowerShellUTF8Script prepares a PowerShell script for captured execution.
24 // Setting both encodings keeps PowerShell's own output and native child-process
25 // output UTF-8 across Windows console code pages.
26 func PowerShellUTF8Script(command string) string {
27 return psUTF8Prologue + command
28 }
29
30 // ShellKind is the interpreter a shell command runs under.
31 type ShellKind int
32
33 const (
34 ShellBash ShellKind = iota
35 ShellPowerShell
36 )
37
38 func (k ShellKind) String() string {
39 if k == ShellPowerShell {
40 return "powershell"
41 }
42 return "bash"
43 }
44
45 // Shell is the resolved interpreter the bash tool executes commands with: a kind
46 // (so callers can adapt prompts) and the executable to invoke.
47 type Shell struct {
48 Kind ShellKind
49 Path string
50 }
51
52 // ResolveShell picks the interpreter the shell tool runs commands under. With
53 // prefer "auto"/"" it favours a real bash so the model's POSIX habits work and
54 // only falls back to PowerShell on Windows when bash is absent. prefer "bash" or
55 // "powershell"/"pwsh" forces that interpreter (path overrides the PATH lookup),
56 // warning to warn and falling back to auto-detection if the forced one is
57 // missing — so a typo or an uninstalled shell can never leave the tool broken.
58 func ResolveShell(prefer, path string, warn io.Writer) Shell {
59 return resolveShell(prefer, path, warn, runtime.GOOS, exec.LookPath, fileExists, windowsBashCandidates(), windowsPowerShellCandidates(), probeBash, isWindowsWSLBash)
60 }
61
62 // resolveShell is ResolveShell with its environment lookups injected — including
63 // the Git-for-Windows bash candidates, which derive from %ProgramFiles% and so
64 // are empty off Windows — so the decision table is deterministically testable on
65 // any host.
66 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 {
67 findBash := func() (Shell, bool) {
68 if p, err := lookPath("bash"); err == nil && !isWSL(p) && probe(p) {
69 return Shell{Kind: ShellBash, Path: p}, true
70 }
71 for _, p := range winBashCandidates {
72 if exists(p) && probe(p) {
73 return Shell{Kind: ShellBash, Path: p}, true
74 }
75 }
76 return Shell{}, false
77 }
78 findPowerShell := func(order []string) (Shell, bool) {
79 for _, name := range order {
80 for _, p := range winPowerShellCandidates {
81 base := strings.ToLower(pathBase(p))
82 if base != strings.ToLower(name) && strings.TrimSuffix(base, ".exe") != strings.ToLower(name) {
83 continue
84 }
85 if exists(p) {
86 return Shell{Kind: ShellPowerShell, Path: p}, true
87 }
88 }
89 if p, err := lookPath(name); err == nil {
90 return Shell{Kind: ShellPowerShell, Path: p}, true
91 }
92 }
93 return Shell{}, false
94 }
95 auto := func() Shell {
96 if sh, ok := findBash(); ok {
97 return sh
98 }
99 if goos == "windows" {
100 if sh, ok := findPowerShell([]string{"pwsh", "powershell"}); ok {
101 return sh
102 }
103 }
104 return Shell{Kind: ShellBash, Path: "bash"}
105 }
106
107 switch strings.ToLower(strings.TrimSpace(prefer)) {
108 case "", "auto":
109 return auto()
110 case "bash":
111 if path != "" && exists(path) && probe(path) {
112 return Shell{Kind: ShellBash, Path: path}
113 }
114 if sh, ok := findBash(); ok {
115 return sh
116 }
117 warnMissingShell(warn, prefer)
118 return auto()
119 case "powershell", "pwsh":
120 if path != "" && exists(path) {
121 return Shell{Kind: ShellPowerShell, Path: path}
122 }
123 order := []string{"pwsh", "powershell"}
124 if strings.EqualFold(strings.TrimSpace(prefer), "powershell") {
125 order = []string{"powershell", "pwsh"}
126 }
127 if sh, ok := findPowerShell(order); ok {
128 return sh
129 }
130 warnMissingShell(warn, prefer)
131 return auto()
132 default:
133 if warn != nil {
134 fmt.Fprintf(warn, "warning: [tools.shell] prefer=%q is not recognised (use auto/bash/powershell); using auto-detection\n", prefer)
135 }
136 return auto()
137 }
138 }
139
140 func warnMissingShell(warn io.Writer, prefer string) {
141 if warn != nil {
142 fmt.Fprintf(warn, "warning: [tools.shell] prefer=%q but that shell was not found; using auto-detection\n", prefer)
143 }
144 }
145
146 // isWindowsWSLBash reports whether a resolved bash path is the WSL launcher
147 // Windows ships under %SystemRoot% (e.g. C:\Windows\System32\bash.exe). With WSL
148 // installed it runs commands inside the Linux VM — where the Windows workspace is
149 // a /mnt/<drive> path — so it must never be chosen for a native Windows workspace;
150 // the only bash.exe Microsoft places under the Windows dir is that launcher.
151 func isWindowsWSLBash(path string) bool {
152 if runtime.GOOS != "windows" || path == "" {
153 return false
154 }
155 win := os.Getenv("SystemRoot")
156 if win == "" {
157 win = os.Getenv("windir")
158 }
159 if win == "" {
160 return false
161 }
162 p := strings.ToLower(filepath.Clean(path))
163 root := strings.ToLower(filepath.Clean(win)) + string(filepath.Separator)
164 return strings.HasPrefix(p, root)
165 }
166
167 // Windows ships a bash.exe launcher stub in %SystemRoot% that opens the WSL
168 // install prompt instead of running anything, so confirm bash actually works
169 // before trusting it. Timeout-bounded in case the stub blocks on that prompt.
170 func probeBash(path string) bool {
171 if runtime.GOOS != "windows" {
172 return true
173 }
174 ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
175 defer cancel()
176 cmd := exec.CommandContext(ctx, path, "-c", "true")
177 cmd.Env = secrets.ProcessEnv()
178 proc.HideWindow(cmd)
179 return cmd.Run() == nil
180 }
181
182 func fileExists(p string) bool {
183 fi, err := os.Stat(p)
184 return err == nil && !fi.IsDir()
185 }
186
187 func pathBase(p string) string {
188 if i := strings.LastIndexAny(p, `/\\`); i >= 0 {
189 return p[i+1:]
190 }
191 return p
192 }
193
194 // windowsBashCandidates lists the bash.exe paths a Git-for-Windows install
195 // ships, across the usual program-files roots and a per-user install.
196 func windowsBashCandidates() []string {
197 var roots []string
198 for _, env := range []string{"ProgramFiles", "ProgramW6432", "ProgramFiles(x86)"} {
199 if v := os.Getenv(env); v != "" {
200 roots = append(roots, v)
201 }
202 }
203 if v := os.Getenv("LOCALAPPDATA"); v != "" {
204 roots = append(roots, filepath.Join(v, "Programs"))
205 }
206 var out []string
207 for _, r := range roots {
208 out = append(out,
209 filepath.Join(r, "Git", "bin", "bash.exe"),
210 filepath.Join(r, "Git", "usr", "bin", "bash.exe"),
211 )
212 }
213 return out
214 }
215
216 // windowsPowerShellCandidates lists common PowerShell executables that are not
217 // always present on PATH, especially PowerShell 7's default MSI install path.
218 func windowsPowerShellCandidates() []string {
219 var roots []string
220 for _, env := range []string{"ProgramFiles", "ProgramW6432", "ProgramFiles(x86)"} {
221 if v := os.Getenv(env); v != "" {
222 roots = append(roots, v)
223 }
224 }
225 var out []string
226 for _, r := range roots {
227 out = append(out, filepath.Join(r, "PowerShell", "7", "pwsh.exe"))
228 }
229 if v := os.Getenv("SystemRoot"); v != "" {
230 out = append(out, filepath.Join(v, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"))
231 } else if v := os.Getenv("windir"); v != "" {
232 out = append(out, filepath.Join(v, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"))
233 }
234 return out
235 }
236
237 // normalizeNullRedirects rewrites null-device redirect aliases to sink
238 // ("/dev/null" for bash, "$null" for PowerShell), so permission-approved
239 // null-sink commands discard output under the resolved shell. It handles
240 // cmd.exe-style `nul`, PowerShell `$null`, and POSIX `/dev/null` while avoiding
241 // quoted/escaped text.
242 func normalizeNullRedirects(command, sink string) string {
243 var (
244 out strings.Builder
245 quote byte
246 )
247 write := func(c byte) {
248 out.WriteByte(c)
249 }
250 for i := 0; i < len(command); {
251 c := command[i]
252 if quote != 0 {
253 write(c)
254 i++
255 if c == '\\' && quote == '"' && i < len(command) {
256 write(command[i])
257 i++
258 continue
259 }
260 if c == '`' && i < len(command) {
261 write(command[i])
262 i++
263 continue
264 }
265 if c == quote {
266 quote = 0
267 }
268 continue
269 }
270 switch c {
271 case '\'', '"':
272 quote = c
273 write(c)
274 i++
275 case '\\', '`':
276 write(c)
277 i++
278 if i < len(command) {
279 write(command[i])
280 i++
281 }
282 default:
283 if replacement, next, ok := consumeNullRedirect(command, i, sink); ok {
284 out.WriteString(replacement)
285 i = next
286 continue
287 }
288 write(c)
289 i++
290 }
291 }
292 return out.String()
293 }
294
295 func consumeNullRedirect(s string, start int, sink string) (string, int, bool) {
296 i := start
297 if i >= len(s) {
298 return "", start, false
299 }
300 if s[i] == '&' {
301 i++
302 if i < len(s) && s[i] == '>' {
303 i++
304 if i < len(s) && s[i] == '>' {
305 i++
306 }
307 } else {
308 return "", start, false
309 }
310 } else {
311 for i < len(s) && s[i] >= '0' && s[i] <= '9' {
312 i++
313 }
314 if i >= len(s) || s[i] != '>' {
315 return "", start, false
316 }
317 i++
318 if i < len(s) && s[i] == '>' {
319 i++
320 }
321 }
322 opEnd := i
323 for i < len(s) && (s[i] == ' ' || s[i] == '\t') {
324 i++
325 }
326 next, ok := consumeNullSink(s, i)
327 if !ok {
328 return "", start, false
329 }
330 return s[start:opEnd] + sink, next, true
331 }
332
333 func consumeNullSink(s string, i int) (int, bool) {
334 for _, sink := range []string{"/dev/null", "$null", "nul"} {
335 if i+len(sink) > len(s) {
336 continue
337 }
338 got := s[i : i+len(sink)]
339 if sink == "/dev/null" {
340 if got != sink {
341 continue
342 }
343 } else if !strings.EqualFold(got, sink) {
344 continue
345 }
346 next := i + len(sink)
347 if next < len(s) && !isNullRedirectWordEnd(s[next]) {
348 return next, false
349 }
350 return next, true
351 }
352 return i, false
353 }
354
355 func isNullRedirectWordEnd(c byte) bool {
356 return c == ' ' || c == '\t' || c == '\n' || c == '\r' || strings.ContainsRune(";&|<>)]", rune(c))
357 }
358
359 // argv builds the exec argv that runs command under this shell.
360 func (s Shell) argv(command string) []string {
361 path := s.Path
362 if path == "" {
363 path = s.Kind.String()
364 }
365 if s.Kind == ShellPowerShell {
366 return []string{path, "-NoProfile", "-NonInteractive", "-Command", PowerShellUTF8Script(normalizeNullRedirects(command, "$null"))}
367 }
368 return []string{path, "-c", normalizeNullRedirects(command, "/dev/null")}
369 }
370
371 // SupportsChaining reports whether the shell parses '&&' / '||'. bash does;
372 // Windows PowerShell 5.1 (powershell.exe) does not — only PowerShell 7+ (pwsh).
373 func (s Shell) SupportsChaining() bool {
374 if s.Kind != ShellPowerShell {
375 return true
376 }
377 base := strings.ToLower(pathBase(s.Path))
378 return base == "pwsh" || base == "pwsh.exe"
379 }
380
380 lines GO