@yanglaofish/dsh-proxy-pro 1.1.0 → 2.0.1

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/lib/index.js CHANGED
@@ -1,832 +1,902 @@
1
- /**
2
- * dsh-proxy-pro — host half (Cordis plugin).
3
- *
4
- * Routes the whole runtime through a proxy when enabled:
5
- * - model requests and plain fetch(): the undici global dispatcher
6
- * - web_search / web_fetch: the dsh-http-proxy POLICY that dsh-web-fetch-http's
7
- * proxyRouteFor() reads (the KEY fix the stock dsh-plugin-proxy lacks — see
8
- * docs/LESSONS.md §2-§3)
9
- * - spawned tools: HTTP_PROXY / HTTPS_PROXY / ALL_PROXY / NO_PROXY are
10
- * written into process.env
11
- *
12
- * Everything goes through ONE channel: `installProxyFromEnvironment(envLike)`.
13
- * That call owns the environment, the global dispatcher, and the module-level
14
- * policy state as one coherent unit, so the plugin never touches undici
15
- * directly and no dispatcher/instance skew is possible (LESSONS §3: mixed
16
- * undici instances → UND_ERR_INVALID_ARG).
17
- *
18
- * A "never applied yet" guard keeps boot with `enabled: false` a strict no-op
19
- * on the transport, so dsh-proxy-pro can coexist with dsh-plugin-proxy during
20
- * migration (NFR-4: only the row whose switch is ON owns the dispatcher).
21
- *
22
- * Configuration lives in the `proxy` settings namespace (Settings → 代理管理),
23
- * layered over the composition entry (cordis.patch.yml).
24
- *
25
- * @module dsh-proxy-pro
26
- */
27
- import z from '@deepseek-ai/schemastery'
28
- import { defineTool } from '@deepseek-ai/dsh-tools'
29
- import { installProxyFromEnvironment, proxyRouteFor } from '@deepseek-ai/dsh-http-proxy'
30
- import { execFile } from 'node:child_process'
31
- import { promisify } from 'node:util'
32
- import {
33
- classifyProbeStatus,
34
- classifyTargetFailure,
35
- combineDualProbe,
36
- configPayload,
37
- DEFAULT_ENTRY_ID,
38
- discoverEntryId,
39
- headNeedsGetRetry,
40
- keepaliveHint,
41
- makeSystemProxyReader,
42
- normalizeProxyUrl,
43
- readEntryConfig,
44
- resolveProxyState,
45
- summarize,
46
- systemFactsEqual,
47
- } from './proxy-core.js'
48
- import {
49
- PROXY_CONFIG_PARAMETERS,
50
- PROXY_SET_PARAMETERS,
51
- PROXY_STATUS_SCHEMA,
52
- PROXY_TEST_PARAMETERS,
53
- PROXY_TEST_SCHEMA,
54
- } from './tool-schemas.js'
55
-
56
- const execFileAsync = promisify(execFile)
57
-
58
- /** Cordis plugin name (also the bundle row id in cordis.patch.yml). */
59
- const name = 'dsh-proxy-pro'
60
-
61
- /** Services this plugin must resolve before it applies. */
62
- const inject = ['tools', 'systemPrompt', 'settings']
63
-
64
- /** Settings namespace owning the proxy configuration (shared with stock
65
- * tooling). Plain literal: the legacy settingsNamespace() helper was removed
66
- * from newer dsh-settings, and 'proxy' is already a valid lowercase-hyphenated
67
- * namespace identifier. */
68
- const PROXY_NS = 'proxy'
69
-
70
- /** Ordered placement of the proxy status section in the system prompt. */
71
- const SECTION_ORDER = 55
72
-
73
- /** Base path for the status HTTP API (client polls this). */
74
- const ROUTE_BASE = '/dsh-proxy-pro/api'
75
-
76
- /** Composition-row configuration (also the settings schema). */
77
- const Config = z.object({
78
- enabled: z.boolean().default(false),
79
- // 'none' kept only for parsing pre-removal settings.yaml values; the UI and
80
- // proxy_config never produce it anymore (LESSONS §17.3e).
81
- mode: z.union([z.const('system'), z.const('custom'), z.const('none')]).default('system'),
82
- customUrl: z.string().default('http://127.0.0.1:7890'),
83
- noProxy: z.string().default('localhost,127.0.0.1,::1'),
84
- systemPollMs: z.number().default(30000).min(0),
85
- })
86
-
87
- /** Every proxy environment name dsh-http-proxy's resolver can read. */
88
- const PROXY_ENV_NAMES = [
89
- 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY',
90
- 'http_proxy', 'https_proxy', 'all_proxy', 'no_proxy',
91
- ]
92
-
93
- /**
94
- * The virtual environment snapshot for one effective state, or null when the
95
- * proxy is inactive. installProxyFromEnvironment resolves its policy purely
96
- * from this (env.get must return `{ value }` or undefined); the lower-case
97
- * names win in resolution, the upper-case ones are the published form.
98
- */
99
- function proxyEnvMap(effective) {
100
- if (effective?.active !== true || effective.url === null || effective.url === undefined) return null
101
- const http = effective.http ?? effective.url
102
- const https = effective.https ?? effective.url
103
- const noProxy = effective.noProxy ?? ''
104
- return {
105
- HTTP_PROXY: http, HTTPS_PROXY: https, ALL_PROXY: effective.url, NO_PROXY: noProxy,
106
- http_proxy: http, https_proxy: https, all_proxy: effective.url, no_proxy: noProxy,
107
- }
108
- }
109
-
110
- /** Build the envLike snapshot `installProxyFromEnvironment` expects (LESSONS §4). */
111
- function makeEnvLike(effective) {
112
- const map = proxyEnvMap(effective)
113
- return {
114
- get: (target) => {
115
- const value = map === null ? undefined : map[target]
116
- return value === undefined ? undefined : { value }
117
- },
118
- }
119
- }
120
-
121
- /** Build the agent-facing status text from the latest sync snapshot. */function renderStatusText(snapshot) {
122
- const effective = snapshot?.effective
123
- const config = snapshot?.config
124
- if (effective === undefined || config === undefined) {
125
- return 'Proxy: not yet initialized.'
126
- }
127
- const active = effective.active === true
128
- const mode = config.mode ?? 'none'
129
- if (!active) {
130
- const reason = effective.reason === 'system-proxy-off'
131
- ? 'the Windows system proxy is currently off'
132
- : effective.reason === 'invalid-custom-url'
133
- ? 'the custom proxy address is invalid'
134
- : effective.reason === 'mode-none'
135
- ? 'the mode is set to "none"'
136
- : 'the proxy switch is off'
137
- return [
138
- '## Proxy status: OFF',
139
- '',
140
- `Network traffic currently goes DIRECT (no proxy): ${reason}.`,
141
- 'If a network operation fails and you suspect it needs a proxy, call `proxy_status` to confirm, then `proxy_set` with enabled=true — or `proxy_config` to change the mode, the address, or the direct/NO_PROXY list.',
142
- ].join('\n')
143
- }
144
- const source = mode === 'system' ? 'Windows system proxy' : 'custom address'
145
- return [
146
- '## Proxy status: ON',
147
- '',
148
- `The whole runtime (model requests, web_search/web_fetch, and all spawned tools) is routed through the proxy: ${effective.url} (${source}).`,
149
- '',
150
- `Bypass list (NO_PROXY, traffic goes direct): ${effective.noProxy || '(none)'}.`,
151
- '',
152
- 'Implications you MUST respect:',
153
- '- Assume every outbound HTTP(S) request goes through this proxy unless the host is on the bypass list.',
154
- '- Localhost / 127.0.0.1 / ::1 and anything in the bypass list always go direct — use them for local services.',
155
- '- If an operation must NOT go through the proxy (an internal network target, a local server, a host the proxy would break), call `proxy_set` with enabled=false first, run the operation, then re-enable with enabled=true.',
156
- '- To change the configuration itself — switch between the system proxy and a custom address, or edit the direct/NO_PROXY list so some hosts bypass the proxy — call `proxy_config`; it persists the change and applies it immediately.',
157
- '- Do not silently retry failed proxy attempts with a different tool expecting a different route; check `proxy_status` and the failure reason first.',
158
- ].join('\n')
159
- }
160
-
161
- // classifyTargetFailure lives in proxy-core.js (pure logic, unit-tested) so the
162
- // panel's verdict can name the undici cause instead of a bare "fetch failed".
163
-
164
- // Forced-channel probing uses undici directly. Load lazily and never let a
165
- // resolution failure block boot (LESSONS §14: boot must not depend on a new
166
- // bare import).
167
- let undiciPromise = null
168
- function getUndici() {
169
- if (undiciPromise === null) {
170
- undiciPromise = import('undici').then((m) => m, () => null)
171
- }
172
- return undiciPromise
173
- }
174
-
175
- const PROBE_UA = 'dsh-proxy-pro/0.1'
176
- const PROBE_TIMEOUT_MS = 6000
177
-
178
- /**
179
- * 一个探测尝试:先 HEAD,HEAD 结论不可信(404/405/501)时再用 GET 复测——
180
- * 405/501 是「不支持该方法」,404 是网关不给 HEAD 注册路由时的伪装
181
- * (LESSONS §27:DashScope `HEAD /models` → 404,同路径 GET → 401),只用
182
- * HEAD 会把健康端点误判成「路径不存在」。`request` 是调用方的 fetch:两条
183
- * 通道都用 app undici 的 `und.fetch` 配同副本 dispatcher(LESSONS §22/§25
184
- * —— 绝不要把两份副本混在一起)。
185
- *
186
- * @returns classifyProbeStatus / classifyTargetFailure 的判定。
187
- */
188
- async function probeHttp(request, target, dispatcher) {
189
- const attempt = async (method) => {
190
- const controller = new AbortController()
191
- const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS)
192
- try {
193
- const init = { method, redirect: 'manual', signal: controller.signal, headers: { 'user-agent': PROBE_UA } }
194
- if (method === 'GET') init.headers.range = 'bytes=0-0'
195
- if (dispatcher !== undefined) init.dispatcher = dispatcher
196
- const res = await request(target, init)
197
- return { res }
198
- } catch (error) {
199
- return { error }
200
- } finally {
201
- clearTimeout(timer)
202
- }
203
- }
204
- const head = await attempt('HEAD')
205
- if (head.error !== undefined) return classifyTargetFailure(head.error)
206
- const verdict = classifyProbeStatus(head.res.status)
207
- if (verdict.kind !== 'http-error' || !headNeedsGetRetry(head.res.status)) return verdict
208
- const get = await attempt('GET')
209
- if (get.error !== undefined) return classifyTargetFailure(get.error)
210
- const retried = classifyProbeStatus(get.res.status)
211
- const sameStatus = get.res.status === head.res.status
212
- retried.note = sameStatus
213
- ? `HEAD 与 GET 均返回 ${get.res.status} — 该路径确实不可用。`
214
- : retried.ok === true
215
- ? `HEAD 返回 ${head.res.status},GET 得到 ${get.res.status} — 以 GET 为准(网关常不给 HEAD 注册路由)。`
216
- : `HEAD 返回 ${head.res.status},GET 得到 ${get.res.status} — 以 GET 为准。`
217
- return retried
218
- }
219
-
220
- /**
221
- * Force the probe down one specific channel, bypassing the routing policy:
222
- * `'proxy'` goes through the configured proxy unconditionally, `'direct'`
223
- * ignores the proxy entirely. Used by the panel diagnostic's 「使用代理」
224
- * checkbox so the user can decide whether a host belongs in NO_PROXY.
225
- */
226
- async function forceChannelProbe(url, channel, proxyUrl) {
227
- const und = await getUndici()
228
- if (und === null) {
229
- return { ok: false, verdict: 'unusable', kind: 'unavailable', short: '强制通道不可用(undici 未能加载)',
230
- why: '宿主进程无法加载 app 侧 undici 包。',
231
- fix: '取消勾选「使用代理」改用策略通道,或重启 DSH;仍出现请反馈插件版本。' }
232
- }
233
- let dispatcher
234
- try {
235
- dispatcher = channel === 'direct'
236
- ? new und.Agent({ connections: 1 })
237
- : new und.ProxyAgent(proxyUrl)
238
- } catch (error) {
239
- return { ok: false, verdict: 'unusable', kind: 'bad-proxy', short: `代理地址无效:${String(error?.message ?? error)}`,
240
- why: '当前生效的代理地址不是有效的 http(s) 代理 URL。',
241
- fix: '在「代理配置」修复地址(mode = 自定义地址)或清空自定义地址。' }
242
- }
243
- try {
244
- // MUST issue the request with THIS undici copy. The host runs two copies —
245
- // Node's built-in `fetch` and the app's `undici` package — and a dispatcher
246
- // built by one handed to the other rejects with UND_ERR_INVALID_ARG
247
- // ("invalid onRequestStart method"); the panel rendered that as a bare
248
- // "fetch failed" for BOTH forced channels (LESSONS §22). und.fetch and the
249
- // Agent/ProxyAgent above come from the same import, so they always agree.
250
- return await probeHttp((u, i) => und.fetch(u, i), url, dispatcher)
251
- } finally {
252
- try { dispatcher.close?.().catch?.(() => {}) } catch { /* best effort */ }
253
- }
254
- }
255
-
256
- /**
257
- * 按已安装策略探测,与 web_fetch 走同一条路:用同一份 undici 的 fetch 加
258
- * 全局 dispatcher。策略通道不能用 Node 内置 `fetch`——全局 dispatcher 由
259
- * app 的 undici(installProxyFromEnvironment)安装,内置 fetch 拿它必然
260
- * UND_ERR_INVALID_ARG(LESSONS §25,A 组合;面板当时显示"dispatcher
261
- * rejected… mixed undici copies")。
262
- */
263
- async function policyProbe(target) {
264
- const und = await getUndici()
265
- if (und === null) {
266
- return { ok: false, verdict: 'unusable', kind: 'unavailable', short: '策略通道不可用(undici 未能加载)',
267
- why: '宿主进程无法加载 app 侧 undici 包。', fix: '重启 DSH;仍出现请反馈插件版本。' }
268
- }
269
- const dispatcher = typeof und.getGlobalDispatcher === 'function' ? und.getGlobalDispatcher() : undefined
270
- return probeHttp((u, i) => und.fetch(u, i), target, dispatcher)
271
- }
272
-
273
- /**
274
- * 按 web_fetch 的方式探测一个目标,**同时**强制走代理 + 强制直连两个通道,
275
- * 再把两侧结果综合成一条人话推荐(combineDualProbe)。
276
- *
277
- * 单侧探测无法判断被拒是谁干的(目标 or 代理?)——只有两侧对比才能得出
278
- * 方向性结论(直连通+代理失败 → 问题在代理一侧;代理通+直连失败 → 代理是
279
- * 唯一通路)。`channel` 保留兼容:'policy'/'proxy'/'direct' 仍是旧的单通道
280
- * 探测(HTTP API 的历史 useProxy 参数使用),默认 'dual' 走双通道并行。
281
- */
282
- async function probeTarget(url, snapshot, channel = 'dual') {
283
- let parsed
284
- try {
285
- parsed = new URL(url)
286
- } catch {
287
- return { url, route: 'invalid', bypassed: false, channel,
288
- proxy: { ok: false, verdict: 'unusable', kind: 'invalid', short: 'URL 无效 — 需要 http(s)://主机/路径',
289
- why: '输入不是可解析的绝对 URL。', fix: '用完整形式,如 https://github.com/foo/bar。' },
290
- direct: { ok: false, verdict: 'unusable', kind: 'invalid', short: 'URL 无效 — 需要 http(s)://主机/路径',
291
- why: '输入不是可解析的绝对 URL。', fix: '用完整形式,如 https://github.com/foo/bar。' },
292
- verdict: 'both-bad', short: 'URL 无效', why: '输入不是可解析的绝对 URL。', fix: '用完整形式,如 https://github.com/foo/bar。' }
293
- }
294
- let routeName = 'DIRECT'
295
- try {
296
- const routeProbe = proxyRouteFor(parsed)
297
- routeName = routeProbe.proxied ? `PROXIED ${routeProbe.proxy}` : 'DIRECT'
298
- } catch {
299
- // proxyRouteFor unavailable in this composition → rely on state snapshot
300
- }
301
- const noProxyList = snapshot?.effective?.noProxy ?? ''
302
- const bypassed = noProxyList.split(',').some((entry) => {
303
- const host = entry.trim().replace(/^\*?\./, '').toLowerCase()
304
- return host.length > 0 && (host === parsed.hostname.toLowerCase() || parsed.hostname.toLowerCase().endsWith(`.${host}`))
305
- })
306
-
307
- // 兼容旧单通道调用(HTTP API 历史 useProxy 参数)
308
- if (channel === 'proxy' || channel === 'direct') {
309
- const proxyUrl = channel === 'proxy' ? (snapshot?.effective?.url ?? '') : ''
310
- const probe = proxyUrl === '' && channel === 'proxy'
311
- ? { ok: false, verdict: 'unusable', kind: 'bad-proxy', short: '当前快照里没有可用的代理地址',
312
- why: '代理开关处于关闭(或 Windows 系统代理关闭),没有可强制走代理的对象。',
313
- fix: '打开代理,或在「代理配置」里修复地址/模式。' }
314
- : await forceChannelProbe(parsed, channel, proxyUrl)
315
- return { url, route: routeName, bypassed, channel, probe }
316
- }
317
- if (channel === 'policy') {
318
- const probe = await policyProbe(parsed)
319
- return { url, route: routeName, bypassed, channel, probe }
320
- }
321
-
322
- // 双通道并行:强制走代理 + 强制直连,各自独立探测(互不干扰)
323
- const proxyUrl = snapshot?.effective?.url ?? ''
324
- const proxyProbe = proxyUrl === ''
325
- ? { ok: false, verdict: 'unusable', kind: 'bad-proxy', short: '当前快照里没有可用的代理地址',
326
- why: '代理开关处于关闭(或 Windows 系统代理关闭),没有可强制走代理的对象。',
327
- fix: '打开代理,或在「代理配置」里修复地址/模式。' }
328
- : await forceChannelProbe(parsed, 'proxy', proxyUrl)
329
- const directProbe = await forceChannelProbe(parsed, 'direct', '')
330
- const combined = combineDualProbe(proxyProbe, directProbe)
331
- return {
332
- url, route: routeName, bypassed, channel: 'dual',
333
- proxy: proxyProbe, direct: directProbe,
334
- verdict: combined.verdict, short: combined.short,
335
- why: combined.why, fix: combined.fix,
336
- proxyBadge: combined.proxyBadge, directBadge: combined.directBadge,
337
- }
338
- }
339
-
340
- // ---- browser-trust fence (mirrors dsh's /api fence) ------------------------
341
- // dsh's own RPC channels run every request through isTrustedApiRequest
342
- // (dsh-client-connection): Host must be loopback (DNS rebinding cannot forge
343
- // Host), sec-fetch-site=cross-site is refused (browser CSRF marker), and an
344
- // attached Origin must be same-host. Our panel API is registered directly on
345
- // ctx.webServer, so we replicate that fence here — the same implementation
346
- // dsh-skill-manager ships (its lib/index.js L131-172), matching the loopback
347
- // verdict of the official fence with zero extra dependencies.
348
-
349
- /** Normalized URL of a Host-header authority (hostname lowercased), or undefined. */
350
- function parseHostHeader(host) {
351
- if (typeof host !== 'string' || !host) return undefined
352
- try {
353
- return new URL(`http://${host}`).hostname.toLowerCase()
354
- } catch {
355
- return undefined
356
- }
357
- }
358
-
359
- /** localhost | [::1] | any IPv4 in 127/8 — the loopback authority set. */
360
- function isLoopbackHostname(hostname) {
361
- if (hostname === 'localhost' || hostname === '[::1]') return true
362
- const parts = hostname.split('.')
363
- return parts.length === 4 && parts[0] === '127'
364
- && parts.every((p) => /^\d{1,3}$/.test(p) && Number(p) <= 255)
365
- }
366
-
367
- /** True when the Host is loopback and any attached browser markers are same-origin. */
368
- function isTrustedPanelRequest(headers) {
369
- const host = typeof headers?.get === 'function' ? headers.get('host') : headers?.host
370
- const hostname = parseHostHeader(host)
371
- if (!hostname || !isLoopbackHostname(hostname)) return false
372
- if (headers?.get && headers.get('sec-fetch-site') === 'cross-site') return false
373
- if (!headers?.get && headers?.['sec-fetch-site'] === 'cross-site') return false
374
- const originRaw = typeof headers?.get === 'function' ? headers.get('origin') : headers?.origin
375
- if (originRaw === undefined || originRaw === null || originRaw === '') return true
376
- try {
377
- return new URL(originRaw).hostname.toLowerCase() === hostname
378
- } catch {
379
- return false
380
- }
381
- }
382
-
383
- /** Write 403 and return true when the request fails the fence (handler calls first). */
384
- function denyIfUntrusted(req, res) {
385
- if (isTrustedPanelRequest(req.headers)) return false
386
- try {
387
- res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' })
388
- res.end('forbidden')
389
- } catch {
390
- /* client gone */
391
- }
392
- return true
393
- }
394
-
395
- /**
396
- * Cordis plugin body.
397
- * @param ctx - registrant context.
398
- * @param config - validated composition configuration.
399
- */
400
- function apply(ctx, config) {
401
- const systemProxyReader = makeSystemProxyReader(execFileAsync)
402
- let source = () => config
403
- let snapshot = undefined
404
- let httpProxyPolicy = undefined
405
- let syncing = null
406
- let disposed = false
407
- let haveApplied = false
408
- let lastAppliedKey = undefined
409
- let pollTimer = undefined
410
- let lastSystemFacts = null
411
-
412
- /** Read all inputs (config + system facts), apply, and record the snapshot. */
413
- const sync = async () => {
414
- if (disposed) return
415
- const resolved = source()
416
- let systemProxy = null
417
- // System facts are only consulted in mode 'system': custom mode keeps the
418
- // configured whitelist alone, so no registry read is needed there (§17.3f).
419
- if (resolved.mode === 'system' && resolved.enabled === true) {
420
- try {
421
- systemProxy = await systemProxyReader()
422
- lastSystemFacts = systemProxy
423
- } catch (error) {
424
- ctx.logger.warn('dsh-proxy-pro: reading the Windows system proxy failed: %s', String(error))
425
- }
426
- }
427
- const effective = resolveProxyState(resolved, systemProxy)
428
- const active = effective.active === true && effective.url !== null
429
- // Idempotency (LESSONS §17): when the effective transport is unchanged we
430
- // refresh the rich snapshot but MUST NOT rebuild env/dispatcher — sync is
431
- // a rebuild only when the effective state actually changed. Pollers may
432
- // call requestSync() freely; the cost of an unchanged pass is a snapshot.
433
- // http/https are part of the key: a https-only change (same url) must
434
- // still rebuild so HTTPS_PROXY actually switches (review fix, 2026-09-17).
435
- const effKey = JSON.stringify([active, effective.url ?? null, effective.http ?? null, effective.https ?? null, effective.noProxy ?? null, effective.reason ?? 'unknown'])
436
- if (lastAppliedKey !== undefined && lastAppliedKey === effKey) {
437
- snapshot = { config: resolved, effective, systemProxy, at: new Date().toISOString() }
438
- ctx.emit('dsh-proxy-pro/status', summarize(snapshot))
439
- return
440
- }
441
- // Guard: while we have never applied anything, OFF stays a transport
442
- // no-op so boot with the default switch never touches env/dispatcher
443
- // (coexistence with dsh-plugin-proxy during migration, NFR-4).
444
- if (!active && !haveApplied) {
445
- snapshot = { config: resolved, effective, systemProxy, at: new Date().toISOString() }
446
- ctx.emit('dsh-proxy-pro/status', summarize(snapshot))
447
- return
448
- }
449
- if (active) haveApplied = true
450
- // Single transport channel: installProxyFromEnvironment owns process.env,
451
- // the global dispatcher, and the module-level policy that proxyRouteFor()
452
- // reads. Uninstall the previous policy before (re)installing so the
453
- // package's active/installed state never goes stale (LESSONS §4).
454
- try {
455
- if (httpProxyPolicy !== undefined) {
456
- // Do NOT await the disposer: it restores dispatcher + env synchronously
457
- // (setGlobalDispatcher / restoreEnv run before the first await inside),
458
- // then awaits agent.close() — undici's graceful close drains every
459
- // keep-alive connection, which with the whole desktop's traffic in
460
- // flight can block this turn for seconds. Fire it, keep the install
461
- // serial, and let the pool drain in the background (LESSONS §17.3b).
462
- const dispose = httpProxyPolicy
463
- httpProxyPolicy = undefined
464
- void dispose().catch(() => {})
465
- }
466
- const envLike = makeEnvLike(effective)
467
- httpProxyPolicy = await installProxyFromEnvironment(envLike, (m) => ctx.logger.warn('dsh-proxy-pro: dsh-http-proxy: %s', m))
468
- lastAppliedKey = effKey
469
- } catch (error) {
470
- ctx.logger.warn('dsh-proxy-pro: applying the proxy policy failed: %s', String(error))
471
- }
472
- snapshot = { config: resolved, effective, systemProxy, at: new Date().toISOString() }
473
- ctx.emit('dsh-proxy-pro/status', summarize(snapshot))
474
- }
475
-
476
- /** Coalesced sync: concurrent triggers share one in-flight pass. */
477
- const requestSync = () => {
478
- if (disposed) return Promise.resolve()
479
- if (syncing !== null) return syncing
480
- syncing = sync().finally(() => { syncing = null })
481
- return syncing
482
- }
483
-
484
- // Configuration source — two settings generations (LESSONS §26, §33):
485
- // - dsh <= 0.1.x: a per-plugin namespace registered through the settings
486
- // service (register(ns, schema, {base}) → scope.get()/watch()).
487
- // - dsh >= 0.2: register()/watch() are gone. The plugin's OWN composition
488
- // entry IS the settings form (namespace = entry id, schema = this Config),
489
- // writes go through settings.update(entryId, patch) and every commit
490
- // emits `settings/document-updated`, which is what re-syncs us live.
491
- const settingsService = ctx.get('settings')
492
- const configEditor = ctx.get('configEditor')
493
- const legacyRegister = typeof settingsService?.register === 'function'
494
- const entryId = discoverEntryId(configEditor, ctx) ?? DEFAULT_ENTRY_ID
495
- if (legacyRegister) {
496
- const proxyScope = settingsService.register(PROXY_NS, Config, { base: config })
497
- source = () => proxyScope.get()
498
- proxyScope.watch(() => {
499
- void requestSync().catch(() => {})
500
- })
501
- } else if (typeof settingsService?.update === 'function') {
502
- source = () => readEntryConfig(configEditor, entryId, config)
503
- ctx.on('settings/document-updated', (ns) => {
504
- if (ns === entryId) void requestSync().catch(() => {})
505
- })
506
- }
507
- // Without either generation the plugin still runs read-only on the
508
- // composition config (source keeps returning it).
509
-
510
- /** Persist one config patch through whichever settings generation is present. */
511
- const writeSettings = async (patch) => {
512
- if (settingsService === undefined) {
513
- throw new Error('the settings service is unavailable in this composition — change the proxy configuration in the composition instead')
514
- }
515
- await settingsService.update(legacyRegister ? PROXY_NS : entryId, patch)
516
- }
517
-
518
- // ---------- Agent tools ----------
519
-
520
- ctx.tools.register(defineTool({
521
- name: 'proxy_status',
522
- description: 'Show the current proxy state of this runtime: whether traffic goes through a proxy, the effective proxy address, the NO_PROXY bypass list, and the source of the setting. Call this before any network operation where the proxy route matters, and whenever a network operation fails unexpectedly.',
523
- parameters: {},
524
- output: {
525
- schema: PROXY_STATUS_SCHEMA,
526
- render: (_args, value) => {
527
- const head = value.active
528
- ? `Proxy ON — ${value.url} (${value.source === 'system' ? 'system' : 'custom'})`
529
- : `Proxy OFF — direct connection (${value.reason})`
530
- const lines = [head, `NO_PROXY bypass: ${value.noProxy || '(none)'}`]
531
- return [{ type: 'text', text: lines.join('\n') }]
532
- },
533
- },
534
- execute: async () => {
535
- await requestSync()
536
- return summarize(snapshot)
537
- },
538
- presentCall: () => ({
539
- card: 'generic',
540
- title: 'Proxy status',
541
- kind: 'other',
542
- rawInput: {},
543
- }),
544
- }))
545
-
546
- ctx.tools.register(defineTool({
547
- name: 'proxy_set',
548
- description: 'Turn the proxy on or off for the whole runtime. Use enabled=false when an operation must go DIRECT (an internal/intranet target, a local server, or a host the proxy would break), then re-enable with enabled=true afterwards. The change is persisted and takes effect immediately for model requests, web fetches, and spawned tools.',
549
- parameters: PROXY_SET_PARAMETERS,
550
- output: {
551
- schema: PROXY_STATUS_SCHEMA,
552
- render: (_args, value) => [{
553
- type: 'text',
554
- text: value.active
555
- ? `Proxy enabled — ${value.url} (${value.source === 'system' ? 'system' : 'custom'})`
556
- : `Proxy disabled — traffic now goes direct.`,
557
- }],
558
- },
559
- execute: async (args) => {
560
- try {
561
- await writeSettings({ enabled: args.enabled === true })
562
- } catch (error) {
563
- throw new Error(`proxy_set: ${error?.message ?? error}`)
564
- }
565
- await requestSync()
566
- return summarize(snapshot)
567
- },
568
- presentCall: (args) => ({
569
- card: 'generic',
570
- title: `Proxy ${args.enabled === true ? 'on' : 'off'}`,
571
- kind: 'other',
572
- rawInput: args,
573
- }),
574
- }))
575
-
576
- ctx.tools.register(defineTool({
577
- name: 'proxy_test',
578
- description: 'Diagnose whether a target URL will route through the proxy or direct, and probe actual connectivity. Use when a fetch/git/curl to a host fails unexpectedly: it reports the route web_fetch will take (PROXIED vs DIRECT), whether the host is bypassed by NO_PROXY, a live connectivity probe, and an NTLM keepalive hint when the proxy asks for authentication.',
579
- parameters: PROXY_TEST_PARAMETERS,
580
- output: {
581
- schema: PROXY_TEST_SCHEMA,
582
- render: (_args, value) => {
583
- const badge = (b) => (b?.tone === 'ok' ? '🟢' : b?.tone === 'warn' ? '🟡' : '🔴') + ' ' + (b?.text ?? '未知')
584
- const lines = [
585
- `路由: ${value.route}`,
586
- `NO_PROXY 命中: ${value.bypassed}`,
587
- `状态码: 走代理 ${value.proxy?.status ?? '—'} · 直连 ${value.direct?.status ?? '—'}`,
588
- `走代理: ${badge(value.proxyBadge)}`,
589
- `直连: ${badge(value.directBadge)}`,
590
- `结论: ${value.short ?? '未知'}`,
591
- ]
592
- if (value.why) lines.push(`可能原因: ${value.why}`)
593
- if (value.fix) lines.push(`挽救措施: ${value.fix}`)
594
- if (value.hint) lines.push(`提示: ${value.hint}`)
595
- return [{ type: 'text', text: lines.join('\n') }]
596
- },
597
- },
598
- execute: async (args) => {
599
- await requestSync()
600
- const result = await probeTarget(String(args.url ?? ''), snapshot)
601
- result.hint = keepaliveHint(`${result.proxy?.short ?? ''} ${result.direct?.short ?? ''}`, snapshot?.effective?.url ?? '')
602
- return result
603
- },
604
- presentCall: (args) => ({
605
- card: 'generic',
606
- title: 'Proxy test',
607
- kind: 'other',
608
- rawInput: args,
609
- }),
610
- }))
611
-
612
- ctx.tools.register(defineTool({
613
- name: 'proxy_config',
614
- description: 'Read or change the proxy configuration: the switch (enabled), the source mode (system / custom / none), the custom proxy address, and the NO_PROXY direct-connection list. Call it with no arguments to read the current configuration, or with any subset of fields to change them. Changes are persisted and take effect immediately for model requests, web fetches, and spawned tools. Use `proxy_set` for a plain on/off toggle, and `proxy_test` to check how one URL routes.',
615
- parameters: PROXY_CONFIG_PARAMETERS,
616
- output: {
617
- schema: PROXY_STATUS_SCHEMA,
618
- render: (_args, value) => [{
619
- type: 'text',
620
- text: value.active
621
- ? `Proxy config — mode=${value.mode}, ${value.url} (${value.source === 'system' ? 'system' : 'custom'}), bypass: ${value.noProxy || '(none)'}`
622
- : `Proxy config — inactive (${value.reason}); mode=${value.mode}, bypass: ${value.noProxy || '(none)'}`,
623
- }],
624
- },
625
- execute: async (args) => {
626
- const patch = {}
627
- if (args.enabled !== undefined) patch.enabled = args.enabled === true
628
- if (args.mode !== undefined) patch.mode = args.mode
629
- if (args.customUrl !== undefined) {
630
- const raw = String(args.customUrl).trim()
631
- if (raw === '') {
632
- patch.customUrl = ''
633
- } else {
634
- const normalized = normalizeProxyUrl(raw)
635
- if (normalized === null) {
636
- throw new Error(`proxy_config: "${args.customUrl}" is not a usable proxy address — expected http(s)://host:port (a bare host:port is also accepted)`)
637
- }
638
- patch.customUrl = normalized
639
- }
640
- }
641
- if (args.noProxy !== undefined) patch.noProxy = String(args.noProxy).trim()
642
- if (Object.keys(patch).length > 0) await writeSettings(patch)
643
- await requestSync()
644
- return summarize(snapshot)
645
- },
646
- presentCall: (args) => ({
647
- card: 'generic',
648
- title: 'Proxy config',
649
- kind: 'other',
650
- rawInput: args,
651
- }),
652
- }))
653
-
654
- // The model must always know whether the proxy is in force.
655
- ctx.systemPrompt.section({
656
- name: 'dsh-proxy-pro:status',
657
- order: SECTION_ORDER,
658
- text: () => renderStatusText(snapshot),
659
- })
660
-
661
- // ---------- Status HTTP API (for the client UI) ----------
662
- const sendJson = (res, status, body) => {
663
- try {
664
- res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
665
- res.end(JSON.stringify(body))
666
- } catch {
667
- /* client gone */
668
- }
669
- }
670
- const readJsonBody = async (req, maxBytes = 2 * 1024 * 1024) => {
671
- const chunks = []
672
- let size = 0
673
- for await (const chunk of req) {
674
- size += chunk.length
675
- if (size > maxBytes) throw new Error('request body too large')
676
- chunks.push(chunk)
677
- }
678
- const text = Buffer.concat(chunks).toString('utf8')
679
- if (!text) return {}
680
- try {
681
- return JSON.parse(text)
682
- } catch {
683
- return {}
684
- }
685
- }
686
-
687
- const webServer = ctx.get('webServer')
688
- if (webServer !== undefined) {
689
- try {
690
- const unregisterRoute = webServer.register({
691
- kind: 'prefix',
692
- path: ROUTE_BASE,
693
- handler: async (req, res) => {
694
- if (denyIfUntrusted(req, res)) return
695
- const url = new URL(req.url ?? '/', 'http://localhost')
696
- const is = (p, m) => url.pathname === `${ROUTE_BASE}${p}` && req.method === m
697
- try {
698
- if (is('/status', 'GET')) {
699
- // Pull-triggered refresh: requestSync is idempotent — when the
700
- // effKey is unchanged it only refreshes the snapshot (no
701
- // dispatcher/env rebuild); only a real system-proxy change
702
- // reinstalls. So refreshing here is cheap and every pull serves
703
- // fresh facts. This is safe where the pre-096e431 /status was
704
- // not: that one reinstalled UNCONDITIONALLY on every pull and
705
- // dragged the desktop down (LESSONS §17). The 30s host poll
706
- // still owns unattended following (page-closed changes); this
707
- // exists only to serve the viewer.
708
- await requestSync()
709
- sendJson(res, 200, { ok: true, status: summarize(snapshot) })
710
- return
711
- }
712
- if (is('/route', 'GET')) {
713
- const target = url.searchParams.get('url') ?? ''
714
- // useProxy 保留兼容:'1'/'0' 走旧的单通道强制;无参数(新版面板)走双通道
715
- const useProxy = url.searchParams.get('useProxy')
716
- const channel = useProxy === '1' ? 'proxy' : useProxy === '0' ? 'direct' : 'dual'
717
- const result = await probeTarget(target, snapshot, channel)
718
- result.hint = keepaliveHint(
719
- `${result.proxy?.short ?? ''} ${result.direct?.short ?? ''}`.trim(),
720
- snapshot?.effective?.url ?? ''
721
- )
722
- sendJson(res, 200, { ok: true, ...result })
723
- return
724
- }
725
- if (is('/toggle', 'POST')) {
726
- const body = await readJsonBody(req)
727
- try {
728
- await writeSettings({ enabled: body.enabled === true })
729
- } catch (error) {
730
- sendJson(res, 503, { ok: false, error: String(error?.message ?? error) })
731
- return
732
- }
733
- await requestSync()
734
- sendJson(res, 200, { ok: true, status: summarize(snapshot) })
735
- return
736
- }
737
- // Editable configuration for the client panel. dsh >= 0.2 removed
738
- // the browser settings transport (`ctx.settingsScope`), so the panel
739
- // reads and writes through this API instead (LESSONS §33).
740
- if (is('/config', 'GET')) {
741
- sendJson(res, 200, { ok: true, ...configPayload(source()) })
742
- return
743
- }
744
- if (is('/config', 'POST')) {
745
- const body = await readJsonBody(req)
746
- const patch = {}
747
- if (body.enabled !== undefined) patch.enabled = body.enabled === true
748
- if (body.mode !== undefined) {
749
- if (body.mode !== 'system' && body.mode !== 'custom') {
750
- sendJson(res, 400, { ok: false, error: 'mode must be "system" or "custom"' })
751
- return
752
- }
753
- patch.mode = body.mode
754
- }
755
- if (body.customUrl !== undefined) {
756
- const raw = String(body.customUrl).trim()
757
- if (raw === '') {
758
- patch.customUrl = ''
759
- } else {
760
- const normalized = normalizeProxyUrl(raw)
761
- if (normalized === null) {
762
- sendJson(res, 400, { ok: false, error: `"${body.customUrl}" is not a usable proxy address — expected http(s)://host:port` })
763
- return
764
- }
765
- patch.customUrl = normalized
766
- }
767
- }
768
- if (body.noProxy !== undefined) patch.noProxy = String(body.noProxy).trim()
769
- try {
770
- if (Object.keys(patch).length > 0) await writeSettings(patch)
771
- } catch (error) {
772
- sendJson(res, 503, { ok: false, error: String(error?.message ?? error) })
773
- return
774
- }
775
- await requestSync()
776
- sendJson(res, 200, { ok: true, ...configPayload(source()) })
777
- return
778
- }
779
- sendJson(res, 404, { ok: false, error: 'not found' })
780
- } catch (error) {
781
- sendJson(res, 500, { ok: false, error: String(error?.message ?? error) })
782
- }
783
- },
784
- })
785
- ctx.effect(() => () => { try { unregisterRoute?.() } catch { /* best effort */ } }, 'dsh-proxy-pro: route cleanup')
786
- } catch (error) {
787
- ctx.logger.warn('dsh-proxy-pro: webServer unavailable — status API disabled: %s', String(error))
788
- }
789
- }
790
-
791
- // Follow the Windows system proxy at runtime (change-only re-apply).
792
- if (config.systemPollMs > 0) {
793
- pollTimer = setInterval(async () => {
794
- if (disposed) return
795
- const resolved = source()
796
- if (resolved.mode !== 'system' || resolved.enabled !== true) return
797
- let facts
798
- try {
799
- facts = await systemProxyReader()
800
- } catch {
801
- return
802
- }
803
- if (lastSystemFacts !== null && systemFactsEqual(lastSystemFacts, facts)) return
804
- void requestSync().catch(() => {})
805
- }, config.systemPollMs)
806
- pollTimer.unref?.()
807
- }
808
-
809
- // Tear down: restore env, dispatcher, and dsh-http-proxy policy. If a sync
810
- // is still in flight (e.g. a status-API request), uninstall chained AFTER it
811
- // — otherwise a late install would leak a policy that teardown already
812
- // skipped (review fix, 2026-09-17).
813
- ctx.effect(() => {
814
- return () => {
815
- disposed = true
816
- if (pollTimer !== undefined) clearInterval(pollTimer)
817
- const detach = () => {
818
- if (httpProxyPolicy === undefined) return
819
- const policy = httpProxyPolicy
820
- httpProxyPolicy = undefined
821
- void Promise.resolve(policy()).catch(() => {})
822
- }
823
- if (syncing !== null) {
824
- void Promise.resolve(syncing).catch(() => {}).finally(detach)
825
- } else {
826
- detach()
827
- }
828
- }
829
- }, 'dsh-proxy-pro: cleanup')
830
- }
831
-
1
+ /**
2
+ * dsh-proxy-pro — host half (Cordis plugin).
3
+ *
4
+ * Routes the whole runtime through a proxy when enabled:
5
+ * - model requests and plain fetch(): the undici global dispatcher
6
+ * - web_search / web_fetch: the dsh-http-proxy POLICY that dsh-web-fetch-http's
7
+ * proxyRouteFor() reads (the KEY fix the stock dsh-plugin-proxy lacks — see
8
+ * docs/LESSONS.md §2-§3)
9
+ * - spawned tools: HTTP_PROXY / HTTPS_PROXY / ALL_PROXY / NO_PROXY are
10
+ * written into process.env
11
+ *
12
+ * Everything goes through ONE channel: `installProxyFromEnvironment(envLike)`.
13
+ * That call owns the environment, the global dispatcher, and the module-level
14
+ * policy state as one coherent unit, so the plugin never touches undici
15
+ * directly and no dispatcher/instance skew is possible (LESSONS §3: mixed
16
+ * undici instances → UND_ERR_INVALID_ARG).
17
+ *
18
+ * A "never applied yet" guard keeps boot with `enabled: false` a strict no-op
19
+ * on the transport, so dsh-proxy-pro can coexist with dsh-plugin-proxy during
20
+ * migration (NFR-4: only the row whose switch is ON owns the dispatcher).
21
+ *
22
+ * Configuration lives in the `proxy` settings namespace (Settings → 代理管理),
23
+ * layered over the composition entry (cordis.patch.yml).
24
+ *
25
+ * @module dsh-proxy-pro
26
+ */
27
+ import z from '@deepseek-ai/schemastery'
28
+ import { defineTool } from '@deepseek-ai/dsh-tools'
29
+ import { installProxyFromEnvironment, proxyRouteFor } from '@deepseek-ai/dsh-http-proxy'
30
+ import { execFile } from 'node:child_process'
31
+ import { promisify } from 'node:util'
32
+ import {
33
+ classifyProbeStatus,
34
+ classifyTargetFailure,
35
+ combineDualProbe,
36
+ configPayload,
37
+ headNeedsGetRetry,
38
+ keepaliveHint,
39
+ makeSystemProxyReader,
40
+ normalizeProxyUrl,
41
+ readProxyConfig,
42
+ resolveEntryId,
43
+ resolveProxyState,
44
+ summarize,
45
+ systemFactsEqual,
46
+ } from './proxy-core.js'
47
+ import {
48
+ PROXY_CONFIG_PARAMETERS,
49
+ PROXY_SET_PARAMETERS,
50
+ PROXY_STATUS_SCHEMA,
51
+ PROXY_TEST_PARAMETERS,
52
+ PROXY_TEST_SCHEMA,
53
+ } from './tool-schemas.js'
54
+
55
+ const execFileAsync = promisify(execFile)
56
+
57
+ /** Cordis plugin name (also the bundle row id in cordis.patch.yml). */
58
+ const name = 'dsh-proxy-pro'
59
+
60
+ /** Services this plugin must resolve before it applies. */
61
+ const inject = ['tools', 'systemPrompt', 'settings']
62
+
63
+ /** Settings namespace owning the proxy configuration (shared with stock
64
+ * tooling). Plain literal: the legacy settingsNamespace() helper was removed
65
+ * from newer dsh-settings, and 'proxy' is already a valid lowercase-hyphenated
66
+ * namespace identifier. */
67
+ const PROXY_NS = 'proxy'
68
+
69
+ /** Ordered placement of the proxy status section in the system prompt. */
70
+ const SECTION_ORDER = 55
71
+
72
+ /** Base path for the status HTTP API (client polls this). */
73
+ const ROUTE_BASE = '/dsh-proxy-pro/api'
74
+
75
+ /**
76
+ * Mark one Config field live-editable.
77
+ *
78
+ * dsh >= 0.2 only accepts a settings write whose paths sit under a field whose
79
+ * schema node carries `meta.volatile` (`volatileForm`/`isVolatilePath` in
80
+ * dsh-settings); an unmarked schema answers `Plugin entry "…" has no volatile
81
+ * fields` and every toggle bounces straight back (LESSONS §36). The guard keeps
82
+ * the definition loadable on older schemastery builds without `.volatile()`.
83
+ *
84
+ * @param field - a schemastery field.
85
+ * @returns the same field, marked volatile when the API exists.
86
+ */
87
+ const volatile = (field) => (typeof field?.volatile === 'function' ? field.volatile() : field)
88
+
89
+ /** Composition-row configuration (also the settings schema). */
90
+ const Config = z.object({
91
+ enabled: volatile(z.boolean().default(false)),
92
+ // 'none' kept only for parsing pre-removal settings.yaml values; the UI and
93
+ // proxy_config never produce it anymore (LESSONS §17.3e).
94
+ mode: volatile(z.union([z.const('system'), z.const('custom'), z.const('none')]).default('system')),
95
+ customUrl: volatile(z.string().default('http://127.0.0.1:7890')),
96
+ noProxy: volatile(z.string().default('localhost,127.0.0.1,::1')),
97
+ // Deliberately NOT volatile: the poll interval is read once at apply time,
98
+ // so promising a live edit here would be a lie. It stays a composition knob.
99
+ systemPollMs: z.number().default(30000).min(0),
100
+ })
101
+
102
+ /** Every proxy environment name dsh-http-proxy's resolver can read. */
103
+ const PROXY_ENV_NAMES = [
104
+ 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY',
105
+ 'http_proxy', 'https_proxy', 'all_proxy', 'no_proxy',
106
+ ]
107
+
108
+ /**
109
+ * The virtual environment snapshot for one effective state, or null when the
110
+ * proxy is inactive. installProxyFromEnvironment resolves its policy purely
111
+ * from this (env.get must return `{ value }` or undefined); the lower-case
112
+ * names win in resolution, the upper-case ones are the published form.
113
+ */
114
+ function proxyEnvMap(effective) {
115
+ if (effective?.active !== true || effective.url === null || effective.url === undefined) return null
116
+ const http = effective.http ?? effective.url
117
+ const https = effective.https ?? effective.url
118
+ const noProxy = effective.noProxy ?? ''
119
+ return {
120
+ HTTP_PROXY: http, HTTPS_PROXY: https, ALL_PROXY: effective.url, NO_PROXY: noProxy,
121
+ http_proxy: http, https_proxy: https, all_proxy: effective.url, no_proxy: noProxy,
122
+ }
123
+ }
124
+
125
+ /** Build the envLike snapshot `installProxyFromEnvironment` expects (LESSONS §4). */
126
+ function makeEnvLike(effective) {
127
+ const map = proxyEnvMap(effective)
128
+ return {
129
+ get: (target) => {
130
+ const value = map === null ? undefined : map[target]
131
+ return value === undefined ? undefined : { value }
132
+ },
133
+ }
134
+ }
135
+
136
+ /** Build the agent-facing status text from the latest sync snapshot. */
137
+ function renderStatusText(snapshot) {
138
+ const effective = snapshot?.effective
139
+ const config = snapshot?.config
140
+ if (effective === undefined || config === undefined) {
141
+ return 'Proxy: not yet initialized.'
142
+ }
143
+ const active = effective.active === true
144
+ const mode = config.mode ?? 'none'
145
+ if (!active) {
146
+ const reason = effective.reason === 'system-proxy-off'
147
+ ? 'the Windows system proxy is currently off'
148
+ : effective.reason === 'invalid-custom-url'
149
+ ? 'the custom proxy address is invalid'
150
+ : effective.reason === 'mode-none'
151
+ ? 'the mode is set to "none"'
152
+ : 'the proxy switch is off'
153
+ return [
154
+ '## Proxy status: OFF',
155
+ '',
156
+ `Network traffic currently goes DIRECT (no proxy): ${reason}.`,
157
+ 'If a network operation fails and you suspect it needs a proxy, call `proxy_status` to confirm, then `proxy_set` with enabled=true — or `proxy_config` to change the mode, the address, or the direct/NO_PROXY list.',
158
+ ].join('\n')
159
+ }
160
+ const source = mode === 'system' ? 'Windows system proxy' : 'custom address'
161
+ return [
162
+ '## Proxy status: ON',
163
+ '',
164
+ `The whole runtime (model requests, web_search/web_fetch, and all spawned tools) is routed through the proxy: ${effective.url} (${source}).`,
165
+ '',
166
+ `Bypass list (NO_PROXY, traffic goes direct): ${effective.noProxy || '(none)'}.`,
167
+ '',
168
+ 'Implications you MUST respect:',
169
+ '- Assume every outbound HTTP(S) request goes through this proxy unless the host is on the bypass list.',
170
+ '- Localhost / 127.0.0.1 / ::1 and anything in the bypass list always go direct — use them for local services.',
171
+ '- If an operation must NOT go through the proxy (an internal network target, a local server, a host the proxy would break), call `proxy_set` with enabled=false first, run the operation, then re-enable with enabled=true.',
172
+ '- To change the configuration itself — switch between the system proxy and a custom address, or edit the direct/NO_PROXY list so some hosts bypass the proxy — call `proxy_config`; it persists the change and applies it immediately.',
173
+ '- Do not silently retry failed proxy attempts with a different tool expecting a different route; check `proxy_status` and the failure reason first.',
174
+ ].join('\n')
175
+ }
176
+
177
+ // classifyTargetFailure lives in proxy-core.js (pure logic, unit-tested) so the
178
+ // panel's verdict can name the undici cause instead of a bare "fetch failed".
179
+
180
+ // Forced-channel probing uses undici directly. Load lazily and never let a
181
+ // resolution failure block boot (LESSONS §14: boot must not depend on a new
182
+ // bare import).
183
+ let undiciPromise = null
184
+ function getUndici() {
185
+ if (undiciPromise === null) {
186
+ undiciPromise = import('undici').then((m) => m, () => null)
187
+ }
188
+ return undiciPromise
189
+ }
190
+
191
+ const PROBE_UA = 'dsh-proxy-pro/0.1'
192
+ const PROBE_TIMEOUT_MS = 6000
193
+
194
+ /**
195
+ * 一个探测尝试:先 HEAD,HEAD 结论不可信(404/405/501)时再用 GET 复测——
196
+ * 405/501 是「不支持该方法」,404 是网关不给 HEAD 注册路由时的伪装
197
+ * (LESSONS §27:DashScope `HEAD /models` → 404,同路径 GET → 401),只用
198
+ * HEAD 会把健康端点误判成「路径不存在」。`request` 是调用方的 fetch:两条
199
+ * 通道都用 app undici 的 `und.fetch` 配同副本 dispatcher(LESSONS §22/§25
200
+ * —— 绝不要把两份副本混在一起)。
201
+ *
202
+ * @returns classifyProbeStatus / classifyTargetFailure 的判定。
203
+ */
204
+ async function probeHttp(request, target, dispatcher) {
205
+ const attempt = async (method) => {
206
+ const controller = new AbortController()
207
+ const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS)
208
+ try {
209
+ const init = { method, redirect: 'manual', signal: controller.signal, headers: { 'user-agent': PROBE_UA } }
210
+ if (method === 'GET') init.headers.range = 'bytes=0-0'
211
+ if (dispatcher !== undefined) init.dispatcher = dispatcher
212
+ const res = await request(target, init)
213
+ return { res }
214
+ } catch (error) {
215
+ return { error }
216
+ } finally {
217
+ clearTimeout(timer)
218
+ }
219
+ }
220
+ const head = await attempt('HEAD')
221
+ if (head.error !== undefined) return classifyTargetFailure(head.error)
222
+ const verdict = classifyProbeStatus(head.res.status)
223
+ if (verdict.kind !== 'http-error' || !headNeedsGetRetry(head.res.status)) return verdict
224
+ const get = await attempt('GET')
225
+ if (get.error !== undefined) return classifyTargetFailure(get.error)
226
+ const retried = classifyProbeStatus(get.res.status)
227
+ const sameStatus = get.res.status === head.res.status
228
+ retried.note = sameStatus
229
+ ? `HEAD 与 GET 均返回 ${get.res.status} — 该路径确实不可用。`
230
+ : retried.ok === true
231
+ ? `HEAD 返回 ${head.res.status},GET 得到 ${get.res.status} — 以 GET 为准(网关常不给 HEAD 注册路由)。`
232
+ : `HEAD 返回 ${head.res.status},GET 得到 ${get.res.status} — 以 GET 为准。`
233
+ return retried
234
+ }
235
+
236
+ /**
237
+ * Force the probe down one specific channel, bypassing the routing policy:
238
+ * `'proxy'` goes through the configured proxy unconditionally, `'direct'`
239
+ * ignores the proxy entirely. Used by the panel diagnostic's 「使用代理」
240
+ * checkbox so the user can decide whether a host belongs in NO_PROXY.
241
+ */
242
+ async function forceChannelProbe(url, channel, proxyUrl) {
243
+ const und = await getUndici()
244
+ if (und === null) {
245
+ return { ok: false, verdict: 'unusable', kind: 'unavailable', short: '强制通道不可用(undici 未能加载)',
246
+ why: '宿主进程无法加载 app 侧 undici 包。',
247
+ fix: '取消勾选「使用代理」改用策略通道,或重启 DSH;仍出现请反馈插件版本。' }
248
+ }
249
+ let dispatcher
250
+ try {
251
+ dispatcher = channel === 'direct'
252
+ ? new und.Agent({ connections: 1 })
253
+ : new und.ProxyAgent(proxyUrl)
254
+ } catch (error) {
255
+ return { ok: false, verdict: 'unusable', kind: 'bad-proxy', short: `代理地址无效:${String(error?.message ?? error)}`,
256
+ why: '当前生效的代理地址不是有效的 http(s) 代理 URL。',
257
+ fix: '在「代理配置」修复地址(mode = 自定义地址)或清空自定义地址。' }
258
+ }
259
+ try {
260
+ // MUST issue the request with THIS undici copy. The host runs two copies —
261
+ // Node's built-in `fetch` and the app's `undici` package — and a dispatcher
262
+ // built by one handed to the other rejects with UND_ERR_INVALID_ARG
263
+ // ("invalid onRequestStart method"); the panel rendered that as a bare
264
+ // "fetch failed" for BOTH forced channels (LESSONS §22). und.fetch and the
265
+ // Agent/ProxyAgent above come from the same import, so they always agree.
266
+ return await probeHttp((u, i) => und.fetch(u, i), url, dispatcher)
267
+ } finally {
268
+ try { dispatcher.close?.().catch?.(() => {}) } catch { /* best effort */ }
269
+ }
270
+ }
271
+
272
+ /**
273
+ * 按已安装策略探测,与 web_fetch 走同一条路:用同一份 undici 的 fetch 加
274
+ * 全局 dispatcher。策略通道不能用 Node 内置 `fetch`——全局 dispatcher 由
275
+ * app 的 undici(installProxyFromEnvironment)安装,内置 fetch 拿它必然
276
+ * UND_ERR_INVALID_ARG(LESSONS §25,A 组合;面板当时显示"dispatcher
277
+ * rejected… mixed undici copies")。
278
+ */
279
+ async function policyProbe(target) {
280
+ const und = await getUndici()
281
+ if (und === null) {
282
+ return { ok: false, verdict: 'unusable', kind: 'unavailable', short: '策略通道不可用(undici 未能加载)',
283
+ why: '宿主进程无法加载 app 侧 undici 包。', fix: '重启 DSH;仍出现请反馈插件版本。' }
284
+ }
285
+ const dispatcher = typeof und.getGlobalDispatcher === 'function' ? und.getGlobalDispatcher() : undefined
286
+ return probeHttp((u, i) => und.fetch(u, i), target, dispatcher)
287
+ }
288
+
289
+ /**
290
+ * 按 web_fetch 的方式探测一个目标,**同时**强制走代理 + 强制直连两个通道,
291
+ * 再把两侧结果综合成一条人话推荐(combineDualProbe)。
292
+ *
293
+ * 单侧探测无法判断被拒是谁干的(目标 or 代理?)——只有两侧对比才能得出
294
+ * 方向性结论(直连通+代理失败 → 问题在代理一侧;代理通+直连失败 → 代理是
295
+ * 唯一通路)。`channel` 保留兼容:'policy'/'proxy'/'direct' 仍是旧的单通道
296
+ * 探测(HTTP API 的历史 useProxy 参数使用),默认 'dual' 走双通道并行。
297
+ */
298
+ async function probeTarget(url, snapshot, channel = 'dual') {
299
+ let parsed
300
+ try {
301
+ parsed = new URL(url)
302
+ } catch {
303
+ return { url, route: 'invalid', bypassed: false, channel,
304
+ proxy: { ok: false, verdict: 'unusable', kind: 'invalid', short: 'URL 无效 — 需要 http(s)://主机/路径',
305
+ why: '输入不是可解析的绝对 URL。', fix: '用完整形式,如 https://github.com/foo/bar。' },
306
+ direct: { ok: false, verdict: 'unusable', kind: 'invalid', short: 'URL 无效 — 需要 http(s)://主机/路径',
307
+ why: '输入不是可解析的绝对 URL。', fix: '用完整形式,如 https://github.com/foo/bar。' },
308
+ verdict: 'both-bad', short: 'URL 无效', why: '输入不是可解析的绝对 URL。', fix: '用完整形式,如 https://github.com/foo/bar。' }
309
+ }
310
+ let routeName = 'DIRECT'
311
+ try {
312
+ const routeProbe = proxyRouteFor(parsed)
313
+ routeName = routeProbe.proxied ? `PROXIED ${routeProbe.proxy}` : 'DIRECT'
314
+ } catch {
315
+ // proxyRouteFor unavailable in this composition → rely on state snapshot
316
+ }
317
+ const noProxyList = snapshot?.effective?.noProxy ?? ''
318
+ const bypassed = noProxyList.split(',').some((entry) => {
319
+ const host = entry.trim().replace(/^\*?\./, '').toLowerCase()
320
+ return host.length > 0 && (host === parsed.hostname.toLowerCase() || parsed.hostname.toLowerCase().endsWith(`.${host}`))
321
+ })
322
+
323
+ // 兼容旧单通道调用(HTTP API 历史 useProxy 参数)
324
+ if (channel === 'proxy' || channel === 'direct') {
325
+ const proxyUrl = channel === 'proxy' ? (snapshot?.effective?.url ?? '') : ''
326
+ const probe = proxyUrl === '' && channel === 'proxy'
327
+ ? { ok: false, verdict: 'unusable', kind: 'bad-proxy', short: '当前快照里没有可用的代理地址',
328
+ why: '代理开关处于关闭(或 Windows 系统代理关闭),没有可强制走代理的对象。',
329
+ fix: '打开代理,或在「代理配置」里修复地址/模式。' }
330
+ : await forceChannelProbe(parsed, channel, proxyUrl)
331
+ return { url, route: routeName, bypassed, channel, probe }
332
+ }
333
+ if (channel === 'policy') {
334
+ const probe = await policyProbe(parsed)
335
+ return { url, route: routeName, bypassed, channel, probe }
336
+ }
337
+
338
+ // 双通道并行:强制走代理 + 强制直连,各自独立探测(互不干扰)
339
+ const proxyUrl = snapshot?.effective?.url ?? ''
340
+ const proxyProbe = proxyUrl === ''
341
+ ? { ok: false, verdict: 'unusable', kind: 'bad-proxy', short: '当前快照里没有可用的代理地址',
342
+ why: '代理开关处于关闭(或 Windows 系统代理关闭),没有可强制走代理的对象。',
343
+ fix: '打开代理,或在「代理配置」里修复地址/模式。' }
344
+ : await forceChannelProbe(parsed, 'proxy', proxyUrl)
345
+ const directProbe = await forceChannelProbe(parsed, 'direct', '')
346
+ const combined = combineDualProbe(proxyProbe, directProbe)
347
+ return {
348
+ url, route: routeName, bypassed, channel: 'dual',
349
+ proxy: proxyProbe, direct: directProbe,
350
+ verdict: combined.verdict, short: combined.short,
351
+ why: combined.why, fix: combined.fix,
352
+ proxyBadge: combined.proxyBadge, directBadge: combined.directBadge,
353
+ }
354
+ }
355
+
356
+ // ---- browser-trust fence (mirrors dsh's /api fence) ------------------------
357
+ // dsh's own RPC channels run every request through isTrustedApiRequest
358
+ // (dsh-client-connection): Host must be loopback (DNS rebinding cannot forge
359
+ // Host), sec-fetch-site=cross-site is refused (browser CSRF marker), and an
360
+ // attached Origin must be same-host. Our panel API is registered directly on
361
+ // ctx.webServer, so we replicate that fence here — the same implementation
362
+ // dsh-skill-manager ships (its lib/index.js L131-172), matching the loopback
363
+ // verdict of the official fence with zero extra dependencies.
364
+
365
+ /** Normalized URL of a Host-header authority (hostname lowercased), or undefined. */
366
+ function parseHostHeader(host) {
367
+ if (typeof host !== 'string' || !host) return undefined
368
+ try {
369
+ return new URL(`http://${host}`).hostname.toLowerCase()
370
+ } catch {
371
+ return undefined
372
+ }
373
+ }
374
+
375
+ /** localhost | [::1] | any IPv4 in 127/8 — the loopback authority set. */
376
+ function isLoopbackHostname(hostname) {
377
+ if (hostname === 'localhost' || hostname === '[::1]') return true
378
+ const parts = hostname.split('.')
379
+ return parts.length === 4 && parts[0] === '127'
380
+ && parts.every((p) => /^\d{1,3}$/.test(p) && Number(p) <= 255)
381
+ }
382
+
383
+ /** True when the Host is loopback and any attached browser markers are same-origin. */
384
+ function isTrustedPanelRequest(headers) {
385
+ const host = typeof headers?.get === 'function' ? headers.get('host') : headers?.host
386
+ const hostname = parseHostHeader(host)
387
+ if (!hostname || !isLoopbackHostname(hostname)) return false
388
+ if (headers?.get && headers.get('sec-fetch-site') === 'cross-site') return false
389
+ if (!headers?.get && headers?.['sec-fetch-site'] === 'cross-site') return false
390
+ const originRaw = typeof headers?.get === 'function' ? headers.get('origin') : headers?.origin
391
+ if (originRaw === undefined || originRaw === null || originRaw === '') return true
392
+ try {
393
+ return new URL(originRaw).hostname.toLowerCase() === hostname
394
+ } catch {
395
+ return false
396
+ }
397
+ }
398
+
399
+ /** Write 403 and return true when the request fails the fence (handler calls first). */
400
+ function denyIfUntrusted(req, res) {
401
+ if (isTrustedPanelRequest(req.headers)) return false
402
+ try {
403
+ res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' })
404
+ res.end('forbidden')
405
+ } catch {
406
+ /* client gone */
407
+ }
408
+ return true
409
+ }
410
+
411
+ /**
412
+ * Cordis plugin body.
413
+ * @param ctx - registrant context.
414
+ * @param config - validated composition configuration.
415
+ */
416
+ function apply(ctx, config) {
417
+ const systemProxyReader = makeSystemProxyReader(execFileAsync)
418
+ let source = () => config
419
+ let snapshot = undefined
420
+ let httpProxyPolicy = undefined
421
+ let syncing = null
422
+ let disposed = false
423
+ let haveApplied = false
424
+ let lastAppliedKey = undefined
425
+ let pollTimer = undefined
426
+ let lastSystemFacts = null
427
+
428
+ /** Read all inputs (config + system facts), apply, and record the snapshot. */
429
+ const sync = async () => {
430
+ if (disposed) return
431
+ const resolved = source()
432
+ let systemProxy = null
433
+ // System facts are only consulted in mode 'system': custom mode keeps the
434
+ // configured whitelist alone, so no registry read is needed there (§17.3f).
435
+ if (resolved.mode === 'system' && resolved.enabled === true) {
436
+ try {
437
+ systemProxy = await systemProxyReader()
438
+ lastSystemFacts = systemProxy
439
+ } catch (error) {
440
+ ctx.logger.warn('dsh-proxy-pro: reading the Windows system proxy failed: %s', String(error))
441
+ }
442
+ }
443
+ const effective = resolveProxyState(resolved, systemProxy)
444
+ const active = effective.active === true && effective.url !== null
445
+ // Idempotency (LESSONS §17): when the effective transport is unchanged we
446
+ // refresh the rich snapshot but MUST NOT rebuild env/dispatcher — sync is
447
+ // a rebuild only when the effective state actually changed. Pollers may
448
+ // call requestSync() freely; the cost of an unchanged pass is a snapshot.
449
+ // http/https are part of the key: a https-only change (same url) must
450
+ // still rebuild so HTTPS_PROXY actually switches (review fix, 2026-09-17).
451
+ const effKey = JSON.stringify([active, effective.url ?? null, effective.http ?? null, effective.https ?? null, effective.noProxy ?? null, effective.reason ?? 'unknown'])
452
+ if (lastAppliedKey !== undefined && lastAppliedKey === effKey) {
453
+ snapshot = { config: resolved, effective, systemProxy, at: new Date().toISOString() }
454
+ ctx.emit('dsh-proxy-pro/status', summarize(snapshot))
455
+ return
456
+ }
457
+ // Guard: while we have never applied anything, OFF stays a transport
458
+ // no-op so boot with the default switch never touches env/dispatcher
459
+ // (coexistence with dsh-plugin-proxy during migration, NFR-4).
460
+ if (!active && !haveApplied) {
461
+ snapshot = { config: resolved, effective, systemProxy, at: new Date().toISOString() }
462
+ ctx.emit('dsh-proxy-pro/status', summarize(snapshot))
463
+ return
464
+ }
465
+ if (active) haveApplied = true
466
+ // Single transport channel: installProxyFromEnvironment owns process.env,
467
+ // the global dispatcher, and the module-level policy that proxyRouteFor()
468
+ // reads. Uninstall the previous policy before (re)installing so the
469
+ // package's active/installed state never goes stale (LESSONS §4).
470
+ try {
471
+ if (httpProxyPolicy !== undefined) {
472
+ // Do NOT await the disposer: it restores dispatcher + env synchronously
473
+ // (setGlobalDispatcher / restoreEnv run before the first await inside),
474
+ // then awaits agent.close() — undici's graceful close drains every
475
+ // keep-alive connection, which with the whole desktop's traffic in
476
+ // flight can block this turn for seconds. Fire it, keep the install
477
+ // serial, and let the pool drain in the background (LESSONS §17.3b).
478
+ const dispose = httpProxyPolicy
479
+ httpProxyPolicy = undefined
480
+ void dispose().catch(() => {})
481
+ }
482
+ const envLike = makeEnvLike(effective)
483
+ httpProxyPolicy = await installProxyFromEnvironment(envLike, (m) => ctx.logger.warn('dsh-proxy-pro: dsh-http-proxy: %s', m))
484
+ lastAppliedKey = effKey
485
+ } catch (error) {
486
+ ctx.logger.warn('dsh-proxy-pro: applying the proxy policy failed: %s', String(error))
487
+ }
488
+ snapshot = { config: resolved, effective, systemProxy, at: new Date().toISOString() }
489
+ ctx.emit('dsh-proxy-pro/status', summarize(snapshot))
490
+ }
491
+
492
+ /** Coalesced sync: concurrent triggers share one in-flight pass. */
493
+ const requestSync = () => {
494
+ if (disposed) return Promise.resolve()
495
+ if (syncing !== null) return syncing
496
+ syncing = sync().finally(() => { syncing = null })
497
+ return syncing
498
+ }
499
+
500
+ // Configuration source — two settings generations (LESSONS §26, §33):
501
+ // - dsh <= 0.1.x: a per-plugin namespace registered through the settings
502
+ // service (register(ns, schema, {base}) → scope.get()/watch()).
503
+ // - dsh >= 0.2: register()/watch() are gone. The plugin's OWN composition
504
+ // entry IS the settings form (namespace = entry id, schema = this Config),
505
+ // writes go through settings.update(entryId, patch), the live values are
506
+ // the Config's volatile references, and Loader announces a volatile-only
507
+ // commit with `loader/volatile-update`.
508
+ const settingsService = ctx.get('settings')
509
+ const configEditor = ctx.get('configEditor')
510
+ const legacyRegister = typeof settingsService?.register === 'function'
511
+ const entryId = resolveEntryId(ctx, configEditor)
512
+ if (legacyRegister) {
513
+ const proxyScope = settingsService.register(PROXY_NS, Config, { base: config })
514
+ source = () => proxyScope.get()
515
+ proxyScope.watch(() => {
516
+ void requestSync().catch(() => {})
517
+ })
518
+ } else if (typeof settingsService?.update === 'function') {
519
+ // Documented volatile contract (cordis README, dsh-settings README, and the
520
+ // host Service/Event Inspect providers — LESSONS §36/§38): resolved
521
+ // `.volatile()` fields are `Volatile<T>` references read through `.get()`,
522
+ // and a volatile-only edit is committed into that same reference. The
523
+ // catalogued notification is `settings/document-updated(ns, revision)`; the
524
+ // loader also emits its internal `loader/volatile-update` on this fiber, so
525
+ // both are observed (sync is idempotent).
526
+ source = () => readProxyConfig(config)
527
+ ctx.on('settings/document-updated', (ns) => {
528
+ if (ns === entryId) void requestSync().catch(() => {})
529
+ })
530
+ ctx.on('loader/volatile-update', () => {
531
+ void requestSync().catch(() => {})
532
+ })
533
+ }
534
+ // We ship our own settings page (代理配置), so opt out of the auto-generated
535
+ // form — the documented pattern is an effect inside an injected child, so a
536
+ // late-loading Settings service still picks the policy up.
537
+ if (typeof ctx.inject === 'function') {
538
+ ctx.inject(['settings'], (child) => {
539
+ child.effect(() => {
540
+ try {
541
+ return child.settings.configure({ auto: false }, ctx.fiber)
542
+ } catch (error) {
543
+ ctx.logger.warn('dsh-proxy-pro: settings page policy not applied: %s', String(error))
544
+ return () => {}
545
+ }
546
+ }, 'dsh-proxy-pro: settings page policy')
547
+ })
548
+ }
549
+ // Without either generation the plugin still runs read-only on the
550
+ // composition config (source keeps returning it).
551
+
552
+ /** Whether this composition can persist a configuration write at all: a
553
+ * read-only composition must not offer enabled controls in the panel. */
554
+ const settingsWritable = settingsService !== undefined
555
+ && (legacyRegister || typeof settingsService.update === 'function')
556
+
557
+ /** Persist one config patch through whichever settings generation is present. */
558
+ const writeSettings = async (patch) => {
559
+ if (!settingsWritable) {
560
+ throw new Error('the settings service is unavailable in this composition — change the proxy configuration in the composition instead')
561
+ }
562
+ await settingsService.update(legacyRegister ? PROXY_NS : entryId, patch)
563
+ }
564
+
565
+ // ---------- Agent tools ----------
566
+
567
+ ctx.tools.register(defineTool({
568
+ name: 'proxy_status',
569
+ description: 'Show the current proxy state of this runtime: whether traffic goes through a proxy, the effective proxy address, the NO_PROXY bypass list, and the source of the setting. Call this before any network operation where the proxy route matters, and whenever a network operation fails unexpectedly.',
570
+ parameters: {},
571
+ output: {
572
+ schema: PROXY_STATUS_SCHEMA,
573
+ render: (_args, value) => {
574
+ const head = value.active
575
+ ? `Proxy ON — ${value.url} (${value.source === 'system' ? 'system' : 'custom'})`
576
+ : `Proxy OFF — direct connection (${value.reason})`
577
+ const lines = [head, `NO_PROXY bypass: ${value.noProxy || '(none)'}`]
578
+ return [{ type: 'text', text: lines.join('\n') }]
579
+ },
580
+ },
581
+ execute: async () => {
582
+ await requestSync()
583
+ return summarize(snapshot)
584
+ },
585
+ presentCall: () => ({
586
+ card: 'generic',
587
+ title: 'Proxy status',
588
+ kind: 'other',
589
+ rawInput: {},
590
+ }),
591
+ }))
592
+
593
+ ctx.tools.register(defineTool({
594
+ name: 'proxy_set',
595
+ description: 'Turn the proxy on or off for the whole runtime. Use enabled=false when an operation must go DIRECT (an internal/intranet target, a local server, or a host the proxy would break), then re-enable with enabled=true afterwards. The change is persisted and takes effect immediately for model requests, web fetches, and spawned tools.',
596
+ parameters: PROXY_SET_PARAMETERS,
597
+ output: {
598
+ schema: PROXY_STATUS_SCHEMA,
599
+ render: (_args, value) => [{
600
+ type: 'text',
601
+ text: value.active
602
+ ? `Proxy enabled — ${value.url} (${value.source === 'system' ? 'system' : 'custom'})`
603
+ : `Proxy disabled — traffic now goes direct.`,
604
+ }],
605
+ },
606
+ execute: async (args) => {
607
+ try {
608
+ await writeSettings({ enabled: args.enabled === true })
609
+ } catch (error) {
610
+ throw new Error(`proxy_set: ${error?.message ?? error}`)
611
+ }
612
+ await requestSync()
613
+ return summarize(snapshot)
614
+ },
615
+ presentCall: (args) => ({
616
+ card: 'generic',
617
+ title: `Proxy ${args.enabled === true ? 'on' : 'off'}`,
618
+ kind: 'other',
619
+ rawInput: args,
620
+ }),
621
+ }))
622
+
623
+ ctx.tools.register(defineTool({
624
+ name: 'proxy_test',
625
+ description: 'Diagnose whether a target URL will route through the proxy or direct, and probe actual connectivity. Use when a fetch/git/curl to a host fails unexpectedly: it reports the route web_fetch will take (PROXIED vs DIRECT), whether the host is bypassed by NO_PROXY, a live connectivity probe, and an NTLM keepalive hint when the proxy asks for authentication.',
626
+ parameters: PROXY_TEST_PARAMETERS,
627
+ output: {
628
+ schema: PROXY_TEST_SCHEMA,
629
+ render: (_args, value) => {
630
+ const badge = (b) => (b?.tone === 'ok' ? '🟢' : b?.tone === 'warn' ? '🟡' : '🔴') + ' ' + (b?.text ?? '未知')
631
+ const lines = [
632
+ `路由: ${value.route}`,
633
+ `NO_PROXY 命中: ${value.bypassed}`,
634
+ `状态码: 走代理 ${value.proxy?.status ?? '—'} · 直连 ${value.direct?.status ?? '—'}`,
635
+ `走代理: ${badge(value.proxyBadge)}`,
636
+ `直连: ${badge(value.directBadge)}`,
637
+ `结论: ${value.short ?? '未知'}`,
638
+ ]
639
+ if (value.why) lines.push(`可能原因: ${value.why}`)
640
+ if (value.fix) lines.push(`挽救措施: ${value.fix}`)
641
+ if (value.hint) lines.push(`提示: ${value.hint}`)
642
+ return [{ type: 'text', text: lines.join('\n') }]
643
+ },
644
+ },
645
+ execute: async (args) => {
646
+ await requestSync()
647
+ const result = await probeTarget(String(args.url ?? ''), snapshot)
648
+ result.hint = keepaliveHint(`${result.proxy?.short ?? ''} ${result.direct?.short ?? ''}`, snapshot?.effective?.url ?? '')
649
+ return result
650
+ },
651
+ presentCall: (args) => ({
652
+ card: 'generic',
653
+ title: 'Proxy test',
654
+ kind: 'other',
655
+ rawInput: args,
656
+ }),
657
+ }))
658
+
659
+ ctx.tools.register(defineTool({
660
+ name: 'proxy_config',
661
+ description: 'Read or change the proxy configuration: the switch (enabled), the source mode (system / custom / none), the custom proxy address, and the NO_PROXY direct-connection list. Call it with no arguments to read the current configuration, or with any subset of fields to change them. Changes are persisted and take effect immediately for model requests, web fetches, and spawned tools. Use `proxy_set` for a plain on/off toggle, and `proxy_test` to check how one URL routes.',
662
+ parameters: PROXY_CONFIG_PARAMETERS,
663
+ output: {
664
+ schema: PROXY_STATUS_SCHEMA,
665
+ render: (_args, value) => [{
666
+ type: 'text',
667
+ text: value.active
668
+ ? `Proxy config — mode=${value.mode}, ${value.url} (${value.source === 'system' ? 'system' : 'custom'}), bypass: ${value.noProxy || '(none)'}`
669
+ : `Proxy config — inactive (${value.reason}); mode=${value.mode}, bypass: ${value.noProxy || '(none)'}`,
670
+ }],
671
+ },
672
+ execute: async (args) => {
673
+ const patch = {}
674
+ if (args.enabled !== undefined) patch.enabled = args.enabled === true
675
+ if (args.mode !== undefined) patch.mode = args.mode
676
+ if (args.customUrl !== undefined) {
677
+ const raw = String(args.customUrl).trim()
678
+ if (raw === '') {
679
+ patch.customUrl = ''
680
+ } else {
681
+ const normalized = normalizeProxyUrl(raw)
682
+ if (normalized === null) {
683
+ throw new Error(`proxy_config: "${args.customUrl}" is not a usable proxy address — expected http(s)://host:port (a bare host:port is also accepted)`)
684
+ }
685
+ patch.customUrl = normalized
686
+ }
687
+ }
688
+ if (args.noProxy !== undefined) patch.noProxy = String(args.noProxy).trim()
689
+ if (Object.keys(patch).length > 0) await writeSettings(patch)
690
+ await requestSync()
691
+ return summarize(snapshot)
692
+ },
693
+ presentCall: (args) => ({
694
+ card: 'generic',
695
+ title: 'Proxy config',
696
+ kind: 'other',
697
+ rawInput: args,
698
+ }),
699
+ }))
700
+
701
+ // The model must always know whether the proxy is in force.
702
+ ctx.systemPrompt.section({
703
+ name: 'dsh-proxy-pro:status',
704
+ order: SECTION_ORDER,
705
+ text: () => renderStatusText(snapshot),
706
+ })
707
+
708
+ // ---------- Status HTTP API (for the client UI) ----------
709
+ const sendJson = (res, status, body) => {
710
+ try {
711
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
712
+ res.end(JSON.stringify(body))
713
+ } catch {
714
+ /* client gone */
715
+ }
716
+ }
717
+ const readJsonBody = async (req, maxBytes = 2 * 1024 * 1024) => {
718
+ const chunks = []
719
+ let size = 0
720
+ for await (const chunk of req) {
721
+ size += chunk.length
722
+ if (size > maxBytes) throw new Error('request body too large')
723
+ chunks.push(chunk)
724
+ }
725
+ const text = Buffer.concat(chunks).toString('utf8')
726
+ if (!text) return {}
727
+ try {
728
+ return JSON.parse(text)
729
+ } catch {
730
+ return {}
731
+ }
732
+ }
733
+
734
+ /**
735
+ * Register the status/config HTTP API on the web server.
736
+ *
737
+ * dsh 0.2 mounts `webServer` AFTER plugin rows have applied, so reading
738
+ * `ctx.get('webServer')` once inside apply() returned undefined and this
739
+ * whole block was skipped silently — the panel then reported "宿主未提供
740
+ * 配置接口" while everything else looked healthy (LESSONS §35). The 0.2
741
+ * idiom is `ctx.inject([...], (child) => …)`: it runs as soon as the service
742
+ * exists (immediately when it already does). The direct read stays as the
743
+ * fallback for older cores.
744
+ */
745
+ let apiRegistered = false
746
+ const registerApi = (webServer) => {
747
+ if (apiRegistered || webServer === undefined || typeof webServer.register !== 'function') return false
748
+ try {
749
+ const unregisterRoute = webServer.register({
750
+ kind: 'prefix',
751
+ path: ROUTE_BASE,
752
+ handler: async (req, res) => {
753
+ if (denyIfUntrusted(req, res)) return
754
+ const url = new URL(req.url ?? '/', 'http://localhost')
755
+ const is = (p, m) => url.pathname === `${ROUTE_BASE}${p}` && req.method === m
756
+ try {
757
+ if (is('/status', 'GET')) {
758
+ // Pull-triggered refresh: requestSync is idempotent — when the
759
+ // effKey is unchanged it only refreshes the snapshot (no
760
+ // dispatcher/env rebuild); only a real system-proxy change
761
+ // reinstalls. So refreshing here is cheap and every pull serves
762
+ // fresh facts. This is safe where the pre-096e431 /status was
763
+ // not: that one reinstalled UNCONDITIONALLY on every pull and
764
+ // dragged the desktop down (LESSONS §17). The 30s host poll
765
+ // still owns unattended following (page-closed changes); this
766
+ // exists only to serve the viewer.
767
+ await requestSync()
768
+ sendJson(res, 200, { ok: true, status: summarize(snapshot) })
769
+ return
770
+ }
771
+ if (is('/route', 'GET')) {
772
+ const target = url.searchParams.get('url') ?? ''
773
+ // useProxy 保留兼容:'1'/'0' 走旧的单通道强制;无参数(新版面板)走双通道
774
+ const useProxy = url.searchParams.get('useProxy')
775
+ const channel = useProxy === '1' ? 'proxy' : useProxy === '0' ? 'direct' : 'dual'
776
+ const result = await probeTarget(target, snapshot, channel)
777
+ result.hint = keepaliveHint(
778
+ `${result.proxy?.short ?? ''} ${result.direct?.short ?? ''}`.trim(),
779
+ snapshot?.effective?.url ?? ''
780
+ )
781
+ sendJson(res, 200, { ok: true, ...result })
782
+ return
783
+ }
784
+ if (is('/toggle', 'POST')) {
785
+ const body = await readJsonBody(req)
786
+ try {
787
+ await writeSettings({ enabled: body.enabled === true })
788
+ } catch (error) {
789
+ sendJson(res, 503, { ok: false, error: String(error?.message ?? error) })
790
+ return
791
+ }
792
+ await requestSync()
793
+ sendJson(res, 200, { ok: true, status: summarize(snapshot) })
794
+ return
795
+ }
796
+ // Editable configuration for the client panel. dsh >= 0.2 removed
797
+ // the browser settings transport (`ctx.settingsScope`), so the panel
798
+ // reads and writes through this API instead (LESSONS §33).
799
+ if (is('/config', 'GET')) {
800
+ sendJson(res, 200, { ok: true, ...configPayload(source(), settingsWritable) })
801
+ return
802
+ }
803
+ if (is('/config', 'POST')) {
804
+ const body = await readJsonBody(req)
805
+ const patch = {}
806
+ if (body.enabled !== undefined) patch.enabled = body.enabled === true
807
+ if (body.mode !== undefined) {
808
+ if (body.mode !== 'system' && body.mode !== 'custom') {
809
+ sendJson(res, 400, { ok: false, error: 'mode must be "system" or "custom"' })
810
+ return
811
+ }
812
+ patch.mode = body.mode
813
+ }
814
+ if (body.customUrl !== undefined) {
815
+ const raw = String(body.customUrl).trim()
816
+ if (raw === '') {
817
+ patch.customUrl = ''
818
+ } else {
819
+ const normalized = normalizeProxyUrl(raw)
820
+ if (normalized === null) {
821
+ sendJson(res, 400, { ok: false, error: `"${body.customUrl}" is not a usable proxy address — expected http(s)://host:port` })
822
+ return
823
+ }
824
+ patch.customUrl = normalized
825
+ }
826
+ }
827
+ if (body.noProxy !== undefined) patch.noProxy = String(body.noProxy).trim()
828
+ try {
829
+ if (Object.keys(patch).length > 0) await writeSettings(patch)
830
+ } catch (error) {
831
+ sendJson(res, 503, { ok: false, error: String(error?.message ?? error) })
832
+ return
833
+ }
834
+ await requestSync()
835
+ sendJson(res, 200, { ok: true, ...configPayload(source(), settingsWritable) })
836
+ return
837
+ }
838
+ sendJson(res, 404, { ok: false, error: 'not found' })
839
+ } catch (error) {
840
+ sendJson(res, 500, { ok: false, error: String(error?.message ?? error) })
841
+ }
842
+ },
843
+ })
844
+ ctx.effect(() => () => { try { unregisterRoute?.() } catch { /* best effort */ } }, 'dsh-proxy-pro: route cleanup')
845
+ apiRegistered = true
846
+ ctx.logger.info('dsh-proxy-pro: status API registered on %s', ROUTE_BASE)
847
+ return true
848
+ } catch (error) {
849
+ ctx.logger.warn('dsh-proxy-pro: webServer unavailable — status API disabled: %s', String(error))
850
+ return false
851
+ }
852
+ }
853
+
854
+ // Late-bound registration (dsh 0.2 mounts webServer after plugin rows).
855
+ if (typeof ctx.inject === 'function') {
856
+ ctx.inject(['webServer'], (childCtx) => { registerApi(childCtx.webServer) })
857
+ }
858
+ // Older cores where the service is already present at apply time.
859
+ registerApi(ctx.get('webServer'))
860
+
861
+ // Follow the Windows system proxy at runtime (change-only re-apply).
862
+ if (config.systemPollMs > 0) {
863
+ pollTimer = setInterval(async () => {
864
+ if (disposed) return
865
+ const resolved = source()
866
+ if (resolved.mode !== 'system' || resolved.enabled !== true) return
867
+ let facts
868
+ try {
869
+ facts = await systemProxyReader()
870
+ } catch {
871
+ return
872
+ }
873
+ if (lastSystemFacts !== null && systemFactsEqual(lastSystemFacts, facts)) return
874
+ void requestSync().catch(() => {})
875
+ }, config.systemPollMs)
876
+ pollTimer.unref?.()
877
+ }
878
+
879
+ // Tear down: restore env, dispatcher, and dsh-http-proxy policy. If a sync
880
+ // is still in flight (e.g. a status-API request), uninstall chained AFTER it
881
+ // — otherwise a late install would leak a policy that teardown already
882
+ // skipped (review fix, 2026-09-17).
883
+ ctx.effect(() => {
884
+ return () => {
885
+ disposed = true
886
+ if (pollTimer !== undefined) clearInterval(pollTimer)
887
+ const detach = () => {
888
+ if (httpProxyPolicy === undefined) return
889
+ const policy = httpProxyPolicy
890
+ httpProxyPolicy = undefined
891
+ void Promise.resolve(policy()).catch(() => {})
892
+ }
893
+ if (syncing !== null) {
894
+ void Promise.resolve(syncing).catch(() => {}).finally(detach)
895
+ } else {
896
+ detach()
897
+ }
898
+ }
899
+ }, 'dsh-proxy-pro: cleanup')
900
+ }
901
+
832
902
  export { Config, apply, inject, name }