on main help sign in
fix(windows): use `mvdan/sh` + general fixes
commit436aa017e45dd6f8eaabecc222b1ad40e9f18994 authorAndrey Nering <andreynering@users.noreply.github.com> dateFri 11 Jul 2025, 15:15 (1 year ago) parente185025
6 files changed+134−415
internal/llm/prompt/prompt_test.go+13−1 internal/llm/tools/bash.go+8−52 internal/llm/tools/bash_test.go−96 internal/llm/tools/safe.go+88 internal/shell/shell.go+4−141 internal/shell/shell_test.go+21−125
internal/llm/prompt/prompt_test.go+13−1
@@ -3,6 +3,7 @@ package prompt
33import (
44 "os"
55 "path/filepath"
6+ "runtime"
67 "strings"
78 "testing"
89)
@@ -96,7 +97,8 @@ func TestProcessContextPaths(t *testing.T) {
9697
9798 // Test with tilde expansion (if we can create a file in home directory)
9899 tmpDir = t.TempDir()
99− t.Setenv("HOME", tmpDir)
100+ rollback := setHomeEnv(tmpDir)
101+ defer rollback()
100102 homeTestFile := filepath.Join(tmpDir, "crush_test_file.txt")
101103 err = os.WriteFile(homeTestFile, []byte(testContent), 0o644)
102104 if err == nil {
@@ -111,3 +113,13 @@ func TestProcessContextPaths(t *testing.T) {
111113 }
112114 }
113115}
116+
117+func setHomeEnv(path string) (rollback func()) {
118+ key := "HOME"
119+ if runtime.GOOS == "windows" {
120+ key = "USERPROFILE"
121+ }
122+ original := os.Getenv(key)
123+ os.Setenv(key, path)
124+ return func() { os.Setenv(key, original) }
125+}
internal/llm/tools/bash.go+8−52
@@ -5,7 +5,6 @@ import (
55 "encoding/json"
66 "fmt"
77 "log/slog"
8− "runtime"
98 "strings"
109 "time"
1110
@@ -112,58 +111,17 @@ var bannedCommands = []string{
112111 "ufw",
113112}
114113
115−// getSafeReadOnlyCommands returns platform-appropriate safe commands
116−func getSafeReadOnlyCommands() []string {
117− // Base commands that work on all platforms
118− baseCommands := []string{
119− // Cross-platform commands
120− "echo", "hostname", "whoami",
121−
122− // Git commands (cross-platform)
123− "git status", "git log", "git diff", "git show", "git branch", "git tag", "git remote", "git ls-files", "git ls-remote",
124− "git rev-parse", "git config --get", "git config --list", "git describe", "git blame", "git grep", "git shortlog",
125−
126− // Go commands (cross-platform)
127− "go version", "go help", "go list", "go env", "go doc", "go vet", "go fmt", "go mod", "go test", "go build", "go run", "go install", "go clean",
128− }
129−
130− if runtime.GOOS == "windows" {
131− // Windows-specific commands
132− windowsCommands := []string{
133− "dir", "type", "where", "ver", "systeminfo", "tasklist", "ipconfig", "ping", "nslookup",
134− "Get-Process", "Get-Location", "Get-ChildItem", "Get-Content", "Get-Date", "Get-Host", "Get-ComputerInfo",
135− }
136− return append(baseCommands, windowsCommands...)
137− } else {
138− // Unix/Linux commands (including WSL, since WSL reports as Linux)
139− unixCommands := []string{
140− "ls", "pwd", "date", "cal", "uptime", "id", "groups", "env", "printenv", "set", "unset", "which", "type", "whereis",
141− "whatis", "uname", "df", "du", "free", "top", "ps", "kill", "killall", "nice", "nohup", "time", "timeout",
142− }
143− return append(baseCommands, unixCommands...)
144− }
145−}
146−
147114func bashDescription() string {
148115 bannedCommandsStr := strings.Join(bannedCommands, ", ")
149116 return fmt.Sprintf(`Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.
150117
151118CROSS-PLATFORM SHELL SUPPORT:
152−- Unix/Linux/macOS: Uses native bash/sh shell
153−- Windows: Intelligent shell selection:
154− * Windows commands (dir, type, copy, etc.) use cmd.exe
155− * PowerShell commands (Get-, Set-, etc.) use PowerShell
156− * Unix-style commands (ls, cat, etc.) use POSIX emulation
157−- WSL: Automatically treated as Linux (which is correct)
158−- Automatic detection: Chooses the best shell based on command and platform
159−- Persistent state: Working directory and environment variables persist between commands
160−
161−WINDOWS-SPECIFIC FEATURES:
162−- Native Windows commands: dir, type, copy, move, del, md, rd, cls, where, tasklist, etc.
163−- PowerShell support: Get-Process, Set-Location, and other PowerShell cmdlets
164−- Windows path handling: Supports both forward slashes (/) and backslashes (\)
165−- Drive letters: Properly handles C:\, D:\, etc.
166−- Environment variables: Supports both Unix ($VAR) and Windows (%%VAR%%) syntax
119+* This tool uses a shell interpreter (mvdan/sh) that mimics the Bash language,
120+ so you should use Bash syntax even on all platforms, even on Windows.
121+ The most common shell builtins and core utils are available even on Windows as
122+ well.
123+* Make sure to use forward slashes (/) as path separators in commands, even on
124+ Windows. Example: "ls C:/foo/bar" instead of "ls C:\foo\bar".
167125
168126Before executing the command, please follow these steps:
169127
@@ -393,10 +351,8 @@ func (b *bashTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
393351 isSafeReadOnly := false
394352 cmdLower := strings.ToLower(params.Command)
395353
396− // Get platform-appropriate safe commands
397− safeReadOnlyCommands := getSafeReadOnlyCommands()
398− for _, safe := range safeReadOnlyCommands {
399− if strings.HasPrefix(cmdLower, strings.ToLower(safe)) {
354+ for _, safe := range safeCommands {
355+ if strings.HasPrefix(cmdLower, safe) {
400356 if len(cmdLower) == len(safe) || cmdLower[len(safe)] == ' ' || cmdLower[len(safe)] == '-' {
401357 isSafeReadOnly = true
402358 break
internal/llm/tools/bash_test.godeleted−96
@@ -1,96 +0,0 @@
1−package tools
2−
3−import (
4− "runtime"
5− "slices"
6− "testing"
7−)
8−
9−func TestGetSafeReadOnlyCommands(t *testing.T) {
10− commands := getSafeReadOnlyCommands()
11−
12− // Check that we have some commands
13− if len(commands) == 0 {
14− t.Fatal("Expected some safe commands, got none")
15− }
16−
17− // Check for cross-platform commands that should always be present
18− crossPlatformCommands := []string{"echo", "hostname", "whoami", "git status", "go version"}
19− for _, cmd := range crossPlatformCommands {
20− found := slices.Contains(commands, cmd)
21− if !found {
22− t.Errorf("Expected cross-platform command %q to be in safe commands", cmd)
23− }
24− }
25−
26− if runtime.GOOS == "windows" {
27− // Check for Windows-specific commands
28− windowsCommands := []string{"dir", "type", "Get-Process"}
29− for _, cmd := range windowsCommands {
30− found := slices.Contains(commands, cmd)
31− if !found {
32− t.Errorf("Expected Windows command %q to be in safe commands on Windows", cmd)
33− }
34− }
35−
36− // Check that Unix commands are NOT present on Windows
37− unixCommands := []string{"ls", "pwd", "ps"}
38− for _, cmd := range unixCommands {
39− found := slices.Contains(commands, cmd)
40− if found {
41− t.Errorf("Unix command %q should not be in safe commands on Windows", cmd)
42− }
43− }
44− } else {
45− // Check for Unix-specific commands
46− unixCommands := []string{"ls", "pwd", "ps"}
47− for _, cmd := range unixCommands {
48− found := slices.Contains(commands, cmd)
49− if !found {
50− t.Errorf("Expected Unix command %q to be in safe commands on Unix", cmd)
51− }
52− }
53−
54− // Check that Windows-specific commands are NOT present on Unix
55− windowsOnlyCommands := []string{"dir", "Get-Process", "systeminfo"}
56− for _, cmd := range windowsOnlyCommands {
57− found := slices.Contains(commands, cmd)
58− if found {
59− t.Errorf("Windows-only command %q should not be in safe commands on Unix", cmd)
60− }
61− }
62− }
63−}
64−
65−func TestPlatformSpecificSafeCommands(t *testing.T) {
66− // Test that the function returns different results on different platforms
67− commands := getSafeReadOnlyCommands()
68−
69− hasWindowsCommands := false
70− hasUnixCommands := false
71−
72− for _, cmd := range commands {
73− if cmd == "dir" || cmd == "Get-Process" || cmd == "systeminfo" {
74− hasWindowsCommands = true
75− }
76− if cmd == "ls" || cmd == "ps" || cmd == "df" {
77− hasUnixCommands = true
78− }
79− }
80−
81− if runtime.GOOS == "windows" {
82− if !hasWindowsCommands {
83− t.Error("Expected Windows commands on Windows platform")
84− }
85− if hasUnixCommands {
86− t.Error("Did not expect Unix commands on Windows platform")
87− }
88− } else {
89− if hasWindowsCommands {
90− t.Error("Did not expect Windows-only commands on Unix platform")
91− }
92− if !hasUnixCommands {
93− t.Error("Expected Unix commands on Unix platform")
94− }
95− }
96−}
internal/llm/tools/safe.gonew file+88
@@ -0,0 +1,88 @@
1+package tools
2+
3+import "runtime"
4+
5+var safeCommands = []string{
6+ // Bash builtins and core utils
7+ "cal",
8+ "date",
9+ "df",
10+ "du",
11+ "echo",
12+ "env",
13+ "free",
14+ "groups",
15+ "hostname",
16+ "id",
17+ "kill",
18+ "killall",
19+ "ls",
20+ "nice",
21+ "nohup",
22+ "printenv",
23+ "ps",
24+ "pwd",
25+ "set",
26+ "time",
27+ "timeout",
28+ "top",
29+ "type",
30+ "uname",
31+ "unset",
32+ "uptime",
33+ "whatis",
34+ "whereis",
35+ "which",
36+ "whoami",
37+
38+ // Git
39+ "git blame",
40+ "git branch",
41+ "git config --get",
42+ "git config --list",
43+ "git describe",
44+ "git diff",
45+ "git grep",
46+ "git log",
47+ "git ls-files",
48+ "git ls-remote",
49+ "git remote",
50+ "git rev-parse",
51+ "git shortlog",
52+ "git show",
53+ "git status",
54+ "git tag",
55+
56+ // Go
57+ "go build",
58+ "go clean",
59+ "go doc",
60+ "go env",
61+ "go fmt",
62+ "go help",
63+ "go install",
64+ "go list",
65+ "go mod",
66+ "go run",
67+ "go test",
68+ "go version",
69+ "go vet",
70+}
71+
72+func init() {
73+ if runtime.GOOS == "windows" {
74+ safeCommands = append(
75+ safeCommands,
76+ // Windows-specific commands
77+ "dir",
78+ "ipconfig",
79+ "nslookup",
80+ "ping",
81+ "systeminfo",
82+ "tasklist",
83+ "type",
84+ "ver",
85+ "where",
86+ )
87+ }
88+}
internal/shell/shell.go+4−141
@@ -5,8 +5,9 @@
55// - PersistentShell: A singleton shell that maintains state across the application
66//
77// WINDOWS COMPATIBILITY:
8−// This implementation provides both POSIX shell emulation (mvdan.cc/sh/v3) and
9−// native Windows shell support (cmd.exe/PowerShell) for optimal compatibility.
8+// This implementation provides both POSIX shell emulation (mvdan.cc/sh/v3),
9+// even on Windows. Some caution has to be taken: commands should have forward
10+// slashes (/) as path separators to work, even on Windows.
1011package shell
1112
1213import (
@@ -15,8 +16,6 @@ import (
1516 "errors"
1617 "fmt"
1718 "os"
18− "os/exec"
19− "runtime"
2019 "strings"
2120 "sync"
2221
@@ -98,17 +97,7 @@ func (s *Shell) Exec(ctx context.Context, command string) (string, string, error
9897 s.mu.Lock()
9998 defer s.mu.Unlock()
10099
101− // Determine which shell to use based on platform and command
102− shellType := s.determineShellType(command)
103−
104− switch shellType {
105− case ShellTypeCmd:
106− return s.execWindows(ctx, command, "cmd")
107− case ShellTypePowerShell:
108− return s.execWindows(ctx, command, "powershell")
109− default:
110− return s.execPOSIX(ctx, command)
111− }
100+ return s.execPOSIX(ctx, command)
112101}
113102
114103// GetWorkingDir returns the current working directory
@@ -165,57 +154,6 @@ func (s *Shell) SetBlockFuncs(blockFuncs []BlockFunc) {
165154 s.blockFuncs = blockFuncs
166155}
167156
168−// Windows-specific commands that should use native shell
169−var windowsNativeCommands = map[string]bool{
170− "dir": true,
171− "type": true,
172− "copy": true,
173− "move": true,
174− "del": true,
175− "md": true,
176− "mkdir": true,
177− "rd": true,
178− "rmdir": true,
179− "cls": true,
180− "where": true,
181− "tasklist": true,
182− "taskkill": true,
183− "net": true,
184− "sc": true,
185− "reg": true,
186− "wmic": true,
187−}
188−
189−// determineShellType decides which shell to use based on platform and command
190−func (s *Shell) determineShellType(command string) ShellType {
191− if runtime.GOOS != "windows" {
192− return ShellTypePOSIX
193− }
194−
195− // Extract the first command from the command line
196− parts := strings.Fields(command)
197− if len(parts) == 0 {
198− return ShellTypePOSIX
199− }
200−
201− firstCmd := strings.ToLower(parts[0])
202−
203− // Check if it's a Windows-specific command
204− if windowsNativeCommands[firstCmd] {
205− return ShellTypeCmd
206− }
207−
208− // Check for PowerShell-specific syntax
209− if strings.Contains(command, "Get-") || strings.Contains(command, "Set-") ||
210− strings.Contains(command, "New-") || strings.Contains(command, "$_") ||
211− strings.Contains(command, "| Where-Object") || strings.Contains(command, "| ForEach-Object") {
212− return ShellTypePowerShell
213− }
214−
215− // Default to POSIX emulation for cross-platform compatibility
216− return ShellTypePOSIX
217−}
218−
219157// CommandsBlocker creates a BlockFunc that blocks exact command matches
220158func CommandsBlocker(bannedCommands []string) BlockFunc {
221159 bannedSet := make(map[string]bool)
@@ -270,81 +208,6 @@ func (s *Shell) blockHandler() func(next interp.ExecHandlerFunc) interp.ExecHand
270208 }
271209}
272210
273−// execWindows executes commands using native Windows shells (cmd.exe or PowerShell)
274−func (s *Shell) execWindows(ctx context.Context, command string, shell string) (string, string, error) {
275− var cmd *exec.Cmd
276−
277− // Handle directory changes specially to maintain persistent shell behavior
278− if strings.HasPrefix(strings.TrimSpace(command), "cd ") {
279− return s.handleWindowsCD(command)
280− }
281−
282− switch shell {
283− case "cmd":
284− // Use cmd.exe for Windows commands
285− // Add current directory context to maintain state
286− fullCommand := fmt.Sprintf("cd /d \"%s\" && %s", s.cwd, command)
287− cmd = exec.CommandContext(ctx, "cmd", "/C", fullCommand)
288− case "powershell":
289− // Use PowerShell for PowerShell commands
290− // Add current directory context to maintain state
291− fullCommand := fmt.Sprintf("Set-Location '%s'; %s", s.cwd, command)
292− cmd = exec.CommandContext(ctx, "powershell", "-Command", fullCommand)
293− default:
294− return "", "", fmt.Errorf("unsupported Windows shell: %s", shell)
295− }
296−
297− // Set environment variables
298− cmd.Env = s.env
299−
300− var stdout, stderr bytes.Buffer
301− cmd.Stdout = &stdout
302− cmd.Stderr = &stderr
303−
304− err := cmd.Run()
305−
306− s.logger.InfoPersist("Windows command finished", "shell", shell, "command", command, "err", err)
307− return stdout.String(), stderr.String(), err
308−}
309−
310−// handleWindowsCD handles directory changes for Windows shells
311−func (s *Shell) handleWindowsCD(command string) (string, string, error) {
312− // Extract the target directory from the cd command
313− parts := strings.Fields(command)
314− if len(parts) < 2 {
315− return "", "cd: missing directory argument", fmt.Errorf("missing directory argument")
316− }
317−
318− targetDir := parts[1]
319−
320− // Handle relative paths
321− if !strings.Contains(targetDir, ":") && !strings.HasPrefix(targetDir, "\\") {
322− // Relative path - resolve against current directory
323− if targetDir == ".." {
324− // Go up one directory
325− if len(s.cwd) > 3 { // Don't go above drive root (C:\)
326− lastSlash := strings.LastIndex(s.cwd, "\\")
327− if lastSlash > 2 { // Keep drive letter
328− s.cwd = s.cwd[:lastSlash]
329− }
330− }
331− } else if targetDir != "." {
332− // Go to subdirectory
333− s.cwd = s.cwd + "\\" + targetDir
334− }
335− } else {
336− // Absolute path
337− s.cwd = targetDir
338− }
339−
340− // Verify the directory exists
341− if _, err := os.Stat(s.cwd); err != nil {
342− return "", fmt.Sprintf("cd: %s: No such file or directory", targetDir), err
343− }
344−
345− return "", "", nil
346−}
347−
348211// execPOSIX executes commands using POSIX shell emulation (cross-platform)
349212func (s *Shell) execPOSIX(ctx context.Context, command string) (string, string, error) {
350213 line, err := syntax.NewParser().Parse(strings.NewReader(command), "")
internal/shell/shell_test.go+21−125
@@ -2,6 +2,7 @@ package shell
22
33import (
44 "context"
5+ "path/filepath"
56 "runtime"
67 "strings"
78 "testing"
@@ -24,6 +25,11 @@ func BenchmarkShellQuickCommands(b *testing.B) {
2425}
2526
2627func TestTestTimeout(t *testing.T) {
28+ // XXX(@andreynering): This fails on Windows. Address once possible.
29+ if runtime.GOOS == "windows" {
30+ t.Skip("Skipping test on Windows")
31+ }
32+
2733 ctx, cancel := context.WithTimeout(t.Context(), time.Millisecond)
2834 t.Cleanup(cancel)
2935
@@ -72,113 +78,23 @@ func TestRunCommandError(t *testing.T) {
7278}
7379
7480func TestRunContinuity(t *testing.T) {
75− shell := NewShell(&Options{WorkingDir: t.TempDir()})
76− shell.Exec(t.Context(), "export FOO=bar")
77− dst := t.TempDir()
78− shell.Exec(t.Context(), "cd "+dst)
79− out, _, _ := shell.Exec(t.Context(), "echo $FOO ; pwd")
80− expect := "bar\n" + dst + "\n"
81− if out != expect {
82− t.Fatalf("Expected output %q, got %q", expect, out)
83− }
84−}
81+ tempDir1 := t.TempDir()
82+ tempDir2 := t.TempDir()
8583
86−// New tests for Windows shell support
87−
88−func TestShellTypeDetection(t *testing.T) {
89− shell := &PersistentShell{}
90−
91− tests := []struct {
92− command string
93− expected ShellType
94− windowsOnly bool
95− }{
96− // Windows-specific commands
97− {"dir", ShellTypeCmd, true},
98− {"type file.txt", ShellTypeCmd, true},
99− {"copy file1.txt file2.txt", ShellTypeCmd, true},
100− {"del file.txt", ShellTypeCmd, true},
101− {"md newdir", ShellTypeCmd, true},
102− {"tasklist", ShellTypeCmd, true},
103−
104− // PowerShell commands
105− {"Get-Process", ShellTypePowerShell, true},
106− {"Get-ChildItem", ShellTypePowerShell, true},
107− {"Set-Location C:\\", ShellTypePowerShell, true},
108− {"Get-Content file.txt | Where-Object {$_ -match 'pattern'}", ShellTypePowerShell, true},
109− {"$files = Get-ChildItem", ShellTypePowerShell, true},
110−
111− // Unix/cross-platform commands
112− {"ls -la", ShellTypePOSIX, false},
113− {"cat file.txt", ShellTypePOSIX, false},
114− {"grep pattern file.txt", ShellTypePOSIX, false},
115− {"echo hello", ShellTypePOSIX, false},
116− {"git status", ShellTypePOSIX, false},
117− {"go build", ShellTypePOSIX, false},
118− }
119−
120− for _, test := range tests {
121− t.Run(test.command, func(t *testing.T) {
122− result := shell.determineShellType(test.command)
123−
124− if test.windowsOnly && runtime.GOOS != "windows" {
125− // On non-Windows systems, everything should use POSIX
126− if result != ShellTypePOSIX {
127− t.Errorf("On non-Windows, command %q should use POSIX shell, got %v", test.command, result)
128− }
129− } else if runtime.GOOS == "windows" {
130− // On Windows, check the expected shell type
131− if result != test.expected {
132− t.Errorf("Command %q should use %v shell, got %v", test.command, test.expected, result)
133− }
134− }
135− })
84+ shell := NewShell(&Options{WorkingDir: tempDir1})
85+ if _, _, err := shell.Exec(t.Context(), "export FOO=bar"); err != nil {
86+ t.Fatalf("failed to set env: %v", err)
13687 }
137−}
138−
139−func TestWindowsCDHandling(t *testing.T) {
140− if runtime.GOOS != "windows" {
141− t.Skip("Windows CD handling test only runs on Windows")
142− }
143−
144− shell := NewShell(&Options{
145− WorkingDir: "C:\\Users",
146− })
147−
148− tests := []struct {
149− command string
150− expectedCwd string
151− shouldError bool
152− }{
153− {"cd ..", "C:\\", false},
154− {"cd Documents", "C:\\Users\\Documents", false},
155− {"cd C:\\Windows", "C:\\Windows", false},
156− {"cd", "", true}, // Missing argument
157− }
158−
159− for _, test := range tests {
160− t.Run(test.command, func(t *testing.T) {
161− originalCwd := shell.GetWorkingDir()
162− stdout, stderr, err := shell.handleWindowsCD(test.command)
163−
164− if test.shouldError {
165− if err == nil {
166− t.Errorf("Command %q should have failed", test.command)
167− }
168− } else {
169− if err != nil {
170− t.Errorf("Command %q failed: %v", test.command, err)
171− }
172− if shell.GetWorkingDir() != test.expectedCwd {
173− t.Errorf("Command %q: expected cwd %q, got %q", test.command, test.expectedCwd, shell.GetWorkingDir())
174− }
175− }
176−
177− // Reset for next test
178− shell.SetWorkingDir(originalCwd)
179− _ = stdout
180− _ = stderr
181− })
88+ if _, _, err := shell.Exec(t.Context(), "cd "+filepath.ToSlash(tempDir2)); err != nil {
89+ t.Fatalf("failed to change directory: %v", err)
90+ }
91+ out, _, err := shell.Exec(t.Context(), "echo $FOO ; pwd")
92+ if err != nil {
93+ t.Fatalf("failed to echo: %v", err)
94+ }
95+ expect := "bar\n" + tempDir2 + "\n"
96+ if out != expect {
97+ t.Fatalf("expected output %q, got %q", expect, out)
18298 }
18399}
184100
@@ -202,23 +118,3 @@ func TestCrossPlatformExecution(t *testing.T) {
202118 t.Errorf("Echo output should contain 'hello', got: %q", stdout)
203119 }
204120}
205−
206−func TestWindowsNativeCommands(t *testing.T) {
207− if runtime.GOOS != "windows" {
208− t.Skip("Windows native command test only runs on Windows")
209− }
210−
211− shell := NewShell(&Options{WorkingDir: "."})
212− ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
213− defer cancel()
214−
215− // Test Windows dir command
216− stdout, stderr, err := shell.Exec(ctx, "dir")
217− if err != nil {
218− t.Fatalf("Dir command failed: %v, stderr: %s", err, stderr)
219− }
220−
221− if stdout == "" {
222− t.Error("Dir command produced no output")
223− }
224−}