Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/1950-seat-line-text-wf.fixed.md
Original file line number Diff line number Diff line change
@@ -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)).
10 changes: 7 additions & 3 deletions core/src/host/room-membership.js
Original file line number Diff line number Diff line change
Expand Up @@ -1980,14 +1980,18 @@ 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
// transport's own `fromHandle`, so `seat.handle` is already the Bot that wrote the line, while a
// 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',
Expand All @@ -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,
Expand All @@ -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))
}
}
Expand Down
51 changes: 51 additions & 0 deletions core/test/remote-room.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, '<untrusted-message from="Ann">\nhi \u{1F600} there\n</untrusted-message>', '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')
})
10 changes: 7 additions & 3 deletions patches/ds-bot/files/src/host/room-membership.js
Original file line number Diff line number Diff line change
Expand Up @@ -1980,14 +1980,18 @@ 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
// transport's own `fromHandle`, so `seat.handle` is already the Bot that wrote the line, while a
// 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',
Expand All @@ -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,
Expand All @@ -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))
}
}
Expand Down
51 changes: 51 additions & 0 deletions patches/ds-bot/files/test/remote-room.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, '<untrusted-message from="Ann">\nhi \u{1F600} there\n</untrusted-message>', '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')
})
Loading