mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-24 11:10:25 +02:00
* 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>
110 lines
3.7 KiB
Go
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
|
|
}
|