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