@yanglaofish/dsh-proxy-pro 1.0.1 → 1.0.2

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
@@ -21,7 +21,7 @@
21
21
 
22
22
  ```sh
23
23
  dsh plugin --profile web add @yanglaofish/dsh-proxy-pro
24
- dsh plugin --profile obsidian-web add @yanglaofish/dsh-proxy-pro
24
+ dsh plugin --profile test add @yanglaofish/dsh-proxy-pro
25
25
  ```
26
26
 
27
27
  **方式 B:GitHub 源安装**
@@ -70,7 +70,7 @@ proxy:
70
70
 
71
71
  ```sh
72
72
  dsh plugin --profile web remove @yanglaofish/dsh-proxy-pro
73
- dsh plugin --profile obsidian-web remove @yanglaofish/dsh-proxy-pro
73
+ dsh plugin --profile test remove @yanglaofish/dsh-proxy-pro
74
74
  ```
75
75
 
76
76
  卸载即恢复原始路由行为,不留残留。
@@ -7,7 +7,7 @@
7
7
  ## 0. 前置(已就绪,无需操作)
8
8
 
9
9
  - [x] 单测 16/16 通过、语法 OK、git 干净(head = afb96b4)
10
- - [x] web / obsidian-web 两 profile 已装入 `dsh-proxy-pro`(node_modules 为 Junction 软链)
10
+ - [x] web / test 两 profile 已装入 `dsh-proxy-pro`(node_modules 为 Junction 软链)
11
11
  - [x] `dsh --dump-config` 预检:两 profile 组合树均含 `- id: dsh-proxy-pro` 行
12
12
  - [x] settings.yaml 的 `proxy.enabled: true` → 重启后插件**启动即接管**系统代理(预期行为)
13
13
 
@@ -52,9 +52,9 @@
52
52
  `<profile>/cordis.patch.yml` 追加一行 `- id: proxy, disabled: true`(禁旧
53
53
  dsh-plugin-proxy)→ 重启 → 复测 A1-A6。回滚 = 删该行,settings.yaml 全程不动。
54
54
 
55
- ## 6. obsidian-web 复用
55
+ ## 6. test 复用
56
56
 
57
- 用 `dsh --profile obsidian-web` 启动 → 重复 §2-§3(A10)。
57
+ 用 `dsh --profile test` 启动 → 重复 §2-§3(A10)。
58
58
 
59
59
  ---
60
60
 
