Skip to content

Releases: cv-superding/dsh-deepseek-web-login

v0.6.38

Choose a tag to compare

@github-actions github-actions released this 03 Oct 06:18

工具调用协议从裸 JSON 改成围栏包裹(```dsh-tool),根治"标记漏到正文"这一类问题。

起因:用户拿 cuckoo 的分享页来对比

用户问「为什么 cuckoo 不会出现 DSML 等内容,他不也是工具调用」。取它的分享页逐项统计:

cuckoo 我们(改之前)
调用格式 ```cuckoo 围栏代码块 {"tool_calls":[…]} 裸 JSON
页面里 DSML 0 出现过
页面里 tool_calls 0 出现过
页面里 invoke / parameters 0 出现过

根因就在格式本身:围栏自带语法边界,模型即使在围栏外多写一句废话也进不了正文;
裸 JSON 没有边界 —— 模型在思考里写 JSON、或者在 JSON 前后多写一个字,整段直接成为正文。
这也是为什么我们的规则 6 早就写着"禁止 XML 标记"却仍然漏:禁令能约束内容,约束不了边界。

DSH 那侧不用改:我们把调用转成 tool-call 事件交给它,它只看事件、不看文本。

改动

提示词(两份,TOOL_PROTOCOL_INSTRUCTIONS 与串行版必须逐字一致):

  • 示例与开头句改成 ```dsh-tool 包裹
  • rule 6 改为「必须用围栏包裹;不包裹的 JSON 与 XML 标记都会泄漏」

解析器(protocol.ts):

  • stripCallFence():调用块前后无条件剥掉所有围栏(含 ```json 这类模型自选语言名)。
    进入该函数的文本都已确定属于调用块,所以放宽是安全的。
  • CALL_FENCE_OPEN_RE + sawCallFence:在 drain() 里对普通正文判围栏。
    ⚠️ 只认带 dsh-tool 的开栏,且不加"后面不是闭栏"的负向断言 ——
    逐字符分块时开栏先到(JSON 还没来),那条断言会把它误判成闭栏而放行出去。
  • 闭栏只在本流见过开栏时才剥(sawCallFence 门控)。

