Skip to content

host: an inbound line's text keeps a lone surrogate — the body is never made well formed #1943

Closed

Description

@HugoXK

Implementation: FlyBot (ours) — PR follows within 24h. Do not pick up without a pickup-ok comment on this issue.

Written-by: FlyBot Lead
Claimed-by: FlyBot Lead
Claim-Id: 5f3d0601ea2e404ca032699771583be0
Branch: dev/2-p295-inbound-line-text-wf
Base: 1016520
At: 2026-10-31T15:17:10Z

Implementation: FlyBot (ours) — PR follows within 24h. Do not pick up without a pickup-ok comment.

Written-by: FlyBot Lead
Owner: FlyBot Lead
Branch: dev/2-p295-inbound-line-text-wf
Base: 1016520

What happens

An inbound room-share envelope with op: 'line' carries its body in text. The receiver cleans the name (who goes through cleanPeerName, which now calls toWellFormed() after #1941) but takes the body straight from the envelope: room-membership.js:2513 does const who = cleanPeerName(data.who || transportName, transportName) and const text = data.text || ''.

So a lone surrogate in text is stored as-is, shown as-is and handed to the turn as-is. It is not a display-only problem: the line is accepted and, when its calls name a handle of ours, really starts an inbound turn.

Reproduction

Prerequisite: a clean join first (origin: 'inbound-share'), otherwise refused: 'not-seated' and nothing shows. recordInboundSharedRoom(...) or an op: 'join' envelope both work.

const raw = 'FLYBOT-ROOM-SHARE\n' + JSON.stringify({
  v: 1, op: 'line', roomId: 'alice-war', name: 'War room', inviterName: 'Alice',
  inviterHome: '@alice.home', inviterHandle: '@chief.alice.home', invitedHandle: '@coder.bob.home',
  seq: 41, who: 'W\ud83dho', authorKind: 'human', authorId: 'alice',
  text: 'hello \ud83d world', calls: ['@coder.bob.home'],
})
await rt.applyInboundRoomShare({ fromHandle: '@chief.alice.home', fromHome: '@alice.home', text: raw }, actorB)

Measured on ff4a3cfb (and on old 1028aa9a / base 4d851e83; room-membership.js is untouched by both #1938 and #1941, so the shape is the same on current main): handled: true, called: ["spec-b"], no refused.

where text
stored row rt.roomHistory(room.id, actorB).at(-1) "<untrusted-message from=\"W\ufffdho\">\nhello \ud83d world\n</untrusted-message>" — isWellFormed() === false
pane (the showSharedLine spec) "hello \ud83d world" — false
startInboundTurn args (botId: 'spec-b') "hello \ud83d world" — false

who is already "W\ufffdho", isWellFormed() === true on that same tip: #1941 fixed the name face only.

Worth keeping in the record: inside the stored row the fence attribute from="…" is already U+FFFD, while the body keeps the raw lone surrogate. The fence builder cleans its own attribute; the body path was never covered.

Expected

A text that arrives ill-formed is made well formed before it is stored, shown, or passed to a turn — one U+FFFD per lone surrogate, length unchanged otherwise, the same shape wellFormedShare already applies to notice, roster[].name/ownerName and history[].who/text. handled: true and called: ["spec-b"] must not change: this is a repair, not a refusal.

Red-first

On base, the line above (in the room-share / room-membership suites): stored row, pane spec and turn args each isWellFormed() === false. With the fix, all three true; an already well-formed body is byte-identical; a body with a real emoji keeps its code point.

Scope

  • core/src/host/room-membership.js — the op: 'line' branch (:2513 area): make the body well formed before it is used, next to where the name is cleaned.
  • Whatever the source tree carries for that file (the patches/ds-bot/ copy and files/ mirror) — generate decides, no hand edits.
  • One regression test in the existing suite, one changelog fragment, one docs line if the subsystem doc names the cleaned fields.

Not in scope: who (fixed by #1941), the notice/roster/history fields (fixed by #1938), and the server-side slices in roster.js (separate report).

Dedupe

Searched the repo (open + closed) for toWellFormed, well-formed, surrogate, untrusted-message, op:'line', line envelope: #1937/#1938/#1940 are the name face, #1908/#1927/#1928/#1930/#1933 are cut-on-code-point bounds, #1923/#1925 are roster cuts. No issue covers an inbound line's body.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions