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