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).

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