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