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