Skip to content

fix(claude-code): 修复多行输入残留与提交指纹误匹配 - #1578

Open
deepcoldy wants to merge 3 commits into
masterfrom
fix/claude-input-submit
Open

deepcoldy wants to merge 3 commits into
masterfrom
fix/claude-input-submit

Conversation

@deepcoldy

Copy link
Copy Markdown
Owner

背景与原因

在飞书会话中向 Claude Code 提交消息时,部分会话偶现输入内容停留在终端输入框内未真正提交:

  1. 提交指纹跨会话碰撞:飞书下发给 CLI 的 prompt 外层封装了 <botmux_reminder>、<identity>、<user_message> 等 XML 信封以及引用元数据。原先 makeSubmitFingerprint 直接截取前 30 个字符作为指纹,同台机器上的所有 Claude 会话均共享相同的指纹前缀(<botmux_reminder>发给你的消)。当某个会话的首轮回车未被即时记录时,findJsonlContainingFingerprint 或跨项目目录检索 findJsonlAcrossProjectsRoot 会误匹配到同目录下其他近期有活跃的会话 JSONL,导致 worker 误判为已成功提交并篡改 pty.claudeJsonlPath,retry loop 提前终止,实际输入停留在输入框中。
  2. Claude Code 2.1 字符拦截 barrier:Claude Code 2.1 引入了对隐藏不可见字符(如零宽空格、BOM)和裸 \r 的安全检查。若 prompt 中混入此类字符,终端会中断提交并显示 Removed X invisible characters · review and press Enter to send,吃掉第一次提交回车;如果叠加了上述指纹误匹配,则补发的确认回车不会送出。

变更内容

  1. 提取纯净 Payload 用于指纹计算:
    • 新增 extractMessageContentForFingerprint,在计算指纹前先剥离 <botmux_reminder>、<botmux_routing>、<identity> 等系统信封及头部引用元数据,提取真实用户 payload。
    • makeSubmitFingerprint 基于剥离后的纯净 payload 生成指纹,避免不同会话共享相同前缀。
  2. 输入清洗与换行规范化:
    • 在 writeInput 发送按键前,将 \r\n 与 \r 规范化为 \n,并过滤掉零宽字符与不可见字符(\u200B-\u200D、\uFEFF 等),防止触发 Claude Code 2.1 的交互式审查屏障。
  3. 检索增强与防误伤:
    • findJsonlAcrossProjectsRoot 增加最小指纹长度门禁(fingerprint.length >= 10),避免过短指纹泛匹配。
    • 所有指纹检索及轮询补充 minEventTimestampMs 时间戳门禁,严格限定为提交前 60 秒内的事件。
  4. 测试用例:
    • 在 test/write-input.test.ts 中补充信封剥离提取测试、相同 reminder 下不同 payload 的指纹隔离测试、输入清洗测试,以及同项目目录下兄弟会话不误判且重试补发回车成功的回归单测。

影响范围评估

  • 模块与平台:仅涉及 Claude Code 适配器(src/adapters/cli/claude-code.ts)与相关测试(test/write-input.test.ts),不影响其他 CLI 适配器。
  • 跨平台:纯字符串处理与路径检索,同时兼容 Linux / macOS。

验证结果

  • bun test test/write-input.test.ts:147 pass,0 fail
  • bun test test/cli-adapters.test.ts test/claude-transcript.test.ts:592 pass,0 fail
  • bun run build:编译及 bundle 顺利通过,audit 无异常

1. 提取指纹前剥离 BotMux XML 信封与元数据,基于真实 payload 计算指纹,避免同机多个 Claude 会话因共享前缀导致指纹碰撞和跨会话误判。
2. 规范化输入换行符并清洗零宽/不可见字符,避免触发 Claude Code 2.1 的交互式拦截 barrier。
3. 补充指纹提取、字符清洗及兄弟会话防误匹配的回归单测。
@deepcoldy

Copy link
Copy Markdown
Owner Author

自动评审的初步意见(最终以维护者审阅为准)。整体方向正确、测试也能真实变异(我把新增用例搬到改动前的 master 上跑,清洗用例确实发出了 \r/零宽字符,兄弟会话用例的 claudeJsonlPath 确实被劫持到了另一个 jsonl,两处都按预期变红;PR head 上 147 + 592 全绿,bun run build 通过)。以下是合入前建议处理的两个小问题:

