mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-11 02:46:45 +08:00
* 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]>
127 lines
4.5 KiB
Go
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
|
|
}
|
|
}
|