@yanglaofish/dsh-proxy-pro 1.0.3 → 1.0.5
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 +52 -1
- package/docs/SPEC.md +4 -2
- package/lib/index.js +70 -47
- package/lib/proxy-core.js +81 -83
- package/package.json +1 -1
package/docs/LESSONS.md
CHANGED
|
@@ -699,4 +699,55 @@ module-level policy(§2-§3)。卸载"插件行"是安全的,删除"包"
|
|
|
699
699
|
|
|
700
700
|
**教训**:诊断工具的返回值必须回答"能不能用"和"下一步怎么办"——只回一个状态码,等于把问题
|
|
701
701
|
又丢回给用户。另外 `additionalProperties: false` 的 schema 必须同步列出新字段
|
|
702
|
-
(verdict/why/fix/note),否则 defineTool 校验直接失败。
|
|
702
|
+
(verdict/why/fix/note),否则 defineTool 校验直接失败。
|
|
703
|
+
|
|
704
|
+
---
|
|
705
|
+
|
|
706
|
+
## 25. 策略通道也要同一 undici 副本(2026-09-17)
|
|
707
|
+
|
|
708
|
+
**症状**:1.0.3 面板「连通探测」(不勾选「使用代理」)显示
|
|
709
|
+
`不可用:dispatcher rejected by this undici instance (UND_ERR_INVALID_ARG) — mixed undici copies`。
|
|
710
|
+
|
|
711
|
+
**根因**:1.0.2 只修了强制通道(`und.fetch` + 同副本 dispatcher,§22)。策略通道一直用 Node
|
|
712
|
+
内置 `fetch` 且不传 dispatcher —— 但全局 dispatcher 是 `installProxyFromEnvironment` 用 **app
|
|
713
|
+
undici** 装的,内置 fetch 拿它发请求 = A 组合(内置 fetch + 外部 Agent)→ 必然
|
|
714
|
+
UND_ERR_INVALID_ARG。之前没暴露,是因为面板默认勾选「使用代理」(强制通道)掩盖了它。
|
|
715
|
+
|
|
716
|
+
**修法**(1.0.4):策略通道同样走 app undici——
|
|
717
|
+
`policyProbe()` = `und.fetch(url, { dispatcher: und.getGlobalDispatcher() })`。
|
|
718
|
+
`getGlobalDispatcher()` 返回的正是 web_fetch 通道在用的全局 dispatcher,语义 = 模拟 web_fetch,
|
|
719
|
+
且永远同副本。三条探测路径现在统一:policy = 全局 dispatcher / forced-direct = 新建 Agent /
|
|
720
|
+
forced-proxy = 新建 ProxyAgent,全部由 `und.fetch` 发起。
|
|
721
|
+
|
|
722
|
+
**连带**:所有展示文案(short/why/fix/hint)中文化——面板是中文 UI,英文 why/fix 让人无法
|
|
723
|
+
行动;`proxy_test` 工具输出同步中文(模型照读)。教训:给用户看的文案别用英文"图省事",
|
|
724
|
+
判定结论必须能直接用。
|
|
725
|
+
|
|
726
|
+
**测试**:断言随中文文案同步翻新(/混用/、/超时/、/上游|超时/、/ECONNREFUSED/)。
|
|
727
|
+
|
|
728
|
+
---
|
|
729
|
+
|
|
730
|
+
## 26. 不 import 会被移除的库导出:settings 走服务(2026-09-18)
|
|
731
|
+
|
|
732
|
+
**症状**:升级 test profile 后,dsh CLI 启动时整棵树崩溃:
|
|
733
|
+
`SyntaxError: The requested module '@deepseek-ai/dsh-settings' does not provide an export named 'installSettingsSection'`,
|
|
734
|
+
插件行加载失败 → 整个启动终止。
|
|
735
|
+
|
|
736
|
+
**根因**:dsh-settings 同时存在两个版本——桌面版(resources/app/node_modules)**保留**了
|
|
737
|
+
`installSettingsSection`/`settingsNamespace` 兼容导出;全局 dsh CLI(AppData/Roaming/npm 的
|
|
738
|
+
`@deepseek-ai/dsh` 自带依赖树)是**新版,已移除这两个导出**。插件在模块顶层
|
|
739
|
+
`import { installSettingsSection } from '@deepseek-ai/dsh-settings'` → ESM 求值阶段就抛
|
|
740
|
+
SyntaxError,不是运行期错误——profile 插件树加载失败,启动直接终止。
|
|
741
|
+
|
|
742
|
+
**改法**(1.0.5):彻底不 import dsh-settings 的任何导出,配置注册改走 **settings 服务**:
|
|
743
|
+
```js
|
|
744
|
+
const scope = ctx.settings.register(PROXY_NS, Config, { base: config })
|
|
745
|
+
source = () => scope.get() // update() 重算 registration.resolved,get() 始终新鲜
|
|
746
|
+
scope.watch(() => { void requestSync().catch(() => {}) })
|
|
747
|
+
```
|
|
748
|
+
`register`/`watch`/`update`/`replace` 在两端(旧桌面版 SettingsProvider 与新 CLI 版)都存在;
|
|
749
|
+
命名空间直接写字面量 `'proxy'`(旧 `settingsNamespace()` 也只是 parse + 校验)。
|
|
750
|
+
|
|
751
|
+
**教训**:① ESM 顶层 import 命中断言是**加载期**错误——一次错误的 import 能杀死整个进程,所以
|
|
752
|
+
对外部包的导出要查运行环境的实际版本,而不是凭记忆;② 能从服务拿到的(settings 是 inject 服务)
|
|
753
|
+
就不要 import 包;③ 「桌面版」与「全局 CLI 版」是两套依赖树,一个功能必须两端都能加载。
|
package/docs/SPEC.md
CHANGED
|
@@ -105,7 +105,7 @@ profile 内安装(pnpm nodeLinker: hoisted;本地=file: 软链,发布=regi
|
|
|
105
105
|
|
|
106
106
|
```
|
|
107
107
|
[浏览器 client.js] [host index.js (NodeService 链)]
|
|
108
|
-
│ inject settings.section │
|
|
108
|
+
│ inject settings.section │ ctx.settings.register('proxy') + watch
|
|
109
109
|
│ inject header.utilities │ sync(): resolve → applyEffective(env+dispatcher)
|
|
110
110
|
│ fetch /dsh-proxy-pro/api/* ◄──────┤ → installProxyFromEnvironment(policy)
|
|
111
111
|
│ │ ↓ 让 web_fetch 的 proxyRouteFor 判 PROXIED
|
|
@@ -140,7 +140,9 @@ function apply(ctx, config) { … } // 导出 { Config, apply, inject, name }
|
|
|
140
140
|
- **没有 savedEnv / EnvHttpProxyAgent / 手写 dispatcher**:传输完全委托
|
|
141
141
|
`installProxyFromEnvironment`(env + 全局 dispatcher + 模块级策略一体,
|
|
142
142
|
LESSONS §12.4:单一通道,杜绝 undici 错版)。
|
|
143
|
-
- `
|
|
143
|
+
- `ctx.settings.register(PROXY_NS, Config, { base: config })` 拿 owner scope,
|
|
144
|
+
`source = () => scope.get()`,`scope.watch(onChange)` —— 新版 dsh-settings 已移除
|
|
145
|
+
`installSettingsSection` 导出(LESSONS §26),必须走 settings 服务;
|
|
144
146
|
onChange → `requestSync()`(合并去重,`syncing` 单飞)。
|
|
145
147
|
- teardown(`ctx.effect`):清 pollTimer → `await httpProxyPolicy?.()`(卸载=恢复
|
|
146
148
|
env/dispatcher/策略)。
|
package/lib/index.js
CHANGED
|
@@ -26,7 +26,6 @@
|
|
|
26
26
|
*/
|
|
27
27
|
import z from '@deepseek-ai/schemastery'
|
|
28
28
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
29
|
-
import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
30
29
|
import { installProxyFromEnvironment, proxyRouteFor } from '@deepseek-ai/dsh-http-proxy'
|
|
31
30
|
import { execFile } from 'node:child_process'
|
|
32
31
|
import { promisify } from 'node:util'
|
|
@@ -56,8 +55,11 @@ const name = 'dsh-proxy-pro'
|
|
|
56
55
|
/** Services this plugin must resolve before it applies. */
|
|
57
56
|
const inject = ['tools', 'systemPrompt', 'settings']
|
|
58
57
|
|
|
59
|
-
/** Settings namespace owning the proxy configuration (shared with stock
|
|
60
|
-
|
|
58
|
+
/** Settings namespace owning the proxy configuration (shared with stock
|
|
59
|
+
* tooling). Plain literal: the legacy settingsNamespace() helper was removed
|
|
60
|
+
* from newer dsh-settings, and 'proxy' is already a valid lowercase-hyphenated
|
|
61
|
+
* namespace identifier. */
|
|
62
|
+
const PROXY_NS = 'proxy'
|
|
61
63
|
|
|
62
64
|
/** Ordered placement of the proxy status section in the system prompt. */
|
|
63
65
|
const SECTION_ORDER = 55
|
|
@@ -169,13 +171,12 @@ const PROBE_UA = 'dsh-proxy-pro/0.1'
|
|
|
169
171
|
const PROBE_TIMEOUT_MS = 6000
|
|
170
172
|
|
|
171
173
|
/**
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
* channels (LESSONS §22 — the two must never be mixed).
|
|
174
|
+
* 一个探测尝试:先 HEAD,端点不支持 HEAD(405/501)时再用 GET 复测——
|
|
175
|
+
* 很多 API 只实现 GET,只用 HEAD 会冤枉一个可用主机。`request` 是调用方
|
|
176
|
+
* 的 fetch:两条通道现在都用 app undici 的 `und.fetch` 配同副本 dispatcher
|
|
177
|
+
* (LESSONS §22/§25 —— 绝不要把两份副本混在一起)。
|
|
177
178
|
*
|
|
178
|
-
* @returns
|
|
179
|
+
* @returns classifyProbeStatus / classifyTargetFailure 的判定。
|
|
179
180
|
*/
|
|
180
181
|
async function probeHttp(request, target, dispatcher) {
|
|
181
182
|
const attempt = async (method) => {
|
|
@@ -201,8 +202,8 @@ async function probeHttp(request, target, dispatcher) {
|
|
|
201
202
|
if (get.error !== undefined) return classifyTargetFailure(get.error)
|
|
202
203
|
const retried = classifyProbeStatus(get.res.status)
|
|
203
204
|
retried.note = retried.ok === true
|
|
204
|
-
? 'HEAD
|
|
205
|
-
: 'HEAD
|
|
205
|
+
? 'HEAD 被拒绝但 GET 可用 — 主机可用。'
|
|
206
|
+
: 'HEAD 被拒绝;GET 复测也遇到同类问题。'
|
|
206
207
|
return retried
|
|
207
208
|
}
|
|
208
209
|
|
|
@@ -215,9 +216,9 @@ async function probeHttp(request, target, dispatcher) {
|
|
|
215
216
|
async function forceChannelProbe(url, channel, proxyUrl) {
|
|
216
217
|
const und = await getUndici()
|
|
217
218
|
if (und === null) {
|
|
218
|
-
return { ok: false, verdict: 'unusable', kind: 'unavailable', short: '
|
|
219
|
-
why: '
|
|
220
|
-
fix: '
|
|
219
|
+
return { ok: false, verdict: 'unusable', kind: 'unavailable', short: '强制通道不可用(undici 未能加载)',
|
|
220
|
+
why: '宿主进程无法加载 app 侧 undici 包。',
|
|
221
|
+
fix: '取消勾选「使用代理」改用策略通道,或重启 DSH;仍出现请反馈插件版本。' }
|
|
221
222
|
}
|
|
222
223
|
let dispatcher
|
|
223
224
|
try {
|
|
@@ -225,9 +226,9 @@ async function forceChannelProbe(url, channel, proxyUrl) {
|
|
|
225
226
|
? new und.Agent({ connections: 1 })
|
|
226
227
|
: new und.ProxyAgent(proxyUrl)
|
|
227
228
|
} catch (error) {
|
|
228
|
-
return { ok: false, verdict: 'unusable', kind: 'bad-proxy', short:
|
|
229
|
-
why: '
|
|
230
|
-
fix: '
|
|
229
|
+
return { ok: false, verdict: 'unusable', kind: 'bad-proxy', short: `代理地址无效:${String(error?.message ?? error)}`,
|
|
230
|
+
why: '当前生效的代理地址不是有效的 http(s) 代理 URL。',
|
|
231
|
+
fix: '在「代理配置」修复地址(mode = 自定义地址)或清空自定义地址。' }
|
|
231
232
|
}
|
|
232
233
|
try {
|
|
233
234
|
// MUST issue the request with THIS undici copy. The host runs two copies —
|
|
@@ -243,10 +244,26 @@ async function forceChannelProbe(url, channel, proxyUrl) {
|
|
|
243
244
|
}
|
|
244
245
|
|
|
245
246
|
/**
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
247
|
+
* 按已安装策略探测,与 web_fetch 走同一条路:用同一份 undici 的 fetch 加
|
|
248
|
+
* 全局 dispatcher。策略通道不能用 Node 内置 `fetch`——全局 dispatcher 由
|
|
249
|
+
* app 的 undici(installProxyFromEnvironment)安装,内置 fetch 拿它必然
|
|
250
|
+
* UND_ERR_INVALID_ARG(LESSONS §25,A 组合;面板当时显示"dispatcher
|
|
251
|
+
* rejected… mixed undici copies")。
|
|
252
|
+
*/
|
|
253
|
+
async function policyProbe(target) {
|
|
254
|
+
const und = await getUndici()
|
|
255
|
+
if (und === null) {
|
|
256
|
+
return { ok: false, verdict: 'unusable', kind: 'unavailable', short: '策略通道不可用(undici 未能加载)',
|
|
257
|
+
why: '宿主进程无法加载 app 侧 undici 包。', fix: '重启 DSH;仍出现请反馈插件版本。' }
|
|
258
|
+
}
|
|
259
|
+
const dispatcher = typeof und.getGlobalDispatcher === 'function' ? und.getGlobalDispatcher() : undefined
|
|
260
|
+
return probeHttp((u, i) => und.fetch(u, i), target, dispatcher)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* 按 web_fetch 的方式探测一个目标:先给路由判定,再用策略通道发真实请求
|
|
265
|
+
* (app undici 的 fetch + 全局 dispatcher,见 policyProbe)。`channel` 选择
|
|
266
|
+
* 探测路径:'policy'(默认)、'proxy'(强制走代理)、'direct'(强制绕过)。
|
|
250
267
|
*/
|
|
251
268
|
async function probeTarget(url, snapshot, channel = 'policy') {
|
|
252
269
|
let parsed
|
|
@@ -254,13 +271,13 @@ async function probeTarget(url, snapshot, channel = 'policy') {
|
|
|
254
271
|
parsed = new URL(url)
|
|
255
272
|
} catch {
|
|
256
273
|
return { url, route: 'invalid', bypassed: false,
|
|
257
|
-
probe: { ok: false, verdict: 'unusable', kind: 'invalid', short: '
|
|
258
|
-
why: '
|
|
274
|
+
probe: { ok: false, verdict: 'unusable', kind: 'invalid', short: 'URL 无效 — 需要 http(s)://主机/路径',
|
|
275
|
+
why: '输入不是可解析的绝对 URL。', fix: '用完整形式,如 https://github.com/foo/bar。' } }
|
|
259
276
|
}
|
|
260
277
|
let routeName = 'DIRECT'
|
|
261
278
|
try {
|
|
262
|
-
const
|
|
263
|
-
routeName =
|
|
279
|
+
const routeProbe = proxyRouteFor(parsed)
|
|
280
|
+
routeName = routeProbe.proxied ? `PROXIED ${routeProbe.proxy}` : 'DIRECT'
|
|
264
281
|
} catch {
|
|
265
282
|
// proxyRouteFor unavailable in this composition → rely on state snapshot
|
|
266
283
|
}
|
|
@@ -272,13 +289,13 @@ async function probeTarget(url, snapshot, channel = 'policy') {
|
|
|
272
289
|
if (channel === 'proxy' || channel === 'direct') {
|
|
273
290
|
const proxyUrl = channel === 'proxy' ? (snapshot?.effective?.url ?? '') : ''
|
|
274
291
|
const probe = proxyUrl === '' && channel === 'proxy'
|
|
275
|
-
? { ok: false, verdict: 'unusable', kind: 'bad-proxy', short: '
|
|
276
|
-
why: '
|
|
277
|
-
fix: '
|
|
292
|
+
? { ok: false, verdict: 'unusable', kind: 'bad-proxy', short: '当前快照里没有可用的代理地址',
|
|
293
|
+
why: '代理开关处于关闭(或 Windows 系统代理关闭),没有可强制走代理的对象。',
|
|
294
|
+
fix: '打开代理,或在「代理配置」里修复地址/模式。' }
|
|
278
295
|
: await forceChannelProbe(parsed, channel, proxyUrl)
|
|
279
296
|
return { url, route: routeName, bypassed, channel, probe }
|
|
280
297
|
}
|
|
281
|
-
const probe = await
|
|
298
|
+
const probe = await policyProbe(parsed)
|
|
282
299
|
return { url, route: routeName, bypassed, probe }
|
|
283
300
|
}
|
|
284
301
|
|
|
@@ -427,16 +444,22 @@ function apply(ctx, config) {
|
|
|
427
444
|
}
|
|
428
445
|
|
|
429
446
|
// Configuration source: the `proxy` settings namespace layered over the
|
|
430
|
-
// composition entry; every committed change re-syncs live.
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
447
|
+
// composition entry; every committed change re-syncs live. Registration goes
|
|
448
|
+
// through the settings SERVICE — newer dsh-settings removed the
|
|
449
|
+
// `installSettingsSection` export entirely, so importing it throws a
|
|
450
|
+
// SyntaxError at boot and kills the whole profile (LESSONS §26).
|
|
451
|
+
// register() + scope.get()/watch() exist on every version we target, and
|
|
452
|
+
// update() recomputes registration.resolved, so scope.get() stays fresh.
|
|
453
|
+
const settingsService = ctx.get('settings')
|
|
454
|
+
if (settingsService !== undefined && typeof settingsService.register === 'function') {
|
|
455
|
+
const proxyScope = settingsService.register(PROXY_NS, Config, { base: config })
|
|
456
|
+
source = () => proxyScope.get()
|
|
457
|
+
proxyScope.watch(() => {
|
|
437
458
|
void requestSync().catch(() => {})
|
|
438
|
-
}
|
|
439
|
-
}
|
|
459
|
+
})
|
|
460
|
+
}
|
|
461
|
+
// Without a settings service in this composition, `source` keeps the
|
|
462
|
+
// composition default (initialized above) and the plugin still runs, read-only.
|
|
440
463
|
|
|
441
464
|
// ---------- Agent tools ----------
|
|
442
465
|
|
|
@@ -505,17 +528,17 @@ function apply(ctx, config) {
|
|
|
505
528
|
render: (_args, value) => {
|
|
506
529
|
const p = value.probe ?? {}
|
|
507
530
|
const head = p.verdict === 'usable'
|
|
508
|
-
?
|
|
509
|
-
: `${p.verdict === 'degraded' ? '
|
|
531
|
+
? `可用${p.status === undefined ? '' : `(HTTP ${p.status})`}`
|
|
532
|
+
: `${p.verdict === 'degraded' ? '降级' : '不可用'} — ${p.short ?? '未知'}`
|
|
510
533
|
const lines = [
|
|
511
|
-
|
|
512
|
-
`NO_PROXY
|
|
513
|
-
|
|
534
|
+
`路由: ${value.route}`,
|
|
535
|
+
`NO_PROXY 命中: ${value.bypassed}`,
|
|
536
|
+
`连通探测: ${head}`,
|
|
514
537
|
]
|
|
515
|
-
if (p.why) lines.push(
|
|
516
|
-
if (p.fix) lines.push(
|
|
517
|
-
if (p.note) lines.push(
|
|
518
|
-
if (value.hint) lines.push(
|
|
538
|
+
if (p.why) lines.push(`可能原因: ${p.why}`)
|
|
539
|
+
if (p.fix) lines.push(`挽救措施: ${p.fix}`)
|
|
540
|
+
if (p.note) lines.push(`备注: ${p.note}`)
|
|
541
|
+
if (value.hint) lines.push(`提示: ${value.hint}`)
|
|
519
542
|
return [{ type: 'text', text: lines.join('\n') }]
|
|
520
543
|
},
|
|
521
544
|
},
|
package/lib/proxy-core.js
CHANGED
|
@@ -254,150 +254,148 @@ export function summarize(snapshot) {
|
|
|
254
254
|
}
|
|
255
255
|
|
|
256
256
|
/**
|
|
257
|
-
* NTLM keepalive
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
257
|
+
* NTLM keepalive 提示:SWG 类代理(netentsec 等)向 undici 索要 NTLM 认证,
|
|
258
|
+
* undici 无法协商;curl/浏览器最近一次经过同一代理的连接会保持一段已认证
|
|
259
|
+
* 窗口。仅在看到 407 或认证相关应答时才有意义。示例 URL 由调用方传入
|
|
260
|
+
* (生效代理地址),绝不硬编码——固定样例曾把开发者自己的公司代理泄漏进
|
|
261
|
+
* 每一条提示里。
|
|
262
262
|
*/
|
|
263
263
|
export function keepaliveHint(message = '', proxyUrl = '') {
|
|
264
264
|
const text = typeof message === 'string' ? message : ''
|
|
265
265
|
if (!/407|NTLM|proxy authentication|authentication required/i.test(text)) return ''
|
|
266
266
|
const example = typeof proxyUrl === 'string' && proxyUrl !== '' ? proxyUrl : 'http://localhost:7890'
|
|
267
|
-
return
|
|
267
|
+
return `该代理要求 NTLM 认证,undici 无法自行协商。最近一次 curl/浏览器到同一代理的连接会保持一段已认证窗口——先执行一次 \`curl -x ${example} -I https://github.com\`,然后重试。`
|
|
268
268
|
}
|
|
269
269
|
|
|
270
270
|
/**
|
|
271
|
-
*
|
|
272
|
-
* and the HTTP API:
|
|
271
|
+
* 单个 HTTP 应答的三态判定,供面板、`proxy_test` 与 HTTP API 共用:
|
|
273
272
|
*
|
|
274
|
-
* - `usable` —
|
|
275
|
-
*
|
|
276
|
-
* - `degraded` —
|
|
277
|
-
*
|
|
278
|
-
* - `unusable` —
|
|
279
|
-
* target, gateway/upstream timeouts).
|
|
273
|
+
* - `usable` — 真实调用可用(2xx/3xx,以及 401:健康的 API 端点对
|
|
274
|
+
* 不带凭证的探测就该回 401)。
|
|
275
|
+
* - `degraded` — 拿到了 HTTP 应答(路由通),但这次调用多半会失败
|
|
276
|
+
* (403/404/405/429 等 4xx)。
|
|
277
|
+
* - `unusable` — 路由或对端失败(407 认证墙、目标 5xx、网关/上游超时)。
|
|
280
278
|
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
* 200 both rendered as "reachable", which answered nothing).
|
|
279
|
+
* 每个结论都带 `why`(可能原因)与 `fix`(挽救措施),让诊断直接可行动,
|
|
280
|
+
* 而不是丢一个状态码(2026-09-17:504 曾与 200 一样显示成「可达」)。
|
|
284
281
|
*
|
|
285
|
-
* @param status -
|
|
286
|
-
* @returns `{ ok, verdict, kind, status, short, why, fix }
|
|
282
|
+
* @param status - 探测收到的 HTTP 状态码。
|
|
283
|
+
* @returns `{ ok, verdict, kind, status, short, why, fix }`(中文文案)。
|
|
287
284
|
*/
|
|
288
285
|
export function classifyProbeStatus(status) {
|
|
289
286
|
const code = Number(status)
|
|
290
287
|
const base = { status: code }
|
|
291
288
|
if (code === 407) {
|
|
292
|
-
return { ...base, ok: false, verdict: 'unusable', kind: 'auth', short: 'HTTP 407 —
|
|
293
|
-
why: '
|
|
294
|
-
fix: '
|
|
289
|
+
return { ...base, ok: false, verdict: 'unusable', kind: 'auth', short: 'HTTP 407 — 代理要求 NTLM 认证',
|
|
290
|
+
why: '请求根本没到目标:代理拦下并索要 NTLM 认证,undici 无法协商。',
|
|
291
|
+
fix: '先用 curl/浏览器走同一代理过一次(如 curl -x <代理> -I https://github.com)打开认证窗口,再重试。' }
|
|
295
292
|
}
|
|
296
293
|
if (code >= 200 && code < 400) {
|
|
297
|
-
return { ...base, ok: true, verdict: 'usable', kind: 'ok', short: `HTTP ${code} —
|
|
294
|
+
return { ...base, ok: true, verdict: 'usable', kind: 'ok', short: `HTTP ${code} — 目标正常响应`, why: '', fix: '' }
|
|
298
295
|
}
|
|
299
296
|
if (code === 401) {
|
|
300
|
-
return { ...base, ok: true, verdict: 'usable', kind: 'ok', short: 'HTTP 401 —
|
|
301
|
-
why: '
|
|
302
|
-
fix: '
|
|
297
|
+
return { ...base, ok: true, verdict: 'usable', kind: 'ok', short: 'HTTP 401 — 目标存活(需要凭证)',
|
|
298
|
+
why: '探测不带凭证,所以 401 是健康 API 端点的正常应答。',
|
|
299
|
+
fix: '真实调用需要有效 key/请求头;这条结果已经证明路由可用。' }
|
|
303
300
|
}
|
|
304
301
|
if (code === 403) {
|
|
305
|
-
return { ...base, ok: false, verdict: 'degraded', kind: 'http-error', short: 'HTTP 403 —
|
|
306
|
-
why: '
|
|
307
|
-
fix: '
|
|
302
|
+
return { ...base, ok: false, verdict: 'degraded', kind: 'http-error', short: 'HTTP 403 — 目标拒绝了请求',
|
|
303
|
+
why: '已到达服务器但被拒绝:key 权限、IP 白名单、WAF,或探测 UA 被挡。',
|
|
304
|
+
fix: '对比直连/代理两侧,并检查该主机的 key/UA 规则。' }
|
|
308
305
|
}
|
|
309
306
|
if (code === 404) {
|
|
310
|
-
return { ...base, ok: false, verdict: 'degraded', kind: 'http-error', short: 'HTTP 404 —
|
|
311
|
-
why: '
|
|
312
|
-
fix: '
|
|
307
|
+
return { ...base, ok: false, verdict: 'degraded', kind: 'http-error', short: 'HTTP 404 — 路径不存在',
|
|
308
|
+
why: '路由已到服务器,但该路径不存在(探测常打根路径)。',
|
|
309
|
+
fix: '用真实调用的完整路径复测;能通就忽略此结果。' }
|
|
313
310
|
}
|
|
314
311
|
if (code === 405 || code === 501) {
|
|
315
|
-
return { ...base, ok: false, verdict: 'degraded', kind: 'http-error', short: `HTTP ${code} —
|
|
316
|
-
why: '
|
|
317
|
-
fix: '
|
|
312
|
+
return { ...base, ok: false, verdict: 'degraded', kind: 'http-error', short: `HTTP ${code} — 端点不支持 HEAD`,
|
|
313
|
+
why: '诊断用 HEAD 探测,有些 API/网关只实现 GET。',
|
|
314
|
+
fix: '面板已自动换 GET 复测;GET 成功即主机可用。' }
|
|
318
315
|
}
|
|
319
316
|
if (code === 429) {
|
|
320
|
-
return { ...base, ok: false, verdict: 'degraded', kind: 'http-error', short: 'HTTP 429 —
|
|
321
|
-
why: '
|
|
322
|
-
fix: '
|
|
317
|
+
return { ...base, ok: false, verdict: 'degraded', kind: 'http-error', short: 'HTTP 429 — 被限流',
|
|
318
|
+
why: '服务器活着但在限流。',
|
|
319
|
+
fix: '稍后重试或降低请求频率;路由本身没问题。' }
|
|
323
320
|
}
|
|
324
321
|
if (code === 502) {
|
|
325
|
-
return { ...base, ok: false, verdict: 'unusable', kind: 'gateway', short: 'HTTP 502 — bad gateway',
|
|
326
|
-
why: '
|
|
327
|
-
fix: '
|
|
322
|
+
return { ...base, ok: false, verdict: 'unusable', kind: 'gateway', short: 'HTTP 502 — 网关错误(bad gateway)',
|
|
323
|
+
why: '中间环节(代理或目标网关)拿到无效上游响应——常见是代理连不上该主机。',
|
|
324
|
+
fix: '对比直连/代理两侧;若失败在代理侧,把该主机保留在 NO_PROXY 的另一边。' }
|
|
328
325
|
}
|
|
329
326
|
if (code === 503) {
|
|
330
|
-
return { ...base, ok: false, verdict: 'unusable', kind: 'gateway', short: 'HTTP 503 —
|
|
331
|
-
why: '
|
|
332
|
-
fix: '
|
|
327
|
+
return { ...base, ok: false, verdict: 'unusable', kind: 'gateway', short: 'HTTP 503 — 服务不可用',
|
|
328
|
+
why: '目标(或其网关)过载或维护中。',
|
|
329
|
+
fix: '稍后重试;这是服务端问题,与路由无关。' }
|
|
333
330
|
}
|
|
334
331
|
if (code === 504) {
|
|
335
|
-
return { ...base, ok: false, verdict: 'unusable', kind: 'gateway', short: 'HTTP 504 —
|
|
336
|
-
why: '
|
|
337
|
-
fix: '
|
|
332
|
+
return { ...base, ok: false, verdict: 'unusable', kind: 'gateway', short: 'HTTP 504 — 网关/上游超时',
|
|
333
|
+
why: '请求已转发但上游迟迟无响应:目标慢或挂了,或代理到目标这一段坏了。',
|
|
334
|
+
fix: '对比直连/代理两侧并重试;只有某一侧超时,问题就在那一侧。' }
|
|
338
335
|
}
|
|
339
336
|
if (code >= 500) {
|
|
340
|
-
return { ...base, ok: false, verdict: 'unusable', kind: 'server-error', short: `HTTP ${code} —
|
|
341
|
-
why: '
|
|
342
|
-
fix: '
|
|
337
|
+
return { ...base, ok: false, verdict: 'unusable', kind: 'server-error', short: `HTTP ${code} — 目标服务端错误`,
|
|
338
|
+
why: '服务器自身报错;路由没问题。',
|
|
339
|
+
fix: '稍后重试;无需改动代理配置。' }
|
|
343
340
|
}
|
|
344
|
-
return { ...base, ok: false, verdict: 'degraded', kind: 'http-error', short: `HTTP ${code} —
|
|
345
|
-
why: '
|
|
346
|
-
fix: '
|
|
341
|
+
return { ...base, ok: false, verdict: 'degraded', kind: 'http-error', short: `HTTP ${code} — 请求被拒绝`,
|
|
342
|
+
why: '路由通(拿到了 HTTP 应答),但这次请求本身不成功。',
|
|
343
|
+
fix: '检查真实调用用的方法、路径和请求头。' }
|
|
347
344
|
}
|
|
348
345
|
|
|
349
346
|
/**
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
*
|
|
347
|
+
* 把 fetch 的拒绝转成可行动的结论。裸的 `fetch failed` 掩盖了真正原因:
|
|
348
|
+
* 一份 undici 造的 dispatcher 交给另一份使用时,会抛 UND_ERR_INVALID_ARG
|
|
349
|
+
* ("invalid onRequestStart method")——1.0.2 之前强制通道诊断两个方向都
|
|
350
|
+
* 死在这里(LESSONS §22);1.0.4 又发现策略通道也在混两份副本
|
|
351
|
+
* (LESSONS §25)。把 cause code 亮出来,下一次报告就能指名道姓。
|
|
354
352
|
*
|
|
355
|
-
* @param error -
|
|
356
|
-
* @returns `{ ok, verdict, kind, short, why, fix }
|
|
353
|
+
* @param error - `fetch` 抛出的拒绝。
|
|
354
|
+
* @returns 带 cause 信息的 `{ ok, verdict, kind, short, why, fix }`(中文文案)。
|
|
357
355
|
*/
|
|
358
356
|
export function classifyTargetFailure(error) {
|
|
359
357
|
const cause = error?.cause
|
|
360
358
|
const code = cause?.code ?? error?.code ?? ''
|
|
361
359
|
const message = String(error?.message ?? error)
|
|
362
360
|
if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') {
|
|
363
|
-
return { ok: false, verdict: 'unusable', kind: 'unreachable', short: `DNS
|
|
364
|
-
why: '
|
|
365
|
-
fix: '
|
|
361
|
+
return { ok: false, verdict: 'unusable', kind: 'unreachable', short: `DNS 解析失败(${code})`,
|
|
362
|
+
why: '主机名没解析出来——名字/DNS 错,或代理无法解析它。',
|
|
363
|
+
fix: '检查主机名;该主机若必须走代理,把它移出 NO_PROXY(反之加进去)。' }
|
|
366
364
|
}
|
|
367
365
|
if (code === 'ECONNREFUSED') {
|
|
368
|
-
return { ok: false, verdict: 'unusable', kind: 'unreachable', short: '
|
|
369
|
-
why: '
|
|
370
|
-
fix: '
|
|
366
|
+
return { ok: false, verdict: 'unusable', kind: 'unreachable', short: '连接被拒绝(ECONNREFUSED)',
|
|
367
|
+
why: '有东西应答但拒绝连接:端口无人监听,或代理不在接受连接。',
|
|
368
|
+
fix: '检查端口与代理是否在运行;对比另一侧通道。' }
|
|
371
369
|
}
|
|
372
370
|
if (code === 'EACCES' || code === 'ENETUNREACH' || code === 'EHOSTUNREACH') {
|
|
373
|
-
return { ok: false, verdict: 'unusable', kind: 'unreachable', short:
|
|
374
|
-
why: '
|
|
375
|
-
fix: '
|
|
371
|
+
return { ok: false, verdict: 'unusable', kind: 'unreachable', short: `直连不可达(${code})—需要走代理`,
|
|
372
|
+
why: '本机到目标的网络路径本身不可用。',
|
|
373
|
+
fix: '让该主机走代理(从 NO_PROXY 移除)再重试。' }
|
|
376
374
|
}
|
|
377
375
|
if (code === 'ETIMEDOUT' || code === 'UND_ERR_CONNECT_TIMEOUT' || code === 'ABORT_ERR' || error?.name === 'AbortError') {
|
|
378
|
-
return { ok: false, verdict: 'unusable', kind: 'unreachable', short:
|
|
379
|
-
why: '
|
|
380
|
-
fix: '
|
|
376
|
+
return { ok: false, verdict: 'unusable', kind: 'unreachable', short: `超时前无应答(${code || error?.name || 'timeout'})`,
|
|
377
|
+
why: '到时间没应答——目标慢/挂,或代理静默丢弃该目标。',
|
|
378
|
+
fix: '对比直连/代理两侧并重试;只有某一侧超时,问题就在那一侧。' }
|
|
381
379
|
}
|
|
382
380
|
if (code === 'ECONNRESET' || code === 'UND_ERR_SOCKET' || code === 'EPIPE') {
|
|
383
|
-
return { ok: false, verdict: 'unusable', kind: 'unreachable', short:
|
|
384
|
-
why: '
|
|
385
|
-
fix: '
|
|
381
|
+
return { ok: false, verdict: 'unusable', kind: 'unreachable', short: `连接被重置(${code})`,
|
|
382
|
+
why: '连接中途被切断——中间设备或 TLS 问题(公司拦截、MTU、对端重置)。',
|
|
383
|
+
fix: '重试并对比另一侧;若有 TLS 拦截,确认公司 CA 已被信任(NODE_EXTRA_CA_CERTS)。' }
|
|
386
384
|
}
|
|
387
385
|
if (code === 'UND_ERR_INVALID_ARG' || /invalid onRequestStart/i.test(String(cause?.message ?? ''))) {
|
|
388
|
-
return { ok: false, verdict: 'unusable', kind: 'dispatcher', short: `dispatcher
|
|
389
|
-
why: '
|
|
390
|
-
fix: '
|
|
386
|
+
return { ok: false, verdict: 'unusable', kind: 'dispatcher', short: `dispatcher 被当前 undici 副本拒绝(${code || 'UND_ERR_INVALID_ARG'})—两份副本混用`,
|
|
387
|
+
why: '请求和它的 dispatcher 来自两份不同的 undici 副本;1.0.2 修过强制通道,1.0.4 修策略通道。',
|
|
388
|
+
fix: '更新插件;若仍出现,把插件版本连同这条信息一起反馈。' }
|
|
391
389
|
}
|
|
392
390
|
if (/407|NTLM|authentication required/i.test(message)) {
|
|
393
|
-
return { ok: false, verdict: 'unusable', kind: 'auth', short: '
|
|
394
|
-
why: '
|
|
395
|
-
fix: '
|
|
391
|
+
return { ok: false, verdict: 'unusable', kind: 'auth', short: '代理要求 NTLM 认证',
|
|
392
|
+
why: '代理索要 NTLM,undici 无法单独完成协商。',
|
|
393
|
+
fix: '先用 curl/浏览器走同一代理过一次(打开认证窗口),再重试。' }
|
|
396
394
|
}
|
|
397
395
|
const detail = cause?.code
|
|
398
396
|
? `${cause.code}${cause?.message ? `: ${String(cause.message).slice(0, 90)}` : ''}`
|
|
399
397
|
: (cause?.message ?? '')
|
|
400
|
-
return { ok: false, verdict: 'unusable', kind: 'other', short: (detail ? `${message}
|
|
401
|
-
why: '
|
|
402
|
-
fix: '
|
|
398
|
+
return { ok: false, verdict: 'unusable', kind: 'other', short: (detail ? `${message}(${detail})` : message).slice(0, 180),
|
|
399
|
+
why: '请求失败,探测器无法归类原因。',
|
|
400
|
+
fix: '对比直连/代理两侧并重试;用 proxy_status 查看生效路由。' }
|
|
403
401
|
}
|
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.5",
|
|
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",
|