@yanglaofish/dsh-proxy-pro 1.0.1 → 1.0.3
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 +2 -2
- package/docs/ACCEPTANCE.md +3 -3
- package/docs/LESSONS.md +74 -8
- package/docs/SPEC.md +3 -3
- package/lib/client.js +14 -1
- package/lib/index.js +75 -54
- package/lib/proxy-core.js +135 -0
- package/lib/tool-schemas.js +8 -0
- package/package.json +1 -1
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
|
|
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
|
|
73
|
+
dsh plugin --profile test remove @yanglaofish/dsh-proxy-pro
|
|
74
74
|
```
|
|
75
75
|
|
|
76
76
|
卸载即恢复原始路由行为,不留残留。
|
package/docs/ACCEPTANCE.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
## 0. 前置(已就绪,无需操作)
|
|
8
8
|
|
|
9
9
|
- [x] 单测 16/16 通过、语法 OK、git 干净(head = afb96b4)
|
|
10
|
-
- [x] web /
|
|
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.
|
|
55
|
+
## 6. test 复用
|
|
56
56
|
|
|
57
|
-
用 `dsh --profile
|
|
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|
|
|
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`(
|
|
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 /
|
|
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,
|
|
284
|
-
- 参照模板:`~/.dsh/profiles\
|
|
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/
|
|
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
|
-
- 修复:对照
|
|
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,70 @@ 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 直接失败。
|
|
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 校验直接失败。
|
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 +
|
|
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. 再对
|
|
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
|
-
→
|
|
381
|
+
→ test A10 → 发布收尾
|
package/lib/client.js
CHANGED
|
@@ -71,6 +71,8 @@ window.__ModuleLoader__.load({
|
|
|
71
71
|
".dsxprx-diagbtn{flex:none;padding:6px 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-specific-tip);color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;cursor:pointer}",
|
|
72
72
|
".dsxprx-diagbtn:disabled{opacity:.5;cursor:not-allowed}",
|
|
73
73
|
".dsxprx-diagout{display:flex;flex-direction:column;gap:3px;padding:8px 10px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-specific-tip);font-size:12px;line-height:17px;color:var(--dsw-alias-label-primary)}",
|
|
74
|
+
".dsxprx-diagwhy{font-size:11px;line-height:16px;color:var(--dsw-alias-label-caption)}",
|
|
75
|
+
".dsxprx-diagfix{font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary)}",
|
|
74
76
|
".dsxprx-title{font-size:16px;font-weight:700;line-height:22px;color:var(--dsw-alias-label-primary)}",
|
|
75
77
|
".dsxprx-diagcheck{flex:none;display:flex;align-items:center;gap:5px;font-size:12px;line-height:16px;color:var(--dsw-alias-label-secondary);cursor:pointer;white-space:nowrap}",
|
|
76
78
|
".dsxprx-diagcheck input{width:14px;height:14px;accent-color:#4d6bfe;margin:0}",
|
|
@@ -295,6 +297,14 @@ window.__ModuleLoader__.load({
|
|
|
295
297
|
}
|
|
296
298
|
var diagUseProxy = useState(true);
|
|
297
299
|
var setDiagUseProxy = diagUseProxy[1];
|
|
300
|
+
/** Three-state headline for one probe verdict (usable / degraded / unusable). */
|
|
301
|
+
function probeHeadline(probe) {
|
|
302
|
+
if (!probe) return "未知";
|
|
303
|
+
if (probe.verdict === "usable") return "可用" + (probe.status === undefined ? "" : "(HTTP " + probe.status + ")");
|
|
304
|
+
if (probe.verdict === "degraded") return "降级:" + (probe.short || "未知");
|
|
305
|
+
if (probe.verdict === "unusable") return "不可用:" + (probe.short || "未知");
|
|
306
|
+
return probe.ok ? "可用" : (probe.short || "未知");
|
|
307
|
+
}
|
|
298
308
|
function runDiag() {
|
|
299
309
|
var target = diagUrl.trim();
|
|
300
310
|
if (!target) return;
|
|
@@ -408,7 +418,10 @@ window.__ModuleLoader__.load({
|
|
|
408
418
|
: [
|
|
409
419
|
react.createElement("span", { key: "c" }, "测试通道: " + (diag.channel === "proxy" ? "经代理(强制)" : diag.channel === "direct" ? "直连(强制绕过)" : "按当前策略")),
|
|
410
420
|
react.createElement("span", { key: "r" }, "策略判定: " + diag.route + (diag.bypassed ? "(NO_PROXY 命中)" : "")),
|
|
411
|
-
react.createElement("span", { key: "p" }, "连通探测: " + (diag.probe
|
|
421
|
+
react.createElement("span", { key: "p" }, "连通探测: " + probeHeadline(diag.probe)),
|
|
422
|
+
diag.probe && diag.probe.why ? react.createElement("span", { key: "w", className: "dsxprx-diagwhy" }, "可能原因: " + diag.probe.why) : null,
|
|
423
|
+
diag.probe && diag.probe.fix ? react.createElement("span", { key: "f", className: "dsxprx-diagfix" }, "挽救措施: " + diag.probe.fix) : null,
|
|
424
|
+
diag.probe && diag.probe.note ? react.createElement("span", { key: "n", className: "dsxprx-diagwhy" }, "备注: " + diag.probe.note) : null,
|
|
412
425
|
diag.hint ? react.createElement("span", { key: "h" }, "提示: " + diag.hint) : null,
|
|
413
426
|
]
|
|
414
427
|
)
|
package/lib/index.js
CHANGED
|
@@ -31,6 +31,8 @@ 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
|
+
classifyProbeStatus,
|
|
35
|
+
classifyTargetFailure,
|
|
34
36
|
keepaliveHint,
|
|
35
37
|
makeSystemProxyReader,
|
|
36
38
|
normalizeProxyUrl,
|
|
@@ -149,18 +151,8 @@ function renderStatusText(snapshot) {
|
|
|
149
151
|
].join('\n')
|
|
150
152
|
}
|
|
151
153
|
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
}
|
|
154
|
+
// classifyTargetFailure lives in proxy-core.js (pure logic, unit-tested) so the
|
|
155
|
+
// panel's verdict can name the undici cause instead of a bare "fetch failed".
|
|
164
156
|
|
|
165
157
|
// Forced-channel probing uses undici directly. Load lazily and never let a
|
|
166
158
|
// resolution failure block boot (LESSONS §14: boot must not depend on a new
|
|
@@ -173,6 +165,47 @@ function getUndici() {
|
|
|
173
165
|
return undiciPromise
|
|
174
166
|
}
|
|
175
167
|
|
|
168
|
+
const PROBE_UA = 'dsh-proxy-pro/0.1'
|
|
169
|
+
const PROBE_TIMEOUT_MS = 6000
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* One probe attempt: HEAD first, then a GET retry when the endpoint does not
|
|
173
|
+
* implement HEAD (405/501) — many APIs only answer GET, and a HEAD-only probe
|
|
174
|
+
* would libel a perfectly usable host. `request` is the caller's fetch: the
|
|
175
|
+
* global one for the policy channel, this undici copy's `fetch` for forced
|
|
176
|
+
* channels (LESSONS §22 — the two must never be mixed).
|
|
177
|
+
*
|
|
178
|
+
* @returns a verdict from classifyProbeStatus / classifyTargetFailure.
|
|
179
|
+
*/
|
|
180
|
+
async function probeHttp(request, target, dispatcher) {
|
|
181
|
+
const attempt = async (method) => {
|
|
182
|
+
const controller = new AbortController()
|
|
183
|
+
const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS)
|
|
184
|
+
try {
|
|
185
|
+
const init = { method, redirect: 'manual', signal: controller.signal, headers: { 'user-agent': PROBE_UA } }
|
|
186
|
+
if (method === 'GET') init.headers.range = 'bytes=0-0'
|
|
187
|
+
if (dispatcher !== undefined) init.dispatcher = dispatcher
|
|
188
|
+
const res = await request(target, init)
|
|
189
|
+
return { res }
|
|
190
|
+
} catch (error) {
|
|
191
|
+
return { error }
|
|
192
|
+
} finally {
|
|
193
|
+
clearTimeout(timer)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const head = await attempt('HEAD')
|
|
197
|
+
if (head.error !== undefined) return classifyTargetFailure(head.error)
|
|
198
|
+
const verdict = classifyProbeStatus(head.res.status)
|
|
199
|
+
if (verdict.kind !== 'http-error' || (head.res.status !== 405 && head.res.status !== 501)) return verdict
|
|
200
|
+
const get = await attempt('GET')
|
|
201
|
+
if (get.error !== undefined) return classifyTargetFailure(get.error)
|
|
202
|
+
const retried = classifyProbeStatus(get.res.status)
|
|
203
|
+
retried.note = retried.ok === true
|
|
204
|
+
? 'HEAD was rejected but GET works — the host is usable.'
|
|
205
|
+
: 'HEAD was rejected; the GET retry hit the same class of problem.'
|
|
206
|
+
return retried
|
|
207
|
+
}
|
|
208
|
+
|
|
176
209
|
/**
|
|
177
210
|
* Force the probe down one specific channel, bypassing the routing policy:
|
|
178
211
|
* `'proxy'` goes through the configured proxy unconditionally, `'direct'`
|
|
@@ -182,7 +215,9 @@ function getUndici() {
|
|
|
182
215
|
async function forceChannelProbe(url, channel, proxyUrl) {
|
|
183
216
|
const und = await getUndici()
|
|
184
217
|
if (und === null) {
|
|
185
|
-
return { ok: false, kind: 'unavailable', short: 'forced channel unavailable (undici could not be loaded)'
|
|
218
|
+
return { ok: false, verdict: 'unusable', kind: 'unavailable', short: 'forced channel unavailable (undici could not be loaded)',
|
|
219
|
+
why: 'The app-side undici package could not be imported by the host process.',
|
|
220
|
+
fix: 'Uncheck 使用代理 to use the policy channel, or restart DSH; report the plugin version if it persists.' }
|
|
186
221
|
}
|
|
187
222
|
let dispatcher
|
|
188
223
|
try {
|
|
@@ -190,36 +225,26 @@ async function forceChannelProbe(url, channel, proxyUrl) {
|
|
|
190
225
|
? new und.Agent({ connections: 1 })
|
|
191
226
|
: new und.ProxyAgent(proxyUrl)
|
|
192
227
|
} catch (error) {
|
|
193
|
-
return { ok: false, kind: 'bad-proxy', short: `invalid proxy address: ${String(error?.message ?? error)}
|
|
228
|
+
return { ok: false, verdict: 'unusable', kind: 'bad-proxy', short: `invalid proxy address: ${String(error?.message ?? error)}`,
|
|
229
|
+
why: 'The effective proxy address is not a valid http(s) proxy URL.',
|
|
230
|
+
fix: 'Fix the address in 代理配置 (mode = 自定义地址) or clear the custom URL.' }
|
|
194
231
|
}
|
|
195
|
-
const controller = new AbortController()
|
|
196
|
-
const timer = setTimeout(() => controller.abort(), 6000)
|
|
197
232
|
try {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
// 407 is an authentication failure, not "reachable": reporting it as ok
|
|
206
|
-
// made the panel print 「可达(HTTP 407)」 and silenced the keepalive hint
|
|
207
|
-
// (review fix, 2026-09-17).
|
|
208
|
-
if (res.status === 407) {
|
|
209
|
-
return { ok: false, kind: 'auth', short: 'HTTP 407 — the proxy asks for authentication (NTLM), which undici cannot negotiate' }
|
|
210
|
-
}
|
|
211
|
-
return { ok: true, status: res.status }
|
|
212
|
-
} catch (error) {
|
|
213
|
-
return classifyTargetFailure(error)
|
|
233
|
+
// MUST issue the request with THIS undici copy. The host runs two copies —
|
|
234
|
+
// Node's built-in `fetch` and the app's `undici` package — and a dispatcher
|
|
235
|
+
// built by one handed to the other rejects with UND_ERR_INVALID_ARG
|
|
236
|
+
// ("invalid onRequestStart method"); the panel rendered that as a bare
|
|
237
|
+
// "fetch failed" for BOTH forced channels (LESSONS §22). und.fetch and the
|
|
238
|
+
// Agent/ProxyAgent above come from the same import, so they always agree.
|
|
239
|
+
return await probeHttp((u, i) => und.fetch(u, i), url, dispatcher)
|
|
214
240
|
} finally {
|
|
215
|
-
clearTimeout(timer)
|
|
216
241
|
try { dispatcher.close?.().catch?.(() => {}) } catch { /* best effort */ }
|
|
217
242
|
}
|
|
218
243
|
}
|
|
219
244
|
|
|
220
245
|
/**
|
|
221
246
|
* Probe one target the way web_fetch will see it: route verdict first, then a
|
|
222
|
-
* real
|
|
247
|
+
* real request through the installed policy (global fetch → policy dispatcher).
|
|
223
248
|
* `channel` selects the probe path: 'policy' (default), 'proxy' (forced via
|
|
224
249
|
* proxy), or 'direct' (forced bypass).
|
|
225
250
|
*/
|
|
@@ -228,7 +253,9 @@ async function probeTarget(url, snapshot, channel = 'policy') {
|
|
|
228
253
|
try {
|
|
229
254
|
parsed = new URL(url)
|
|
230
255
|
} catch {
|
|
231
|
-
return { url, route: 'invalid', bypassed: false,
|
|
256
|
+
return { url, route: 'invalid', bypassed: false,
|
|
257
|
+
probe: { ok: false, verdict: 'unusable', kind: 'invalid', short: 'invalid URL — expected http(s)://host/path',
|
|
258
|
+
why: 'The text is not a parseable absolute URL.', fix: 'Use the full form, e.g. https://github.com/foo/bar.' } }
|
|
232
259
|
}
|
|
233
260
|
let routeName = 'DIRECT'
|
|
234
261
|
try {
|
|
@@ -245,27 +272,14 @@ async function probeTarget(url, snapshot, channel = 'policy') {
|
|
|
245
272
|
if (channel === 'proxy' || channel === 'direct') {
|
|
246
273
|
const proxyUrl = channel === 'proxy' ? (snapshot?.effective?.url ?? '') : ''
|
|
247
274
|
const probe = proxyUrl === '' && channel === 'proxy'
|
|
248
|
-
? { ok: false, kind: 'bad-proxy', short: 'no active proxy url in the current snapshot'
|
|
275
|
+
? { ok: false, verdict: 'unusable', kind: 'bad-proxy', short: 'no active proxy url in the current snapshot',
|
|
276
|
+
why: 'The proxy switch is off (or the Windows system proxy is off), so there is nothing to force traffic through.',
|
|
277
|
+
fix: 'Turn the proxy on, or fix the address/mode in 代理配置.' }
|
|
249
278
|
: await forceChannelProbe(parsed, channel, proxyUrl)
|
|
250
279
|
return { url, route: routeName, bypassed, channel, probe }
|
|
251
280
|
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
const timer = setTimeout(() => controller.abort(), 6000)
|
|
255
|
-
const res = await fetch(parsed, {
|
|
256
|
-
method: 'HEAD',
|
|
257
|
-
redirect: 'manual',
|
|
258
|
-
signal: controller.signal,
|
|
259
|
-
headers: { 'user-agent': 'dsh-proxy-pro/0.1' },
|
|
260
|
-
})
|
|
261
|
-
clearTimeout(timer)
|
|
262
|
-
if (res.status === 407) {
|
|
263
|
-
return { url, route: routeName, bypassed, probe: { ok: false, kind: 'auth', short: 'HTTP 407 — the proxy asks for authentication (NTLM), which undici cannot negotiate' } }
|
|
264
|
-
}
|
|
265
|
-
return { url, route: routeName, bypassed, probe: { ok: true, status: res.status } }
|
|
266
|
-
} catch (error) {
|
|
267
|
-
return { url, route: routeName, bypassed, probe: classifyTargetFailure(error) }
|
|
268
|
-
}
|
|
281
|
+
const probe = await probeHttp((u, i) => fetch(u, i), parsed, undefined)
|
|
282
|
+
return { url, route: routeName, bypassed, probe }
|
|
269
283
|
}
|
|
270
284
|
|
|
271
285
|
// ---- browser-trust fence (mirrors dsh's /api fence) ------------------------
|
|
@@ -489,11 +503,18 @@ function apply(ctx, config) {
|
|
|
489
503
|
output: {
|
|
490
504
|
schema: PROXY_TEST_SCHEMA,
|
|
491
505
|
render: (_args, value) => {
|
|
506
|
+
const p = value.probe ?? {}
|
|
507
|
+
const head = p.verdict === 'usable'
|
|
508
|
+
? `usable${p.status === undefined ? '' : ` (HTTP ${p.status})`}`
|
|
509
|
+
: `${p.verdict === 'degraded' ? 'degraded' : 'unusable'} — ${p.short ?? 'unknown'}`
|
|
492
510
|
const lines = [
|
|
493
511
|
`Route: ${value.route}`,
|
|
494
512
|
`NO_PROXY bypassed: ${value.bypassed}`,
|
|
495
|
-
`Probe: ${
|
|
513
|
+
`Probe: ${head}`,
|
|
496
514
|
]
|
|
515
|
+
if (p.why) lines.push(`Why: ${p.why}`)
|
|
516
|
+
if (p.fix) lines.push(`Fix: ${p.fix}`)
|
|
517
|
+
if (p.note) lines.push(`Note: ${p.note}`)
|
|
497
518
|
if (value.hint) lines.push(`Hint: ${value.hint}`)
|
|
498
519
|
return [{ type: 'text', text: lines.join('\n') }]
|
|
499
520
|
},
|
package/lib/proxy-core.js
CHANGED
|
@@ -266,3 +266,138 @@ 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
|
+
* Three-state verdict for one HTTP answer, shared by the panel, `proxy_test`
|
|
272
|
+
* and the HTTP API:
|
|
273
|
+
*
|
|
274
|
+
* - `usable` — the route works for real calls (2xx/3xx, plus 401: a healthy
|
|
275
|
+
* API endpoint answering the credential-less probe).
|
|
276
|
+
* - `degraded` — an HTTP answer came back, so the route works, but the call
|
|
277
|
+
* itself would likely fail (403/404/405/429 and other 4xx).
|
|
278
|
+
* - `unusable` — the route or the far end failed (407 auth wall, 5xx from the
|
|
279
|
+
* target, gateway/upstream timeouts).
|
|
280
|
+
*
|
|
281
|
+
* Every verdict carries `why` (the likely cause) and `fix` (what to try), so a
|
|
282
|
+
* diagnosis is actionable instead of a bare status code (2026-09-17: 504 and
|
|
283
|
+
* 200 both rendered as "reachable", which answered nothing).
|
|
284
|
+
*
|
|
285
|
+
* @param status - the HTTP status code a probe received.
|
|
286
|
+
* @returns `{ ok, verdict, kind, status, short, why, fix }`.
|
|
287
|
+
*/
|
|
288
|
+
export function classifyProbeStatus(status) {
|
|
289
|
+
const code = Number(status)
|
|
290
|
+
const base = { status: code }
|
|
291
|
+
if (code === 407) {
|
|
292
|
+
return { ...base, ok: false, verdict: 'unusable', kind: 'auth', short: 'HTTP 407 — the proxy asks for authentication (NTLM)',
|
|
293
|
+
why: 'The request never reached the target: the proxy demands NTLM, which undici cannot negotiate.',
|
|
294
|
+
fix: 'Run one curl/browser request through the same proxy (e.g. curl -x <proxy> -I https://github.com) to open an authenticated window, then retry.' }
|
|
295
|
+
}
|
|
296
|
+
if (code >= 200 && code < 400) {
|
|
297
|
+
return { ...base, ok: true, verdict: 'usable', kind: 'ok', short: `HTTP ${code} — the target answered`, why: '', fix: '' }
|
|
298
|
+
}
|
|
299
|
+
if (code === 401) {
|
|
300
|
+
return { ...base, ok: true, verdict: 'usable', kind: 'ok', short: 'HTTP 401 — the target is alive (credentials required)',
|
|
301
|
+
why: 'The probe sends no credentials, so 401 is the healthy answer from an API endpoint.',
|
|
302
|
+
fix: 'Real calls need a valid key/header; the route itself is proven to work.' }
|
|
303
|
+
}
|
|
304
|
+
if (code === 403) {
|
|
305
|
+
return { ...base, ok: false, verdict: 'degraded', kind: 'http-error', short: 'HTTP 403 — the target refused the request',
|
|
306
|
+
why: 'The server was reached but denies this client: key scope, IP allow-list, WAF, or a blocked probe User-Agent.',
|
|
307
|
+
fix: 'Compare the other channel (direct vs proxy) and check the key/UA rules for this host.' }
|
|
308
|
+
}
|
|
309
|
+
if (code === 404) {
|
|
310
|
+
return { ...base, ok: false, verdict: 'degraded', kind: 'http-error', short: 'HTTP 404 — path not found',
|
|
311
|
+
why: 'The route reached the server; that path does not exist there (probes often hit the bare origin).',
|
|
312
|
+
fix: 'Re-test with the concrete path you really call; if that works, ignore this result.' }
|
|
313
|
+
}
|
|
314
|
+
if (code === 405 || code === 501) {
|
|
315
|
+
return { ...base, ok: false, verdict: 'degraded', kind: 'http-error', short: `HTTP ${code} — the endpoint does not accept HEAD`,
|
|
316
|
+
why: 'The diagnostic probes with HEAD; some APIs and gateways only implement GET.',
|
|
317
|
+
fix: 'The panel retries with GET automatically; a GET success means the host is usable.' }
|
|
318
|
+
}
|
|
319
|
+
if (code === 429) {
|
|
320
|
+
return { ...base, ok: false, verdict: 'degraded', kind: 'http-error', short: 'HTTP 429 — rate limited',
|
|
321
|
+
why: 'The server is alive but throttling this client.',
|
|
322
|
+
fix: 'Retry later or lower the request rate; the route itself is fine.' }
|
|
323
|
+
}
|
|
324
|
+
if (code === 502) {
|
|
325
|
+
return { ...base, ok: false, verdict: 'unusable', kind: 'gateway', short: 'HTTP 502 — bad gateway',
|
|
326
|
+
why: 'An intermediary (the proxy or the target gateway) got an invalid answer from upstream — often the proxy cannot reach this host.',
|
|
327
|
+
fix: 'Compare the other channel (direct vs proxy); if the proxy is the failing leg, keep this host on the working side of NO_PROXY.' }
|
|
328
|
+
}
|
|
329
|
+
if (code === 503) {
|
|
330
|
+
return { ...base, ok: false, verdict: 'unusable', kind: 'gateway', short: 'HTTP 503 — service unavailable',
|
|
331
|
+
why: 'The target (or its gateway) is overloaded or in maintenance.',
|
|
332
|
+
fix: 'Retry later; this is server-side and unrelated to the route.' }
|
|
333
|
+
}
|
|
334
|
+
if (code === 504) {
|
|
335
|
+
return { ...base, ok: false, verdict: 'unusable', kind: 'gateway', short: 'HTTP 504 — gateway/upstream timeout',
|
|
336
|
+
why: 'The request was forwarded but nothing answered upstream in time: the target is slow or dead, or the proxy leg to it is broken.',
|
|
337
|
+
fix: 'Compare the other channel (direct vs proxy) and retry; if only one channel times out, that channel is the problem.' }
|
|
338
|
+
}
|
|
339
|
+
if (code >= 500) {
|
|
340
|
+
return { ...base, ok: false, verdict: 'unusable', kind: 'server-error', short: `HTTP ${code} — the target failed`,
|
|
341
|
+
why: 'The server answered with an error of its own; the route is fine.',
|
|
342
|
+
fix: 'Retry later; nothing to change in the proxy configuration.' }
|
|
343
|
+
}
|
|
344
|
+
return { ...base, ok: false, verdict: 'degraded', kind: 'http-error', short: `HTTP ${code} — request rejected`,
|
|
345
|
+
why: 'The route works (an HTTP answer came back) but the request itself was not successful.',
|
|
346
|
+
fix: 'Check the method, path and headers your real calls use.' }
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Turn a fetch rejection into an actionable verdict. A bare `fetch failed`
|
|
351
|
+
* hides the real cause: a dispatcher built by one undici copy handed to another
|
|
352
|
+
* rejects with UND_ERR_INVALID_ARG ("invalid onRequestStart method"), which is
|
|
353
|
+
* how the forced-channel diagnostic failed for both channels (LESSONS §22).
|
|
354
|
+
*
|
|
355
|
+
* @param error - the rejection from `fetch`.
|
|
356
|
+
* @returns `{ ok, verdict, kind, short, why, fix }` with a cause-aware message.
|
|
357
|
+
*/
|
|
358
|
+
export function classifyTargetFailure(error) {
|
|
359
|
+
const cause = error?.cause
|
|
360
|
+
const code = cause?.code ?? error?.code ?? ''
|
|
361
|
+
const message = String(error?.message ?? error)
|
|
362
|
+
if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') {
|
|
363
|
+
return { ok: false, verdict: 'unusable', kind: 'unreachable', short: `DNS lookup failed (${code})`,
|
|
364
|
+
why: 'The hostname did not resolve — a wrong name/DNS, or a proxy that cannot resolve it.',
|
|
365
|
+
fix: 'Check the hostname; if this host must go through the proxy, remove it from NO_PROXY (or add it if it must go direct).' }
|
|
366
|
+
}
|
|
367
|
+
if (code === 'ECONNREFUSED') {
|
|
368
|
+
return { ok: false, verdict: 'unusable', kind: 'unreachable', short: 'connection refused (ECONNREFUSED)',
|
|
369
|
+
why: 'Something answered but refused the connection: nothing listens on that port, or the proxy is not accepting.',
|
|
370
|
+
fix: 'Check the port and that the proxy is running; compare the other channel.' }
|
|
371
|
+
}
|
|
372
|
+
if (code === 'EACCES' || code === 'ENETUNREACH' || code === 'EHOSTUNREACH') {
|
|
373
|
+
return { ok: false, verdict: 'unusable', kind: 'unreachable', short: `cannot reach directly (${code}) — needs a proxy route`,
|
|
374
|
+
why: 'The network path itself is unavailable from this machine.',
|
|
375
|
+
fix: 'Route this host through the proxy (remove the NO_PROXY entry) and retry.' }
|
|
376
|
+
}
|
|
377
|
+
if (code === 'ETIMEDOUT' || code === 'UND_ERR_CONNECT_TIMEOUT' || code === 'ABORT_ERR' || error?.name === 'AbortError') {
|
|
378
|
+
return { ok: false, verdict: 'unusable', kind: 'unreachable', short: `no answer before the timeout (${code || error?.name || 'timeout'})`,
|
|
379
|
+
why: 'Nothing answered in time — a slow/dead target, or a proxy that silently drops this destination.',
|
|
380
|
+
fix: 'Compare the other channel (direct vs proxy) and retry; if only one channel times out, that channel is the problem.' }
|
|
381
|
+
}
|
|
382
|
+
if (code === 'ECONNRESET' || code === 'UND_ERR_SOCKET' || code === 'EPIPE') {
|
|
383
|
+
return { ok: false, verdict: 'unusable', kind: 'unreachable', short: `connection reset (${code})`,
|
|
384
|
+
why: 'The connection was cut mid-flight by an intermediary or a TLS problem (corporate interception, MTU, or a reset by the far end).',
|
|
385
|
+
fix: 'Retry; compare the other channel; if TLS interception is in play, confirm the corporate CA is trusted (NODE_EXTRA_CA_CERTS).' }
|
|
386
|
+
}
|
|
387
|
+
if (code === 'UND_ERR_INVALID_ARG' || /invalid onRequestStart/i.test(String(cause?.message ?? ''))) {
|
|
388
|
+
return { ok: false, verdict: 'unusable', kind: 'dispatcher', short: `dispatcher rejected by this undici instance (${code || 'UND_ERR_INVALID_ARG'}) — mixed undici copies`,
|
|
389
|
+
why: 'The request and its dispatcher came from two different undici copies; the diagnostic had this bug (fixed in 1.0.2).',
|
|
390
|
+
fix: 'Update the plugin; if it persists, report the plugin version with this message.' }
|
|
391
|
+
}
|
|
392
|
+
if (/407|NTLM|authentication required/i.test(message)) {
|
|
393
|
+
return { ok: false, verdict: 'unusable', kind: 'auth', short: 'the proxy asks for NTLM authentication',
|
|
394
|
+
why: 'The proxy demands NTLM, which undici cannot negotiate by itself.',
|
|
395
|
+
fix: 'Run one curl/browser request through the same proxy to open an authenticated window, then retry.' }
|
|
396
|
+
}
|
|
397
|
+
const detail = cause?.code
|
|
398
|
+
? `${cause.code}${cause?.message ? `: ${String(cause.message).slice(0, 90)}` : ''}`
|
|
399
|
+
: (cause?.message ?? '')
|
|
400
|
+
return { ok: false, verdict: 'unusable', kind: 'other', short: (detail ? `${message} (${detail})` : message).slice(0, 180),
|
|
401
|
+
why: 'The request failed for a reason the probe could not classify.',
|
|
402
|
+
fix: 'Compare the other channel (direct vs proxy) and retry; check proxy_status for the effective route.' }
|
|
403
|
+
}
|
package/lib/tool-schemas.js
CHANGED
|
@@ -68,9 +68,17 @@ export const PROXY_TEST_SCHEMA = {
|
|
|
68
68
|
required: true,
|
|
69
69
|
properties: {
|
|
70
70
|
ok: { type: 'boolean', required: true },
|
|
71
|
+
// Three-state verdict plus the actionable pair the panel and the tool
|
|
72
|
+
// render (2026-09-17: a 504 used to print as "reachable"). Every field
|
|
73
|
+
// must be listed here — the object is additionalProperties:false, so an
|
|
74
|
+
// undeclared field fails schema validation.
|
|
75
|
+
verdict: { type: 'string' },
|
|
71
76
|
status: { type: 'number' },
|
|
72
77
|
kind: { type: 'string' },
|
|
73
78
|
short: { type: 'string' },
|
|
79
|
+
why: { type: 'string' },
|
|
80
|
+
fix: { type: 'string' },
|
|
81
|
+
note: { type: 'string' },
|
|
74
82
|
},
|
|
75
83
|
},
|
|
76
84
|
hint: { type: 'string' },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yanglaofish/dsh-proxy-pro",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
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",
|