From 99281c85a39387054d6776a852797cba5b7abed9 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:30:03 +0800 Subject: [PATCH] host: two remote-chat name paths cut a peer name at 40 UTF-16 units --- .../1990-p321-remote-chat-name-bound.fixed.md | 1 + core/src/host/remote-chat.js | 4 +-- core/test/remote-chat.test.js | 36 +++++++++++++++++++ docs/subsystems/core-host.md | 2 +- patches/ds-bot/files/src/host/remote-chat.js | 4 +-- patches/ds-bot/files/test/remote-chat.test.js | 36 +++++++++++++++++++ 6 files changed, 78 insertions(+), 5 deletions(-) create mode 100644 changelog.d/1990-p321-remote-chat-name-bound.fixed.md diff --git a/changelog.d/1990-p321-remote-chat-name-bound.fixed.md b/changelog.d/1990-p321-remote-chat-name-bound.fixed.md new file mode 100644 index 000000000..731e76b09 --- /dev/null +++ b/changelog.d/1990-p321-remote-chat-name-bound.fixed.md @@ -0,0 +1 @@ +The name a friend's Bot keeps is cut on code points at both places the chat stores it: creating the chat (40) and refreshing its name (40) used a bare `slice`, so a name whose bound fell between the halves of a surrogate pair became a lone high surrogate β€” shown as U+FFFD once `roster.createBot` bounded it again. Both now go through the shared `cut`, so the longest whole-code-point prefix within the same 40-unit budget is kept, and a short name is stored word for word ([#1990](https://github.com/ThinkFlowLab/FlyBot/issues/1990)). diff --git a/core/src/host/remote-chat.js b/core/src/host/remote-chat.js index f0de33b49..84b01699f 100644 --- a/core/src/host/remote-chat.js +++ b/core/src/host/remote-chat.js @@ -226,14 +226,14 @@ export function install(rt) { const lookChanged = applyPublishedLook(existing, { color, avatar, avatarUpdatedAt }) if (JSON.stringify(next) !== JSON.stringify(existing.remote) || (name && existing.name !== name) || lookChanged) { existing.remote = next - if (name) existing.name = String(name).slice(0, 40) + if (name) existing.name = cut(String(name), 40).text await rt.save() } return existing.id } pending.set(handle, name) try { - const bot = await rt.createBot({ name: String(name || handle).slice(0, 40), model: `${REMOTE_PROVIDER}/${handle}`, remote: { handle, home, ...(homeName ? { homeName } : {}), plugin } }) + const bot = await rt.createBot({ name: cut(String(name || handle), 40).text, model: `${REMOTE_PROVIDER}/${handle}`, remote: { handle, home, ...(homeName ? { homeName } : {}), plugin } }) if (applyPublishedLook(bot, { color, avatar, avatarUpdatedAt })) await rt.save() return bot.id } finally { pending.delete(handle) } diff --git a/core/test/remote-chat.test.js b/core/test/remote-chat.test.js index 729ddc80a..6fd5ffc96 100644 --- a/core/test/remote-chat.test.js +++ b/core/test/remote-chat.test.js @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { remoteTurn, isRemoteChat, REMOTE_PROVIDER, install, notSentLine } from '../src/host/remote-chat.js'; import { isHomeLevelRemote, isListedRemoteChat, isShownRemoteChat } from '../src/shared/remote-label.js'; import { feishuReply } from '../src/host/connectors.js'; +import { cut } from '../src/host/text.js'; const user = (content, source, id = 'm1') => ({ role: 'user', id, content, ...(source ? { source } : {}) }); @@ -330,3 +331,38 @@ test('#1979: an abbreviated remote reply is well formed at 80 units', () => { assert.equal(line, 'Not sent: ' + kept + '. Try again.', 'the pair at the bound is dropped, not split'); assert.ok(body.startsWith(kept), 'a code-point prefix of the reply'); }); + +// P-321 (#1979 follow-up): a friend's Bot name arrives from another home, and both places the chat +// stores it bounded it with a bare `slice(0, 40)`. A name whose 40th UTF-16 unit falls inside a +// surrogate pair came out broken: on create, roster.createBot's own bound (roster.js:65) turns the +// lone high surrogate into U+FFFD; on refresh the lone high surrogate is stored as it is. The bound +// is 40 code points at both sites, and the value is the same one both ways. +test('P-321: a friend\'s Bot name from another home is cut at 40 code points on create and on refresh', async () => { + const state = { bots: {} } + let created = 0 + const rt = { + state, load: async () => {}, save: async () => {}, warn() {}, ctx: {}, + // roster.createBot's own bound (roster.js:65: `cut(String(botName ?? '').trim(), 40).text`), so the + // name kept here is the one the app would keep. + createBot: async ({ name, model, remote }) => { + const bot = { id: `b${++created}`, name: cut(String(name ?? '').trim(), 40).text, model, remote } + state.bots[bot.id] = bot + return bot + }, + } + install(rt) + const member = { handle: '@long.name.friend.home', name: `${'a'.repeat(39)}πŸ˜€tail`, home: '@friend.home', plugin: 'social' } + const id = await rt.remoteChats.open(member) + assert.equal(state.bots[id].name.isWellFormed(), true, 'the created name is well formed, never a U+FFFD for a split pair') + assert.equal(state.bots[id].name, 'a'.repeat(39), 'the pair at the bound is dropped, not split') + // Opening the same handle again with the same name refreshes the record through the other site. + await rt.remoteChats.open(member) + assert.equal(state.bots[id].name.isWellFormed(), true, 'the refreshed name is well formed, never a lone high surrogate') + assert.equal(state.bots[id].name, 'a'.repeat(39), 'and it is the same value the create kept') + // The control: a short name is stored word for word through both sites. + const short = { handle: '@bob.friend.home', name: 'Bob', home: '@friend.home', plugin: 'social' } + const sid = await rt.remoteChats.open(short) + assert.equal(state.bots[sid].name, 'Bob') + await rt.remoteChats.open(short) + assert.equal(state.bots[sid].name, 'Bob') +}) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 0bb279ab1..1b8810d2c 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -38,7 +38,7 @@ The server side of the patched `ds-bot` core (`core/`): the part of [ds-bot-core | --- | --- | | `core/index.js` | Server entry. | | `core/src/host/` | Host-side helpers (non-hooks). | -| `core/src/host/text.js` | The pure text helpers the host modules share: message text, names, model refs, cutting and estimating text, and the clock format of chat anchors. Since #1895 `codePoints(text)` (a step per code point, no array built since #1898) is how the host counts a limit β€” one character costs one whatever its UTF-16 width β€” and the bounds this change puts through it are: a memory entry of 300 and a `MEMORY.md` of 4,000 or a topic of 16,000 (the refusal and the size note print the code-point number), a scheduled task's name of 120, a message of 20,000, and the Project memory entry of 600 in both writers, so an emoji is never charged twice. Both cuts land on a code point boundary: `clip` and `excerpt` move back rather than keep half of a surrogate pair, so a clipped emoji reaches a UTF-8 encoder as the character and not as U+FFFD ([#1907](https://github.com/ThinkFlowLab/FlyBot/issues/1907)). Since #1912 that rule is one exported helper rather than a private detail: `cut(text, max)` answers `{ text, cut }` with the longest prefix that stops before a split pair, and the archive writers that used to `slice(0, max)` go through it β€” the `oneLine` of `decisions.js` and `meetings.js` (into `decisions.jsonl` and `decisions/-.md`), `claimLine` in `groups.js`, the report cap in `diagnostics.js`, `cleanLabel` in `flybot-feed.js` and `clean` in `browser.js` β€” so a decision or a meeting record holds the whole character instead of U+FFFD. Since #1916 the boundary rule itself lives in `core/src/shared/text-boundary.js` (`codePointStart`, `codePointEnd`, `cut`), and `host/text.js` builds its `cut` on it so a shared module can bound text too; the eight remaining `slice(0, max)` writers follow it β€” the claim cue's fallback reference (60), the task lead's name in a member prompt (80), a failed group Session's error text (300), `read_own_chat`'s `query` (80) and `around` (40), the open-page address and title (2000/300), the diagnostics browser-log line and its code (500/40), and `cleanPeerName` in `shared/remote-label.js` (60) β€” each keeping the longest prefix that stops before a split pair, `length <= max` and a byte prefix of the input. Since #1918 the memory review's own two bounds go through the same rule: its 800-unit context `clip` cuts through `cut`, and its 3,000-unit two-ended `ends` takes the head through `cut` and moves the tail's start back with `codePointStart`, giving the head back the unit a stepped-back tail keeps, so the prompt and the log hold whole characters within the same budget; the exported `clip`'s reason is optional there too, so a two-argument call reads `… (cut)` instead of `… (cut; undefined)` while a call with a reason is unchanged. Since #1923 the four bounds whose text crosses an owner boundary go through the same `cut` β€” `cleanPublishedName` in `hooks.js` (60, into `state.flybot.remote`), the group member line's `displayName` 60 and `ownerName` 40 in `tools.js`, `label` in `reach.js` (60, the `othersInRoom` labels), and the local `clip` in `shared/room-share.js` (its per-field caps included) β€” so a published, listed or relayed name keeps whole characters and never reaches a UTF-8 encoder as U+FFFD ([#1923](https://github.com/ThinkFlowLab/FlyBot/issues/1923)). The same rule now covers the host's own line bounds: the busy ack's 600-unit task and 1,500-unit message text in `busy-ack.js`, the group line's notice (100), a Bot's job line (120), the group answer echo (300) and the rework line's note (200) in `tools.js`, and `cleanName`'s 60-unit peer name in `delivery.js`, which lands in `fenceUntrusted`'s `from="…"` attribute β€” so none of them can end in half a surrogate pair. The five bounds `turns.js` keeps go through `cut` as well: a failure's 500-unit message and the 200-unit message of its line (`turnFailure`, `failureLine`), the 79-unit step a running call shows (the `…` is appended only when `cut` dropped something, never by a length guess), an approval card's 300-unit description and 400-unit command, and the 160-unit reason a failed turn leaves for the inbox. Since #1963 `cut` also flattens its input before it bounds β€” `String(text).toWellFormed()` first, `codePointEnd` on the flat string β€” so a title, a note or a name that arrives already carrying an unpaired surrogate is stored as U+FFFD instead of the lone surrogate, and U+FFFD is one UTF-16 unit for the one it replaces, so a well-formed input keeps its exact prefix and its `cut` flag ([#1963](https://github.com/ThinkFlowLab/FlyBot/issues/1963)). Since #1979 the six remaining bounds on text another home or another person supplied go through it too β€” a connector item name (80, `connectors.js`), a role label another home set on a face-room seat (80, `face-lead.js`), a cross-home group name (200, `project-team.js`), a capability description from a peer server (2,000, `capabilities.js`), the remote home's own error text (157, `remote-chat.js`) and an abbreviated remote reply (80, same file) β€” so each keeps the longest prefix that stops before a split pair, is well formed and is a code-point prefix of the input, while `remote-chat.js`'s `…` and trailing-period strip still apply to the cut value ([#1979](https://github.com/ThinkFlowLab/FlyBot/issues/1979)). | +| `core/src/host/text.js` | The pure text helpers the host modules share: message text, names, model refs, cutting and estimating text, and the clock format of chat anchors. Since #1895 `codePoints(text)` (a step per code point, no array built since #1898) is how the host counts a limit β€” one character costs one whatever its UTF-16 width β€” and the bounds this change puts through it are: a memory entry of 300 and a `MEMORY.md` of 4,000 or a topic of 16,000 (the refusal and the size note print the code-point number), a scheduled task's name of 120, a message of 20,000, and the Project memory entry of 600 in both writers, so an emoji is never charged twice. Both cuts land on a code point boundary: `clip` and `excerpt` move back rather than keep half of a surrogate pair, so a clipped emoji reaches a UTF-8 encoder as the character and not as U+FFFD ([#1907](https://github.com/ThinkFlowLab/FlyBot/issues/1907)). Since #1912 that rule is one exported helper rather than a private detail: `cut(text, max)` answers `{ text, cut }` with the longest prefix that stops before a split pair, and the archive writers that used to `slice(0, max)` go through it β€” the `oneLine` of `decisions.js` and `meetings.js` (into `decisions.jsonl` and `decisions/-.md`), `claimLine` in `groups.js`, the report cap in `diagnostics.js`, `cleanLabel` in `flybot-feed.js` and `clean` in `browser.js` β€” so a decision or a meeting record holds the whole character instead of U+FFFD. Since #1916 the boundary rule itself lives in `core/src/shared/text-boundary.js` (`codePointStart`, `codePointEnd`, `cut`), and `host/text.js` builds its `cut` on it so a shared module can bound text too; the eight remaining `slice(0, max)` writers follow it β€” the claim cue's fallback reference (60), the task lead's name in a member prompt (80), a failed group Session's error text (300), `read_own_chat`'s `query` (80) and `around` (40), the open-page address and title (2000/300), the diagnostics browser-log line and its code (500/40), and `cleanPeerName` in `shared/remote-label.js` (60) β€” each keeping the longest prefix that stops before a split pair, `length <= max` and a byte prefix of the input. Since #1918 the memory review's own two bounds go through the same rule: its 800-unit context `clip` cuts through `cut`, and its 3,000-unit two-ended `ends` takes the head through `cut` and moves the tail's start back with `codePointStart`, giving the head back the unit a stepped-back tail keeps, so the prompt and the log hold whole characters within the same budget; the exported `clip`'s reason is optional there too, so a two-argument call reads `… (cut)` instead of `… (cut; undefined)` while a call with a reason is unchanged. Since #1923 the four bounds whose text crosses an owner boundary go through the same `cut` β€” `cleanPublishedName` in `hooks.js` (60, into `state.flybot.remote`), the group member line's `displayName` 60 and `ownerName` 40 in `tools.js`, `label` in `reach.js` (60, the `othersInRoom` labels), and the local `clip` in `shared/room-share.js` (its per-field caps included) β€” so a published, listed or relayed name keeps whole characters and never reaches a UTF-8 encoder as U+FFFD ([#1923](https://github.com/ThinkFlowLab/FlyBot/issues/1923)). The same rule now covers the host's own line bounds: the busy ack's 600-unit task and 1,500-unit message text in `busy-ack.js`, the group line's notice (100), a Bot's job line (120), the group answer echo (300) and the rework line's note (200) in `tools.js`, and `cleanName`'s 60-unit peer name in `delivery.js`, which lands in `fenceUntrusted`'s `from="…"` attribute β€” so none of them can end in half a surrogate pair. The five bounds `turns.js` keeps go through `cut` as well: a failure's 500-unit message and the 200-unit message of its line (`turnFailure`, `failureLine`), the 79-unit step a running call shows (the `…` is appended only when `cut` dropped something, never by a length guess), an approval card's 300-unit description and 400-unit command, and the 160-unit reason a failed turn leaves for the inbox. Since #1963 `cut` also flattens its input before it bounds β€” `String(text).toWellFormed()` first, `codePointEnd` on the flat string β€” so a title, a note or a name that arrives already carrying an unpaired surrogate is stored as U+FFFD instead of the lone surrogate, and U+FFFD is one UTF-16 unit for the one it replaces, so a well-formed input keeps its exact prefix and its `cut` flag ([#1963](https://github.com/ThinkFlowLab/FlyBot/issues/1963)). Since #1979 the six remaining bounds on text another home or another person supplied go through it too β€” a connector item name (80, `connectors.js`), a role label another home set on a face-room seat (80, `face-lead.js`), a cross-home group name (200, `project-team.js`), a capability description from a peer server (2,000, `capabilities.js`), the remote home's own error text (157, `remote-chat.js`) and an abbreviated remote reply (80, same file) β€” so each keeps the longest prefix that stops before a split pair, is well formed and is a code-point prefix of the input, while `remote-chat.js`'s `…` and trailing-period strip still apply to the cut value ([#1979](https://github.com/ThinkFlowLab/FlyBot/issues/1979)). The two bounds on a friend's Bot's own name in that file β€” the name its chat is created with (40) and the name a refresh writes (40) β€” go through `cut` as well, so a refresh cannot store a lone high surrogate and a create no longer hands `roster.createBot` half a pair to flatten into U+FFFD; the budget stays 40 and a shorter name is stored word for word. | | `core/src/host/prompt.js` | The Bot system prompt; a Main Bot's section is headed `You are the Main Bot` and uses that one term, the one the UI shows (#276). The team rules state the idle default (#699): a Bot does nothing until the user addresses it, it is @-mentioned in a group, its parent gives it a task, or unfinished tasks it was already given are still on its board. It does not invent work. A coordinator acts on a user task rather than on its own initiative β€” an addressed Bot answers as before. | | `core/src/host/room-membership.js` | Typed room participants (human / local bot / granted remote), authorship, membership-window history (#50); rooms with friends' Bots or other owners take granted local Bots only and evict a Bot that stops qualifying (#90). Face-to-face rooms (#83) reuse this model (`origin: 'face-to-face'`, room-only remotes without a person-grant, `upsertFaceRoom` / `syncFaceParticipants` / `applyFaceRoomLine`); a face-room local Bot is `viaCode` (brought by number), `evictUnshared` skips face rooms (membership is the server's), and a Bot that returns to the member list drops the empty local `left` line (#236). Refusing an ungranted local Bot next to a friend's Bot throws `ungrantedRefusal`: a message that names the Bot, the friend and the two ways out, plus `error.details` `{ reason: 'ungranted-local', bots, remotes }`; `channel.js` `fail` forwards a plain-object `details` (empty otherwise) and `client/api.js` puts it on the thrown error, where `client/new-chat.js` draws a card (**Copy invite link for {Bot}** via `__flybot__.social.createInvite`, **Leave {Bot} out**) read through `ungrantedLocalFix` in `shared/remote-label.js`; the same file's `copiedToClipboard` reports whether the invite link really reached the clipboard (none, or a refused write, is `false`), and the card then shows the link to copy by hand instead of saying it was copied. | | `core/src/host/face-mentions.js` | Who "@" calls in a face-to-face room (#287): every alias of a participant (`faceAliases`, kept in `shared/remote-label.js` so the bubble chips the same names, #305; bare name, the picker label `'s Chief` / ` Β· 's Bot`, ` Β· `, handle), local and friend's Bots told apart. `faceMentions().handles` is the handle of every Bot a line names (the explicit mention list a sender attaches, #293) and `mentionedByHandle` resolves a received list to my Bots (`known` counts handles the room has). | diff --git a/patches/ds-bot/files/src/host/remote-chat.js b/patches/ds-bot/files/src/host/remote-chat.js index f0de33b49..84b01699f 100644 --- a/patches/ds-bot/files/src/host/remote-chat.js +++ b/patches/ds-bot/files/src/host/remote-chat.js @@ -226,14 +226,14 @@ export function install(rt) { const lookChanged = applyPublishedLook(existing, { color, avatar, avatarUpdatedAt }) if (JSON.stringify(next) !== JSON.stringify(existing.remote) || (name && existing.name !== name) || lookChanged) { existing.remote = next - if (name) existing.name = String(name).slice(0, 40) + if (name) existing.name = cut(String(name), 40).text await rt.save() } return existing.id } pending.set(handle, name) try { - const bot = await rt.createBot({ name: String(name || handle).slice(0, 40), model: `${REMOTE_PROVIDER}/${handle}`, remote: { handle, home, ...(homeName ? { homeName } : {}), plugin } }) + const bot = await rt.createBot({ name: cut(String(name || handle), 40).text, model: `${REMOTE_PROVIDER}/${handle}`, remote: { handle, home, ...(homeName ? { homeName } : {}), plugin } }) if (applyPublishedLook(bot, { color, avatar, avatarUpdatedAt })) await rt.save() return bot.id } finally { pending.delete(handle) } diff --git a/patches/ds-bot/files/test/remote-chat.test.js b/patches/ds-bot/files/test/remote-chat.test.js index 729ddc80a..6fd5ffc96 100644 --- a/patches/ds-bot/files/test/remote-chat.test.js +++ b/patches/ds-bot/files/test/remote-chat.test.js @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { remoteTurn, isRemoteChat, REMOTE_PROVIDER, install, notSentLine } from '../src/host/remote-chat.js'; import { isHomeLevelRemote, isListedRemoteChat, isShownRemoteChat } from '../src/shared/remote-label.js'; import { feishuReply } from '../src/host/connectors.js'; +import { cut } from '../src/host/text.js'; const user = (content, source, id = 'm1') => ({ role: 'user', id, content, ...(source ? { source } : {}) }); @@ -330,3 +331,38 @@ test('#1979: an abbreviated remote reply is well formed at 80 units', () => { assert.equal(line, 'Not sent: ' + kept + '. Try again.', 'the pair at the bound is dropped, not split'); assert.ok(body.startsWith(kept), 'a code-point prefix of the reply'); }); + +// P-321 (#1979 follow-up): a friend's Bot name arrives from another home, and both places the chat +// stores it bounded it with a bare `slice(0, 40)`. A name whose 40th UTF-16 unit falls inside a +// surrogate pair came out broken: on create, roster.createBot's own bound (roster.js:65) turns the +// lone high surrogate into U+FFFD; on refresh the lone high surrogate is stored as it is. The bound +// is 40 code points at both sites, and the value is the same one both ways. +test('P-321: a friend\'s Bot name from another home is cut at 40 code points on create and on refresh', async () => { + const state = { bots: {} } + let created = 0 + const rt = { + state, load: async () => {}, save: async () => {}, warn() {}, ctx: {}, + // roster.createBot's own bound (roster.js:65: `cut(String(botName ?? '').trim(), 40).text`), so the + // name kept here is the one the app would keep. + createBot: async ({ name, model, remote }) => { + const bot = { id: `b${++created}`, name: cut(String(name ?? '').trim(), 40).text, model, remote } + state.bots[bot.id] = bot + return bot + }, + } + install(rt) + const member = { handle: '@long.name.friend.home', name: `${'a'.repeat(39)}πŸ˜€tail`, home: '@friend.home', plugin: 'social' } + const id = await rt.remoteChats.open(member) + assert.equal(state.bots[id].name.isWellFormed(), true, 'the created name is well formed, never a U+FFFD for a split pair') + assert.equal(state.bots[id].name, 'a'.repeat(39), 'the pair at the bound is dropped, not split') + // Opening the same handle again with the same name refreshes the record through the other site. + await rt.remoteChats.open(member) + assert.equal(state.bots[id].name.isWellFormed(), true, 'the refreshed name is well formed, never a lone high surrogate') + assert.equal(state.bots[id].name, 'a'.repeat(39), 'and it is the same value the create kept') + // The control: a short name is stored word for word through both sites. + const short = { handle: '@bob.friend.home', name: 'Bob', home: '@friend.home', plugin: 'social' } + const sid = await rt.remoteChats.open(short) + assert.equal(state.bots[sid].name, 'Bob') + await rt.remoteChats.open(short) + assert.equal(state.bots[sid].name, 'Bob') +})