Skip to content

fix(gateway): truncateCloseReason drops partial UTF-8 code point instead of emitting mojibake#100047

Merged
vincentkoc merged 3 commits into
openclaw:mainfrom
NarahariRaghava:fix/truncate-close-reason-utf8
Jul 5, 2026
Merged

fix(gateway): truncateCloseReason drops partial UTF-8 code point instead of emitting mojibake#100047
vincentkoc merged 3 commits into
openclaw:mainfrom
NarahariRaghava:fix/truncate-close-reason-utf8

Conversation

@NarahariRaghava

@NarahariRaghava NarahariRaghava commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

What Problem This Solves

truncateCloseReason truncated WebSocket close reasons at a raw byte boundary using Buffer.subarray(0, maxBytes). When maxBytes fell inside a multi-byte UTF-8 sequence, Node decoded the dangling continuation bytes as U+FFFD replacement characters each 3 bytes wide. This meant:

  1. The re-encoded result could exceed maxBytes, breaking the RFC 6455 123-byte close-reason limit.
  2. Clients received garbled close reason text containing 😀 characters.

Why This Change Was Made

The fix backs up from maxBytes to the nearest UTF-8 sequence start before slicing. UTF-8 continuation bytes match the bit pattern 10xxxxxx (byte & 0xC0 === 0x80); we skip them to land on a sequence-start byte, then slice cleanly.

User Impact

WebSocket close reasons are now guaranteed to stay within maxBytes when re-encoded and never contain replacement characters. The RFC 6455 123-byte limit is reliably enforced for callers using the 120-byte default.

Evidence

Repro (from issue):

// Before fix
const out = truncateCloseReason("x".repeat(118) + "😀".repeat(5));
Buffer.byteLength(out); // 121 — exceeds 120-byte cap
out.includes(""); // true — mojibake

// After fix
Buffer.byteLength(out); // 118 — within cap
out.includes(""); // false

Tests added in src/gateway/server/close-reason.test.ts (new file, 7 cases):

  • ASCII-only truncation still cuts at exactly maxBytes
  • 4-byte emoji cut: was 121 bytes, now 118
  • 2-byte Latin extended cut (é)
  • 3-byte BMP cut (✓)
  • Custom maxBytes respected
Test Files  4 passed (4)
     Tests  28 passed (28)
  Duration  1.00s

Closes #99976

@openclaw-barnacle openclaw-barnacle Bot added gateway Gateway runtime size: S labels Jul 4, 2026
@NarahariRaghava

Copy link
Copy Markdown
Contributor Author

Closing — will re-open manually.

@clawsweeper

clawsweeper Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Codex review: needs maintainer review before merge. Reviewed July 4, 2026, 8:21 PM ET / 00:21 UTC.

Summary
The PR changes the gateway close-reason helper to truncate at UTF-8 byte boundaries and adds Vitest coverage for empty, ASCII, multibyte, and custom byte-cap cases.

PR surface: Source +7, Tests +58. Total +65 across 2 files.

Reproducibility: yes. A focused read-only Node check reproduces current-main behavior as 121 bytes with U+FFFD for the reported input and shows the PR logic returning 118 bytes without replacement characters.

Review metrics: none identified.

Root-cause cluster
Relationship: fixed_by_candidate
Canonical: #99976
Summary: The linked issue is the canonical reproduced bug; this PR is the active candidate fix, while the prior competing PR is closed unmerged.

Members:

Proposal only: this assessment does not dispatch repair, suppress jobs, mutate sibling items, close, or merge anything.

Merge readiness
Overall: 🐚 platinum hermit
Proof: 🦞 diamond lobster ✨ media proof bonus
Patch quality: 🐚 platinum hermit
Result: ready for maintainer review.

Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch.

Rank-up moves:

  • none.

Next step before merge

  • No automated repair is needed; a maintainer should review and land or decline this active candidate fix for the canonical linked bug.

Security
Cleared: The diff only changes a small gateway string truncation helper and colocated tests; it does not alter secrets, auth policy, dependencies, workflows, or supply-chain execution paths.

