Repository navigation
Description
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 尾部 �;导出件是下游要读的数据。
期望的改法(一处一类,别只修调用点)
- 能拿到
core/src/shared/text-boundary.js的地方(host 侧)统一用cut(text, max);task-drive.js的两处直接换成它(cut(title, 200).text)。 - 插件包(
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 文本入包前用同一个。 client.js的.slice(0,1)改成Array.from(name)[0] ?? '?'(与core/src/client/workflows.js:528一致)。- 顺手核对
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。
- workflow:300 个 emoji 的
npm run verify全绿;core/dev/与 wiki 类改动不需要 docs 更新,但plugins/*的行为变化请在对应 README 提一句(若已有段落)。
不要做的事
- 不要动
plugins/social/src/face-rooms.js(social: peer-supplied face-room names, roles, platform lines and join errors are capped on UTF-16 units, so a label can carry half a surrogate pair #2438 / PR social: cap face-room names, roles and platform lines on code points #2439 的范围)。 - 不要改生成物(
core/client.js、patches/**);client.js的改动只改core/src/client/,再cd core && npm run build。
Activity
HugoXK commentedon Nov 7, 2026
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
Birfy commentedon Nov 7, 2026
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.jsis untouched (#2438 / #2439).