@yanglaofish/dsh-proxy-pro 2.0.1 → 2.0.2

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