Review details

Best possible solution:

Land one helper-level UTF-8 boundary fix with its regression tests, then let the linked canonical issue close through the PR.

Do we have a high-confidence way to reproduce the issue?

Yes. A focused read-only Node check reproduces current-main behavior as 121 bytes with U+FFFD for the reported input and shows the PR logic returning 118 bytes without replacement characters.

Is this the best way to solve the issue?

Yes. The existing close-reason helper is the narrowest maintainable fix point because the dynamic gateway handshake/auth close paths already funnel through it; patching individual callers would duplicate the byte-boundary invariant.

AGENTS.md: found and applied where relevant.

Codex review notes: model internal, reasoning high; reviewed against 4ec7842be0af.

Label changes

Label justifications:

  • P2: This is a narrow gateway runtime bug fix for malformed non-ASCII close reasons with a clear reproduction and limited blast radius.
  • rating: 🐚 platinum hermit: Overall readiness is 🐚 platinum hermit; proof is 🦞 diamond lobster and patch quality is 🐚 platinum hermit.
  • status: 👀 ready for maintainer look: ClawSweeper has no concrete contributor-facing blocker left for this PR. Sufficient (screenshot): The contributor's inspected terminal screenshot directly shows the before/after byte length and mojibake behavior for the changed helper.
  • proof: sufficient: Contributor real behavior proof is sufficient. The contributor's inspected terminal screenshot directly shows the before/after byte length and mojibake behavior for the changed helper.
  • proof: 📸 screenshot: Contributor real behavior proof includes screenshot evidence. The contributor's inspected terminal screenshot directly shows the before/after byte length and mojibake behavior for the changed helper.
Evidence reviewed

PR surface:

Source +7, Tests +58. Total +65 across 2 files.

View PR surface stats
Area Files Added Removed Net
Source 1 8 1 +7
Tests 1 58 0 +58
Docs 0 0 0 0
Config 0 0 0 0
Generated 0 0 0 0
Other 0 0 0 0
Total 2 66 1 +65

What I checked:

  • Root and scoped policy read: Read root AGENTS.md plus src/gateway/AGENTS.md and src/gateway/server-methods/AGENTS.md; the relevant guidance required reading beyond the diff and checking gateway callers before verdict. (AGENTS.md:1, 4ec7842be0af)
  • Current main still has the bug shape: Current main returns buf.subarray(0, maxBytes).toString(), so it can decode a partial UTF-8 sequence after a raw byte cut. (src/gateway/server/close-reason.ts:18, 4ec7842be0af)
  • Gateway callers funnel through the helper: Handshake validation, origin, auth, pairing, and runtime identity close paths call truncateCloseReason before close(1008, ...), so the helper is the right owner boundary for the invariant. (src/gateway/server/ws-connection/message-handler.ts:750, 4ec7842be0af)
  • PR head backs up over UTF-8 continuation bytes: The proposed helper sets end to the byte cap, backs up while the byte at the cut is a continuation byte, then decodes only a complete UTF-8 prefix. (src/gateway/server/close-reason.ts:22, 9aab51662282)
  • Regression tests cover the boundary cases: The added tests cover unchanged short reasons, empty fallback, exact ASCII truncation, 2-byte, 3-byte, and 4-byte UTF-8 boundary cuts, plus a custom maxBytes cap. (src/gateway/server/close-reason.test.ts:20, 9aab51662282)
  • Focused reproduction confirms before and after behavior: A read-only Node check of the old helper logic returned 121 bytes with U+FFFD, while the PR logic returned 118 bytes without replacement characters for the reported input. (src/gateway/server/close-reason.ts:18, 4ec7842be0af)

