@yanglaofish/dsh-proxy-pro 1.0.2 → 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/docs/LESSONS.md CHANGED
@@ -675,4 +675,28 @@ E 外部 undici 的 fetch + 同一 ProxyAgent -> OK 401
675
675
 
676
676
  **结论**:本插件与 `dsh-http-proxy` 插件行**二选一**。库本身必须保留——插件 import 它的
677
677
  `installProxyFromEnvironment` / `proxyRouteFor`,而 DSH 的 web_fetch 通道读的正是同一模块的
678
- module-level policy(§2-§3)。卸载"插件行"是安全的,删除"包"会让本插件 import 直接失败。
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/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 && diag.probe.ok ? "可达(HTTP " + diag.probe.status + ")" : (diag.probe && diag.probe.short) || "未知")),
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,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
+ classifyProbeStatus,
34
35
  classifyTargetFailure,
35
36
  keepaliveHint,
36
37
  makeSystemProxyReader,
@@ -164,6 +165,47 @@ function getUndici() {
164
165
  return undiciPromise
165
166
  }
166
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
+
167
209
  /**
168
210
  * Force the probe down one specific channel, bypassing the routing policy:
169
211
  * `'proxy'` goes through the configured proxy unconditionally, `'direct'`
@@ -173,7 +215,9 @@ function getUndici() {
173
215
  async function forceChannelProbe(url, channel, proxyUrl) {
174
216
  const und = await getUndici()
175
217
  if (und === null) {
176
- 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.' }
177
221
  }
178
222
  let dispatcher
179
223
  try {
@@ -181,10 +225,10 @@ async function forceChannelProbe(url, channel, proxyUrl) {
181
225
  ? new und.Agent({ connections: 1 })
182
226
  : new und.ProxyAgent(proxyUrl)
183
227
  } catch (error) {
184
- 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.' }
185
231
  }
186
- const controller = new AbortController()
187
- const timer = setTimeout(() => controller.abort(), 6000)
188
232
  try {
189
233
  // MUST issue the request with THIS undici copy. The host runs two copies —
190
234
  // Node's built-in `fetch` and the app's `undici` package — and a dispatcher
@@ -192,31 +236,15 @@ async function forceChannelProbe(url, channel, proxyUrl) {
192
236
  // ("invalid onRequestStart method"); the panel rendered that as a bare
193
237
  // "fetch failed" for BOTH forced channels (LESSONS §22). und.fetch and the
194
238
  // Agent/ProxyAgent above come from the same import, so they always agree.
195
- const res = await und.fetch(url, {
196
- method: 'HEAD',
197
- redirect: 'manual',
198
- signal: controller.signal,
199
- dispatcher,
200
- headers: { 'user-agent': 'dsh-proxy-pro/0.1' },
201
- })
202
- // 407 is an authentication failure, not "reachable": reporting it as ok
203
- // made the panel print 「可达(HTTP 407)」 and silenced the keepalive hint
204
- // (review fix, 2026-09-17).
205
- if (res.status === 407) {
206
- return { ok: false, kind: 'auth', short: 'HTTP 407 — the proxy asks for authentication (NTLM), which undici cannot negotiate' }
207
- }
208
- return { ok: true, status: res.status }
209
- } catch (error) {
210
- return classifyTargetFailure(error)
239
+ return await probeHttp((u, i) => und.fetch(u, i), url, dispatcher)
211
240
  } finally {
212
- clearTimeout(timer)
213
241
  try { dispatcher.close?.().catch?.(() => {}) } catch { /* best effort */ }
214
242
  }
215
243
  }
216
244
 
217
245
  /**
218
246
  * Probe one target the way web_fetch will see it: route verdict first, then a
219
- * real HEAD through the installed policy (global fetch → policy dispatcher).
247
+ * real request through the installed policy (global fetch → policy dispatcher).
220
248
  * `channel` selects the probe path: 'policy' (default), 'proxy' (forced via
221
249
  * proxy), or 'direct' (forced bypass).
222
250
  */
