@yanglaofish/dsh-proxy-pro 1.0.7 → 1.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/LESSONS.md CHANGED
@@ -780,4 +780,38 @@ API 端点就这样被误判。**404 从来不是「网络不可达」的证据*
780
780
 
781
781
  **教训**:① 探测方法本身就是误判来源——HEAD「轻量」不等于「等价」;② 把 404 当「不存在」
782
782
  之前先问「服务器认不认这个方法」;③ 状态码是二手的,链路层结论只能由「是否拿到 HTTP
783
- 应答」给出。
783
+ 应答」给出。
784
+
785
+ ---
786
+
787
+ ## 28. 发布铁律:每次 npm publish 后必须主动强制触发淘宝源同步(用户定案,2026-09-18)
788
+
789
+ **用户的规矩(说了两次,不可违背)**:每次我们更新 npm 包(publish 成功)后,**必须手动
790
+ 主动触发淘宝源(npmmirror)更新,不许等待**——用户原话「每次更新了 npm,都请你手动强制
791
+ 触发淘宝源更新」「不要等待,就是走淘宝的主动同步」。
792
+
793
+ **为什么**:npmmirror 自己拉取官方源有队列延迟,干等(只 PUT 一次 syncs 然后 poll)会 stuck
794
+ 几分钟甚至更久,导致 `dsh plugin add <新版本>` 在淘宝源上解析不到。**主动循环触发**(每轮
795
+ 先 PUT syncs、再 poll)实测 1.0.5=11 轮、1.0.6=13 轮、1.0.7=33 轮(约 2-4 分钟)必定拉到。
796
+
797
+ **标准流程(每一步都要做)**:
798
+ 1. `npm publish --access=public --registry=https://registry.npmjs.org --replace-registry-host=never` 成功;
799
+ 2. 循环最多 60 轮:`PUT https://registry.npmmirror.com/-/package/@yanglaofish%2Fdsh-proxy-pro/syncs`
800
+ 带 body `{"version":"<新版本>"}`(ContentType application/json),随后
801
+ `GET https://registry.npmmirror.com/@yanglaofish%2Fdsh-proxy-pro` 看 `dist-tags.latest`;
802
+ 每轮间隔 8-10s,**一直循环到 latest == 新版本**(不许提前放弃);
803
+ 3. latest 到位后立即:`dsh plugin --profile test add '@yanglaofish/dsh-proxy-pro@^<新版本>'`
804
+ 与 `dsh plugin --profile web add ...`,并验证两个 profile 的 node_modules 版本。
805
+
806
+ **教训**:① 「PUT syncs 返回 201」只是入队确认,不是同步完成——以 `dist-tags.latest` 为准;
807
+ ② 官方 registry 先可见不代表淘宝可见,淘宝拉取是独立队列;③ 用户规则优先于"省事"——主动
808
+ 触发是默认动作,不是可选优化。
809
+
810
+ **补充:git push 的代理必须与「被暖窗的代理」一致(2026-09-18 实测)**:git 全局配置里写死的
811
+ `http.proxy=http://proxyhk.huawei.com:8080` 是历史遗留——当前网络环境该代理已 407 失效,
812
+ 而系统代理是 `proxyza`(南非)。正确姿势:push 前用 `Invoke-WebRequest https://github.com`
813
+ 暖**当前系统代理**(.NET 自动 NTLM),然后 `git -c http.proxy=http://<当前系统代理> push`
814
+ (用 `-c` 临时覆盖写死的 config)。若暖窗对象与 git 实际走的代理不一致,必然 407 CONNECT
815
+ tunnel failed(proxyhk 暖窗 407、proxyza 暖窗 200 → 只有后者能推成功)。长期可选:
816
+ `git config --global --unset http.proxy` 让 git 走插件写入的 `http_proxy` 环境变量(动态跟随
817
+ 当前系统代理),注意 CLI 环境无插件时需手动 `-c` 指定。
package/lib/client.js CHANGED
@@ -74,9 +74,15 @@ window.__ModuleLoader__.load({
74
74
  ".dsxprx-diagwhy{font-size:12px;line-height:17px;color:var(--dsw-alias-label-secondary)}",
75
75
  ".dsxprx-diagfix{font-size:12px;line-height:17px;color:var(--dsw-alias-label-primary)}",
76
76
  ".dsxprx-diagnote{font-size:11px;line-height:16px;color:var(--dsw-alias-label-caption)}",
77
+ ".dsxprx-verdict{display:inline-flex;align-items:center;gap:6px;padding:2px 10px;border-radius:999px;font-weight:600;font-size:12px;line-height:18px}",
78
+ ".dsxprx-verdict[data-tone=ok]{background:rgba(16,185,129,.12);color:var(--dsw-alias-state-success-primary,#10b981)}",
79
+ ".dsxprx-verdict[data-tone=warn]{background:rgba(245,158,11,.14);color:#f59e0b}",
80
+ ".dsxprx-verdict[data-tone=bad]{background:rgba(239,68,68,.12);color:var(--dsw-alias-state-error-primary,#ef4444)}",
81
+ ".dsxprx-diagprobe{display:flex;align-items:center;gap:8px;flex-wrap:wrap}",
82
+ ".dsxprx-diotag{flex:none;min-width:44px;font-size:12px;line-height:17px;color:var(--dsw-alias-label-caption)}",
83
+ ".dsxprx-verdictlarge{font-size:13px;font-weight:600;line-height:19px;color:var(--dsw-alias-label-primary)}",
84
+ ".dsxprx-diagverdict{display:flex;flex-direction:column;gap:3px;padding:6px 0 2px;border-top:1px dashed var(--dsw-alias-border-l1)}",
77
85
  ".dsxprx-title{font-size:16px;font-weight:700;line-height:22px;color:var(--dsw-alias-label-primary)}",
78
- ".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}",
79
- ".dsxprx-diagcheck input{width:14px;height:14px;accent-color:#4d6bfe;margin:0}",
80
86
  ".dsxprx-hdr{display:flex;align-items:center;gap:6px;max-width:180px;height:26px;padding:0 10px;border:1px solid var(--dsw-alias-border-l1);border-radius:999px;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;line-height:16px;cursor:pointer;white-space:nowrap;overflow:hidden}",
81
87
  ".dsxprx-hdr:hover{background:var(--dsw-alias-state-hover)}",
82
88
  ".dsxprx-hdr .dsxprx-dot{width:8px;height:8px}",
@@ -296,26 +302,31 @@ window.__ModuleLoader__.load({
296
302
  });
297
303
  };
298
304
  }