Likely related people:

  • steipete: GitHub commit metadata maps the January handshake-reason commit and later module split to this handle; those commits added raw byte close-reason truncation and then moved it into the current helper file. (role: introduced current helper behavior; confidence: high; commits: 146f7ab43325, bcbfb357bec7; files: src/gateway/server.ts, src/gateway/server/close-reason.ts)
  • RomneyDa: Current blame on src/gateway/server/close-reason.ts points to a recent refactor that carried the same raw-slicing helper in the current main snapshot. (role: recent current-main carrier; confidence: medium; commits: 6c53dfa1df99; files: src/gateway/server/close-reason.ts)
  • BunsDev: Recent gateway message-handler history includes auth/hello-ok fixes in the same handshake/auth surface that consumes the close-reason helper. (role: recent adjacent contributor; confidence: low; commits: be7a415eb096, 0b6c39be1875; files: src/gateway/server/ws-connection/message-handler.ts)
What the crustacean ranks mean
  • 🦀 challenger crab: rare, exceptional readiness with strong proof, clean implementation, and convincing validation.
  • 🦞 diamond lobster: very strong readiness with only minor maintainer review expected.
  • 🐚 platinum hermit: good normal PR, likely mergeable with ordinary maintainer review.
  • 🦐 gold shrimp: useful signal, but proof or patch confidence is still limited.
  • 🦪 silver shellfish: thin signal; proof, validation, or implementation needs work.
  • 🧂 unranked krab: not merge-ready because proof is missing/unusable or there are serious correctness or safety concerns.
  • 🌊 off-meta tidepool: rating does not apply to this item.

Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics.

How this review workflow works
  • ClawSweeper keeps one durable marker-backed review comment per issue or PR.
  • Re-runs edit this comment so the latest verdict, findings, and automation markers stay together instead of adding duplicate bot comments.
  • A fresh review can be triggered by eligible @clawsweeper re-review comments, exact-item GitHub events, scheduled/background review runs, or manual workflow dispatch.
  • PR/issue authors and users with repository write access can comment @clawsweeper re-review or @clawsweeper re-run on an open PR or issue to request a fresh review only.
  • Maintainers can also comment @clawsweeper review to request a fresh review only.
  • Fresh-review commands do not start repair, autofix, rebase, CI repair, or automerge.
  • Maintainer-only repair and merge flows require explicit commands such as @clawsweeper autofix, @clawsweeper automerge, @clawsweeper fix ci, or @clawsweeper address review.
  • Maintainers can comment @clawsweeper explain to ask for more context, or @clawsweeper stop to stop active automation.
Review history (4 earlier review cycles)
  • reviewed 2026-07-04T18:22:57.756Z sha 3a727bd :: needs real behavior proof before merge. :: none
  • reviewed 2026-07-04T18:37:37.029Z sha ec787c8 :: needs maintainer review before merge. :: none
  • reviewed 2026-07-04T19:02:38.439Z sha d02eab9 :: needs maintainer review before merge. :: none
  • reviewed 2026-07-04T21:12:41.360Z sha d02eab9 :: needs maintainer review before merge. :: none

@NarahariRaghava

Copy link
Copy Markdown
Contributor Author

Added real behavior proof. Running the fix branch logic against the reported input:

BEFORE fix - byte length: 121 (exceeds RFC 6455 123-byte cap), contains mojibake: true
AFTER fix -
Screenshot 2026-07-04 at 1 28 26 PM
byte length: 118 (within cap), contains mojibake: false, valid UTF-8: true

Screenshot attached showing terminal output. Unit tests also pass: 28/28.

@clawsweeper re-review

@clawsweeper

clawsweeper Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

🦞🧹
ClawSweeper re-review requested.

I asked ClawSweeper to review this item again.
Action: item re-review queued (workflow sweep.yml, event repository_dispatch).
Result: the existing ClawSweeper review comment will be edited in place when the review finishes.

@clawsweeper clawsweeper Bot added proof: sufficient ClawSweeper judged the real behavior proof convincing. proof: 📸 screenshot Contributor real behavior proof includes screenshot evidence. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. P2 Normal backlog priority with limited blast radius. labels Jul 4, 2026
@vincentkoc vincentkoc self-assigned this Jul 5, 2026
…ead of emitting mojibake

