dsh-deepseek-web-login 0.6.37 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,497 @@
2
2
 
3
3
  本项目大致遵循语义化版本;日期为本地时间。
4
4
 
5
+ ## 0.7.1 — 2026-10-09
6
+
7
+ **修好 CI:从 2026-10-02 起一直红的发布链路**(根因是探针里硬编码了本机绝对路径)。
8
+
9
+ ### 根因
10
+
11
+ 七个 `dev/*.mjs` 探针把本机路径写进了源码:
12
+
13
+ ```js
14
+ await import('file:///F:/Code/Github-Self/dsh-login-web/dsh-deepseek-web-login1/src/protocol.ts')
15
+ ```
16
+
17
+ Windows 上跑得通(文件就在那儿)⇒ **本地怎么测都绿**;
18
+ CI runner 上那个路径不存在 ⇒ 6 个探针全部 `ERR_MODULE_NOT_FOUND` ⇒
19
+ `check-devtools` 8 过 6 失败 ⇒ 跑批 exit 1 ⇒ CI 与 Release 全红。
20
+
21
+ ### 为什么拖了这么久才定位
22
+
23
+ 拿到 job 日志(`actions/runs/{id}/logs` 需要 token)后一眼看见:
24
+
25
+ ```
26
+ [test] 65/66 个用例文件通过
27
+ FAIL check-devtools.mjs
28
+ ```
29
+
30
+ 🔴 **在此之前我花了两轮(40+30 分钟)在本地复现,全是白费**:
31
+ 干净克隆 66/66 全绿、最后一次绿(0.6.36)与第一个红(0.6.37)在本地都通过。
32
+ 差异只在 Linux/macOS runner 上,**本机根本复现不了**。
33
+ ⇒ 教训:**「CI 红但本地绿」第一件事是要日志,不是本地重造那一步。**
34
+
35
+ ### 改动
36
+
37
+ - 7 个探针改用相对路径(`../src/protocol.ts`)。`trace-invoke-swallow.mjs` 读文件用
38
+ `new URL(…, import.meta.url)`,Windows / Linux 都认。
39
+ - `check-devtools` 新增守卫「探针里不许硬编码本机绝对路径」,扫 `dev/*.mjs` 的
40
+ `file:///<盘符>/…` 与裸 `X:/…` 字面量。变异验证:把探针改回绝对路径 ⇒ 打红。
41
+
42
+ `tsc` / 产物 / 隔离 / **66/66** 全过。
43
+
44
+ ## 0.7.0 — 2026-10-09
45
+
46
+ **要求系统 Edge / Chrome**(唯一的破坏性变更),并按「更不容易被限流」重做四处:PoW 在页面内求解、会话跨重启复用、链式增量,以及把 README 里已经过时的实现描述同步为现状。
47
+
48
+ ### ⚠️ 破坏性变更:必须有可用的 Edge / Chrome
49
+
50
+ PoW 挑战改为**在真实页面上下文里**求解。找不到浏览器时**明确报错,不退回 Node 侧**
51
+ (Node 侧解 PoW 是被公开列为的封号触发条件,退回去等于这个改动没做、只是看着像做完了)。
52
+ 已经设了 `DSH_NO_BROWSER_TRANSPORT=1` 的环境需要撤销。
53
+
54
+ ### 为什么这样更不容易被限流
55
+
56
+ 公开的同类项目文档列出了封号触发条件,本版本按其中最可识别的四条做了调整:
57
+
58
+ | 原本 | 问题 | 现在 |
59
+ |---|---|---|
60
+ | Node 里 `WebAssembly.instantiate` 求 PoW | 「自动解挑战」触发条件 | **在真实页面里**用官方自己的 wasm 求 |
61
+ | 每次调用临时建会话、用完即删 | 真人不会这样建删(实测曾一天建 182 个) | **按 DSH 会话复用**,跨重启续上 |
62
+ | 每轮全量重发 | 体量与节奏都不像真人 | **链式投喂**:只发增量,`parent_message_id` 挂在上一条回答下 |
63
+ | 固定请求间隔 | 真人打字不等距 | 间隔可配随机区间 |
64
+
65
+ ⚠️ **这降低被识别的概率,不构成「不会被封」的保证**:逆向网页端本身违反服务条款,
66
+ 风险是「判罚多严」而不是「会不会被抓到」。稳定性优先请用官方 API 或付费订阅走 OAuth。
67
+
68
+ ### 修复
69
+
70
+ - **0.6.43**:DSH 重启后会话槽与链丢失(纯内存 `Map`)⇒ 同一条对话线被迫建新会话 +
71
+ 全量重发(实测 27 万字符)⇒ 网页端**多窗口 + 「修改 / 重新生成」分叉**。现在落盘
72
+ `resume-state.json`(原子写),并新增 `check-resume-after-restart.mjs`(**两个进程**:
73
+ ESM 同进程只求值一次,同进程测「续上了」是假的)。
74
+ - **0.6.42**:PoW 改在页面内解(同上)。
75
+ - **0.6.41**:内部请求(`session-title`)的脚手架会话在**抛错路径**上没被丢弃 ⇒
76
+ 「发一句话网页端建俩窗口」。
77
+ - **0.6.40**:`unwrapDsmlArguments` —— `<parameter name="arguments">` 包一层会让参数变嵌套。
78
+ - **0.6.39**:记录调用形态,让 0.6.38 的围栏终于可被验证。
79
+
80
+ ### 文档
81
+
82
+ README 里三处**已经过时且会骗人**的描述已按现状改写:传输层(不再是 Electron `net.fetch`,
83
+ 而是系统浏览器进程代理)、架构图里的「每次调用临时会话」(现在是复用 + 跨重启续上),
84
+ 以及新增「为什么这样更不容易被限流」一节。
85
+
86
+ ### 已知问题
87
+
88
+ **CI / Release 从 2026-10-02(0.6.36 之后)起持续失败,本地无法复现**:
89
+ Windows 上干净克隆跑 `scripts/test-offline.mjs` 全绿(66/66,132s),
90
+ 0.6.36 与 0.6.37 两个 commit 在本地也都通过 ⇒ **差异只在 Linux/macOS runner 上**。
91
+ 定位需要该次 job 的日志(Actions API 读日志要 token,本机没有)。
92
+
93
+ ## 0.6.43 — 2026-10-09
94
+
95
+ **DSH 重启后能续上同一对话线**(会话槽与链落盘)—— 修「只聊了一个窗口,网页端却多出第二个 + 出现『修改』」。
96
+
97
+ ### 起因:用户实锤
98
+
99
+ > 「我今天 dsh 就只聊了一个会话窗口,然后发现网页版有俩会话窗口,同时有个窗口又用了『修改』」
100
+ > 「不管是重启 dsh,那也应该要续上」
101
+
102
+ 现场(feed-decisions.jsonl):
103
+
104
+ ```
105
+ 15:08:56 chained d6327951 entries=138 ← 对话进行到第 138 条
106
+ ↓ 7 分钟空档(DSH 重启)
107
+ 15:15:32 new-session 84b66924 entries=140 ← 接着第 140 条
108
+ ```
109
+
110
+ **`entriesLen` 连续** = 同一条对话线被迫换了会话;旧会话因 `sessionCleanup: keep` 留在网页端侧栏。
111
+ 换会话必须发根消息⇒ 网页端出现「修改 / 重新生成」+ `n / n`(截图里的 `2/2` 分叉标记)。
112
+
113
+ ### 根因:槽与链都是**纯内存 Map,零持久化**
114
+
115
+ ```
116
+ webapi.ts:2107const reuseSlots = new Map() ← 会话复用槽
117
+ webapi.ts:2149 const contextChains = new Map() ← 投喂链
118
+ ```
119
+
120
+ `grep -E 'read|write|persist'` ⇒空;`grep '重启|restart'` ⇒只有环境变量说明,**压根没考虑过重启**。
121
+ ⇒ 重启后① 复用槽命中不到(建新会话)② 链为空(全量重发,实测 promptChars=270954)③ 发根消息(网页端分叉)。
122
+
123
+ **这不是配置问题**:三个开关(`chained` / `freshSessionOnRestart:false` / `keep`)
124
+ 都是「最小封号风险」设的,**没有任何开关能解决重启丢槽**。
125
+
126
+ ### 关于「重命名会不会换掉ID」(用户提问,已核实)
127
+
128
+ 实测 DSH 会话目录 `~/.dsh/sessions/<工作区>/` 里,同一 ID 曾以两个名字共存:
129
+ `session-8ea338bc-…` 与 `8ea338bc-…` ⇒ **重命名只改目录名、ID 恒定**(DSH 用 `randomUUID` 生成)。
130
+ ⇒ 槽键 `账号|dsh会话ID` 在重命名前后一致,能续上。
131
+
132
+ ### 改动
133
+
134
+ - 新增 `resume-state.json`("原子写:临时文件 + renameSync",半截文件会把状态搞坏)。
135
+ - 落 `SessionSlot` 的 `sessionId/turns/at/key` 与 `ChainState` 全部字段。
136
+ - ⚠️ **`cleanup` 是函数、不恢复** ⇒ 重启前建、没到轮换次数的会话没有删除回调,
137
+ 交给 `sessions-in-use.json` 那条路兜底。**有意取舍:宁可少删也不误删。**
138
+ - 落盘时机:**10 处**变更点(入栈/ 命中 / 链更新 / 五处删除与清空)。
139
+ ⚠️ 清空类**也必须落盘** —— 否则重启后旧状态又被读回来。
140
+ - 启动时 `restoreResumeState()` 读回。**不做网络请求**(慢且可能失败);
141
+ 网页端会话真没了会在用的时候拿到 `invalid chat session id`,届时清记录。
142
+
143
+ ### 验证(变异)
144
+
145
+ 新增 `check-resume-after-restart.mjs`:**两个进程**(ESM 同进程只求值一次 ⇒ 同进程测「续上了」是假的),
146
+ 判据两条:①重启后不新建会话 ② 重启那一轮带 `parent_message_id`(为 null ⇒ 网页端出「修改」)。
147
+ 变异「把 `restoreResumeState` 短路」⇒ 红,报错信息即现场现象(`created=1`)。已实测。
148
+
149
+ 🔴 **三个夹具坑**(都踩过,已写进注释):
150
+ ① `currentContextMode` 是 context-feed.ts 的模块级变量,只有 index.ts 启动时才 apply
151
+ ⇒ 直接 import webapi.ts 永远是 `full`、链式走不到;
152
+ ② assistant 帧必须 `v.response.fragments`,写 `{type:'assistant'}` 会被静默丢弃 ⇒ 拿不到 response_message_id;
153
+ ③ 本机 `spawnSync` **一律 EBUSY**(连 `node -e` 都是)⇒ 用例必须用**异步 spawn**。
154
+
155
+ `tsc` / smoke / 产物 / 隔离 / logic-test 81 / **66/66** 全过。
156
+
157
+ ## 0.6.42 — 2026-10-09
158
+
159
+ **PoW 改在浏览器页面上下文里求解** —— 消掉整条链路上最容易被风控识别的特征。
160
+
161
+ ### 起因:封号风险调研
162
+
163
+ `xiaoY233/DeepSeek-Free-API` 的 Disclaimers 把封号触发条件列得很明确,其中一条直接命中本项目:
164
+
165
+ > Challenge Solving Patterns: Automated challenge solving detected
166
+
167
+ 本项目此前在 **Node 侧** `WebAssembly.instantiate()` 加载 DeepSeek 的
168
+ `sha3_wasm_bg.*.wasm` 并调用 `wasm_solve` 求答案(`webapi.ts` 的 `solvePoW`),
169
+ 再用 `activeFetch` 自己发 `POST /api/v0/chat/create_pow_challenge`。
170
+
171
+ **官方网页端是在页面上下文里解的。** 而本项目本来就已经把请求跑在真实浏览器里
172
+ (CDP `Runtime.evaluate` + `Runtime.addBinding`)⇒ 让官方自己的 wasm 在官方自己的
173
+ 页面里跑,是可行且改动不大的。
174
+
175
+ ### 改动
176
+
177
+ **`browser-transport.ts`**
178
+ - `solvePowInPage()`:`Runtime.evaluate` 在页面里 `fetch` + `WebAssembly.instantiate`
179
+ + 调 `wasm_solve`,答案经**新增的独立 binding**(`__dshPowSolveResult`)回传。
180
+ - `handlePowBindingEvent()`:解析 `requestId + ':ok:' + 答案`。
181
+ ⚠️ 用 `indexOf` 找分隔符而不是 `split` —— 错误消息里可能含 `:`(如 `solve code=0`)。
182
+ - `POW_BINDING` 与传输层的 `BINDING_NAME` **分开注册**:后者负载是 JSON,
183
+ 混在一个通道里会让两边的解析器互相误判。
184
+
185
+ **`webapi.ts`**
186
+ - 新增 `solvePow()` 作为**唯一入口**:浏览器可用 ⇒ 走页面内;不可用 ⇒ **显式报错**。
187
+ - 旧的 Node 侧实现保留为 `solvePoW_removedForReference`(**注释块形式**),
188
+ 留档 prefix 拼法 `${salt}_${expire_at}_`(少一个下划线 ⇒ 服务端判失败),
189
+ 并明确写「不要因为 Node 侧也能算就加回来」。
190
+ - 新增 `resetWasmUrlCache()`(单测用:`resolvedWasmUrl` 是模块级缓存)。
191
+
192
+ ### 为什么没有「失败就退回 Node 侧」
193
+
194
+ 那等于这个改动没做,却给了「已经改好了」的错觉。**浏览器不可用就报错**,
195
+ 让调用方与用户都看得见。⚠️ **这是有意的行为变更**:原来没有浏览器也能跑,现在必须有。
196
+
197
+ ### 测试:删掉两条、改掉四条、新增三条
198
+
199
+ **删除 `check-round2` 的两条 N05** —— 它们守「失效地址被清掉并重新 discovery」,
200
+ 触发者是 Node 侧下载失败;PoW 改到页面内后那条链在 Node 侧**不可观测**。
201
+ 硬留会变成「看着绿、其实不测东西」的虚守卫。我试过三种写法来保住它,**全部失败**:
202
+ ① 断言 `homeHits` 增长 → 永远红(第二轮在 `solvePow` 就先抛错了);
203
+ ② 断言 `probes` 增长 → 正常版也红(第一轮探测成功就缓存,第二轮命中缓存是**正确行为**);
204
+ ③ 断言 `probesAfterSecond >= 1` → 能过但**几乎恒真** = 虚守卫。
205
+ ⇒ 如实记为缺口,等浏览器集成用例来验。
206
+
207
+ **新增一条真能测的**(替身):无浏览器时 `createPowHeader` 必须抛错,
208
+ 且报错要说清「没有浏览器」。判据是**行为**不是文本 ——
209
+ 变异「把报错文案换掉」⇒ 打红,且报错信息直接复现了变异文案。
210
+
211
+ **新增 `check-test-isolation` 的第二条守卫**:会走浏览器传输的用例必须钉
212
+ `DSH_NO_BROWSER_TRANSPORT`。⚠️ 这条守卫改了**四次**才不误报,全是同一个毛病:
213
+ 判据没对准「真的会发生什么」(详见代码注释里的四轮记录)。
214
+ ⚠️ 且已写明它的**能力边界**:只守将来的新用例,**对已有用例无能为力**
215
+ (变异删掉 `check-round2` 的开关它没报红,因为该文件已不再调 `createPowHeader`)。
216
+
217
+ ### 已知影响
218
+
219
+ `check-round2` 从 **300s 超时挂住** 回到 **5s** —— 之前是我的改动让它去真启浏览器。
220
+
221
+ ### 验证
222
+
223
+ - `check-bundle` 新增三条产物守卫;变异验证时被守卫**自身的两个漏洞**打到:
224
+ ① 方向错(查「报错文案之后」而真实风险在 `if` 之前);
225
+ ② 词边界不足(`/await solvePoW\(/` 匹配不到 `solvePoW_removedForReference(`)⇒ 改成扫整个函数体。
226
+ - `tsc` / smoke / 产物 / 隔离 / 诊断 / logic-test 81 / **65/65** 全过。
227
+
228
+ ## 0.6.41 — 2026-10-04
229
+
230
+ 修「发一句话网页端建俩窗口」在**出错路径**上漏掉的那个窗口,并补上缺失的判据。
231
+
232
+ ### 起因
233
+
234
+ 用户新开一个 DSH 对话、只发一句话,网页端出现**两个**窗口(同标题、不同 URL)。
235
+ `feed-decisions.jsonl` 里对上了:
236
+
237
+ ```
238
+ 21:37:15 new-session a3c3b79b ← 真对话
239
+ 21:37:33 no-parts 1705d934 ← 🔴 多出来的那个
240
+ ```
241
+
242
+ `reason: 'no-parts'` = **内部请求**,`promptChars: 477` 与那段 "Create a concise title…"
243
+ 完全吻合 ⇒ 是 `session-title` 的**脚手架会话**,本该被丢掉。
244
+
245
+ **先证伪了"PR 引入"**:`git diff d24d5f3 HEAD --stat -- src/webapi.ts` 为空 ——
246
+ 今天两个提交(含 PR #11 合并)一个字都没碰 `webapi.ts`,而这段逻辑来自 0.6.31。
247
+
248
+ ### 根因:抛错路径绕过了丢弃
249
+
250
+ `openCompletion` 的收尾路径里有 `params.onDiscardSession?.(id)`,
251
+ 但**它前面有 `catch` 里的三个 `throw`**(`ABORTED` / 上游 `AdapterLlmError` / `TRANSPORT`)
252
+ ⇒ 内部请求一旦出错(限流、网络抖动、5xx),**这三个 `throw` 直接跳过收尾**,
253
+ `finally` 只清 timer/abort,**不丢会话** ⇒ 脚手架会话永远留在网页端。
254
+
255
+ ### 改动
256
+
257
+ **`catch` 里、第一个 `throw` 之前**就把脚手架会话丢掉。
258
+ ⚠️ 只在 `params.promptParts === undefined` 时做 —— 用户的对话会话**绝不能**在出错时丢
259
+ (0.6.29 的约定:出错后还要重登/重试接着聊)。
260
+
261
+ **新增留痕 `diagnostics/scaffolding-discards.jsonl`**(`sent` / `failed` 两态)。
262
+ 🔴 之前 `ledger` 只记 `ok`、**不记删除** ⇒ 这个 bug 历史上出现过 **14 次**却一次都查不了
263
+ (分不清"没走到 discard"还是"discard 了但 DELETE 失败")。**判据缺失就永远只能靠用户报。**
264
+
265
+ ### 验证(两处都做了变异,且都踩了一次坑)
266
+
267
+ - 变异 A(删掉 catch 里的丢弃块)→ **1 条红**;变异 B(把 `deleteChunk([{auth,sessionId}])`
268
+ 改成 `deleteChunk(queue)`)→ 那条"不 drain 队列"守卫红,**还原后全绿**。
269
+ - ⚠️ **变异 B 第一次测"红 0 项"**:守卫读的是**产物**,我改的是 `src` 又忘了 `build` ⇒
270
+ 产物没变。**"跑过了"不等于"测到了"**(今天第五次踩同一类)。
271
+ - ⚠️ 新守卫首版用 `indexOf('} catch (error: any) {')` 匹配到**第一个** catch
272
+ (`webapi.ts` 里有 **7 个**)⇒ 判据恒假。改成**扫全部**、只认"含丢弃逻辑的那个"。
273
+ - ⚠️ 把 `trace` 插在 `discard` 函数开头会**顶出一条已有守卫**(它按"开头 90 字符内"
274
+ 定位 `deleteChunk`,而 tsdown 还会把这个调用拆成多行)⇒ 守卫窗口放宽到 240,并补了
275
+ "不许把 queue/owned 整个传进去"的反向判据。
276
+ - `tsc` / smoke / 产物 / 隔离 / 诊断脚本 / logic-test 81 / **65/65** 全过。
277
+
278
+ ### 仍需你验证
279
+
280
+ **判据只证明"逻辑在",不证明"窗口真的没了"** —— 后者要真机:
281
+ 重启 DSH → 新开对话发一句话 → 看网页端**是不是只有一个窗口**,
282
+ 再看 `diagnostics/scaffolding-discards.jsonl` 里有没有 `outcome:"sent"`。
283
+
284
+ ## 0.6.40 — 2026-10-04
285
+
286
+ 修 **DSML 被当成合法调用执行**时参数结构错报。**这是 0.6.39 首次拿到真机数据后查出的第一个真缺陷。**
287
+
288
+ ### 起因:一张截图
289
+
290
+ 用户在网页端"已思考"里看到完整的 DSML 结构,而且**它被执行了**:
291
+
292
+ ```
293
+ <||DSML||invoke name="pwsh">
294
+ <||DSML||parameter name="arguments" string="false">
295
+ {"command":"node audit-tool-args.mjs","description":"Classify DSML hits"}
296
+ </parameter>
297
+ </||DSML||invoke>
298
+ [Tool Result [ERROR] for call_26ef892b8db934a0c8a05]
299
+ Error: invalid arguments: missing required property "command"
300
+ ```
301
+
302
+ 0.6.39 加的 `diagnostics/call-shapes.jsonl` 同时给出了三行留痕:
303
+
304
+ ```
305
+ 12:03:28 fenced ← 模型照 0.6.38 发了围栏
306
+ 12:03:32 bare ← 下一轮退回裸 DSML,并且被执行
307
+ 12:03:40 none
308
+ ```
309
+
310
+ ⚠️ `rejected-meta.jsonl` **没有**新记录 ⇒ 那个 DSML **没被丢弃**,
311
+ 走 `parseXmlToolCalls` 被当成合法调用执行了。
312
+
313
+ ### 根因
314
+
315
+ `parseParameterValue` 会把 `{"command":…}` **解析成对象**,然后塞进 `args["arguments"]`
316
+ ⇒ 参数表变成 `{arguments: {command: …}}`,而 DSH 要的是 `{command: …}` 顶层
317
+ ⇒ 执行器报 `missing required property "command"`。
318
+
319
+ **模型看不懂这条报错**(它并不知道参数被包了一层),所以下一轮继续写错的 ——
320
+ 截图里那句"它跑到了执行器,返回的是 invalid arguments…"正是它自己的困惑。
321
+
322
+ ### 改动
323
+
324
+ **`unwrapDsmlArguments()`**(新函数,`protocol.ts`)——
325
+ 剥掉 DSML 的 `<parameter name="arguments">` **包装层**。两种形态都处理:
326
+
327
+ - **对象**:`parseParameterValue` 已把内文解析成对象(上面那种,最常见)
328
+ - **字符串**:整份 JSON 被当字符串塞进来 ⇒ 补一次解析
329
+
330
+ ⚠️ **只在"只有一个 `arguments` 键"时剥** —— 多个键时那不是包装层
331
+ (某个工具真的有名为 `arguments` 的参数),硬剥会把参数丢掉。
332
+
333
+ **两条解析路径都接上**:`parseXmlToolCalls` 与 `salvageXmlToolCalls`。
334
+ salvage 是"收尾不全"的兜底路径,遇到坏形态的概率**更高**(DSML 变体常缺闭栏);
335
+ 只修一条等于给另一条留后门。
336
+
337
+ ### 验证
338
+
339
+ - `logic-test` 74 → **78**,四条新用例:
340
+ - 真机样本(**逐字用现场形态**:全角 `||` + `string="false"` 属性)
341
+ - 正常形态不被破坏(反向用例)
342
+ - **salvage 路径单独一条**(同一语义两个来源,各守一条)
343
+ - 多键时**不许**剥(防"无脑剥一层")
344
+ - **两个变异分别验证**:断对象分支 → 2 条红;只断 salvage → 恰好 1 条红
345
+ (证明两条用例真的各守一条路径,不是同一条在重复报)
346
+ - `tsc` / smoke / 产物 / 隔离 / 诊断脚本 / **65/65** 全过
347
+
348
+ ### 尚未解决:围栏触发率
349
+
350
+ 3 轮里只有 1 轮照围栏(`fenced` 50%)。**这次修的是"错结构被执行",
351
+ 不是"模型不听话"。** 提高触发率要改载荷形态(避开 `tool_calls` 这个撞形键名),
352
+ 是独立的一件事,见 0.6.39 的 cuckoo / ToolBridge 对照分析。
353
+
354
+ ## 0.6.39 — 2026-10-04
355
+
356
+ 让 0.6.38 的围栏协议**能被验证**。这是一个纯观测能力的变更 —— 上一版改完协议之后,
357
+ 我们**没有任何办法确认模型到底听没听话**。
358
+
359
+ ### 起因
360
+
361
+ 0.6.38 把工具调用从裸 JSON 改成 ` ```dsh-tool ` 围栏,但"改完就完事"是**不够的**:
362
+
363
+ 1. **裸 JSON 走 fallback 路径同样能执行成功** ⇒ "工具调用成功了"**不能**证明围栏生效。
364
+ 2. **我们的轮次不落 DSH 会话日志**(走插件 → DeepSeek 网页端,逆向 fetch)
365
+ ⇒ `~/.dsh/sessions/` 里**根本没有我们的原文**可查。
366
+
367
+ 结果就是:0.6.38 至今**零真机验证**,谁也说不清模型有没有照做。
368
+ 期间还被自己坑了两次 —— 先信了一个扫 `~/.dsh/sessions/` 的探针,
369
+ 得出"2 次围栏调用、0.6.38 已验证",**后来发现那两条属于另外两个项目**
370
+ (那个目录按 cwd 分组,我们的不在里面)。
371
+
372
+ ### 改动
373
+
374
+ **`protocol.ts`** —— `FilterOutput` 增 `fenced?: boolean`。
375
+
376
+ 判据直接用已有的 `sawCallFence` 标志 —— 它本来就是"这次调用是围栏裹着的"的**权威信号**,
377
+ 而且**天然跨 `push`**:围栏开栏与 JSON 经常不在同一个分块里(逐字符分块是极端情况)。
378
+ JSON 与 XML 两条捕获路径都置位;`drainTextPipeline` 转发,轮末收尾那条路径也拿得到。
379
+
380
+ **`adapter.ts`** —— 新增 `noteCallShape()`,每轮追加一行到
381
+ `web-login/diagnostics/call-shapes.jsonl`。
382
+
383
+ 照抄同文件里已有的 `dumpRejectedPayload` 模式:同一个 `diagnostics/` 目录、
384
+ 同样的 `0700` 目录 + `0600` 文件权限、同样的 4MB 上限、**只记结构化事实不记内容**。
385
+ 三态:`fenced`(照新协议)/ `bare`(没听话,但功能正常)/ `none`(本轮无调用)。
386
+ ⚠️ 没有调用的轮次**也要记 `none`** —— 否则"日志里没这一轮"与"这轮没调用"分不开。
387
+
388
+ **为什么不塞进 `feed-decisions.jsonl`**(先试过,又退回来了):
389
+ 那份记录在**请求发出前**写,而形态要等**响应回来**才知道 ⇒ 只能记成"上一轮的",
390
+ 字段名会骗人。分开写没有这个歧义。
391
+
392
+ **`dev/verify-fence-on-machine.mjs`** —— 改成读上面那个文件。
393
+ 旧版去扫 `~/.dsh/sessions/`,那里**没有我们的轮次**,所以它报的数字一律不可信。
394
+
395
+ ### 验证
396
+
397
+ - `logic-test` 67 → **74**:围栏/裸 × 整块/逐字符/按行六条,外加一条反向用例
398
+ (正文里普通的 ` ```json ` 代码块既不算调用也不算围栏)。
399
+ - **变异验证**:把两处 `if (this.sawCallFence)` 改成 `if (false)` ⇒ **3 条打红**,还原 74/74。
400
+ ⚠️ 第一次变异"没打红"是**脚本没打全**(XML 那行尾部有注释,`replace` 只替换了第一处)——
401
+ **不确认变异是否落地就报"全绿"是假的**。
402
+ - `tsc` / smoke / 产物 / 隔离 / 诊断脚本 / **65/65** 全过,真实留痕未污染。
403
+
404
+ ### 顺带更正两条昨天的判断
405
+
406
+ - DSH 引用的 `rejected-meta.jsonl`「1 条、`mode:"json"`、22 字符」**是准确的** ——
407
+ 我昨天说"该文件不存在"是只查了 `web-login/` 顶层,漏了 `diagnostics/` 子目录。
408
+ - 但它引用的 `call_2ad277619424417f975c` **仍然不成立**:那个 call 读的是
409
+ `Documents\deepseek-harness\default-workspace\pelican-bicycle.html`,**另一个项目**。
410
+
411
+ ## 0.6.38 — 2026-10-03
412
+
413
+ 工具调用协议从**裸 JSON** 改成**围栏包裹**(` ```dsh-tool `),根治"标记漏到正文"这一类问题。
414
+
415
+ ### 起因:用户拿 cuckoo 的分享页来对比
416
+
417
+ 用户问「为什么 cuckoo 不会出现 DSML 等内容,他不也是工具调用」。取它的分享页逐项统计:
418
+
419
+ | | cuckoo | 我们(改之前) |
420
+ |---|---|---|
421
+ | 调用格式 | ` ```cuckoo ` **围栏代码块** | `{"tool_calls":[…]}` **裸 JSON** |
422
+ | 页面里 `DSML` | **0** | 出现过 |
423
+ | 页面里 `tool_calls` | **0** | 出现过 |
424
+ | 页面里 `invoke` / `parameters` | **0** | 出现过 |
425
+
426
+ **根因就在格式本身**:围栏**自带语法边界**,模型即使在围栏外多写一句废话也进不了正文;
427
+ 裸 JSON 没有边界 —— 模型在思考里写 JSON、或者在 JSON 前后多写一个字,整段直接成为正文。
428
+ 这也是为什么我们的规则 6 早就写着"禁止 XML 标记"却仍然漏:**禁令能约束内容,约束不了边界。**
429
+
430
+ DSH 那侧**不用改**:我们把调用转成 `tool-call` 事件交给它,它只看事件、不看文本。
431
+
432
+ ### 改动
433
+
434
+ **提示词**(两份,`TOOL_PROTOCOL_INSTRUCTIONS` 与串行版必须逐字一致):
435
+
436
+ - 示例与开头句改成 ` ```dsh-tool ` 包裹
437
+ - rule 6 改为「必须用围栏包裹;不包裹的 JSON 与 XML 标记都会泄漏」
438
+
439
+ **解析器**(`protocol.ts`):
440
+
441
+ - `stripCallFence()`:调用块前后**无条件**剥掉所有围栏(含 ```json 这类模型自选语言名)。
442
+ 进入该函数的文本都已确定属于调用块,所以放宽是安全的。
443
+ - `CALL_FENCE_OPEN_RE` + `sawCallFence`:在 `drain()` 里对**普通正文**判围栏。
444
+ ⚠️ 只认带 `dsh-tool` 的开栏,且**不**加"后面不是闭栏"的负向断言 ——
445
+ 逐字符分块时开栏先到(JSON 还没来),那条断言会把它误判成闭栏而放行出去。
446
+ - 闭栏只在本流见过开栏时才剥(`sawCallFence` 门控)。
447
+
448
+ **没有动**:`extractBalancedJson` 要求 `{` 在开头这件事(靠捕获时先剥 head 解决)、
449
+ DSH 侧的调用解析、裸 JSON 的支持路径(模型不听话时的兜底必须留着)。
450
+
451
+ ### 期间踩到的两个坑
452
+
453
+ **① 放宽判据吃掉正常内容(真实回归)。** 一度无条件剥所有 ``` ⇒ `check-auto-continue` N03
454
+ (正文里的 ```xml 示例)与 `check-tools-section` 各变红。曾试图用"独占一行的裸闭栏"补救,
455
+ 逐字符分块时闭栏还没成行、照样漏 user 的代码块。最终形态:**普通正文只认带 `dsh-tool` 的开栏**,
456
+ 闭栏靠 `sawCallFence` 配对。
457
+
458
+ **② 围栏示例有体积成本。** `maxChars=5000` 的极小预算下,协议头占 3304/5000(66%),
459
+ 新增的围栏示例把工具定义挤掉了(`check-tools-section` 变红)。压掉 rule 6 与 rule 8 的冗余措辞后
460
+ 协议头 **2869 字符**(比改动前只多 55),工具定义恢复。`logic-test` 里加了一条上限断言
461
+ (`< 3000`)防它悄悄涨回去。
462
+
463
+ ### 复审(同日第二轮)发现并处理的两件事
464
+
465
+ **① 一个真调用会漏的过度吞咽。** 用真机 reasoning 原文(模型**复盘自己写坏的调用**,
466
+ `The tool call got mangled again… {"tool_calls":…{...}}`)实测,发现思考通道把那段吞了
467
+ (78% 被吞)⇒ 用户看不到模型正在纠错。**试了三种判据、三次失败后决定不修**:
468
+ ①"独立成段" ⇒ 前面说过一句话的**真调用**被误判成复盘,标记整块漏进正文 ——
469
+ 那正是 0.6.28 建这道网要防的,等于把它拆了;②"紧邻 8 字符" ⇒ `slice(-8)` 把紧邻的 `\n\n` 截掉;
470
+ ③"配平能否解析"(判据本身对)⇒ 流未收全时配平必然失败,逐字符下把真调用整块放行(更糟)。
471
+ **理由**:这段 JSON 出现在思考区不影响功能,而三次尝试都在真实调用路径上引入了新风险 ——
472
+ 净负收益。取舍与三次失败已写进 `ReasoningSanitizer` 的类注释,避免后人重走。
473
+
474
+ **② 诊断脚本没有任何用例守着。** `dev/probe-*.mjs` 是 0.6.37/0.6.38 排查的主力,但**不被任何测试引用**
475
+ ⇒ 改坏了不会有人知道(探针自己还会静默报"全绿")。新增 `tests/check-devtools.mjs`(12 项):
476
+
477
+ - 每个探针都必须**能启动**(变异验证:把某个探针改成语法错误 → 11/12 变红)
478
+ - 探针在**无真机数据**的环境里必须**优雅退出**而不是崩 —— 批跑(`scripts/test-offline.mjs`
479
+ 注入临时 `DSH_HOME`,**CI 跑的也是批**)里没有会话,原先 5 个探针直接抛 ENOENT
480
+ - 提取 `dev/session-locate.mjs` 统一处理"找会话",5 个探针共用
481
+ - ⚠️ 文件名不能含 `probe`:`scripts/test-files.mjs` 的 `isManualProbe` 用 `/probe/i`
482
+ 匹配,把 `check-probes.mjs` 判成"人工诊断脚本不许进自动化" ⇒ 定名 `check-devtools.mjs`
483
+
484
+ ### 验证
485
+
486
+ - `logic-test` 62 → **67 项**(5 条新围栏用例:整块/逐字符/按行三种分块 × 带散文、
487
+ 正常代码块不许被剥、裸 JSON 兜底仍可用、协议指令必须要求围栏 + 体积上限)
488
+ - `dev/probe-fenced-protocol.mjs`:7 种形态全部"调用提取成功 + 零泄漏"
489
+ - DSML 矩阵 144/144 仍全拦得住
490
+ - **四处变异手工确认打红**(脚本里的 `execFileSync` 在本机不可靠,变异写不进去 ⇒ 假"全绿"):
491
+ 开栏不扣留 → 2 红、flush 不剥闭栏 → 2 红、head 不剥围栏 → 1 红、`stripCallFence` 恒等 → 2 红
492
+ - 又抓到一次**虚守卫**:提示词退回裸 JSON 时用例仍全绿(只查了 `includes('dsh-tool')`,
493
+ 而围栏示例行里也有这个词)。补上"必须出现 `inside a fenced code block`"后变异正确打红
494
+ - `tsc` / smoke / 产物 / 隔离 / **65/65** 全过,单跑与批跑两种模式都对,真实留痕未污染
495
+
5
496
  ## 0.6.37 — 2026-10-03
