From 479195a7d95009192f97787da8f7bab490450533 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Sun, 1 Nov 2026 00:23:53 +0800 Subject: [PATCH] host: make a seat line's body well formed before it is used MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner: Dev-2 Reviewer: Birfy Written-by: Dev-2 Branch: dev/2-p301-seat-share-wellformed Base: 48c0df28d1085d2414089d8be6192066eca2e42e The other entry into a room's log (#884, ownSeatOf -> ingestSeatShare) took the envelope's body as it arrived: the same string reached the stored line, the pane and the inbound turn, so half of a surrogate pair was stored, drawn and handed to a model. #1943 repaired the projected-mirror branch only; this is its sibling. One line next to the cleaned name, three consumers use it; already well formed bodies, real emoji included, pass untouched. patches/ds-bot/files/ is the generated copy (node scripts/ds-bot-patches.mjs generate), cmp-equal to core/. Docs-not-needed: `docs/subsystems/core-host.md` documents the room log and the seat path's line fields with their bounds; this change only makes a body that arrives ill formed well formed before it is stored, drawn or handed to a turn — no field, bound, tool or path a page describes is added or changed. --- changelog.d/1950-seat-line-text-wf.fixed.md | 1 + core/src/host/room-membership.js | 10 ++-- core/test/remote-room.test.js | 51 +++++++++++++++++++ .../ds-bot/files/src/host/room-membership.js | 10 ++-- patches/ds-bot/files/test/remote-room.test.js | 51 +++++++++++++++++++ 5 files changed, 117 insertions(+), 6 deletions(-) create mode 100644 changelog.d/1950-seat-line-text-wf.fixed.md diff --git a/changelog.d/1950-seat-line-text-wf.fixed.md b/changelog.d/1950-seat-line-text-wf.fixed.md new file mode 100644 index 000000000..2f54d52d8 --- /dev/null +++ b/changelog.d/1950-seat-line-text-wf.fixed.md @@ -0,0 +1 @@ +A line that a remote seat writes into a room of this home is now made well formed before it is used: the stored room line, the line the pane draws and the text handed to the Bot the line calls all carry the replacement character instead of half of a surrogate pair that would not survive a UTF-8 round trip. The line is still accepted and still calls that Bot, and a body that is already well formed — a real emoji included — is passed through exactly as it arrived ([#1950](https://github.com/ThinkFlowLab/FlyBot/issues/1950)). diff --git a/core/src/host/room-membership.js b/core/src/host/room-membership.js index 77e2bc97e..29bbe0dda 100644 --- a/core/src/host/room-membership.js +++ b/core/src/host/room-membership.js @@ -1980,6 +1980,10 @@ export function install(rt) { } if (data.op !== 'line') return { handled: true, room } const who = cleanPeerName(data.who, remoteBotTitle(seat)) + // The seat path carries a peer's body exactly as the projected-mirror branch does (#1943): + // JSON.parse hands back an unpaired surrogate escape as half a character, and this body is + // stored, drawn and handed to a model. Repair it once, where the line is read. + const text = typeof data.text === 'string' ? data.text.toWellFormed() : (data.text || '') const hop = Number.isFinite(Number(data.hop)) ? Math.max(0, Math.floor(Number(data.hop))) : 0 const human = data.authorKind !== 'bot' // #904: this path never reads the envelope's `authorId`: `ownSeatOf` matched the seat by the @@ -1987,7 +1991,7 @@ export function install(rt) { // claim inside the envelope is only the guest's word. A guest cannot speak as another Bot here. const posted = append(room, rt.authoredRoomLine(room, { who, - text: data.text || '', + text, // A guest person's id is theirs, not this home's: it is kept apart from the members here. authorId: human ? `${seat.handle}#${String(data.authorId || 'person').slice(0, 40)}` : seat.handle, authorKind: human ? 'human' : 'bot', @@ -2000,7 +2004,7 @@ export function install(rt) { })) await showShareLine(room, { who, - text: data.text || '', + text, remoteSeq: data.seq, via: seat.handle, senderSessionId: seat.handle, @@ -2019,7 +2023,7 @@ export function install(rt) { if (room.answered.includes(key)) continue room.answered = [...room.answered, key].slice(-ANSWERED_KEPT) called.push(botId) - void Promise.resolve(rt.startInboundTurn({ room, botId, seq: posted.seq, hop, who, text: data.text || '' })) + void Promise.resolve(rt.startInboundTurn({ room, botId, seq: posted.seq, hop, who, text })) .catch(error => rt.warn?.('ds-bot: a guest\'s call in %s failed: %s', room.name, error)) } } diff --git a/core/test/remote-room.test.js b/core/test/remote-room.test.js index 8ccc6a7f9..abb53720c 100644 --- a/core/test/remote-room.test.js +++ b/core/test/remote-room.test.js @@ -2929,3 +2929,54 @@ test('an inbound line body that arrives ill formed is made well formed where it assert.equal(shown.at(-1).text, 'hi \u{1F600} there') assert.equal(turns.at(-1).text, 'hi \u{1F600} there', 'a real pair is not replaced') }) + +// The other way a peer's text enters this home (#884): a remote seat writes a line into a room of +// mine, and `ingestSeatShare` took `data.text` exactly as the envelope carried it — to the log, to the +// pane and to the Bot the line calls. #1943 repaired the projected-mirror branch alone; this is the +// sibling path, where the same string JSON accepts but does not make well formed arrives intact. +test('a seat line body that arrives ill formed is made well formed where it is used', async (t) => { + const { rt } = host(t, TEAM) + await rt.load() + const { alice } = await signAliceAndBob(rt) + const actorA = await rt.authenticate(alice.sessionToken) + publish(rt) + await grantToFriend(rt, 'chief-a') + await grantToFriend(rt, 'chief-b') + await rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: '@coder.bob.home' }, actorA) + const shown = [] + rt.showSharedLine = async (room, spec) => { shown.push({ roomId: room.id, ...spec }) } + const turns = [] + rt.startInboundTurn = async args => { turns.push(args) } + const line = (seq, extra = {}) => 'FLYBOT-ROOM-SHARE\n' + JSON.stringify({ + v: 1, op: 'line', roomId: 'war', seq, who: 'W\ud83dho', authorKind: 'human', authorId: 'alice', + text: 'seat \ud83d body', calls: ['chief-a'], ...extra, + }) + const out = await rt.applyInboundRoomShare({ fromHandle: '@coder.bob.home', fromHome: '@bob.home', fromName: 'Coder', text: line(5) }, actorA) + assert.equal(out.handled, true, 'an ill-formed body is repaired, not refused') + assert.equal('refused' in out, false, 'the line is not refused') + assert.deepEqual(out.called, ['chief-a'], 'the line still calls the Bot it names') + + const stored = rt.roomHistory('war', actorA).at(-1) + assert.ok(stored, 'the line is stored') + assert.equal(stored.text.isWellFormed(), true, `the stored row is not well formed: ${JSON.stringify(stored.text)}`) + assert.equal(stored.text.includes('seat \uFFFD body'), true, 'the stored body carries one U+FFFD for the lone half') + assert.equal(stored.who, 'W\uFFFDho', 'the author was already cleaned, in the same shape') + + assert.equal(shown.length, 1, 'the pane is drawn once') + assert.equal(shown[0].text, 'seat \uFFFD body') + assert.equal(shown[0].text.isWellFormed(), true, `the pane is not well formed: ${JSON.stringify(shown[0].text)}`) + + assert.equal(turns.length, 1, 'the named Bot is called') + assert.equal(turns[0].botId, 'chief-a') + assert.equal(turns[0].text, 'seat \uFFFD body') + assert.equal(turns[0].text.isWellFormed(), true, `the turn is not well formed: ${JSON.stringify(turns[0].text)}`) + assert.equal(turns[0].text.length, 'seat \ud83d body'.length, 'one unit in, one U+FFFD out: the length does not move') + + // A body that is already well formed is passed through untouched, and a real pair keeps its code point. + const ok = await rt.applyInboundRoomShare({ fromHandle: '@coder.bob.home', fromHome: '@bob.home', fromName: 'Coder', text: line(6, { who: 'Ann', text: 'hi \u{1F600} there' }) }, actorA) + assert.equal(ok.handled, true) + const kept = rt.roomHistory('war', actorA).at(-1) + assert.equal(kept.text, '\nhi \u{1F600} there\n', 'a well-formed body is stored exactly as it arrived') + assert.equal(shown.at(-1).text, 'hi \u{1F600} there') + assert.equal(turns.at(-1).text, 'hi \u{1F600} there', 'a real pair is not replaced') +}) diff --git a/patches/ds-bot/files/src/host/room-membership.js b/patches/ds-bot/files/src/host/room-membership.js index 77e2bc97e..29bbe0dda 100644 --- a/patches/ds-bot/files/src/host/room-membership.js +++ b/patches/ds-bot/files/src/host/room-membership.js @@ -1980,6 +1980,10 @@ export function install(rt) { } if (data.op !== 'line') return { handled: true, room } const who = cleanPeerName(data.who, remoteBotTitle(seat)) + // The seat path carries a peer's body exactly as the projected-mirror branch does (#1943): + // JSON.parse hands back an unpaired surrogate escape as half a character, and this body is + // stored, drawn and handed to a model. Repair it once, where the line is read. + const text = typeof data.text === 'string' ? data.text.toWellFormed() : (data.text || '') const hop = Number.isFinite(Number(data.hop)) ? Math.max(0, Math.floor(Number(data.hop))) : 0 const human = data.authorKind !== 'bot' // #904: this path never reads the envelope's `authorId`: `ownSeatOf` matched the seat by the @@ -1987,7 +1991,7 @@ export function install(rt) { // claim inside the envelope is only the guest's word. A guest cannot speak as another Bot here. const posted = append(room, rt.authoredRoomLine(room, { who, - text: data.text || '', + text, // A guest person's id is theirs, not this home's: it is kept apart from the members here. authorId: human ? `${seat.handle}#${String(data.authorId || 'person').slice(0, 40)}` : seat.handle, authorKind: human ? 'human' : 'bot', @@ -2000,7 +2004,7 @@ export function install(rt) { })) await showShareLine(room, { who, - text: data.text || '', + text, remoteSeq: data.seq, via: seat.handle, senderSessionId: seat.handle, @@ -2019,7 +2023,7 @@ export function install(rt) { if (room.answered.includes(key)) continue room.answered = [...room.answered, key].slice(-ANSWERED_KEPT) called.push(botId) - void Promise.resolve(rt.startInboundTurn({ room, botId, seq: posted.seq, hop, who, text: data.text || '' })) + void Promise.resolve(rt.startInboundTurn({ room, botId, seq: posted.seq, hop, who, text })) .catch(error => rt.warn?.('ds-bot: a guest\'s call in %s failed: %s', room.name, error)) } } diff --git a/patches/ds-bot/files/test/remote-room.test.js b/patches/ds-bot/files/test/remote-room.test.js index 8ccc6a7f9..abb53720c 100644 --- a/patches/ds-bot/files/test/remote-room.test.js +++ b/patches/ds-bot/files/test/remote-room.test.js @@ -2929,3 +2929,54 @@ test('an inbound line body that arrives ill formed is made well formed where it assert.equal(shown.at(-1).text, 'hi \u{1F600} there') assert.equal(turns.at(-1).text, 'hi \u{1F600} there', 'a real pair is not replaced') }) + +// The other way a peer's text enters this home (#884): a remote seat writes a line into a room of +// mine, and `ingestSeatShare` took `data.text` exactly as the envelope carried it — to the log, to the +// pane and to the Bot the line calls. #1943 repaired the projected-mirror branch alone; this is the +// sibling path, where the same string JSON accepts but does not make well formed arrives intact. +test('a seat line body that arrives ill formed is made well formed where it is used', async (t) => { + const { rt } = host(t, TEAM) + await rt.load() + const { alice } = await signAliceAndBob(rt) + const actorA = await rt.authenticate(alice.sessionToken) + publish(rt) + await grantToFriend(rt, 'chief-a') + await grantToFriend(rt, 'chief-b') + await rt.addRoomParticipant('war', { kind: 'bot', remote: true, handle: '@coder.bob.home' }, actorA) + const shown = [] + rt.showSharedLine = async (room, spec) => { shown.push({ roomId: room.id, ...spec }) } + const turns = [] + rt.startInboundTurn = async args => { turns.push(args) } + const line = (seq, extra = {}) => 'FLYBOT-ROOM-SHARE\n' + JSON.stringify({ + v: 1, op: 'line', roomId: 'war', seq, who: 'W\ud83dho', authorKind: 'human', authorId: 'alice', + text: 'seat \ud83d body', calls: ['chief-a'], ...extra, + }) + const out = await rt.applyInboundRoomShare({ fromHandle: '@coder.bob.home', fromHome: '@bob.home', fromName: 'Coder', text: line(5) }, actorA) + assert.equal(out.handled, true, 'an ill-formed body is repaired, not refused') + assert.equal('refused' in out, false, 'the line is not refused') + assert.deepEqual(out.called, ['chief-a'], 'the line still calls the Bot it names') + + const stored = rt.roomHistory('war', actorA).at(-1) + assert.ok(stored, 'the line is stored') + assert.equal(stored.text.isWellFormed(), true, `the stored row is not well formed: ${JSON.stringify(stored.text)}`) + assert.equal(stored.text.includes('seat \uFFFD body'), true, 'the stored body carries one U+FFFD for the lone half') + assert.equal(stored.who, 'W\uFFFDho', 'the author was already cleaned, in the same shape') + + assert.equal(shown.length, 1, 'the pane is drawn once') + assert.equal(shown[0].text, 'seat \uFFFD body') + assert.equal(shown[0].text.isWellFormed(), true, `the pane is not well formed: ${JSON.stringify(shown[0].text)}`) + + assert.equal(turns.length, 1, 'the named Bot is called') + assert.equal(turns[0].botId, 'chief-a') + assert.equal(turns[0].text, 'seat \uFFFD body') + assert.equal(turns[0].text.isWellFormed(), true, `the turn is not well formed: ${JSON.stringify(turns[0].text)}`) + assert.equal(turns[0].text.length, 'seat \ud83d body'.length, 'one unit in, one U+FFFD out: the length does not move') + + // A body that is already well formed is passed through untouched, and a real pair keeps its code point. + const ok = await rt.applyInboundRoomShare({ fromHandle: '@coder.bob.home', fromHome: '@bob.home', fromName: 'Coder', text: line(6, { who: 'Ann', text: 'hi \u{1F600} there' }) }, actorA) + assert.equal(ok.handled, true) + const kept = rt.roomHistory('war', actorA).at(-1) + assert.equal(kept.text, '\nhi \u{1F600} there\n', 'a well-formed body is stored exactly as it arrived') + assert.equal(shown.at(-1).text, 'hi \u{1F600} there') + assert.equal(turns.at(-1).text, 'hi \u{1F600} there', 'a real pair is not replaced') +})