dify/dify-agent-runtime/internal/sanitize/sanitize.go
Yunlu Wen 302d7b1e1b
feat(agent): shellctl rewritten in go (#38841)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-15 01:58:36 +00:00

195 lines
3.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package sanitize implements a streaming PTY output sanitizer.
//
// It strips ANSI escape sequences (CSI, OSC), normalizes carriage-return
// progress updates into the final visible line, and performs incremental
// UTF-8 decoding—mirroring the Python shellctl_runtime/sanitize.py.
package sanitize
import (
"bufio"
"io"
"os"
"unicode/utf8"
)
// escapeState tracks the ANSI escape sequence parser state.
type escapeState int
const (
stateNormal escapeState = iota
stateEsc
stateCSI
stateOSC
stateOSCEsc
)
// PtySanitizer incrementally converts PTY bytes into stable, readable UTF-8.
type PtySanitizer struct {
lineBuffer []byte
pendingCR bool
state escapeState
}
// New returns a fresh PtySanitizer.
func New() *PtySanitizer {
return &PtySanitizer{}
}
// Feed consumes one chunk of decoded text and returns newly stable output.
func (s *PtySanitizer) Feed(text []byte) []byte {
var out []byte
for len(text) > 0 {
r, size := utf8.DecodeRune(text)
if r == utf8.RuneError && size <= 1 {
// Replace invalid byte with U+FFFD
text = text[1:]
r = utf8.RuneError
} else {
text = text[size:]
}
out = s.consumeRune(r, out)
}
return out
}
// Flush returns any remaining buffered content at end-of-stream.
func (s *PtySanitizer) Flush() []byte {
s.state = stateNormal
s.pendingCR = false
result := s.lineBuffer
s.lineBuffer = nil
return result
}
func (s *PtySanitizer) consumeRune(r rune, out []byte) []byte {
switch s.state {
case stateNormal:
if r == '\x1b' {
s.state = stateEsc
return out
}
return s.consumeVisible(r, out)
case stateEsc:
switch r {
case '[':
s.state = stateCSI
case ']':
s.state = stateOSC
default:
s.state = stateNormal
if r != '\x1b' && isPrintable(r) {
return s.consumeVisible(r, out)
}
}
return out
case stateCSI:
// CSI sequence ends at a byte in the range 0x400x7E
if r >= '@' && r <= '~' {
s.state = stateNormal
}
return out
case stateOSC:
switch r {
case '\x07':
s.state = stateNormal
case '\x1b':
s.state = stateOSCEsc
}
return out
case stateOSCEsc:
if r == '\\' {
s.state = stateNormal
} else {
s.state = stateOSC
}
return out
}
return out
}
func (s *PtySanitizer) consumeVisible(r rune, out []byte) []byte {
if s.pendingCR {
if r == '\n' {
out = append(out, s.lineBuffer...)
out = append(out, '\n')
s.lineBuffer = nil
s.pendingCR = false
return out
}
// CR without LF: overwrite line buffer (progress update)
s.lineBuffer = nil
s.pendingCR = false
}
if r == '\r' {
s.pendingCR = true
return out
}
if r == '\n' {
out = append(out, s.lineBuffer...)
out = append(out, '\n')
s.lineBuffer = nil
return out
}
// Append rune to line buffer
var buf [utf8.UTFMax]byte
n := utf8.EncodeRune(buf[:], r)
s.lineBuffer = append(s.lineBuffer, buf[:n]...)
return out
}
func isPrintable(r rune) bool {
// Match Python's str.isprintable: exclude C0/C1 control chars
return r >= 0x20 && r != 0x7f
}
// Run executes the streaming sanitizer as a stdin→stdout filter.
// If readyFile is non-empty, it is touched before reading begins.
func Run(readyFile string, stdin io.Reader, stdout io.Writer) error {
if readyFile != "" {
f, err := os.Create(readyFile)
if err != nil {
return err
}
_ = f.Close()
}
sanitizer := New()
reader := bufio.NewReaderSize(stdin, 65536)
writer := bufio.NewWriter(stdout)
defer func() { _ = writer.Flush() }()
buf := make([]byte, 65536)
for {
n, err := reader.Read(buf)
if n > 0 {
out := sanitizer.Feed(buf[:n])
if len(out) > 0 {
if _, werr := writer.Write(out); werr != nil {
return werr
}
_ = writer.Flush()
}
}
if err != nil {
if err == io.EOF {
break
}
return err
}
}
tail := sanitizer.Flush()
if len(tail) > 0 {
if _, err := writer.Write(tail); err != nil {
return err
}
}
return writer.Flush()
}