299
- var diagUseProxy = useState(true);
300
- var setDiagUseProxy = diagUseProxy[1];
301
- /** Three-state headline for one probe verdict (usable / degraded / unusable). */
302
- function probeHeadline(probe) {
303
- if (!probe) return "未知";
304
- if (probe.verdict === "usable") return "可用" + (probe.status === undefined ? "" : "(HTTP " + probe.status + ")");
305
- if (probe.verdict === "degraded") return "降级:" + (probe.short || "未知");
306
- if (probe.verdict === "unusable") return "不可用:" + (probe.short || "未知");
307
- return probe.ok ? "可用" : (probe.short || "未知");
308
- }
305
+ // 诊断双通道并行(走代理 + 直连),由 host 一次给出两侧徽章和推荐,
306
+ // 不再让用户手动勾选通道(2026-09-18:单侧探针分不清被拒者是谁)
309
307
  function runDiag() {
310
308
  var target = diagUrl.trim();
311
309
  if (!target) return;
312
310
  setDiagBusy(true);
313
311
  setDiagResult(null);
314
- fetchJson("/route?url=" + encodeURIComponent(target) + "&useProxy=" + (diagUseProxy[0] ? "1" : "0")).then(function (data) {
312
+ fetchJson("/route?url=" + encodeURIComponent(target)).then(function (data) {
315
313
  setDiagResult(data === null ? { error: "诊断接口不可用" } : data);
316
314
  }).finally(function () { setDiagBusy(false); });
317
315
  }
318
316
  var diag = diagResult[0];
317
+ /** 通道徽章:优先用 host 的 proxyBadge/directBadge(人话+色),缺失时回退旧判定。 */
318
+ function channelBadge(name) {
319
+ var b = diag && typeof diag[name] === "object" && diag[name] !== null ? diag[name] : null;
320
+ if (b && b.text && b.tone) return b;
321
+ var fallback = name === "proxyBadge" ? diag?.proxy : diag?.direct;
322
+ if (!fallback) return { text: "未知", tone: "warn" };
323
+ if (fallback.verdict === "usable") return { text: "链路通 · 目标正常响应", tone: "ok" };
324
+ if (fallback.verdict === "degraded") return { text: "链路通 · 这次请求被目标拒绝", tone: "warn" };
325
+ // 5xx:拿到了 HTTP 应答,链路通但服务端/网关报错(严重性误导修复:不算"链路不通")
326
+ if (fallback.kind === "gateway" || fallback.kind === "server-error") return { text: "链路通 · 目标/网关报错", tone: "warn" };
327
+ if (fallback.unusable === true || fallback.verdict === "unusable") return { text: "链路不通 · 网络/代理问题", tone: "bad" };
328
+ return fallback.ok ? { text: "链路通", tone: "ok" } : { text: "链路不通", tone: "bad" };
329
+ }
319
330
 
320
331
  return react.createElement("div", { className: "dsxprx", "data-proxy-settings": true },
321
332
 
@@ -402,27 +413,29 @@ window.__ModuleLoader__.load({
402
413
  onChange: function (event) { setDiagUrl(event.target.value); },
403
414
  onKeyDown: function (event) { if (event.key === "Enter") runDiag(); },
404
415
  }),
405
- react.createElement("label", { className: "dsxprx-diagcheck" },
406
- react.createElement("input", {
407
- type: "checkbox",
408
- checked: diagUseProxy[0],
409
- onChange: function (event) { setDiagUseProxy(event.target.checked); },
410
- }),
411
- "使用代理"),
412
416
  react.createElement("button", { type: "button", className: "dsxprx-diagbtn", onClick: runDiag, disabled: diagBusy[0] || !diagUrl.trim() }, "测试路由")
413
417
  ),
414
- react.createElement("span", { className: "dsxprx-hint" }, "勾选「使用代理」= 强制走代理测通不通;不勾 = 强制直连测。对比可判断该域名该不该加进 NO_PROXY。"),
418
+ react.createElement("span", { className: "dsxprx-hint" }, "同时测「走代理」与「直连」两条通道并给出推荐,可判断该域名该不该加进 NO_PROXY。"),
415
419
  diag !== null
416
420
  ? react.createElement("div", { className: "dsxprx-diagout" },
417
421
  diag.error
418
422
  ? react.createElement("span", null, "诊断失败:" + diag.error)
419
423
  : [
420
- react.createElement("span", { key: "c" }, "测试通道: " + (diag.channel === "proxy" ? "经代理(强制)" : diag.channel === "direct" ? "直连(强制绕过)" : "按当前策略")),
421
424
  react.createElement("span", { key: "r" }, "策略判定: " + diag.route + (diag.bypassed ? "(NO_PROXY 命中)" : "")),
422
- react.createElement("span", { key: "p" }, "连通探测: " + probeHeadline(diag.probe)),
423
- diag.probe && diag.probe.why ? react.createElement("span", { key: "w", className: "dsxprx-diagwhy" }, "可能原因: " + diag.probe.why) : null,
424
- diag.probe && diag.probe.fix ? react.createElement("span", { key: "f", className: "dsxprx-diagfix" }, "挽救措施: " + diag.probe.fix) : null,
425
- diag.probe && diag.probe.note ? react.createElement("span", { key: "n", className: "dsxprx-diagnote" }, "备注: " + diag.probe.note) : null,
425
+ react.createElement("span", { key: "px", className: "dsxprx-diagprobe" },
426
+ react.createElement("span", { className: "dsxprx-diotag" }, "走代理"),
427
+ react.createElement("span", { className: "dsxprx-verdict", "data-tone": channelBadge("proxyBadge").tone }, channelBadge("proxyBadge").text)
428
+ ),
429
+ react.createElement("span", { key: "dx", className: "dsxprx-diagprobe" },
430
+ react.createElement("span", { className: "dsxprx-diotag" }, "直连"),
431
+ react.createElement("span", { className: "dsxprx-verdict", "data-tone": channelBadge("directBadge").tone }, channelBadge("directBadge").text)
432
+ ),
433
+ diag.verdict ? react.createElement("span", { key: "v", className: "dsxprx-diagverdict" },
434
+ react.createElement("span", { className: "dsxprx-verdictlarge" }, "推荐: " + (diag.short || "未知")),
435
+ diag.why ? react.createElement("span", { className: "dsxprx-diagwhy" }, "可能原因: " + diag.why) : null,
436
+ diag.fix ? react.createElement("span", { className: "dsxprx-diagfix" }, "挽救措施: " + diag.fix) : null
437
+ ) : null,
438
+ diag.proxy && diag.proxy.note ? react.createElement("span", { key: "n", className: "dsxprx-diagnote" }, "备注: " + diag.proxy.note) : null,
426
439
  diag.hint ? react.createElement("span", { key: "h" }, "提示: " + diag.hint) : null,
427
440
  ]
428
441
  )
package/lib/index.js CHANGED
@@ -32,6 +32,7 @@ import { promisify } from 'node:util'
32
32
  import {
33
33
  classifyProbeStatus,
34
34
  classifyTargetFailure,
35
+ combineDualProbe,
35
36
  headNeedsGetRetry,
36
37
  keepaliveHint,
37
38
  makeSystemProxyReader,
@@ -267,18 +268,25 @@ async function policyProbe(target) {
267
268
  }
268
269
 
269
270
  /**
270
- * 按 web_fetch 的方式探测一个目标:先给路由判定,再用策略通道发真实请求
271
- * (app undici 的 fetch + 全局 dispatcher,见 policyProbe)。`channel` 选择
272
- * 探测路径:'policy'(默认)、'proxy'(强制走代理)、'direct'(强制绕过)。
271
+ * 按 web_fetch 的方式探测一个目标,**同时**强制走代理 + 强制直连两个通道,
272
+ * 再把两侧结果综合成一条人话推荐(combineDualProbe)。
273
+ *
274
+ * 单侧探测无法判断被拒是谁干的(目标 or 代理?)——只有两侧对比才能得出
275
+ * 方向性结论(直连通+代理失败 → 问题在代理一侧;代理通+直连失败 → 代理是
276
+ * 唯一通路)。`channel` 保留兼容:'policy'/'proxy'/'direct' 仍是旧的单通道
277
+ * 探测(HTTP API 的历史 useProxy 参数使用),默认 'dual' 走双通道并行。
273
278
  */
274
- async function probeTarget(url, snapshot, channel = 'policy') {
279
+ async function probeTarget(url, snapshot, channel = 'dual') {
275
280
  let parsed
276
281
  try {
277
282
  parsed = new URL(url)
278
283
  } catch {
279
- return { url, route: 'invalid', bypassed: false,
280
- probe: { ok: false, verdict: 'unusable', kind: 'invalid', short: 'URL 无效 — 需要 http(s)://主机/路径',
281
- why: '输入不是可解析的绝对 URL。', fix: '用完整形式,如 https://github.com/foo/bar。' } }
284
+ return { url, route: 'invalid', bypassed: false, channel,
285
+ proxy: { ok: false, verdict: 'unusable', kind: 'invalid', short: 'URL 无效 — 需要 http(s)://主机/路径',
286
+ why: '输入不是可解析的绝对 URL。', fix: '用完整形式,如 https://github.com/foo/bar。' },
287
+ direct: { ok: false, verdict: 'unusable', kind: 'invalid', short: 'URL 无效 — 需要 http(s)://主机/路径',
288
+ why: '输入不是可解析的绝对 URL。', fix: '用完整形式,如 https://github.com/foo/bar。' },
289
+ verdict: 'both-bad', short: 'URL 无效', why: '输入不是可解析的绝对 URL。', fix: '用完整形式,如 https://github.com/foo/bar。' }
282
290
  }
283
291
  let routeName = 'DIRECT'
284
292
  try {
@@ -292,6 +300,8 @@ async function probeTarget(url, snapshot, channel = 'policy') {
292
300
  const host = entry.trim().replace(/^\*?\./, '').toLowerCase()
293
301
  return host.length > 0 && (host === parsed.hostname.toLowerCase() || parsed.hostname.toLowerCase().endsWith(`.${host}`))
294
302
  })
303
+
304
+ // 兼容旧单通道调用(HTTP API 历史 useProxy 参数)
295
305
  if (channel === 'proxy' || channel === 'direct') {
296
306
  const proxyUrl = channel === 'proxy' ? (snapshot?.effective?.url ?? '') : ''
297
307
  const probe = proxyUrl === '' && channel === 'proxy'
@@ -301,8 +311,27 @@ async function probeTarget(url, snapshot, channel = 'policy') {
301
311
  : await forceChannelProbe(parsed, channel, proxyUrl)
302
312
  return { url, route: routeName, bypassed, channel, probe }
303
313
  }
304
- const probe = await policyProbe(parsed)
305
- return { url, route: routeName, bypassed, probe }
314
+ if (channel === 'policy') {
315
+ const probe = await policyProbe(parsed)
316
+ return { url, route: routeName, bypassed, channel, probe }
317
+ }
318
+
319
+ // 双通道并行:强制走代理 + 强制直连,各自独立探测(互不干扰)
320
+ const proxyUrl = snapshot?.effective?.url ?? ''
321
+ const proxyProbe = proxyUrl === ''
322
+ ? { ok: false, verdict: 'unusable', kind: 'bad-proxy', short: '当前快照里没有可用的代理地址',
323
+ why: '代理开关处于关闭(或 Windows 系统代理关闭),没有可强制走代理的对象。',
324
+ fix: '打开代理,或在「代理配置」里修复地址/模式。' }
325
+ : await forceChannelProbe(parsed, 'proxy', proxyUrl)
326
+ const directProbe = await forceChannelProbe(parsed, 'direct', '')
327
+ const combined = combineDualProbe(proxyProbe, directProbe)
328
+ return {
329
+ url, route: routeName, bypassed, channel: 'dual',
330
+ proxy: proxyProbe, direct: directProbe,
331
+ verdict: combined.verdict, short: combined.short,
332
+ why: combined.why, fix: combined.fix,
333
+ proxyBadge: combined.proxyBadge, directBadge: combined.directBadge,
334
+ }
306
335
  }
307
336
 
308
337
  // ---- browser-trust fence (mirrors dsh's /api fence) ------------------------
@@ -532,18 +561,16 @@ function apply(ctx, config) {
532
561
  output: {
533
562
  schema: PROXY_TEST_SCHEMA,
534
563
  render: (_args, value) => {
535
- const p = value.probe ?? {}
536
- const head = p.verdict === 'usable'
537
- ? `可用${p.status === undefined ? '' : `(HTTP ${p.status})`}`
538
- : `${p.verdict === 'degraded' ? '降级' : '不可用'} — ${p.short ?? '未知'}`
564
+ const badge = (b) => (b?.tone === 'ok' ? '🟢' : b?.tone === 'warn' ? '🟡' : '🔴') + ' ' + (b?.text ?? '未知')
539
565
  const lines = [
540
566
  `路由: ${value.route}`,
541
567
  `NO_PROXY 命中: ${value.bypassed}`,
542
- `连通探测: ${head}`,
568
+ `走代理: ${badge(value.proxyBadge)}`,
569
+ `直连: ${badge(value.directBadge)}`,
570
+ `结论: ${value.short ?? '未知'}`,
543
571
  ]
544
- if (p.why) lines.push(`可能原因: ${p.why}`)
545
- if (p.fix) lines.push(`挽救措施: ${p.fix}`)
546
- if (p.note) lines.push(`备注: ${p.note}`)
572
+ if (value.why) lines.push(`可能原因: ${value.why}`)
573
+ if (value.fix) lines.push(`挽救措施: ${value.fix}`)
547
574
  if (value.hint) lines.push(`提示: ${value.hint}`)
548
575
  return [{ type: 'text', text: lines.join('\n') }]
549
576
  },
@@ -551,7 +578,7 @@ function apply(ctx, config) {
551
578
  execute: async (args) => {
552
579
  await requestSync()
553
580
  const result = await probeTarget(String(args.url ?? ''), snapshot)
554
- result.hint = keepaliveHint(result.probe?.short ?? '', snapshot?.effective?.url ?? '')
581
+ result.hint = keepaliveHint(`${result.proxy?.short ?? ''} ${result.direct?.short ?? ''}`, snapshot?.effective?.url ?? '')
555
582
  return result
556
583
  },
557
584
  presentCall: (args) => ({
@@ -668,10 +695,14 @@ function apply(ctx, config) {
668
695
  }
669
696
  if (is('/route', 'GET')) {
670
697
  const target = url.searchParams.get('url') ?? ''
698
+ // useProxy 保留兼容:'1'/'0' 走旧的单通道强制;无参数(新版面板)走双通道
671
699
  const useProxy = url.searchParams.get('useProxy')
672
- const channel = useProxy === '1' ? 'proxy' : useProxy === '0' ? 'direct' : 'policy'
700
+ const channel = useProxy === '1' ? 'proxy' : useProxy === '0' ? 'direct' : 'dual'
673
701
  const result = await probeTarget(target, snapshot, channel)
674
- result.hint = keepaliveHint(result.probe?.short ?? '', snapshot?.effective?.url ?? '')
702
+ result.hint = keepaliveHint(
703
+ `${result.proxy?.short ?? ''} ${result.direct?.short ?? ''}`.trim(),
704
+ snapshot?.effective?.url ?? ''
705
+ )
675
706
  sendJson(res, 200, { ok: true, ...result })
676
707
  return
677
708
  }
package/lib/proxy-core.js CHANGED
@@ -416,3 +416,172 @@ export function classifyTargetFailure(error) {
416
416
  why: '请求失败,探测器无法归类原因。',
417
417
  fix: '对比直连/代理两侧并重试;用 proxy_status 查看生效路由。' }
418
418
  }
419
+
420
+ /**
421
+ * 单侧通道的徽章:按真实语义给色,杜绝"严重性误导"(LESSONS §32)。
422
+ * - usable(2xx/3xx/401)→ 🟢 链路通 · 目标正常响应
423
+ * - degraded(403/404/405/429 等被目标拒绝)→ 🟡 链路通 · 目标拒绝(…)
424
+ * - 5xx(500/502/503/504 拿到应答但目标/网关报错)→ 🟡 链路通 · 目标/网关报错(…)
425
+ * - 407 认证拦截 → 🔴 链路不通 · 代理要求认证
426
+ * - 其他不可达(DNS/拒绝/超时/本地错误)→ 🔴 链路不通 · …
427
+ * 关键区分:**只要拿到了 HTTP 应答(含 4xx/5xx)链路就是通的**,只有
428
+ * 连接层失败(未拿到应答)才是"链路不通"。404 曾因 ok=false 被误标红色
429
+ * 链路不通 → 截图"代理与直连均失败"误导(2026-09-18 用户实况)。
430
+ */
431
+ function dualBadge(probe) {
432
+ const detail = (s) => String(s ?? '').replace(/^HTTP \d+ — /, '')
433
+ if (!probe) return { text: '结果未知', tone: 'warn' }
434
+ if (probe.verdict === 'usable') return { text: '链路通 · 目标正常响应', tone: 'ok' }
435
+ if (probe.verdict === 'degraded') {
436
+ return { text: `链路通 · 目标拒绝(${detail(probe.short) || '请求被拒'})`, tone: 'warn' }
437
+ }
438
+ if (probe.kind === 'gateway' || probe.kind === 'server-error') {
439
+ return { text: `链路通 · 目标/网关报错(${detail(probe.short) || String(probe.status ?? '5xx')})`, tone: 'warn' }
440
+ }
441
+ if (probe.kind === 'auth') return { text: '链路不通 · 代理要求认证', tone: 'bad' }
442
+ return { text: `链路不通 · ${detail(probe.short) || '连接失败'}`, tone: 'bad' }
443
+ }
444
+
445
+ /**
446
+ * 双通道综合判定:把「强制走代理」与「强制直连」两次探测合成为一份可给人
447
+ * 看的结果:两条徽章(各自带 tone 色)+ 一条推荐。
448
+ *
449
+ * 三个概念分离(LESSONS §32 严重性误导修复):
450
+ * - 可达(reachable):通道真正拿到了目标应答——usable 或 degraded(4xx)。
451
+ * 5xx 属"链路通但目标/网关报错"(kind=gateway/server-error),不算可达
452
+ * 也不算不通,徽章黄色;407/连接层失败才是"不通"。
453
+ * - 健康(healthy):usbale,目标给了正常应答。
454
+ * - 徽章:由 dualBadge 按实际 verdict/kind 生成,绝不把 404/403 标红。
455
+ * 只有连接层失败才标红"链路不通"。
456
+ *
457
+ * 单侧探针拿不到应答者身份,因此「代理策略拦截」只能靠两侧对比得到方向性
458
+ * 结论(LESSONS §30:直连通+代理失败 → 问题在代理一侧;代理通+直连失败 →
459
+ * 代理是唯一通路;都失败 → 环境/服务端问题;都通 → 保持现状)。
460
+ *
461
+ * @param proxyProbe - 强制走代理通道的探测结果(classify 输出形态)。
462
+ * @param directProbe - 强制直连通道的探测结果。
463
+ * @returns `{ verdict, short, why, fix, proxyBadge, directBadge }`,
464
+ * badge = { text, tone: 'ok'|'warn'|'bad' }。
465
+ */
466
+ export function combineDualProbe(proxyProbe, directProbe) {
467
+ const p = proxyProbe ?? {}
468
+ const d = directProbe ?? {}
469
+ const pReach = p.verdict === 'usable' || p.verdict === 'degraded'
470
+ const dReach = d.verdict === 'usable' || d.verdict === 'degraded'
471
+ const pOk = p.verdict === 'usable'
472
+ const dOk = d.verdict === 'usable'
473
+ const proxyAuth = p.kind === 'auth'
474
+ || (p.verdict === 'unusable' && /NTLM|407/.test(String(p.short ?? '')))
475
+
476
+ const notReachable = (probe, label) => {
477
+ const detail = (probe?.short ?? '未知原因').replace(/^HTTP \d+ — /, '')
478
+ return `${label}:${detail}`
479
+ }
480
+
481
+ // ① 都健康 → 都可以(保持现状)
482
+ if (pOk && dOk) {
483
+ return {
484
+ verdict: 'both-ok',
485
+ short: '代理与直连均通',
486
+ why: '两条通道都拿到了正常应答,目标本身可用。',
487
+ fix: '保持当前策略即可;无需改 NO_PROXY。',
488
+ proxyBadge: { text: '链路通 · 目标正常响应', tone: 'ok' },
489
+ directBadge: { text: '链路通 · 目标正常响应', tone: 'ok' },
490
+ }
491
+ }
492
+
493
+ // ② 只有代理健康 → 走代理
494
+ if (pOk && !dOk) {
495
+ return {
496
+ verdict: 'proxy-only',
497
+ short: '只有走代理能通',
498
+ why: `直连 ${notReachable(d, '失败')};走代理正常——该目标在当前网络需经代理访问。`,
499
+ fix: '确认该域名不在 NO_PROXY 直连名单里;保持代理开启。',
500
+ proxyBadge: { text: '链路通 · 目标正常响应', tone: 'ok' },
501
+ directBadge: dualBadge(d),
502
+ }
503
+ }
504
+
505
+ // ③ 只有直连健康 → 问题在代理一侧(认证拦截 / 不可达 / 被目标拒绝)
506
+ if (!pOk && dOk) {
507
+ const pSide = proxyAuth
508
+ ? '代理要求 NTLM 认证(407),请求没到目标'
509
+ : p.verdict === 'degraded'
510
+ ? '经代理已到达目标,但目标拒绝了这次请求(403/404/429 等)'
511
+ : '经代理未能连接目标(可能是代理策略拦截、代理故障或链路异常)'
512
+ const base = proxyAuth
513
+ ? {
514
+ why: '代理要求 NTLM 认证(407),而直连正常——该目标不值得绕代理。',
515
+ fix: '该域名走代理会被认证卡住:把它加入 NO_PROXY 直连名单即可;无需打开认证窗口。',
516
+ }
517
+ : {
518
+ why: `${pSide};而直连正常——结论:该域名走直连更可靠。`,
519
+ fix: p.verdict === 'degraded'
520
+ ? '把该域名加入 NO_PROXY 直连名单即可绕开代理侧的目标拒绝;若确需走代理,检查 key/UA/权限。'
521
+ : '先重试排除临时故障;仍失败则检查代理可用性或联系管理员。若确认是策略拦截,把该域名加入 NO_PROXY 直连名单。',
522
+ }
523
+ return {
524
+ verdict: proxyAuth ? 'direct-only-auth' : 'direct-only',
525
+ short: proxyAuth ? '直连可通 · 代理需认证' : (p.verdict === 'degraded' ? '直连可通 · 代理侧被目标拒绝' : '直连可通 · 代理被拦'),
526
+ ...base,
527
+ proxyBadge: dualBadge(p),
528
+ directBadge: { text: '链路通 · 目标正常响应', tone: 'ok' },
529
+ }
530
+ }
531
+
532
+ // ④ 都不健康但都可达(双 4xx:链路通,目标对探测路径有异常应答)
533
+ if (pReach && dReach) {
534
+ const pNote = notReachable(p, '代理').replace(/^失败:/, '代理:')
535
+ const dNote = notReachable(d, '直连').replace(/^失败:/, '直连:')
536
+ return {
537
+ verdict: 'both-reached',
538
+ short: '双链路通 · 目标应答异常',
539
+ why: `两条通道都到达了目标,但目标返回异常状态:${pNote};${dNote}。`,
540
+ fix: '探测的是根路径/裸前缀,目标可能对 HEAD 或未授权请求如此应答——改用真实调用端点(如 /models、/chat/completions)复测,或检查该主机的 API Key。',
541
+ proxyBadge: dualBadge(p),
542
+ directBadge: dualBadge(d),
543
+ }
544
+ }
545
+
546
+ // ⑤ 只有代理可达(哪怕 404/403,也是唯一通路)——修复重点:
547
+ // 404 不再落入"双失败",恢复为代理是唯一通路(腾讯 Copilot 实况)
548
+ if (pReach && !dReach) {
549
+ const pNote = p.verdict === 'usable' ? '正常响应' : notReachable(p, '请求被拒').replace(/^请求被拒:/, '')
550
+ return {
551
+ verdict: 'proxy-only',
552
+ short: p.verdict === 'usable' ? '只有走代理能通' : '只有走代理可达 · 目标有异常应答',
553
+ why: `直连 ${notReachable(d, '失败')};经代理 ${pNote}——该域名在当前网络只有代理通道能到达目标。`,
554
+ fix: p.verdict === 'usable'
555
+ ? '确认该域名不在 NO_PROXY 直连名单里;保持代理开启。'
556
+ : '走代理链路是通的,但目标返回异常(尝试换真实端点复测);同时确认该域名不在 NO_PROXY 直连名单里。',
557
+ proxyBadge: dualBadge(p),
558
+ directBadge: dualBadge(d),
559
+ }
560
+ }
561
+
562
+ // ⑥ 只有直连可达(代理侧 4xx/5xx 或不可达)
563
+ if (dReach) {
564
+ return {
565
+ verdict: proxyAuth ? 'direct-only-auth' : 'direct-only',
566
+ short: proxyAuth ? '直连可达 · 代理需认证' : (d.verdict === 'usable' ? '直连可通 · 代理被拦' : '直连可达 · 代理侧异常'),
567
+ why: proxyAuth
568
+ ? '代理要求 NTLM 认证(407),而直连正常——该目标不值得绕代理。'
569
+ : `经代理 ${p.kind === 'gateway' || p.kind === 'server-error' ? '返回网关/服务端错误(链路本身通,问题在目标或中间网关)' : `失败(${notReachable(p, '').replace(/^:/, '')})`};直连 ${dOk ? '正常' : notReachable(d, '可达但目标异常')}。`,
570
+ fix: proxyAuth
571
+ ? '把该域名加入 NO_PROXY 直连名单即可。'
572
+ : '建议该域名走直连(加入 NO_PROXY);若代理侧是 4xx/5xx,也检查一下真实调用端点。',
573
+ proxyBadge: dualBadge(p),
574
+ directBadge: dualBadge(d),
575
+ }
576
+ }
577
+
578
+ // ⑦ 都不可达(连接层失败)→ 环境/服务端问题
579
+ return {
580
+ verdict: 'both-bad',
581
+ short: '代理与直连均失败',
582
+ why: `两条通道都没能建立到目标的连接:代理 ${notReachable(p, '失败')};直连 ${notReachable(d, '失败')}。`,
583
+ fix: '是环境或服务端问题,与代理开关无关:检查网络/目标服务,或稍后重试。',
584
+ proxyBadge: dualBadge(p),
585
+ directBadge: dualBadge(d),
586
+ }
587
+ }
@@ -54,7 +54,28 @@ export const PROXY_TEST_PARAMETERS = {
54
54
  },
55
55
  }
56
56
 
57
- /** Output schema of `proxy_test`. */
57
+ /** Reusable per-channel probe result (a classifyProbeStatus / classifyTargetFailure
58
+ * output). Schema DSL has no $ref, so this inlines into PROXY_TEST_SCHEMA. */
59
+ const PROBE_RESULT_SCHEMA = {
60
+ type: 'object',
61
+ additionalProperties: false,
62
+ required: true,
63
+ properties: {
64
+ ok: { type: 'boolean', required: true },
65
+ verdict: { type: 'string' },
66
+ status: { type: 'number' },
67
+ kind: { type: 'string' },
68
+ short: { type: 'string' },
69
+ why: { type: 'string' },
70
+ fix: { type: 'string' },
71
+ note: { type: 'string' },
72
+ },
73
+ }
74
+
75
+ /** Output schema of `proxy_test`. Dual-channel: forces both legs in parallel
76
+ * (走代理 / 直连), then combines them into one human verdict + recommendation
77
+ * (2026-09-18: single-side probes cannot tell WHO answered, so a strategy
78
+ * block can only be inferred by comparing both sides — LESSONS §30). */
58
79
  export const PROXY_TEST_SCHEMA = {
59
80
  type: 'object',
60
81
  additionalProperties: false,
@@ -62,25 +83,13 @@ export const PROXY_TEST_SCHEMA = {
62
83
  url: { type: 'string', required: true },
63
84
  route: { type: 'string', required: true },
64
85
  bypassed: { type: 'boolean', required: true },
65
- probe: {
66
- type: 'object',
67
- additionalProperties: false,
68
- required: true,
69
- properties: {
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' },
76
- status: { type: 'number' },
77
- kind: { type: 'string' },
78
- short: { type: 'string' },
79
- why: { type: 'string' },
80
- fix: { type: 'string' },
81
- note: { type: 'string' },
82
- },
83
- },
86
+ channel: { type: 'string', required: true },
87
+ proxy: PROBE_RESULT_SCHEMA,
88
+ direct: PROBE_RESULT_SCHEMA,
89
+ verdict: { type: 'string', required: true },
90
+ short: { type: 'string', required: true },
91
+ why: { type: 'string', required: true },
92
+ fix: { type: 'string', required: true },
84
93
  hint: { type: 'string' },
85
94
  },
86
95
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yanglaofish/dsh-proxy-pro",
3
- "version": "1.0.7",
3
+ "version": "1.0.9",
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",