6
497
 
7
498
  修 DSML 标记泄漏:`dsml-` 连字符变体**带空格**时整块原样上屏(用户分享页现场)。
package/README.md CHANGED
@@ -31,13 +31,19 @@ DSH(DeepSeek Harness)通过 `ctx.llm` 的 **provider 适配器**接入模型
31
31
 
32
32
  ```text
33
33
  DSH agent loop ──▶ ctx.llm ──▶ [deepseek-web 适配器] ──▶ chat.deepseek.com
34
- │ ├─ PoW(SHA3 WASM 求解)
35
- │ ├─ chat_session(每次调用临时会话,用完即删)
36
- │ ├─ chat/completion(SSE patch 流)
34
+ │ ├─ PoW(SHA3 WASM,**在浏览器页面里**求解)
35
+ │ ├─ chat_session(**按 DSH 会话复用,跨重启续上**)
36
+ │ ├─ chat/completion(SSE patch 流,链式只发增量)
37
37
  │ └─ file/upload_file(图片输入)
38
38
  └─ 提示词工具协议 ⇄ tool-call 块
39
39
  ```
40
40
 
41
+ > **⚠️ 0.7.0 起:必须能启动 Edge / Chrome。**
42
+ > PoW 挑战改为**在真实页面上下文里**求解(原因见下方「为什么这样更不容易被限流」)。
43
+ > 找不到浏览器时**会明确报错,不会退回 Node 侧求解** —— 这是刻意的:
44
+ > Node 侧解 PoW 是被公开列为的封号触发条件,退回去等于这个改动没做。
45
+ > 已经用 `DSH_NO_BROWSER_TRANSPORT=1` 显式关掉浏览器传输的环境需要撤销该设置。
46
+
41
47
  <img src="docs/assets/architecture.svg" alt="架构与数据流" width="1000">
