blob: 8a43fce1068be4035686a01fb2c83fbbfc126c92 [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001// Package server provides HTTP server functionality for the sketch loop.
2package server
3
4import (
Sean McCulloughbaa2b592025-04-23 10:40:08 -07005 "context"
Earl Lee2e463fb2025-04-17 11:22:22 -07006 "encoding/base64"
7 "encoding/json"
8 "fmt"
9 "html"
10 "io"
11 "io/fs"
12 "log/slog"
13 "net/http"
14 "net/http/pprof"
15 "os"
16 "os/exec"
17 "strconv"
18 "strings"
19 "sync"
20 "syscall"
21 "time"
22
Philip Zeyliger176de792025-04-21 12:25:18 -070023 "sketch.dev/loop/server/gzhandler"
24
Earl Lee2e463fb2025-04-17 11:22:22 -070025 "github.com/creack/pty"
26 "sketch.dev/ant"
27 "sketch.dev/loop"
28 "sketch.dev/loop/webui"
29)
30
31// terminalSession represents a terminal session with its PTY and the event channel
32type terminalSession struct {
33 pty *os.File
34 eventsClients map[chan []byte]bool
35 lastEventClientID int
36 eventsClientsMutex sync.Mutex
37 cmd *exec.Cmd
38}
39
40// TerminalMessage represents a message sent from the client for terminal resize events
41type TerminalMessage struct {
42 Type string `json:"type"`
43 Cols uint16 `json:"cols"`
44 Rows uint16 `json:"rows"`
45}
46
47// TerminalResponse represents the response for a new terminal creation
48type TerminalResponse struct {
49 SessionID string `json:"sessionId"`
50}
51
Sean McCulloughd9f13372025-04-21 15:08:49 -070052type State struct {
53 MessageCount int `json:"message_count"`
54 TotalUsage *ant.CumulativeUsage `json:"total_usage,omitempty"`
Sean McCulloughd9f13372025-04-21 15:08:49 -070055 InitialCommit string `json:"initial_commit"`
56 Title string `json:"title"`
Philip Zeyligerd1402952025-04-23 03:54:37 +000057 Hostname string `json:"hostname"` // deprecated
58 WorkingDir string `json:"working_dir"` // deprecated
59 OS string `json:"os"` // deprecated
60 GitOrigin string `json:"git_origin,omitempty"`
61
62 HostHostname string `json:"host_hostname,omitempty"`
63 RuntimeHostname string `json:"runtime_hostname,omitempty"`
64 HostOS string `json:"host_os,omitempty"`
65 RuntimeOS string `json:"runtime_os,omitempty"`
66 HostWorkingDir string `json:"host_working_dir,omitempty"`
67 RuntimeWorkingDir string `json:"runtime_working_dir,omitempty"`
Sean McCulloughd9f13372025-04-21 15:08:49 -070068}
69
Sean McCulloughbaa2b592025-04-23 10:40:08 -070070type InitRequest struct {
71 HostAddr string `json:"host_addr"`
72 GitRemoteAddr string `json:"git_remote_addr"`
73 Commit string `json:"commit"`
74 SSHAuthorizedKeys []byte `json:"ssh_authorized_keys"`
75 SSHServerIdentity []byte `json:"ssh_server_identity"`
76}
77
Earl Lee2e463fb2025-04-17 11:22:22 -070078// Server serves sketch HTTP. Server implements http.Handler.
79type Server struct {
80 mux *http.ServeMux
81 agent loop.CodingAgent
82 hostname string
83 logFile *os.File
84 // Mutex to protect terminalSessions
85 ptyMutex sync.Mutex
86 terminalSessions map[string]*terminalSession
87}
88
89func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
90 s.mux.ServeHTTP(w, r)
91}
92
93// New creates a new HTTP server.
94func New(agent loop.CodingAgent, logFile *os.File) (*Server, error) {
95 s := &Server{
96 mux: http.NewServeMux(),
97 agent: agent,
98 hostname: getHostname(),
99 logFile: logFile,
100 terminalSessions: make(map[string]*terminalSession),
101 }
102
103 webBundle, err := webui.Build()
104 if err != nil {
105 return nil, fmt.Errorf("failed to build web bundle, did you run 'go generate sketch.dev/loop/...'?: %w", err)
106 }
107
108 s.mux.HandleFunc("/diff", func(w http.ResponseWriter, r *http.Request) {
109 // Check if a specific commit hash was requested
110 commit := r.URL.Query().Get("commit")
111
112 // Get the diff, optionally for a specific commit
113 var diff string
114 var err error
115 if commit != "" {
116 // Validate the commit hash format
117 if !isValidGitSHA(commit) {
118 http.Error(w, fmt.Sprintf("Invalid git commit SHA format: %s", commit), http.StatusBadRequest)
119 return
120 }
121
122 diff, err = agent.Diff(&commit)
123 } else {
124 diff, err = agent.Diff(nil)
125 }
126
127 if err != nil {
128 http.Error(w, fmt.Sprintf("Error generating diff: %v", err), http.StatusInternalServerError)
129 return
130 }
131
132 w.Header().Set("Content-Type", "text/plain")
133 w.Write([]byte(diff))
134 })
135
136 // Handler for initialization called by host sketch binary when inside docker.
137 s.mux.HandleFunc("/init", func(w http.ResponseWriter, r *http.Request) {
138 defer func() {
139 if err := recover(); err != nil {
140 slog.ErrorContext(r.Context(), "/init panic", slog.Any("recovered_err", err))
141
142 // Return an error response to the client
143 http.Error(w, fmt.Sprintf("panic: %v\n", err), http.StatusInternalServerError)
144 }
145 }()
146
147 if r.Method != "POST" {
148 http.Error(w, "POST required", http.StatusBadRequest)
149 return
150 }
151
152 body, err := io.ReadAll(r.Body)
153 r.Body.Close()
154 if err != nil {
155 http.Error(w, "failed to read request body: "+err.Error(), http.StatusBadRequest)
156 return
157 }
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700158
159 m := &InitRequest{}
160 if err := json.Unmarshal(body, m); err != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700161 http.Error(w, "bad request body: "+err.Error(), http.StatusBadRequest)
162 return
163 }
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700164
165 // Start the SSH server if the init request included ssh keys.
166 if len(m.SSHAuthorizedKeys) > 0 && len(m.SSHServerIdentity) > 0 {
167 go func() {
168 ctx := context.Background()
169 if err := s.ServeSSH(ctx, m.SSHServerIdentity, m.SSHAuthorizedKeys); err != nil {
170 slog.ErrorContext(r.Context(), "/init ServeSSH", slog.String("err", err.Error()))
171 }
172 }()
173 }
174
Earl Lee2e463fb2025-04-17 11:22:22 -0700175 ini := loop.AgentInit{
176 WorkingDir: "/app",
177 InDocker: true,
Sean McCulloughbaa2b592025-04-23 10:40:08 -0700178 Commit: m.Commit,
179 GitRemoteAddr: m.GitRemoteAddr,
180 HostAddr: m.HostAddr,
Earl Lee2e463fb2025-04-17 11:22:22 -0700181 }
182 if err := agent.Init(ini); err != nil {
183 http.Error(w, "init failed: "+err.Error(), http.StatusInternalServerError)
184 return
185 }
186 w.Header().Set("Content-Type", "application/json")
187 io.WriteString(w, "{}\n")
188 })
189
190 // Handler for /messages?start=N&end=M (start/end are optional)
191 s.mux.HandleFunc("/messages", func(w http.ResponseWriter, r *http.Request) {
192 w.Header().Set("Content-Type", "application/json")
193
194 // Extract query parameters for range
195 var start, end int
196 var err error
197
198 currentCount := agent.MessageCount()
199
200 startParam := r.URL.Query().Get("start")
201 if startParam != "" {
202 start, err = strconv.Atoi(startParam)
203 if err != nil {
204 http.Error(w, "Invalid 'start' parameter", http.StatusBadRequest)
205 return
206 }
207 }
208
209 endParam := r.URL.Query().Get("end")
210 if endParam != "" {
211 end, err = strconv.Atoi(endParam)
212 if err != nil {
213 http.Error(w, "Invalid 'end' parameter", http.StatusBadRequest)
214 return
215 }
216 } else {
217 end = currentCount
218 }
219
220 if start < 0 || start > end || end > currentCount {
221 http.Error(w, fmt.Sprintf("Invalid range: start %d end %d currentCount %d", start, end, currentCount), http.StatusBadRequest)
222 return
223 }
224
225 start = max(0, start)
226 end = min(agent.MessageCount(), end)
227 messages := agent.Messages(start, end)
228
229 // Create a JSON encoder with indentation for pretty-printing
230 encoder := json.NewEncoder(w)
231 encoder.SetIndent("", " ") // Two spaces for each indentation level
232
233 err = encoder.Encode(messages)
234 if err != nil {
235 http.Error(w, err.Error(), http.StatusInternalServerError)
236 }
237 })
238
239 // Handler for /logs - displays the contents of the log file
240 s.mux.HandleFunc("/logs", func(w http.ResponseWriter, r *http.Request) {
241 if s.logFile == nil {
242 http.Error(w, "log file not set", http.StatusNotFound)
243 return
244 }
245 logContents, err := os.ReadFile(s.logFile.Name())
246 if err != nil {
247 http.Error(w, "error reading log file: "+err.Error(), http.StatusInternalServerError)
248 return
249 }
250 w.Header().Set("Content-Type", "text/html; charset=utf-8")
251 fmt.Fprintf(w, "<!DOCTYPE html>\n<html>\n<head>\n<title>Sketchy Log File</title>\n</head>\n<body>\n")
252 fmt.Fprintf(w, "<pre>%s</pre>\n", html.EscapeString(string(logContents)))
253 fmt.Fprintf(w, "</body>\n</html>")
254 })
255
256 // Handler for /download - downloads both messages and status as a JSON file
257 s.mux.HandleFunc("/download", func(w http.ResponseWriter, r *http.Request) {
258 // Set headers for file download
259 w.Header().Set("Content-Type", "application/octet-stream")
260
261 // Generate filename with format: sketch-YYYYMMDD-HHMMSS.json
262 timestamp := time.Now().Format("20060102-150405")
263 filename := fmt.Sprintf("sketch-%s.json", timestamp)
264
265 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
266
267 // Get all messages
268 messageCount := agent.MessageCount()
269 messages := agent.Messages(0, messageCount)
270
271 // Get status information (usage and other metadata)
272 totalUsage := agent.TotalUsage()
273 hostname := getHostname()
274 workingDir := getWorkingDir()
275
276 // Create a combined structure with all information
277 downloadData := struct {
278 Messages []loop.AgentMessage `json:"messages"`
279 MessageCount int `json:"message_count"`
280 TotalUsage ant.CumulativeUsage `json:"total_usage"`
281 Hostname string `json:"hostname"`
282 WorkingDir string `json:"working_dir"`
283 DownloadTime string `json:"download_time"`
284 }{
285 Messages: messages,
286 MessageCount: messageCount,
287 TotalUsage: totalUsage,
288 Hostname: hostname,
289 WorkingDir: workingDir,
290 DownloadTime: time.Now().Format(time.RFC3339),
291 }
292
293 // Marshal the JSON with indentation for better readability
294 jsonData, err := json.MarshalIndent(downloadData, "", " ")
295 if err != nil {
296 http.Error(w, err.Error(), http.StatusInternalServerError)
297 return
298 }
299 w.Write(jsonData)
300 })
301
302 // The latter doesn't return until the number of messages has changed (from seen
303 // or from when this was called.)
304 s.mux.HandleFunc("/state", func(w http.ResponseWriter, r *http.Request) {
305 pollParam := r.URL.Query().Get("poll")
306 seenParam := r.URL.Query().Get("seen")
307
308 // Get the client's current message count (if provided)
309 clientMessageCount := -1
310 var err error
311 if seenParam != "" {
312 clientMessageCount, err = strconv.Atoi(seenParam)
313 if err != nil {
314 http.Error(w, "Invalid 'seen' parameter", http.StatusBadRequest)
315 return
316 }
317 }
318
319 serverMessageCount := agent.MessageCount()
320
321 // Let lazy clients not have to specify this.
322 if clientMessageCount == -1 {
323 clientMessageCount = serverMessageCount
324 }
325
326 if pollParam == "true" {
327 ch := make(chan string)
328 go func() {
329 // This is your blocking operation
330 agent.WaitForMessageCount(r.Context(), clientMessageCount)
331 close(ch)
332 }()
333 select {
334 case <-r.Context().Done():
335 slog.DebugContext(r.Context(), "abandoned poll request")
336 return
337 case <-time.After(90 * time.Second):
338 // Let the user call /state again to get the latest to limit how long our long polls hang out.
339 slog.DebugContext(r.Context(), "longish poll request")
340 break
341 case <-ch:
342 break
343 }
344 }
345
346 serverMessageCount = agent.MessageCount()
347 totalUsage := agent.TotalUsage()
348
349 w.Header().Set("Content-Type", "application/json")
350
Sean McCulloughd9f13372025-04-21 15:08:49 -0700351 state := State{
Philip Zeyligerd1402952025-04-23 03:54:37 +0000352 MessageCount: serverMessageCount,
353 TotalUsage: &totalUsage,
354 Hostname: s.hostname,
355 WorkingDir: getWorkingDir(),
356 InitialCommit: agent.InitialCommit(),
357 Title: agent.Title(),
358 OS: agent.OS(),
359 HostHostname: agent.HostHostname(),
360 RuntimeHostname: s.hostname,
361 HostOS: agent.HostOS(),
362 RuntimeOS: agent.OS(),
363 HostWorkingDir: agent.HostWorkingDir(),
364 RuntimeWorkingDir: getWorkingDir(),
365 GitOrigin: agent.GitOrigin(),
Earl Lee2e463fb2025-04-17 11:22:22 -0700366 }
367
368 // Create a JSON encoder with indentation for pretty-printing
369 encoder := json.NewEncoder(w)
370 encoder.SetIndent("", " ") // Two spaces for each indentation level
371
372 err = encoder.Encode(state)
373 if err != nil {
374 http.Error(w, err.Error(), http.StatusInternalServerError)
375 }
376 })
377
Philip Zeyliger176de792025-04-21 12:25:18 -0700378 s.mux.Handle("/static/", http.StripPrefix("/static/", gzhandler.New(webBundle)))
Earl Lee2e463fb2025-04-17 11:22:22 -0700379
380 // Terminal WebSocket handler
381 // Terminal endpoints - predefined terminals 1-9
382 // TODO: The UI doesn't actually know how to use terminals 2-9!
383 s.mux.HandleFunc("/terminal/events/", func(w http.ResponseWriter, r *http.Request) {
384 if r.Method != http.MethodGet {
385 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
386 return
387 }
388 pathParts := strings.Split(r.URL.Path, "/")
389 if len(pathParts) < 4 {
390 http.Error(w, "Invalid terminal ID", http.StatusBadRequest)
391 return
392 }
393
394 sessionID := pathParts[3]
395 // Validate that the terminal ID is between 1-9
396 if len(sessionID) != 1 || sessionID[0] < '1' || sessionID[0] > '9' {
397 http.Error(w, "Terminal ID must be between 1 and 9", http.StatusBadRequest)
398 return
399 }
400
401 s.handleTerminalEvents(w, r, sessionID)
402 })
403
404 s.mux.HandleFunc("/terminal/input/", func(w http.ResponseWriter, r *http.Request) {
405 if r.Method != http.MethodPost {
406 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
407 return
408 }
409 pathParts := strings.Split(r.URL.Path, "/")
410 if len(pathParts) < 4 {
411 http.Error(w, "Invalid terminal ID", http.StatusBadRequest)
412 return
413 }
414 sessionID := pathParts[3]
415 s.handleTerminalInput(w, r, sessionID)
416 })
417
418 s.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
Sean McCullough86b56862025-04-18 13:04:03 -0700419 // Serve the sketch-app-shell.html file directly from the embedded filesystem
420 data, err := fs.ReadFile(webBundle, "sketch-app-shell.html")
Earl Lee2e463fb2025-04-17 11:22:22 -0700421 if err != nil {
422 http.Error(w, "File not found", http.StatusNotFound)
423 return
424 }
425 w.Header().Set("Content-Type", "text/html")
426 w.Write(data)
427 })
428
429 // Handler for POST /chat
430 s.mux.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) {
431 if r.Method != http.MethodPost {
432 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
433 return
434 }
435
436 // Parse the request body
437 var requestBody struct {
438 Message string `json:"message"`
439 }
440
441 decoder := json.NewDecoder(r.Body)
442 if err := decoder.Decode(&requestBody); err != nil {
443 http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
444 return
445 }
446 defer r.Body.Close()
447
448 if requestBody.Message == "" {
449 http.Error(w, "Message cannot be empty", http.StatusBadRequest)
450 return
451 }
452
453 agent.UserMessage(r.Context(), requestBody.Message)
454
455 w.WriteHeader(http.StatusOK)
456 })
457
458 // Handler for /cancel - cancels the current inner loop in progress
459 s.mux.HandleFunc("/cancel", func(w http.ResponseWriter, r *http.Request) {
460 if r.Method != http.MethodPost {
461 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
462 return
463 }
464
465 // Parse the request body (optional)
466 var requestBody struct {
467 Reason string `json:"reason"`
468 ToolCallID string `json:"tool_call_id"`
469 }
470
471 decoder := json.NewDecoder(r.Body)
472 if err := decoder.Decode(&requestBody); err != nil && err != io.EOF {
473 http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
474 return
475 }
476 defer r.Body.Close()
477
478 cancelReason := "user requested cancellation"
479 if requestBody.Reason != "" {
480 cancelReason = requestBody.Reason
481 }
482
483 if requestBody.ToolCallID != "" {
484 err := agent.CancelToolUse(requestBody.ToolCallID, fmt.Errorf("%s", cancelReason))
485 if err != nil {
486 http.Error(w, err.Error(), http.StatusBadRequest)
487 return
488 }
489 // Return a success response
490 w.Header().Set("Content-Type", "application/json")
491 json.NewEncoder(w).Encode(map[string]string{
492 "status": "cancelled",
493 "too_use_id": requestBody.ToolCallID,
Philip Zeyliger8d50d7b2025-04-23 13:12:40 -0700494 "reason": cancelReason,
495 })
Earl Lee2e463fb2025-04-17 11:22:22 -0700496 return
497 }
498 // Call the CancelInnerLoop method
499 agent.CancelInnerLoop(fmt.Errorf("%s", cancelReason))
500 // Return a success response
501 w.Header().Set("Content-Type", "application/json")
502 json.NewEncoder(w).Encode(map[string]string{"status": "cancelled", "reason": cancelReason})
503 })
504
505 debugMux := initDebugMux()
506 s.mux.HandleFunc("/debug/", func(w http.ResponseWriter, r *http.Request) {
507 debugMux.ServeHTTP(w, r)
508 })
509
510 return s, nil
511}
512
513// Utility functions
514func getHostname() string {
515 hostname, err := os.Hostname()
516 if err != nil {
517 return "unknown"
518 }
519 return hostname
520}
521
522func getWorkingDir() string {
523 wd, err := os.Getwd()
524 if err != nil {
525 return "unknown"
526 }
527 return wd
528}
529
530// createTerminalSession creates a new terminal session with the given ID
531func (s *Server) createTerminalSession(sessionID string) (*terminalSession, error) {
532 // Start a new shell process
533 shellPath := getShellPath()
534 cmd := exec.Command(shellPath)
535
536 // Get working directory from the agent if possible
537 workDir := getWorkingDir()
538 cmd.Dir = workDir
539
540 // Set up environment
541 cmd.Env = append(os.Environ(), "TERM=xterm-256color")
542
543 // Start the command with a pty
544 ptmx, err := pty.Start(cmd)
545 if err != nil {
546 slog.Error("Failed to start pty", "error", err)
547 return nil, err
548 }
549
550 // Create the terminal session
551 session := &terminalSession{
552 pty: ptmx,
553 eventsClients: make(map[chan []byte]bool),
554 cmd: cmd,
555 }
556
557 // Start goroutine to read from pty and broadcast to all connected SSE clients
558 go s.readFromPtyAndBroadcast(sessionID, session)
559
560 return session, nil
561} // handleTerminalEvents handles SSE connections for terminal output
562func (s *Server) handleTerminalEvents(w http.ResponseWriter, r *http.Request, sessionID string) {
563 // Check if the session exists, if not, create it
564 s.ptyMutex.Lock()
565 session, exists := s.terminalSessions[sessionID]
566
567 if !exists {
568 // Create a new terminal session
569 var err error
570 session, err = s.createTerminalSession(sessionID)
571 if err != nil {
572 s.ptyMutex.Unlock()
573 http.Error(w, fmt.Sprintf("Failed to create terminal: %v", err), http.StatusInternalServerError)
574 return
575 }
576
577 // Store the new session
578 s.terminalSessions[sessionID] = session
579 }
580 s.ptyMutex.Unlock()
581
582 // Set headers for SSE
583 w.Header().Set("Content-Type", "text/event-stream")
584 w.Header().Set("Cache-Control", "no-cache")
585 w.Header().Set("Connection", "keep-alive")
586 w.Header().Set("Access-Control-Allow-Origin", "*")
587
588 // Create a channel for this client
589 events := make(chan []byte, 4096) // Buffer to prevent blocking
590
591 // Register this client's channel
592 session.eventsClientsMutex.Lock()
593 clientID := session.lastEventClientID + 1
594 session.lastEventClientID = clientID
595 session.eventsClients[events] = true
596 session.eventsClientsMutex.Unlock()
597
598 // When the client disconnects, remove their channel
599 defer func() {
600 session.eventsClientsMutex.Lock()
601 delete(session.eventsClients, events)
602 close(events)
603 session.eventsClientsMutex.Unlock()
604 }()
605
606 // Flush to send headers to client immediately
607 if f, ok := w.(http.Flusher); ok {
608 f.Flush()
609 }
610
611 // Send events to the client as they arrive
612 for {
613 select {
614 case <-r.Context().Done():
615 return
616 case data := <-events:
617 // Format as SSE with base64 encoding
618 fmt.Fprintf(w, "data: %s\n\n", base64.StdEncoding.EncodeToString(data))
619
620 // Flush the data immediately
621 if f, ok := w.(http.Flusher); ok {
622 f.Flush()
623 }
624 }
625 }
626}
627
628// handleTerminalInput processes input to the terminal
629func (s *Server) handleTerminalInput(w http.ResponseWriter, r *http.Request, sessionID string) {
630 // Check if the session exists
631 s.ptyMutex.Lock()
632 session, exists := s.terminalSessions[sessionID]
633 s.ptyMutex.Unlock()
634
635 if !exists {
636 http.Error(w, "Terminal session not found", http.StatusNotFound)
637 return
638 }
639
640 // Read the request body (terminal input or resize command)
641 body, err := io.ReadAll(r.Body)
642 if err != nil {
643 http.Error(w, "Failed to read request body", http.StatusBadRequest)
644 return
645 }
646
647 // Check if it's a resize message
648 if len(body) > 0 && body[0] == '{' {
649 var msg TerminalMessage
650 if err := json.Unmarshal(body, &msg); err == nil && msg.Type == "resize" {
651 if msg.Cols > 0 && msg.Rows > 0 {
652 pty.Setsize(session.pty, &pty.Winsize{
653 Cols: msg.Cols,
654 Rows: msg.Rows,
655 })
656
657 // Respond with success
658 w.WriteHeader(http.StatusOK)
659 return
660 }
661 }
662 }
663
664 // Regular terminal input
665 _, err = session.pty.Write(body)
666 if err != nil {
667 slog.Error("Failed to write to pty", "error", err)
668 http.Error(w, "Failed to write to terminal", http.StatusInternalServerError)
669 return
670 }
671
672 // Respond with success
673 w.WriteHeader(http.StatusOK)
674}
675
676// readFromPtyAndBroadcast reads output from the PTY and broadcasts it to all connected clients
677func (s *Server) readFromPtyAndBroadcast(sessionID string, session *terminalSession) {
678 buf := make([]byte, 4096)
679 defer func() {
680 // Clean up when done
681 s.ptyMutex.Lock()
682 delete(s.terminalSessions, sessionID)
683 s.ptyMutex.Unlock()
684
685 // Close the PTY
686 session.pty.Close()
687
688 // Ensure process is terminated
689 if session.cmd.Process != nil {
690 session.cmd.Process.Signal(syscall.SIGTERM)
691 time.Sleep(100 * time.Millisecond)
692 session.cmd.Process.Kill()
693 }
694
695 // Close all client channels
696 session.eventsClientsMutex.Lock()
697 for ch := range session.eventsClients {
698 delete(session.eventsClients, ch)
699 close(ch)
700 }
701 session.eventsClientsMutex.Unlock()
702 }()
703
704 for {
705 n, err := session.pty.Read(buf)
706 if err != nil {
707 if err != io.EOF {
708 slog.Error("Failed to read from pty", "error", err)
709 }
710 break
711 }
712
713 // Make a copy of the data for each client
714 data := make([]byte, n)
715 copy(data, buf[:n])
716
717 // Broadcast to all connected clients
718 session.eventsClientsMutex.Lock()
719 for ch := range session.eventsClients {
720 // Try to send, but don't block if channel is full
721 select {
722 case ch <- data:
723 default:
724 // Channel is full, drop the message for this client
725 }
726 }
727 session.eventsClientsMutex.Unlock()
728 }
729}
730
731// getShellPath returns the path to the shell to use
732func getShellPath() string {
733 // Try to use the user's preferred shell
734 shell := os.Getenv("SHELL")
735 if shell != "" {
736 return shell
737 }
738
739 // Default to bash on Unix-like systems
740 if _, err := os.Stat("/bin/bash"); err == nil {
741 return "/bin/bash"
742 }
743
744 // Fall back to sh
745 return "/bin/sh"
746}
747
748func initDebugMux() *http.ServeMux {
749 mux := http.NewServeMux()
750 mux.HandleFunc("GET /debug/{$}", func(w http.ResponseWriter, r *http.Request) {
751 w.Header().Set("Content-Type", "text/html; charset=utf-8")
752 fmt.Fprintf(w, `<!doctype html>
753 <html><head><title>sketch debug</title></head><body>
754 <h1>sketch debug</h1>
755 <ul>
756 <li><a href="/debug/pprof/cmdline">pprof/cmdline</a></li>
757 <li><a href="/debug/pprof/profile">pprof/profile</a></li>
758 <li><a href="/debug/pprof/symbol">pprof/symbol</a></li>
759 <li><a href="/debug/pprof/trace">pprof/trace</a></li>
760 <li><a href="/debug/pprof/goroutine?debug=1">pprof/goroutine?debug=1</a></li>
761 <li><a href="/debug/metrics">metrics</a></li>
762 </ul>
763 </body>
764 </html>
765 `)
766 })
767 mux.HandleFunc("GET /debug/pprof/", pprof.Index)
768 mux.HandleFunc("GET /debug/pprof/cmdline", pprof.Cmdline)
769 mux.HandleFunc("GET /debug/pprof/profile", pprof.Profile)
770 mux.HandleFunc("GET /debug/pprof/symbol", pprof.Symbol)
771 mux.HandleFunc("GET /debug/pprof/trace", pprof.Trace)
772 return mux
773}
774
775// isValidGitSHA validates if a string looks like a valid git SHA hash.
776// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
777func isValidGitSHA(sha string) bool {
778 // Git SHA must be a hexadecimal string with at least 4 characters
779 if len(sha) < 4 || len(sha) > 40 {
780 return false
781 }
782
783 // Check if the string only contains hexadecimal characters
784 for _, char := range sha {
785 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
786 return false
787 }
788 }
789
790 return true
791}