没有动:extractBalancedJson 要求 { 在开头这件事(靠捕获时先剥 head 解决)、
DSH 侧的调用解析、裸 JSON 的支持路径(模型不听话时的兜底必须留着)。

期间踩到的两个坑

① 放宽判据吃掉正常内容(真实回归)。 一度无条件剥所有 ⇒ `check-auto-continue` N03 (正文里的xml 示例)与 check-tools-section 各变红。曾试图用"独占一行的裸闭栏"补救,
逐字符分块时闭栏还没成行、照样漏 user 的代码块。最终形态:普通正文只认带 dsh-tool 的开栏,
闭栏靠 sawCallFence 配对。

② 围栏示例有体积成本。 maxChars=5000 的极小预算下,协议头占 3304/5000(66%),
新增的围栏示例把工具定义挤掉了(check-tools-section 变红)。压掉 rule 6 与 rule 8 的冗余措辞后
协议头 2869 字符(比改动前只多 55),工具定义恢复。logic-test 里加了一条上限断言
(< 3000)防它悄悄涨回去。

验证

  • logic-test 62 → 67 项(5 条新围栏用例:整块/逐字符/按行三种分块 × 带散文、
    正常代码块不许被剥、裸 JSON 兜底仍可用、协议指令必须要求围栏 + 体积上限)
  • dev/probe-fenced-protocol.mjs:7 种形态全部"调用提取成功 + 零泄漏"
  • DSML 矩阵 144/144 仍全拦得住
  • 四处变异手工确认打红(脚本里的 execFileSync 在本机不可靠,变异写不进去 ⇒ 假"全绿"):
    开栏不扣留 → 2 红、flush 不剥闭栏 → 2 红、head 不剥围栏 → 1 红、stripCallFence 恒等 → 2 红
  • 又抓到一次虚守卫:提示词退回裸 JSON 时用例仍全绿(只查了 includes('dsh-tool'),
    而围栏示例行里也有这个词)。补上"必须出现 inside a fenced code block"后变异正确打红。
  • tsc / smoke / 产物 / 64/64 全过,真实留痕未污染

v0.6.37

Choose a tag to compare

@github-actions github-actions released this 03 Oct 05:21

修 DSML 标记泄漏:dsml- 连字符变体带空格时整块原样上屏(用户分享页现场)。

现场

用户对比 cuckoo(干净)与本插件的分享页,报告 DSML 漏到网页端。分享页原文:

<||DSML|| calls>
<||DSML|| invoke name="pwsh">
<||DSML|| parameter name="command" string="true">Get-Date -Format …</||DSML|| parameter>
</||DSML|| invoke>
</||DSML|| calls>

同时工具调用确实执行了(下一轮有 [Tool Result for call_68d163…])—— 同一段标记
既被执行、又漏到正文,所以不是"认不出",是"没走进捕获态"。

根因

DSML_PREFIX(竖线段,可选)与 (?:dsml-)?(连字符段,可选)是两个各自独立的可选片段。
<dsml- calls>(连字符后带一个空格)两边都匹配不上 ⇒ 不进捕获态 ⇒ 整块当正文。

144 组合矩阵(前缀 6 种 × 形态 4 种 × 分块 3 种 × 通道 2 条)实测:修复前 54 个组合泄漏,
其中 dsml- 两个变体全部 24 个组合整块上屏,正文与思考通道都一样。

为什么一直没暴露

现有用例只写了无空格形态(check-dsml-stray.mjs / logic-test.mjs 都是 <dsml-calls>)——
测的形态和现场形态不是同一个。与「0.6.28 思考分支一句 continue 跳过全部四道网」同类:
判据只覆盖了一种规范写法,现场是另一种。

修法

  • 前缀两种变体合并成一个可选组 DSML_PREFIX_BODY,dsml- 后面允许空白。
  • normalizeDsml 的两个 dsml- 替换同样吃空白(否则归一化后仍剩 < calls>,后面全失败)。
  • XML_MARKER_STARTERS 补 dsml- tool_calls / dsml- invoke / dsml- calls
    (hold-back 判据是逐字符比对 starter 前缀,少列一种跨包就判不出)。
  • findXmlToolCallEnd 里那条闭合正则只认半角单竖线 (?:\|\s*DSML\s*\|\s*)?,
    而现场是全角 || ⇒ 裸 invoke 块的收尾会落到"当正文吐出"分支。改用共用片段。
  • stripStrayToolMarkup 的包裹标签前缀改为可选(裸 <calls> / </calls> 此前只剥闭合,
    开启标签整行残留)。

踩到并修掉的第二个坑:可选性不能写在片段里

修完第一处,logic-test 立刻红:在 HTML 里 <invoke> 不是一个标准标签 被吞。

原因是我把"前缀可选"实现成 (?:${DSML_PREFIX})?${DSML_HYPHEN} —— 两个各自可选的片段串起来,
整组仍可为空 ⇒ "无前缀也匹配 invoke" ⇒ 正文里正常讨论的标签被吞。连踩两次才想清楚:
可选性只能写在整个片段的外层,两个变体必须放进同一个交替里。

所以引入了 DSML_PREFIX_ONLY(不带 ?)专供 invoke 使用:包裹标签是 DSML 私有标记,
剥它零风险;invoke 是通用 XML 标签,正文里真的会被讨论,必须要求带前缀。

用例

  • check-dsml-stray 18 → 40 项:前缀矩阵(7 种前缀 × 配平/截断/逐字符)+ 一条反向用例
    (正文里正常讨论 <invoke> 不被误吞)。
  • dev/probe-dsml-matrix.mjs:144 组合探针,判为泄漏时打印上屏原文。
    ⚠️ 写探针时我连栽两次假结论(把 ReasoningSanitizer.push 的字符串返回值当对象用;
    模板拼接出错造出双份后缀),两次都是"只报结论不打证据"。探针必须能自证。
  • 变异验证:①去掉 dsml- 的空白容忍 → 7 条红;②invoke 前缀改回可选 → 新用例 +
    logic-test 各 1 条红。两个方向都有守卫。
  • 产物断言修了一条:原正则绑死了旧的双可选结构(正是本次修掉的错误结构),改成守意图。

tsc / smoke / 产物 / 64/64 全过,矩阵 144 组合全拦得住,真实留痕未污染。

v0.6.36

Choose a tag to compare

@github-actions github-actions released this 02 Oct 11:44

把"提示词里到底有什么"变成可查的事实(诊断),并顺带给出这次现场的完整核对结论。

用户反复问「是不是把答案直接告诉模型了」—— 这个问题只靠字符数回答不了:一个 25k 的提示词
既可能是"把模型的旧回答又发了一遍"(缺陷),也可能是"工具返回"(正常且必要)。
两者在网页端看起来一模一样,但一个是 bug、另一个是机制。所以每轮额外落盘结构计数:

"stats": { "assistant": 0, "user": 1, "toolResult": 1, "systemBlocks": 0 }

assistant > 0 才是"把模型自己说过的话又发了一遍"。以后不用读分享页估,直接查这个文件。

本次现场的核对结论(dev/replay-turn-prompts.mjs 新增,用会话日志重放真实发出去的提示词):

轮次 实发字符 里面有什么
"现在几点了" 11 只有 User: 现在几点了
下一轮 12 只有新增那一条
天气那轮的搜索返回 25491 [Tool Result for call_…](工具返回,必须回灌)

真实发送里 Assistant: 是 0 条 —— 模型的旧回答没有被发回去。
分享页里能看到的 [Tool Result for call_055551695bfc4f82b3e9] 2026-10-02 19:34:10 星期五
正是 0.6.35 生效的证据(改之前这里会是 User: 2026-10-02 19:34:10 星期五);
Assistant: 前缀 0 次、Tool Calling Protocol 只出现 1 次(没有重复的固定头)。

⚠️ 说明一件事,免得再被误读:"网页端的提示词里出现了时间/天气"本身不是 bug ——
那是 agent 自己调了 Get-Date / web_fetch,工具输出必须回灌,否则模型无从得知。
判断标准只有一条:它是 [Tool Result for …](正常)还是 User: …(缺陷)。

新增 dev/replay-turn-prompts.mjs:把某会话每一轮真实发出去的提示词重放出来并给出结构统计。
以后再遇到"提示词里有什么"的疑问,一条命令即可,不必依赖分享页的二手转述。

v0.6.35

Choose a tag to compare

@github-actions github-actions released this 02 Oct 11:28

纠正 0.6.34:真正的原因是 DSH 的工具返回是 role: "tool",而我们没有这个分支。

0.6.34 我按"工具返回被折进一条普通 user 文本"去修的(判据是"assistant 发过工具调用 ⇒ 紧随的
user 文本就是返回")。那个推断是猜的,而且我的证据本身是错的 —— 我用的
dev/dump-turn-input.mjs 只打了 user/message / assistant/message / request/header / step/end
四种事件,而工具返回记在第五种里:

type=tool/result  {"message":{"role":"tool",
  "toolCallId":"call_c2cf288c12734112b11e",
  "content":[{"type":"text","text":"2026-10-02 18:54:28 星期五\r\n"}],"isError":false}}

role: "tool" —— 不是 role:'user',也不是 tool-result 块(测试夹具里长的是后者,真机不是)。
我们原来没有 tool 这个分支,于是它掉进 user 分支 ⇒ 渲染成
User: 2026-10-02 18:54:28 星期五 ⇒ 模型以为"用户告诉了我时间"(它的思考原话:
「我没拿到工具结果,用户直接给了时间。那就接受。」)⇒ 回答变成「收到,…」。

改法(这次是有证据的):serializePromptParts 增加 role === 'tool' 分支,
用消息自带的 toolCallId 与 isError 标成 [Tool Result for <id>],
并清掉 0.6.34 那条"预期下一个 user 文本是工具返回"的待办。
0.6.34 的推断保留为次要防线(注释已改写,明确它不是主要依据),因为两种形状的期望输出一致、
留着能覆盖我没能证伪的那种形状;且主分支会先清掉待办,它不会再误触发。

顺带修掉那个把我带偏的工具:dev/dump-turn-input.mjs 增加 tool/call / tool/result 分支 ——
看不见工具返回的诊断,比没有诊断更危险(它让我在错误前提上写了一版修复)。

用例 69 项(新增 4 条,直接用手机会话日志里的确切形状)。
⚠️ 写这 4 条时踩了一次虚守卫:第一版把 assistant 侧的调用 id 与 toolCallId 写成同一个值,
于是关掉主分支时次要防线给出同样结果、用例照样全绿(变异只红 2 条而不是 4 条)。
把两边的 id 改成不同值后,变异才真正红 4 条 —— 又一次"两个来源同一个值就测不出真伪"。

v0.6.34

Choose a tag to compare

@github-actions github-actions released this 02 Oct 11:06

修复:工具返回被当成「用户说的话」发给模型 —— 所以它回「收到,…」而不是回答问题。

用户现场(原话):「提示词还是把答案直接告诉模型了,难怪最后模型的回答有『收到,…』」
(附分享页 + 截图:问「你知道现在几点嘛」→ 答「收到,2026-10-02 星期五 18:54,傍晚快七点了。」)

先排除掉已修的那一半:0.6.33 确实在跑(日志已有 firstDiff),而且增量已经正常 ——
18:51:37 chars=17 / 18:54:09 chars=11 / 18:54:26 chars=14 / 18:54:30 chars=31,
对比修之前的 40193。所以这次的「答案在提示词里」不是回声。

证据链(dev/dump-turn-input.mjs 读 DSH 自己的会话日志 + 分享页逐行核对):

turn 3  step 1:  user      「你知道现在几点嘛」
                 assistant 「pwsh」            ← 工具调用
         step 2:  assistant 「收到,2026-10-02 星期五 18:54,傍晚快七点了。」

中间那条工具返回,在 DSH 的消息列表里根本不存在(不是 tool-result 块)。
而分享页里渲染出来的是 User: 2026-10-02 18:54:28 星期五 ——
User: 前缀只可能由我们的"纯文本分支"产生 ⇒ DSH 把 pwsh 的输出当成一条普通 user 文本交给我们。

于是模型读到的是"用户告诉了我时间"。它自己的思考原话(分享页里看得到):

「用户告诉我时间。不需要工具。」
「不过我应该核对一下——我没拿到工具结果,用户直接给了时间。那就接受。」

⇒ 回答从"回答几点"变成"收到,…"。用户看到的"答案被喂进去"是真的,但它的根因不是重发,是错标。

修法:serializePromptParts 认出这种形状 —— assistant 发过工具调用 ⇒ 紧随其后的那条 user 文本
按构造只能是工具返回
(agent 循环里必须先有工具返回才轮到下一条用户输入),于是标成
[Tool Result for <id>],不再渲染成 User: …。
一对多时逐个配对(一个调用一条返回);数量对不齐就合并成一条(宁可少一层对应关系,
也不能让它看起来像用户说的话);带图片的 user 消息不误标(那是用户真的发的)。

新增 5 条用例(check-context-feed 65 项):必须标成工具返回 / 没有工具调用时不许误标 /
多调用多返回逐个配对 / 数量不齐时合并 / 带图消息不误标。一处变异确认变红(关掉这条判据 → 3 红)。

v0.6.33

Choose a tag to compare

@github-actions github-actions released this 02 Oct 10:48

修复:断链重发时把模型自己刚说的话又喂了回去 —— 一句话走掉 4 万字符。

用户现场(原话):「我就说了个『哇哦帅气』,结果网页版发送的提示词怎么这么长???
以及我问他天气怎么样,结果我看提示词,相当于我发给网页版已知的答案,然后模型在回复我!」

日志一条就够(feed-decisions.jsonl):

18:33:09  not-appended  chain=15  ent=15  tail=false  chars=40193  head=14594

5 个字的输入,发出去 40193 字符。 网页端的用户气泡里赫然是"上一句天气回答 + 哇哦帅气"。

根因:replay(链还在、但不能只发增量)发的是整份历史,而历史里全是 Assistant: … 条目
—— 也就是模型自己刚说过的那段回答。增量路径早就有"剔掉模型回声"这条规则(0.6.17 为同一个
投诉加的,原话也是"完全没必要"),但 replay 这条路没走它 —— 当时注释写的是"只在增量里剔,
全量需要完整对话(从零重述)"。这个理由在 replay 上不成立:走到 replay 说明链还在
(同会话、同账号),那条会话里固定头和历史都还在,根本不是"从零重述"。

改法:replay 按代价从小到大挑,第一个可用的就用:

  1. 从第一个分歧点起的条目(剔回声)—— 通常就是这一句新消息;
  2. 整份条目(剔回声)—— 分歧点算不出来时退一步;
  3. transcript(0.6.32:至少省掉固定头);
  4. 整份 full —— 什么都算不出来时的兜底。

每档都要求"非空且不超预算";头变了时只有第 4 档合法(新头从没发过)。
上方那次的 40193 字符,在新逻辑下是 User: 哇哦帅气。

顺带把"为什么断链"变成一个可读的事实:以前只有 tailSame=false,它只说"链尾变了",
不说是哪一条、从哪儿变的 —— 于是每次排查都只能猜。现在每轮多记 firstDiff
(和链从第几个条目开始不一样)。这一轮假如早就有它,一眼就能看到是哪一条被改了。

用例:check-context-feed 60 项(新增 7 条:最小档 / 回声必须剔 / 头变了必须整份 /
退到整份剔回声 / 不许发空串 / 四档都不行时兜底 / 历史变短也走最小档),
check-context-chain 19 项(端到端断言"只发分歧点之后的条目、且不许含固定头",
并守 firstDiff / promptChars / headChars 三个留痕字段)。
两处变异确认变红:把 replay 改回总是发 full(9 红)、去掉回声过滤(4 红)。

顺带修的:三条绑语法形态的产物断言与两条老用例,在行为变更后失配 —— 按"改测同一意图"
重写(例如"历史被改写 ⇒ 退回全量"改成"⇒ 只发分歧点之后的条目"),没有把行为改回去。

v0.6.32

Choose a tag to compare

@github-actions github-actions released this 02 Oct 10:28

修复:链式投喂"退回全量重发"时会重发整份固定头(约 6.35 万字符)—— 那一段是纯重复。

起因是用户对比了 cuckoo 与自己的分享链接:同一段 Tool Calling Protocol 在网页端出现了两次。

先看数据,不猜(feed-decisions.jsonl,25 条真实记录):

reason 次数 含义
chained(增量) 14(56%) 正常
new-session 6(24%) 新窗口的第一句 —— 新会话必须全量,天经地义
no-parts 4(16%) 内部标题请求
not-appended(链断) 1(4%) 只有这一次真的退了全量

所以"每次调用工具就重发"与日志不符(1/25)。但那次重发里有一件确实该改的事:

根因:replay("链还在但不能只发增量")一律发整份 full = 固定头 + 历史。
而走到 replay 就说明链还在(同一个网页端会话、同一个账号)——
那条会话的首条消息里已经把固定头给过了,重发它是纯重复。
固定头 = system + 协议指令 + 工具目录,实测约 6.35 万字符,占一轮的大头;
网页端看到"同一大段又出现一次"就是它。

改法:PromptParts 新增 transcript(full 里属于历史的那一段,超预算时是截断后那份),
replay 在头没变时只发 transcript:

  • not-appended(头已确认相同)⇒ 省掉固定头,省下的就是那 6.35 万字符。
  • head-changed ⇒ 必须整份重发(新头从没发过,省了模型手里就是旧头)。
  • 没传 transcript ⇒ 退回旧行为。所以这是一条纯优化:漏传只少省一点,不会错。
  • ⚠️ transcript 交出去的必须是截断后那份 —— 否则"只发历史"会比原来的全量还长,把优化做成事故。有用例钉死。

顺带补上体量诊断:feed-decisions.jsonl 此前只有"为什么走全量",没有"发了多大"。
正是这个缺口让我今天只能去读分享页估字符数 —— 估出来的数字不能用来下任何结论。
现在每轮多记 promptChars(实发字符数)与 headChars(固定头字符数),体量从此可测。

新增 6 条用例:省头路径(两种触发都要省)/ head-changed 必须重发头 / 没传 transcript 退回旧行为 /
transcript 恒等于 full 里属于历史的那一段 / 截断时 transcript 也必须是截断后那份。
三处变异确认变红:把 replay 改回总是发 full(2 红)、把 transcript 交成未截断那份(1 红)、
以及重建后的产物断言。

⚠️ 顺带修掉一条绑语法形态的产物断言:它写死 const replay = (reason) + 120 字窗口,
于是这次无害的形态改动(箭头+对象字面量 → 块体)把它打红了。已放宽窗口并注明理由 ——
产物断言要守意图,别守签名长什么样。

v0.6.31

Choose a tag to compare

@github-actions github-actions released this 02 Oct 05:20

修复:新建一个窗口、只发一句话,网页端会多出一个会话(那个是"生成标题"用的脚手架)。

现场(用户原话):「刚在 dsh 中新建一个窗口聊天,就发了一句话,网页版直接俩窗口」。
feed-decisions.jsonl 里两条对得整整齐齐:

12:55:54  new-session   sess=04c63135   ← 对话那条
12:56:00  no-parts      sess=de6a8d4f   ← 网页端多出来的那个

de6a8d4f 正是用户在网页端看到的那条(标题请求的 prompt 直接显示在里面)。

根因:session-title 这类内部请求必须有一条自己的网页端会话才能调 completion
(0.6.26 为了修「n/n 分叉」特意把它们和对话分开,见 requestSlotKey 的注释)。
当时的注释写的是「代价是内部请求自己占一个会话,它不出现在对话里」——
判断漏了一半:它确实不在对话里,但它出现在侧边栏里。

而它从来不会被删:用户设的是 sessionCleanup: keep,而清理器的 schedule() 在 keep 下
第一行就 return —— 于是这条会话连 ledger 都没有(从没安排过删除),永远留在网页端。

改法:把「内部请求的会话」与「用户的对话会话」在收尾时彻底分开。

  • 收尾时按 promptParts 有没有传来分(与 requestSlotKey、adapter 的 chatLike 同一处判据):
    不带 = 内部请求 ⇒ 会话是脚手架,一律走新的 onDiscardSession 通道丢掉,
    不进用户的清理队列。
  • ⚠️ 复用来的会话也要丢 —— 它不在收尾逻辑的 owned 集合里(那一轮没调 createSession),
    这正是最容易漏掉的一半。
  • 清理器新增 discard(auth, sessionId):不受 keep / manualOnly 影响 ——
    那两道开关的语义是「别删我的对话」,而脚手架会话不是用户的对话。
  • ⚠️ discard 只删那一个,不 drain 队列:manualOnly(链式模式)下队列里攒的是
    用户的会话,借这次机会顺手删掉就变成"用户没点按钮却被删了"(0.6.1x 修过的那个 bug)。
  • ⚠️ 仍然受 deleteWebSessions === false("一个都不许删"总闸)约束 —— 那个开关不能破。
    所以关掉总闸时这类会话仍会留在网页端,这是已知代价。

顺带把一条"注意事项"升级成硬约束:测试 fixture 模拟真实 chat 时必须传 promptParts。
改了收尾判据之后,4 个用例文件成批变红 —— 全是拿裸 params(不带 promptParts)去测
"chat 会话的轮换 / 回收 / 归属"的。这不是 bug,是夹具没按生产链路的形态构造
(adapter 只给 chatLike 传 promptParts)。已给 6 处夹具补上,并在文件里写清为什么。

新增 6 条用例(check-internal-session-discard.mjs):内部请求走「丢掉」通道且不进用户队列 /
复用的会话也要丢 / 对话请求不走丢弃通道(防过度清理)/ keep 与 manualOnly 下都必须删 /
discard 不许顺手清空用户的队列。三处变异确认变红:关掉内部请求分支(2 红)、
把 discard 改成 drain 队列(2 红)、以及重建后的产物断言。

⚠️ 又记一条产物断言的坑:打包器除了会去掉单语句的花括号,还会把 undefined 改写成 void 0
—— 断言里写 === undefined 必然假红(这次踩了)。

v0.6.30

Choose a tag to compare

@github-actions github-actions released this 02 Oct 04:46

补齐请求头:把浏览器自动加的那批指纹头收回来,并修掉一个过时的写死版本号。

起因是调研"能不能不打开浏览器也像真浏览器发消息"(报告见工作区 浏览器指纹伪装可行性调研.md)。
调研的结论之一是:在改 TLS 之前,头部这一层就有零成本的差距可以补。

对着真实登录捕获(accounts/acc_9be659e4.json,2026-10-02T03:42Z)核对出两处:

  1. 收头的规则只认 x-*,于是浏览器自动加的那批全被丢掉:
    sec-ch-ua / sec-ch-ua-mobile / sec-ch-ua-platform、
    sec-fetch-dest / sec-fetch-mode / sec-fetch-site、priority、accept。
    这批是"是不是真浏览器"最表层的信号 —— 真实 Chrome/Edge 发 fetch 请求时一定会带,
    而我们一条都没发。现在收进来,并且保留浏览器给头的顺序(顺序本身就是指纹)。

  2. x-client-version 写死 2.0.0,而真实捕获是 2.5.0 —— 落后两个小版本,而且
    没有任何机制会发现它过时。真实值一直走的是抓取那条路(所以线上没暴露),
    但抓取失败时就会发这个陈旧值。现在:抓来的真值永远优先,写死的只做兜底并标注要定期校准。

顺带(同一批证据的两件事):

  • pickExtraHeaders 的规则原先在 browser-login.ts(CDP 路径)和 login.ts(旧 webRequest 路径)
    各写了一遍 —— 两份一旦漂移,"同一账号换个登录方式指纹就不一样",且不会有任何报错。
    现在只有一处,两边都调它。
  • captureDefect() 增加一条判据:抓到了头、但里面没有 x-device-id 也算捕获缺陷。
    依据:真实捕获里 x-device-id 是数美设备指纹(UUID),而上游对"缺少浏览器设备指纹"
    会直接返回 biz_code=11 / RISK_DEVICE_DETECTED —— 缺了它不是"信息少一点",是会被风控判定。
    ⚠️ 只覆盖"有头但缺这一项";extraHeaders 整个为空的手工粘 token 路径故意不碰(既有设计)。

刻意不做的一件事:不收 accept-encoding。
它是浏览器的解压能力声明,服务端可能据此回 zstd,而 Node 侧的 fetch(undici)不保证能解 ——
一旦解不开,SSE 会静默变成乱码(不是报错,是内容全错)。这是"宁可少一个头,也不要静默坏掉"的取舍,
代码里写了理由,用例里也钉住了(防止有人"顺手补上")。

⚠️ 一处保留的偏差:真实浏览器不发 x-app-version(7 个 x-* 里没有它),我们仍在发。
保留是为了不改变既有行为,代码里标了注释 —— 想更贴近浏览器,删掉那一行即可。

新增 11 条用例(check-request-headers.mjs,此前 pickExtraHeaders 只有 1 条、buildDsHeaders 零覆盖):
该收的收进来 / 不该收的别收(逐请求头 + accept-encoding)/ 顺序保留 / 抓来的真值赢过兜底 /
逐请求头必须用当前值(旧 token 旧 cookie 不许漏出去)。
四处变异确认变红:白名单改回 x-(3 条红)、兜底覆盖抓取值(1 条红)、关掉 x-device-id 判据(1 条红)、
以及重建后的一处产物断言。

⚠️ 写用例时踩了一次虚断言:第一版把"抓来的值"和兜底都写成 2.5.0,于是
"兜底覆盖抓取值"这个变异体照样通过(两者无法区分)。改用 88.8.8 之后才真的能打红。
这正是"先自问这条用例在改之前是不是也绿"那条铁律的又一次实证。

v0.6.29

Choose a tag to compare

@github-actions github-actions released this 02 Oct 04:03

修复:登录态过期(或账号被限流)之后,重新登录会"又新建一个网页端会话"、整段历史全量重发。

现场:用户在旧窗口发了一条消息,网页端凭空多出一个会话;DSH 日志里那一轮是
API 密钥无效(AUTH)→ 重试 1795ms。他随后重新登录了同一个账号,但会话没能接回去。
feed-decisions.jsonl 对上了:那次是 new-session,而 account 前后完全一样 ——
账号没变,是会话槽被退役了。

根因:retireSession() 被当成"失败即弃"用在了三处失败分支(webapi.ts):
建连抛错、!resp.ok、业务错误信封。401/403 说的是令牌过期、429 说的是账号级限流
—— 这些跟"这个会话"毫无关系,而且换一个新会话账号照样被限:退役它没有任何好处,
唯一的效果是把用户整段对话丢掉(重登后只能新建会话 + 把历史全量重发一遍)。

改法:只有服务端明说"这个会话废了"(业务码 invalid chat session id)才退役;
认证与限流这类账号级失败改为记进 keepIds,由收尾逻辑保住。

  • 判定发生在 openCompletion(只有它看得见错误码/业务码),消费发生在
    streamWebCompletion 的 finally —— 因为建连阶段失败时外层 sessionId 还是 undefined
    (它在 await 之后才赋值),id === sessionId 恒假,靠错误码在外层判会连"本轮真正在用的
    那个会话"一起退役。
  • ⚠️ 5xx / 网络失败 / 取消 / 空响应不在保留之列:那些情况下服务端可能已经开始生成、
    会话停在半路,照旧退役(N04 的意图不削弱)。原「失败即弃」用例改用 500 并改名为
    「服务端 5xx(会话可能停在半路)⇒ 不复用那个会话」,意图不变、范围收窄。

新增 5 条用例:401 / 429 / user is muted / throttled 四种账号级失败后必须接回原会话、
且不得把会话交给宿主去删;外加"新窗口第一轮就 401"(走的是收尾逻辑那条路径 ——
复用来的会话不在 owned 里,两条路径必须各守一条)。
四处变异全部确认变红:内联跳过、信封分支、收尾闸门,以及重建后的产物断言。