1.(P2)指纹取自原始文本,而 jsonl 实际落盘的是清洗后文本,残留一类假失败

submitFingerprint = makeSubmitFingerprint(content) 用的是未经清洗的原文,而实际敲进 TTY、Claude 记录进 jsonl 的是 sanitizedContent。当 payload 前 30 个字符内含有会被清洗掉的格式字符时(恰恰就是本 PR 针对的零宽字符场景),指纹不会是落盘文本的子串。实测探针:

content = "...<user_message>clean​ line one here\nsecond line</user_message>"
指纹        = "clean​ line one here second li"   // 含 ZWSP
落盘文本归一化 = "clean line one here second line" // 不含 ZWSP
includes(指纹) = false

常规路径有 waitForSubmit 的字节标记兜底,不受影响;但这会让本 PR 刚加固的两条内容相关路径失效:

  • 中途 rotation 分支的 (a) 情形(submit 在 re-resolve 之前已落进新 jsonl,rotatedBaseByte 之后不再追加字节),指纹是唯一检出手段;
  • 兄弟目录 / 跨项目 fan-out。

结果是这类 prompt 被误判提交失败(多发确认回车 + 用户告警),方向安全但与加固目标相悖。建议把清洗提前到计算指纹之前,指纹直接从 sanitizedContent 提取(清洗逻辑抽成一个共享 helper,顺便保证两侧永远一致)。

2.(P2)10 字符最短指纹门禁只加在了跨项目 fan-out,逐层目录扫描没有门禁

findJsonlAcrossProjectsRoot 开头有 fingerprint.length < 10 → null,但 confirmSubmit 末尾的 per-attempt fallback 直接调的是 findJsonlContainingFingerprint(不含当前文件),没有等长门禁。时间戳门禁只防 60 秒外的旧事件,防不了「同一项目目录下两个同族会话在 60 秒内提交了相同短消息」——例如「继续」「ok」,仍然会把 claudeJsonlPath 翻到兄弟 jsonl,即本 PR 要消灭的那个误判形状(只是窗口被收窄了)。建议把长度门禁下沉到共享检索层,或在该调用点补同样的判断。

