返回 DeepSeek-Reasonix
tmux_test.go
根目录 / internal / taskmonitor / tmux_test.go
1 package taskmonitor
2
3 import (
4 "context"
5 "encoding/json"
6 "os"
7 "path/filepath"
8 "runtime"
9 "strings"
10 "testing"
11 "time"
12 )
13
14 type tmuxMock struct {
15 calls [][]string
16 err error
17 options map[string]string
18 }
19
20 func (m *tmuxMock) Run(_ context.Context, args ...string) ([]byte, error) {
21 m.calls = append(m.calls, append([]string(nil), args...))
22 if m.err != nil {
23 return nil, m.err
24 }
25 if len(args) == 5 && args[0] == "set-option" && args[1] == "-t" {
26 if m.options == nil {
27 m.options = make(map[string]string)
28 }
29 m.options[tmuxMockSession(args[2])+"\x00"+args[3]] = args[4]
30 return nil, nil
31 }
32 if len(args) == 5 && args[0] == "show-options" && args[1] == "-v" && args[2] == "-t" {
33 value, ok := m.options[tmuxMockSession(args[3])+"\x00"+args[4]]
34 if !ok {
35 return nil, os.ErrNotExist
36 }
37 return []byte(value + "\n"), nil
38 }
39 if len(args) == 7 && args[0] == "if-shell" && args[1] == "-t" && args[3] == "-F" {
40 session := tmuxMockSession(args[2])
41 if m.options[session+"\x00"+tmuxOwnerOption] != "" &&
42 strings.Contains(args[4], m.options[session+"\x00"+tmuxOwnerOption]) {
43 delete(m.options, session+"\x00"+tmuxOwnerOption)
44 }
45 return nil, nil
46 }
47 return nil, nil
48 }
49
50 func tmuxMockSession(target string) string {
51 return strings.TrimSuffix(strings.TrimPrefix(target, "="), ":")
52 }
53
54 func seedTmuxTask(t *testing.T, s *InMemoryStore, projectDir string) {
55 seedTmuxTaskID(t, s, projectDir, "t1")
56 }
57
58 func seedTmuxTaskID(t *testing.T, s *InMemoryStore, projectDir, taskID string) {
59 t.Helper()
60 if err := s.UpsertTask(projectDir, TaskSnapshot{SchemaVersion: 1, TaskID: taskID, SessionID: "s1", State: TaskStateRunning, Version: 1, CreatedAt: time.Now(), UpdatedAt: time.Now()}); err != nil {
61 t.Fatal(err)
62 }
63 }
64
65 func TestTmuxAdapterAttachIdempotent(t *testing.T) {
66 s := NewInMemoryStore()
67 root := t.TempDir()
68 seedTmuxTask(t, s, root)
69 mock := &tmuxMock{}
70 a := NewTmuxAdapterWithRunner(s, ".reasonix/tasks", mock)
71 first := a.Attach(context.Background(), root, "t1", "demo")
72 if first.Error != nil || first.Mapping == nil {
73 t.Fatalf("attach failed: %+v", first)
74 }
75 second := a.Attach(context.Background(), root, "t1", "demo")
76 if !second.Idempotent {
77 t.Fatalf("expected idempotent attach: %+v", second)
78 }
79 if len(mock.calls) != 3 { // new-session, set ownership marker, then verify it
80 t.Fatalf("unexpected tmux calls: %#v", mock.calls)
81 }
82 }
83
84 func TestTmuxAdapterBoundsGeneratedSessionName(t *testing.T) {
85 s := NewInMemoryStore()
86 root := t.TempDir()
87 taskID := strings.Repeat("a", 60)
88 seedTmuxTaskID(t, s, root, taskID)
89 a := NewTmuxAdapterWithRunner(s, ".reasonix/tasks", &tmuxMock{})
90 r := a.Attach(context.Background(), root, taskID, "")
91 if r.Error != nil || r.Mapping == nil {
92 t.Fatalf("attach failed: %+v", r)
93 }
94 if got := r.Mapping.Session; len(got) > 64 || !strings.HasPrefix(got, defaultTmuxNamePrefix) {
95 t.Fatalf("generated session = %q (len %d)", got, len(got))
96 }
97 if r.Mapping.Session != defaultTmuxSessionName(taskID) || r.Mapping.Session == defaultTmuxSessionName(taskID+"b") {
98 t.Fatalf("generated session is not deterministic and collision-resistant: %q", r.Mapping.Session)
99 }
100 }
101
102 func TestTmuxAdapterUnavailableDoesNotChangeTask(t *testing.T) {
103 s := NewInMemoryStore()
104 root := t.TempDir()
105 seedTmuxTask(t, s, root)
106 a := NewTmuxAdapterWithRunner(s, ".reasonix/tasks", nil)
107 r := a.Attach(context.Background(), root, "t1", "")
108 if r.Error == nil || r.Error.Code != ErrTmuxUnavailable {
109 t.Fatalf("expected unavailable, got %+v", r)
110 }
111 snap, _ := s.GetTask(context.Background(), root, "t1")
112 if snap == nil || snap.State != TaskStateRunning {
113 t.Fatal("tmux operation changed task state")
114 }
115 }
116
117 func TestTmuxAdapterRejectsUnsafeNames(t *testing.T) {
118 s := NewInMemoryStore()
119 seedTmuxTask(t, s, "/p")
120 a := NewTmuxAdapterWithRunner(s, ".reasonix/tasks", &tmuxMock{})
121 for _, name := range []string{"bad/name", "semi;colon", "dollar$name", "has space"} {
122 r := a.Attach(context.Background(), "/p", "t1", name)
123 if r.Error == nil || r.Error.Code != ErrTmuxInvalidName {
124 t.Fatalf("expected invalid name for %q, got %+v", name, r)
125 }
126 }
127 }
128
129 func TestTmuxAdapterStaleAndDetach(t *testing.T) {
130 s := NewInMemoryStore()
131 root := t.TempDir()
132 seedTmuxTask(t, s, root)
133 mock := &tmuxMock{}
134 a := NewTmuxAdapterWithRunner(s, ".reasonix/tasks", mock)
135 if r := a.Attach(context.Background(), root, "t1", "demo"); r.Error != nil {
136 t.Fatal(r.Error)
137 }
138 mock.err = os.ErrNotExist
139 r := a.Status(context.Background(), root, "t1")
140 if r.Mapping == nil || !r.Mapping.Stale {
141 t.Fatalf("expected stale mapping: %+v", r)
142 }
143 mock.err = nil
144 if r := a.Detach(context.Background(), root, "t1"); r.Error != nil {
145 t.Fatal(r.Error)
146 }
147 if _, err := os.Stat(filepath.Join(root, ".reasonix/tasks/.tmux/t1.json")); !os.IsNotExist(err) {
148 t.Fatalf("mapping was not removed: %v", err)
149 }
150 }
151
152 func TestTmuxAdapterRejectsTaskPathTraversal(t *testing.T) {
153 a := NewTmuxAdapterWithRunner(NewInMemoryStore(), ".reasonix/tasks", &tmuxMock{})
154 r := a.Status(context.Background(), t.TempDir(), "../secret")
155 if r.Error == nil {
156 t.Fatal("expected invalid task id error")
157 }
158 }
159
160 func TestTmuxAdapterAcceptsCleanableProjectDir(t *testing.T) {
161 parent := t.TempDir()
162 for _, projectDir := range []string{
163 filepath.Join(parent, "nested", "..", "project"),
164 filepath.Join(parent, "project..archive"),
165 } {
166 if err := os.MkdirAll(filepath.Clean(projectDir), 0o755); err != nil {
167 t.Fatal(err)
168 }
169 s := NewInMemoryStore()
170 seedTmuxTask(t, s, projectDir)
171 a := NewTmuxAdapterWithRunner(s, ".reasonix/tasks", &tmuxMock{})
172 if result := a.Attach(context.Background(), projectDir, "t1", "demo"); result.Error != nil {
173 t.Fatalf("Attach(%q): %+v", projectDir, result.Error)
174 }
175 }
176 }
177
178 func TestTmuxAdapterRejectsSymlinkMappingDirectory(t *testing.T) {
179 project := t.TempDir()
180 outside := t.TempDir()
181 root := filepath.Join(project, ".reasonix", "tasks")
182 if err := os.MkdirAll(root, 0o700); err != nil {
183 t.Fatal(err)
184 }
185 if err := os.Symlink(outside, filepath.Join(root, ".tmux")); err != nil {
186 t.Skipf("symlink unavailable: %v", err)
187 }
188
189 s := NewInMemoryStore()
190 seedTmuxTask(t, s, project)
191 attachRunner := &tmuxMock{}
192 a := NewTmuxAdapterWithRunner(s, ".reasonix/tasks", attachRunner)
193 if result := a.Attach(context.Background(), project, "t1", "demo"); result.Error == nil || result.Error.Code != ErrTmuxMappingFailed {
194 t.Fatalf("expected mapping write rejection, got %+v", result)
195 }
196 outsideMapping := filepath.Join(outside, "t1.json")
197 if _, err := os.Stat(outsideMapping); !os.IsNotExist(err) {
198 t.Fatalf("mapping escaped through symlink: %v", err)
199 }
200
201 mapping := `{"schema_version":1,"task_id":"t1","session":"victim","window":"task","pane":"victim:task.0"}`
202 if err := os.WriteFile(outsideMapping, []byte(mapping), 0o600); err != nil {
203 t.Fatal(err)
204 }
205 readRunner := &tmuxMock{}
206 a = NewTmuxAdapterWithRunner(s, ".reasonix/tasks", readRunner)
207 for _, result := range []TmuxResult{
208 a.Status(context.Background(), project, "t1"),
209 a.Detach(context.Background(), project, "t1"),
210 } {
211 if result.Error == nil || result.Error.Code != ErrTmuxMappingFailed {
212 t.Fatalf("expected mapping read rejection, got %+v", result)
213 }
214 }
215 if len(readRunner.calls) != 0 {
216 t.Fatalf("untrusted mapping triggered tmux calls: %#v", readRunner.calls)
217 }
218 if _, err := os.Stat(outsideMapping); err != nil {
219 t.Fatalf("outside mapping was removed: %v", err)
220 }
221 }
222
223 func TestTmuxAdapterRejectsForgedMappingIdentityWithoutTmuxCalls(t *testing.T) {
224 project := t.TempDir()
225 s := NewInMemoryStore()
226 seedTmuxTask(t, s, project)
227 mock := &tmuxMock{}
228 a := NewTmuxAdapterWithRunner(s, ".reasonix/tasks", mock)
229 attached := a.Attach(context.Background(), project, "t1", "demo")
230 if attached.Error != nil || attached.Mapping == nil {
231 t.Fatalf("attach failed: %+v", attached)
232 }
233
234 path := filepath.Join(project, ".reasonix", "tasks", ".tmux", "t1.json")
235 forged := *attached.Mapping
236 forged.TaskID = "different-task"
237 forged.ProjectDir = filepath.Join(project, "different-project")
238 forged.Session = "user-owned-session"
239 forged.Pane = "user-owned-session:task.0"
240 b, err := json.Marshal(forged)
241 if err != nil {
242 t.Fatal(err)
243 }
244 if err := os.WriteFile(path, b, 0o600); err != nil {
245 t.Fatal(err)
246 }
247
248 mock.calls = nil
249 if result := a.Status(context.Background(), project, "t1"); result.Error == nil || result.Error.Code != ErrTmuxMappingFailed {
250 t.Fatalf("forged status mapping was accepted: %+v", result)
251 }
252 if len(mock.calls) != 0 {
253 t.Fatalf("forged status mapping triggered tmux calls: %#v", mock.calls)
254 }
255 if result := a.Detach(context.Background(), project, "t1"); result.Error == nil || result.Error.Code != ErrTmuxMappingFailed {
256 t.Fatalf("forged detach mapping was accepted: %+v", result)
257 }
258 if len(mock.calls) != 0 {
259 t.Fatalf("forged detach mapping triggered tmux calls: %#v", mock.calls)
260 }
261 if _, err := os.Stat(path); !os.IsNotExist(err) {
262 t.Fatalf("forged mapping was not removed safely: %v", err)
263 }
264 }
265
266 func TestTmuxAdapterDoesNotKillSessionWithWrongOwnerToken(t *testing.T) {
267 project := t.TempDir()
268 s := NewInMemoryStore()
269 seedTmuxTask(t, s, project)
270 mock := &tmuxMock{}
271 a := NewTmuxAdapterWithRunner(s, ".reasonix/tasks", mock)
272 attached := a.Attach(context.Background(), project, "t1", "demo")
273 if attached.Error != nil || attached.Mapping == nil {
274 t.Fatalf("attach failed: %+v", attached)
275 }
276 mock.options["demo\x00"+tmuxOwnerOption] = strings.Repeat("0", tmuxOwnerTokenBytes*2)
277 mock.calls = nil
278
279 result := a.Detach(context.Background(), project, "t1")
280 if result.Error != nil || result.Mapping == nil || !result.Mapping.Stale {
281 t.Fatalf("wrong-owner detach result: %+v", result)
282 }
283 for _, call := range mock.calls {
284 if len(call) > 0 && (call[0] == "kill-session" || call[0] == "if-shell") {
285 t.Fatalf("wrong owner token killed tmux session: %#v", mock.calls)
286 }
287 }
288 }
289
290 func TestTmuxAdapterDetachUsesAtomicOwnedKill(t *testing.T) {
291 project := t.TempDir()
292 s := NewInMemoryStore()
293 seedTmuxTask(t, s, project)
294 mock := &tmuxMock{}
295 a := NewTmuxAdapterWithRunner(s, ".reasonix/tasks", mock)
296 attached := a.Attach(context.Background(), project, "t1", "demo")
297 if attached.Error != nil || attached.Mapping == nil {
298 t.Fatalf("attach failed: %+v", attached)
299 }
300 mock.calls = nil
301
302 result := a.Detach(context.Background(), project, "t1")
303 if result.Error != nil {
304 t.Fatalf("detach failed: %+v", result)
305 }
306 foundAtomicKill := false
307 for _, call := range mock.calls {
308 if len(call) > 0 && call[0] == "kill-session" {
309 t.Fatalf("detach used a standalone kill-session: %#v", mock.calls)
310 }
311 if len(call) > 0 && call[0] == "if-shell" {
312 foundAtomicKill = true
313 }
314 }
315 if !foundAtomicKill {
316 t.Fatalf("detach did not issue an ownership-checked atomic kill: %#v", mock.calls)
317 }
318 }
319
320 func TestTmuxAdapterReplacesLegacyMappingWithoutTrustingIt(t *testing.T) {
321 project := t.TempDir()
322 s := NewInMemoryStore()
323 seedTmuxTask(t, s, project)
324 path := filepath.Join(project, ".reasonix", "tasks", ".tmux", "t1.json")
325 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
326 t.Fatal(err)
327 }
328 legacy, err := json.Marshal(TmuxMapping{SchemaVersion: 1, TaskID: "t1", ProjectDir: project, Session: "legacy", Window: "task", Pane: "legacy:task.0"})
329 if err != nil {
330 t.Fatal(err)
331 }
332 if err := os.WriteFile(path, legacy, 0o600); err != nil {
333 t.Fatal(err)
334 }
335 mock := &tmuxMock{}
336 a := NewTmuxAdapterWithRunner(s, ".reasonix/tasks", mock)
337
338 result := a.Attach(context.Background(), project, "t1", "fresh")
339 if result.Error != nil || result.Mapping == nil || result.Mapping.OwnerToken == "" {
340 t.Fatalf("legacy mapping replacement failed: %+v", result)
341 }
342 for _, call := range mock.calls {
343 if len(call) > 2 && call[0] == "show-options" && call[3] == "legacy" {
344 t.Fatalf("legacy mapping was trusted: %#v", mock.calls)
345 }
346 }
347 }
348
349 func TestTmuxAdapterWritesPrivateMappingFiles(t *testing.T) {
350 project := t.TempDir()
351 s := NewInMemoryStore()
352 seedTmuxTask(t, s, project)
353 a := NewTmuxAdapterWithRunner(s, ".reasonix/tasks", &tmuxMock{})
354 if result := a.Attach(context.Background(), project, "t1", "demo"); result.Error != nil {
355 t.Fatal(result.Error)
356 }
357 if runtime.GOOS == "windows" {
358 return // Windows does not expose POSIX permission bits through os.FileMode.
359 }
360
361 for path, want := range map[string]os.FileMode{
362 filepath.Join(project, ".reasonix", "tasks"): 0o700,
363 filepath.Join(project, ".reasonix", "tasks", ".tmux"): 0o700,
364 filepath.Join(project, ".reasonix", "tasks", ".tmux", "t1.json"): 0o600,
365 } {
366 info, err := os.Stat(path)
367 if err != nil {
368 t.Fatal(err)
369 }
370 if got := info.Mode().Perm(); got != want {
371 t.Errorf("%s mode = %o, want %o", path, got, want)
372 }
373 }
374 }
375
375 lines GO