package services import ( "context" "encoding/json" "fmt" "io" "net/http" "sync" "clawreef/internal/services/k8s" "github.com/gorilla/websocket" corev1 "k8s.io/api/core/v1" "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/tools/remotecommand" ) // InstanceShellService streams an interactive shell into an instance pod. type InstanceShellService struct { podService *k8s.PodService upgrader websocket.Upgrader } type shellClientMessage struct { Type string `json:"type"` Data string `json:"data,omitempty"` Cols uint16 `json:"cols,omitempty"` Rows uint16 `json:"rows,omitempty"` } type shellTerminalSizeQueue struct { sizes chan remotecommand.TerminalSize mu sync.Mutex closed bool } type shellWebSocketWriter struct { conn *websocket.Conn mu *sync.Mutex done <-chan struct{} } func NewInstanceShellService() *InstanceShellService { return &InstanceShellService{ podService: k8s.NewPodService(), upgrader: websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { return true }, }, } } func (s *InstanceShellService) Stream(ctx context.Context, userID, instanceID int, w http.ResponseWriter, r *http.Request) error { if s.podService == nil || s.podService.GetClient() == nil || s.podService.GetClient().Clientset == nil { return fmt.Errorf("k8s client not initialized") } conn, err := s.upgrader.Upgrade(w, r, nil) if err != nil { return fmt.Errorf("failed to upgrade shell websocket: %w", err) } streamCtx, cancel := context.WithCancel(ctx) defer cancel() writeMu := &sync.Mutex{} done := make(chan struct{}) defer close(done) defer conn.Close() stdinReader, stdinWriter := io.Pipe() defer stdinReader.Close() defer stdinWriter.Close() sizeQueue := newShellTerminalSizeQueue() defer sizeQueue.close() sizeQueue.push(120, 32) go readShellWebSocket(streamCtx, cancel, conn, stdinWriter, sizeQueue) pod, err := s.podService.GetPod(streamCtx, userID, instanceID) if err != nil { _ = writeShellBytes(conn, writeMu, done, []byte(fmt.Sprintf("\r\nfailed to get pod: %v\r\n", err))) return fmt.Errorf("failed to get pod: %w", err) } req := s.podService.GetClient().Clientset.CoreV1().RESTClient().Post(). Resource("pods"). Name(pod.Name). Namespace(pod.Namespace). SubResource("exec") req.VersionedParams(&corev1.PodExecOptions{ Container: "desktop", Command: defaultShellCommand(), Stdin: true, Stdout: true, Stderr: false, TTY: true, }, scheme.ParameterCodec) exec, err := remotecommand.NewSPDYExecutor(s.podService.GetClient().Config, "POST", req.URL()) if err != nil { _ = writeShellBytes(conn, writeMu, done, []byte(fmt.Sprintf("\r\nfailed to initialize shell: %v\r\n", err))) return fmt.Errorf("failed to initialize shell stream: %w", err) } writer := &shellWebSocketWriter{conn: conn, mu: writeMu, done: done} if err := exec.StreamWithContext(streamCtx, remotecommand.StreamOptions{ Stdin: stdinReader, Stdout: writer, Tty: true, TerminalSizeQueue: sizeQueue, }); err != nil && streamCtx.Err() == nil { _ = writeShellBytes(conn, writeMu, done, []byte(fmt.Sprintf("\r\nshell closed: %v\r\n", err))) return fmt.Errorf("shell stream failed: %w", err) } return nil } func defaultShellCommand() []string { return []string{ "sh", "-lc", `export TERM="${TERM:-xterm-256color}" COLORTERM="${COLORTERM:-truecolor}"; if command -v tmux >/dev/null 2>&1; then session="${CLAWMANAGER_TMUX_SESSION:-openclaw-shell}"; exec tmux new-session -A -s "$session"; fi; if command -v bash >/dev/null 2>&1; then exec bash -l; fi; exec sh`, } } func newShellTerminalSizeQueue() *shellTerminalSizeQueue { return &shellTerminalSizeQueue{ sizes: make(chan remotecommand.TerminalSize, 4), } } func (q *shellTerminalSizeQueue) Next() *remotecommand.TerminalSize { size, ok := <-q.sizes if !ok { return nil } return &size } func (q *shellTerminalSizeQueue) push(cols, rows uint16) { if q == nil || cols == 0 || rows == 0 { return } q.mu.Lock() defer q.mu.Unlock() if q.closed { return } size := remotecommand.TerminalSize{Width: cols, Height: rows} select { case q.sizes <- size: default: select { case <-q.sizes: default: } select { case q.sizes <- size: default: } } } func (q *shellTerminalSizeQueue) close() { if q == nil { return } q.mu.Lock() defer q.mu.Unlock() if !q.closed { q.closed = true close(q.sizes) } } func readShellWebSocket(ctx context.Context, cancel context.CancelFunc, conn *websocket.Conn, stdin *io.PipeWriter, sizeQueue *shellTerminalSizeQueue) { defer cancel() defer stdin.Close() for { select { case <-ctx.Done(): return default: } messageType, payload, err := conn.ReadMessage() if err != nil { _ = stdin.CloseWithError(err) return } switch messageType { case websocket.BinaryMessage: if len(payload) > 0 { _, _ = stdin.Write(payload) } case websocket.TextMessage: var message shellClientMessage if json.Unmarshal(payload, &message) == nil && message.Type != "" { switch message.Type { case "input": if message.Data != "" { _, _ = io.WriteString(stdin, message.Data) } case "resize": sizeQueue.push(message.Cols, message.Rows) } continue } if len(payload) > 0 { _, _ = stdin.Write(payload) } } } } func (w *shellWebSocketWriter) Write(payload []byte) (int, error) { if err := writeShellBytes(w.conn, w.mu, w.done, payload); err != nil { return 0, err } return len(payload), nil } func writeShellBytes(conn *websocket.Conn, mu *sync.Mutex, done <-chan struct{}, payload []byte) error { select { case <-done: return io.ErrClosedPipe default: } mu.Lock() defer mu.Unlock() return conn.WriteMessage(websocket.BinaryMessage, payload) }