package/docs/LESSONS.md CHANGED
@@ -106,7 +106,7 @@ const envLike = {
106
106
  ## 5. 为什么新插件放独立目录(避开 node_modules)
107
107
 
108
108
  ```
109
- C:\Users\...\profiles\[web|obsidian-web]\node_modules\dsh-plugin-proxy\package.json
109
+ C:\Users\...\profiles\[web|test]\node_modules\dsh-plugin-proxy\package.json
110
110
  LinkType = HardLink → C:\Users\...\pnpm\store\v11\files\...(pnpm 全局 store)
111
111
  ```
112
112
 
@@ -167,7 +167,7 @@ window.__ModuleLoader__.load({
167
167
  });
168
168
  ```
169
169
 
170
- 技能库里的 `dsh-skill-manager@4.3.3`(obsidian-web profile 已装)是**现代写法参照模板**:
170
+ 技能库里的 `dsh-skill-manager@4.3.3`(test profile 已装)是**现代写法参照模板**:
171
171
 
172
172
  ```js
173
173
  ctx.slots.inject("settings.section", () => ctx.slots.register({
@@ -270,7 +270,7 @@ POST /dsh-proxy-pro/api/toggle {"enabled":bool} → 更新 settings + 立即 syn
270
270
  | Web GUI | http://127.0.0.1:43120(NodeService 21872 监听) |
271
271
  | 系统代理 | proxyhk.huawei.com:8080(NTLM SWG,Proxy ON) |
272
272
  | NO_PROXY | 大列表:`*.huawei.com` 等内网全量 + localhost/127.0.0.1/::1 |
273
- | profile | web / obsidian-web(两份,插件需都装) |
273
+ | profile | web / test(两份,插件需都装) |
274
274
  | settings.yaml | proxy: {enabled: true, mode: system, customUrl: http://127.0.0.1:7890} |
275
275
  | 验证基线 | github → PROXIED;deepseek.com → DIRECT;web_fetch PR#3574 → 200 |
276
276
  | loadLayeredEnv | process.env + cwd/.env + ~/.dsh/.env(仅 undefined 时写入) |
@@ -280,8 +280,8 @@ POST /dsh-proxy-pro/api/toggle {"enabled":bool} → 更新 settings + 立即 syn
280
280
  - 打包源码:`C:\Users\w00958282\AppData\Local\Programs\DSH Desktop\resources\app\node_modules\@deepseek-ai\...`
281
281
  (dsh-web-fetch-http / dsh-http-proxy / dsh-app-boot / dsh-web / dsh-tool-web /
282
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)
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
285
  - 本插件:`~/.dsh/plugins/dsh-proxy-pro/`(git 仓库,发布形态,见 §12)
286
286
  - 环境事实:`~/.dsh/.env`(已删,纯冗余证据);`settings.yaml`、`cordis.patch.yml`、`cordis.yml`
287
287
 
@@ -390,7 +390,7 @@ POST /dsh-proxy-pro/api/toggle {"enabled":bool} → 更新 settings + 立即 syn
390
390
  ——这就是 8ce5e21 修的那个 schema bug(跑在修复前)。
391
391
  - 决定性证据:`profiles/web/package.json` LastWriteTime = **11:50:28**,
392
392
  恰好在 desktop 的 `recovery plugin uninstall failed`(11:50:09 error.log)
393
- 窗口内;对读 `profiles/obsidian-web/package.json` 完好(11:35 未动)。
393
+ 窗口内;对读 `profiles/test/package.json` 完好(11:35 未动)。
394
394
  - **根因:桌面版启动时对解析失败的插件执行 recovery 卸载**
395
395
  (`dsh plugin --profile web remove @yanglaofish/dsh-skill-manager`),
396
396
  该操作重写了 web profile 的 package.json,**把我们手工加的两处
@@ -401,7 +401,7 @@ POST /dsh-proxy-pro/api/toggle {"enabled":bool} → 更新 settings + 立即 syn
401
401
 
402
402
  ### 14.2 修复与预防
403
403
 
404
- - 修复:对照 obsidian-web 的形态把 `dependencies["dsh-proxy-pro"] =
404
+ - 修复:对照 test 的形态把 `dependencies["dsh-proxy-pro"] =
405
405
  link:...` 与 `dsh.profile.bundles` 里的 `"dsh-proxy-pro"` 加回
406
406
  web/package.json;`dsh --profile web --dump-config` 预检两行都在、exit=0。
407
407
  - 预防:每次用户重启前**先读 profile 的 package.json** 确认条目在(read
@@ -633,4 +633,46 @@ git log --oneline # 历史完整(refs/heads/master 里的 tip 仍在)
633
633
  前提:`.git.broken/refs/heads/<branch>` 仍指向有效 commit、objects 完整(松散或 pack)。
634
634
  若 refs 也没了,就只能等网络恢复后 `git fetch` 远端历史再 `git reset --mixed origin/<branch>`。
635
635
  本例 `git fetch` 失败于 `Could not resolve proxy: proxyhk.huawei.com`——git 的 `http.proxy`
636
- 指向公司代理,离开公司网络(或代理关闭)时 GitHub 不可达;此时**不要** push/force push。
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 直接失败。
package/docs/SPEC.md CHANGED
@@ -65,7 +65,7 @@
65
65
  | NFR-3 | 可回滚 | 切回原插件只需恢复 cordis.patch.yml 一行 + 保留 `.bak` |
66
66
  | NFR-4 | 单一 dispatcher 主人 | 与 dsh-plugin-proxy 二选一启用,不同时 |
67
67
  | NFR-5 | 无新增第三方依赖 | 纯 ESM + 现有 DSH 包;逻辑层(proxy-core.js)零依赖可单测 |
68
- | NFR-6 | 双 profile 部署 | web + obsidian-web 都要装 |
68
+ | NFR-6 | 双 profile 部署 | web + test 都要装 |
69
69
 
70
70
  ---
71
71
 
@@ -321,7 +321,7 @@ systemPollMs: 30000
321
321
  3. 重启 DSH → `proxy_status` 确认 active=true、url=proxyhk → 手动 web_fetch
322
322
  一个被墙 URL(如 github)确认 200。
323
323
  4. 验证 proxy_test github=PROXIED / deepseek=DIRECT。
324
- 5. 再对 obsidian-web profile 做同样操作,重复验证。
324
+ 5. 再对 test profile 做同样操作,重复验证。
325
325
  6. 回滚 = 恢复原插件行 enabled(settings.yaml 没动过,天然安全),或
326
326
  `dsh plugin --profile web remove @yanglaofish/dsh-proxy-pro`。
327
327
 
@@ -378,4 +378,4 @@ _运行时快照(2026-09-17,⚠️ 尚未重启):`/dsh-proxy-pro/api/sta
378
378
  5. ✅ 已装入两 profile + `--dump-config` 预检通过(两个组合树都含 dsh-proxy-pro 行);
379
379
  ⏳ 待用户重启(当前**尚未重启**,403 为应用安全门、非插件响应)
380
380
  6. ⏳ GUI 确认 A1/A2(头部胶囊 + 设置页「代理管理」)→ 运行时验收 A3-A9
381
- → obsidian-web A10 → 发布收尾
381
+ → test A10 → 发布收尾
package/lib/index.js CHANGED
@@ -31,6 +31,7 @@ import { installProxyFromEnvironment, proxyRouteFor } from '@deepseek-ai/dsh-htt
31
31
  import { execFile } from 'node:child_process'
32
32
  import { promisify } from 'node:util'
33
33
  import {
34
+ classifyTargetFailure,
34
35
  keepaliveHint,
35
36
  makeSystemProxyReader,
36
37
  normalizeProxyUrl,
@@ -149,18 +150,8 @@ function renderStatusText(snapshot) {
149
150
  ].join('\n')
150
151
  }
151
152
 
152
- /** Isolate NetworkError-ish codes that mean "could not connect at all". */
153
- function classifyTargetFailure(error) {
154
- const cause = error?.cause
155
- const code = cause?.code ?? error?.code ?? ''
156
- if (code === 'EACCES' || code === 'ENETUNREACH' || code === 'EHOSTUNREACH' || code === 'ENOTFOUND' || code === 'ECONNREFUSED' || code === 'ETIMEDOUT') {
157
- return { ok: false, kind: 'unreachable', short: `cannot reach directly (${code}) — needs a proxy route` }
158
- }
159
- if (/407|NTLM|authentication required/i.test(String(error?.message ?? ''))) {
160
- return { ok: false, kind: 'auth', short: 'the proxy asks for NTLM authentication' }
161
- }
162
- return { ok: false, kind: 'other', short: (error?.message ?? String(error)).slice(0, 120) }
163
- }
153
+ // classifyTargetFailure lives in proxy-core.js (pure logic, unit-tested) so the
154
+ // panel's verdict can name the undici cause instead of a bare "fetch failed".
164
155
 
165
156
  // Forced-channel probing uses undici directly. Load lazily and never let a
166
157
  // resolution failure block boot (LESSONS §14: boot must not depend on a new
@@ -195,7 +186,13 @@ async function forceChannelProbe(url, channel, proxyUrl) {
195
186
  const controller = new AbortController()
196
187
  const timer = setTimeout(() => controller.abort(), 6000)
197
188
  try {
198
- const res = await fetch(url, {
189
+ // MUST issue the request with THIS undici copy. The host runs two copies —
190
+ // Node's built-in `fetch` and the app's `undici` package — and a dispatcher
191
+ // built by one handed to the other rejects with UND_ERR_INVALID_ARG
192
+ // ("invalid onRequestStart method"); the panel rendered that as a bare
193
+ // "fetch failed" for BOTH forced channels (LESSONS §22). und.fetch and the
194
+ // Agent/ProxyAgent above come from the same import, so they always agree.
195
+ const res = await und.fetch(url, {
199
196
  method: 'HEAD',
200
197
  redirect: 'manual',
201
198
  signal: controller.signal,
package/lib/proxy-core.js CHANGED
@@ -266,3 +266,35 @@ export function keepaliveHint(message = '', proxyUrl = '') {
266
266
  const example = typeof proxyUrl === 'string' && proxyUrl !== '' ? proxyUrl : 'http://localhost:7890'
267
267
  return `This proxy asks for NTLM authentication, which undici cannot negotiate by itself. A recent curl/browser connection to the same proxy keeps an authenticated window open — run a \`curl\` through the proxy (e.g. \`curl -x ${example} -I https://github.com\`) once, then retry.`
268
268
  }
269
+
270
+ /**
271
+ * Turn a fetch rejection into a short, actionable verdict for the panel and the
272
+ * `proxy_test` tool.
273
+ *
274
+ * A bare `fetch failed` hides the real cause. The host runs TWO undici copies —
275
+ * Node's built-in `fetch` and the app's `undici` package — and a dispatcher
276
+ * built by one handed to the other rejects with UND_ERR_INVALID_ARG ("invalid
277
+ * onRequestStart method"); that is exactly how the forced-channel diagnostic
278
+ * failed (LESSONS §22). Surface the cause code so the next report names it.
279
+ *
280
+ * @param error - the rejection from `fetch`.
281
+ * @returns `{ ok: false, kind, short }` with a cause-aware message.
282
+ */
283
+ export function classifyTargetFailure(error) {
284
+ const cause = error?.cause
285
+ const code = cause?.code ?? error?.code ?? ''
286
+ if (code === 'EACCES' || code === 'ENETUNREACH' || code === 'EHOSTUNREACH' || code === 'ENOTFOUND' || code === 'ECONNREFUSED' || code === 'ETIMEDOUT') {
287
+ return { ok: false, kind: 'unreachable', short: `cannot reach directly (${code}) — needs a proxy route` }
288
+ }
289
+ if (code === 'UND_ERR_INVALID_ARG' || /invalid onRequestStart/i.test(String(cause?.message ?? ''))) {
290
+ return { ok: false, kind: 'dispatcher', short: `dispatcher rejected by this undici instance (${code || 'UND_ERR_INVALID_ARG'}) — mixed undici copies` }
291
+ }
292
+ if (/407|NTLM|authentication required/i.test(String(error?.message ?? ''))) {
293
+ return { ok: false, kind: 'auth', short: 'the proxy asks for NTLM authentication' }
294
+ }
295
+ const detail = cause?.code
296
+ ? `${cause.code}${cause?.message ? `: ${String(cause.message).slice(0, 90)}` : ''}`
297
+ : (cause?.message ?? '')
298
+ const base = String(error?.message ?? error)
299
+ return { ok: false, kind: 'other', short: (detail ? `${base} (${detail})` : base).slice(0, 180) }
300
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yanglaofish/dsh-proxy-pro",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
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",