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