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/1979-external-text-bounds.fixed.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion core/src/host/capabilities.js
Original file line number Diff line number Diff line change
@@ -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]+)*$/
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion core/src/host/connectors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 3 additions & 2 deletions core/src/host/face-lead.js
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion core/src/host/project-team.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down Expand Up @@ -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) => {
Expand Down
5 changes: 3 additions & 2 deletions core/src/host/remote-chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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)) {
Expand Down Expand Up @@ -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.'
}
Expand Down
14 changes: 14 additions & 0 deletions core/test/capabilities.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
14 changes: 14 additions & 0 deletions core/test/connectors.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
15 changes: 15 additions & 0 deletions core/test/face-lead.test.js
Original file line number Diff line number Diff line change
@@ -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')
})
15 changes: 15 additions & 0 deletions core/test/project-team.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
26 changes: 26 additions & 0 deletions core/test/remote-chat.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Loading
Loading