From db76c762250e9d975f680a5fbfbd8252ff372230 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Sun, 1 Nov 2026 12:59:36 +0800 Subject: [PATCH] host: bound six external-text values on code points, not UTF-16 units A connector item name, a role label another home set on a face-room seat, a cross-home group name, a capability description from a peer server, a remote home's own error text and an abbreviated remote reply still used a bare slice(0, max), so a bound that landed between the halves of a surrogate pair produced a lone high surrogate that reaches a UTF-8 encoder as U+FFFD. Each goes through the shared cut() now, with every max unchanged and the error preview's ellipsis and the reply's trailing-period strip still applied to the cut value. Six red-first tests, one per site; the mirror, the patches and the changelog fragment come with it. --- .../1979-external-text-bounds.fixed.md | 1 + core/src/host/capabilities.js | 3 +- core/src/host/connectors.js | 3 +- core/src/host/face-lead.js | 5 +-- core/src/host/project-team.js | 3 +- core/src/host/remote-chat.js | 5 +-- core/test/capabilities.test.js | 14 ++++++++ core/test/connectors.test.js | 14 ++++++++ core/test/face-lead.test.js | 15 +++++++++ core/test/project-team.test.js | 15 +++++++++ core/test/remote-chat.test.js | 26 +++++++++++++++ docs/subsystems/core-host.md | 2 +- patches/ds-bot/0001-core-fixes.patch | 33 ++++++++++++++++--- patches/ds-bot/0014-flybot-branding.patch | 16 ++++++++- patches/ds-bot/files/src/host/face-lead.js | 5 +-- patches/ds-bot/files/src/host/project-team.js | 3 +- patches/ds-bot/files/src/host/remote-chat.js | 5 +-- patches/ds-bot/files/test/face-lead.test.js | 15 +++++++++ .../ds-bot/files/test/project-team.test.js | 15 +++++++++ patches/ds-bot/files/test/remote-chat.test.js | 26 +++++++++++++++ 20 files changed, 206 insertions(+), 18 deletions(-) create mode 100644 changelog.d/1979-external-text-bounds.fixed.md create mode 100644 core/test/face-lead.test.js create mode 100644 patches/ds-bot/files/test/face-lead.test.js diff --git a/changelog.d/1979-external-text-bounds.fixed.md b/changelog.d/1979-external-text-bounds.fixed.md new file mode 100644 index 00000000..26774306 --- /dev/null +++ b/changelog.d/1979-external-text-bounds.fixed.md @@ -0,0 +1 @@ +Six bounds on text another home or another person supplied no longer cut on UTF-16 units ([#1979](https://github.com/ThinkFlowLab/FlyBot/issues/1979)): a connector item name (80), a role label another home set on a face-room seat (80), a cross-home group name (200), a capability description from a peer server (2,000), a remote home's own error text (157) and an abbreviated remote reply (80) used a bare `slice`, so a bound that landed between the halves of a surrogate pair produced a lone high surrogate — read as U+FFFD by any encoder and shown to the owner that way. Each now goes through the shared `cut`, which keeps the longest prefix that stops before a split pair within the same budget; the error preview's `…` and the reply's trailing-period strip still apply to the cut value. diff --git a/core/src/host/capabilities.js b/core/src/host/capabilities.js index e95d189e..861a0de5 100644 --- a/core/src/host/capabilities.js +++ b/core/src/host/capabilities.js @@ -1,6 +1,7 @@ import { createHash } from 'node:crypto' import { RESERVED_NAMES } from './connectors.js' import { ownEntry, retiredBot } from './store.js' +import { cut } from './text.js' const MCP_CLIENT = '@deepseek-ai/dsh-mcp-client' const NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/ @@ -94,7 +95,7 @@ export function install(rt) { const item = { name, kind, - description: String(body.description ?? '').trim().slice(0, 2000), + description: cut(String(body.description ?? '').trim(), 2000).text, createdBy: previous?.createdBy ?? (ui ? null : botId), createdAt: previous?.createdAt ?? now, updatedAt: now, diff --git a/core/src/host/connectors.js b/core/src/host/connectors.js index 634d5091..2d529734 100644 --- a/core/src/host/connectors.js +++ b/core/src/host/connectors.js @@ -20,6 +20,7 @@ import { randomUUID } from 'node:crypto' import { createNotifier } from './notify.js' import { APP_SECRET_SECRET, appCredentials, createInbound } from './feishu-inbound.js' import { createFeishuTransport } from './feishu-sdk.js' +import { cut } from './text.js' export const GITHUB = 'github' export const GITHUB_SECRET = 'GITHUB_TOKEN' @@ -200,7 +201,7 @@ export async function composioSearchApps(apiKey, { api = COMPOSIO_DEFAULTS.api, for (const item of items) { const slug = typeof item?.slug === 'string' ? item.slug.toLowerCase() : '' if (!APP_SLUG.test(slug) || found.has(slug)) continue - const name = typeof item.name === 'string' && item.name.trim() !== '' ? item.name.trim().slice(0, 80) : slug + const name = typeof item.name === 'string' && item.name.trim() !== '' ? cut(item.name.trim(), 80).text : slug found.set(slug, { slug, name }) } return [...found.values()].slice(0, MAX_FOUND) diff --git a/core/src/host/face-lead.js b/core/src/host/face-lead.js index 30873591..c3a8b781 100644 --- a/core/src/host/face-lead.js +++ b/core/src/host/face-lead.js @@ -3,6 +3,7 @@ // field, it is the first Bot of the creator's home that joined. The lead writes the one plan and // gives out the parts; every other Bot does only its part. Pure: the room record in, text out. import { cleanPeerName, ownerNameOf } from '../shared/remote-label.js' +import { cut } from './text.js' const ROLE_MAX = 80 // Same as room-membership.js currentParticipants (kept here so the two modules do not import each other). @@ -11,12 +12,12 @@ const handleKey = handle => String(handle ?? '').trim().replace(/^@+/, '').toLow // A role label another home set: one line of plain text, no markup or @, capped. export function cleanRoleLabel(value) { - return String(value ?? '') + const line = String(value ?? '') .replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ') .replace(/[[\]"`<>@]/g, '') .replace(/\s+/g, ' ') .trim() - .slice(0, ROLE_MAX) + return cut(line, ROLE_MAX).text } // What the host keeps on room.face.lead: the handle every home follows, whether the creator picked diff --git a/core/src/host/project-team.js b/core/src/host/project-team.js index af6c59a9..5fe3ef55 100644 --- a/core/src/host/project-team.js +++ b/core/src/host/project-team.js @@ -14,6 +14,7 @@ // dossier panel draws, so it carries exactly those keys and nothing that names an owner, a working // folder or a stored secret. import { rolesOf } from './project-board.js' +import { cut } from './text.js' export const DOSSIER_LIMITS = { persona: 4000, memoryHead: 4000, topics: 64, skills: 64, rooms: 32, name: 200, role: 120, model: 200, color: 64 } @@ -45,7 +46,7 @@ export function install(rt) { const roomsOf = (bot, project) => Object.values(rt.state?.rooms ?? {}) .filter(room => room !== null && typeof room === 'object' && Array.isArray(room.members) && room.members.includes(bot.id)) .filter(room => rt.projectOfMember?.('group', room.id)?.id === project.id) - .map(room => ({ id: text(room.id), name: text(room.name).slice(0, 200) })) + .map(room => ({ id: text(room.id), name: cut(text(room.name), 200).text })) .slice(0, DOSSIER_LIMITS.rooms) const rowOf = (botId, project) => { diff --git a/core/src/host/remote-chat.js b/core/src/host/remote-chat.js index 88f5278a..f0de33b4 100644 --- a/core/src/host/remote-chat.js +++ b/core/src/host/remote-chat.js @@ -15,6 +15,7 @@ import { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm' import { SOURCE_KIND } from './constants.js' import { cleanName, fenceUntrusted } from './delivery.js' import { isHomeLevelRemote, normRemoteHandle } from '../shared/remote-label.js' +import { cut } from './text.js' export const REMOTE_PROVIDER = 'flybot-remote' export const REMOTE_FORM = 'remote-chat' @@ -27,7 +28,7 @@ const textOf = content => (Array.isArray(content) ? content.filter(block => bloc // English line with a next step; never dump `Bot Social send_as_member: …`. export function notSentLine(error) { const raw = String(error?.message ?? error ?? '').replace(/\s+/g, ' ').trim() - if (/^Not sent:/i.test(raw)) return raw.length > 160 ? `${raw.slice(0, 157)}…` : raw + if (/^Not sent:/i.test(raw)) return raw.length > 160 ? `${cut(raw, 157).text}…` : raw const body = raw.replace(/^Bot Social\s+[A-Za-z0-9_.-]+:\s*/i, '').trim() const blob = `${raw} ${error?.code ?? ''}` if (/paused|turn budget|loop detected|emergency stop/i.test(blob)) { @@ -73,7 +74,7 @@ export function notSentLine(error) { return 'Not sent: Bot Social did not answer. Try again.' } // One "Try again." only: an earlier layer may already have said it (#954), and two reads as a stutter. - const short = (body || raw).slice(0, 80).replace(/\.$/, '').replace(/\.?\s*Try again\.?$/i, '').replace(/\.$/, '') + const short = cut(body || raw, 80).text.replace(/\.$/, '').replace(/\.?\s*Try again\.?$/i, '').replace(/\.$/, '') if (!short || /^[a-z0-9_]+$/i.test(short)) { return 'Not sent: the message could not leave. Try again.' } diff --git a/core/test/capabilities.test.js b/core/test/capabilities.test.js index ba017593..b758ff56 100644 --- a/core/test/capabilities.test.js +++ b/core/test/capabilities.test.js @@ -447,3 +447,17 @@ test('a key for all Bots reaches a shared Bot\'s capability; a key scoped to oth assert.equal(rt.capabilityStatusesView().worker.echo.state, 'error') assert.equal(rt.capabilityStatusesView().chief.echo.state, 'ready') }) + +// #1979: a capability description comes from a peer server, and its 2000-unit bound must not end +// on the first half of a surrogate pair. +test('a capability description is cut at 2000 without splitting an emoji', async t => { + const env = await setup() + t.after(() => env.dispose()) + const description = 'a'.repeat(1999) + '😀' + 'tail' + await env.rt.capabilitySave({ name: 'echo', kind: 'skill', description, content: 'Do the work.' }, { ui: true }) + const kept = env.rt.state.capabilities.items.echo.description + assert.equal(kept.isWellFormed(), true, 'the stored description is well formed, never a lone high surrogate') + assert.equal(kept, 'a'.repeat(1999), 'the 2000th unit was the first half of the pair, so the pair is dropped, not split') + assert.ok(description.startsWith(kept), 'a code-point prefix of the description the peer sent') + assert.ok(kept.length <= 2000, 'the budget of 2000 UTF-16 units is unchanged') +}) diff --git a/core/test/connectors.test.js b/core/test/connectors.test.js index d8ad10ce..b872cf7e 100644 --- a/core/test/connectors.test.js +++ b/core/test/connectors.test.js @@ -943,3 +943,17 @@ test('the Connectors page searches apps with the pasted key before connecting (n assert.equal(github.ok, false) assert.match(github.error.message, /no apps to pick/) }) + +// #1979: a connector item name is text the connector side supplies, and its 80-unit bound must not +// end on the first half of a surrogate pair: that value reaches the owner's screen as U+FFFD. +test('a Composio app name cut at 80 does not split an emoji', async () => { + const key = composioKey() + const name = 'a'.repeat(79) + '😀' + 'tail' + const found = await composioSearchApps(key, { fetch: async () => answer(200, { items: [{ slug: 'gmail', name }] }) }) + assert.equal(found.length, 1, 'the app is still listed') + const kept = found[0].name + assert.equal(kept.isWellFormed(), true, 'the kept name is well formed, never a lone high surrogate') + assert.equal(kept, 'a'.repeat(79), 'the 80th unit was the first half of the pair, so the pair is dropped, not split') + assert.ok(name.startsWith(kept), 'a code-point prefix of the name Composio supplied') + assert.ok(kept.length <= 80, 'the budget of 80 UTF-16 units is unchanged') +}) diff --git a/core/test/face-lead.test.js b/core/test/face-lead.test.js new file mode 100644 index 00000000..c823d7ab --- /dev/null +++ b/core/test/face-lead.test.js @@ -0,0 +1,15 @@ +// #1979: a role label another home set on a face-room seat is bounded at 80. A cut that lands between +// the halves of a surrogate pair returns a lone high surrogate, which reaches a UTF-8 encoder as +// U+FFFD, so the bound goes through `cut()` and keeps a well-formed code-point prefix. +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { cleanRoleLabel } from '../src/host/face-lead.js' + +test('a role label another home set is cut at 80 without splitting an emoji', () => { + const label = 'a'.repeat(79) + '😀' + 'tail' + const cleaned = cleanRoleLabel(label) + assert.equal(cleaned.isWellFormed(), true, 'the cleaned label is well formed, never a lone high surrogate') + assert.equal(cleaned, 'a'.repeat(79), 'the 80th unit was the first half of the pair, so the pair is dropped, not split') + assert.ok(label.startsWith(cleaned), 'a code-point prefix of the label another home supplied') + assert.ok(cleaned.length <= 80, 'the budget of 80 UTF-16 units is unchanged') +}) diff --git a/core/test/project-team.test.js b/core/test/project-team.test.js index b4e10612..7c7ada55 100644 --- a/core/test/project-team.test.js +++ b/core/test/project-team.test.js @@ -173,3 +173,18 @@ test('project-team reads with the project-read class of caller, including a sign const unknown = await callAs('project-team', { id: 'project-none' }, 'tok-owner') assert.deepEqual(unknown.value.rows, [], 'an unknown Project answers no rows instead of an error') }) + +// #1979: a group name is text another home supplied, and its 200-unit bound must not end on the +// first half of a surrogate pair. +test('project-team cuts a room name from another home at 200 without splitting an emoji', async (t) => { + const { callAs, rt, stop } = await start() + t.after(stop) + const name = 'a'.repeat(199) + '😀' + 'tail' + rt.state.rooms.r1.name = name + const rows = (await callAs('project-team', { id: 'project-1' })).value.rows + const kept = rows[0].rooms.find(room => room.id === 'r1').name + assert.equal(kept.isWellFormed(), true, 'the group name is well formed, never a lone high surrogate') + assert.equal(kept, 'a'.repeat(199), 'the 200th unit was the first half of the pair, so the pair is dropped, not split') + assert.ok(name.startsWith(kept), 'a code-point prefix of the group name') + assert.ok(kept.length <= 200, 'the budget of 200 UTF-16 units is unchanged') +}) diff --git a/core/test/remote-chat.test.js b/core/test/remote-chat.test.js index 39b95d85..729ddc80 100644 --- a/core/test/remote-chat.test.js +++ b/core/test/remote-chat.test.js @@ -304,3 +304,29 @@ test('a reply typed into a feishu chat leaves on the Feishu webhook (#1266)', as assert.equal(said, '', 'nothing is drawn for a send that left') assert.deepEqual(posts, [['https://open.feishu.cn/open-apis/bot/v2/hook/abc', 'the answer']]) }) + +// #1979: the remote home's own error text is cut at 157 units and ends in "…"; a cut that lands +// between the halves of a surrogate pair hands the owner a lone high surrogate. +test('#1979: a long remote error keeps a well-formed 157-unit tail before the ellipsis', () => { + const raw = 'Not sent: ' + 'a'.repeat(146) + '😀' + 'tail'; + assert.ok(raw.length > 160, 'the long-line branch only runs past 160 units'); + const line = notSentLine(new Error(raw)); + assert.equal(line.isWellFormed(), true, 'the preview is well formed, never a lone high surrogate'); + assert.ok(line.endsWith('…'), 'the long line still ends in the ellipsis'); + const kept = line.slice(0, -1); + assert.equal(kept, 'Not sent: ' + 'a'.repeat(146), 'the pair at the bound is dropped, not split'); + assert.ok(raw.startsWith(kept), 'a code-point prefix of what the remote home wrote'); + assert.ok(kept.length <= 157, 'the budget of 157 UTF-16 units is unchanged'); +}); + +// #1979: a remote reply is abbreviated to 80 units before the trailing-period strip, so an emoji at +// the bound must not leave a lone high surrogate in the sentence the owner reads. +test('#1979: an abbreviated remote reply is well formed at 80 units', () => { + const body = 'the remote said ' + 'a'.repeat(63) + '😀' + 'tail'; + const kept = 'the remote said ' + 'a'.repeat(63); + assert.equal(kept.length, 79, 'the pair starts at unit 79, so a plain slice(0, 80) keeps half of it'); + const line = notSentLine(new Error('Bot Social send_as_member: ' + body)); + assert.equal(line.isWellFormed(), true, 'the sentence is well formed, never a lone high surrogate'); + 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'); +}); diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 6a4c7bf2..9361dcd7 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. 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)). | +| `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. 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/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/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 1afc90e5..33faedb3 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -3125,10 +3125,11 @@ diff --git a/src/host/approvals.js b/src/host/approvals.js diff --git a/src/host/capabilities.js b/src/host/capabilities.js --- a/src/host/capabilities.js +++ b/src/host/capabilities.js -@@ -1,4 +1,6 @@ +@@ -1,4 +1,7 @@ import { createHash } from 'node:crypto' +import { RESERVED_NAMES } from './connectors.js' +import { ownEntry, retiredBot } from './store.js' ++import { cut } from './text.js' const MCP_CLIENT = '@deepseek-ai/dsh-mcp-client' const NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/ @@ -3143,6 +3144,15 @@ diff --git a/src/host/capabilities.js b/src/host/capabilities.js } return name } +@@ -92,7 +92,7 @@ export function install(rt) { + const item = { + name, + kind, +- description: String(body.description ?? '').trim().slice(0, 2000), ++ description: cut(String(body.description ?? '').trim(), 2000).text, + createdBy: previous?.createdBy ?? (ui ? null : botId), + createdAt: previous?.createdAt ?? now, + updatedAt: now, @@ -139,7 +139,7 @@ export function install(rt) { await rt.load() writeCheck() @@ -3344,7 +3354,7 @@ diff --git a/src/host/chat-index.js b/src/host/chat-index.js diff --git a/src/host/connectors.js b/src/host/connectors.js --- a/src/host/connectors.js +++ b/src/host/connectors.js -@@ -1,27 +1,57 @@ +@@ -1,27 +1,58 @@ -// Connectors: outside services every Bot can use. GitHub is the first one. Its tools come -// from GitHub's MCP server through DSH's own MCP client, mounted on the plugin context, -// so every Bot and Session gets them as mcp__github__. @@ -3376,6 +3386,7 @@ diff --git a/src/host/connectors.js b/src/host/connectors.js +import { createNotifier } from './notify.js' +import { APP_SECRET_SECRET, appCredentials, createInbound } from './feishu-inbound.js' +import { createFeishuTransport } from './feishu-sdk.js' ++import { cut } from './text.js' export const GITHUB = 'github' export const GITHUB_SECRET = 'GITHUB_TOKEN' @@ -3550,7 +3561,7 @@ diff --git a/src/host/connectors.js b/src/host/connectors.js + for (const item of items) { + const slug = typeof item?.slug === 'string' ? item.slug.toLowerCase() : '' + if (!APP_SLUG.test(slug) || found.has(slug)) continue -+ const name = typeof item.name === 'string' && item.name.trim() !== '' ? item.name.trim().slice(0, 80) : slug ++ const name = typeof item.name === 'string' && item.name.trim() !== '' ? cut(item.name.trim(), 80).text : slug + found.set(slug, { slug, name }) + } + return [...found.values()].slice(0, MAX_FOUND) @@ -8036,7 +8047,7 @@ diff --git a/test/connectors.test.js b/test/connectors.test.js }) test('a GitHub address without https refuses to send the token', async (t) => { -@@ -165,10 +165,730 @@ test('a GitHub address without https refuses to send the token', async (t) => { +@@ -165,10 +165,744 @@ test('a GitHub address without https refuses to send the token', async (t) => { let asked = false globalThis.fetch = async () => { asked = true; return answer(200, { login: 'octo' }) } const { ctx, home, mounts } = setup({ github: { url: 'http://mcp.test/', api: 'https://gh.test' } }) @@ -8768,6 +8779,20 @@ diff --git a/test/connectors.test.js b/test/connectors.test.js + assert.equal(github.ok, false) + assert.match(github.error.message, /no apps to pick/) +}) ++ ++// #1979: a connector item name is text the connector side supplies, and its 80-unit bound must not ++// end on the first half of a surrogate pair: that value reaches the owner's screen as U+FFFD. ++test('a Composio app name cut at 80 does not split an emoji', async () => { ++ const key = composioKey() ++ const name = 'a'.repeat(79) + '😀' + 'tail' ++ const found = await composioSearchApps(key, { fetch: async () => answer(200, { items: [{ slug: 'gmail', name }] }) }) ++ assert.equal(found.length, 1, 'the app is still listed') ++ const kept = found[0].name ++ assert.equal(kept.isWellFormed(), true, 'the kept name is well formed, never a lone high surrogate') ++ assert.equal(kept, 'a'.repeat(79), 'the 80th unit was the first half of the pair, so the pair is dropped, not split') ++ assert.ok(name.startsWith(kept), 'a code-point prefix of the name Composio supplied') ++ assert.ok(kept.length <= 80, 'the budget of 80 UTF-16 units is unchanged') ++}) diff --git a/test/follow-through.test.js b/test/follow-through.test.js --- a/test/follow-through.test.js +++ b/test/follow-through.test.js diff --git a/patches/ds-bot/0014-flybot-branding.patch b/patches/ds-bot/0014-flybot-branding.patch index 269102a9..dbe92db8 100644 --- a/patches/ds-bot/0014-flybot-branding.patch +++ b/patches/ds-bot/0014-flybot-branding.patch @@ -1716,7 +1716,7 @@ diff --git a/test/capabilities.test.js b/test/capabilities.test.js }) test('read-only secret reload does not requeue capability reconciliation', async t => { -@@ -425,3 +425,25 @@ test('mounts agent-only MCP and skills, remounts changed config, resolves scoped +@@ -425,3 +425,39 @@ test('mounts agent-only MCP and skills, remounts changed config, resolves scoped assert.equal(mounts.findLast(mount => mount.sessionId === 'chief' && mount.plugin.inject?.includes('skills')).disposed, true) assert.equal(rt.capabilityStatusesView().chief.team, undefined) }) @@ -1742,6 +1742,20 @@ diff --git a/test/capabilities.test.js b/test/capabilities.test.js + assert.equal(rt.capabilityStatusesView().worker.echo.state, 'error') + assert.equal(rt.capabilityStatusesView().chief.echo.state, 'ready') +}) ++ ++// #1979: a capability description comes from a peer server, and its 2000-unit bound must not end ++// on the first half of a surrogate pair. ++test('a capability description is cut at 2000 without splitting an emoji', async t => { ++ const env = await setup() ++ t.after(() => env.dispose()) ++ const description = 'a'.repeat(1999) + '😀' + 'tail' ++ await env.rt.capabilitySave({ name: 'echo', kind: 'skill', description, content: 'Do the work.' }, { ui: true }) ++ const kept = env.rt.state.capabilities.items.echo.description ++ assert.equal(kept.isWellFormed(), true, 'the stored description is well formed, never a lone high surrogate') ++ assert.equal(kept, 'a'.repeat(1999), 'the 2000th unit was the first half of the pair, so the pair is dropped, not split') ++ assert.ok(description.startsWith(kept), 'a code-point prefix of the description the peer sent') ++ assert.ok(kept.length <= 2000, 'the budget of 2000 UTF-16 units is unchanged') ++}) diff --git a/test/dsh-update.test.js b/test/dsh-update.test.js --- a/test/dsh-update.test.js +++ b/test/dsh-update.test.js diff --git a/patches/ds-bot/files/src/host/face-lead.js b/patches/ds-bot/files/src/host/face-lead.js index 30873591..c3a8b781 100644 --- a/patches/ds-bot/files/src/host/face-lead.js +++ b/patches/ds-bot/files/src/host/face-lead.js @@ -3,6 +3,7 @@ // field, it is the first Bot of the creator's home that joined. The lead writes the one plan and // gives out the parts; every other Bot does only its part. Pure: the room record in, text out. import { cleanPeerName, ownerNameOf } from '../shared/remote-label.js' +import { cut } from './text.js' const ROLE_MAX = 80 // Same as room-membership.js currentParticipants (kept here so the two modules do not import each other). @@ -11,12 +12,12 @@ const handleKey = handle => String(handle ?? '').trim().replace(/^@+/, '').toLow // A role label another home set: one line of plain text, no markup or @, capped. export function cleanRoleLabel(value) { - return String(value ?? '') + const line = String(value ?? '') .replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ') .replace(/[[\]"`<>@]/g, '') .replace(/\s+/g, ' ') .trim() - .slice(0, ROLE_MAX) + return cut(line, ROLE_MAX).text } // What the host keeps on room.face.lead: the handle every home follows, whether the creator picked diff --git a/patches/ds-bot/files/src/host/project-team.js b/patches/ds-bot/files/src/host/project-team.js index af6c59a9..5fe3ef55 100644 --- a/patches/ds-bot/files/src/host/project-team.js +++ b/patches/ds-bot/files/src/host/project-team.js @@ -14,6 +14,7 @@ // dossier panel draws, so it carries exactly those keys and nothing that names an owner, a working // folder or a stored secret. import { rolesOf } from './project-board.js' +import { cut } from './text.js' export const DOSSIER_LIMITS = { persona: 4000, memoryHead: 4000, topics: 64, skills: 64, rooms: 32, name: 200, role: 120, model: 200, color: 64 } @@ -45,7 +46,7 @@ export function install(rt) { const roomsOf = (bot, project) => Object.values(rt.state?.rooms ?? {}) .filter(room => room !== null && typeof room === 'object' && Array.isArray(room.members) && room.members.includes(bot.id)) .filter(room => rt.projectOfMember?.('group', room.id)?.id === project.id) - .map(room => ({ id: text(room.id), name: text(room.name).slice(0, 200) })) + .map(room => ({ id: text(room.id), name: cut(text(room.name), 200).text })) .slice(0, DOSSIER_LIMITS.rooms) const rowOf = (botId, project) => { diff --git a/patches/ds-bot/files/src/host/remote-chat.js b/patches/ds-bot/files/src/host/remote-chat.js index 88f5278a..f0de33b4 100644 --- a/patches/ds-bot/files/src/host/remote-chat.js +++ b/patches/ds-bot/files/src/host/remote-chat.js @@ -15,6 +15,7 @@ import { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm' import { SOURCE_KIND } from './constants.js' import { cleanName, fenceUntrusted } from './delivery.js' import { isHomeLevelRemote, normRemoteHandle } from '../shared/remote-label.js' +import { cut } from './text.js' export const REMOTE_PROVIDER = 'flybot-remote' export const REMOTE_FORM = 'remote-chat' @@ -27,7 +28,7 @@ const textOf = content => (Array.isArray(content) ? content.filter(block => bloc // English line with a next step; never dump `Bot Social send_as_member: …`. export function notSentLine(error) { const raw = String(error?.message ?? error ?? '').replace(/\s+/g, ' ').trim() - if (/^Not sent:/i.test(raw)) return raw.length > 160 ? `${raw.slice(0, 157)}…` : raw + if (/^Not sent:/i.test(raw)) return raw.length > 160 ? `${cut(raw, 157).text}…` : raw const body = raw.replace(/^Bot Social\s+[A-Za-z0-9_.-]+:\s*/i, '').trim() const blob = `${raw} ${error?.code ?? ''}` if (/paused|turn budget|loop detected|emergency stop/i.test(blob)) { @@ -73,7 +74,7 @@ export function notSentLine(error) { return 'Not sent: Bot Social did not answer. Try again.' } // One "Try again." only: an earlier layer may already have said it (#954), and two reads as a stutter. - const short = (body || raw).slice(0, 80).replace(/\.$/, '').replace(/\.?\s*Try again\.?$/i, '').replace(/\.$/, '') + const short = cut(body || raw, 80).text.replace(/\.$/, '').replace(/\.?\s*Try again\.?$/i, '').replace(/\.$/, '') if (!short || /^[a-z0-9_]+$/i.test(short)) { return 'Not sent: the message could not leave. Try again.' } diff --git a/patches/ds-bot/files/test/face-lead.test.js b/patches/ds-bot/files/test/face-lead.test.js new file mode 100644 index 00000000..c823d7ab --- /dev/null +++ b/patches/ds-bot/files/test/face-lead.test.js @@ -0,0 +1,15 @@ +// #1979: a role label another home set on a face-room seat is bounded at 80. A cut that lands between +// the halves of a surrogate pair returns a lone high surrogate, which reaches a UTF-8 encoder as +// U+FFFD, so the bound goes through `cut()` and keeps a well-formed code-point prefix. +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { cleanRoleLabel } from '../src/host/face-lead.js' + +test('a role label another home set is cut at 80 without splitting an emoji', () => { + const label = 'a'.repeat(79) + '😀' + 'tail' + const cleaned = cleanRoleLabel(label) + assert.equal(cleaned.isWellFormed(), true, 'the cleaned label is well formed, never a lone high surrogate') + assert.equal(cleaned, 'a'.repeat(79), 'the 80th unit was the first half of the pair, so the pair is dropped, not split') + assert.ok(label.startsWith(cleaned), 'a code-point prefix of the label another home supplied') + assert.ok(cleaned.length <= 80, 'the budget of 80 UTF-16 units is unchanged') +}) diff --git a/patches/ds-bot/files/test/project-team.test.js b/patches/ds-bot/files/test/project-team.test.js index b4e10612..7c7ada55 100644 --- a/patches/ds-bot/files/test/project-team.test.js +++ b/patches/ds-bot/files/test/project-team.test.js @@ -173,3 +173,18 @@ test('project-team reads with the project-read class of caller, including a sign const unknown = await callAs('project-team', { id: 'project-none' }, 'tok-owner') assert.deepEqual(unknown.value.rows, [], 'an unknown Project answers no rows instead of an error') }) + +// #1979: a group name is text another home supplied, and its 200-unit bound must not end on the +// first half of a surrogate pair. +test('project-team cuts a room name from another home at 200 without splitting an emoji', async (t) => { + const { callAs, rt, stop } = await start() + t.after(stop) + const name = 'a'.repeat(199) + '😀' + 'tail' + rt.state.rooms.r1.name = name + const rows = (await callAs('project-team', { id: 'project-1' })).value.rows + const kept = rows[0].rooms.find(room => room.id === 'r1').name + assert.equal(kept.isWellFormed(), true, 'the group name is well formed, never a lone high surrogate') + assert.equal(kept, 'a'.repeat(199), 'the 200th unit was the first half of the pair, so the pair is dropped, not split') + assert.ok(name.startsWith(kept), 'a code-point prefix of the group name') + assert.ok(kept.length <= 200, 'the budget of 200 UTF-16 units is unchanged') +}) diff --git a/patches/ds-bot/files/test/remote-chat.test.js b/patches/ds-bot/files/test/remote-chat.test.js index 39b95d85..729ddc80 100644 --- a/patches/ds-bot/files/test/remote-chat.test.js +++ b/patches/ds-bot/files/test/remote-chat.test.js @@ -304,3 +304,29 @@ test('a reply typed into a feishu chat leaves on the Feishu webhook (#1266)', as assert.equal(said, '', 'nothing is drawn for a send that left') assert.deepEqual(posts, [['https://open.feishu.cn/open-apis/bot/v2/hook/abc', 'the answer']]) }) + +// #1979: the remote home's own error text is cut at 157 units and ends in "…"; a cut that lands +// between the halves of a surrogate pair hands the owner a lone high surrogate. +test('#1979: a long remote error keeps a well-formed 157-unit tail before the ellipsis', () => { + const raw = 'Not sent: ' + 'a'.repeat(146) + '😀' + 'tail'; + assert.ok(raw.length > 160, 'the long-line branch only runs past 160 units'); + const line = notSentLine(new Error(raw)); + assert.equal(line.isWellFormed(), true, 'the preview is well formed, never a lone high surrogate'); + assert.ok(line.endsWith('…'), 'the long line still ends in the ellipsis'); + const kept = line.slice(0, -1); + assert.equal(kept, 'Not sent: ' + 'a'.repeat(146), 'the pair at the bound is dropped, not split'); + assert.ok(raw.startsWith(kept), 'a code-point prefix of what the remote home wrote'); + assert.ok(kept.length <= 157, 'the budget of 157 UTF-16 units is unchanged'); +}); + +// #1979: a remote reply is abbreviated to 80 units before the trailing-period strip, so an emoji at +// the bound must not leave a lone high surrogate in the sentence the owner reads. +test('#1979: an abbreviated remote reply is well formed at 80 units', () => { + const body = 'the remote said ' + 'a'.repeat(63) + '😀' + 'tail'; + const kept = 'the remote said ' + 'a'.repeat(63); + assert.equal(kept.length, 79, 'the pair starts at unit 79, so a plain slice(0, 80) keeps half of it'); + const line = notSentLine(new Error('Bot Social send_as_member: ' + body)); + assert.equal(line.isWellFormed(), true, 'the sentence is well formed, never a lone high surrogate'); + 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'); +});