From d17ff1bc2e1f74acf1f6cf8f07f018a48d8113ff Mon Sep 17 00:00:00 2001 From: Alessandro Rinaldi Date: Sat, 5 Sep 2026 22:01:41 +0200 Subject: [PATCH] session: count framed payload lengths in characters, not bytes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each command response is framed as ::, and that length counts characters — the service produces it the way JavaScript's String.length and Java's String.length() do. dwshell sliced the body by bytes, which agrees only while everything is ASCII: one non-ASCII character and the payload is cut short, leaving JSON that ends mid-structure. Found against a live account, where a rejected agent name came back as K:K11:71:K:{"message":"L'agente 'x' già esiste.","status":"error"} 71 characters, 72 bytes. dwshell took 71 bytes, lost the closing brace, and reported "unexpected end of JSON input" instead of the service's own message. The blast radius is narrower than it first appears, and worth recording: the agent escapes non-ASCII in its own responses (\u00e0), so anything coming from an agent — file listings with accented names included — was never affected. It is the account channel, where the service answers with literal UTF-8, that truncates. Localized error messages are the common case, which is why this surfaced only on an Italian account. Counted in UTF-16 code units rather than Go runes: for anything up to U+FFFF the two agree, and beyond it the service counts two, so runes would have traded one off-by-one for another. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MvidAFW9a2r4hTgHPW9ywG --- internal/session/session.go | 47 +++++++++++++++++++++++++++-- internal/session/session_test.go | 51 +++++++++++++++++++++++++++++++- 2 files changed, 94 insertions(+), 4 deletions(-) diff --git a/internal/session/session.go b/internal/session/session.go index 3d1a152..8168ec4 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -256,8 +256,49 @@ func (f frame) result(found bool) ([]byte, error) { } } +// utf16Len counts a string the way the service does: in UTF-16 code units, one +// per character up to U+FFFF and two beyond it. This is what JavaScript's +// String.length and Java's String.length() report, and the framed payload +// lengths are produced by that side. +func utf16Len(s string) int { + n := 0 + for _, r := range s { + if r > 0xFFFF { + n += 2 + } else { + n++ + } + } + return n +} + +// utf16Split cuts s after n UTF-16 code units, returning the prefix and the +// rest. ok is false when s holds fewer than n units. +func utf16Split(s string, n int) (prefix, rest string, ok bool) { + units := 0 + for i, r := range s { + if units == n { + return s[:i], s[i:], true + } + if r > 0xFFFF { + units += 2 + } else { + units++ + } + } + if units == n { + return s, "", true + } + return "", s, false +} + // parseFrames decodes a framed command response (PROTOCOL.md §3) into a map of // command id → frame. A leading E/D/B status is a session-level error. +// +// Payload lengths in the framing count characters, not bytes, so they are +// consumed in UTF-16 code units. Slicing by bytes works only while everything +// is ASCII and silently truncates the moment it is not — a localized error +// message or an accented file name loses its tail. func parseFrames(body string) (map[string]frame, error) { if body == "" { return nil, fmt.Errorf("empty response") @@ -293,11 +334,11 @@ func parseFrames(body string) (map[string]frame, error) { return nil, fmt.Errorf("malformed length: %w", err) } i += l + 1 - if i+n > len(body) { + payload, rest, ok := utf16Split(body[i:], n) + if !ok { return nil, fmt.Errorf("truncated payload") } - payload := body[i : i+n] - i += n + i = len(body) - len(rest) if len(payload) < 1 { continue } diff --git a/internal/session/session_test.go b/internal/session/session_test.go index 0188c36..185a0ff 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -1,6 +1,9 @@ package session -import "testing" +import ( + "strconv" + "testing" +) // resultFor decodes the frame for id from body (test helper mirroring Execute). func resultFor(t *testing.T, body, id string) ([]byte, error) { @@ -69,3 +72,49 @@ func TestFrameMissing(t *testing.T) { t.Fatal("missing frame should error") } } + +// The framed length counts characters, not bytes: the service is counting the +// way JavaScript and Java do. Slicing the body by bytes truncates any payload +// containing non-ASCII — a localized error message, an accented file name — +// and leaves the caller with a payload cut mid-character. +func TestParseFramesLengthCountsCharactersNotBytes(t *testing.T) { + payload := `K:{"message":"L'agente 'x' già esiste.","status":"error"}` + body := "K:K1:" + strconv.Itoa(len([]rune(payload))) + ":" + payload + + frames, err := parseFrames(body) + if err != nil { + t.Fatalf("parseFrames: %v", err) + } + f, ok := frames["K1"] + if !ok { + t.Fatal("frame K1 missing") + } + data, err := f.result(true) + if err != nil { + t.Fatalf("result: %v", err) + } + want := `{"message":"L'agente 'x' già esiste.","status":"error"}` + if string(data) != want { + t.Fatalf("payload truncated:\n got %q\nwant %q", data, want) + } +} + +// Beyond the basic plane a character is two UTF-16 units, which is what the +// service counts — counting Go runes there would trade one off-by-one for +// another. +func TestParseFramesCountsUTF16Units(t *testing.T) { + payload := `K:{"n":"😀"}` // the emoji is one rune but two UTF-16 units + body := "K:K1:" + strconv.Itoa(utf16Len(payload)) + ":" + payload + + frames, err := parseFrames(body) + if err != nil { + t.Fatalf("parseFrames: %v", err) + } + data, err := frames["K1"].result(true) + if err != nil { + t.Fatalf("result: %v", err) + } + if string(data) != `{"n":"😀"}` { + t.Fatalf("got %q", data) + } +}