42
48
 
43
49
  ## 它是怎么工作的(以及为什么不是「反代」)
@@ -50,7 +56,7 @@ DSH agent loop ──▶ ctx.llm ──▶ [deepseek-web 适配器] ──▶ ch
50
56
  | --- | --- | --- |
51
57
  | **① 登录捕获**(只做一次) | 开一个**独立 profile** 的浏览器窗口让你登录,把页面自己发出的请求头读一份存下来:`Authorization: Bearer`、域 cookie、反爬头 `x-hif-dliq` / `x-hif-leim`、一批 `x-client-*` | Electron 的 `webRequest.onBeforeSendHeaders` |
52
58
  | **② 请求构造**(每次调用) | 自己拼 `POST /api/v0/chat/completion`;PoW 自己解:先要 `create_pow_challenge`,再用 SHA3 WASM 算出答案塞进 `x-ds-pow-response` | 插件自己的 HTTP 客户端 |
53
- | **③ 发送** | 默认从 **Chromium 网络栈**出去(Electron `net.fetch`),TLS / HTTP2 指纹与真实浏览器一致;也可切回 Node | 见下方「传输层」 |
59
+ | **③ 发送** | 默认**交给系统 Edge / Chrome 的网络栈**发出(CDP 驱动真实页面),TLS / HTTP2 指纹与真实浏览器一致;也可切回 Node | 见下方「传输层」 |
54
60
  | **④ 解析与对接** | 自己解 `response/fragments` 的 SSE 帧、分思考与正文通道;工具调用走**提示词协议**(网页端没有原生 function calling) | 自研解析器 |
