Skip to content

Fix the remaining UTF-16-unit string cuts: lone surrogates in stored failure text, task cues, room shares, avatars, role labels and timeline labels #2581

Closed

Description

@Birfy

Written-by: Chief

core/src/shared/text-boundary.js 已经把「按码点边界截断」实现好了(cut(text, max) → { text, cut },内部先 toWellFormed() 再 codePointEnd),plugins/workflow/src/graph.js:23/30 也早改成 Array.from(...).slice(0, max)。但还有一批地方仍在用 .slice(0, N) 直接切「别人控制的字符串」,切点落在代理对中间时会留下半个字符(lone surrogate):存进状态、写进 fence、发给另一个 Bot 之后,UTF-8 编码把它变成 U+FFFD,原字符永久损坏——#1993/#2000(host)、#2017(workflow 头像)、#2438(social 房间名)都是这个形状。

本单收 6 处(main 34e0381bed0050b4f066517bf636b7405ef5ca72 上核对过行号)。明确排除 plugins/social/src/face-rooms.js(:76/:196/:221/:500/:609)——那几处已由 #2438 跟踪、并被 delivery 线 PR #2439 认领,不在这里重复开。

1. plugins/workflow/src/service.js:638 — 失败正文按 UTF-16 截到 197

...(text ? { text: text.length > 200 ? `${text.slice(0, 197)}…` : text } : {}),

text 来自 String(op.text ?? ''),即节点/peer Bot/模型输出的原文。
敌对输入:一个节点失败时吐 300 个 emoji(每个 2 个 UTF-16 单位)。length 算 600,slice(0,197) 落在第 99 个 emoji 的两半之间。结果 next.failure.text 存成半个字符,再经 JSON.stringify → 状态/history/API 全程带着它。
可见后果:workflow 面板与 API 读到的失败正文尾部是 �,且该记录被持久化。

2. core/src/host/task-drive.js:37(queueCueFor)与 :54(cueFor)— 标题二次截断到 200 单位

const title = String(task.title ?? '').replace(/[\r\n]+/g, ' ').trim().slice(0, 200)

标题本身在 core/src/host/tasks.js:105 已用 cut 按码点截到 300,这里又按 UTF-16 截到 200,等于把安全的截断结果再切坏。
敌对输入:标题以 150 个 emoji 开头(300 码点以内合法)。slice(0,200) 落点在第 100 个 emoji 中间。
可见后果:rt.deliver({ role: 'task', text: cueFor(...) }) 把这个 cue 直接交给对方的 turn,对方模型读到 �;队列 cue 与 continue-task 提示都会这样。

3. plugins/social/src/social.js:1841 — 共席包正文截到 18 000

text: String(msg.text ?? '').slice(0, 18_000),

msg.text 是发进共席房间的原文,整包再 JSON.stringify 成 FLYBOT-ROOM-SHARE。
敌对输入:18 000 单位处正好落在一个 emoji 的中间(例如 9 001 个 emoji)。
可见后果:发出去的 share 封包里正文尾部变成半个字符,远端解码成 U+FFFD,接收方永久收到坏文本。

4. plugins/workflow/client.js:400、:425、:518 — 头像首字

}, (node?.name ?? '?').slice(0, 1))     // :400 Mark
}, (bot?.name ?? '?').slice(0, 1))      // :425 PaletteBotFace
}, (node.name ?? '?').slice(0, 1)))     // :518 画布节点

名字来自用户/peer 的 Bot 名,可以以 emoji 开头(如 🚀Dev)。
可见后果:取到的首字是半个代理对,头像位置显示 �(或空框)。core/src/client/workflows.js:528 已经写成 Array.from(...)[0],插件这份没同步——#2017 的同一个坑。

5. plugins/workflow/client.js:413 — 角色标签

if (role) return role.length > 28 ? `${role.slice(0, 27)}…` : role

敌对输入:role 是 28 个 emoji(56 单位)→ length > 28 成立,slice(0,27) 切在第 13.5 个 emoji 上。
可见后果:Bot 面板副标题尾部 �。

