@yanglaofish/dsh-proxy-pro 1.0.8 → 1.0.10
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/docs/LESSONS.md +905 -817
- package/lib/client.js +25 -13
- package/lib/index.js +1 -0
- package/lib/proxy-core.js +117 -21
- package/package.json +1 -1
package/docs/LESSONS.md
CHANGED
|
@@ -1,817 +1,905 @@
|
|
|
1
|
-
# dsh-proxy-pro — 设计依据与踩坑备忘
|
|
2
|
-
|
|
3
|
-
> 本文档记录本插件诞生的完整背景:DSH 代理链路的真实行为、我们踩过的每一个
|
|
4
|
-
> 坑、验证过的修复方案,以及据此做出的插件设计决策。**后续任何改动请先读本文**,
|
|
5
|
-
> 很多"看起来显然"的假设在这里都被证伪过。
|
|
6
|
-
|
|
7
|
-
---
|
|
8
|
-
|
|
9
|
-
## 1. 一句话背景
|
|
10
|
-
|
|
11
|
-
2026-09-16 前后,用户的 `web_fetch` 全线失败(`web fetch failed: TypeError: fetch failed`),
|
|
12
|
-
我们在调测中发现 DSH 的代理机制存在一个真实缺口:**环境里的代理设置对 web_fetch
|
|
13
|
-
进程完全不生效**。修好之后,用户决定基于这次积累的经验自研代理插件(本插件),
|
|
14
|
-
替换体验别扭的 `dsh-plugin-proxy`。
|
|
15
|
-
|
|
16
|
-
---
|
|
17
|
-
|
|
18
|
-
## 2. 代理链路真实架构(务必先看懂这张图)
|
|
19
|
-
|
|
20
|
-
```
|
|
21
|
-
┌─ web_fetch / web_search 工具 ─────────────────────────────────────┐
|
|
22
|
-
│ dsh-tool-web (line ~805) │
|
|
23
|
-
│ await ctx.web.fetch({ url }, exec.signal) │
|
|
24
|
-
│ └─ dsh-web-fetch-http 的 HttpFetchProvider (id "http") │
|
|
25
|
-
│ └─ requestOnce (line ~500-504) │
|
|
26
|
-
│ const route = proxyRouteFor(url) │
|
|
27
|
-
│ if (route.proxied && !isNonPublicIpLiteral(...)) │
|
|
28
|
-
│ → publicHttpNetwork.requestVia(route.dispatcher…) │
|
|
29
|
-
│ else → requestPinned 直连(被墙 = EACCES) │
|
|
30
|
-
└───────────────────────────────────────────────────────────────────┘
|
|
31
|
-
|
|
32
|
-
┌─ 模型请求 / 其他 spawn 的进程 ─────────────────────────────────────┐
|
|
33
|
-
│ undici 全局 dispatcher (setGlobalDispatcher) │
|
|
34
|
-
│ + process.env 的 HTTP_PROXY / HTTPS_PROXY / ALL_PROXY / NO_PROXY│
|
|
35
|
-
└───────────────────────────────────────────────────────────────────┘
|
|
36
|
-
|
|
37
|
-
关键:proxyRouteFor() 读的是 @deepseek-ai/dsh-http-proxy 模块级状态
|
|
38
|
-
(active / installed),这个状态只有 installProxyFromEnvironment() 会写入。
|
|
39
|
-
```
|
|
40
|
-
|
|
41
|
-
### 进程架构(重启后实测)
|
|
42
|
-
|
|
43
|
-
```
|
|
44
|
-
DSH main (28404)
|
|
45
|
-
└─ NodeService utility (21872) —— 监听 :43120 web 服务
|
|
46
|
-
├─ 我的 pwsh 祖父链:15416 → 21872 → 28404
|
|
47
|
-
└─ web_fetch 工具运行在这条链上(**从不跑 profile-boot 的
|
|
48
|
-
installProxyFromEnvironment** —— 这就是 web_fetch 恒 DIRECT 的根因)
|
|
49
|
-
```
|
|
50
|
-
|
|
51
|
-
- `~/.dsh/.env`(home 层)只影响跑 **profile-boot** 的进程(host/main)。
|
|
52
|
-
- web_fetch 在 **NodeService 链**,且该链没有调用任何 `installProxy…`。
|
|
53
|
-
- 结论:**只要 web_fetch 进程内 `proxyRouteFor` 判定为 DIRECT,它就连不上
|
|
54
|
-
外网被墙的 github 等目标;与你的系统代理、shell 代理、DSH 设置全无关。**
|
|
55
|
-
|
|
56
|
-
---
|
|
57
|
-
|
|
58
|
-
## 3. 根因与已证伪的假设(血泪史)
|
|
59
|
-
|
|
60
|
-
| 假设 | 结论 | 证据 |
|
|
61
|
-
|---|---|---|
|
|
62
|
-
| `.env` 设置 HTTP_PROXY 能修 web_fetch | ❌ 证伪 | `.env` 只被 profile-boot 读取;删除后 loadLayeredEnv 仍解出代理,但 **web_fetch 依旧 DIRECT** |
|
|
63
|
-
| `settings.yaml` 的 proxy 段是给 dsh-http-proxy 用的 | ❌ 被用户纠正两次 | 真消费者是**已安装插件 dsh-plugin-proxy**(settingsNamespace('proxy')) |
|
|
64
|
-
| 改全局 dispatcher 就够 | ❌ 不覆盖 web_fetch | web_fetch 不读全局 dispatcher,只读 proxyRouteFor |
|
|
65
|
-
| 两份 undici 混用没问题 | ❌ UND_ERR_INVALID_ARG | Node fetch + DSH dispatcher 是两个 undici 实例,构造参数不兼容;必须同源 |
|
|
66
|
-
| proxyhk 是普通代理 | ❌ NTLM | SWG 代理需要 NAT 认证窗口;undici ProxyAgent 不协商 NTLM,靠 keepalive 复用 |
|
|
67
|
-
|
|
68
|
-
**真正修复(已验证 HTTP 200)**:给 dsh-plugin-proxy 打补丁,在 sync() 内
|
|
69
|
-
`applyEffective` 之后追加调用:
|
|
70
|
-
|
|
71
|
-
```js
|
|
72
|
-
httpProxyPolicy = await installProxyFromEnvironment(envLike, (m) => ctx.logger.warn('proxy: dsh-http-proxy: %s', m))
|
|
73
|
-
```
|
|
74
|
-
|
|
75
|
-
重启后:github → PROXIED (proxyhk),deepseek → DIRECT,web_fetch 200。✅
|
|
76
|
-
|
|
77
|
-
---
|
|
78
|
-
|
|
79
|
-
## 4. installProxyFromEnvironment 的调用契约(新插件必须遵守)
|
|
80
|
-
|
|
81
|
-
```js
|
|
82
|
-
// dsh-http-proxy/lib/index.js 真实逻辑
|
|
83
|
-
// installProxyFromEnvironment(env, report) = resolveProxyPolicy(env) + installGlobalProxy(policy)
|
|
84
|
-
// resolveProxyPolicy 读 all_proxy/http_proxy/https_proxy/no_proxy(lowercase 优先,uppercase 兜底)
|
|
85
|
-
// policy.source 只有 "env" / "none" 两种
|
|
86
|
-
|
|
87
|
-
// env 必须带 .get(),不是裸 process.env(否则 "env.get is not a function"):
|
|
88
|
-
const envLike = {
|
|
89
|
-
get: (name) => {
|
|
90
|
-
const value = process.env[name]
|
|
91
|
-
return value === undefined ? undefined : { value }
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
```
|
|
95
|
-
|
|
96
|
-
**幂等要求**:再次 install 前必须先 `await 旧 policy?.()`(卸载),否则模块级
|
|
97
|
-
`active/installed` 状态残留导致判定错乱。
|
|
98
|
-
|
|
99
|
-
**loadLayeredEnv 行为**(dsh-app-boot lib/index.js ~1091-1116):
|
|
100
|
-
`inherited={...process.env}` + 项目层 `cwd/.env` + home 层 `~/.dsh/.env`
|
|
101
|
-
(`home !== resolve(cwd)` 时读),只在 `process.env[name] === void 0` 时写入;
|
|
102
|
-
返回带 `.get()` 的快照。`HOME_LAYER_PROXY_NAMES` 允许 home .env 设代理变量。
|
|
103
|
-
|
|
104
|
-
---
|
|
105
|
-
|
|
106
|
-
## 5. 为什么新插件放独立目录(避开 node_modules)
|
|
107
|
-
|
|
108
|
-
```
|
|
109
|
-
C:\Users\...\profiles\[web|test]\node_modules\dsh-plugin-proxy\package.json
|
|
110
|
-
LinkType = HardLink → C:\Users\...\pnpm\store\v11\files\...(pnpm 全局 store)
|
|
111
|
-
```
|
|
112
|
-
|
|
113
|
-
- profile 用 pnpm(`nodeLinker: hoisted`)安装插件,**node_modules 里是 store 的硬链接**。
|
|
114
|
-
- 直接改 node_modules 文件 = 改 store 里的原文件;**下次 `pnpm install` 会洗回 store 原样**,
|
|
115
|
-
我们的补丁全丢(这也是用户抱怨"修改别扭"的根源)。
|
|
116
|
-
- 原 `dsh-plugin-proxy` 版本升级同样会覆盖我们的补丁(我们保留了 `.bak` 也是无奈之举)。
|
|
117
|
-
|
|
118
|
-
> **⚠️ 2026-09-17 已修订:下面的 file:// 部署决策作废。**用户明确要求
|
|
119
|
-
> 「不要 file:// host 插件,要能安装发布的插件」→ 改为标准 npm 插件包 +
|
|
120
|
-
> `dsh plugin --profile add` 部署,机制见本文档 §12。本节保留为历史证据
|
|
121
|
-
> (node_modules 硬链接被洗的动机仍然成立,但解法是"自有包 + file: 软链装
|
|
122
|
-
> 入 profile",而不是 file:// 加载)。
|
|
123
|
-
|
|
124
|
-
**原决策(已作废):`dsh-proxy-pro` 独立部署在 `~/.dsh/plugins/dsh-proxy-pro/`,
|
|
125
|
-
通过 `cordis.patch.yml` 以 file:// 形式加载,完全不进 node_modules。**
|
|
126
|
-
|
|
127
|
-
```yaml
|
|
128
|
-
# profiles/<profile>/cordis.patch.yml 增补(已作废)
|
|
129
|
-
- insert:
|
|
130
|
-
- id: dsh-proxy-pro
|
|
131
|
-
name: file:///C:/Users/w00958282/.dsh/plugins/dsh-proxy-pro/lib/index.js?raw # 见 loader 约定
|
|
132
|
-
```
|
|
133
|
-
|
|
134
|
-
### file:// 插件能否带 client 端?——能(已验证源码)
|
|
135
|
-
|
|
136
|
-
`dsh-client-modules/lib/index.js` 的 `locatePkgJson`(L679-708):
|
|
137
|
-
- `loaderName.startsWith("file:")` 或绝对路径 → pathLike 分支
|
|
138
|
-
- 通过 `nearestPackage(moduleUrl)` 向上找最近的 package.json
|
|
139
|
-
- **只要该目录的 package.json 声明 `dsh.client`(platform: "web")+ `exports["./client"]`,
|
|
140
|
-
client 端就会被 client-modules 扫描、打包、注入**
|
|
141
|
-
|
|
142
|
-
所以新插件形态:
|
|
143
|
-
```
|
|
144
|
-
~/.dsh/plugins/dsh-proxy-pro/
|
|
145
|
-
package.json # name/dsh.client/platform:web/exports["./client"]
|
|
146
|
-
cordis.patch.yml # dsh.bundle.patch 指向的补丁
|
|
147
|
-
lib/index.js # host 半
|
|
148
|
-
lib/client.js # 浏览器半(__ModuleLoader__.load 格式)
|
|
149
|
-
lib/proxy-core.js # 纯逻辑(无依赖,可单测)
|
|
150
|
-
docs/LESSONS.md # 本文档
|
|
151
|
-
```
|
|
152
|
-
|
|
153
|
-
---
|
|
154
|
-
|
|
155
|
-
## 6. Client 插件写法(浏览器端铁律)
|
|
156
|
-
|
|
157
|
-
```js
|
|
158
|
-
window.__ModuleLoader__.load({
|
|
159
|
-
id: "dsh-proxy-pro",
|
|
160
|
-
factory: (require) => {
|
|
161
|
-
// 注意:inject 列的是【服务名】(如 "slots","settingsScope","sessions"),
|
|
162
|
-
// 不是包名!写包名会阻塞 web boot(踩坑教训,见插件 docs/LESSONS.md)
|
|
163
|
-
const inject = ["slots", "settingsScope"];
|
|
164
|
-
function apply(ctx) { ... }
|
|
165
|
-
return { apply, inject };
|
|
166
|
-
}
|
|
167
|
-
});
|
|
168
|
-
```
|
|
169
|
-
|
|
170
|
-
技能库里的 `dsh-skill-manager@4.3.3`(test profile 已装)是**现代写法参照模板**:
|
|
171
|
-
|
|
172
|
-
```js
|
|
173
|
-
ctx.slots.inject("settings.section", () => ctx.slots.register({
|
|
174
|
-
name: "settings.section", id: "skill-manager", order: 30, label: "技能管理",
|
|
175
|
-
}, Panel));
|
|
176
|
-
ctx.slots.inject("conversation.view", () => ctx.slots.register({
|
|
177
|
-
name: "conversation.view", id: "skill-manager", order: 20, label: "技能",
|
|
178
|
-
}, View));
|
|
179
|
-
```
|
|
180
|
-
|
|
181
|
-
### 对话头部按钮(用户明确要的"对话页面上可点击开关的代理按钮")
|
|
182
|
-
|
|
183
|
-
```js
|
|
184
|
-
// dsh-client-ui-conversation/lib/client.js L16683-16715
|
|
185
|
-
// registerConversationHeader 声明了 4 个子 slot:
|
|
186
|
-
// conversation.session.header.lineage (single, session)
|
|
187
|
-
// conversation.session.header.actions (list, session) ← 左侧
|
|
188
|
-
// conversation.session.header.utilities (list, session) ← 右侧操作区
|
|
189
|
-
// conversation.session.header.corner (single, session)
|
|
190
|
-
// 用法:ctx.slots.inject("conversation.session.header", () => ctx.slots.register({
|
|
191
|
-
// name: "conversation.session.header.utilities", id: "...", order: 10, label: "代理",
|
|
192
|
-
// }, Button));
|
|
193
|
-
```
|
|
194
|
-
|
|
195
|
-
拖动按钮:reflect/slots 是布局注入点,**原生不支持拖拽浮层**(用户问过"如果能拖动更好")。
|
|
196
|
-
采用"状态胶囊按钮 + 点击弹层"形态代替,信息密度更高且不违背平台约束。
|
|
197
|
-
|
|
198
|
-
### 原 dsh-plugin-proxy client 的写法(旧式,可见性差)
|
|
199
|
-
|
|
200
|
-
- `sidebar.footer.action`(侧边栏底部小开关)
|
|
201
|
-
- `settings.plugin.item`(Settings → Plugins 页卡片)
|
|
202
|
-
- 只读 settingsScope,**看不到运行时状态**(active/url/reason 纯推算)
|
|
203
|
-
- 新插件改为:settings 独立页(`settings.section`,现代式)+ 对话头部按钮 + 状态 API 轮询
|
|
204
|
-
|
|
205
|
-
---
|
|
206
|
-
|
|
207
|
-
## 7. Host 状态如何给到 Client(不重启也能实时)
|
|
208
|
-
|
|
209
|
-
`dsh-skill-manager` 的做法(样板):
|
|
210
|
-
|
|
211
|
-
```js
|
|
212
|
-
const routePath = '/skill-manager/api'
|
|
213
|
-
const unregisterRoute = ctx.webServer.register({
|
|
214
|
-
kind: 'prefix',
|
|
215
|
-
path: routePath,
|
|
216
|
-
handler: async (req, res) => { /* 解析 method+pathname 分发 */ }
|
|
217
|
-
})
|
|
218
|
-
// client 端直接 fetch(API + "/xxx") 同源调用
|
|
219
|
-
```
|
|
220
|
-
|
|
221
|
-
**必须带 browser-trust fence**(防跨站请求,skill-manager L131-162 完整实现):
|
|
222
|
-
- `Host` 头解析后必须 loopback(localhost / [::1] / 127/8)
|
|
223
|
-
- `sec-fetch-site: cross-site` 拒绝
|
|
224
|
-
- `origin` 若存在必须与 host 同源
|
|
225
|
-
- 拒绝时 403 `forbidden`(`denyIfUntrusted(req, res)` 供 handler 首行调用)
|
|
226
|
-
|
|
227
|
-
新插件 API 设计:
|
|
228
|
-
```
|
|
229
|
-
GET /dsh-proxy-pro/api/status → { ok, status: summarize(snapshot) } # UI 轮询
|
|
230
|
-
GET /dsh-proxy-pro/api/route?url=… → { ok, route, bypassed, probe, hint } # 诊断
|
|
231
|
-
POST /dsh-proxy-pro/api/toggle {"enabled":bool} → 更新 settings + 立即 sync
|
|
232
|
-
```
|
|
233
|
-
|
|
234
|
-
---
|
|
235
|
-
|
|
236
|
-
## 8. NTLM / keepalive(用户明确要求"有打的应用范围才做")
|
|
237
|
-
|
|
238
|
-
**事实**:
|
|
239
|
-
- proxyhk.huawei.com:8080 是 NTLM 认证的 SWG 代理。
|
|
240
|
-
- curl 用 `--proxy-ntlm --proxy-user ":"` 自带 NTLM 协商;**undici ProxyAgent 不协商 NTLM**。
|
|
241
|
-
- 因此依赖"认证后 keepalive 连接复用窗口":`~/.dsh/proxy-keepalive` 在跑,日志 09:24:31 "ok"。
|
|
242
|
-
|
|
243
|
-
**判断(对用户问题的答复——已确认)**:
|
|
244
|
-
- **不常见**:仅企业 SWG(netentsec/华为这类)需要;家里 Clash/v2rayN 本地代理无 NTLM。
|
|
245
|
-
- **不做进核心**:keepalive 做成**可选诊断**——`proxy_test` 探测到 407/authentication
|
|
246
|
-
required 时,才提示"可能需要 curl 先建认证窗口"(`keepaliveHint()`,贴近响应文本检测)。
|
|
247
|
-
- 多网站 keepalive **无意义**:代理是单点,keepalive 只对代理那一台建,与目标网站无关。
|
|
248
|
-
|
|
249
|
-
---
|
|
250
|
-
|
|
251
|
-
## 9. 无缝过渡方案(用户问"避免现在的路由断掉")
|
|
252
|
-
|
|
253
|
-
1. 新插件读**同一个 `proxy` settings namespace** → settings.yaml 的 proxy 段直接接管,
|
|
254
|
-
用户已有的 enabled/mode/customUrl/noProxy 配置零迁移。
|
|
255
|
-
2. 行为对齐:新插件 = 原来 dsh-plugin-proxy(env + 全局 dispatcher)**+ 我们验证过的
|
|
256
|
-
installProxyFromEnvironment 修复**,路由判定结果与当前打补丁后的状态完全一致
|
|
257
|
-
(github→PROXIED,deepseek→DIRECT)。
|
|
258
|
-
3. 切换顺序:装好新插件 → **先禁原插件**(cordis.patch.yml `- id: proxy, disabled: true`
|
|
259
|
-
或删除该行)→ 重启 → `proxy_status` 确认 → `web_test`/web_fetch 200 确认。
|
|
260
|
-
任何时候出问题,恢复原插件行 + 保留的 `.bak` 即回滚,配置从未动过。
|
|
261
|
-
4. 同一时刻只允许一个 dispatcher 主人(新老插件都写全局 dispatcher,同时启用会互踩)。
|
|
262
|
-
|
|
263
|
-
---
|
|
264
|
-
|
|
265
|
-
## 10. 已知事实速查(调测用)
|
|
266
|
-
|
|
267
|
-
| 项 | 值 |
|
|
268
|
-
|---|---|
|
|
269
|
-
| DSH | 2.0.10 / Electron 43.3.0 |
|
|
270
|
-
| Web GUI | http://127.0.0.1:43120(NodeService 21872 监听) |
|
|
271
|
-
| 系统代理 | proxyhk.huawei.com:8080(NTLM SWG,Proxy ON) |
|
|
272
|
-
| NO_PROXY | 大列表:`*.huawei.com` 等内网全量 + localhost/127.0.0.1/::1 |
|
|
273
|
-
| profile | web / test(两份,插件需都装) |
|
|
274
|
-
| settings.yaml | proxy: {enabled: true, mode: system, customUrl: http://127.0.0.1:7890} |
|
|
275
|
-
| 验证基线 | github → PROXIED;deepseek.com → DIRECT;web_fetch PR#3574 → 200 |
|
|
276
|
-
| loadLayeredEnv | process.env + cwd/.env + ~/.dsh/.env(仅 undefined 时写入) |
|
|
277
|
-
|
|
278
|
-
## 11. 关键文件位置
|
|
279
|
-
|
|
280
|
-
- 打包源码:`C:\Users\w00958282\AppData\Local\Programs\DSH Desktop\resources\app\node_modules\@deepseek-ai\...`
|
|
281
|
-
(dsh-web-fetch-http / dsh-http-proxy / dsh-app-boot / dsh-web / dsh-tool-web /
|
|
282
|
-
dsh-client-ui-conversation / dsh-client-ui-chat / dsh-client-modules / dsh-settings)
|
|
283
|
-
- 原插件(已打补丁 + .bak):`~/.dsh/profiles\{web,test}\node_modules\dsh-plugin-proxy\lib\`
|
|
284
|
-
- 参照模板:`~/.dsh/profiles\test\node_modules\dsh-skill-manager\lib\`(index.js + client.js)
|
|
285
|
-
- 本插件:`~/.dsh/plugins/dsh-proxy-pro/`(git 仓库,发布形态,见 §12)
|
|
286
|
-
- 环境事实:`~/.dsh/.env`(已删,纯冗余证据);`settings.yaml`、`cordis.patch.yml`、`cordis.yml`
|
|
287
|
-
|
|
288
|
-
---
|
|
289
|
-
|
|
290
|
-
## 12. 发布形态(2026-09-17 修订,取代 §5 的 file:// 方案)
|
|
291
|
-
|
|
292
|
-
**决策:dsh-proxy-pro 是标准 npm 插件包**,参照已发布的 `dsh-skill-manager@4.3.3`
|
|
293
|
-
(GitHub yanglaofish/dsh-skill-manager)。`~/.dsh/plugins/dsh-proxy-pro/` 是它的
|
|
294
|
-
开发源目录(git init,commit 04eb709),发布后从 npm / GitHub 安装。
|
|
295
|
-
|
|
296
|
-
### 12.1 机制(全部经本机源码验证)
|
|
297
|
-
|
|
298
|
-
- profile 的 `package.json`:
|
|
299
|
-
- `dependencies` 装插件:发布版 = registry 包;本地开发 =
|
|
300
|
-
`file:../../plugins/dsh-proxy-pro`(pnpm 软链,改源码免重装、两 profile 共用一份)。
|
|
301
|
-
- `dsh.profile.bundles` 追加包名 → dsh-app-boot `loadProfileDirectory`
|
|
302
|
-
(app lib/index.js L866-877)逐个读每个 bundle 包的 `dsh.bundle.patch`:
|
|
303
|
-
**声明了 dsh.bundle 但 patch 文件缺失 = 整个 profile boot 抛错**(不是跳过)。
|
|
304
|
-
- 插件包自己的 `cordis.patch.yml`(dsh.bundle.patch 指向)insert 一行
|
|
305
|
-
`- id: dsh-proxy-pro, name: '@yanglaofish/dsh-proxy-pro', config: {enabled:false, mode:system, ...}`,
|
|
306
|
-
格式照抄 dsh-plugin-proxy 自己的 patch。
|
|
307
|
-
- host 解析:一行 `name: '@yanglaofish/dsh-proxy-pro'`(包名)→ include import 走 profile
|
|
308
|
-
resolver(app lib/module-resolution-*.js 的 registerHooks):
|
|
309
|
-
- profile 边界/图内模块的裸包名先按 profile node_modules 解析,
|
|
310
|
-
没有则兜底 app 安装副本(`@deepseek-ai/*` 全在 app;profile 里实际只有
|
|
311
|
-
cosmokit/dsh-storage-domain/schemastery——已实测)。
|
|
312
|
-
- 因此 peerDependencies 声明版本即可,**profile 无需安装 `@deepseek-ai/*`**。
|
|
313
|
-
- client 发现同 skill-manager:package.json `dsh.client.platform: "web"` +
|
|
314
|
-
`exports["./client"]`;exports 应含 `"./cordis.patch.yml"`。
|
|
315
|
-
- 安装命令:`dsh plugin --profile web add <包或 file: 或 github:>`;卸载
|
|
316
|
-
`dsh plugin --profile web remove @yanglaofish/dsh-proxy-pro`。
|
|
317
|
-
|
|
318
|
-
### 12.2 传输架构(host 重构后,v0.1 落地)
|
|
319
|
-
|
|
320
|
-
- **删掉 undici 依赖 / EnvHttpProxyAgent / savedEnv / applyEffective**——
|
|
321
|
-
插件对 undici 零依赖,错版雷区(§3:7.29.0 vs 8.10.0)在源头上不存在。
|
|
322
|
-
- 唯一通道:`installProxyFromEnvironment(makeEnvLike(effective), log)` 一次完成
|
|
323
|
-
env(applyPolicyEnv 写 8 个名字大小写双写 + loopback 合并)+ per-origin
|
|
324
|
-
dispatcher + 模块级策略(proxyRouteFor 读它 → web_fetch 通道,§2 图)。
|
|
325
|
-
- OFF 分支:none-branch 恢复首次安装前的 env + 装直连 Agent(A9 干净卸载即此)。
|
|
326
|
-
- **haveApplied 守卫**:从未启用过且当前 OFF → 只记快照不碰传输层;默认
|
|
327
|
-
enabled:false 启动时与旧 dsh-plugin-proxy 并存互不抢 dispatcher(NFR-4)。
|
|
328
|
-
- 动机:旧设计先 applyEffective 再 installProxyFromEnvironment,后者永远最后赢,
|
|
329
|
-
EnvHttpProxyAgent 白建白关;且窗口期两个 undici 实例的 dispatcher 互操作正是
|
|
330
|
-
§3 UND_ERR_INVALID_ARG 的触发面。
|
|
331
|
-
|
|
332
|
-
### 12.3 过渡(NFR-3)
|
|
333
|
-
|
|
334
|
-
新行默认 `enabled: false` 共存启动 → 验证通过后再接管:profile cordis.patch.yml
|
|
335
|
-
加 `- id: proxy, disabled: true`(bundle 层 insert 的行可按 id patch)→ 重启。
|
|
336
|
-
回滚 = 删该行或 `dsh plugin remove`;settings.yaml 全程不动。
|
|
337
|
-
|
|
338
|
-
---
|
|
339
|
-
|
|
340
|
-
## 13. 自查记录(2026-09-17,code-review-skill 一轮)
|
|
341
|
-
|
|
342
|
-
### 13.1 dsh-tools schema 编译器铁律(**会让整树 boot 失败**)
|
|
343
|
-
|
|
344
|
-
- 每个 `type: 'object'` 必须显式 `additionalProperties: true|false`,否则
|
|
345
|
-
`defineTool` 抛 `JsonSchemaError: ... additionalProperties must be explicitly
|
|
346
|
-
true or false` → 该行 apply 抛错 → plugin tree 加载失败、GUI 起不来。
|
|
347
|
-
- 只信赖 schema 的**静态审计**不够:这次错误只在**运行期 apply** 暴露,
|
|
348
|
-
`--dump-config` 预检测不到(它只 compose 行,不执行插件)。
|
|
349
|
-
- 教训:改工具 schema 后,用「遍历全部 object 断言 additionalProperties
|
|
350
|
-
存在」的静态脚本或直接引用编译器,而不是靠肉眼。
|
|
351
|
-
|
|
352
|
-
### 13.2 settings.yaml 共享命名空间的实测事实
|
|
353
|
-
|
|
354
|
-
- 本机 `~/.dsh/settings.yaml` `proxy:` = `{enabled: true, mode: system}`——
|
|
355
|
-
与旧 dsh-plugin-proxy 共用同一 namespace。
|
|
356
|
-
- 后果:**重启后本插件会立即接管**(行序在后 = install 最后落地),
|
|
357
|
-
不是展示层默认的 OFF。这是预期行为,验收按 ON 流程走即可。
|
|
358
|
-
- 双插件共存期:任何 UI 改设置都会触发两边 sync,但两边读同一份配置、
|
|
359
|
-
计算结果一致,且我们是最后写入者 → 终态一致;旧行是天然兜底
|
|
360
|
-
(本插件 apply 失败时代理仍由旧插件管理)。
|
|
361
|
-
|
|
362
|
-
### 13.3 已修的三类问题(commit 5918310)
|
|
363
|
-
|
|
364
|
-
1. **teardown 与在途 sync 竞态**:卸载若赶在 sync 完成前执行,晚到的
|
|
365
|
-
`installProxyFromEnvironment` 会把 policy 泄漏到卸载之后 → 卸载链到
|
|
366
|
-
`syncing.finally` 之后。
|
|
367
|
-
2. **probeTarget 无效 URL 抛 throw**:`new URL('not a url')` 已捕获 →
|
|
368
|
-
结构化 `{route:'invalid', probe:{ok:false, kind:'invalid', ...}}`,
|
|
369
|
-
工具与 /api/route 都友好显示而非 500。
|
|
370
|
-
3. **`void requestSync()` 未捕获拒绝**:Node 15+ 未捕获的 promise 拒绝会
|
|
371
|
-
崩主进程 → setSource/onChange/poll 三处调用点都补 `.catch(() => {})`。
|
|
372
|
-
|
|
373
|
-
### 13.4 遗留 backlog(非阻塞,已文档化)
|
|
374
|
-
|
|
375
|
-
- index.js 业务逻辑(sync/guard/teardown/API/fence)无单测——依赖运行时验收;
|
|
376
|
-
补测需 mock dsh-http-proxy/dsh-settings,成本中等,留待发布后。
|
|
377
|
-
- client 轮询无 no-op 去重、无超时、慢响应可能乱序显示旧状态——组件极小,
|
|
378
|
-
现实风险低,暂不改。
|
|
379
|
-
|
|
380
|
-
---
|
|
381
|
-
|
|
382
|
-
## 14. 桌面 recovery 会静默重写 profile 的 package.json(2026-09-16 事故)
|
|
383
|
-
|
|
384
|
-
### 14.1 现象与根因
|
|
385
|
-
|
|
386
|
-
- 用户重启后"啥也没看到":头部无胶囊、设置页无「代理管理」。
|
|
387
|
-
- 查 `%APPDATA%\DSH Desktop\logs\dsh-<date>.log`:最后一次 run
|
|
388
|
-
(12:56)**无报错**,但更早一次(11:48)记录了
|
|
389
|
-
`unsupported JSON schema: schema.properties.probe.additionalProperties`
|
|
390
|
-
——这就是 8ce5e21 修的那个 schema bug(跑在修复前)。
|
|
391
|
-
- 决定性证据:`profiles/web/package.json` LastWriteTime = **11:50:28**,
|
|
392
|
-
恰好在 desktop 的 `recovery plugin uninstall failed`(11:50:09 error.log)
|
|
393
|
-
窗口内;对读 `profiles/test/package.json` 完好(11:35 未动)。
|
|
394
|
-
- **根因:桌面版启动时对解析失败的插件执行 recovery 卸载**
|
|
395
|
-
(`dsh plugin --profile web remove @yanglaofish/dsh-skill-manager`),
|
|
396
|
-
该操作重写了 web profile 的 package.json,**把我们手工加的两处
|
|
397
|
-
dsh-proxy-pro 条目连带清掉**,而 node_modules 的 Junction 软链仍在。
|
|
398
|
-
- 结论:**profile 文件不是只写一次的**——desktop 会在特定条件下用
|
|
399
|
-
`dsh plugin` 命令重写它。验收前必须复查 package.json 而非只信一次
|
|
400
|
-
`--dump-config` 的记忆。
|
|
401
|
-
|
|
402
|
-
### 14.2 修复与预防
|
|
403
|
-
|
|
404
|
-
- 修复:对照 test 的形态把 `dependencies["dsh-proxy-pro"] =
|
|
405
|
-
link:...` 与 `dsh.profile.bundles` 里的 `"dsh-proxy-pro"` 加回
|
|
406
|
-
web/package.json;`dsh --profile web --dump-config` 预检两行都在、exit=0。
|
|
407
|
-
- 预防:每次用户重启前**先读 profile 的 package.json** 确认条目在(read
|
|
408
|
-
工具,别依赖记忆);recovery 只会在树加载失败时触发——schema 已修 +
|
|
409
|
-
行在 = 干净加载 = 不会再被重写。
|
|
410
|
-
|
|
411
|
-
### 14.3 可复用排查路径("重启后啥也没看到"标准动作)
|
|
412
|
-
|
|
413
|
-
1. 读 `%APPDATA%\DSH Desktop\logs\dsh-<date>.log` 最后几段——成功 run
|
|
414
|
-
只有 `--- run ---` 头,失败 run 有 `Error:` 行 + 完整栈(栈里
|
|
415
|
-
`#dsh-proxy-pro` / `#include` 指向 profiles/web,确认 desktop 用的
|
|
416
|
-
就是 web profile)。
|
|
417
|
-
2. 对照同一时刻 `dsh-<date>.error.log` 与 profile package.json 的
|
|
418
|
-
LastWriteTime——两者撞窗 = recovery 重写过。
|
|
419
|
-
3. `dsh --profile web --dump-config` 复检行在树里。
|
|
420
|
-
4. 修好 → 重启 → 按 ACCEPTANCE.md 验收。
|
|
421
|
-
|
|
422
|
-
---
|
|
423
|
-
|
|
424
|
-
## 15. dsh-tools 工具 schema DSL 完整词表 + 本地编译验证(2026-09-16)
|
|
425
|
-
|
|
426
|
-
### 15.1 事故:schema 类错误烧掉三次重启
|
|
427
|
-
|
|
428
|
-
三次 boot 失败,同一个根因家族,全都是 `defineTool` 在 apply 期抛
|
|
429
|
-
`JsonSchemaError` → 整个 plugin tree 加载失败:
|
|
430
|
-
|
|
431
|
-
| 报错 | 违规写法 | 正确写法 |
|
|
432
|
-
|---|---|---|
|
|
433
|
-
| `.additionalProperties must be explicitly true or false` | object 省略该键 | 每个 `type:'object'` 显式 `additionalProperties: false` |
|
|
434
|
-
| `.required must be true when present` | `{ type:'number', required: false }` | 可选属性**省略 `required`**,不能写 `false` |
|
|
435
|
-
|
|
436
|
-
### 15.2 DSL 词表(编译器逐字确认,dsh-tools lib/schema)
|
|
437
|
-
|
|
438
|
-
每个节点允许的键 = 通用注解 `description/title/default/examples`
|
|
439
|
-
+(仅**属性节点**且仅当 `required === true` 时)`required`
|
|
440
|
-
+ 按类型:
|
|
441
|
-
|
|
442
|
-
| 类型 | 额外允许键 | 硬性要求 |
|
|
443
|
-
|---|---|---|
|
|
444
|
-
| `object` | `properties`, `additionalProperties` | `additionalProperties` 必须是显式 boolean |
|
|
445
|
-
| `array` | `items` | — |
|
|
446
|
-
| `string`/`number`/`integer`/`boolean`/`null` | `enum`, `const` | — |
|
|
447
|
-
| `json` | (仅注解) | — |
|
|
448
|
-
| 替代式 `oneOf` | `oneOf`(≥2 分支) | 不可与 `type` 同时出现 |
|
|
449
|
-
|
|
450
|
-
- **任何 DSL 之外的键都是错误**(`format`/`pattern`/`minimum` 等一律不支持)。
|
|
451
|
-
- `required` 是**属性级标记**而非属性名数组;对象根的 required 列表由编译器收集。
|
|
452
|
-
|
|
453
|
-
### 15.3 解法:把 apply 期错误提前到本地(不重启)
|
|
454
|
-
|
|
455
|
-
`dsh-tools` **导出**了 `defineTool` 内部使用的两个编译器:
|
|
456
|
-
|
|
457
|
-
```js
|
|
458
|
-
parameterSchemaSpecToJsonSchema(options.parameters) // defineTool L846
|
|
459
|
-
valueSchemaSpecToJsonSchema(options.output.schema) // defineTool L847
|
|
460
|
-
```
|
|
461
|
-
|
|
462
|
-
因此工具 schema 抽到 `lib/tool-schemas.js`(纯数据、零依赖),
|
|
463
|
-
`test/tool-schema.test.mjs` 做两层校验:
|
|
464
|
-
|
|
465
|
-
1. **静态镜像**:完整复刻上面的词表(含 `required` 只许 true、object 必须
|
|
466
|
-
显式 additionalProperties、未知键报错),无 app 也能跑;
|
|
467
|
-
2. **真实编译器**:定位 `@deepseek-ai/dsh-tools` 并调用那两个导出——
|
|
468
|
-
路径来源依次为 `DSH_TOOLS_MODULE` → `DSH_APP_ROOT` →
|
|
469
|
-
`%LOCALAPPDATA%\Programs\DSH Desktop\resources\app`;找不到则 skip
|
|
470
|
-
并打 diagnostic(可移植,不硬编码机器路径)。
|
|
471
|
-
|
|
472
|
-
### 15.4 规矩
|
|
473
|
-
|
|
474
|
-
- **改任何 tool schema(含新增工具)后,必须先跑 `npm test`**——
|
|
475
|
-
`test/tool-schema.test.mjs` 必须绿,否则不要重启 DSH。
|
|
476
|
-
- `--dump-config` 检测不到这类错误(它只 compose 行,不执行插件 apply),
|
|
477
|
-
别用它当"能启动"的证据。
|
|
478
|
-
|
|
479
|
-
---
|
|
480
|
-
|
|
481
|
-
## 16. Client 半的槽位规矩(2026-09-16 静态核对发现)
|
|
482
|
-
|
|
483
|
-
### 16.1 inject 的槽名必须等于 register 的槽名
|
|
484
|
-
|
|
485
|
-
- 错误写法(本插件原样,已修):`slots.inject("conversation.session.header", …)`
|
|
486
|
-
里 `register({ name: "conversation.session.header.utilities" })` ——
|
|
487
|
-
**注入父槽、注册子槽**,注册不会生效。
|
|
488
|
-
- 正确写法(app 官方先例逐字):
|
|
489
|
-
`dsh-client-ui-open-in-app/lib/client.js:406`、
|
|
490
|
-
`dsh-session-log-export/lib/client.js:274` 都是
|
|
491
|
-
`ctx.slots.inject("conversation.session.header.utilities", () => ctx.slots.register({
|
|
492
|
-
name: "conversation.session.header.utilities", … }))`。
|
|
493
|
-
- 结论:**inject 的键就是 register 的 name**,子槽也要写全名。
|
|
494
|
-
|
|
495
|
-
### 16.2 可用槽位的事实来源(别再猜)
|
|
496
|
-
|
|
497
|
-
- `conversation.session.header` 家族由 `@deepseek-ai/dsh-client-ui-conversation`
|
|
498
|
-
定义(client.js L16684-16699:`.lineage` / `.actions` / `.utilities` / `.corner`),
|
|
499
|
-
渲染点在 L14951 / L15072-15082。
|
|
500
|
-
- `settings.section` 由 `dsh-client-ui-settings-general`、`settings-models`、
|
|
501
|
-
`settings-plugins`、`ui-agent-preset` 等注册;`dsh-cordis-client-runner`
|
|
502
|
-
L3872 持有槽树 `key: "settings.section"`。
|
|
503
|
-
- 核对方法:grep `@deepseek-ai/*/lib/client.js` 找 `renderSlot("槽名"` 与
|
|
504
|
-
`register({ name: "槽名"` 的**实际先例**,照抄其形式(含 `slots.inject` 写法)。
|
|
505
|
-
- 对比:dsh-plugin-proxy 挂在 `sidebar.footer.action` + `settings.plugin.item`;
|
|
506
|
-
本插件按 FR-2 选对话头部工具区(`…header.utilities`),与 open-in-app 同款。
|
|
507
|
-
|
|
508
|
-
### 16.3 两处 inject 不要混
|
|
509
|
-
|
|
510
|
-
- `package.json` 的 `dsh.client.inject` = **client 包名列表**(加载顺序)。
|
|
511
|
-
- `client.js` 导出的 `exports.inject` = **Cordis 服务名**(`slots`、`settingsScope`)。
|
|
512
|
-
- 把包名写进 `exports.inject` 会永久 pending 并阻塞 web boot(§6)。
|
|
513
|
-
- 本插件 client 模块 id 遵循 `<包名>/client`(`"dsh-proxy-pro/client"`),
|
|
514
|
-
与 `"dsh-plugin-proxy/client"` 同规。
|
|
515
|
-
|
|
516
|
-
---
|
|
517
|
-
|
|
518
|
-
## 17. 轮询≠重组:GET 状态绝不能触发全量重装(2026-09-17 桌面卡顿根因)
|
|
519
|
-
|
|
520
|
-
### 17.1 现象
|
|
521
|
-
|
|
522
|
-
- 用户报告:挂上 dsh-proxy-pro 后 desktop 很卡,"切换模型都卡"。
|
|
523
|
-
- 直觉困惑:"插件只是流量走代理转发,怎么会卡 UI?"——**转发本身不卡,
|
|
524
|
-
卡的是把读状态变成了每几秒拆装一次代理通道**。
|
|
525
|
-
|
|
526
|
-
### 17.2 根因链(三层叠加)
|
|
527
|
-
|
|
528
|
-
1. client 两个 `useRuntimeStatus(4000/3000)` 轮询 `/api/status`;
|
|
529
|
-
2. host 的 `/api/status` handler 上来就 `await requestSync()`;
|
|
530
|
-
3. `sync()` 无条件执行:**读注册表(execFile 子进程)→ 卸载旧 policy →
|
|
531
|
-
`installProxyFromEnvironment` 重装全局 dispatcher + 重置 env**。
|
|
532
|
-
|
|
533
|
-
结果:GUI 打开时 ≈ 每 3-4 秒一次全量重建全局代理(undici agent 池反复
|
|
534
|
-
弃建、在途连接被拆、utility process 高频 spawn reg 子进程)。所有走代理的
|
|
535
|
-
请求(含模型/页面)都在被反复打断,desktop 自然拖垮。
|
|
536
|
-
|
|
537
|
-
### 17.3 修复(commit 096e431,两层)
|
|
538
|
-
|
|
539
|
-
1. **API 只读快照**:`/api/status`、`/api/route` 不再 `requestSync()`——
|
|
540
|
-
直接返回内存里的 `summarize(snapshot)`。快照由配置钩子 + systemPollMs
|
|
541
|
-
轮询维持新鲜,读请求零成本。(`/api/toggle` 是写操作,保留 requestSync。)
|
|
542
|
-
2. **sync 幂等化**:sync 开头计算 `effKey = JSON.stringify([active, url, noProxy, reason])`,
|
|
543
|
-
**生效状态未变 → 只刷新快照 + emit,绝不触碰 env/dispatcher**;只有
|
|
544
|
-
effKey 变化(配置变更 / 系统代理事实变化)才真正重装。
|
|
545
|
-
|
|
546
|
-
### 17.4 此后铁律
|
|
547
|
-
|
|
548
|
-
- **读型 API / 读型轮询永远不触发重装**;重装只发生在"生效状态真正变化"
|
|
549
|
-
的路径上(写操作、poll 检测到变化)。
|
|
550
|
-
- `requestSync()` 本身也要幂等:被高频调用时,未变化的 pass 必须 ≈0 成本。
|
|
551
|
-
- 新增任何"定时/轮询"行为时,先问:它会不会触发全量重建?会就拆开。
|
|
552
|
-
|
|
553
|
-
---
|
|
554
|
-
|
|
555
|
-
## 18. 客户端注册 id 必须是包名(2026-09-17,改 scoped 名后暴露)
|
|
556
|
-
|
|
557
|
-
**症状**:包名从 `dsh-proxy-pro` 改为 `@yanglaofish/dsh-proxy-pro` 后,Web 端启动直接失败:
|
|
558
|
-
|
|
559
|
-
```
|
|
560
|
-
failed to import loader entry 18081979 (@yanglaofish/dsh-proxy-pro): client-modules: bundle
|
|
561
|
-
/plugins/??…,@yanglaofish/dsh-proxy-pro/client.js,…&rev=… loaded without registering
|
|
562
|
-
"@yanglaofish/dsh-proxy-pro" via __ModuleLoader__.load
|
|
563
|
-
```
|
|
564
|
-
|
|
565
|
-
**根因**:`lib/client.js` 仍在用旧名注册:`window.__ModuleLoader__.load({ id: "dsh-proxy-pro/client", … })`。
|
|
566
|
-
`@deepseek-ai/dsh-client-modules/lib/client.js` 的 `register()` 存 factories 时用
|
|
567
|
-
`stripClientSuffix(registration.id)`,而 `arrive()` 是按 **graph 行 id(= 包名)** 查找的
|
|
568
|
-
(L230 / L248)。`dsh-proxy-pro` ≠ `@yanglaofish/dsh-proxy-pro` → 查不到 → 抛错。
|
|
569
|
-
|
|
570
|
-
**规矩**:client bundle 注册 id 写**完整包名**(对照 `@yanglaofish/dsh-skill-manager` 的
|
|
571
|
-
`id: "@yanglaofish/dsh-skill-manager"`;它连 `exports.name` 都没有,说明注册 id 才是匹配键)。
|
|
572
|
-
包名改名时,"看起来像运行时标识、其实是匹配键"的地方必须一起切:
|
|
573
|
-
|
|
574
|
-
- `cordis.patch.yml` insert 行的 `name`(= 包名,host 侧 import 用)
|
|
575
|
-
- `package.json` 的 `name`(`dsh.client` 声明的宿主包名)
|
|
576
|
-
- `lib/client.js` 的 `__ModuleLoader__.load({ id })`(= 包名)
|
|
577
|
-
|
|
578
|
-
**保留不动的内部标识**(与包名解耦,改了反而破坏兼容):
|
|
579
|
-
`/dsh-proxy-pro/api` 路由、`ctx.emit('dsh-proxy-pro/status')` 事件名、插件 id、
|
|
580
|
-
`.dsxprx` CSS 前缀、日志前缀、settings 的 `proxy` namespace。
|
|
581
|
-
|
|
582
|
-
---
|
|
583
|
-
|
|
584
|
-
## 19. profile 依赖只能经 dsh CLI 改(手改会被运行中的 Desktop 覆盖)
|
|
585
|
-
|
|
586
|
-
运行中的 DSH Desktop 以自身内存状态为准回写 `profiles/<p>/package.json`:手动 edit 加回
|
|
587
|
-
`@yanglaofish/dsh-proxy-pro` 依赖后,文件会被抹回"无此插件"——pnpm 于是看不到依赖
|
|
588
|
-
(lockfile 里没有条目,`pnpm install` 直接 "Already up to date",`--force` 也不装)。
|
|
589
|
-
|
|
590
|
-
**正确做法**(2026-09-17 实测通过):
|
|
591
|
-
|
|
592
|
-
```sh
|
|
593
|
-
# 快照式(pnpm 把目录内容装进 node_modules,改源码需重装)
|
|
594
|
-
dsh plugin --profile web add file:C:/Users/w00958282/.dsh/plugins/dsh-proxy-pro
|
|
595
|
-
|
|
596
|
-
# 实时式(junction 指向源码,改源码即时生效)——本地开发用这个
|
|
597
|
-
dsh plugin --profile web add link:C:/Users/w00958282/.dsh/plugins/dsh-proxy-pro
|
|
598
|
-
```
|
|
599
|
-
|
|
600
|
-
CLI 会同时写 `dependencies` + `dsh.profile.bundles` 并跑 pnpm 安装;两侧都不会被覆盖。
|
|
601
|
-
验证:`(Get-Item node_modules\@yanglaofish\dsh-proxy-pro).LinkType` 应为 `Junction`,
|
|
602
|
-
`.Target` 指向 `~/.dsh/plugins/dsh-proxy-pro`。
|
|
603
|
-
|
|
604
|
-
---
|
|
605
|
-
|
|
606
|
-
## 20. 写中文文档不要走 pwsh 命令行(编码会坏)
|
|
607
|
-
|
|
608
|
-
用 `pwsh` 的 here-string + `Add-Content` 向 markdown 追加中文时,命令传输过程会把非 ASCII
|
|
609
|
-
字符写成乱码(实测 LESSONS.md 末尾 50 行全成 `浼氬悓鏃跺啓` 一类)。**中文内容一律用
|
|
610
|
-
write / edit 工具写入**(工具通道是 UTF-8 正确的),pwsh 只做纯 ASCII 操作。
|
|
611
|
-
|
|
612
|
-
同一次事故的连带发现:手写文件操作绕过了读观测策略,也容易在"截断/重写"时改变行尾
|
|
613
|
-
(CRLF/LF)导致整文件 diff。恢复手段:`git checkout -- <file>` 回 HEAD 版本重来。
|
|
614
|
-
|
|
615
|
-
---
|
|
616
|
-
|
|
617
|
-
## 21. .git 目录只丢 HEAD/config/index 时可以就地救回
|
|
618
|
-
|
|
619
|
-
现象:`git` 报 `fatal: not a git repository`,但 `.git/` 存在且含 `objects/`、`refs/`。
|
|
620
|
-
(本例 `.git` 只剩这两个子目录,缺 `HEAD`/`config`/`index`。)
|
|
621
|
-
|
|
622
|
-
**救回步骤**(不需要网络):
|
|
623
|
-
|
|
624
|
-
```sh
|
|
625
|
-
mv .git .git.broken
|
|
626
|
-
git init -b master
|
|
627
|
-
cp -r .git.broken/refs/* .git/refs/
|
|
628
|
-
cp -r .git.broken/objects/* .git/objects/
|
|
629
|
-
git reset # 用 HEAD 重建 index
|
|
630
|
-
git log --oneline # 历史完整(refs/heads/master 里的 tip 仍在)
|
|
631
|
-
```
|
|
632
|
-
|
|
633
|
-
前提:`.git.broken/refs/heads/<branch>` 仍指向有效 commit、objects 完整(松散或 pack)。
|
|
634
|
-
若 refs 也没了,就只能等网络恢复后 `git fetch` 远端历史再 `git reset --mixed origin/<branch>`。
|
|
635
|
-
本例 `git fetch` 失败于 `Could not resolve proxy: proxyhk.huawei.com`——git 的 `http.proxy`
|
|
636
|
-
指向公司代理,离开公司网络(或代理关闭)时 GitHub 不可达;此时**不要** push/force push。
|
|
637
|
-
|
|
638
|
-
---
|
|
639
|
-
|
|
640
|
-
## 22. 强制通道探测必须用同一个 undici 副本(2026-09-17)
|
|
641
|
-
|
|
642
|
-
**症状**:设置页「代理配置」的逐 URL 诊断,无论勾不勾「使用代理」都返回 `fail`
|
|
643
|
-
(面板只显示 `fetch failed`);而 `curl`、独立 node 进程、policy 通道都正常。
|
|
644
|
-
|
|
645
|
-
**根因(实测)**:`forceChannelProbe` 用 `import('undici')`(app 的 undici 包)造
|
|
646
|
-
`Agent`/`ProxyAgent`,却把它交给**全局 `fetch`**(Node 内置的另一份 undici)当 `dispatcher`:
|
|
647
|
-
|
|
648
|
-
```
|
|
649
|
-
CONTROL 内置 fetch(无 dispatcher) -> OK 401
|
|
650
|
-
A 内置 fetch + 外部 undici Agent -> ERR fetch failed | UND_ERR_INVALID_ARG: invalid onRequestStart method
|
|
651
|
-
B 外部 undici 的 fetch + 同一 Agent -> OK 401
|
|
652
|
-
D 内置 fetch + 外部 ProxyAgent -> ERR fetch failed | UND_ERR_INVALID_ARG
|
|
653
|
-
E 外部 undici 的 fetch + 同一 ProxyAgent -> OK 401
|
|
654
|
-
```
|
|
655
|
-
|
|
656
|
-
**规矩**:凡是要传 `dispatcher` 的请求,**请求与被传的 dispatcher 必须来自同一次
|
|
657
|
-
`import('undici')`**(即 `und.fetch(url, { dispatcher })`)。这正是 §3 的老教训
|
|
658
|
-
(mixed undici instances → UND_ERR_INVALID_ARG)——插件主干一直靠"委托
|
|
659
|
-
`installProxyFromEnvironment`、自己绝不碰 undici"规避它,而强制通道绕过了这条原则。
|
|
660
|
-
|
|
661
|
-
**顺带修掉的诊断缺陷**:`classifyTargetFailure` 原本只回 `error.message`("fetch failed"),
|
|
662
|
-
丢掉了 `error.cause` 里的 `code`。现在优先输出 cause code,并把 `UND_ERR_INVALID_ARG`
|
|
663
|
-
单独归类(`kind: 'dispatcher'`,提示 "mixed undici copies")。
|
|
664
|
-
|
|
665
|
-
---
|
|
666
|
-
|
|
667
|
-
## 23. 与 @deepseek-ai/dsh-http-proxy 插件行共存会抢全局 dispatcher
|
|
668
|
-
|
|
669
|
-
同一天的另一条线索:把 `@deepseek-ai/dsh-http-proxy` **作为插件行同时启用**时,policy 通道
|
|
670
|
-
探测也出现过 `fetch failed`;从 profile 的 bundles/依赖里移除该行后恢复正常。
|
|
671
|
-
|
|
672
|
-
原因:两个插件都调同一个库的 `installProxyFromEnvironment()`,各自
|
|
673
|
-
`setGlobalDispatcher()`;谁先卸载,谁的 disposer 就把"它安装前"的 dispatcher
|
|
674
|
-
(`previousDispatcher`)恢复回去,全局 dispatcher 于是可能指向已关闭/陈旧实例。
|
|
675
|
-
|
|
676
|
-
**结论**:本插件与 `dsh-http-proxy` 插件行**二选一**。库本身必须保留——插件 import 它的
|
|
677
|
-
`installProxyFromEnvironment` / `proxyRouteFor`,而 DSH 的 web_fetch 通道读的正是同一模块的
|
|
678
|
-
module-level policy(§2-§3)。卸载"插件行"是安全的,删除"包"会让本插件 import 直接失败。
|
|
679
|
-
|
|
680
|
-
---
|
|
681
|
-
|
|
682
|
-
## 24. 诊断结论要三态 + 可行动(2026-09-17)
|
|
683
|
-
|
|
684
|
-
**症状**:面板与 `proxy_test` 的「连通探测」只分可达/不可达两档——`HTTP 504`(网关/上游超时)
|
|
685
|
-
和 `HTTP 200` 都打印成「可达」,用户无法判断"到底能不能用"。
|
|
686
|
-
|
|
687
|
-
**改法**(1.0.3):
|
|
688
|
-
|
|
689
|
-
- `classifyProbeStatus(status)` 产出三态:
|
|
690
|
-
- `usable`:2xx/3xx,以及 **401**(探测不带凭证,服务健康就该回 401);
|
|
691
|
-
- `degraded`:403/404/405/429 等 4xx —— **路由通、这次调用会失败**;
|
|
692
|
-
- `unusable`:407、5xx(500/502/503/504)—— 路由或对端失败。
|
|
693
|
-
- 每个结论都带 `why`(可能原因)+ `fix`(挽救措施):面板显示三层
|
|
694
|
-
(连通探测 / 可能原因 / 挽救措施),`proxy_test` 输出 `Why:` / `Fix:` 行。
|
|
695
|
-
- `classifyTargetFailure` 同样补齐 why/fix,并按 ENOTFOUND / ECONNREFUSED / ETIMEDOUT /
|
|
696
|
-
ECONNRESET / UND_ERR_INVALID_ARG / 407 分别归类。
|
|
697
|
-
- 探测顺序:**HEAD →(405/501 时)GET `Range: bytes=0-0` 复测**。很多 API 只实现 GET,
|
|
698
|
-
单用 HEAD 会冤枉一个可用主机;复测结果里附 `note` 说明。
|
|
699
|
-
|
|
700
|
-
**教训**:诊断工具的返回值必须回答"能不能用"和"下一步怎么办"——只回一个状态码,等于把问题
|
|
701
|
-
又丢回给用户。另外 `additionalProperties: false` 的 schema 必须同步列出新字段
|
|
702
|
-
(verdict/why/fix/note),否则 defineTool 校验直接失败。
|
|
703
|
-
|
|
704
|
-
---
|
|
705
|
-
|
|
706
|
-
## 25. 策略通道也要同一 undici 副本(2026-09-17)
|
|
707
|
-
|
|
708
|
-
**症状**:1.0.3 面板「连通探测」(不勾选「使用代理」)显示
|
|
709
|
-
`不可用:dispatcher rejected by this undici instance (UND_ERR_INVALID_ARG) — mixed undici copies`。
|
|
710
|
-
|
|
711
|
-
**根因**:1.0.2 只修了强制通道(`und.fetch` + 同副本 dispatcher,§22)。策略通道一直用 Node
|
|
712
|
-
内置 `fetch` 且不传 dispatcher —— 但全局 dispatcher 是 `installProxyFromEnvironment` 用 **app
|
|
713
|
-
undici** 装的,内置 fetch 拿它发请求 = A 组合(内置 fetch + 外部 Agent)→ 必然
|
|
714
|
-
UND_ERR_INVALID_ARG。之前没暴露,是因为面板默认勾选「使用代理」(强制通道)掩盖了它。
|
|
715
|
-
|
|
716
|
-
**修法**(1.0.4):策略通道同样走 app undici——
|
|
717
|
-
`policyProbe()` = `und.fetch(url, { dispatcher: und.getGlobalDispatcher() })`。
|
|
718
|
-
`getGlobalDispatcher()` 返回的正是 web_fetch 通道在用的全局 dispatcher,语义 = 模拟 web_fetch,
|
|
719
|
-
且永远同副本。三条探测路径现在统一:policy = 全局 dispatcher / forced-direct = 新建 Agent /
|
|
720
|
-
forced-proxy = 新建 ProxyAgent,全部由 `und.fetch` 发起。
|
|
721
|
-
|
|
722
|
-
**连带**:所有展示文案(short/why/fix/hint)中文化——面板是中文 UI,英文 why/fix 让人无法
|
|
723
|
-
行动;`proxy_test` 工具输出同步中文(模型照读)。教训:给用户看的文案别用英文"图省事",
|
|
724
|
-
判定结论必须能直接用。
|
|
725
|
-
|
|
726
|
-
**测试**:断言随中文文案同步翻新(/混用/、/超时/、/上游|超时/、/ECONNREFUSED/)。
|
|
727
|
-
|
|
728
|
-
---
|
|
729
|
-
|
|
730
|
-
## 26. 不 import 会被移除的库导出:settings 走服务(2026-09-18)
|
|
731
|
-
|
|
732
|
-
**症状**:升级 test profile 后,dsh CLI 启动时整棵树崩溃:
|
|
733
|
-
`SyntaxError: The requested module '@deepseek-ai/dsh-settings' does not provide an export named 'installSettingsSection'`,
|
|
734
|
-
插件行加载失败 → 整个启动终止。
|
|
735
|
-
|
|
736
|
-
**根因**:dsh-settings 同时存在两个版本——桌面版(resources/app/node_modules)**保留**了
|
|
737
|
-
`installSettingsSection`/`settingsNamespace` 兼容导出;全局 dsh CLI(AppData/Roaming/npm 的
|
|
738
|
-
`@deepseek-ai/dsh` 自带依赖树)是**新版,已移除这两个导出**。插件在模块顶层
|
|
739
|
-
`import { installSettingsSection } from '@deepseek-ai/dsh-settings'` → ESM 求值阶段就抛
|
|
740
|
-
SyntaxError,不是运行期错误——profile 插件树加载失败,启动直接终止。
|
|
741
|
-
|
|
742
|
-
**改法**(1.0.5):彻底不 import dsh-settings 的任何导出,配置注册改走 **settings 服务**:
|
|
743
|
-
```js
|
|
744
|
-
const scope = ctx.settings.register(PROXY_NS, Config, { base: config })
|
|
745
|
-
source = () => scope.get() // update() 重算 registration.resolved,get() 始终新鲜
|
|
746
|
-
scope.watch(() => { void requestSync().catch(() => {}) })
|
|
747
|
-
```
|
|
748
|
-
`register`/`watch`/`update`/`replace` 在两端(旧桌面版 SettingsProvider 与新 CLI 版)都存在;
|
|
749
|
-
命名空间直接写字面量 `'proxy'`(旧 `settingsNamespace()` 也只是 parse + 校验)。
|
|
750
|
-
|
|
751
|
-
**教训**:① ESM 顶层 import 命中断言是**加载期**错误——一次错误的 import 能杀死整个进程,所以
|
|
752
|
-
对外部包的导出要查运行环境的实际版本,而不是凭记忆;② 能从服务拿到的(settings 是 inject 服务)
|
|
753
|
-
就不要 import 包;③ 「桌面版」与「全局 CLI 版」是两套依赖树,一个功能必须两端都能加载。
|
|
754
|
-
|
|
755
|
-
---
|
|
756
|
-
|
|
757
|
-
## 27. HEAD 404 ≠ 路径不存在:探测必须 GET 复测(2026-09-18)
|
|
758
|
-
|
|
759
|
-
**症状**:面板测 `https://dashscope.aliyuncs.com/compatible-mode/v1` 和 `.../v1/models`
|
|
760
|
-
一律显示「降级 — HTTP 404 — 路径不存在」;连测几个厂商的 API 都是 404,用户开始怀疑
|
|
761
|
-
「是不是网络不可达」。
|
|
762
|
-
|
|
763
|
-
**真相**(实测对照):
|
|
764
|
-
|
|
765
|
-
| 请求 | DashScope 应答 |
|
|
766
|
-
| --- | --- |
|
|
767
|
-
| GET `/compatible-mode/v1/models` | **401**(端点存在,只缺 API Key) |
|
|
768
|
-
| GET `/compatible-mode/v1/chat/completions` | **400**(端点存在,请求不合法) |
|
|
769
|
-
| GET `/` | 404 |
|
|
770
|
-
| HEAD `/compatible-mode/v1/models` | **404** ← 同一个端点! |
|
|
771
|
-
|
|
772
|
-
网关不给 HEAD 注册路由,对 HEAD 一律回 404;而 404 被探测当成「路径不存在」,健康的
|
|
773
|
-
API 端点就这样被误判。**404 从来不是「网络不可达」的证据**——它恰恰证明拿到了 HTTP 应答
|
|
774
|
-
(真正的不可达是 ENOTFOUND/ECONNREFUSED/ETIMEDOUT/代理 407)。
|
|
775
|
-
|
|
776
|
-
**改法**(1.0.7):回退条件从 405/501 扩到 **404/405/501**,提为纯函数
|
|
777
|
-
`headNeedsGetRetry(status)`(proxy-core,可单测)。GET 复测带 `Range: bytes=0-0` 只取首
|
|
778
|
-
字节,代价极小;note 写明「HEAD 返回 X,GET 得到 Y — 以 GET 为准」。404 的 why/fix 改为
|
|
779
|
-
「HEAD 与 GET 都 404」并引导换真实完整端点复测。
|
|
780
|
-
|
|
781
|
-
**教训**:① 探测方法本身就是误判来源——HEAD「轻量」不等于「等价」;② 把 404 当「不存在」
|
|
782
|
-
之前先问「服务器认不认这个方法」;③ 状态码是二手的,链路层结论只能由「是否拿到 HTTP
|
|
783
|
-
应答」给出。
|
|
784
|
-
|
|
785
|
-
---
|
|
786
|
-
|
|
787
|
-
## 28. 发布铁律:每次 npm publish 后必须主动强制触发淘宝源同步(用户定案,2026-09-18)
|
|
788
|
-
|
|
789
|
-
**用户的规矩(说了两次,不可违背)**:每次我们更新 npm 包(publish 成功)后,**必须手动
|
|
790
|
-
主动触发淘宝源(npmmirror)更新,不许等待**——用户原话「每次更新了 npm,都请你手动强制
|
|
791
|
-
触发淘宝源更新」「不要等待,就是走淘宝的主动同步」。
|
|
792
|
-
|
|
793
|
-
**为什么**:npmmirror 自己拉取官方源有队列延迟,干等(只 PUT 一次 syncs 然后 poll)会 stuck
|
|
794
|
-
几分钟甚至更久,导致 `dsh plugin add <新版本>` 在淘宝源上解析不到。**主动循环触发**(每轮
|
|
795
|
-
先 PUT syncs、再 poll)实测 1.0.5=11 轮、1.0.6=13 轮、1.0.7=33 轮(约 2-4 分钟)必定拉到。
|
|
796
|
-
|
|
797
|
-
**标准流程(每一步都要做)**:
|
|
798
|
-
1. `npm publish --access=public --registry=https://registry.npmjs.org --replace-registry-host=never` 成功;
|
|
799
|
-
2. 循环最多 60 轮:`PUT https://registry.npmmirror.com/-/package/@yanglaofish%2Fdsh-proxy-pro/syncs`
|
|
800
|
-
带 body `{"version":"<新版本>"}`(ContentType application/json),随后
|
|
801
|
-
`GET https://registry.npmmirror.com/@yanglaofish%2Fdsh-proxy-pro` 看 `dist-tags.latest`;
|
|
802
|
-
每轮间隔 8-10s,**一直循环到 latest == 新版本**(不许提前放弃);
|
|
803
|
-
3. latest 到位后立即:`dsh plugin --profile test add '@yanglaofish/dsh-proxy-pro@^<新版本>'`
|
|
804
|
-
与 `dsh plugin --profile web add ...`,并验证两个 profile 的 node_modules 版本。
|
|
805
|
-
|
|
806
|
-
**教训**:① 「PUT syncs 返回 201」只是入队确认,不是同步完成——以 `dist-tags.latest` 为准;
|
|
807
|
-
② 官方 registry 先可见不代表淘宝可见,淘宝拉取是独立队列;③ 用户规则优先于"省事"——主动
|
|
808
|
-
触发是默认动作,不是可选优化。
|
|
809
|
-
|
|
810
|
-
**补充:git push 的代理必须与「被暖窗的代理」一致(2026-09-18 实测)**:git 全局配置里写死的
|
|
811
|
-
`http.proxy=http://proxyhk.huawei.com:8080` 是历史遗留——当前网络环境该代理已 407 失效,
|
|
812
|
-
而系统代理是 `proxyza`(南非)。正确姿势:push 前用 `Invoke-WebRequest https://github.com`
|
|
813
|
-
暖**当前系统代理**(.NET 自动 NTLM),然后 `git -c http.proxy=http://<当前系统代理> push`
|
|
814
|
-
(用 `-c` 临时覆盖写死的 config)。若暖窗对象与 git 实际走的代理不一致,必然 407 CONNECT
|
|
815
|
-
tunnel failed(proxyhk 暖窗 407、proxyza 暖窗 200 → 只有后者能推成功)。长期可选:
|
|
816
|
-
`git config --global --unset http.proxy` 让 git 走插件写入的 `http_proxy` 环境变量(动态跟随
|
|
817
|
-
当前系统代理),注意 CLI 环境无插件时需手动 `-c` 指定。
|
|
1
|
+
# dsh-proxy-pro — 设计依据与踩坑备忘
|
|
2
|
+
|
|
3
|
+
> 本文档记录本插件诞生的完整背景:DSH 代理链路的真实行为、我们踩过的每一个
|
|
4
|
+
> 坑、验证过的修复方案,以及据此做出的插件设计决策。**后续任何改动请先读本文**,
|
|
5
|
+
> 很多"看起来显然"的假设在这里都被证伪过。
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 1. 一句话背景
|
|
10
|
+
|
|
11
|
+
2026-09-16 前后,用户的 `web_fetch` 全线失败(`web fetch failed: TypeError: fetch failed`),
|
|
12
|
+
我们在调测中发现 DSH 的代理机制存在一个真实缺口:**环境里的代理设置对 web_fetch
|
|
13
|
+
进程完全不生效**。修好之后,用户决定基于这次积累的经验自研代理插件(本插件),
|
|
14
|
+
替换体验别扭的 `dsh-plugin-proxy`。
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## 2. 代理链路真实架构(务必先看懂这张图)
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
┌─ web_fetch / web_search 工具 ─────────────────────────────────────┐
|
|
22
|
+
│ dsh-tool-web (line ~805) │
|
|
23
|
+
│ await ctx.web.fetch({ url }, exec.signal) │
|
|
24
|
+
│ └─ dsh-web-fetch-http 的 HttpFetchProvider (id "http") │
|
|
25
|
+
│ └─ requestOnce (line ~500-504) │
|
|
26
|
+
│ const route = proxyRouteFor(url) │
|
|
27
|
+
│ if (route.proxied && !isNonPublicIpLiteral(...)) │
|
|
28
|
+
│ → publicHttpNetwork.requestVia(route.dispatcher…) │
|
|
29
|
+
│ else → requestPinned 直连(被墙 = EACCES) │
|
|
30
|
+
└───────────────────────────────────────────────────────────────────┘
|
|
31
|
+
|
|
32
|
+
┌─ 模型请求 / 其他 spawn 的进程 ─────────────────────────────────────┐
|
|
33
|
+
│ undici 全局 dispatcher (setGlobalDispatcher) │
|
|
34
|
+
│ + process.env 的 HTTP_PROXY / HTTPS_PROXY / ALL_PROXY / NO_PROXY│
|
|
35
|
+
└───────────────────────────────────────────────────────────────────┘
|
|
36
|
+
|
|
37
|
+
关键:proxyRouteFor() 读的是 @deepseek-ai/dsh-http-proxy 模块级状态
|
|
38
|
+
(active / installed),这个状态只有 installProxyFromEnvironment() 会写入。
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### 进程架构(重启后实测)
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
DSH main (28404)
|
|
45
|
+
└─ NodeService utility (21872) —— 监听 :43120 web 服务
|
|
46
|
+
├─ 我的 pwsh 祖父链:15416 → 21872 → 28404
|
|
47
|
+
└─ web_fetch 工具运行在这条链上(**从不跑 profile-boot 的
|
|
48
|
+
installProxyFromEnvironment** —— 这就是 web_fetch 恒 DIRECT 的根因)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
- `~/.dsh/.env`(home 层)只影响跑 **profile-boot** 的进程(host/main)。
|
|
52
|
+
- web_fetch 在 **NodeService 链**,且该链没有调用任何 `installProxy…`。
|
|
53
|
+
- 结论:**只要 web_fetch 进程内 `proxyRouteFor` 判定为 DIRECT,它就连不上
|
|
54
|
+
外网被墙的 github 等目标;与你的系统代理、shell 代理、DSH 设置全无关。**
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## 3. 根因与已证伪的假设(血泪史)
|
|
59
|
+
|
|
60
|
+
| 假设 | 结论 | 证据 |
|
|
61
|
+
|---|---|---|
|
|
62
|
+
| `.env` 设置 HTTP_PROXY 能修 web_fetch | ❌ 证伪 | `.env` 只被 profile-boot 读取;删除后 loadLayeredEnv 仍解出代理,但 **web_fetch 依旧 DIRECT** |
|
|
63
|
+
| `settings.yaml` 的 proxy 段是给 dsh-http-proxy 用的 | ❌ 被用户纠正两次 | 真消费者是**已安装插件 dsh-plugin-proxy**(settingsNamespace('proxy')) |
|
|
64
|
+
| 改全局 dispatcher 就够 | ❌ 不覆盖 web_fetch | web_fetch 不读全局 dispatcher,只读 proxyRouteFor |
|
|
65
|
+
| 两份 undici 混用没问题 | ❌ UND_ERR_INVALID_ARG | Node fetch + DSH dispatcher 是两个 undici 实例,构造参数不兼容;必须同源 |
|
|
66
|
+
| proxyhk 是普通代理 | ❌ NTLM | SWG 代理需要 NAT 认证窗口;undici ProxyAgent 不协商 NTLM,靠 keepalive 复用 |
|
|
67
|
+
|
|
68
|
+
**真正修复(已验证 HTTP 200)**:给 dsh-plugin-proxy 打补丁,在 sync() 内
|
|
69
|
+
`applyEffective` 之后追加调用:
|
|
70
|
+
|
|
71
|
+
```js
|
|
72
|
+
httpProxyPolicy = await installProxyFromEnvironment(envLike, (m) => ctx.logger.warn('proxy: dsh-http-proxy: %s', m))
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
重启后:github → PROXIED (proxyhk),deepseek → DIRECT,web_fetch 200。✅
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## 4. installProxyFromEnvironment 的调用契约(新插件必须遵守)
|
|
80
|
+
|
|
81
|
+
```js
|
|
82
|
+
// dsh-http-proxy/lib/index.js 真实逻辑
|
|
83
|
+
// installProxyFromEnvironment(env, report) = resolveProxyPolicy(env) + installGlobalProxy(policy)
|
|
84
|
+
// resolveProxyPolicy 读 all_proxy/http_proxy/https_proxy/no_proxy(lowercase 优先,uppercase 兜底)
|
|
85
|
+
// policy.source 只有 "env" / "none" 两种
|
|
86
|
+
|
|
87
|
+
// env 必须带 .get(),不是裸 process.env(否则 "env.get is not a function"):
|
|
88
|
+
const envLike = {
|
|
89
|
+
get: (name) => {
|
|
90
|
+
const value = process.env[name]
|
|
91
|
+
return value === undefined ? undefined : { value }
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
**幂等要求**:再次 install 前必须先 `await 旧 policy?.()`(卸载),否则模块级
|
|
97
|
+
`active/installed` 状态残留导致判定错乱。
|
|
98
|
+
|
|
99
|
+
**loadLayeredEnv 行为**(dsh-app-boot lib/index.js ~1091-1116):
|
|
100
|
+
`inherited={...process.env}` + 项目层 `cwd/.env` + home 层 `~/.dsh/.env`
|
|
101
|
+
(`home !== resolve(cwd)` 时读),只在 `process.env[name] === void 0` 时写入;
|
|
102
|
+
返回带 `.get()` 的快照。`HOME_LAYER_PROXY_NAMES` 允许 home .env 设代理变量。
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## 5. 为什么新插件放独立目录(避开 node_modules)
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
C:\Users\...\profiles\[web|test]\node_modules\dsh-plugin-proxy\package.json
|
|
110
|
+
LinkType = HardLink → C:\Users\...\pnpm\store\v11\files\...(pnpm 全局 store)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
- profile 用 pnpm(`nodeLinker: hoisted`)安装插件,**node_modules 里是 store 的硬链接**。
|
|
114
|
+
- 直接改 node_modules 文件 = 改 store 里的原文件;**下次 `pnpm install` 会洗回 store 原样**,
|
|
115
|
+
我们的补丁全丢(这也是用户抱怨"修改别扭"的根源)。
|
|
116
|
+
- 原 `dsh-plugin-proxy` 版本升级同样会覆盖我们的补丁(我们保留了 `.bak` 也是无奈之举)。
|
|
117
|
+
|
|
118
|
+
> **⚠️ 2026-09-17 已修订:下面的 file:// 部署决策作废。**用户明确要求
|
|
119
|
+
> 「不要 file:// host 插件,要能安装发布的插件」→ 改为标准 npm 插件包 +
|
|
120
|
+
> `dsh plugin --profile add` 部署,机制见本文档 §12。本节保留为历史证据
|
|
121
|
+
> (node_modules 硬链接被洗的动机仍然成立,但解法是"自有包 + file: 软链装
|
|
122
|
+
> 入 profile",而不是 file:// 加载)。
|
|
123
|
+
|
|
124
|
+
**原决策(已作废):`dsh-proxy-pro` 独立部署在 `~/.dsh/plugins/dsh-proxy-pro/`,
|
|
125
|
+
通过 `cordis.patch.yml` 以 file:// 形式加载,完全不进 node_modules。**
|
|
126
|
+
|
|
127
|
+
```yaml
|
|
128
|
+
# profiles/<profile>/cordis.patch.yml 增补(已作废)
|
|
129
|
+
- insert:
|
|
130
|
+
- id: dsh-proxy-pro
|
|
131
|
+
name: file:///C:/Users/w00958282/.dsh/plugins/dsh-proxy-pro/lib/index.js?raw # 见 loader 约定
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### file:// 插件能否带 client 端?——能(已验证源码)
|
|
135
|
+
|
|
136
|
+
`dsh-client-modules/lib/index.js` 的 `locatePkgJson`(L679-708):
|
|
137
|
+
- `loaderName.startsWith("file:")` 或绝对路径 → pathLike 分支
|
|
138
|
+
- 通过 `nearestPackage(moduleUrl)` 向上找最近的 package.json
|
|
139
|
+
- **只要该目录的 package.json 声明 `dsh.client`(platform: "web")+ `exports["./client"]`,
|
|
140
|
+
client 端就会被 client-modules 扫描、打包、注入**
|
|
141
|
+
|
|
142
|
+
所以新插件形态:
|
|
143
|
+
```
|
|
144
|
+
~/.dsh/plugins/dsh-proxy-pro/
|
|
145
|
+
package.json # name/dsh.client/platform:web/exports["./client"]
|
|
146
|
+
cordis.patch.yml # dsh.bundle.patch 指向的补丁
|
|
147
|
+
lib/index.js # host 半
|
|
148
|
+
lib/client.js # 浏览器半(__ModuleLoader__.load 格式)
|
|
149
|
+
lib/proxy-core.js # 纯逻辑(无依赖,可单测)
|
|
150
|
+
docs/LESSONS.md # 本文档
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
---
|
|
154
|
+
|
|
155
|
+
## 6. Client 插件写法(浏览器端铁律)
|
|
156
|
+
|
|
157
|
+
```js
|
|
158
|
+
window.__ModuleLoader__.load({
|
|
159
|
+
id: "dsh-proxy-pro",
|
|
160
|
+
factory: (require) => {
|
|
161
|
+
// 注意:inject 列的是【服务名】(如 "slots","settingsScope","sessions"),
|
|
162
|
+
// 不是包名!写包名会阻塞 web boot(踩坑教训,见插件 docs/LESSONS.md)
|
|
163
|
+
const inject = ["slots", "settingsScope"];
|
|
164
|
+
function apply(ctx) { ... }
|
|
165
|
+
return { apply, inject };
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
技能库里的 `dsh-skill-manager@4.3.3`(test profile 已装)是**现代写法参照模板**:
|
|
171
|
+
|
|
172
|
+
```js
|
|
173
|
+
ctx.slots.inject("settings.section", () => ctx.slots.register({
|
|
174
|
+
name: "settings.section", id: "skill-manager", order: 30, label: "技能管理",
|
|
175
|
+
}, Panel));
|
|
176
|
+
ctx.slots.inject("conversation.view", () => ctx.slots.register({
|
|
177
|
+
name: "conversation.view", id: "skill-manager", order: 20, label: "技能",
|
|
178
|
+
}, View));
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### 对话头部按钮(用户明确要的"对话页面上可点击开关的代理按钮")
|
|
182
|
+
|
|
183
|
+
```js
|
|
184
|
+
// dsh-client-ui-conversation/lib/client.js L16683-16715
|
|
185
|
+
// registerConversationHeader 声明了 4 个子 slot:
|
|
186
|
+
// conversation.session.header.lineage (single, session)
|
|
187
|
+
// conversation.session.header.actions (list, session) ← 左侧
|
|
188
|
+
// conversation.session.header.utilities (list, session) ← 右侧操作区
|
|
189
|
+
// conversation.session.header.corner (single, session)
|
|
190
|
+
// 用法:ctx.slots.inject("conversation.session.header", () => ctx.slots.register({
|
|
191
|
+
// name: "conversation.session.header.utilities", id: "...", order: 10, label: "代理",
|
|
192
|
+
// }, Button));
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
拖动按钮:reflect/slots 是布局注入点,**原生不支持拖拽浮层**(用户问过"如果能拖动更好")。
|
|
196
|
+
采用"状态胶囊按钮 + 点击弹层"形态代替,信息密度更高且不违背平台约束。
|
|
197
|
+
|
|
198
|
+
### 原 dsh-plugin-proxy client 的写法(旧式,可见性差)
|
|
199
|
+
|
|
200
|
+
- `sidebar.footer.action`(侧边栏底部小开关)
|
|
201
|
+
- `settings.plugin.item`(Settings → Plugins 页卡片)
|
|
202
|
+
- 只读 settingsScope,**看不到运行时状态**(active/url/reason 纯推算)
|
|
203
|
+
- 新插件改为:settings 独立页(`settings.section`,现代式)+ 对话头部按钮 + 状态 API 轮询
|
|
204
|
+
|
|
205
|
+
---
|
|
206
|
+
|
|
207
|
+
## 7. Host 状态如何给到 Client(不重启也能实时)
|
|
208
|
+
|
|
209
|
+
`dsh-skill-manager` 的做法(样板):
|
|
210
|
+
|
|
211
|
+
```js
|
|
212
|
+
const routePath = '/skill-manager/api'
|
|
213
|
+
const unregisterRoute = ctx.webServer.register({
|
|
214
|
+
kind: 'prefix',
|
|
215
|
+
path: routePath,
|
|
216
|
+
handler: async (req, res) => { /* 解析 method+pathname 分发 */ }
|
|
217
|
+
})
|
|
218
|
+
// client 端直接 fetch(API + "/xxx") 同源调用
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
**必须带 browser-trust fence**(防跨站请求,skill-manager L131-162 完整实现):
|
|
222
|
+
- `Host` 头解析后必须 loopback(localhost / [::1] / 127/8)
|
|
223
|
+
- `sec-fetch-site: cross-site` 拒绝
|
|
224
|
+
- `origin` 若存在必须与 host 同源
|
|
225
|
+
- 拒绝时 403 `forbidden`(`denyIfUntrusted(req, res)` 供 handler 首行调用)
|
|
226
|
+
|
|
227
|
+
新插件 API 设计:
|
|
228
|
+
```
|
|
229
|
+
GET /dsh-proxy-pro/api/status → { ok, status: summarize(snapshot) } # UI 轮询
|
|
230
|
+
GET /dsh-proxy-pro/api/route?url=… → { ok, route, bypassed, probe, hint } # 诊断
|
|
231
|
+
POST /dsh-proxy-pro/api/toggle {"enabled":bool} → 更新 settings + 立即 sync
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
236
|
+
## 8. NTLM / keepalive(用户明确要求"有打的应用范围才做")
|
|
237
|
+
|
|
238
|
+
**事实**:
|
|
239
|
+
- proxyhk.huawei.com:8080 是 NTLM 认证的 SWG 代理。
|
|
240
|
+
- curl 用 `--proxy-ntlm --proxy-user ":"` 自带 NTLM 协商;**undici ProxyAgent 不协商 NTLM**。
|
|
241
|
+
- 因此依赖"认证后 keepalive 连接复用窗口":`~/.dsh/proxy-keepalive` 在跑,日志 09:24:31 "ok"。
|
|
242
|
+
|
|
243
|
+
**判断(对用户问题的答复——已确认)**:
|
|
244
|
+
- **不常见**:仅企业 SWG(netentsec/华为这类)需要;家里 Clash/v2rayN 本地代理无 NTLM。
|
|
245
|
+
- **不做进核心**:keepalive 做成**可选诊断**——`proxy_test` 探测到 407/authentication
|
|
246
|
+
required 时,才提示"可能需要 curl 先建认证窗口"(`keepaliveHint()`,贴近响应文本检测)。
|
|
247
|
+
- 多网站 keepalive **无意义**:代理是单点,keepalive 只对代理那一台建,与目标网站无关。
|
|
248
|
+
|
|
249
|
+
---
|
|
250
|
+
|
|
251
|
+
## 9. 无缝过渡方案(用户问"避免现在的路由断掉")
|
|
252
|
+
|
|
253
|
+
1. 新插件读**同一个 `proxy` settings namespace** → settings.yaml 的 proxy 段直接接管,
|
|
254
|
+
用户已有的 enabled/mode/customUrl/noProxy 配置零迁移。
|
|
255
|
+
2. 行为对齐:新插件 = 原来 dsh-plugin-proxy(env + 全局 dispatcher)**+ 我们验证过的
|
|
256
|
+
installProxyFromEnvironment 修复**,路由判定结果与当前打补丁后的状态完全一致
|
|
257
|
+
(github→PROXIED,deepseek→DIRECT)。
|
|
258
|
+
3. 切换顺序:装好新插件 → **先禁原插件**(cordis.patch.yml `- id: proxy, disabled: true`
|
|
259
|
+
或删除该行)→ 重启 → `proxy_status` 确认 → `web_test`/web_fetch 200 确认。
|
|
260
|
+
任何时候出问题,恢复原插件行 + 保留的 `.bak` 即回滚,配置从未动过。
|
|
261
|
+
4. 同一时刻只允许一个 dispatcher 主人(新老插件都写全局 dispatcher,同时启用会互踩)。
|
|
262
|
+
|
|
263
|
+
---
|
|
264
|
+
|
|
265
|
+
## 10. 已知事实速查(调测用)
|
|
266
|
+
|
|
267
|
+
| 项 | 值 |
|
|
268
|
+
|---|---|
|
|
269
|
+
| DSH | 2.0.10 / Electron 43.3.0 |
|
|
270
|
+
| Web GUI | http://127.0.0.1:43120(NodeService 21872 监听) |
|
|
271
|
+
| 系统代理 | proxyhk.huawei.com:8080(NTLM SWG,Proxy ON) |
|
|
272
|
+
| NO_PROXY | 大列表:`*.huawei.com` 等内网全量 + localhost/127.0.0.1/::1 |
|
|
273
|
+
| profile | web / test(两份,插件需都装) |
|
|
274
|
+
| settings.yaml | proxy: {enabled: true, mode: system, customUrl: http://127.0.0.1:7890} |
|
|
275
|
+
| 验证基线 | github → PROXIED;deepseek.com → DIRECT;web_fetch PR#3574 → 200 |
|
|
276
|
+
| loadLayeredEnv | process.env + cwd/.env + ~/.dsh/.env(仅 undefined 时写入) |
|
|
277
|
+
|
|
278
|
+
## 11. 关键文件位置
|
|
279
|
+
|
|
280
|
+
- 打包源码:`C:\Users\w00958282\AppData\Local\Programs\DSH Desktop\resources\app\node_modules\@deepseek-ai\...`
|
|
281
|
+
(dsh-web-fetch-http / dsh-http-proxy / dsh-app-boot / dsh-web / dsh-tool-web /
|
|
282
|
+
dsh-client-ui-conversation / dsh-client-ui-chat / dsh-client-modules / dsh-settings)
|
|
283
|
+
- 原插件(已打补丁 + .bak):`~/.dsh/profiles\{web,test}\node_modules\dsh-plugin-proxy\lib\`
|
|
284
|
+
- 参照模板:`~/.dsh/profiles\test\node_modules\dsh-skill-manager\lib\`(index.js + client.js)
|
|
285
|
+
- 本插件:`~/.dsh/plugins/dsh-proxy-pro/`(git 仓库,发布形态,见 §12)
|
|
286
|
+
- 环境事实:`~/.dsh/.env`(已删,纯冗余证据);`settings.yaml`、`cordis.patch.yml`、`cordis.yml`
|
|
287
|
+
|
|
288
|
+
---
|
|
289
|
+
|
|
290
|
+
## 12. 发布形态(2026-09-17 修订,取代 §5 的 file:// 方案)
|
|
291
|
+
|
|
292
|
+
**决策:dsh-proxy-pro 是标准 npm 插件包**,参照已发布的 `dsh-skill-manager@4.3.3`
|
|
293
|
+
(GitHub yanglaofish/dsh-skill-manager)。`~/.dsh/plugins/dsh-proxy-pro/` 是它的
|
|
294
|
+
开发源目录(git init,commit 04eb709),发布后从 npm / GitHub 安装。
|
|
295
|
+
|
|
296
|
+
### 12.1 机制(全部经本机源码验证)
|
|
297
|
+
|
|
298
|
+
- profile 的 `package.json`:
|
|
299
|
+
- `dependencies` 装插件:发布版 = registry 包;本地开发 =
|
|
300
|
+
`file:../../plugins/dsh-proxy-pro`(pnpm 软链,改源码免重装、两 profile 共用一份)。
|
|
301
|
+
- `dsh.profile.bundles` 追加包名 → dsh-app-boot `loadProfileDirectory`
|
|
302
|
+
(app lib/index.js L866-877)逐个读每个 bundle 包的 `dsh.bundle.patch`:
|
|
303
|
+
**声明了 dsh.bundle 但 patch 文件缺失 = 整个 profile boot 抛错**(不是跳过)。
|
|
304
|
+
- 插件包自己的 `cordis.patch.yml`(dsh.bundle.patch 指向)insert 一行
|
|
305
|
+
`- id: dsh-proxy-pro, name: '@yanglaofish/dsh-proxy-pro', config: {enabled:false, mode:system, ...}`,
|
|
306
|
+
格式照抄 dsh-plugin-proxy 自己的 patch。
|
|
307
|
+
- host 解析:一行 `name: '@yanglaofish/dsh-proxy-pro'`(包名)→ include import 走 profile
|
|
308
|
+
resolver(app lib/module-resolution-*.js 的 registerHooks):
|
|
309
|
+
- profile 边界/图内模块的裸包名先按 profile node_modules 解析,
|
|
310
|
+
没有则兜底 app 安装副本(`@deepseek-ai/*` 全在 app;profile 里实际只有
|
|
311
|
+
cosmokit/dsh-storage-domain/schemastery——已实测)。
|
|
312
|
+
- 因此 peerDependencies 声明版本即可,**profile 无需安装 `@deepseek-ai/*`**。
|
|
313
|
+
- client 发现同 skill-manager:package.json `dsh.client.platform: "web"` +
|
|
314
|
+
`exports["./client"]`;exports 应含 `"./cordis.patch.yml"`。
|
|
315
|
+
- 安装命令:`dsh plugin --profile web add <包或 file: 或 github:>`;卸载
|
|
316
|
+
`dsh plugin --profile web remove @yanglaofish/dsh-proxy-pro`。
|
|
317
|
+
|
|
318
|
+
### 12.2 传输架构(host 重构后,v0.1 落地)
|
|
319
|
+
|
|
320
|
+
- **删掉 undici 依赖 / EnvHttpProxyAgent / savedEnv / applyEffective**——
|
|
321
|
+
插件对 undici 零依赖,错版雷区(§3:7.29.0 vs 8.10.0)在源头上不存在。
|
|
322
|
+
- 唯一通道:`installProxyFromEnvironment(makeEnvLike(effective), log)` 一次完成
|
|
323
|
+
env(applyPolicyEnv 写 8 个名字大小写双写 + loopback 合并)+ per-origin
|
|
324
|
+
dispatcher + 模块级策略(proxyRouteFor 读它 → web_fetch 通道,§2 图)。
|
|
325
|
+
- OFF 分支:none-branch 恢复首次安装前的 env + 装直连 Agent(A9 干净卸载即此)。
|
|
326
|
+
- **haveApplied 守卫**:从未启用过且当前 OFF → 只记快照不碰传输层;默认
|
|
327
|
+
enabled:false 启动时与旧 dsh-plugin-proxy 并存互不抢 dispatcher(NFR-4)。
|
|
328
|
+
- 动机:旧设计先 applyEffective 再 installProxyFromEnvironment,后者永远最后赢,
|
|
329
|
+
EnvHttpProxyAgent 白建白关;且窗口期两个 undici 实例的 dispatcher 互操作正是
|
|
330
|
+
§3 UND_ERR_INVALID_ARG 的触发面。
|
|
331
|
+
|
|
332
|
+
### 12.3 过渡(NFR-3)
|
|
333
|
+
|
|
334
|
+
新行默认 `enabled: false` 共存启动 → 验证通过后再接管:profile cordis.patch.yml
|
|
335
|
+
加 `- id: proxy, disabled: true`(bundle 层 insert 的行可按 id patch)→ 重启。
|
|
336
|
+
回滚 = 删该行或 `dsh plugin remove`;settings.yaml 全程不动。
|
|
337
|
+
|
|
338
|
+
---
|
|
339
|
+
|
|
340
|
+
## 13. 自查记录(2026-09-17,code-review-skill 一轮)
|
|
341
|
+
|
|
342
|
+
### 13.1 dsh-tools schema 编译器铁律(**会让整树 boot 失败**)
|
|
343
|
+
|
|
344
|
+
- 每个 `type: 'object'` 必须显式 `additionalProperties: true|false`,否则
|
|
345
|
+
`defineTool` 抛 `JsonSchemaError: ... additionalProperties must be explicitly
|
|
346
|
+
true or false` → 该行 apply 抛错 → plugin tree 加载失败、GUI 起不来。
|
|
347
|
+
- 只信赖 schema 的**静态审计**不够:这次错误只在**运行期 apply** 暴露,
|
|
348
|
+
`--dump-config` 预检测不到(它只 compose 行,不执行插件)。
|
|
349
|
+
- 教训:改工具 schema 后,用「遍历全部 object 断言 additionalProperties
|
|
350
|
+
存在」的静态脚本或直接引用编译器,而不是靠肉眼。
|
|
351
|
+
|
|
352
|
+
### 13.2 settings.yaml 共享命名空间的实测事实
|
|
353
|
+
|
|
354
|
+
- 本机 `~/.dsh/settings.yaml` `proxy:` = `{enabled: true, mode: system}`——
|
|
355
|
+
与旧 dsh-plugin-proxy 共用同一 namespace。
|
|
356
|
+
- 后果:**重启后本插件会立即接管**(行序在后 = install 最后落地),
|
|
357
|
+
不是展示层默认的 OFF。这是预期行为,验收按 ON 流程走即可。
|
|
358
|
+
- 双插件共存期:任何 UI 改设置都会触发两边 sync,但两边读同一份配置、
|
|
359
|
+
计算结果一致,且我们是最后写入者 → 终态一致;旧行是天然兜底
|
|
360
|
+
(本插件 apply 失败时代理仍由旧插件管理)。
|
|
361
|
+
|
|
362
|
+
### 13.3 已修的三类问题(commit 5918310)
|
|
363
|
+
|
|
364
|
+
1. **teardown 与在途 sync 竞态**:卸载若赶在 sync 完成前执行,晚到的
|
|
365
|
+
`installProxyFromEnvironment` 会把 policy 泄漏到卸载之后 → 卸载链到
|
|
366
|
+
`syncing.finally` 之后。
|
|
367
|
+
2. **probeTarget 无效 URL 抛 throw**:`new URL('not a url')` 已捕获 →
|
|
368
|
+
结构化 `{route:'invalid', probe:{ok:false, kind:'invalid', ...}}`,
|
|
369
|
+
工具与 /api/route 都友好显示而非 500。
|
|
370
|
+
3. **`void requestSync()` 未捕获拒绝**:Node 15+ 未捕获的 promise 拒绝会
|
|
371
|
+
崩主进程 → setSource/onChange/poll 三处调用点都补 `.catch(() => {})`。
|
|
372
|
+
|
|
373
|
+
### 13.4 遗留 backlog(非阻塞,已文档化)
|
|
374
|
+
|
|
375
|
+
- index.js 业务逻辑(sync/guard/teardown/API/fence)无单测——依赖运行时验收;
|
|
376
|
+
补测需 mock dsh-http-proxy/dsh-settings,成本中等,留待发布后。
|
|
377
|
+
- client 轮询无 no-op 去重、无超时、慢响应可能乱序显示旧状态——组件极小,
|
|
378
|
+
现实风险低,暂不改。
|
|
379
|
+
|
|
380
|
+
---
|
|
381
|
+
|
|
382
|
+
## 14. 桌面 recovery 会静默重写 profile 的 package.json(2026-09-16 事故)
|
|
383
|
+
|
|
384
|
+
### 14.1 现象与根因
|
|
385
|
+
|
|
386
|
+
- 用户重启后"啥也没看到":头部无胶囊、设置页无「代理管理」。
|
|
387
|
+
- 查 `%APPDATA%\DSH Desktop\logs\dsh-<date>.log`:最后一次 run
|
|
388
|
+
(12:56)**无报错**,但更早一次(11:48)记录了
|
|
389
|
+
`unsupported JSON schema: schema.properties.probe.additionalProperties`
|
|
390
|
+
——这就是 8ce5e21 修的那个 schema bug(跑在修复前)。
|
|
391
|
+
- 决定性证据:`profiles/web/package.json` LastWriteTime = **11:50:28**,
|
|
392
|
+
恰好在 desktop 的 `recovery plugin uninstall failed`(11:50:09 error.log)
|
|
393
|
+
窗口内;对读 `profiles/test/package.json` 完好(11:35 未动)。
|
|
394
|
+
- **根因:桌面版启动时对解析失败的插件执行 recovery 卸载**
|
|
395
|
+
(`dsh plugin --profile web remove @yanglaofish/dsh-skill-manager`),
|
|
396
|
+
该操作重写了 web profile 的 package.json,**把我们手工加的两处
|
|
397
|
+
dsh-proxy-pro 条目连带清掉**,而 node_modules 的 Junction 软链仍在。
|
|
398
|
+
- 结论:**profile 文件不是只写一次的**——desktop 会在特定条件下用
|
|
399
|
+
`dsh plugin` 命令重写它。验收前必须复查 package.json 而非只信一次
|
|
400
|
+
`--dump-config` 的记忆。
|
|
401
|
+
|
|
402
|
+
### 14.2 修复与预防
|
|
403
|
+
|
|
404
|
+
- 修复:对照 test 的形态把 `dependencies["dsh-proxy-pro"] =
|
|
405
|
+
link:...` 与 `dsh.profile.bundles` 里的 `"dsh-proxy-pro"` 加回
|
|
406
|
+
web/package.json;`dsh --profile web --dump-config` 预检两行都在、exit=0。
|
|
407
|
+
- 预防:每次用户重启前**先读 profile 的 package.json** 确认条目在(read
|
|
408
|
+
工具,别依赖记忆);recovery 只会在树加载失败时触发——schema 已修 +
|
|
409
|
+
行在 = 干净加载 = 不会再被重写。
|
|
410
|
+
|
|
411
|
+
### 14.3 可复用排查路径("重启后啥也没看到"标准动作)
|
|
412
|
+
|
|
413
|
+
1. 读 `%APPDATA%\DSH Desktop\logs\dsh-<date>.log` 最后几段——成功 run
|
|
414
|
+
只有 `--- run ---` 头,失败 run 有 `Error:` 行 + 完整栈(栈里
|
|
415
|
+
`#dsh-proxy-pro` / `#include` 指向 profiles/web,确认 desktop 用的
|
|
416
|
+
就是 web profile)。
|
|
417
|
+
2. 对照同一时刻 `dsh-<date>.error.log` 与 profile package.json 的
|
|
418
|
+
LastWriteTime——两者撞窗 = recovery 重写过。
|
|
419
|
+
3. `dsh --profile web --dump-config` 复检行在树里。
|
|
420
|
+
4. 修好 → 重启 → 按 ACCEPTANCE.md 验收。
|
|
421
|
+
|
|
422
|
+
---
|
|
423
|
+
|
|
424
|
+
## 15. dsh-tools 工具 schema DSL 完整词表 + 本地编译验证(2026-09-16)
|
|
425
|
+
|
|
426
|
+
### 15.1 事故:schema 类错误烧掉三次重启
|
|
427
|
+
|
|
428
|
+
三次 boot 失败,同一个根因家族,全都是 `defineTool` 在 apply 期抛
|
|
429
|
+
`JsonSchemaError` → 整个 plugin tree 加载失败:
|
|
430
|
+
|
|
431
|
+
| 报错 | 违规写法 | 正确写法 |
|
|
432
|
+
|---|---|---|
|
|
433
|
+
| `.additionalProperties must be explicitly true or false` | object 省略该键 | 每个 `type:'object'` 显式 `additionalProperties: false` |
|
|
434
|
+
| `.required must be true when present` | `{ type:'number', required: false }` | 可选属性**省略 `required`**,不能写 `false` |
|
|
435
|
+
|
|
436
|
+
### 15.2 DSL 词表(编译器逐字确认,dsh-tools lib/schema)
|
|
437
|
+
|
|
438
|
+
每个节点允许的键 = 通用注解 `description/title/default/examples`
|
|
439
|
+
+(仅**属性节点**且仅当 `required === true` 时)`required`
|
|
440
|
+
+ 按类型:
|
|
441
|
+
|
|
442
|
+
| 类型 | 额外允许键 | 硬性要求 |
|
|
443
|
+
|---|---|---|
|
|
444
|
+
| `object` | `properties`, `additionalProperties` | `additionalProperties` 必须是显式 boolean |
|
|
445
|
+
| `array` | `items` | — |
|
|
446
|
+
| `string`/`number`/`integer`/`boolean`/`null` | `enum`, `const` | — |
|
|
447
|
+
| `json` | (仅注解) | — |
|
|
448
|
+
| 替代式 `oneOf` | `oneOf`(≥2 分支) | 不可与 `type` 同时出现 |
|
|
449
|
+
|
|
450
|
+
- **任何 DSL 之外的键都是错误**(`format`/`pattern`/`minimum` 等一律不支持)。
|
|
451
|
+
- `required` 是**属性级标记**而非属性名数组;对象根的 required 列表由编译器收集。
|
|
452
|
+
|
|
453
|
+
### 15.3 解法:把 apply 期错误提前到本地(不重启)
|
|
454
|
+
|
|
455
|
+
`dsh-tools` **导出**了 `defineTool` 内部使用的两个编译器:
|
|
456
|
+
|
|
457
|
+
```js
|
|
458
|
+
parameterSchemaSpecToJsonSchema(options.parameters) // defineTool L846
|
|
459
|
+
valueSchemaSpecToJsonSchema(options.output.schema) // defineTool L847
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
因此工具 schema 抽到 `lib/tool-schemas.js`(纯数据、零依赖),
|
|
463
|
+
`test/tool-schema.test.mjs` 做两层校验:
|
|
464
|
+
|
|
465
|
+
1. **静态镜像**:完整复刻上面的词表(含 `required` 只许 true、object 必须
|
|
466
|
+
显式 additionalProperties、未知键报错),无 app 也能跑;
|
|
467
|
+
2. **真实编译器**:定位 `@deepseek-ai/dsh-tools` 并调用那两个导出——
|
|
468
|
+
路径来源依次为 `DSH_TOOLS_MODULE` → `DSH_APP_ROOT` →
|
|
469
|
+
`%LOCALAPPDATA%\Programs\DSH Desktop\resources\app`;找不到则 skip
|
|
470
|
+
并打 diagnostic(可移植,不硬编码机器路径)。
|
|
471
|
+
|
|
472
|
+
### 15.4 规矩
|
|
473
|
+
|
|
474
|
+
- **改任何 tool schema(含新增工具)后,必须先跑 `npm test`**——
|
|
475
|
+
`test/tool-schema.test.mjs` 必须绿,否则不要重启 DSH。
|
|
476
|
+
- `--dump-config` 检测不到这类错误(它只 compose 行,不执行插件 apply),
|
|
477
|
+
别用它当"能启动"的证据。
|
|
478
|
+
|
|
479
|
+
---
|
|
480
|
+
|
|
481
|
+
## 16. Client 半的槽位规矩(2026-09-16 静态核对发现)
|
|
482
|
+
|
|
483
|
+
### 16.1 inject 的槽名必须等于 register 的槽名
|
|
484
|
+
|
|
485
|
+
- 错误写法(本插件原样,已修):`slots.inject("conversation.session.header", …)`
|
|
486
|
+
里 `register({ name: "conversation.session.header.utilities" })` ——
|
|
487
|
+
**注入父槽、注册子槽**,注册不会生效。
|
|
488
|
+
- 正确写法(app 官方先例逐字):
|
|
489
|
+
`dsh-client-ui-open-in-app/lib/client.js:406`、
|
|
490
|
+
`dsh-session-log-export/lib/client.js:274` 都是
|
|
491
|
+
`ctx.slots.inject("conversation.session.header.utilities", () => ctx.slots.register({
|
|
492
|
+
name: "conversation.session.header.utilities", … }))`。
|
|
493
|
+
- 结论:**inject 的键就是 register 的 name**,子槽也要写全名。
|
|
494
|
+
|
|
495
|
+
### 16.2 可用槽位的事实来源(别再猜)
|
|
496
|
+
|
|
497
|
+
- `conversation.session.header` 家族由 `@deepseek-ai/dsh-client-ui-conversation`
|
|
498
|
+
定义(client.js L16684-16699:`.lineage` / `.actions` / `.utilities` / `.corner`),
|
|
499
|
+
渲染点在 L14951 / L15072-15082。
|
|
500
|
+
- `settings.section` 由 `dsh-client-ui-settings-general`、`settings-models`、
|
|
501
|
+
`settings-plugins`、`ui-agent-preset` 等注册;`dsh-cordis-client-runner`
|
|
502
|
+
L3872 持有槽树 `key: "settings.section"`。
|
|
503
|
+
- 核对方法:grep `@deepseek-ai/*/lib/client.js` 找 `renderSlot("槽名"` 与
|
|
504
|
+
`register({ name: "槽名"` 的**实际先例**,照抄其形式(含 `slots.inject` 写法)。
|
|
505
|
+
- 对比:dsh-plugin-proxy 挂在 `sidebar.footer.action` + `settings.plugin.item`;
|
|
506
|
+
本插件按 FR-2 选对话头部工具区(`…header.utilities`),与 open-in-app 同款。
|
|
507
|
+
|
|
508
|
+
### 16.3 两处 inject 不要混
|
|
509
|
+
|
|
510
|
+
- `package.json` 的 `dsh.client.inject` = **client 包名列表**(加载顺序)。
|
|
511
|
+
- `client.js` 导出的 `exports.inject` = **Cordis 服务名**(`slots`、`settingsScope`)。
|
|
512
|
+
- 把包名写进 `exports.inject` 会永久 pending 并阻塞 web boot(§6)。
|
|
513
|
+
- 本插件 client 模块 id 遵循 `<包名>/client`(`"dsh-proxy-pro/client"`),
|
|
514
|
+
与 `"dsh-plugin-proxy/client"` 同规。
|
|
515
|
+
|
|
516
|
+
---
|
|
517
|
+
|
|
518
|
+
## 17. 轮询≠重组:GET 状态绝不能触发全量重装(2026-09-17 桌面卡顿根因)
|
|
519
|
+
|
|
520
|
+
### 17.1 现象
|
|
521
|
+
|
|
522
|
+
- 用户报告:挂上 dsh-proxy-pro 后 desktop 很卡,"切换模型都卡"。
|
|
523
|
+
- 直觉困惑:"插件只是流量走代理转发,怎么会卡 UI?"——**转发本身不卡,
|
|
524
|
+
卡的是把读状态变成了每几秒拆装一次代理通道**。
|
|
525
|
+
|
|
526
|
+
### 17.2 根因链(三层叠加)
|
|
527
|
+
|
|
528
|
+
1. client 两个 `useRuntimeStatus(4000/3000)` 轮询 `/api/status`;
|
|
529
|
+
2. host 的 `/api/status` handler 上来就 `await requestSync()`;
|
|
530
|
+
3. `sync()` 无条件执行:**读注册表(execFile 子进程)→ 卸载旧 policy →
|
|
531
|
+
`installProxyFromEnvironment` 重装全局 dispatcher + 重置 env**。
|
|
532
|
+
|
|
533
|
+
结果:GUI 打开时 ≈ 每 3-4 秒一次全量重建全局代理(undici agent 池反复
|
|
534
|
+
弃建、在途连接被拆、utility process 高频 spawn reg 子进程)。所有走代理的
|
|
535
|
+
请求(含模型/页面)都在被反复打断,desktop 自然拖垮。
|
|
536
|
+
|
|
537
|
+
### 17.3 修复(commit 096e431,两层)
|
|
538
|
+
|
|
539
|
+
1. **API 只读快照**:`/api/status`、`/api/route` 不再 `requestSync()`——
|
|
540
|
+
直接返回内存里的 `summarize(snapshot)`。快照由配置钩子 + systemPollMs
|
|
541
|
+
轮询维持新鲜,读请求零成本。(`/api/toggle` 是写操作,保留 requestSync。)
|
|
542
|
+
2. **sync 幂等化**:sync 开头计算 `effKey = JSON.stringify([active, url, noProxy, reason])`,
|
|
543
|
+
**生效状态未变 → 只刷新快照 + emit,绝不触碰 env/dispatcher**;只有
|
|
544
|
+
effKey 变化(配置变更 / 系统代理事实变化)才真正重装。
|
|
545
|
+
|
|
546
|
+
### 17.4 此后铁律
|
|
547
|
+
|
|
548
|
+
- **读型 API / 读型轮询永远不触发重装**;重装只发生在"生效状态真正变化"
|
|
549
|
+
的路径上(写操作、poll 检测到变化)。
|
|
550
|
+
- `requestSync()` 本身也要幂等:被高频调用时,未变化的 pass 必须 ≈0 成本。
|
|
551
|
+
- 新增任何"定时/轮询"行为时,先问:它会不会触发全量重建?会就拆开。
|
|
552
|
+
|
|
553
|
+
---
|
|
554
|
+
|
|
555
|
+
## 18. 客户端注册 id 必须是包名(2026-09-17,改 scoped 名后暴露)
|
|
556
|
+
|
|
557
|
+
**症状**:包名从 `dsh-proxy-pro` 改为 `@yanglaofish/dsh-proxy-pro` 后,Web 端启动直接失败:
|
|
558
|
+
|
|
559
|
+
```
|
|
560
|
+
failed to import loader entry 18081979 (@yanglaofish/dsh-proxy-pro): client-modules: bundle
|
|
561
|
+
/plugins/??…,@yanglaofish/dsh-proxy-pro/client.js,…&rev=… loaded without registering
|
|
562
|
+
"@yanglaofish/dsh-proxy-pro" via __ModuleLoader__.load
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
**根因**:`lib/client.js` 仍在用旧名注册:`window.__ModuleLoader__.load({ id: "dsh-proxy-pro/client", … })`。
|
|
566
|
+
`@deepseek-ai/dsh-client-modules/lib/client.js` 的 `register()` 存 factories 时用
|
|
567
|
+
`stripClientSuffix(registration.id)`,而 `arrive()` 是按 **graph 行 id(= 包名)** 查找的
|
|
568
|
+
(L230 / L248)。`dsh-proxy-pro` ≠ `@yanglaofish/dsh-proxy-pro` → 查不到 → 抛错。
|
|
569
|
+
|
|
570
|
+
**规矩**:client bundle 注册 id 写**完整包名**(对照 `@yanglaofish/dsh-skill-manager` 的
|
|
571
|
+
`id: "@yanglaofish/dsh-skill-manager"`;它连 `exports.name` 都没有,说明注册 id 才是匹配键)。
|
|
572
|
+
包名改名时,"看起来像运行时标识、其实是匹配键"的地方必须一起切:
|
|
573
|
+
|
|
574
|
+
- `cordis.patch.yml` insert 行的 `name`(= 包名,host 侧 import 用)
|
|
575
|
+
- `package.json` 的 `name`(`dsh.client` 声明的宿主包名)
|
|
576
|
+
- `lib/client.js` 的 `__ModuleLoader__.load({ id })`(= 包名)
|
|
577
|
+
|
|
578
|
+
**保留不动的内部标识**(与包名解耦,改了反而破坏兼容):
|
|
579
|
+
`/dsh-proxy-pro/api` 路由、`ctx.emit('dsh-proxy-pro/status')` 事件名、插件 id、
|
|
580
|
+
`.dsxprx` CSS 前缀、日志前缀、settings 的 `proxy` namespace。
|
|
581
|
+
|
|
582
|
+
---
|
|
583
|
+
|
|
584
|
+
## 19. profile 依赖只能经 dsh CLI 改(手改会被运行中的 Desktop 覆盖)
|
|
585
|
+
|
|
586
|
+
运行中的 DSH Desktop 以自身内存状态为准回写 `profiles/<p>/package.json`:手动 edit 加回
|
|
587
|
+
`@yanglaofish/dsh-proxy-pro` 依赖后,文件会被抹回"无此插件"——pnpm 于是看不到依赖
|
|
588
|
+
(lockfile 里没有条目,`pnpm install` 直接 "Already up to date",`--force` 也不装)。
|
|
589
|
+
|
|
590
|
+
**正确做法**(2026-09-17 实测通过):
|
|
591
|
+
|
|
592
|
+
```sh
|
|
593
|
+
# 快照式(pnpm 把目录内容装进 node_modules,改源码需重装)
|
|
594
|
+
dsh plugin --profile web add file:C:/Users/w00958282/.dsh/plugins/dsh-proxy-pro
|
|
595
|
+
|
|
596
|
+
# 实时式(junction 指向源码,改源码即时生效)——本地开发用这个
|
|
597
|
+
dsh plugin --profile web add link:C:/Users/w00958282/.dsh/plugins/dsh-proxy-pro
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
CLI 会同时写 `dependencies` + `dsh.profile.bundles` 并跑 pnpm 安装;两侧都不会被覆盖。
|
|
601
|
+
验证:`(Get-Item node_modules\@yanglaofish\dsh-proxy-pro).LinkType` 应为 `Junction`,
|
|
602
|
+
`.Target` 指向 `~/.dsh/plugins/dsh-proxy-pro`。
|
|
603
|
+
|
|
604
|
+
---
|
|
605
|
+
|
|
606
|
+
## 20. 写中文文档不要走 pwsh 命令行(编码会坏)
|
|
607
|
+
|
|
608
|
+
用 `pwsh` 的 here-string + `Add-Content` 向 markdown 追加中文时,命令传输过程会把非 ASCII
|
|
609
|
+
字符写成乱码(实测 LESSONS.md 末尾 50 行全成 `浼氬悓鏃跺啓` 一类)。**中文内容一律用
|
|
610
|
+
write / edit 工具写入**(工具通道是 UTF-8 正确的),pwsh 只做纯 ASCII 操作。
|
|
611
|
+
|
|
612
|
+
同一次事故的连带发现:手写文件操作绕过了读观测策略,也容易在"截断/重写"时改变行尾
|
|
613
|
+
(CRLF/LF)导致整文件 diff。恢复手段:`git checkout -- <file>` 回 HEAD 版本重来。
|
|
614
|
+
|
|
615
|
+
---
|
|
616
|
+
|
|
617
|
+
## 21. .git 目录只丢 HEAD/config/index 时可以就地救回
|
|
618
|
+
|
|
619
|
+
现象:`git` 报 `fatal: not a git repository`,但 `.git/` 存在且含 `objects/`、`refs/`。
|
|
620
|
+
(本例 `.git` 只剩这两个子目录,缺 `HEAD`/`config`/`index`。)
|
|
621
|
+
|
|
622
|
+
**救回步骤**(不需要网络):
|
|
623
|
+
|
|
624
|
+
```sh
|
|
625
|
+
mv .git .git.broken
|
|
626
|
+
git init -b master
|
|
627
|
+
cp -r .git.broken/refs/* .git/refs/
|
|
628
|
+
cp -r .git.broken/objects/* .git/objects/
|
|
629
|
+
git reset # 用 HEAD 重建 index
|
|
630
|
+
git log --oneline # 历史完整(refs/heads/master 里的 tip 仍在)
|
|
631
|
+
```
|
|
632
|
+
|
|
633
|
+
前提:`.git.broken/refs/heads/<branch>` 仍指向有效 commit、objects 完整(松散或 pack)。
|
|
634
|
+
若 refs 也没了,就只能等网络恢复后 `git fetch` 远端历史再 `git reset --mixed origin/<branch>`。
|
|
635
|
+
本例 `git fetch` 失败于 `Could not resolve proxy: proxyhk.huawei.com`——git 的 `http.proxy`
|
|
636
|
+
指向公司代理,离开公司网络(或代理关闭)时 GitHub 不可达;此时**不要** push/force push。
|
|
637
|
+
|
|
638
|
+
---
|
|
639
|
+
|
|
640
|
+
## 22. 强制通道探测必须用同一个 undici 副本(2026-09-17)
|
|
641
|
+
|
|
642
|
+
**症状**:设置页「代理配置」的逐 URL 诊断,无论勾不勾「使用代理」都返回 `fail`
|
|
643
|
+
(面板只显示 `fetch failed`);而 `curl`、独立 node 进程、policy 通道都正常。
|
|
644
|
+
|
|
645
|
+
**根因(实测)**:`forceChannelProbe` 用 `import('undici')`(app 的 undici 包)造
|
|
646
|
+
`Agent`/`ProxyAgent`,却把它交给**全局 `fetch`**(Node 内置的另一份 undici)当 `dispatcher`:
|
|
647
|
+
|
|
648
|
+
```
|
|
649
|
+
CONTROL 内置 fetch(无 dispatcher) -> OK 401
|
|
650
|
+
A 内置 fetch + 外部 undici Agent -> ERR fetch failed | UND_ERR_INVALID_ARG: invalid onRequestStart method
|
|
651
|
+
B 外部 undici 的 fetch + 同一 Agent -> OK 401
|
|
652
|
+
D 内置 fetch + 外部 ProxyAgent -> ERR fetch failed | UND_ERR_INVALID_ARG
|
|
653
|
+
E 外部 undici 的 fetch + 同一 ProxyAgent -> OK 401
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
**规矩**:凡是要传 `dispatcher` 的请求,**请求与被传的 dispatcher 必须来自同一次
|
|
657
|
+
`import('undici')`**(即 `und.fetch(url, { dispatcher })`)。这正是 §3 的老教训
|
|
658
|
+
(mixed undici instances → UND_ERR_INVALID_ARG)——插件主干一直靠"委托
|
|
659
|
+
`installProxyFromEnvironment`、自己绝不碰 undici"规避它,而强制通道绕过了这条原则。
|
|
660
|
+
|
|
661
|
+
**顺带修掉的诊断缺陷**:`classifyTargetFailure` 原本只回 `error.message`("fetch failed"),
|
|
662
|
+
丢掉了 `error.cause` 里的 `code`。现在优先输出 cause code,并把 `UND_ERR_INVALID_ARG`
|
|
663
|
+
单独归类(`kind: 'dispatcher'`,提示 "mixed undici copies")。
|
|
664
|
+
|
|
665
|
+
---
|
|
666
|
+
|
|
667
|
+
## 23. 与 @deepseek-ai/dsh-http-proxy 插件行共存会抢全局 dispatcher
|
|
668
|
+
|
|
669
|
+
同一天的另一条线索:把 `@deepseek-ai/dsh-http-proxy` **作为插件行同时启用**时,policy 通道
|
|
670
|
+
探测也出现过 `fetch failed`;从 profile 的 bundles/依赖里移除该行后恢复正常。
|
|
671
|
+
|
|
672
|
+
原因:两个插件都调同一个库的 `installProxyFromEnvironment()`,各自
|
|
673
|
+
`setGlobalDispatcher()`;谁先卸载,谁的 disposer 就把"它安装前"的 dispatcher
|
|
674
|
+
(`previousDispatcher`)恢复回去,全局 dispatcher 于是可能指向已关闭/陈旧实例。
|
|
675
|
+
|
|
676
|
+
**结论**:本插件与 `dsh-http-proxy` 插件行**二选一**。库本身必须保留——插件 import 它的
|
|
677
|
+
`installProxyFromEnvironment` / `proxyRouteFor`,而 DSH 的 web_fetch 通道读的正是同一模块的
|
|
678
|
+
module-level policy(§2-§3)。卸载"插件行"是安全的,删除"包"会让本插件 import 直接失败。
|
|
679
|
+
|
|
680
|
+
---
|
|
681
|
+
|
|
682
|
+
## 24. 诊断结论要三态 + 可行动(2026-09-17)
|
|
683
|
+
|
|
684
|
+
**症状**:面板与 `proxy_test` 的「连通探测」只分可达/不可达两档——`HTTP 504`(网关/上游超时)
|
|
685
|
+
和 `HTTP 200` 都打印成「可达」,用户无法判断"到底能不能用"。
|
|
686
|
+
|
|
687
|
+
**改法**(1.0.3):
|
|
688
|
+
|
|
689
|
+
- `classifyProbeStatus(status)` 产出三态:
|
|
690
|
+
- `usable`:2xx/3xx,以及 **401**(探测不带凭证,服务健康就该回 401);
|
|
691
|
+
- `degraded`:403/404/405/429 等 4xx —— **路由通、这次调用会失败**;
|
|
692
|
+
- `unusable`:407、5xx(500/502/503/504)—— 路由或对端失败。
|
|
693
|
+
- 每个结论都带 `why`(可能原因)+ `fix`(挽救措施):面板显示三层
|
|
694
|
+
(连通探测 / 可能原因 / 挽救措施),`proxy_test` 输出 `Why:` / `Fix:` 行。
|
|
695
|
+
- `classifyTargetFailure` 同样补齐 why/fix,并按 ENOTFOUND / ECONNREFUSED / ETIMEDOUT /
|
|
696
|
+
ECONNRESET / UND_ERR_INVALID_ARG / 407 分别归类。
|
|
697
|
+
- 探测顺序:**HEAD →(405/501 时)GET `Range: bytes=0-0` 复测**。很多 API 只实现 GET,
|
|
698
|
+
单用 HEAD 会冤枉一个可用主机;复测结果里附 `note` 说明。
|
|
699
|
+
|
|
700
|
+
**教训**:诊断工具的返回值必须回答"能不能用"和"下一步怎么办"——只回一个状态码,等于把问题
|
|
701
|
+
又丢回给用户。另外 `additionalProperties: false` 的 schema 必须同步列出新字段
|
|
702
|
+
(verdict/why/fix/note),否则 defineTool 校验直接失败。
|
|
703
|
+
|
|
704
|
+
---
|
|
705
|
+
|
|
706
|
+
## 25. 策略通道也要同一 undici 副本(2026-09-17)
|
|
707
|
+
|
|
708
|
+
**症状**:1.0.3 面板「连通探测」(不勾选「使用代理」)显示
|
|
709
|
+
`不可用:dispatcher rejected by this undici instance (UND_ERR_INVALID_ARG) — mixed undici copies`。
|
|
710
|
+
|
|
711
|
+
**根因**:1.0.2 只修了强制通道(`und.fetch` + 同副本 dispatcher,§22)。策略通道一直用 Node
|
|
712
|
+
内置 `fetch` 且不传 dispatcher —— 但全局 dispatcher 是 `installProxyFromEnvironment` 用 **app
|
|
713
|
+
undici** 装的,内置 fetch 拿它发请求 = A 组合(内置 fetch + 外部 Agent)→ 必然
|
|
714
|
+
UND_ERR_INVALID_ARG。之前没暴露,是因为面板默认勾选「使用代理」(强制通道)掩盖了它。
|
|
715
|
+
|
|
716
|
+
**修法**(1.0.4):策略通道同样走 app undici——
|
|
717
|
+
`policyProbe()` = `und.fetch(url, { dispatcher: und.getGlobalDispatcher() })`。
|
|
718
|
+
`getGlobalDispatcher()` 返回的正是 web_fetch 通道在用的全局 dispatcher,语义 = 模拟 web_fetch,
|
|
719
|
+
且永远同副本。三条探测路径现在统一:policy = 全局 dispatcher / forced-direct = 新建 Agent /
|
|
720
|
+
forced-proxy = 新建 ProxyAgent,全部由 `und.fetch` 发起。
|
|
721
|
+
|
|
722
|
+
**连带**:所有展示文案(short/why/fix/hint)中文化——面板是中文 UI,英文 why/fix 让人无法
|
|
723
|
+
行动;`proxy_test` 工具输出同步中文(模型照读)。教训:给用户看的文案别用英文"图省事",
|
|
724
|
+
判定结论必须能直接用。
|
|
725
|
+
|
|
726
|
+
**测试**:断言随中文文案同步翻新(/混用/、/超时/、/上游|超时/、/ECONNREFUSED/)。
|
|
727
|
+
|
|
728
|
+
---
|
|
729
|
+
|
|
730
|
+
## 26. 不 import 会被移除的库导出:settings 走服务(2026-09-18)
|
|
731
|
+
|
|
732
|
+
**症状**:升级 test profile 后,dsh CLI 启动时整棵树崩溃:
|
|
733
|
+
`SyntaxError: The requested module '@deepseek-ai/dsh-settings' does not provide an export named 'installSettingsSection'`,
|
|
734
|
+
插件行加载失败 → 整个启动终止。
|
|
735
|
+
|
|
736
|
+
**根因**:dsh-settings 同时存在两个版本——桌面版(resources/app/node_modules)**保留**了
|
|
737
|
+
`installSettingsSection`/`settingsNamespace` 兼容导出;全局 dsh CLI(AppData/Roaming/npm 的
|
|
738
|
+
`@deepseek-ai/dsh` 自带依赖树)是**新版,已移除这两个导出**。插件在模块顶层
|
|
739
|
+
`import { installSettingsSection } from '@deepseek-ai/dsh-settings'` → ESM 求值阶段就抛
|
|
740
|
+
SyntaxError,不是运行期错误——profile 插件树加载失败,启动直接终止。
|
|
741
|
+
|
|
742
|
+
**改法**(1.0.5):彻底不 import dsh-settings 的任何导出,配置注册改走 **settings 服务**:
|
|
743
|
+
```js
|
|
744
|
+
const scope = ctx.settings.register(PROXY_NS, Config, { base: config })
|
|
745
|
+
source = () => scope.get() // update() 重算 registration.resolved,get() 始终新鲜
|
|
746
|
+
scope.watch(() => { void requestSync().catch(() => {}) })
|
|
747
|
+
```
|
|
748
|
+
`register`/`watch`/`update`/`replace` 在两端(旧桌面版 SettingsProvider 与新 CLI 版)都存在;
|
|
749
|
+
命名空间直接写字面量 `'proxy'`(旧 `settingsNamespace()` 也只是 parse + 校验)。
|
|
750
|
+
|
|
751
|
+
**教训**:① ESM 顶层 import 命中断言是**加载期**错误——一次错误的 import 能杀死整个进程,所以
|
|
752
|
+
对外部包的导出要查运行环境的实际版本,而不是凭记忆;② 能从服务拿到的(settings 是 inject 服务)
|
|
753
|
+
就不要 import 包;③ 「桌面版」与「全局 CLI 版」是两套依赖树,一个功能必须两端都能加载。
|
|
754
|
+
|
|
755
|
+
---
|
|
756
|
+
|
|
757
|
+
## 27. HEAD 404 ≠ 路径不存在:探测必须 GET 复测(2026-09-18)
|
|
758
|
+
|
|
759
|
+
**症状**:面板测 `https://dashscope.aliyuncs.com/compatible-mode/v1` 和 `.../v1/models`
|
|
760
|
+
一律显示「降级 — HTTP 404 — 路径不存在」;连测几个厂商的 API 都是 404,用户开始怀疑
|
|
761
|
+
「是不是网络不可达」。
|
|
762
|
+
|
|
763
|
+
**真相**(实测对照):
|
|
764
|
+
|
|
765
|
+
| 请求 | DashScope 应答 |
|
|
766
|
+
| --- | --- |
|
|
767
|
+
| GET `/compatible-mode/v1/models` | **401**(端点存在,只缺 API Key) |
|
|
768
|
+
| GET `/compatible-mode/v1/chat/completions` | **400**(端点存在,请求不合法) |
|
|
769
|
+
| GET `/` | 404 |
|
|
770
|
+
| HEAD `/compatible-mode/v1/models` | **404** ← 同一个端点! |
|
|
771
|
+
|
|
772
|
+
网关不给 HEAD 注册路由,对 HEAD 一律回 404;而 404 被探测当成「路径不存在」,健康的
|
|
773
|
+
API 端点就这样被误判。**404 从来不是「网络不可达」的证据**——它恰恰证明拿到了 HTTP 应答
|
|
774
|
+
(真正的不可达是 ENOTFOUND/ECONNREFUSED/ETIMEDOUT/代理 407)。
|
|
775
|
+
|
|
776
|
+
**改法**(1.0.7):回退条件从 405/501 扩到 **404/405/501**,提为纯函数
|
|
777
|
+
`headNeedsGetRetry(status)`(proxy-core,可单测)。GET 复测带 `Range: bytes=0-0` 只取首
|
|
778
|
+
字节,代价极小;note 写明「HEAD 返回 X,GET 得到 Y — 以 GET 为准」。404 的 why/fix 改为
|
|
779
|
+
「HEAD 与 GET 都 404」并引导换真实完整端点复测。
|
|
780
|
+
|
|
781
|
+
**教训**:① 探测方法本身就是误判来源——HEAD「轻量」不等于「等价」;② 把 404 当「不存在」
|
|
782
|
+
之前先问「服务器认不认这个方法」;③ 状态码是二手的,链路层结论只能由「是否拿到 HTTP
|
|
783
|
+
应答」给出。
|
|
784
|
+
|
|
785
|
+
---
|
|
786
|
+
|
|
787
|
+
## 28. 发布铁律:每次 npm publish 后必须主动强制触发淘宝源同步(用户定案,2026-09-18)
|
|
788
|
+
|
|
789
|
+
**用户的规矩(说了两次,不可违背)**:每次我们更新 npm 包(publish 成功)后,**必须手动
|
|
790
|
+
主动触发淘宝源(npmmirror)更新,不许等待**——用户原话「每次更新了 npm,都请你手动强制
|
|
791
|
+
触发淘宝源更新」「不要等待,就是走淘宝的主动同步」。
|
|
792
|
+
|
|
793
|
+
**为什么**:npmmirror 自己拉取官方源有队列延迟,干等(只 PUT 一次 syncs 然后 poll)会 stuck
|
|
794
|
+
几分钟甚至更久,导致 `dsh plugin add <新版本>` 在淘宝源上解析不到。**主动循环触发**(每轮
|
|
795
|
+
先 PUT syncs、再 poll)实测 1.0.5=11 轮、1.0.6=13 轮、1.0.7=33 轮(约 2-4 分钟)必定拉到。
|
|
796
|
+
|
|
797
|
+
**标准流程(每一步都要做)**:
|
|
798
|
+
1. `npm publish --access=public --registry=https://registry.npmjs.org --replace-registry-host=never` 成功;
|
|
799
|
+
2. 循环最多 60 轮:`PUT https://registry.npmmirror.com/-/package/@yanglaofish%2Fdsh-proxy-pro/syncs`
|
|
800
|
+
带 body `{"version":"<新版本>"}`(ContentType application/json),随后
|
|
801
|
+
`GET https://registry.npmmirror.com/@yanglaofish%2Fdsh-proxy-pro` 看 `dist-tags.latest`;
|
|
802
|
+
每轮间隔 8-10s,**一直循环到 latest == 新版本**(不许提前放弃);
|
|
803
|
+
3. latest 到位后立即:`dsh plugin --profile test add '@yanglaofish/dsh-proxy-pro@^<新版本>'`
|
|
804
|
+
与 `dsh plugin --profile web add ...`,并验证两个 profile 的 node_modules 版本。
|
|
805
|
+
|
|
806
|
+
**教训**:① 「PUT syncs 返回 201」只是入队确认,不是同步完成——以 `dist-tags.latest` 为准;
|
|
807
|
+
② 官方 registry 先可见不代表淘宝可见,淘宝拉取是独立队列;③ 用户规则优先于"省事"——主动
|
|
808
|
+
触发是默认动作,不是可选优化。
|
|
809
|
+
|
|
810
|
+
**补充:git push 的代理必须与「被暖窗的代理」一致(2026-09-18 实测)**:git 全局配置里写死的
|
|
811
|
+
`http.proxy=http://proxyhk.huawei.com:8080` 是历史遗留——当前网络环境该代理已 407 失效,
|
|
812
|
+
而系统代理是 `proxyza`(南非)。正确姿势:push 前用 `Invoke-WebRequest https://github.com`
|
|
813
|
+
暖**当前系统代理**(.NET 自动 NTLM),然后 `git -c http.proxy=http://<当前系统代理> push`
|
|
814
|
+
(用 `-c` 临时覆盖写死的 config)。若暖窗对象与 git 实际走的代理不一致,必然 407 CONNECT
|
|
815
|
+
tunnel failed(proxyhk 暖窗 407、proxyza 暖窗 200 → 只有后者能推成功)。长期可选:
|
|
816
|
+
`git config --global --unset http.proxy` 让 git 走插件写入的 `http_proxy` 环境变量(动态跟随
|
|
817
|
+
当前系统代理),注意 CLI 环境无插件时需手动 `-c` 指定。
|
|
818
|
+
|
|
819
|
+
## 29. npm 发布 403 的根因链 + 绕行法(2026-09-18 发布 1.0.9 实测,抓原始响应体)
|
|
820
|
+
|
|
821
|
+
**背景**:`npm publish` 报 `403 Forbidden - PUT registry.npmjs.org`,换 token/重登录/纠结
|
|
822
|
+
2FA 都没用,最后靠"抓 npmjs 原始响应体 + curl 直发"解决。**核心教训:npm CLI 的 403
|
|
823
|
+
错误是泛化的,必须抓 PUT 的 HTTP body 才能知道真实拒绝原因。**
|
|
824
|
+
|
|
825
|
+
**根因链(三层,缺一不可)**:
|
|
826
|
+
1. **granular token 必须勾选「Bypass 2FA」才能发布**——与直觉相反!
|
|
827
|
+
抓到的原始 body:`{"error":"Two-factor authentication or granular access token with
|
|
828
|
+
bypass 2fa enabled is required to publish packages."}`
|
|
829
|
+
即:账号没开 2FA 时,token **必须**带 bypass-2FA 标志才被允许发布;不勾 → 403。
|
|
830
|
+
注意 CLI 每次 403 都打印的 notice「bypass-2FA tokens are being restricted for direct
|
|
831
|
+
publishing」是 2027-01 才生效的预告,**不是**当下拒绝的原因(差点被带偏)。
|
|
832
|
+
2. **npm CLI 的 publish 走 otplease 路径,对老包(已有 8 个历史版本+dist-tags)403,
|
|
833
|
+
而 curl 同 token 直发正常**(对照实验:同 CLI 发全新测试包成功 → 排除 CLI 整体坏;
|
|
834
|
+
同 token curl 骨架 PUT → 400=权限已过只是数据无效)。绕行:
|
|
835
|
+
3. **curl 完整 couch 格式 PUT 直发**(含 `_attachments` 里 base64 tarball)→ `202 Accepted`,
|
|
836
|
+
随后 `latest` 落库为 1.0.9。这条路径等价于官方 `npm stage publish` 的直发变体。
|
|
837
|
+
|
|
838
|
+
**Staged Publishing(2026-05-22 上线的 npm 新机制,别被 202 骗了)**:
|
|
839
|
+
- 2026-05-22 起 npm 全面启用 staged publishing:发布先进暂存队列,**需真人维护者 2FA 批准
|
|
840
|
+
才落地**。`202 Accepted` 只是入队成功,GET 简短延迟后可见;再 PUT 同版本 → `409
|
|
841
|
+
{"error":"Cannot publish over previously staged version \"1.0.9\""}`(证明已入队)。
|
|
842
|
+
- 官方正解流程:`npm stage publish`(暂存)→ `npm stage approve <stage-id>`(带 2FA 批准),
|
|
843
|
+
或网页 npmjs.com 批准;CI 建议 trusted publishing (OIDC)。
|
|
844
|
+
- **坑**:`npm stage list` 默认打向 user `.npmrc` 里的 registry(华为镜像
|
|
845
|
+
`/-/stage` 504 超时)→ 必须显式 `--registry=https://registry.npmjs.org`。
|
|
846
|
+
|
|
847
|
+
**配套工具坑(本次踩到三次)**:
|
|
848
|
+
- PowerShell `Set-Content -Encoding UTF8` 会写 BOM → node `JSON.parse` 直接炸
|
|
849
|
+
(`Unexpected token '\ufeff'`)。写 JSON/脚本给 node 用必须
|
|
850
|
+
`[IO.File]::WriteAllText($path, $txt, [Text.UTF8Encoding]::new($false))`。
|
|
851
|
+
- node 脚本 argv 下标:`process.argv[1]` 是**脚本自身路径**,业务参数从 `argv[2]` 起
|
|
852
|
+
(写 `${argv[1]}` 会把脚本内容当 JSON 读,报诡异错误)。
|
|
853
|
+
- `npm pack` 产出的 tarball 文件名**不带 scope**:`dsh-proxy-pro-1.0.9.tgz`
|
|
854
|
+
(不是 `yanglaofish-dsh-proxy-pro-1.0.9.tgz`),验证 200 时容易 404 误报。
|
|
855
|
+
|
|
856
|
+
**发布 1.0.9 的最终验证链**:npmjs `latest=1.0.9` + tarball 200(65582B) →
|
|
857
|
+
npmmirror sync 主动触发(201)→ npmmirror `latest=1.0.9` + tarball 200(65582B) →
|
|
858
|
+
test profile `pnpm install` 装 1.0.9 → 双通道新逻辑在位置(dualBadge/both-reached)→
|
|
859
|
+
腾讯 Copilot 场景复验:代理 🟡「链路通·目标拒绝」+ 直连 🔴 → 「只有走代理可达」(旧版误报
|
|
860
|
+
双失败,修复后正确)。
|
|
861
|
+
|
|
862
|
+
## 30. 官方双拼写是必要设计:子进程 env ≠ 主进程 env,别"优化"掉(2026-09-18 MCP 实测)
|
|
863
|
+
|
|
864
|
+
**起因**:我们曾把官方 dsh-http-proxy 的 `applyPolicyEnv` / `proxyEnvironmentForChild`
|
|
865
|
+
改成 Windows 只发布小写拼写(判断:Windows env 大小写不敏感,双拼写是"污染枚举的 bug",
|
|
866
|
+
PowerShell `env:` 枚举报「已添加了具有相同键的项」就是"证据")。
|
|
867
|
+
|
|
868
|
+
**判断错在两处**:
|
|
869
|
+
1. **Node 的 `process.env` 区分大小写**(JS 对象属性查找)——"Windows env 大小写不敏感"
|
|
870
|
+
只对系统 API(GetEnvironmentVariable)成立。Node 子进程 env 是 JS 对象传给 spawn,
|
|
871
|
+
`process.env.HTTP_PROXY` 与 `process.env.http_proxy` 是**两个不同的键**。
|
|
872
|
+
2. **只验证了主进程 env,没验证子进程 env**:当时实测主进程 env 双拼写齐全,就断言
|
|
873
|
+
"MCP 与单拼写无关"。但 **MCP 等子进程继承的不是主进程 env,而是官方库
|
|
874
|
+
`proxyEnvironmentForChild()` 生成的 overlay**——补丁把 overlay 改成只剩小写后:
|
|
875
|
+
npm/npx(及 obsidian MCP 的 Node 依赖)读大写 `HTTP_PROXY` → 拿不到代理 →
|
|
876
|
+
npx 拉包/服务器网络初始化失败 → **MCP 连不上**。
|
|
877
|
+
|
|
878
|
+
**用户实测闭环(定案)**:补丁版重启 → MCP 断(`ws_mcp_call` 报「未连接或连接失败」);
|
|
879
|
+
还原官方双拼写再重启 → **MCP 恢复**。大小写判断错误被实测定案,无用 issue 已关闭。
|
|
880
|
+
|
|
881
|
+
**教训**:
|
|
882
|
+
- 官方成对发布(`http_proxy`/`HTTP_PROXY` 等)是**必要设计**:两条生态各读各的拼写,
|
|
883
|
+
少一条就会挂掉只读那一条的工具(尤其子进程)。
|
|
884
|
+
- **查子进程环境问题:去看 spawn 的 env 来源(这里是 `proxyEnvironmentForChild` overlay),
|
|
885
|
+
不是主进程 process.env**。
|
|
886
|
+
- PowerShell `env:` 枚举报「已添加了具有相同键的项」是 .NET 收集器对大小写键的显示冲突,
|
|
887
|
+
**无害**,不是"官方 bug"的证据。
|
|
888
|
+
- 处置:官方库两处与插件 `proxyEnvMap` 已全部还原为官方默认双拼写;web/test 1.0.9
|
|
889
|
+
自始就是双拼写,从未含过单拼写。
|
|
890
|
+
|
|
891
|
+
## 31. 标准发布流程(用户定案,2026-09-18 起执行)
|
|
892
|
+
|
|
893
|
+
**开发 → test profile 验证 → 验证通过 → 提交远程 → 发包 → 淘宝强制同步 → 升级 web**
|
|
894
|
+
|
|
895
|
+
1. **开发**:工作区源码(`D:\个人材料\Agent\dsh-workspace\dsh-proxy-pro`)改代码,测试全绿。
|
|
896
|
+
2. **test profile 验证**:未发布前先把开发版接入 test(直接复制 lib 到
|
|
897
|
+
`~/.dsh/profiles/test/node_modules/@yanglaofish/dsh-proxy-pro/lib`,md5 核对),
|
|
898
|
+
用户实测功能/回归通过才算过。
|
|
899
|
+
3. **验证通过 → 提交远程**:先 commit 当前工作区(消除 git 与 npm 的漂移——1.0.9 曾
|
|
900
|
+
出现发布树与 HEAD 不一致:单拼写被混进 commit 但从未发布),再 push。
|
|
901
|
+
4. **发包**:显式 `--registry=https://registry.npmjs.org`,scoped `--access=public`,
|
|
902
|
+
`--replace-registry-host=never`;403/staged publishing 绕行见 §29。
|
|
903
|
+
5. **淘宝强制同步**:publish 后**不等**,循环 PUT npmmirror sync + 轮询
|
|
904
|
+
dist-tags.latest 直到更新(铁律 §28)。
|
|
905
|
+
6. **升级 web**:web profile `pnpm install` 装新版本,验收后结束。
|