55
61
 
56
62
  **唯一沾到"截获"的只有第 ① 步**,而且那一步是「读一份 + 清掉自己的痕迹」:同一个回调顺手把请求头里的
@@ -75,6 +81,26 @@ Electron 品牌(UA 与 UA-CH)删掉,否则网页端会判「使用环境
75
81
  前两条路线的共同点是「**请求是浏览器发的**」,只有本插件是发送方。代价是接口变了要跟着改;
76
82
  换来的是不依赖 DOM、可以后台无人值守地跑。
77
83
 
84
+ ## 为什么这样更不容易被限流
85
+
86
+ 网页端会拦自动化调用,而且公开的同类项目文档里已经列出了触发条件。0.7.0 按其中最可识别的那几条
87
+ 做了调整:
88
+
89
+ | 原本的做法 | 问题 | 现在 |
90
+ |---|---|---|
91
+ | 在 **Node 里** `WebAssembly.instantiate` 求PoW 挑战 | 被明确列为「自动解挑战」的封号触发条件 | **在真实页面里**用官方自己的 wasm 求 |
92
+ | 每次调用**临时建会话、用完即删** | 真人不会这样建删对话(实测曾一天建 182 个会话) | **按 DSH 会话复用**,跨重启续上(`resume-state.json`) |
93
+ | 每轮**全量重发** | 请求体量与节奏都不像真人 | **链式投喂**:只发增量,`parent_message_id` 挂在上一条回答下面 |
94
+ | 固定请求间隔 | 真人打字不是等距的 | 见设置页的请求间隔(可配随机区间) |
95
+
96
+ **代价(也是 0.7.0 的破坏性变更)**:必须有可用的 Edge / Chrome。
97
+ 找不到浏览器时会明确报错—— **不会静默退回 Node 侧解PoW**,因为那样等于这个改动没做,
98
+ 只是给了「已经改好了」的错觉。
99
+
100
+ ⚠️ **需要说清的边界**:这些调整**降低**被识别为自动化的概率,**不构成"不会被封"的保证**。
101
+ 逆向网页端本身违反服务条款,风险是"判罚多严"而不是"会不会被抓到"。
102
+ 稳定性优先请用官方 API,或用已有的付费订阅走 OAuth。
103
+
78
104
  ## 界面预览
