Files
multica/server/pkg/agent/stderr_tail.go
Multica Eve ea8ccf3123 MUL-4903 fix(agent): harden Cursor terminal failures (#5559)
* fix(agent): harden Cursor terminal failures

Co-authored-by: multica-agent <github@multica.ai>

* fix(agent): preserve UTF-8 stderr tails

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-17 13:09:20 +08:00

110 lines
3.7 KiB
Go

package agent
import (
"io"
"regexp"
"strings"
"sync"
"github.com/multica-ai/multica/server/pkg/redact"
)
// agentStderrTailBytes bounds the stderr tail captured for inclusion in
// error messages when an agent CLI exits before emitting a structured
// error (e.g. V8 abort on Windows, Bun panic, OOM). Large enough to
// contain typical CLI error lines, small enough to stay sensible inside
// a task-level Result.Error string.
const agentStderrTailBytes = 2048
var (
agentAuthorizationHeaderRe = regexp.MustCompile(`(?im)(authorization\s*:\s*)[^\r\n]+`)
agentJSONSecretRe = regexp.MustCompile(`(?i)("(?:token|auth|authorization|api[_-]?key|secret|password)"\s*:\s*)"(?:\\.|[^"\\])*"`)
agentDiagnosticSecretRe = regexp.MustCompile(`(?i)(authorization|auth|api[_-]?key|token|secret|password)(\s*[:=]\s*)([^\s,;]+)`)
)
// sanitizeAgentDiagnostic removes terminal control characters, common secret
// shapes, and local home-directory details before a child-process diagnostic is
// persisted in Result.Error. stderr is still forwarded to the local daemon log;
// this helper protects the task row and user-visible failure comment.
func sanitizeAgentDiagnostic(value string) string {
value = strings.Map(func(r rune) rune {
if r < 0x20 && r != '\n' && r != '\t' {
return -1
}
return r
}, value)
value = agentAuthorizationHeaderRe.ReplaceAllString(value, `$1[REDACTED]`)
value = agentJSONSecretRe.ReplaceAllString(value, `$1"[REDACTED]"`)
value = agentDiagnosticSecretRe.ReplaceAllString(value, `$1$2[REDACTED]`)
return redact.Text(value)
}
// stderrTail forwards writes to an inner writer (typically the daemon's
// log) while also retaining a bounded tail of the bytes written. Consumers
// call Tail() to include that context in error messages when the agent
// process exits before it emits a structured error — otherwise all the
// user sees is "exit status N", with the real reason stuck in daemon logs.
//
// All backends that supervise a child CLI process should wire their
// cmd.Stderr through this type, and on failure include Tail() in
// Result.Error via withAgentStderr. That makes root-causing CLI crashes
// possible without having to crawl the daemon host's log files.
type stderrTail struct {
inner io.Writer
max int
mu sync.Mutex
buf []byte
total int64
}
func newStderrTail(inner io.Writer, max int) *stderrTail {
if max <= 0 {
max = agentStderrTailBytes
}
return &stderrTail{inner: inner, max: max}
}
func (s *stderrTail) Write(p []byte) (int, error) {
if _, err := s.inner.Write(p); err != nil {
return 0, err
}
s.mu.Lock()
s.total += int64(len(p))
s.buf = append(s.buf, p...)
if len(s.buf) > s.max {
s.buf = s.buf[len(s.buf)-s.max:]
}
s.mu.Unlock()
return len(p), nil
}
func (s *stderrTail) TotalBytes() int64 {
s.mu.Lock()
defer s.mu.Unlock()
return s.total
}
// Tail returns the captured stderr as valid UTF-8 with leading/trailing
// whitespace trimmed. A byte-bounded tail can start or end partway through a
// multi-byte rune, so invalid fragments are discarded before the diagnostic is
// persisted. The inner writer still receives every original byte verbatim.
// Empty string means nothing was written or no valid non-whitespace text was
// captured.
func (s *stderrTail) Tail() string {
s.mu.Lock()
defer s.mu.Unlock()
return strings.TrimSpace(strings.ToValidUTF8(string(s.buf), ""))
}
// withAgentStderr appends a stderr tail hint to an error message when
// non-empty, otherwise returns msg unchanged. The tail is prefixed with a
// short label so the composed string stays readable even when the original
// msg is already verbose.
func withAgentStderr(msg, label, tail string) string {
if tail == "" {
return msg
}
return msg + "; " + label + " stderr: " + tail
}