@@ -225,7 +253,9 @@ async function probeTarget(url, snapshot, channel = 'policy') {
225
253
  try {
226
254
  parsed = new URL(url)
227
255
  } catch {
228
- return { url, route: 'invalid', bypassed: false, probe: { ok: false, kind: 'invalid', short: 'invalid URL — expected http(s)://host/path' } }
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.' } }
229
259
  }
230
260
  let routeName = 'DIRECT'
231
261
  try {
@@ -242,27 +272,14 @@ async function probeTarget(url, snapshot, channel = 'policy') {
242
272
  if (channel === 'proxy' || channel === 'direct') {
243
273
  const proxyUrl = channel === 'proxy' ? (snapshot?.effective?.url ?? '') : ''
244
274
  const probe = proxyUrl === '' && channel === 'proxy'
245
- ? { 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 代理配置.' }
246
278
  : await forceChannelProbe(parsed, channel, proxyUrl)
247
279
  return { url, route: routeName, bypassed, channel, probe }
248
280
  }
249
- try {
250
- const controller = new AbortController()
251
- const timer = setTimeout(() => controller.abort(), 6000)
252
- const res = await fetch(parsed, {
253
- method: 'HEAD',
254
- redirect: 'manual',
255
- signal: controller.signal,
256
- headers: { 'user-agent': 'dsh-proxy-pro/0.1' },
257
- })
258
- clearTimeout(timer)
259
- if (res.status === 407) {
260
- return { url, route: routeName, bypassed, probe: { ok: false, kind: 'auth', short: 'HTTP 407 — the proxy asks for authentication (NTLM), which undici cannot negotiate' } }
261
- }
262
- return { url, route: routeName, bypassed, probe: { ok: true, status: res.status } }
263
- } catch (error) {
264
- return { url, route: routeName, bypassed, probe: classifyTargetFailure(error) }
265
- }
281
+ const probe = await probeHttp((u, i) => fetch(u, i), parsed, undefined)
282
+ return { url, route: routeName, bypassed, probe }
266
283
  }
267
284
 
268
285
  // ---- browser-trust fence (mirrors dsh's /api fence) ------------------------
@@ -486,11 +503,18 @@ function apply(ctx, config) {
486
503
  output: {
487
504
  schema: PROXY_TEST_SCHEMA,
488
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'}`
489
510
  const lines = [
490
511
  `Route: ${value.route}`,
491
512
  `NO_PROXY bypassed: ${value.bypassed}`,
492
- `Probe: ${value.probe?.ok === true ? `reachable (HTTP ${value.probe.status})` : value.probe?.short ?? 'unknown'}`,
513
+ `Probe: ${head}`,
493
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}`)
494
518
  if (value.hint) lines.push(`Hint: ${value.hint}`)
495
519
  return [{ type: 'text', text: lines.join('\n') }]
496
520
  },
package/lib/proxy-core.js CHANGED
@@ -268,33 +268,136 @@ export function keepaliveHint(message = '', proxyUrl = '') {
268
268
  }
269
269
 
270
270
  /**
271
- * Turn a fetch rejection into a short, actionable verdict for the panel and the
272
- * `proxy_test` tool.
271
+ * Three-state verdict for one HTTP answer, shared by the panel, `proxy_test`
272
+ * and the HTTP API:
273
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.
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).
279
354
  *
280
355
  * @param error - the rejection from `fetch`.
281
- * @returns `{ ok: false, kind, short }` with a cause-aware message.
356
+ * @returns `{ ok, verdict, kind, short, why, fix }` with a cause-aware message.
282
357
  */
283
358
  export function classifyTargetFailure(error) {
284
359
  const cause = error?.cause
285
360
  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` }
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).' }
288
386
  }
289
387
  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` }
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.' }
291
391
  }
292
- if (/407|NTLM|authentication required/i.test(String(error?.message ?? ''))) {
293
- return { ok: false, kind: 'auth', short: 'the proxy asks for NTLM authentication' }
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.' }
294
396
  }
295
397
  const detail = cause?.code
296
398
  ? `${cause.code}${cause?.message ? `: ${String(cause.message).slice(0, 90)}` : ''}`
297
399
  : (cause?.message ?? '')
298
- const base = String(error?.message ?? error)
299
- return { ok: false, kind: 'other', short: (detail ? `${base} (${detail})` : base).slice(0, 180) }
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.' }
300
403
  }
@@ -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.2",
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",