79
105
 
80
106
  设置页按用途分成 **6 个标签**(一次只显示一页)—— **账号**(登录状态 / 当前账号 / **账号库** / 手动 Token)·
@@ -99,7 +125,7 @@ DSH「使用统计」里看到的调用量 —— 免费网页通道,当日 10
99
125
  | 能力 | 说明 |
100
126
  |---|---|
101
127
  | 🔐 **网页登录(无 API Key)** | Electron 独立分区窗口里正常登录,插件**旁路捕获**真实 `Authorization`、cookie、`x-hif-*` 指纹头与客户端版本头 |
102
- | 🧩 **PoW 求解** | `create_pow_challenge` + DeepSeek 自家 `sha3_wasm_bg.*.wasm` 的 `wasm_solve`;WASM 地址**自动发现**(哈希随部署变化),失败自动回退 |
128
+ | 🧩 **PoW 求解** | `create_pow_challenge` + DeepSeek 自家 `sha3_wasm_bg.*.wasm` 的 `wasm_solve`,**在浏览器页面上下文里执行**(0.7.0 起,见下方「为什么这样更不容易被限流」);WASM 地址**自动发现**(哈希随部署变化),失败自动回退 |
103
129
  | 🌊 **流式** | 同时兼容 `response/fragments`(THINK/RESPONSE 片段)与直连 `thinking_content`/`content` 两套 SSE 格式,含 `{o:"APPEND"}` 与裸 `{v}` 续段;按逻辑流去重,快照重放不重复吐字 |
