返回 DeepSeek-Reasonix
bash_persistent_test.go
根目录 / internal / tool / builtin / bash_persistent_test.go
1 package builtin
2
3 import (
4 "context"
5 "encoding/json"
6 "os"
7 "path/filepath"
8 "runtime"
9 "strings"
10 "testing"
11 "time"
12
13 "reasonix/internal/persistentshell"
14 "reasonix/internal/sandbox"
15 )
16
17 func TestPowerShellNeverUsesPersistentSession(t *testing.T) {
18 b := bash{persistent: persistentshell.New()}
19 sh := sandbox.Shell{Kind: sandbox.ShellPowerShell, Path: "pwsh"}
20 if b.shouldUsePersistent(context.Background(), bashParams{}, sh) {
21 t.Fatal("ordinary PowerShell calls must use one-shot processes")
22 }
23 }
24
25 func persistentBash(t *testing.T, workDir string) bash {
26 t.Helper()
27 m := persistentshell.New()
28 m.Retain()
29 t.Cleanup(m.Release)
30 sh := sandbox.ResolveShell("", "", nil)
31 return bash{
32 sb: sandbox.Spec{Mode: "off"},
33 shell: sh,
34 workDir: workDir,
35 timeout: 8 * time.Second,
36 persistent: m,
37 }
38 }
39
40 func TestBashPersistentKeepsWorkingDirectory(t *testing.T) {
41 if runtime.GOOS == "windows" {
42 t.Skip("POSIX persistent bash")
43 }
44 dir := t.TempDir()
45 sub := filepath.Join(dir, "sub")
46 if err := os.Mkdir(sub, 0o755); err != nil {
47 t.Fatal(err)
48 }
49 b := persistentBash(t, dir)
50 ctx := fullAccessBashTestContext(t.Context())
51 if _, err := b.Execute(ctx, argsJSON(t, map[string]any{"command": "cd sub"})); err != nil {
52 t.Fatalf("cd: %v", err)
53 }
54 out, err := b.Execute(ctx, argsJSON(t, map[string]any{"command": "pwd"}))
55 if err != nil {
56 t.Fatalf("pwd: %v (%q)", err, out)
57 }
58 want, _ := filepath.EvalSymlinks(sub)
59 got := strings.TrimSpace(out)
60 if resolved, rerr := filepath.EvalSymlinks(got); rerr == nil {
61 got = resolved
62 }
63 if got != want && !strings.Contains(out, "sub") {
64 t.Fatalf("pwd=%q want %q", out, want)
65 }
66 }
67
68 func TestBashWithoutPersistentDoesNotKeepCwd(t *testing.T) {
69 if runtime.GOOS == "windows" {
70 t.Skip("POSIX one-shot bash")
71 }
72 dir := t.TempDir()
73 sub := filepath.Join(dir, "sub")
74 if err := os.Mkdir(sub, 0o755); err != nil {
75 t.Fatal(err)
76 }
77 b := bash{
78 sb: sandbox.Spec{Mode: "off"},
79 shell: sandbox.ResolveShell("", "", nil),
80 workDir: dir,
81 timeout: 8 * time.Second,
82 }
83 ctx := fullAccessBashTestContext(t.Context())
84 if _, err := b.Execute(ctx, argsJSON(t, map[string]any{"command": "cd sub"})); err != nil {
85 t.Fatalf("cd: %v", err)
86 }
87 out, err := b.Execute(ctx, argsJSON(t, map[string]any{"command": "pwd"}))
88 if err != nil {
89 t.Fatalf("pwd: %v (%q)", err, out)
90 }
91 got := strings.TrimSpace(out)
92 if resolved, rerr := filepath.EvalSymlinks(got); rerr == nil {
93 got = resolved
94 }
95 want, _ := filepath.EvalSymlinks(dir)
96 if got != want {
97 t.Fatalf("one-shot pwd=%q want workspace %q", out, want)
98 }
99 }
100
101 func TestBashPersistentSchemaUnchanged(t *testing.T) {
102 plain := bash{}.Schema()
103 withPTY := bash{persistent: persistentshell.New()}.Schema()
104 if string(plain) != string(withPTY) {
105 t.Fatalf("persistent PTY must not change bash schema\nplain=%s\nwith=%s", plain, withPTY)
106 }
107 if (bash{}).Description() != (bash{persistent: persistentshell.New()}).Description() {
108 t.Fatal("persistent PTY must not change bash description")
109 }
110 var schema map[string]any
111 if err := json.Unmarshal(plain, &schema); err != nil {
112 t.Fatal(err)
113 }
114 }
115
116 func TestBashPersistentUnicodeWithoutUTF8Locale(t *testing.T) {
117 if runtime.GOOS == "windows" {
118 t.Skip("POSIX persistent bash")
119 }
120 t.Setenv("LC_ALL", "C")
121 t.Setenv("INPUTRC", "/dev/null")
122 b := persistentBash(t, t.TempDir())
123 b.shell = sandbox.ResolveShell("bash", "", nil)
124 ctx := fullAccessBashTestContext(t.Context())
125 for _, command := range []string{
126 "export RX_UNICODE='中文😀'; printf '%s' \"$RX_UNICODE\"",
127 "printf '%s' \"$RX_UNICODE\"",
128 } {
129 out, err := b.Execute(ctx, argsJSON(t, map[string]any{"command": command}))
130 if err != nil || strings.TrimSpace(out) != "中文😀" {
131 t.Fatalf("Unicode command: output=%q err=%v", out, err)
132 }
133 }
134 }
135
136 func TestBashPersistentSkipsBackgroundAndWriteEscalation(t *testing.T) {
137 if runtime.GOOS == "windows" {
138 t.Skip("POSIX persistent bash")
139 }
140 dir := t.TempDir()
141 sub := filepath.Join(dir, "sub")
142 extra := t.TempDir()
143 if err := os.Mkdir(sub, 0o755); err != nil {
144 t.Fatal(err)
145 }
146 b := persistentBash(t, dir)
147 ctx := fullAccessBashTestContext(t.Context())
148 if _, err := b.Execute(ctx, argsJSON(t, map[string]any{"command": "cd sub"})); err != nil {
149 t.Fatalf("cd: %v", err)
150 }
151 out, err := b.Execute(ctx, argsJSON(t, map[string]any{
152 "command": "pwd",
153 "additional_write_dirs": []string{extra},
154 "justification": "test one-shot fallback",
155 }))
156 if err != nil {
157 t.Fatalf("escalated pwd: %v (%q)", err, out)
158 }
159 got := strings.TrimSpace(out)
160 if resolved, rerr := filepath.EvalSymlinks(got); rerr == nil {
161 got = resolved
162 }
163 wantSub, _ := filepath.EvalSymlinks(sub)
164 if got == wantSub {
165 t.Fatalf("additional_write_dirs must not reuse persistent cwd, got %q", out)
166 }
167 }
168
169 // A foreground command that backgrounds a child stays on the one-shot path:
170 // only there does #3702's process-group reap run, and only there can the
171 // child's later output not land inside the next command's result.
172 func TestBashPersistentSkipsBackgroundOperator(t *testing.T) {
173 if runtime.GOOS == "windows" {
174 t.Skip("POSIX persistent bash")
175 }
176 dir := t.TempDir()
177 sub := filepath.Join(dir, "sub")
178 if err := os.Mkdir(sub, 0o755); err != nil {
179 t.Fatal(err)
180 }
181 b := persistentBash(t, dir)
182 ctx := fullAccessBashTestContext(t.Context())
183 if _, err := b.Execute(ctx, argsJSON(t, map[string]any{"command": "cd sub"})); err != nil {
184 t.Fatalf("cd: %v", err)
185 }
186 // A backgrounding command must not observe the persistent cwd.
187 out, err := b.Execute(ctx, argsJSON(t, map[string]any{"command": "(sleep 0) & wait; pwd"}))
188 if err != nil {
189 t.Fatalf("background pwd: %v (%q)", err, out)
190 }
191 got := strings.TrimSpace(out)
192 if resolved, rerr := filepath.EvalSymlinks(got); rerr == nil {
193 got = resolved
194 }
195 wantSub, _ := filepath.EvalSymlinks(sub)
196 if got == wantSub {
197 t.Fatalf("a backgrounding command must run one-shot, got %q", out)
198 }
199 }
200
201 func TestHasBackgroundStatement(t *testing.T) {
202 cases := map[string]bool{
203 "sleep 1 &": true,
204 "npm run dev &": true,
205 "(while true; do :; done) &": true,
206 "sleep 1": false,
207 "echo 'a & b'": false,
208 "grep -n 'x && y' file": false,
209 "a && b": false,
210 }
211 for command, want := range cases {
212 if got := hasBackgroundStatement(command); got != want {
213 t.Fatalf("hasBackgroundStatement(%q)=%v want %v", command, got, want)
214 }
215 }
216 }
217
218 // The persistent launch must carry the session-private temporary directory into
219 // the sandbox profile, not only into the child environment: the same spec sets
220 // TMPDIR/GOCACHE, so a profile without that directory denies every write
221 // through them.
222 func TestBashPersistentLaunchCarriesSessionTemp(t *testing.T) {
223 sessionTemp := t.TempDir()
224 spec := sandbox.Spec{Mode: "enforce", WriteRoots: []string{t.TempDir()}}
225 launch := sandbox.PrepareArgs(spec, persistentshell.InteractiveArgv(sandbox.ResolveShell("", "", nil)), sessionTemp)
226 if launch.SessionTemp != sessionTemp {
227 t.Fatalf("session temp %q not carried into the launch", launch.SessionTemp)
228 }
229 if len(launch.EnvOverrides) == 0 {
230 t.Fatal("session temp env overrides missing")
231 }
232 if !launch.Wrapped {
233 t.Skip("no OS sandbox backend on this host")
234 }
235 if !strings.Contains(strings.Join(launch.Argv, " "), sessionTemp) {
236 t.Fatalf("sandbox argv does not reference the session temp dir: %v", launch.Argv)
237 }
238 }
239
239 lines GO