Files
multica/server/internal/util/text.go
T
d6301091e0 MUL-6310 fix(agent tasks): stop NUL bytes wedging tasks on complete/messages/cancel (#7124)
* fix(agent tasks): stop NUL bytes wedging tasks on complete/messages/cancel

GH #7098 reported that a NUL in a failure diagnostic 500s POST
/api/daemon/tasks/{id}/fail and leaves the task stuck in 'running'.
PR #7115 fixes that one endpoint. Verifying the report turned up the
same defect on three more persistence paths, including the one users
are most likely to hit.

/complete is the important one. The whole request is marshalled into
agent_task_queue.result, a JSONB column, and encoding/json renders an
embedded NUL as a unicode escape that PostgreSQL rejects with SQLSTATE
22P05 - a different error from the TEXT case, same user-visible wedge.
Agent stdout carries a stray NUL far more readily than a curated error
string does: a tool that cats a binary, a Windows host emitting UTF-16,
a child process dumping memory as it crashes.

Nothing reaps the wedged row. Both sweepers (FailStaleTasks,
FailTasksForOfflineRuntimes) require the runtime to be offline, so a
healthy heartbeating daemon leaves the task in 'running' indefinitely,
holding one of the agent's max_concurrent_tasks slots forever.

Changes:

- util.SanitizeTextForPostgres - one shared definition of "safe to
  persist": remove NUL, replace other invalid UTF-8 with U+FFFD.
  handler.sanitizeNullBytes now delegates to it, so the task endpoints
  and the comment/skill/property endpoints cannot drift apart. This
  does change behaviour at those existing call sites: invalid UTF-8
  becomes U+FFFD instead of being dropped silently.
- util.SanitizeJSONForPostgres - depth-bounded walk over a decoded JSON
  tree, keys included. task_messages.input is JSONB, so a NUL nested in
  a tool's arguments breaks the insert even when every top-level string
  is clean.
- /complete: scrub right after decode, before the context-exhaustion
  re-route, so the failure classifier sees the text we will persist.
- /messages: scrub the strings and walk input. This endpoint has no
  daemon-side retry, so an unguarded batch is dropped silently.
- /cancel-ack and CancelTaskWithResult: same TEXT columns, same hazard.

Note for reviewers: strings.ToValidUTF8 alone does NOT solve this. NUL
is a valid UTF-8 encoding of U+0000 and passes straight through, which
is why the explicit ReplaceAll is load-bearing and has a test pinning
it.

Tests: unit coverage for both helpers, plus PostgreSQL-backed
regressions for the /complete JSONB payload and a NUL nested two levels
into task message input. Both include a control leg asserting the
database really does reject the unsanitized payload, so deleting the
sanitizer fails the test rather than silently passing.

Refs GH #7098, MUL-6310. Follow-up to #7115.

Co-authored-by: multica-agent <[email protected]>

* fix(agent tasks): sanitize messages.type; drive NUL tests through the handlers

Addresses both must-fix items from review.

1. TaskMessageRequest.Type was still written to a TEXT column unsanitized,
   so a NUL in `type` alone kept the whole batch 500ing and being dropped.
   Sanitized alongside tool/content/output, and covered by the entry test.

2. The two regression tests called the sanitizers by hand and then hit the
   query directly. That proved the helpers and the database behaviour but
   not the wiring: deleting the sanitize calls from CompleteTask /
   ReportTaskMessages left both tests green, which is the one regression
   they existed to catch.

   The positive branches now POST through testHandler.CompleteTask and
   testHandler.ReportTaskMessages via newDaemonTokenRequest, asserting HTTP
   200 plus the persisted row. The NUL travels the way the report describes
   -- escaped on the wire by encoding/json, rebuilt by the decoder -- so the
   handler sees exactly what a daemon would send.

   Verified by sabotage: commenting out the sanitize calls fails both tests
   with the reported errors (22P05 on /complete, "failed to persist task
   message" on /messages). Restored before commit.

   The direct-to-database rejection legs are kept and consolidated into
   TestPostgresRejectsUnsanitizedTaskPayloads, now also covering the TEXT
   `type` column, so the suite still proves PostgreSQL genuinely refuses
   these payloads rather than the fix being a no-op.

Seeding note: the fixture task now gets an issue, because
requireDaemonTaskAccess resolves the workspace through it and a task with no
issue / chat / autopilot link 404s before the handler reads the body.

Refs GH #7098, MUL-6310.

Co-authored-by: multica-agent <[email protected]>

* refactor(agent tasks): collapse normalizeTaskFailureDiagnostic into the shared helper

#7115 landed its own sanitizer at the FailTask service boundary while this
branch added util.SanitizeTextForPostgres for the remaining paths. The two
had identical semantics, which is exactly the drift this PR set out to
prevent: one definition of "safe to persist", not one per entry point.

FailTask now calls util.SanitizeTextForPostgres directly. The service-boundary
guard itself stays — failClaimedTaskBeforeLaunch reaches FailTask without
going through request decoding, so the handler-level scrub does not cover it.

Drops TestNormalizeTaskFailureDiagnostic, whose cases are a subset of
TestSanitizeTextForPostgres in internal/util. TestFailTaskSanitizesNULDiagnostic
is kept untouched: it drives the real FailTask against PostgreSQL and asserts
the task reaches 'failed' with the diagnostic intact, so it still guards the
wiring — and it passing against the shared helper is what demonstrates this
collapse preserved behaviour.

Refs GH #7098, MUL-6310.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-18 14:17:14 +08:00

127 lines
4.5 KiB
Go

package util
import (
"strings"
"unicode/utf8"
)
// UnescapeBackslashEscapes decodes the common backslash escape sequences
// (\n, \r, \t, \\) that LLM agents routinely emit as 4-character literals
// because Python/JSON-style string conventions are their default. The same
// helper is used by the CLI to fix bash-double-quote bodies (where the shell
// doesn't expand \n) and by the daemon-task completion path to fix raw agent
// stdout that arrives with literal `\n\n` between paragraphs.
//
// Only \n / \r / \t / \\ are decoded. Other escape sequences (\d, \w, \s,
// \u, \0, \", etc.) pass through verbatim so regex literals and printf
// format strings survive without surprise mutation. Callers that need the
// literal 4-char sequence intact should bypass this helper entirely (the CLI
// exposes --content-stdin / --description-stdin for that case).
func UnescapeBackslashEscapes(s string) string {
if !strings.ContainsRune(s, '\\') {
return s
}
var b strings.Builder
b.Grow(len(s))
for i := 0; i < len(s); i++ {
c := s[i]
if c == '\\' && i+1 < len(s) {
switch s[i+1] {
case 'n':
b.WriteByte('\n')
i++
continue
case 'r':
b.WriteByte('\r')
i++
continue
case 't':
b.WriteByte('\t')
i++
continue
case '\\':
b.WriteByte('\\')
i++
continue
}
}
b.WriteByte(c)
}
return b.String()
}
// sanitizeJSONMaxDepth bounds SanitizeJSONForPostgres's recursion. Task tool
// input is a handful of levels deep in practice; anything past this is either
// pathological or hostile, and is dropped rather than walked so a deeply
// nested payload cannot turn a persistence guard into a stack overflow.
const sanitizeJSONMaxDepth = 32
// SanitizeTextForPostgres makes an arbitrary externally-produced string safe to
// persist in a PostgreSQL TEXT or JSONB column.
//
// Two distinct failure modes, either of which aborts the entire INSERT and
// rolls the surrounding transaction back:
//
// - Embedded NUL (0x00). PostgreSQL rejects it in TEXT with SQLSTATE 22021
// ("invalid byte sequence for encoding UTF8"), and in JSONB with 22P05
// ("unsupported Unicode escape sequence") once encoding/json has escaped it.
// Removed outright: it has no useful rendering, and the caller wants the
// surrounding diagnostic far more than it wants an invisible byte.
// - Any other invalid UTF-8 byte sequence — a byte-bounded tail that cut a
// multi-byte rune in half, a Windows-1252 smart quote, raw binary from a
// child process that crashed. Replaced with U+FFFD so the text keeps its
// shape and a reader can see that something was there.
//
// IMPORTANT: strings.ToValidUTF8 alone does NOT cover the NUL case — 0x00 is a
// perfectly valid UTF-8 encoding of U+0000, so it passes through untouched.
// The explicit ReplaceAll is load-bearing. Do not "simplify" it away; that is
// exactly the gap that let GH #7098 wedge tasks in 'running'.
func SanitizeTextForPostgres(s string) string {
// Overwhelmingly the common case: leave ordinary text completely alone.
if !strings.ContainsRune(s, 0) && utf8.ValidString(s) {
return s
}
return strings.ReplaceAll(strings.ToValidUTF8(s, "\uFFFD"), "\x00", "")
}
// SanitizeJSONForPostgres walks a decoded JSON value — the `any` tree
// encoding/json produces — and applies SanitizeTextForPostgres to every string
// inside it, object keys included: a poisoned key breaks the INSERT exactly
// like a poisoned value does.
//
// This exists because sanitizing only the top-level string fields of a request
// is not enough when one of those fields is itself a JSONB blob (agent tool
// input, for instance), where the offending byte can sit at any depth.
//
// Subtrees deeper than sanitizeJSONMaxDepth are replaced with nil rather than
// walked. That is a deliberate, bounded loss: the alternative is letting an
// adversarial payload dictate our recursion depth.
func SanitizeJSONForPostgres(v any) any {
return sanitizeJSONValue(v, 0)
}
func sanitizeJSONValue(v any, depth int) any {
if depth > sanitizeJSONMaxDepth {
return nil
}
switch t := v.(type) {
case string:
return SanitizeTextForPostgres(t)
case map[string]any:
out := make(map[string]any, len(t))
for k, val := range t {
out[SanitizeTextForPostgres(k)] = sanitizeJSONValue(val, depth+1)
}
return out
case []any:
out := make([]any, len(t))
for i, val := range t {
out[i] = sanitizeJSONValue(val, depth+1)
}
return out
default:
// Numbers, bools, nil — nothing a TEXT/JSONB column can choke on.
return v
}
}