104
130
  | 🛠 **工具调用** | 网页端没有原生 function calling → 提示词 JSON 协议 + 流式过滤器(跨包标记、围栏、多调用、假阳性回退)→ 合成 `tool-call` 块并给出 `finish: tool-calls`;工具定义按 5.6 万字符预算整段下发,超预算时**列出被省略的工具名**并要求模型别猜参数 |
105
131
  | 🛡 **格式漂移双保险** | ① 指令层显式禁止 XML/DSML 标记并说明后果(实测模型会主动拒绝该格式);② 解析层同时容忍 JSON 与 XML/DSML 两族(`\|DSML\|` 前缀、`dsml-` 连字符、裸 `<invoke>`、CDATA) |
@@ -237,7 +263,7 @@ prompt 字符上限默认 400,000(可配)—— 这个数字同时是**风
237
263
  | `cleanupBatch` | `6~10`(随机) | deferred:攒批阈值的**区间**(个)。这一轮具体攒几个 = 每次清理时在区间内随机抽 |
238
264
  | `cleanupDelayMs` | `60000~120000`(随机) | deferred:最长等待的**区间**(毫秒)。每轮清理重抽 |
239
265
  | `cleanupGapMs` | `800~2500`(随机) | deferred:**相邻两个删除请求之间**的间隔区间(毫秒)。每删一个重抽 |
