返回 DeepSeek-Reasonix
tmux.go
1 package taskmonitor
2
3 import (
4 "context"
5 "crypto/rand"
6 "crypto/sha256"
7 "encoding/hex"
8 "encoding/json"
9 "errors"
10 "fmt"
11 "os"
12 "os/exec"
13 "path/filepath"
14 "strings"
15 "time"
16
17 "reasonix/internal/proc"
18 )
19
20 // TmuxRunner is the narrow command surface used by Adapter. Implementations
21 // must pass arguments as an array; callers never construct a shell command.
22 type TmuxRunner interface {
23 Run(ctx context.Context, args ...string) ([]byte, error)
24 }
25
26 type execTmuxRunner struct{ binary string }
27
28 func (r execTmuxRunner) Run(ctx context.Context, args ...string) ([]byte, error) {
29 cmd := proc.CommandContext(ctx, r.binary, args...)
30 return cmd.Output()
31 }
32
33 // Mapping records only resources created by this adapter.
34 type TmuxMapping struct {
35 SchemaVersion int `json:"schema_version"`
36 TaskID string `json:"task_id"`
37 ProjectDir string `json:"project_dir"`
38 Session string `json:"session"`
39 Window string `json:"window"`
40 Pane string `json:"pane"`
41 OwnerToken string `json:"owner_token,omitempty"`
42 CreatedAt time.Time `json:"created_at"`
43 Stale bool `json:"stale"`
44 }
45
46 type TmuxResult struct {
47 SchemaVersion int `json:"schema_version"`
48 TaskID string `json:"task_id"`
49 Available bool `json:"available"`
50 Idempotent bool `json:"idempotent"`
51 Mapping *TmuxMapping `json:"mapping,omitempty"`
52 Error *CtrlError `json:"error,omitempty"`
53 }
54
55 // TmuxAdapter maps tasks to user-visible tmux windows. It never changes task
56 // state; the Task Store remains the sole source of truth.
57 type TmuxAdapter struct {
58 store Store
59 runner TmuxRunner
60 base string
61 }
62
63 func NewTmuxAdapter(store Store, baseDir string) *TmuxAdapter {
64 return &TmuxAdapter{store: store, runner: newDefaultTmuxRunner(), base: baseDir}
65 }
66
67 func NewTmuxAdapterWithRunner(store Store, baseDir string, runner TmuxRunner) *TmuxAdapter {
68 return &TmuxAdapter{store: store, runner: runner, base: baseDir}
69 }
70
71 func newDefaultTmuxRunner() TmuxRunner {
72 path, err := exec.LookPath("tmux")
73 if err != nil {
74 return nil
75 }
76 return execTmuxRunner{binary: path}
77 }
78
79 func (a *TmuxAdapter) Attach(ctx context.Context, projectDir, taskID, requestedSession string) TmuxResult {
80 if err := validateTmuxName(requestedSession); err != nil {
81 return tmuxError(taskID, ErrTmuxInvalidName, err.Error())
82 }
83 snap, err := a.store.GetTask(ctx, projectDir, taskID)
84 if err != nil {
85 return tmuxError(taskID, ErrTmuxTaskError, "task lookup failed")
86 }
87 if snap == nil {
88 return tmuxError(taskID, ErrTaskNotFound, "task not found")
89 }
90 if a.runner == nil {
91 return tmuxUnavailable(taskID)
92 }
93 old, err := a.load(projectDir, taskID)
94 if err != nil {
95 return tmuxError(taskID, ErrTmuxMappingFailed, "mapping read failed")
96 }
97 if old != nil {
98 if err := a.validateMapping(projectDir, taskID, old); err != nil {
99 if err := a.removeMapping(projectDir, taskID); err != nil {
100 return tmuxError(taskID, ErrTmuxMappingFailed, "invalid mapping removal failed")
101 }
102 } else if !old.Stale && a.ownsSession(ctx, old) {
103 return TmuxResult{SchemaVersion: 1, TaskID: taskID, Available: true, Idempotent: true, Mapping: old}
104 } else {
105 old.Stale = true
106 if err := a.save(projectDir, *old); err != nil {
107 return tmuxError(taskID, ErrTmuxMappingFailed, "mapping update failed")
108 }
109 }
110 }
111 session := requestedSession
112 if session == "" {
113 session = defaultTmuxSessionName(taskID)
114 }
115 if err := validateTmuxName(session); err != nil {
116 return tmuxError(taskID, ErrTmuxInvalidName, err.Error())
117 }
118 ownerToken, err := newTmuxOwnerToken()
119 if err != nil {
120 return tmuxError(taskID, ErrTmuxMappingFailed, "mapping ownership creation failed")
121 }
122 window := "task"
123 if _, err := a.runner.Run(ctx, "new-session", "-d", "-s", session, "-n", window); err != nil {
124 return tmuxError(taskID, ErrTmuxCommandFailed, "tmux session creation failed")
125 }
126 m := &TmuxMapping{SchemaVersion: 1, TaskID: taskID, ProjectDir: projectDir, Session: session, Window: window, Pane: session + ":" + window + ".0", OwnerToken: ownerToken, CreatedAt: time.Now().UTC()}
127 if _, err := a.runner.Run(ctx, "set-option", "-t", tmuxSessionPaneTarget(session), tmuxOwnerOption, ownerToken); err != nil {
128 // set-option may have reached the tmux server even when the client
129 // reports an error. Clean up only through the ownership-checked command.
130 _ = a.killOwnedSession(ctx, m)
131 return tmuxError(taskID, ErrTmuxCommandFailed, "tmux ownership marker failed")
132 }
133 if err := a.save(projectDir, *m); err != nil {
134 _ = a.killOwnedSession(ctx, m)
135 return tmuxError(taskID, ErrTmuxMappingFailed, "mapping write failed")
136 }
137 return TmuxResult{SchemaVersion: 1, TaskID: taskID, Available: true, Mapping: m}
138 }
139
140 func (a *TmuxAdapter) Status(ctx context.Context, projectDir, taskID string) TmuxResult {
141 m, err := a.load(projectDir, taskID)
142 if err != nil {
143 return tmuxError(taskID, ErrTmuxMappingFailed, "mapping read failed")
144 }
145 if m == nil {
146 return TmuxResult{SchemaVersion: 1, TaskID: taskID, Available: a.runner != nil}
147 }
148 if err := a.validateMapping(projectDir, taskID, m); err != nil {
149 return tmuxError(taskID, ErrTmuxMappingFailed, "mapping ownership validation failed")
150 }
151 if a.runner == nil {
152 m.Stale = true
153 return TmuxResult{SchemaVersion: 1, TaskID: taskID, Available: false, Mapping: m}
154 }
155 if !a.ownsSession(ctx, m) {
156 m.Stale = true
157 if err := a.save(projectDir, *m); err != nil {
158 return tmuxError(taskID, ErrTmuxMappingFailed, "mapping update failed")
159 }
160 }
161 return TmuxResult{SchemaVersion: 1, TaskID: taskID, Available: true, Mapping: m}
162 }
163
164 func (a *TmuxAdapter) Open(ctx context.Context, projectDir, taskID string) TmuxResult {
165 r := a.Status(ctx, projectDir, taskID)
166 if r.Mapping == nil || r.Mapping.Stale {
167 return r
168 }
169 if a.runner == nil {
170 return tmuxUnavailable(taskID)
171 }
172 if _, err := a.runner.Run(ctx, "switch-client", "-t", r.Mapping.Pane); err != nil {
173 r.Error = &CtrlError{Code: ErrTmuxCommandFailed, Message: "tmux open failed"}
174 }
175 return r
176 }
177
178 func (a *TmuxAdapter) Detach(ctx context.Context, projectDir, taskID string) TmuxResult {
179 m, err := a.load(projectDir, taskID)
180 if err != nil {
181 return tmuxError(taskID, ErrTmuxMappingFailed, "mapping read failed")
182 }
183 if m == nil {
184 return TmuxResult{SchemaVersion: 1, TaskID: taskID, Available: a.runner != nil, Idempotent: true}
185 }
186 if err := a.validateMapping(projectDir, taskID, m); err != nil {
187 if err := a.removeMapping(projectDir, taskID); err != nil {
188 return tmuxError(taskID, ErrTmuxMappingFailed, "invalid mapping removal failed")
189 }
190 return tmuxError(taskID, ErrTmuxMappingFailed, "mapping ownership validation failed")
191 }
192 if a.runner != nil && !m.Stale {
193 if a.ownsSession(ctx, m) {
194 if err := a.killOwnedSession(ctx, m); err != nil && a.ownsSession(ctx, m) {
195 return tmuxError(taskID, ErrTmuxCommandFailed, "tmux detach failed")
196 }
197 if a.ownsSession(ctx, m) {
198 return tmuxError(taskID, ErrTmuxCommandFailed, "tmux detach failed")
199 }
200 } else {
201 m.Stale = true
202 }
203 }
204 if err := a.removeMapping(projectDir, taskID); err != nil {
205 return tmuxError(taskID, ErrTmuxMappingFailed, "mapping removal failed")
206 }
207 return TmuxResult{SchemaVersion: 1, TaskID: taskID, Available: a.runner != nil, Idempotent: false, Mapping: m}
208 }
209
210 const (
211 tmuxOwnerOption = "@reasonix-owner"
212 tmuxOwnerTokenBytes = 16
213 defaultTmuxNamePrefix = "reasonix-"
214
215 ErrTmuxUnavailable = "tmux_unavailable"
216 ErrTmuxInvalidName = "tmux_invalid_name"
217 ErrTmuxCommandFailed = "tmux_command_failed"
218 ErrTmuxMappingFailed = "tmux_mapping_failed"
219 ErrTmuxTaskError = "tmux_task_error"
220 )
221
222 func defaultTmuxSessionName(taskID string) string {
223 candidate := defaultTmuxNamePrefix + taskID
224 if len(candidate) <= 64 {
225 return candidate
226 }
227 sum := sha256.Sum256([]byte(taskID))
228 return defaultTmuxNamePrefix + hex.EncodeToString(sum[:16])
229 }
230
231 func newTmuxOwnerToken() (string, error) {
232 raw := make([]byte, tmuxOwnerTokenBytes)
233 if _, err := rand.Read(raw); err != nil {
234 return "", err
235 }
236 return hex.EncodeToString(raw), nil
237 }
238
239 func tmuxUnavailable(taskID string) TmuxResult {
240 return tmuxError(taskID, ErrTmuxUnavailable, "tmux is not available")
241 }
242
243 func tmuxError(taskID, code, message string) TmuxResult {
244 return TmuxResult{SchemaVersion: 1, TaskID: taskID, Error: &CtrlError{Code: code, Message: message}}
245 }
246
247 func validateTmuxName(name string) error {
248 if name == "" {
249 return nil
250 }
251 if len(name) > 64 {
252 return errors.New("tmux name contains invalid characters")
253 }
254 for _, r := range name {
255 if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.') {
256 return errors.New("tmux name contains invalid characters")
257 }
258 }
259 return nil
260 }
261
262 func (a *TmuxAdapter) validateMapping(projectDir, taskID string, m *TmuxMapping) error {
263 if m == nil || m.SchemaVersion != 1 || m.TaskID != taskID {
264 return errors.New("tmux mapping identity mismatch")
265 }
266 if err := validateTmuxName(m.Session); err != nil || m.Session == "" || m.Window != "task" || m.Pane != m.Session+":"+m.Window+".0" {
267 return errors.New("tmux mapping target is invalid")
268 }
269 if len(m.OwnerToken) != tmuxOwnerTokenBytes*2 {
270 return errors.New("tmux mapping owner token is missing")
271 }
272 if _, err := hex.DecodeString(m.OwnerToken); err != nil {
273 return errors.New("tmux mapping owner token is invalid")
274 }
275 wantRoot, err := NewFileStore(a.base).taskRoot(projectDir)
276 if err != nil {
277 return err
278 }
279 gotRoot, err := NewFileStore(a.base).taskRoot(m.ProjectDir)
280 if err != nil {
281 return err
282 }
283 wantRoot, err = filepath.Abs(wantRoot)
284 if err != nil {
285 return err
286 }
287 gotRoot, err = filepath.Abs(gotRoot)
288 if err != nil {
289 return err
290 }
291 if filepath.Clean(gotRoot) != filepath.Clean(wantRoot) {
292 return errors.New("tmux mapping project mismatch")
293 }
294 return nil
295 }
296
297 func (a *TmuxAdapter) ownsSession(ctx context.Context, m *TmuxMapping) bool {
298 if a.runner == nil || m == nil {
299 return false
300 }
301 out, err := a.runner.Run(ctx, "show-options", "-v", "-t", tmuxSessionPaneTarget(m.Session), tmuxOwnerOption)
302 return err == nil && strings.TrimSpace(string(out)) == m.OwnerToken
303 }
304
305 // killOwnedSession performs the ownership comparison and destructive action in
306 // one tmux server command queue. A separate show-options + kill-session pair
307 // would allow the named session to be replaced between the check and the kill.
308 func (a *TmuxAdapter) killOwnedSession(ctx context.Context, m *TmuxMapping) error {
309 if a.runner == nil || m == nil {
310 return nil
311 }
312 condition := fmt.Sprintf("#{==:#{%s},%s}", tmuxOwnerOption, m.OwnerToken)
313 killCommand := "kill-session -t =" + m.Session
314 _, err := a.runner.Run(ctx, "if-shell", "-t", tmuxSessionPaneTarget(m.Session), "-F", condition, killCommand, "")
315 return err
316 }
317
318 func tmuxSessionPaneTarget(session string) string {
319 return "=" + session + ":"
320 }
321
322 func (a *TmuxAdapter) removeMapping(projectDir, taskID string) error {
323 path, err := a.mappingPath(projectDir, taskID)
324 if err != nil {
325 return err
326 }
327 if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
328 return fmt.Errorf("remove tmux mapping: %w", err)
329 }
330 return nil
331 }
332
333 func (a *TmuxAdapter) mappingPath(projectDir, taskID string) (string, error) {
334 id, err := safeID(taskID)
335 if err != nil {
336 return "", err
337 }
338 root, err := NewFileStore(a.base).taskRoot(projectDir)
339 if err != nil {
340 return "", err
341 }
342 path := filepath.Join(root, ".tmux", id+".json")
343 if err := rejectSymlinkChain(root, path); err != nil {
344 return "", err
345 }
346 return path, nil
347 }
348
349 func (a *TmuxAdapter) load(projectDir, taskID string) (*TmuxMapping, error) {
350 path, err := a.mappingPath(projectDir, taskID)
351 if err != nil {
352 return nil, err
353 }
354 b, err := os.ReadFile(path)
355 if os.IsNotExist(err) {
356 return nil, nil
357 }
358 if err != nil {
359 return nil, err
360 }
361 var m TmuxMapping
362 if err := json.Unmarshal(b, &m); err != nil {
363 return nil, err
364 }
365 return &m, nil
366 }
367
368 func (a *TmuxAdapter) save(projectDir string, m TmuxMapping) error {
369 path, err := a.mappingPath(projectDir, m.TaskID)
370 if err != nil {
371 return err
372 }
373 root, err := NewFileStore(a.base).taskRoot(projectDir)
374 if err != nil {
375 return err
376 }
377 if _, err := prepareTaskDir(root, ".tmux"); err != nil {
378 return err
379 }
380 b, err := json.Marshal(m)
381 if err != nil {
382 return err
383 }
384 tmp, err := os.CreateTemp(filepath.Dir(path), ".tmux-*.tmp")
385 if err != nil {
386 return err
387 }
388 name := tmp.Name()
389 defer os.Remove(name)
390 if _, err = tmp.Write(b); err != nil {
391 _ = tmp.Close()
392 return err
393 }
394 if err = tmp.Sync(); err != nil {
395 _ = tmp.Close()
396 return err
397 }
398 if err = tmp.Close(); err != nil {
399 return err
400 }
401 if err = os.Rename(name, path); err != nil {
402 return err
403 }
404 return nil
405 }
406
406 lines GO