@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 +25 -1
- package/lib/client.js +14 -1
- package/lib/index.js +66 -42
- package/lib/proxy-core.js +118 -15
- package/lib/tool-schemas.js +8 -0
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
250
|
-
|
|
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: ${
|
|
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
|
-
*
|
|
272
|
-
*
|
|
271
|
+
* Three-state verdict for one HTTP answer, shared by the panel, `proxy_test`
|
|
272
|
+
* and the HTTP API:
|
|
273
273
|
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
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
|
|
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
|
-
|
|
287
|
-
|
|
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(
|
|
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
|
-
|
|
299
|
-
|
|
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
|
}
|
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",
|