diff --git a/apps/backend/internal/gateway/websocket/terminal_handler.go b/apps/backend/internal/gateway/websocket/terminal_handler.go index a5d12737a4..a8721d43a7 100644 --- a/apps/backend/internal/gateway/websocket/terminal_handler.go +++ b/apps/backend/internal/gateway/websocket/terminal_handler.go @@ -9,6 +9,7 @@ import ( "io" "net/http" "net/url" + "regexp" "strconv" "strings" "sync" @@ -1054,6 +1055,27 @@ func (h *TerminalHandler) setupUserShellPtyAccess( return h.setupPtyAccessCommon(sessionID, processID, interactiveRunner, wsw, ptyWriter, getWriter) } +// terminalResponsePattern matches terminal query responses that should not be replayed. +// These are responses from the terminal emulator to queries sent by the shell, which +// appear as PTY output and get captured in the ring buffer. On replay they render as +// visible garbage text because they are response sequences, not display sequences. +// +// Matched sequences: +// - OSC 11 response: \x1b]11;rgb:XXXX/XXXX/XXXX ST (background color, ST = ESC\ or BEL) +// - DA1 response: \x1b[?c (device attributes) +// - CPR response: \x1b[;R or \x1b[R (cursor position report) +var terminalResponsePattern = regexp.MustCompile( + `\x1b]11;rgb:[0-9a-fA-F/]+(?:\x1b\\|\x07)` + // OSC 11 (ESC\ or BEL terminator) + `|\x1b\[\?[0-9;]*c` + // DA1 response + `|\x1b\[\d+(?:;\d+)?R`, // CPR response +) + +// stripTerminalResponses removes terminal query response sequences from data. +// Used to clean buffered PTY output before replaying it on reconnect. +func stripTerminalResponses(data []byte) []byte { + return terminalResponsePattern.ReplaceAll(data, nil) +} + // setupPtyAccessCommon is the shared implementation for setupPtyAccess and setupUserShellPtyAccess. // It gets a PTY writer via getWriter, sends buffered output, and sets up direct output. func (h *TerminalHandler) setupPtyAccessCommon( @@ -1080,14 +1102,18 @@ func (h *TerminalHandler) setupPtyAccessCommon( combined.WriteString(chunk.Data) } if combined.Len() > 0 { + cleaned := stripTerminalResponses(combined.Bytes()) h.logger.Debug("sending buffered output to terminal", zap.String("session_id", sessionID), zap.Int("chunks", len(chunks)), - zap.Int("total_bytes", combined.Len())) - if _, writeErr := wsw.Write(combined.Bytes()); writeErr != nil { - h.logger.Warn("failed to send buffered output", - zap.String("session_id", sessionID), - zap.Error(writeErr)) + zap.Int("total_bytes", combined.Len()), + zap.Int("cleaned_bytes", len(cleaned))) + if len(cleaned) > 0 { + if _, writeErr := wsw.Write(cleaned); writeErr != nil { + h.logger.Warn("failed to send buffered output", + zap.String("session_id", sessionID), + zap.Error(writeErr)) + } } } } diff --git a/apps/backend/internal/gateway/websocket/terminal_handler_test.go b/apps/backend/internal/gateway/websocket/terminal_handler_test.go index 423f2ea3d3..ff782d7104 100644 --- a/apps/backend/internal/gateway/websocket/terminal_handler_test.go +++ b/apps/backend/internal/gateway/websocket/terminal_handler_test.go @@ -1,6 +1,7 @@ package websocket import ( + "bytes" "net/http" "net/url" "testing" @@ -61,3 +62,86 @@ func TestCheckWebSocketOrigin(t *testing.T) { }) } } + +func TestStripTerminalResponses(t *testing.T) { + tests := []struct { + name string + input []byte + want []byte + }{ + { + name: "no sequences", + input: []byte("hello world\r\n$ "), + want: []byte("hello world\r\n$ "), + }, + { + name: "empty input", + input: []byte{}, + want: []byte{}, + }, + { + name: "OSC 11 response with ESC backslash", + input: []byte("\x1b]11;rgb:1f1f/1f1f/1f1f\x1b\\"), + want: []byte{}, + }, + { + name: "OSC 11 response with BEL", + input: []byte("\x1b]11;rgb:1f1f/1f1f/1f1f\x07"), + want: []byte{}, + }, + { + name: "DA1 response", + input: []byte("\x1b[?1;2c"), + want: []byte{}, + }, + { + name: "DA1 response with multiple params", + input: []byte("\x1b[?64;1;2;6;22c"), + want: []byte{}, + }, + { + name: "CPR response row;col", + input: []byte("\x1b[5;1R"), + want: []byte{}, + }, + { + name: "CPR response row only", + input: []byte("\x1b[1R"), + want: []byte{}, + }, + { + name: "only responses produces empty", + input: []byte("\x1b]11;rgb:1f1f/1f1f/1f1f\x1b\\\x1b[?1;2c\x1b[5;1R"), + want: []byte{}, + }, + { + name: "mixed content preserves normal output", + input: []byte("$ ls\r\nfile.txt\r\n\x1b]11;rgb:0000/0000/0000\x1b\\\x1b[?1;2c$ "), + want: []byte("$ ls\r\nfile.txt\r\n$ "), + }, + { + name: "sequences between normal text", + input: []byte("before\x1b[24;80Rafter"), + want: []byte("beforeafter"), + }, + { + name: "multiple OSC 11 responses", + input: []byte("\x1b]11;rgb:1f1f/1f1f/1f1f\x1b\\\x1b]11;rgb:ffff/ffff/ffff\x07"), + want: []byte{}, + }, + { + name: "preserves other escape sequences", + input: []byte("\x1b[32mgreen\x1b[0m \x1b[?1;2c normal"), + want: []byte("\x1b[32mgreen\x1b[0m normal"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := stripTerminalResponses(tt.input) + if !bytes.Equal(got, tt.want) { + t.Errorf("stripTerminalResponses(%q)\n got: %q\nwant: %q", tt.input, got, tt.want) + } + }) + } +}