返回 DeepSeek-Reasonix
bash_powershell_test.go
根目录 / internal / tool / builtin / bash_powershell_test.go
1 package builtin
2
3 import (
4 "encoding/json"
5 "os"
6 "os/exec"
7 "path/filepath"
8 "runtime"
9 "strings"
10 "testing"
11 "unicode/utf8"
12
13 "reasonix/internal/sandbox"
14 "reasonix/internal/tool"
15 )
16
17 func powershellPath(t *testing.T) string {
18 t.Helper()
19 for _, n := range []string{"pwsh", "powershell"} {
20 if p, err := exec.LookPath(n); err == nil {
21 return p
22 }
23 }
24 t.Skip("no PowerShell on PATH")
25 return ""
26 }
27
28 func TestLegacyPowerShellCallWithoutDescriptionStillValidates(t *testing.T) {
29 var params bashParams
30 if err := json.Unmarshal([]byte(`{"command":"Write-Output legacy"}`), &params); err != nil {
31 t.Fatal(err)
32 }
33 if err := validateBashParams(params); err != nil {
34 t.Fatalf("legacy bash call without description must remain executable: %v", err)
35 }
36 }
37
38 func runPS(t *testing.T, command string) (string, error) {
39 t.Helper()
40 b := bash{shell: sandbox.Shell{Kind: sandbox.ShellPowerShell, Path: powershellPath(t)}}
41 args, _ := json.Marshal(map[string]string{"command": command})
42 return b.Execute(sandbox.WithPermissionPreset(t.Context(), "danger-full-access"), args)
43 }
44
45 func TestBashPowerShellRunsNativeCommand(t *testing.T) {
46 if runtime.GOOS != "windows" {
47 t.Skip("powershell e2e is windows-only")
48 }
49 out, err := runPS(t, "Write-Output reasonix-ok")
50 if err != nil {
51 t.Fatalf("powershell command failed: %v (out=%q)", err, out)
52 }
53 if !strings.Contains(out, "reasonix-ok") {
54 t.Fatalf("output = %q, want it to contain reasonix-ok", out)
55 }
56 }
57
58 func TestBashPowerShellSurfacesNonZeroExit(t *testing.T) {
59 if runtime.GOOS != "windows" {
60 t.Skip("powershell e2e is windows-only")
61 }
62 if _, err := runPS(t, "exit 3"); err == nil {
63 t.Fatal("non-zero exit should surface as an error")
64 }
65 }
66
67 func TestBashPowerShellRejectsChaining(t *testing.T) {
68 b := bash{shell: sandbox.Shell{Kind: sandbox.ShellPowerShell, Path: "powershell"}}
69 for _, cmd := range []string{"echo a && echo b", "echo a || echo b"} {
70 args, _ := json.Marshal(map[string]string{"command": cmd})
71 out, err := b.Execute(sandbox.WithPermissionPreset(t.Context(), "danger-full-access"), args)
72 if err == nil {
73 t.Errorf("%q should be rejected on powershell, got out=%q", cmd, out)
74 } else if !strings.Contains(err.Error(), "PowerShell") {
75 t.Errorf("%q error should explain PowerShell, got %v", cmd, err)
76 }
77 }
78 }
79
80 func TestBashPowerShellAllowsQuotedOperator(t *testing.T) {
81 if runtime.GOOS != "windows" {
82 t.Skip("runs a real powershell command")
83 }
84 // "&&" inside a string literal is data, not chaining — must not be rejected.
85 out, err := runPS(t, `Write-Output "a && b"`)
86 if err != nil {
87 t.Fatalf("quoted && should run: %v (out=%q)", err, out)
88 }
89 if !strings.Contains(out, "a && b") {
90 t.Fatalf("output = %q", out)
91 }
92 }
93
94 func TestBashPwshAllowsChaining(t *testing.T) {
95 // pwsh (PowerShell 7+) parses && — the guard must not block it.
96 b := bash{shell: sandbox.Shell{Kind: sandbox.ShellPowerShell, Path: "pwsh"}}
97 args, _ := json.Marshal(map[string]string{"command": "echo a && echo b"})
98 _, err := b.Execute(sandbox.WithPermissionPreset(t.Context(), "danger-full-access"), args)
99 if err != nil && strings.Contains(err.Error(), "does not parse") {
100 t.Errorf("pwsh should not be blocked by the chaining guard: %v", err)
101 }
102 }
103
104 func TestBashPowerShellOutputIsUTF8(t *testing.T) {
105 if runtime.GOOS != "windows" {
106 t.Skip("powershell e2e is windows-only")
107 }
108 out, err := runPS(t, "Write-Output 'AB-中文-CD'")
109 if err != nil {
110 t.Fatalf("command failed: %v (out=%q)", err, out)
111 }
112 if !strings.Contains(out, "中文") {
113 t.Fatalf("non-ASCII output mojibake — got %q (want it to contain 中文)", out)
114 }
115 }
116
117 // TestBashPowerShellExecuteDetailedContract is the Windows CI contract for the
118 // shell execution metadata path: Chinese workspace path, UTF-8 output, exit
119 // code preservation (including 0), and PowerShell 5.1 vs pwsh identity.
120 func TestBashPowerShellExecuteDetailedContract(t *testing.T) {
121 if runtime.GOOS != "windows" {
122 t.Skip("powershell e2e is windows-only; Linux/macOS CI covers bash path")
123 }
124 // Windows PowerShell 5.1 is the compatibility-critical path: unlike pwsh,
125 // it does not parse &&/|| and commonly runs under a legacy console code page.
126 // Require it on native Windows instead of silently selecting pwsh first.
127 ps51Path, err := exec.LookPath("powershell")
128 if err != nil {
129 t.Fatalf("Windows PowerShell 5.1 is required for this contract: %v", err)
130 }
131 paths := []struct {
132 name string
133 path string
134 }{{name: "powershell-5.1", path: ps51Path}}
135 // PowerShell 7 is optional for ordinary Windows installations, but the
136 // GitHub Windows runner provides it; exercise it whenever available.
137 if pwshPath, lookupErr := exec.LookPath("pwsh"); lookupErr == nil {
138 paths = append(paths, struct {
139 name string
140 path string
141 }{name: "pwsh-7", path: pwshPath})
142 }
143 for _, tc := range paths {
144 t.Run(tc.name, func(t *testing.T) {
145 assertPowerShellDetailedContract(t, tc.path)
146 })
147 }
148 }
149
150 func assertPowerShellDetailedContract(t *testing.T, psPath string) {
151 t.Helper()
152 // Chinese directory name — native Windows CI must keep path + UTF-8 intact.
153 work := filepath.Join(t.TempDir(), "中文目录-reasonix")
154 if err := os.MkdirAll(work, 0o755); err != nil {
155 t.Fatal(err)
156 }
157 marker := filepath.Join(work, "标记.txt")
158 if err := os.WriteFile(marker, []byte("内容-utf8"), 0o644); err != nil {
159 t.Fatal(err)
160 }
161
162 b := bash{shell: sandbox.Shell{Kind: sandbox.ShellPowerShell, Path: psPath}, workDir: work}
163
164 // Success: exit 0 must be retained (not omitted) and UTF-8 content preserved.
165 argsOK, _ := json.Marshal(map[string]string{
166 "command": "Get-Content -LiteralPath .\\标记.txt -Encoding utf8; Write-Output '中文-ok'",
167 })
168 res, err := b.ExecuteDetailed(sandbox.WithPermissionPreset(t.Context(), "danger-full-access"), argsOK)
169 if err != nil {
170 t.Fatalf("success path: %v out=%q", err, res.Output)
171 }
172 if res.Execution == nil {
173 t.Fatal("missing execution metadata")
174 }
175 if res.Execution.State != tool.ShellStateCompleted {
176 t.Fatalf("state=%q", res.Execution.State)
177 }
178 if res.Execution.ExitCode == nil || *res.Execution.ExitCode != 0 {
179 t.Fatalf("exitCode=%v want 0", res.Execution.ExitCode)
180 }
181 if !strings.Contains(res.Output, "内容-utf8") || !strings.Contains(res.Output, "中文-ok") {
182 t.Fatalf("UTF-8/Chinese lost in combined output: %q", res.Output)
183 }
184 if !utf8.ValidString(res.Output) {
185 t.Fatal("combined output is not valid UTF-8")
186 }
187 // Descriptor identity: powershell.exe → 5.1; pwsh → 7+.
188 base := strings.ToLower(filepath.Base(psPath))
189 base = strings.TrimSuffix(base, ".exe")
190 if base == "pwsh" {
191 if res.Execution.Shell != tool.ShellNamePwsh || res.Execution.ShellVersion != tool.ShellVersionPS7 {
192 t.Fatalf("pwsh identity = %s/%s", res.Execution.Shell, res.Execution.ShellVersion)
193 }
194 if !res.Execution.SupportsAndAnd {
195 t.Fatal("pwsh should support &&")
196 }
197 } else {
198 if res.Execution.Shell != tool.ShellNamePowerShell || res.Execution.ShellVersion != tool.ShellVersionPS51 {
199 t.Fatalf("powershell identity = %s/%s", res.Execution.Shell, res.Execution.ShellVersion)
200 }
201 if res.Execution.SupportsAndAnd {
202 t.Fatal("Windows PowerShell 5.1 must not claim && support")
203 }
204 }
205
206 // Non-zero exit: preserve real code and execution failure phase.
207 argsFail, _ := json.Marshal(map[string]string{"command": "exit 17"})
208 fail, err := b.ExecuteDetailed(sandbox.WithPermissionPreset(t.Context(), "danger-full-access"), argsFail)
209 if err == nil {
210 t.Fatal("exit 17 should error")
211 }
212 if fail.Execution == nil || fail.Execution.ExitCode == nil || *fail.Execution.ExitCode != 17 {
213 t.Fatalf("exit metadata = %+v", fail.Execution)
214 }
215 if fail.Execution.State != tool.ShellStateFailed || fail.Execution.FailurePhase != tool.ShellPhaseExecution {
216 t.Fatalf("fail state/phase = %s/%s", fail.Execution.State, fail.Execution.FailurePhase)
217 }
218 }
219
220 func TestBashPowerShell51PreflightRejectsAndAndDetailed(t *testing.T) {
221 // Runs on every OS: pure preflight, no process launch.
222 b := bash{shell: sandbox.Shell{Kind: sandbox.ShellPowerShell, Path: "powershell"}}
223 args, _ := json.Marshal(map[string]string{"command": "echo a && echo b"})
224 res, err := b.ExecuteDetailed(sandbox.WithPermissionPreset(t.Context(), "danger-full-access"), args)
225 if err == nil {
226 t.Fatal("expected preflight rejection")
227 }
228 if res.Execution == nil {
229 t.Fatal("missing execution")
230 }
231 if res.Execution.State != tool.ShellStateNotRun || res.Execution.FailurePhase != tool.ShellPhasePreflight {
232 t.Fatalf("state/phase = %s/%s", res.Execution.State, res.Execution.FailurePhase)
233 }
234 if res.Execution.MutationRisk != tool.ShellMutationNotStarted {
235 t.Fatalf("mutationRisk = %q", res.Execution.MutationRisk)
236 }
237 if res.Execution.ExitCode != nil {
238 t.Fatalf("exitCode must be unset for preflight, got %v", *res.Execution.ExitCode)
239 }
240 }
241
242 func TestPowerShellDescriptionIsStableAcrossVersions(t *testing.T) {
243 ps := bash{shell: sandbox.Shell{Kind: sandbox.ShellPowerShell, Path: "powershell"}}
244 psDesc := ps.Description()
245 if !strings.Contains(psDesc, "isolated process") {
246 t.Errorf("powershell description should state one-shot execution: %q", psDesc)
247 }
248 if !strings.Contains(psDesc, "if ($?)") || strings.Contains(psDesc, "'&&'") {
249 t.Errorf("powershell description should use the portable chaining subset: %q", psDesc)
250 }
251 pwsh := bash{shell: sandbox.Shell{Kind: sandbox.ShellPowerShell, Path: "pwsh"}}
252 pwshDesc := pwsh.Description()
253 if pwshDesc != psDesc {
254 t.Errorf("PowerShell schema description must stay cache-stable across resolved versions\n5.1=%q\n7=%q", psDesc, pwshDesc)
255 }
256 sh := bash{shell: sandbox.Shell{Kind: sandbox.ShellBash, Path: "bash"}}
257 if strings.Contains(sh.Description(), "PowerShell") {
258 t.Errorf("bash description should not mention PowerShell: %q", sh.Description())
259 }
260 }
261
261 lines GO