另外两个非阻断建议(P3):

  • 头部剥离正则只认中文:英文 locale 下 [User quoted a message — run \botmux quoted om_x` to view it]和[@mention from X]` 不会被剥离,英文头部会占满 30 字符指纹。引用头的消息 id 本身唯一,影响不大;但同一 bot 的英文 handoff 前缀是共享的,建议补齐英文模板或改用按 i18n 模板结构匹配。
  • 清洗正则对 ZWJ/ZWNJ 一刀切:合法 emoji ZWJ 序列会被拆散(👨‍💻 → 👨💻),波斯语等用词中的 ZWNJ 被删会合并单词。若这是有意取舍建议加注释说明;更精细的做法是只剥离「孤立的」格式字符(不参与 emoji 序列/字母连接的那些)。

测试质量不错(兄弟会话回归用例的 Enter 时序模拟尤其好);如果能再补一个「payload 前 30 字符含零宽字符时 rotation/fan-out 仍能确认成功」的用例,可以把第 1 点锁死。

@deepcoldy

Copy link
Copy Markdown
Owner Author

补充第二轮评审意见(另一位评审独立复现了上面两个 P2,我这边又做了核实),还有三点建议,前两点建议本轮一并处理:

3.(建议本轮修)英文 locale 的头部不剥离,且同样会碰撞

不只是头部占指纹的小问题——已实测碰撞:两条正文不同的英文 prompt,只要带同一个 bot 的 handoff 头,指纹都算成 "[@mention from ReviewerBot] [U",完全相同。引用头 [User quoted a message — run \botmux quoted om_x` to view it]` 同理。也就是说非中文 bot 上,本 PR 的核心修复(指纹按会话隔离)等于没生效。建议不要写死中文字面,改成结构化剥离(引用头/handoff 头按 i18n 模板的固定结构识别),或由组装侧统一生成可识别前缀。

4.(建议本轮修)兜底分支漏剥 <summary_memory>,hook 模式 + 记忆开关下碰撞原样存在

hook 注入模式(buildFollowUpCliInput 的 hook 分支)下,敲进 PTY 的文本不含 <user_message> 壳,只保留 sessionId/role/summaryMemory/正文等稳定块(reminder/sender/mentions 走 sidecar)。此时 extractMessageContentForFingerprint 走兜底 strip 分支,而该分支的标签清单里没有 <summary_memory>。实测(summaryMemory 开启时):

正文 A "帮我把 CI 跑红的用例修了" → fp "<summary_memory> 先读 summary.md"
正文 B "总结一下刚才这个 PR"       → fp "<summary_memory> 先读 summary.md"  → 碰撞

两个开关都不是默认项,但组合起来会把本 PR 修好的跨会话碰撞原样带回。建议兜底 strip 的标签清单与 buildNewTopicBlocks / buildFollowUpBlocks 实际保留在 PTY 文本里的稳定块键对齐(至少补 summary_memory;new-topic 路径的 chatContext 类块也建议一并核对)。

5.(可本轮顺手,也可另开)<user_message> 惰性正则是二次复杂度

/<user_message>([\s\S]*?)<\/user_message>/ 在「重复开标签、无闭标签」的输入上回溯爆炸,实测(bun 1.4):60KB ≈ 0.2s、180KB ≈ 1.7s、360KB ≈ 7.3s。普通源码粘贴不受影响(2ms 量级),但把带重复 <user_message> 字样的 botmux prompt 日志/文档粘进会话即可触发,且这段在 writeInput 键入前同步执行,会卡住 worker。src/services/resumable-session-discovery.ts 已处理过同形正则,做法是游标式 indexOf/startsWith 扫描,可以照搬。ZWJ/ZWNJ 一刀切的取舍也建议加一行注释说明。

收敛后的处理建议:第 1、2 点(两个 P2)和第 3、4 点建议本轮修掉——它们都落在本 PR 直接加固的「指纹跨会话隔离」这条路上;第 5 点可本轮顺手或另开 follow-up。最终以维护者审阅为准,辛苦了。

@deepcoldy

Copy link
Copy Markdown
Owner Author

接上面第 4 点,new-topic hook 路径也核对完了,chat_context_policy / chat_context 同样漏剥并实测碰撞(两条不同正文指纹均为 <chat_context_policy>本群聊上下文仅供参)。

按「块在 userMessage 之前还是之后」区分(指纹取剥离后文本的前 30 字符,只有正文之前的稳定块会直接占据指纹、让不同正文碰撞):

正文之前、碰撞关键、且清单缺失的(3 个块):

块 标签 出现路径
summaryMemory <summary_memory> opening + follow-up hook
chatContextPolicy <chat_context_policy> opening hook
chatContext <chat_context …>(开标签带属性、非自闭合) opening hook

同位置的 role / session_id / whiteboard 现有清单已覆盖;claude-code 在 opening hook 下因 injectsSessionContext 不产生 routing/identity/session_id/credentials 块;follow-up 路径不产生 chatContext 块。

正文之后、不造成前缀碰撞但剥离清单也应一并处理的:

  • <substitute_trigger> / <substitute_target>:在两个 builder 里都 push 在 userMessage 之后,只在 substitute 触发时出现。不占指纹前缀,但作为稳定元数据同样应剥掉,避免污染提取出的 payload。
  • attachments:实际渲染为 <attachments hint="...">(开标签带属性),现有正则写的是字面量 <attachments>,匹配失败。它同样位于正文之后、当前无碰撞后果;统一改时应像 <sender\b...、<whiteboard\b... 那样对属性宽容(<attachments\b[^>]*>)。

因此更建议按复审讨论的做法修:让剥离清单与 buildNewTopicBlocks/buildFollowUpBlocks 经 ENVELOPE_KEYS 过滤后实际留在 PTY 文本里的块共用一份来源(集中维护「键 → 实际标签(含属性形态)」的映射,或按行首块标签统一识别),而不是逐个补标签——否则以后新增一个正文前稳定块、或给现有标签加上属性,还会以同样形状漏剥。

1. 统一在指纹生成前清洗输入(抽离 sanitizeClaudeInput),保证检索指纹与落盘文本一致,修复首 30 字符含零宽字符时的检索假失败。
2. 逐层目录扫描 fallback 补充 10 字符最小指纹门禁,防止短文本误匹配同目录活跃兄弟会话。
3. 扩展元数据剥离正则以覆盖英文引用头与 handoff 头,并在 fallback 模式下完整剥离 summary_memory、chat_context 等稳定块。
4. user_message 提取改用游标线性定位,消除极端多开标签无闭合输入下的二次回溯风险。
5. 补充相应场景的回归单测。
@deepcoldy

Copy link
Copy Markdown
Owner Author

已根据评审意见完成针对性修复与强化(提交 commit 22b8d22f2):

  1. 落盘文本与指纹清洗对齐(解决第 1 点 P2):

    • 提取共享 helper sanitizeClaudeInput,在 makeSubmitFingerprint 生成指纹前先统一执行清洗,确保检索指纹与实际敲入 TTY 并由 Claude 落盘进 JSONL 的文本完全一致。
    • 补充回归单测:覆盖 payload 前 30 字符含零宽字符时,指纹回退检索仍能准确命中并成功确认提交。
  2. 目录级检索补充最短指纹门禁(解决第 2 点 P2):

    • 将 CLAUDE_MIN_FALLBACK_FINGERPRINT_LEN = 10 统一同步至 confirmSubmit 的目录扫描路径,防止「ok」「继续」等超短文本在重试阶段误匹配同目录下近期活跃的兄弟会话。
    • 补充回归单测:验证短文本在首轮回车未落盘时,不会误判命中兄弟会话。
  3. 支持英文引用头与交棒前缀剥离(解决第 3 点):

    • 扩展 LARK_METADATA_HEADER_RE,覆盖英文环境下的 [User quoted a message — run botmux quoted ... to view it] 与 [@mention from ...],杜绝英文 prompt 因共享交棒前缀发生碰撞。
  4. 对齐 hook 模式下的正文前稳定块剥离(解决第 4 点):

    • 在无 <user_message> 壳的兜底路径下,使用游标线性扫描剥离全部已知 BotMux 信封块,包括 <summary_memory>、<chat_context_policy>、<chat_context ...>、<role ...>、<whiteboard ...>、<attachments ...> 等,彻底消除 hook 模式开启 memory / chatContext 时的前缀碰撞。
  5. 线性扫描消除正则回溯隐患(解决第 5 点):

    • <user_message> 提取改为游标式 indexOf 线性定位,消除多次重复开标签且无闭合标签时的二次回溯风险;并在 sanitizeClaudeInput 处注释说明了对 ZWJ/ZWNJ 字符做整体清洗的取舍考虑。

验证

  • bun test test/write-input.test.ts:151 pass,0 fail
  • bun test test/cli-adapters.test.ts test/claude-transcript.test.ts:592 pass,0 fail
  • bun run build:完整 build 与资产审计通过

@deepcoldy

Copy link
Copy Markdown
Owner Author

自动评审的第二轮意见(最终以维护者审阅为准)。新 commit 已同步到最新 master 本地 rebase 复核(零冲突,rebase 结果树与整树合并逐字节一致),上一轮的收敛项逐条核对:

  • 指纹/落盘对齐(P2-1):sanitizeClaudeInput 抽成共享 helper,writeInput 在算 baseByte/指纹前先清洗,指纹与实际键入文本同源;新增的「前 30 字符含零宽字符仍能经指纹确认 rotation」用例在改动前源码上实测变红、在新代码上通过。
  • 短指纹门禁(P2-2):per-attempt 的同目录扫描补上了 >= CLAUDE_MIN_FALLBACK_FINGERPRINT_LEN 门禁,常量统一;「ok 短消息不劫持兄弟 jsonl」用例同样在旧码变红、新码通过。
  • 英文头部:LARK_METADATA_HEADER_RE 中英文模板都覆盖(quote hint + handoff 两种语序),有对应用例。
  • 稳定块剥离:游标扫描替代了回溯正则,<summary_memory>/<chat_context_policy>/<chat_context …>/<substitute_*>/<attachments hint="…">(带属性、自闭合都兼容)均有覆盖,hook 形态有对应用例;原 ReDoS 形状(重复 <user_message> 无闭标签)360KB 从约 7.3s 降到 21ms。

本地验证:bun run build 通过;write-input 151/151、cli-adapters + claude-transcript 592/592(1 skip);4 个新用例搬到改动前源码上全部按预期失败;CI 全项通过。无阻断意见。

一个可选的 P3(不阻塞合入,本轮顺手或后续均可):新的游标扫描器自身仍残留一个二次复杂度形状——当输入是「连续大量 < 后才遇到一个定界符、且标签名不匹配」时,每个 < 都会从下一个位置重新线性扫一遍标签名。实测(bun 1.4):2 万个连续 < ≈ 0.5s、6 万 ≈ 4.3s、12 万 ≈ 18s。触发条件比原来苛刻很多(需要上万连续 <,原来只需重复 <user_message> 字面量),但这段在键入前同步执行。修法一行:标签名扫描的定界符集合里加上 < 本身(合法标签名不含 <):

if (ch === '>' || ch === ' ' || ch === '\n' || ch === '\t' || ch === '\r' || ch === '/' || ch === '<') {

我验证过:改后 12 万连续 < 从 18s 降到 7ms,且 <<sender …/> 这类嵌套 < 的识别结果与现状逐字一致。

另有一个无关紧要的小点:BOTMUX_ENVELOPE_TAGS 里的 botmux_skill_help 在渲染侧不存在(skill help 指针实际也渲染为 <botmux_builtin_skills>,后者已在集合中),留着无害;集合目前是手工维护的,建议在块渲染处或集合定义处互相加一行注释指引,避免以后新增稳定块时漏同步。

@deepcoldy

Copy link
Copy Markdown
Owner Author

补充上一条 P3:经第二轮交叉验证,只加 < 定界符只堵了一半,还存在第二个二次复杂度形状——已识别的开标签连续出现且没有对应闭合标签时,每次 indexOf('</tag>', openTagEnd+1) 都会扫到串尾。用真实导出函数实测:

'<summary_memory>x'.repeat(N):1 万 51ms / 2 万 191ms / 4 万 751ms / 8 万 3083ms(约 1.3MB,标准二次)

触发条件比原版仍苛刻(需要数千个可识别开标签字面量无闭合),但把含这些标签的 botmux 源码/diff/log 粘进会话即可造,量级与原 ReDoS 同阶。

完整修法(两个方向,均已验证线性,二选一):

  1. 在加 < 定界符的基础上,对「出现过无闭合实例」的标签加 dead-set:再次遇到同名标签直接按普通文本处理,不再尝试找闭合(1.3MB→约 44ms)。注意不能简单地「遇到无闭合就 break」——那样会漏掉它之后的其他有效块,例如 <summary_memory>x<attachments hint="x">f</attachments> 现实现仍会剥掉 attachments,break 会保留,语义变了。
  2. 预建每个标签全部闭合位置的有序数组,二分查找开标签之后的第一个闭合(1.3MB→约 39ms,5.4MB→约 205ms)。

语义边界(供写测试时参考):这两种修法对良构输入(botmux 实际渲染的所有块:带属性的开标签/自闭合/正常闭合,20 万次随机块序列验证「指纹都从用户正文开始」零失败)与现状逐字节一致;只有当用户正文本身含有畸形嵌套的标签残片(例如字面量 <role <sender …/>、<role 无闭合后接 </role> 这类 botmux 永不生成的标签汤)时才会有字节差异,而现状在那种输入上的贪心剥离本身也是偶然行为。建议测试用真实渲染形状覆盖,并在函数注释里说明「对畸形标签汤为尽力而为、不承诺逐字节」即可,不必为残片汤保现状。

仍是不阻断的 P3,本轮顺手修或记 follow-up 都可以。

上一提交用游标扫描替代回溯正则后,仍残留两个 O(N²) 形状:
1. 连续大量 '<' 后才出现一个定界符时,每个 '<' 都重扫整段;
2. 已识别开标签连续出现且无闭合标签时,每次 indexOf 闭合标签都扫到串尾。
标签名定界符集合补 '<'(合法标签名不含 '<'),并对已出现无闭合实例的
标签记 dead-set 跳过后续闭合查找;无闭合时不 break,继续剥离其后的其他
良构块。对 botmux 实际渲染的良构块语义逐字不变(注释写明畸形标签汤仅
尽力而为)。实测 12 万 '<' 18s→6ms、8 万无闭合开标签 3s→73ms,补两条
回归(含无闭合标签后随良构块仍被剥离)。

Co-Authored-By: Claude Code <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant