From 58c71182f03f2883299bd5fdbe72e95bbcf92532 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:11:00 +0800 Subject: [PATCH] Read a room-share envelope past a brace in a chat line's text The scan that finds the end of a `FLYBOT-ROOM-SHARE` body counted braces without tracking JSON string literals, and `encodeRoomShare` writes `text` with `JSON.stringify`, which leaves braces raw. One unbalanced `{` or `}` in a message therefore ended the object early, `JSON.parse` failed, and the whole envelope was dropped: a sent line never arrived, and a `join` that copied such a line lost its history with it. The scan now skips a string literal and its `\` escapes, which is what the client's `envelopeTail` already did (#855); a truncated body is still refused. Fixes #2447 --- changelog.d/2447-room-share-braces.fixed.md | 1 + core/src/shared/room-share.js | 14 ++++- core/test/room-share-braces.test.js | 57 +++++++++++++++++++ docs/subsystems/core-host.md | 1 + patches/ds-bot/files/src/shared/room-share.js | 14 ++++- .../files/test/room-share-braces.test.js | 57 +++++++++++++++++++ 6 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 changelog.d/2447-room-share-braces.fixed.md create mode 100644 core/test/room-share-braces.test.js create mode 100644 patches/ds-bot/files/test/room-share-braces.test.js diff --git a/changelog.d/2447-room-share-braces.fixed.md b/changelog.d/2447-room-share-braces.fixed.md new file mode 100644 index 000000000..323fb306c --- /dev/null +++ b/changelog.d/2447-room-share-braces.fixed.md @@ -0,0 +1 @@ +Fixed a room-share envelope being dropped when a chat line's own text carried one unbalanced brace, which lost the sent line and the join history copied into a `join` envelope ([#2447](https://github.com/ThinkFlowLab/FlyBot/issues/2447)). diff --git a/core/src/shared/room-share.js b/core/src/shared/room-share.js index 0f21da824..dc4c3bd12 100644 --- a/core/src/shared/room-share.js +++ b/core/src/shared/room-share.js @@ -257,9 +257,21 @@ export function parseRoomShare(text) { if (start < 0) return undefined let depth = 0 let end = -1 + // #2447: a brace inside a string literal is a message's own text, not structure. `encodeRoomShare` + // writes `text` with `JSON.stringify`, which leaves braces raw, so counting them dropped the whole + // envelope over one `{` in a chat line — the same scan the client's `envelopeTail` does (#855). + let inString = false + let escaped = false for (let i = start; i < raw.length; i += 1) { const ch = raw[i] - if (ch === '{') depth += 1 + if (inString) { + if (escaped) escaped = false + else if (ch === '\\') escaped = true + else if (ch === '"') inString = false + continue + } + if (ch === '"') inString = true + else if (ch === '{') depth += 1 else if (ch === '}') { depth -= 1 if (depth === 0) { end = i; break } diff --git a/core/test/room-share-braces.test.js b/core/test/room-share-braces.test.js new file mode 100644 index 000000000..9f5f3b1c1 --- /dev/null +++ b/core/test/room-share-braces.test.js @@ -0,0 +1,57 @@ +// #2447: the brace scan that finds the end of a room-share envelope has to know when a brace is +// part of a chat line's own text. One unbalanced `{` or `}` in a message used to end the scan in +// the wrong place, JSON.parse then failed, and the whole envelope was dropped — a sent line never +// arrived, and one brace in a copied line killed the join that carried it. +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { encodeRoomShare, parseRoomShare } from '../src/shared/room-share.js' + +const line = (text, extra = {}) => ({ op: 'line', roomId: 'r1', seq: 7, who: 'Alice', text, ...extra }) + +test('a line whose text opens a brace is read back, not dropped', () => { + const parsed = parseRoomShare(encodeRoomShare(line('if (x) { y()'))) + assert.equal(parsed?.op, 'line') + assert.equal(parsed?.roomId, 'r1') + assert.equal(parsed?.who, 'Alice') + assert.equal(parsed?.text, 'if (x) { y()') +}) + +test('a line whose text closes a brace is read back, not dropped', () => { + const text = 'so the closing brace is }' + const parsed = parseRoomShare(encodeRoomShare(line(text))) + assert.equal(parsed?.op, 'line') + assert.equal(parsed?.text, text) +}) + +test('a brace right after an escaped quote is still inside the string', () => { + const text = 'say "{" now' + assert.equal(parseRoomShare(encodeRoomShare(line(text)))?.text, text) + const quoteThenBrace = 'a quote " then a brace }' + assert.equal(parseRoomShare(encodeRoomShare(line(quoteThenBrace)))?.text, quoteThenBrace) +}) + +test('a join keeps the braces of the lines it copies', () => { + const parsed = parseRoomShare(encodeRoomShare({ + op: 'join', + roomId: 'r1', + name: 'Launch plan', + history: [ + { who: 'Alice', text: 'if (x) { y() }' }, + { who: 'Bob', text: 'plain line' }, + ], + })) + assert.equal(parsed?.op, 'join') + assert.deepEqual(parsed?.history?.map(entry => entry.text), ['if (x) { y() }', 'plain line']) +}) + +test('plain text and balanced braces still parse', () => { + assert.equal(parseRoomShare(encodeRoomShare(line('hello there')))?.text, 'hello there') + const balanced = 'plan {a} then {b}' + assert.equal(parseRoomShare(encodeRoomShare(line(balanced)))?.text, balanced) +}) + +test('a truncated envelope is still refused', () => { + const whole = encodeRoomShare(line('hello')) + assert.ok(parseRoomShare(whole)) + assert.equal(parseRoomShare(whole.slice(0, -1)), undefined) +}) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 26e3627a2..a4d394f29 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -334,6 +334,7 @@ Work a member hands a Bot with `message_bot` from its group Session runs in a hi ### A local room's invite link (#852) +- A share envelope's body is found by scanning from the first `{` after the marker, and that scan tracks JSON string literals and `\` escapes (#2447): `encodeRoomShare` writes `text` with `JSON.stringify`, which leaves braces raw, so a chat line's own `{` or `}` is text and never ends the envelope early. One unbalanced brace used to fail `JSON.parse` and drop the whole envelope — a sent line never arrived, and a `join` that copied such a line lost its history with it. `parseRoomShare` is the parser the host trusts; the client's `envelopeTail` (#855) already scanned this way. - A room that lives in FlyBot can hand out one link: `https://social.botawaken.com/r//`. `core/src/shared/room-share.js` `parseRoomInviteLink` reads both shapes in the one invite field and says which one it saw (`{ kind: 'local', handle, ticket }` / `{ kind: 'face', ticket }`), and `roomInviteUrl` builds the local one. The ticket is `randomBytes(16)` in base64url — 22 characters of `[A-Za-z0-9_-]`, inside the 64-character shape `FACE_INVITE_TICKET` already accepts. The handle is its own path segment because `send_as_member` addresses a Bot by handle, and a ticket cannot carry one inside that limit. - The room record is the only authority: `room.localInvites [{ ticket, handle, botId, expiresAt?, mintedAt, mintedBy, server?, url?, homes?, acceptedAt?, withdrawnAt? }]`. A link has **no deadline and is not single-use** (owner decision 2026-10-24, #892, Bot Social #65): anyone it is given to can join with it, each home once (`homes: { [home]: [seat handles] }`; a record from before keeps its one `acceptedBy` / `seats`, read through `homesOf`), until the owner withdraws it or the room is deleted. A link minted with an `expiresAt` (an older Bot Social, or before the change) keeps that deadline. Several may be live at once (#878); a record written before the list (`room.localInvite`) is moved into it on first read. `rt.mintRoomInvite(roomId, actor)` adds one (dead links that seated nobody are dropped then); `rt.localInvitesOf(roomId)` lists the open ones (not withdrawn, not out of date; a link someone joined with stays open) and `rt.localInviteOf(roomId)` is the newest; `rt.roomOfLocalInvite(ticket)` resolves the room from the ticket; `rt.revokeRoomInvite(roomId, ticket?)` withdraws one link, or without a ticket every open one: it sets `withdrawnAt` (nobody new joins; the server ticket is revoked without `endClaims`), the seats it made stay, and a withdrawn link that seats nobody is dropped. A shared (`inbound-share`) or face-to-face room refuses to mint — it has its own invitation. - Three steps on the same envelope, answered by the room record: the invitee's host sends `{ op: 'ask', ticket }`; the inviter answers `{ op: 'invite', ticket, roomId, name, lead, expiresAt }`, which is information only (it consumes nothing and seats nobody); the accepting knock `{ op: 'ask', ticket, accept: true }` runs verify → consume → add → the existing `join`, which `addRoomParticipant` sends. `applyInboundRoomShare` handles both ops **before** the branch that projects a room for an unmatched `join` / `line`, so a knock never conjures a room; `parseRoomShare` accepts `ask` without a `roomId` and requires one on `invite`. The asker is `event.fromHandle` (the transport's sender), never a field of the payload. The accepting home is added to `homes`: the same home retrying is idempotent success (the lost `join` reply is sent again) and another home may join too (#892); an add the room refuses removes that home again. Refusals are local on the inviter's host (`unknown` / `revoked` / `expired` / `no-sender` / `not-addable`) and change nothing. The UI half — the Members card's **Invite by link** row and the dialog the offer draws — is still to come. Covered by `core/test/remote-room.test.js` (#852). diff --git a/patches/ds-bot/files/src/shared/room-share.js b/patches/ds-bot/files/src/shared/room-share.js index 0f21da824..dc4c3bd12 100644 --- a/patches/ds-bot/files/src/shared/room-share.js +++ b/patches/ds-bot/files/src/shared/room-share.js @@ -257,9 +257,21 @@ export function parseRoomShare(text) { if (start < 0) return undefined let depth = 0 let end = -1 + // #2447: a brace inside a string literal is a message's own text, not structure. `encodeRoomShare` + // writes `text` with `JSON.stringify`, which leaves braces raw, so counting them dropped the whole + // envelope over one `{` in a chat line — the same scan the client's `envelopeTail` does (#855). + let inString = false + let escaped = false for (let i = start; i < raw.length; i += 1) { const ch = raw[i] - if (ch === '{') depth += 1 + if (inString) { + if (escaped) escaped = false + else if (ch === '\\') escaped = true + else if (ch === '"') inString = false + continue + } + if (ch === '"') inString = true + else if (ch === '{') depth += 1 else if (ch === '}') { depth -= 1 if (depth === 0) { end = i; break } diff --git a/patches/ds-bot/files/test/room-share-braces.test.js b/patches/ds-bot/files/test/room-share-braces.test.js new file mode 100644 index 000000000..9f5f3b1c1 --- /dev/null +++ b/patches/ds-bot/files/test/room-share-braces.test.js @@ -0,0 +1,57 @@ +// #2447: the brace scan that finds the end of a room-share envelope has to know when a brace is +// part of a chat line's own text. One unbalanced `{` or `}` in a message used to end the scan in +// the wrong place, JSON.parse then failed, and the whole envelope was dropped — a sent line never +// arrived, and one brace in a copied line killed the join that carried it. +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { encodeRoomShare, parseRoomShare } from '../src/shared/room-share.js' + +const line = (text, extra = {}) => ({ op: 'line', roomId: 'r1', seq: 7, who: 'Alice', text, ...extra }) + +test('a line whose text opens a brace is read back, not dropped', () => { + const parsed = parseRoomShare(encodeRoomShare(line('if (x) { y()'))) + assert.equal(parsed?.op, 'line') + assert.equal(parsed?.roomId, 'r1') + assert.equal(parsed?.who, 'Alice') + assert.equal(parsed?.text, 'if (x) { y()') +}) + +test('a line whose text closes a brace is read back, not dropped', () => { + const text = 'so the closing brace is }' + const parsed = parseRoomShare(encodeRoomShare(line(text))) + assert.equal(parsed?.op, 'line') + assert.equal(parsed?.text, text) +}) + +test('a brace right after an escaped quote is still inside the string', () => { + const text = 'say "{" now' + assert.equal(parseRoomShare(encodeRoomShare(line(text)))?.text, text) + const quoteThenBrace = 'a quote " then a brace }' + assert.equal(parseRoomShare(encodeRoomShare(line(quoteThenBrace)))?.text, quoteThenBrace) +}) + +test('a join keeps the braces of the lines it copies', () => { + const parsed = parseRoomShare(encodeRoomShare({ + op: 'join', + roomId: 'r1', + name: 'Launch plan', + history: [ + { who: 'Alice', text: 'if (x) { y() }' }, + { who: 'Bob', text: 'plain line' }, + ], + })) + assert.equal(parsed?.op, 'join') + assert.deepEqual(parsed?.history?.map(entry => entry.text), ['if (x) { y() }', 'plain line']) +}) + +test('plain text and balanced braces still parse', () => { + assert.equal(parseRoomShare(encodeRoomShare(line('hello there')))?.text, 'hello there') + const balanced = 'plan {a} then {b}' + assert.equal(parseRoomShare(encodeRoomShare(line(balanced)))?.text, balanced) +}) + +test('a truncated envelope is still refused', () => { + const whole = encodeRoomShare(line('hello')) + assert.ok(parseRoomShare(whole)) + assert.equal(parseRoomShare(whole.slice(0, -1)), undefined) +})