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