Buffer.subarray at a raw byte offset can cut inside a multi-byte UTF-8
sequence. Node decodes the dangling continuation bytes as U+FFFD (3 bytes
each), so the re-encoded result can exceed the intended maxBytes cap —
violating the RFC 6455 close-reason size contract and surfacing garbled
text to clients.

Back up from the cut point to the nearest UTF-8 sequence start before
slicing (UTF-8 continuation bytes match 10xxxxxx; skip them).

Closes openclaw#99976
@vincentkoc
vincentkoc force-pushed the fix/truncate-close-reason-utf8 branch from d02eab9 to 9aab516 Compare July 5, 2026 00:14
@vincentkoc

Copy link
Copy Markdown
Member

Land-ready verification completed on exact PR head 9aab516622821b90a6093b362d42011458554a3a.

  • Confirmed the issue against pinned [email protected]: Sender.close() measures the re-encoded reason with Buffer.byteLength() and rejects values over 123 bytes.
  • Reviewed all five gateway handshake callers; they share truncateCloseReason, so the fix is at the complete owner boundary.
  • Focused Crabbox/Testbox proof (blacksmith-testbox, tbx_01kwqsq09k8t00bcnnttm2xrmc): corepack pnpm test:serial src/gateway/server/close-reason.test.ts src/gateway/server.auth.default-token.test.ts passed 6 files / 66 tests.
  • corepack pnpm check:changed passed on the same Testbox.
  • Fresh branch autoreview: no findings, patch correct, confidence 0.99.
  • Exact-head hosted CI passed: run 28724083121.
  • scripts/pr prepare-run 100047 passed with exact-head hosted gates.
  • scripts/pr merge-verify 100047 passed; current main drift is limited to unrelated extensions/qa-channel files.

No known proof gaps.

@vincentkoc
vincentkoc merged commit 161c458 into openclaw:main Jul 5, 2026
96 checks passed
@vincentkoc

Copy link
Copy Markdown
Member

Merged via squash.

github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request Jul 5, 2026
…ead of emitting mojibake (openclaw#100047)

* fix(gateway): truncateCloseReason drops partial UTF-8 code point instead of emitting mojibake

Buffer.subarray at a raw byte offset can cut inside a multi-byte UTF-8
sequence. Node decodes the dangling continuation bytes as U+FFFD (3 bytes
each), so the re-encoded result can exceed the intended maxBytes cap —
violating the RFC 6455 close-reason size contract and surfacing garbled
text to clients.

Back up from the cut point to the nearest UTF-8 sequence start before
slicing (UTF-8 continuation bytes match 10xxxxxx; skip them).

Closes openclaw#99976

* fix(lint): remove unnecessary non-null assertion on Buffer index

* ci: retrigger checks
wheakerd pushed a commit to wheakerd/clawdbot that referenced this pull request Jul 15, 2026
…ead of emitting mojibake (openclaw#100047)

* fix(gateway): truncateCloseReason drops partial UTF-8 code point instead of emitting mojibake

Buffer.subarray at a raw byte offset can cut inside a multi-byte UTF-8
sequence. Node decodes the dangling continuation bytes as U+FFFD (3 bytes
each), so the re-encoded result can exceed the intended maxBytes cap —
violating the RFC 6455 close-reason size contract and surfacing garbled
text to clients.

Back up from the cut point to the nearest UTF-8 sequence start before
slicing (UTF-8 continuation bytes match 10xxxxxx; skip them).

Closes openclaw#99976

* fix(lint): remove unnecessary non-null assertion on Buffer index

* ci: retrigger checks

(cherry picked from commit 161c458)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gateway Gateway runtime P2 Normal backlog priority with limited blast radius. proof: 📸 screenshot Contributor real behavior proof includes screenshot evidence. proof: sufficient ClawSweeper judged the real behavior proof convincing. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. size: S status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: truncateCloseReason can exceed its byte cap and emit U+FFFD mojibake when cutting mid-UTF-8 sequence

2 participants