@yanglaofish/dsh-proxy-pro 1.0.0 → 1.0.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/README.md CHANGED
@@ -145,7 +145,7 @@ npm test
145
145
 
146
146
  - 纯逻辑层 `lib/proxy-core.js` 零 DSH 依赖,可独立单测。
147
147
  - **GitHub 安装模式**:改代码需 `git push` 后 `pnpm update @yanglaofish/dsh-proxy-pro` 再重启 `dsh web` 生效。
148
- - **npm 安装模式**:bump 版本 → `npm publish --registry=https://registry.npmjs.org` 后,profile 内 `pnpm add @yanglaofish/dsh-proxy-pro@最新版` 再重启 `dsh web` 生效。
148
+ - **npm 安装模式**:bump 版本 → `npm publish --access=public --registry=https://registry.npmjs.org --replace-registry-host=never` 后,profile 内 `pnpm add @yanglaofish/dsh-proxy-pro@最新版` 再重启 `dsh web` 生效。(scoped 包发布必须 `--access=public`;本机 npm 默认 registry 被公司镜像接管且 `replace-registry-host` 会重写 GET——发布与验证一律显式 `--registry` 官方源 + `--replace-registry-host=never`。)
149
149
  - **本地开发模式**(改代码重启即生效):`dsh plugin --profile web add file:../../plugins/dsh-proxy-pro`。
150
150
 
151
151
  ## 许可
package/docs/LESSONS.md CHANGED
@@ -1,551 +1,636 @@
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|obsidian-web]\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`(obsidian-web 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 / obsidian-web(两份,插件需都装) |
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,obsidian-web}\node_modules\dsh-plugin-proxy\lib\`
284
- - 参照模板:`~/.dsh/profiles\obsidian-web\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/obsidian-web/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
- - 修复:对照 obsidian-web 的形态把 `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
- - 新增任何"定时/轮询"行为时,先问:它会不会触发全量重建?会就拆开。
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|obsidian-web]\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`(obsidian-web 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 / obsidian-web(两份,插件需都装) |
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,obsidian-web}\node_modules\dsh-plugin-proxy\lib\`
284
+ - 参照模板:`~/.dsh/profiles\obsidian-web\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/obsidian-web/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
+ - 修复:对照 obsidian-web 的形态把 `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。
package/lib/client.js CHANGED
@@ -18,9 +18,15 @@
18
18
  * This hand-written module mirrors the loader format the in-repo client
19
19
  * bundles emit (window.__ModuleLoader__.load with a CommonJS factory): react
20
20
  * is required through the app's module table, no JSX, no bundler, no TS.
21
+ *
22
+ * The registration id MUST be the PACKAGE name: dsh-client-modules stores the
23
+ * factory under stripClientSuffix(id) and later looks it up by the graph row
24
+ * id (= package name). Registering "dsh-proxy-pro/client" (the pre-scope name)
25
+ * throws 'bundle ... loaded without registering "@yanglaofish/dsh-proxy-pro"
26
+ * via __ModuleLoader__.load' (see docs/LESSONS.md §18).
21
27
  */
22
28
  window.__ModuleLoader__.load({
23
- id: "dsh-proxy-pro/client",
29
+ id: "@yanglaofish/dsh-proxy-pro",
24
30
  factory: (require) => {
25
31
  var module = { exports: {} };
26
32
  var exports = module.exports;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yanglaofish/dsh-proxy-pro",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "DSH proxy manager: route model requests, web fetches and spawned tools through the Windows system proxy or a custom address; per-URL route diagnosis, one-click toggle from the conversation header, a dedicated Settings page, and keepalive-aware 407 hints.",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",