Repository navigation
fix: clip untrusted text without splitting a surrogate pair - #208
rahul05ranjan wants to merge 1 commit into
Conversation
Mail and page text is clipped at UTF-16 limits on its way into tool results, evidence and notifications, so a cut that lands between the two halves of an astral character leaves a lone high surrogate: not a character, rendered as U+FFFD and rejected by consumers that encode the value to UTF-8. The calendar tool and sanitizeFileName already guard this; the new clipText helper gives the same guarantee to every untrusted-text clip site (mail snippet and thread body, search and web evidence, read_web text, monitor observations and notices). Fixes CopilotKit#207 Co-authored-by: rahul05ranjan <11793455+rahul05ranjan@users.noreply.github.com>
|
Checked this out locally:
Typecheck and the new tests pass. |
|
The surrogate fix is right — guarding the cut on a trailing high surrogate covers every clip site in one helper. One scope gap worth deciding on before merge: clipText only repairs lone halves the clip itself creates. The inputs are untrusted, and a page or mail body that already contains a lone surrogate in the middle (malformed upstream, bad copy/paste) passes through untouched: there is no cut to trip the regex, so it still lands in evidence and storage. A consumer that then encodes to UTF-8 fails the same way this PR fixes — Postgres rejects the insert rather than storing it. If those inputs are in scope, normalizing at the boundary is cheaper than guarding each sink: |
What changed
Untrusted mail and page text is clipped at UTF-16 limits on its way into tool results, recorded evidence and notifications. A cut that lands between the two halves of an astral character (any emoji outside the BMP) leaves a lone high surrogate at the end of the value — not a character: the UI renders U+FFFD, and any consumer that encodes the value to UTF-8 (Python services reading the same records) fails on it.
#207has the one-line reproduction.Two places already guard exactly this —
clipCalendarTextinconversation.tsandsanitizeFileNameinfiles.ts("Slice code points, not UTF-16 units, so a surrogate pair is never split in half"), and #183 fixed the related surrogate-reference case for the mail list. This PR moves the guard into one sharedclipTexthelper (apps/server/src/text.ts) and applies it to the clip sites that carry untrusted text:engine/conversation.ts—search_mailsnippet (240) andread_mail_threadbody (12000), replacing the localclipCalendarTextwhose three calendar call sites now use the helper too;engine/model.ts— search evidence excerpt (500),read_webevidence excerpt (500),read_webreturned text (30000), the recorded agent update event (12000);engine/service.ts—mailEvidenceexcerpt (400), monitorlastValue(1000), observation events (1000), result text (500), notice body (240), monitor evidence excerpt (600);jev/service.ts— recorded page text (30000);browser.ts— returned page text (30000).Behavior is otherwise identical: the same limits, the same values, plus one extra character dropped when — and only when — the cut would split a pair.
Verification
tests/clip-text.test.ts(node:test, in the existingtests/suite): six cases — short/BMP strings unchanged, a cut landing on a pair drops the high half with the low half (asserted to end ona, regex-checked not to end on a lone high surrogate), a pair that fits is kept, an exact-pair boundary is exact, and a string of exactly the limit is unchanged. All 6 pass locally on this host (node --experimental-strip-types --test tests/clip-text.test.ts).node --experimental-strip-types --check.("a".repeat(239) + "\u{1F44D}").slice(0, 240)ends on\uD83D(lone half,charCodeAt(239) === 0xd83d);clipTexton the same input ends ona.pnpm test/pnpm lint/pnpm typecheckwere not run here (no pnpm install on this machine); CI is the full-suite run. No provider credentials are involved — the change is pure string handling, verified without any backend.Integration limits
None.