240
- | `transport` | **`chromium`** | 传输层:`chromium`=Electron 的 `net.fetch`(指纹与真实浏览器一致)/ `node`=Node 原生 fetch |
266
+ | `transport` | **`chromium`** | 传输层:`chromium`=**系统浏览器进程代理**(拉起 Edge/Chrome,CDP 驱动页面发请求)/ `node`=Node 原生 fetch |
241
267
  | `contextMode` | **`full`** | 上下文投喂:`full`=每轮重发全量 prompt / `chained`=只发增量 + 把上一条回答当父消息(见下节) |
242
268
  | `probeIntervalMs` | `1800000` | 登录态主动探活间隔(毫秒),`0`=关闭。只读 `users/current`,零额度 |
243
269
 
@@ -349,9 +375,14 @@ prompt 字符上限默认 400,000(可配)—— 这个数字同时是**风
349
375
  | **默认:net.fetch(Electron 43)** | `t13d1516h2_8daaf6152771_806a8c22fdea` | **`8daaf6152771`** | h2 |
350
376
 
351
377
  Node 的请求在 **TLS 层**就能被判定为非浏览器(不走 HTTP/2、cipher 数量差 3 倍多、不带 GREASE),
352
- 而且这几项**调参修不了**。改用 Electron 的 `net.fetch` 后走 Chromium 内置网络栈,
353
- cipher 列表哈希与 Chrome 逐字节一致 —— 且**零新依赖**(不用 uTLS / curl-impersonate)。
354
- 唯一残留差异是扩展数 16 vs 17(内置 Chromium 150 vs 本机 Chrome 152,版本差异,属正常)。
378
+ 而且这几项**调参修不了**。
379
+
380
+ > **⚠️ 0.7.0 更新:实际走的是「系统浏览器进程代理」,不是 Electron 的 `net.fetch`。**
381
+ > 官方端是 `ELECTRON_RUN_AS_NODE=1` 的子进程,**取不到 `electron.net.fetch`**,
382
+ > 所以浏览器传输层改为拉起**系统的 Edge / Chrome**、用 CDP(`Runtime.addBinding`)把请求
383
+ > 交给真实页面发出,响应分块回传。上表的 `net.fetch` 一行是**当时的对比数据**,
384
+ > 结论仍然成立(Node 的 TLS 指纹确实区分得出来),但**现在不是靠 Electron 实现的**。
385
+ > 代价就是上面那条:**必须有可用的 Edge / Chrome**。
355
386
 