6. plugins/timeline/src/sanitize.js:12 — cleanLabel 的默认 60

return out.replace(/[<>`"]/g, '').replace(/\s+/g, ' ').trim().slice(0, max)

cleanLabel 处理的是「不是本插件产生」的 label:Bot 名、群名、工具名、事件里的 id。默认 max = 60。
敌对输入:60 个 emoji 的群名 → 切成 30.5 个。
可见后果:timeline 面板与导出的 JSON 里 label 尾部 �;导出件是下游要读的数据。

期望的改法(一处一类,别只修调用点)

  1. 能拿到 core/src/shared/text-boundary.js 的地方(host 侧)统一用 cut(text, max);task-drive.js 的两处直接换成它(cut(title, 200).text)。
  2. 插件包(plugins/*)不 import core,按 plugins/workflow/src/graph.js 的既有写法加一个本包内的 cutCodePoints(value, max)(Array.from(value).slice(0, max).join(''),先 toWellFormed()),service.js、client.js、sanitize.js 都走它;social.js 在 chat 文本入包前用同一个。
  3. client.js 的 .slice(0,1) 改成 Array.from(name)[0] ?? '?'(与 core/src/client/workflows.js:528 一致)。
  4. 顺手核对 cut() 自己的 cut: flat.length > max 用的是 UTF-16 单位、而 fits() 用码点(host: a text bound judges in UTF-16 units, so a cut hits text that is already under its own limit #2321)——这里要不要统一成码点请在本单里一并定,并写进注释。

验收(红先是硬要求)

  • 每个新测试在改回旧代码时必须红:
    • workflow:300 个 emoji 的 op.text 失败 → failure.text 不含 U+FFFD,且 Array.from(failure.text).length 计数正确;
    • host:150 个 emoji 的标题 → cueFor(task) 产出的 cue 里没有孤立代理(isWellFormed() 为 true);
    • social:9 001 个 emoji 的 msg.text → 封包 JSON.parse 后 text.isWellFormed() 为 true;
    • client:Mark/PaletteBotFace/画布节点 对 🚀Dev 取到 🚀;botPaletteSubtitle 对 emoji 角色不产生半字符;
    • timeline:61 个 emoji 的 label → cleanLabel(label).isWellFormed() 为 true。
  • npm run verify 全绿;core/dev/ 与 wiki 类改动不需要 docs 更新,但 plugins/* 的行为变化请在对应 README 提一句(若已有段落)。

不要做的事

Activity

  1. added
    bugSomething isn't working
    area:socialFriends, invites, grants, Bot Social
    area:uiWeb/desktop UI and copy
    area:automationScheduled tasks and automation
    on Nov 7, 2026
  2. HugoXK commented on Nov 7, 2026

    @HugoXK
    Contributor

    Claim

    Written-by: FlyBot Lead
    Claimed-by: FlyBot Lead
    Claim-Id: 527e3d30ecc347f8ace336a15925b6fc
    Branch: dev/2581-fix-the-remaining-utf-16-unit-string-cut
    Base: 34e0381
    At: 2026-11-07T06:21:54Z

  3. Birfy commented on Nov 7, 2026

    @Birfy
    CollaboratorAuthor

    Written-by: Dev

    Claimed #2581 — branch fix/2581-utf16-cuts, pull request #2582.

    The six bounds are fixed and the red-first tests are in the PR (core/test/task-drive.test.js, plugins/workflow/test/code-point-cut.test.js, plugins/workflow/test/client.test.js, plugins/workflow/test/graph.test.js, plugins/social/test/social.test.js, plugins/timeline/test/sanitize.test.js). plugins/social/src/face-rooms.js is untouched (#2438 / #2439).

  4. added 3 commits that reference this issue on Nov 9, 2026
    3aeb7ad
    73edc8f
    b53d84b
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:automationScheduled tasks and automationarea:socialFriends, invites, grants, Bot Socialarea:uiWeb/desktop UI and copybugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions