fix(coderd/x/chatd/chatprompt): keep NUL out of jsonb chat content - #28625
Merged
Conversation
PostgreSQL jsonb rejects \u0000 and lone UTF-16 surrogate escapes, so persisting Gemini responses failed with "pq: unsupported Unicode escape sequence". Gemini binary thought signatures reach ProviderMetadata as strings containing NUL, and the existing sentinel codec only covered six fields. Classify every string-bearing ChatMessagePart field in one production table, partNulFields. Free-form and opaque fields (text, args, results, provider metadata) encode NUL reversibly as PUA sentinel pairs; structured fields (identifiers, paths, URLs, media types, commands) reject NUL with an error naming the part, field, and byte offset, because their downstream consumers cannot handle it. Raw JSON is also normalized for lone surrogate escapes, gated by a lexical detector and json.Valid so malformed input passes through untouched instead of being truncated by a lenient decode. A reflection-driven coverage test injects NUL and natural sentinels into every field and fails actionably when a future field is missing from the table, so additions are default-deny. Content version 1 cannot distinguish codec sentinels from identical natural text stored before a field was covered; such historical values decode as NUL. Signatures with other invalid UTF-8 are still corrupted upstream by json.Marshal (CODAGT-986), and debug step inserts still fail non-fatally on NUL text (CODAGT-987). Fixes #25555 Refs CODAGT-463
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
…n jsonb The Gemini regression previously proved absence of \u0000 and a clean round-trip. Add positive physical evidence: the stored jsonb row contains exactly one U+E000 U+E001 sentinel pair per NUL byte.
This comment was marked as outdated.
This comment was marked as outdated.
mafredri
marked this pull request as ready for review
August 26, 2026 13:22
This comment was marked as outdated.
This comment was marked as outdated.
ibetitsmike
approved these changes
Aug 26, 2026
Contributor
|
Cherry-pick PR created: #28695 |
mtojek
added a commit
that referenced
this pull request
Aug 27, 2026
…28625) (#28695) Cherry-pick of #28625 Original PR: #28625 — fix(coderd/x/chatd/chatprompt): keep NUL out of jsonb chat content Merge commit: c51ffc7 Requested by: @mafredri Co-authored-by: Mathias Fredriksson <[email protected]> Co-authored-by: Marcin Tojek <[email protected]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #25555
Refs CODAGT-463
Why
PostgreSQL jsonb rejects
\u0000and lone UTF-16 surrogate escapes. Gemini binary thought signatures reachChatMessagePart.ProviderMetadataas strings containing NUL (fantasy convertsThoughtSignature []bytewithstring(...)), and the existing sentinel codec covered only six fields, so persisting Gemini tool-use steps failed withpq: unsupported Unicode escape sequence.Approach
One production table,
partNulFields, classifies every string-bearingChatMessagePartfield:MarshalPartserrors, naming the part, field, and byte offset, because downstream consumers (syscalls,url.Parse) cannot handle NUL and persisting an encoded form would only hide invalid input.Raw JSON is additionally normalized for lone surrogate escapes (to U+FFFD; PostgreSQL cannot store them), gated by a read-only lexical detector plus
json.Validso clean or malformed input passes through byte-identical instead of being reformatted or truncated by a lenient decode.A reflection-driven coverage test injects NUL and natural-sentinel probes into every field through the public
MarshalParts/ParseContentAPI. A future string-bearing field that is missing from the table fails the test with instructions; verified by mutation (removing a table entry or no-op'ing the encoder fails the corresponding subtests). Two regressions run the real flows end to end against PostgreSQL: a GeminiReasoningMetadatasignature with binary NUL, and tool args with lone/paired/escaped surrogates.A/B proof at the production insert statement
The same Gemini payload (
ReasoningMetadatasignature containing binary NUL, built viaPartFromContentandMarshalParts) was inserted through the productionInsertChatMessagesquery against real PostgreSQL on both sides:64d2d8a108(unfixed): fails withpq: unsupported Unicode escape sequence, the exact leaf error in the customer's log.\u0000, andConvertMessagesWithFilesrestores the byte-exact signature.The committed Gemini regression now asserts the stored-row sentinel evidence directly (raw
SELECT content::text).The payload is representative of real traffic, not just synthetic: fantasy's recorded Gemini API cassettes (
providertests/testdata/TestGoogleCommon) contain 34 real Google-issued thought signatures; 22 contain NUL bytes and all 34 are invalid UTF-8 when cast to a Go string. Repeating the A/B with one of those real signatures gives the same result: the customer's exact error on base, successful sentinel-encoded persistence with the NUL escape restored on decode on this branch.E2E verification (dev server on this branch, real Gemini key)
The reported repro was run live through the gateway path: a
gemini-3.5-flashchat executedwhich workspaces are available(tool call, tool result, answer) and a follow-up turn replayed the persisted history. Both turns completed; zerounsupported Unicode escape sequenceerrors in the server log. Note the live run could not exercise the signature vector itself: on this branch chat generation always routes through the AI gateway's OpenAI-compat wire, where signatures travel as base64 text intool_calls[].extra_content.google.thought_signature(with Google's documented dummy-signature fallback injected bygoogleopenai.AddThoughtSignaturesToLatestTurn). Base64 text cannot contain binary NUL, so the customer's exact payload cannot arrive via chat anymore; the binary vector remains reachable only through nativefantasygooglepaths. The NUL/surrogate class is exercised against real PostgreSQL by the regression tests instead.Known limitations
json.Marshalupstream of this codec; root fix belongs in fantasy (Signatureshould be[]byte). Tracked in CODAGT-986 and scoped to paths using the nativefantasygoogleclient: current chat generation routes exclusively through the AI gateway on the OpenAI-compat wire, which carries no thought signatures at all.encodeNulInParts; probability negligible.jsontype removes the codec but breaksjsonb_array_elements,jsonb_typeof,@>, and content search inchats.sql(verified against Postgres 13).Implementation plan
CODAGT-463: Complete the existing V1 field codec
Direction for approval
Outcome
Gemini tool-use steps and other chat messages persist successfully when any
codersdk.ChatMessagePartstring contains NUL or any raw JSON field contains PostgreSQL-invalid Unicode escapes.Recommended direction
Make the smallest extension of the existing V1 design:
encodeNulInPartsanddecodeNulInPartsas direct, handwritten traversal.json.RawMessagedecode, walk, and re-marshal implementation, but activate it for NUL, escaped or literal U+E000, and unpaired surrogate escapes.ChatMessagePartfield throughMarshalPartsandParseContent.There is no production registry, reflection, code generation, whole-document transform, content-version change, or migration.
Reason
The production fix is ordinary field assignment and one loop for
ParsedCommands. The test supplies the durability: a new direct string, raw JSON field, or nested string container is automatically populated with a probe value and fails by field name if the production traversal does not encode and decode it.This retains the existing, understood V1 boundary and limits lexical JSON logic to detection. Actual raw JSON transformation continues to use
encoding/jsonrather than a handwritten mutating parser.Observable end state
Args,Result, andProviderMetadataencode NUL in keys and values.ParsedCommands [][]stringis encoded without mutating caller-owned slices.Decisions and constraints
ContentVersionV1remains current.Ruled-out directions
Implementation detail
1. Complete the handwritten traversal
Update
encodeNulInPartsanddecodeNulInPartsincoderd/x/chatd/chatprompt/chatprompt.go.Apply the string codec to every direct or named string field on
codersdk.ChatMessagePart, including the discriminant and all tool, source, file, context-file, and skill strings.Apply the raw JSON codec to:
ArgsResultProviderMetadataHandle
ParsedCommands [][]stringwith typed nested loops. Encoding must clone the outer and inner slices before changing strings. Decoding may operate in place afterjson.Unmarshal.Leave scalar booleans, integers, UUID values, timestamps, and
[]byteunchanged.2. Extend raw JSON candidate detection
Keep
encodeNulInJSONbased onjson.Unmarshal, recursive semantic transformation, andjson.Marshal.Replace its current incomplete fast-path check with a read-only detector that recognizes actual JSON string content requiring the slow path:
\u0000The detector must account for JSON string boundaries and escaped backslashes. It must not mutate JSON. Valid adjacent surrogate pairs alone do not require transformation.
When the detector reports a candidate:
json.Unmarshalsupplies the semantic value and normalizes lone surrogates to U+FFFD;json.Marshalproduces PostgreSQL-safe JSON.Keep
decodeNulInJSONunchanged except for applying it toProviderMetadatathrough the completed part traversal.3. Add automatic field coverage
Add
TestChatMessagePartNULCoveragein the internalchatprompttest package.For each reflected
ChatMessagePartfield, run a subtest named after that field:json.RawMessagereceives valid nested JSON with NUL and natural sentinel values in keys and values.ParsedCommands.[]byteare recognized as safe and skipped.Each transforming subtest must:
MarshalPartson a value with only that field populated.\u0000.ParseContentand compare the named field with its original value.A missing traversal assignment must fail in the subtest for that field. An unsupported future shape must fail with guidance equivalent to:
This test has no production field-name list and no parallel test registry to maintain.
4. Add focused regressions
Extend
chatprompttests with:fantasygoogle.ReasoningMetadatawhose signature contains binary NUL, serialized throughProviderMetadata;\\u0000and\\uD800remaining literal text;jsonbinsertion and read-back for Gemini metadata and raw tool arguments.5. Verification
Run:
Verify the original failure path: a Gemini-shaped tool-use step containing a binary thought signature marshals, inserts through
InsertChatMessages, and parses successfully.Invariants
ChatMessagePart.Post-review revision (2026-08-26)
Applied after a fresh critique and a second independent review:
partNulFields, mapping every string-bearingChatMessagePartfield tonulEncodeornulRejectwith pointer accessors shared by encode, decode, and validate. Removing an entry failsTestChatMessagePartNULCoverage(proven by mutation).MarshalPartswraps rejection as "validate chat message parts" and marshal failure as "marshal chat message parts".Residual limitations for the PR body
Signature stringbecoming[]byte.encodeNulInParts; probability negligible).jsontype would delete the codec but breakjsonb_array_elements,jsonb_typeof,@>, and content search in chats.sql (verified against Postgres 13).