356
387
  设置页「传输层(指纹)」卡可以直接切换,并带一个**零额度的一键测试**
357
388
  (回显指纹 / 流式 / 鉴权三项结论)。
@@ -468,7 +499,10 @@ cipher 列表哈希与 Chrome 逐字节一致 —— 且**零新依赖**(不
468
499
  - `temperature` / `stop` / `max_tokens` 网页端无对应字段,会被忽略;usage 为**估算值**(网页端不返回 token 计数)
469
500
  - 免费额度有频控;`429` 会带上 `providerRetryAfterMs` 交给 DSH 的重试策略
470
501
  - `describe_image` 是 DSH 侧另一个独立工具(调用外部视觉模型),与本插件无关;本插件的图片能力不依赖它
471
- - **默认走 Chromium 网络栈**(Electron 的 `net.fetch`),TLS/HTTP2 指纹与真实浏览器一致;
502
+ - **必须能启动 Edge / Chrome**(0.7.0 起):PoW 改为在真实页面里求解,官方端是
503
+ `ELECTRON_RUN_AS_NODE=1` 子进程拿不到 `electron.net.fetch`,只能拉起系统浏览器、用 CDP 驱动页面发请求。
504
+ 找不到浏览器会**明确报错**(不会静默退回 Node 侧解PoW —— 那是刻意的,见「为什么这样更不容易被限流」)
505
+ - **默认走系统浏览器的网络栈**(CDP 驱动真实页面),TLS/HTTP2 指纹与真实浏览器一致;
472
506
  但它会跟随**系统代理**,梯子关着而系统代理仍指向它时会连不上 —— 设置页「传输层(指纹)」切回 Node 即可
473
507
 
474
508
  ## 测试与验证