返回 DeepSeek-Reasonix
bash_session_temp_test.go
根目录 / internal / tool / builtin / bash_session_temp_test.go
1 package builtin
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "os"
8 "path/filepath"
9 "runtime"
10 "slices"
11 "strings"
12 "testing"
13
14 "reasonix/internal/sandbox"
15 "reasonix/internal/sessiontemp"
16 )
17
18 func TestBashSharesSessionTempAcrossCalls(t *testing.T) {
19 type shellCase struct {
20 name string
21 shell sandbox.Shell
22 }
23 shells := []shellCase{
24 {name: "default"},
25 }
26 if runtime.GOOS == "windows" {
27 // The default on Windows prefers Git Bash when installed. Exercise native
28 // PowerShell explicitly as well so both supported shell modes prove the
29 // same session-temp contract.
30 powerShell := sandbox.ResolveShell("powershell", "", nil)
31 if powerShell.Kind != sandbox.ShellPowerShell {
32 t.Fatal("PowerShell is required for the Windows session-temp regression")
33 }
34 shells = append(shells, shellCase{name: "powershell", shell: powerShell})
35 }
36
37 for _, tc := range shells {
38 t.Run(tc.name, func(t *testing.T) {
39 ctx := sandbox.WithPermissionPreset(t.Context(), "danger-full-access")
40 m := sessiontemp.NewWithRoot(t.TempDir())
41 m.Retain()
42 defer m.Release()
43
44 b := bash{
45 sb: sandbox.Spec{Mode: "off"},
46 shell: tc.shell,
47 workDir: t.TempDir(),
48 sessionTemp: m,
49 }
50 // Pin the lazily resolved default so command syntax follows the shell
51 // actually selected, rather than assuming every Windows host uses
52 // PowerShell (Git Bash is preferred when present).
53 b.shell = b.resolved()
54
55 marker := "reasonix-session-temp-share"
56 writeCmd := `test "$TMPDIR" = "$TMP" && test "$TMPDIR" = "$TEMP" && printf '%s' shared > "${TMPDIR:?}/` + marker + `"`
57 readCmd := `cat "${TMPDIR:?}/` + marker + `"`
58 if b.shell.Kind == sandbox.ShellPowerShell {
59 writeCmd = `if (($env:TMPDIR -ne $env:TMP) -or ($env:TMPDIR -ne $env:TEMP)) { throw 'temporary environment variables differ' }; Set-Content -Path (Join-Path $env:TEMP '` + marker + `') -Value 'shared' -NoNewline`
60 readCmd = `Get-Content -Raw (Join-Path $env:TEMP '` + marker + `')`
61 }
62 if _, err := b.Execute(ctx, argsJSON(t, map[string]any{"command": writeCmd})); err != nil {
63 t.Fatalf("write: %v", err)
64 }
65
66 out, err := b.Execute(ctx, argsJSON(t, map[string]any{"command": readCmd}))
67 if err != nil {
68 t.Fatalf("read: %v", err)
69 }
70 if !strings.Contains(out, "shared") {
71 t.Fatalf("second bash call did not see first temp file: %q", out)
72 }
73
74 dir := m.Dir()
75 if dir == "" {
76 t.Fatal("manager has no generation after use")
77 }
78 body, err := os.ReadFile(filepath.Join(dir, marker))
79 if err != nil || string(body) != "shared" {
80 t.Fatalf("host private dir content = %q err=%v", body, err)
81 }
82 })
83 }
84 }
85
86 func TestBashSchemaUnchangedWithSessionTemp(t *testing.T) {
87 plain := bash{}.Schema()
88 withTemp := bash{sessionTemp: sessiontemp.New()}.Schema()
89 if string(plain) != string(withTemp) {
90 t.Fatalf("session temp must not change bash schema\nplain=%s\nwith=%s", plain, withTemp)
91 }
92 var schema map[string]any
93 if err := json.Unmarshal(plain, &schema); err != nil {
94 t.Fatal(err)
95 }
96 req, _ := schema["required"].([]any)
97 want := []any{"command"}
98 if (bash{}).resolved().Kind == sandbox.ShellPowerShell {
99 want = []any{"command", "description"}
100 }
101 if !slices.Equal(req, want) {
102 t.Fatalf("required = %v, want %v for the current platform shell", req, want)
103 }
104 }
105
106 func TestBashFailsWhenSessionTempUnavailable(t *testing.T) {
107 // Create-failure path: manager is owned but cannot create the directory.
108 m := sessiontemp.NewWithRoot(t.TempDir())
109 m.Retain()
110 defer m.Release()
111 m.SetMkDirForTest(func(string) (string, error) {
112 return "", os.ErrPermission
113 })
114 b := bash{
115 sb: sandbox.Spec{Mode: "off"},
116 workDir: t.TempDir(),
117 sessionTemp: m,
118 }
119 _, err := b.Execute(context.Background(), argsJSON(t, map[string]any{"command": "true"}))
120 if err == nil {
121 t.Fatal("want command failure when session temp cannot be created")
122 }
123 if !errors.Is(err, sessiontemp.ErrUnavailable) && !strings.Contains(err.Error(), "session temporary") {
124 t.Fatalf("error = %v, want session temporary failure", err)
125 }
126
127 // Sealed manager (last owner released) must fail closed, not fall back.
128 sealed := sessiontemp.NewWithRoot(t.TempDir())
129 sealed.Retain()
130 sealed.Release()
131 b2 := bash{
132 sb: sandbox.Spec{Mode: "off"},
133 workDir: t.TempDir(),
134 sessionTemp: sealed,
135 }
136 _, err = b2.Execute(context.Background(), argsJSON(t, map[string]any{"command": "true"}))
137 if err == nil {
138 t.Fatal("want failure against sealed session temp manager")
139 }
140 }
141
142 func TestBashBackgroundLeaseSurvivesRotate(t *testing.T) {
143 m := sessiontemp.NewWithRoot(t.TempDir())
144 m.Retain()
145 defer m.Release()
146
147 // Pin the old generation with a lease (simulates a running background job).
148 oldLease, err := m.Acquire()
149 if err != nil {
150 t.Fatal(err)
151 }
152 oldDir := oldLease.Dir()
153 if err := os.WriteFile(filepath.Join(oldDir, "bg.txt"), []byte("still-here"), 0o600); err != nil {
154 t.Fatal(err)
155 }
156
157 m.Rotate()
158 fresh, err := m.Acquire()
159 if err != nil {
160 t.Fatal(err)
161 }
162 if fresh.Dir() == oldDir {
163 t.Fatal("rotate should yield a new generation for new commands")
164 }
165 // Background job's generation remains until its lease is released.
166 if _, err := os.Stat(filepath.Join(oldDir, "bg.txt")); err != nil {
167 t.Fatalf("old generation deleted while background lease held: %v", err)
168 }
169 oldLease.Release()
170 if _, err := os.Stat(oldDir); !os.IsNotExist(err) {
171 t.Fatalf("old generation should delete after background lease release: %v", err)
172 }
173 fresh.Release()
174 }
175
175 lines GO