@yanglaofish/dsh-proxy-pro 1.0.0

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 ADDED
@@ -0,0 +1,693 @@
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 { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
30
+ import { installProxyFromEnvironment, proxyRouteFor } from '@deepseek-ai/dsh-http-proxy'
31
+ import { execFile } from 'node:child_process'
32
+ import { promisify } from 'node:util'
33
+ import {
34
+ keepaliveHint,
35
+ makeSystemProxyReader,
36
+ normalizeProxyUrl,
37
+ resolveProxyState,
38
+ summarize,
39
+ systemFactsEqual,
40
+ } from './proxy-core.js'
41
+ import {
42
+ PROXY_CONFIG_PARAMETERS,
43
+ PROXY_SET_PARAMETERS,
44
+ PROXY_STATUS_SCHEMA,
45
+ PROXY_TEST_PARAMETERS,
46
+ PROXY_TEST_SCHEMA,
47
+ } from './tool-schemas.js'
48
+
49
+ const execFileAsync = promisify(execFile)
50
+
51
+ /** Cordis plugin name (also the bundle row id in cordis.patch.yml). */
52
+ const name = 'dsh-proxy-pro'
53
+
54
+ /** Services this plugin must resolve before it applies. */
55
+ const inject = ['tools', 'systemPrompt', 'settings']
56
+
57
+ /** Settings namespace owning the proxy configuration (shared with stock tooling). */
58
+ const PROXY_NS = settingsNamespace('proxy')
59
+
60
+ /** Ordered placement of the proxy status section in the system prompt. */
61
+ const SECTION_ORDER = 55
62
+
63
+ /** Base path for the status HTTP API (client polls this). */
64
+ const ROUTE_BASE = '/dsh-proxy-pro/api'
65
+
66
+ /** Composition-row configuration (also the settings schema). */
67
+ const Config = z.object({
68
+ enabled: z.boolean().default(false),
69
+ // 'none' kept only for parsing pre-removal settings.yaml values; the UI and
70
+ // proxy_config never produce it anymore (LESSONS §17.3e).
71
+ mode: z.union([z.const('system'), z.const('custom'), z.const('none')]).default('system'),
72
+ customUrl: z.string().default('http://127.0.0.1:7890'),
73
+ noProxy: z.string().default('localhost,127.0.0.1,::1'),
74
+ systemPollMs: z.number().default(30000).min(0),
75
+ })
76
+
77
+ /** Every proxy environment name dsh-http-proxy's resolver can read. */
78
+ const PROXY_ENV_NAMES = [
79
+ 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY',
80
+ 'http_proxy', 'https_proxy', 'all_proxy', 'no_proxy',
81
+ ]
82
+
83
+ /**
84
+ * The virtual environment snapshot for one effective state, or null when the
85
+ * proxy is inactive. installProxyFromEnvironment resolves its policy purely
86
+ * from this (env.get must return `{ value }` or undefined); the lower-case
87
+ * names win in resolution, the upper-case ones are the published form.
88
+ */
89
+ function proxyEnvMap(effective) {
90
+ if (effective?.active !== true || effective.url === null || effective.url === undefined) return null
91
+ const http = effective.http ?? effective.url
92
+ const https = effective.https ?? effective.url
93
+ const noProxy = effective.noProxy ?? ''
94
+ return {
95
+ HTTP_PROXY: http, HTTPS_PROXY: https, ALL_PROXY: effective.url, NO_PROXY: noProxy,
96
+ http_proxy: http, https_proxy: https, all_proxy: effective.url, no_proxy: noProxy,
97
+ }
98
+ }
99
+
100
+ /** Build the envLike snapshot `installProxyFromEnvironment` expects (LESSONS §4). */
101
+ function makeEnvLike(effective) {
102
+ const map = proxyEnvMap(effective)
103
+ return {
104
+ get: (target) => {
105
+ const value = map === null ? undefined : map[target]
106
+ return value === undefined ? undefined : { value }
107
+ },
108
+ }
109
+ }
110
+
111
+ /** Build the agent-facing status text from the latest sync snapshot. */
112
+ function renderStatusText(snapshot) {
113
+ const effective = snapshot?.effective
114
+ const config = snapshot?.config
115
+ if (effective === undefined || config === undefined) {
116
+ return 'Proxy: not yet initialized.'
117
+ }
118
+ const active = effective.active === true
119
+ const mode = config.mode ?? 'none'
120
+ if (!active) {
121
+ const reason = effective.reason === 'system-proxy-off'
122
+ ? 'the Windows system proxy is currently off'
123
+ : effective.reason === 'invalid-custom-url'
124
+ ? 'the custom proxy address is invalid'
125
+ : effective.reason === 'mode-none'
126
+ ? 'the mode is set to "none"'
127
+ : 'the proxy switch is off'
128
+ return [
129
+ '## Proxy status: OFF',
130
+ '',
131
+ `Network traffic currently goes DIRECT (no proxy): ${reason}.`,
132
+ '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.',
133
+ ].join('\n')
134
+ }
135
+ const source = mode === 'system' ? 'Windows system proxy' : 'custom address'
136
+ return [
137
+ '## Proxy status: ON',
138
+ '',
139
+ `The whole runtime (model requests, web_search/web_fetch, and all spawned tools) is routed through the proxy: ${effective.url} (${source}).`,
140
+ '',
141
+ `Bypass list (NO_PROXY, traffic goes direct): ${effective.noProxy || '(none)'}.`,
142
+ '',
143
+ 'Implications you MUST respect:',
144
+ '- Assume every outbound HTTP(S) request goes through this proxy unless the host is on the bypass list.',
145
+ '- Localhost / 127.0.0.1 / ::1 and anything in the bypass list always go direct — use them for local services.',
146
+ '- 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.',
147
+ '- 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.',
148
+ '- Do not silently retry failed proxy attempts with a different tool expecting a different route; check `proxy_status` and the failure reason first.',
149
+ ].join('\n')
150
+ }
151
+
152
+ /** Isolate NetworkError-ish codes that mean "could not connect at all". */
153
+ function classifyTargetFailure(error) {
154
+ const cause = error?.cause
155
+ const code = cause?.code ?? error?.code ?? ''
156
+ if (code === 'EACCES' || code === 'ENETUNREACH' || code === 'EHOSTUNREACH' || code === 'ENOTFOUND' || code === 'ECONNREFUSED' || code === 'ETIMEDOUT') {
157
+ return { ok: false, kind: 'unreachable', short: `cannot reach directly (${code}) — needs a proxy route` }
158
+ }
159
+ if (/407|NTLM|authentication required/i.test(String(error?.message ?? ''))) {
160
+ return { ok: false, kind: 'auth', short: 'the proxy asks for NTLM authentication' }
161
+ }
162
+ return { ok: false, kind: 'other', short: (error?.message ?? String(error)).slice(0, 120) }
163
+ }
164
+
165
+ // Forced-channel probing uses undici directly. Load lazily and never let a
166
+ // resolution failure block boot (LESSONS §14: boot must not depend on a new
167
+ // bare import).
168
+ let undiciPromise = null
169
+ function getUndici() {
170
+ if (undiciPromise === null) {
171
+ undiciPromise = import('undici').then((m) => m, () => null)
172
+ }
173
+ return undiciPromise
174
+ }
175
+
176
+ /**
177
+ * Force the probe down one specific channel, bypassing the routing policy:
178
+ * `'proxy'` goes through the configured proxy unconditionally, `'direct'`
179
+ * ignores the proxy entirely. Used by the panel diagnostic's 「使用代理」
180
+ * checkbox so the user can decide whether a host belongs in NO_PROXY.
181
+ */
182
+ async function forceChannelProbe(url, channel, proxyUrl) {
183
+ const und = await getUndici()
184
+ if (und === null) {
185
+ return { ok: false, kind: 'unavailable', short: 'forced channel unavailable (undici could not be loaded)' }
186
+ }
187
+ let dispatcher
188
+ try {
189
+ dispatcher = channel === 'direct'
190
+ ? new und.Agent({ connections: 1 })
191
+ : new und.ProxyAgent(proxyUrl)
192
+ } catch (error) {
193
+ return { ok: false, kind: 'bad-proxy', short: `invalid proxy address: ${String(error?.message ?? error)}` }
194
+ }
195
+ const controller = new AbortController()
196
+ const timer = setTimeout(() => controller.abort(), 6000)
197
+ try {
198
+ const res = await fetch(url, {
199
+ method: 'HEAD',
200
+ redirect: 'manual',
201
+ signal: controller.signal,
202
+ dispatcher,
203
+ headers: { 'user-agent': 'dsh-proxy-pro/0.1' },
204
+ })
205
+ // 407 is an authentication failure, not "reachable": reporting it as ok
206
+ // made the panel print 「可达(HTTP 407)」 and silenced the keepalive hint
207
+ // (review fix, 2026-09-17).
208
+ if (res.status === 407) {
209
+ return { ok: false, kind: 'auth', short: 'HTTP 407 — the proxy asks for authentication (NTLM), which undici cannot negotiate' }
210
+ }
211
+ return { ok: true, status: res.status }
212
+ } catch (error) {
213
+ return classifyTargetFailure(error)
214
+ } finally {
215
+ clearTimeout(timer)
216
+ try { dispatcher.close?.().catch?.(() => {}) } catch { /* best effort */ }
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Probe one target the way web_fetch will see it: route verdict first, then a
222
+ * real HEAD through the installed policy (global fetch → policy dispatcher).
223
+ * `channel` selects the probe path: 'policy' (default), 'proxy' (forced via
224
+ * proxy), or 'direct' (forced bypass).
225
+ */
226
+ async function probeTarget(url, snapshot, channel = 'policy') {
227
+ let parsed
228
+ try {
229
+ parsed = new URL(url)
230
+ } catch {
231
+ return { url, route: 'invalid', bypassed: false, probe: { ok: false, kind: 'invalid', short: 'invalid URL — expected http(s)://host/path' } }
232
+ }
233
+ let routeName = 'DIRECT'
234
+ try {
235
+ const policyProbe = proxyRouteFor(parsed)
236
+ routeName = policyProbe.proxied ? `PROXIED ${policyProbe.proxy}` : 'DIRECT'
237
+ } catch {
238
+ // proxyRouteFor unavailable in this composition → rely on state snapshot
239
+ }
240
+ const noProxyList = snapshot?.effective?.noProxy ?? ''
241
+ const bypassed = noProxyList.split(',').some((entry) => {
242
+ const host = entry.trim().replace(/^\*?\./, '').toLowerCase()
243
+ return host.length > 0 && (host === parsed.hostname.toLowerCase() || parsed.hostname.toLowerCase().endsWith(`.${host}`))
244
+ })
245
+ if (channel === 'proxy' || channel === 'direct') {
246
+ const proxyUrl = channel === 'proxy' ? (snapshot?.effective?.url ?? '') : ''
247
+ const probe = proxyUrl === '' && channel === 'proxy'
248
+ ? { ok: false, kind: 'bad-proxy', short: 'no active proxy url in the current snapshot' }
249
+ : await forceChannelProbe(parsed, channel, proxyUrl)
250
+ return { url, route: routeName, bypassed, channel, probe }
251
+ }
252
+ try {
253
+ const controller = new AbortController()
254
+ const timer = setTimeout(() => controller.abort(), 6000)
255
+ const res = await fetch(parsed, {
256
+ method: 'HEAD',
257
+ redirect: 'manual',
258
+ signal: controller.signal,
259
+ headers: { 'user-agent': 'dsh-proxy-pro/0.1' },
260
+ })
261
+ clearTimeout(timer)
262
+ if (res.status === 407) {
263
+ return { url, route: routeName, bypassed, probe: { ok: false, kind: 'auth', short: 'HTTP 407 — the proxy asks for authentication (NTLM), which undici cannot negotiate' } }
264
+ }
265
+ return { url, route: routeName, bypassed, probe: { ok: true, status: res.status } }
266
+ } catch (error) {
267
+ return { url, route: routeName, bypassed, probe: classifyTargetFailure(error) }
268
+ }
269
+ }
270
+
271
+ // ---- browser-trust fence (mirrors dsh's /api fence) ------------------------
272
+ // dsh's own RPC channels run every request through isTrustedApiRequest
273
+ // (dsh-client-connection): Host must be loopback (DNS rebinding cannot forge
274
+ // Host), sec-fetch-site=cross-site is refused (browser CSRF marker), and an
275
+ // attached Origin must be same-host. Our panel API is registered directly on
276
+ // ctx.webServer, so we replicate that fence here — the same implementation
277
+ // dsh-skill-manager ships (its lib/index.js L131-172), matching the loopback
278
+ // verdict of the official fence with zero extra dependencies.
279
+
280
+ /** Normalized URL of a Host-header authority (hostname lowercased), or undefined. */
281
+ function parseHostHeader(host) {
282
+ if (typeof host !== 'string' || !host) return undefined
283
+ try {
284
+ return new URL(`http://${host}`).hostname.toLowerCase()
285
+ } catch {
286
+ return undefined
287
+ }
288
+ }
289
+
290
+ /** localhost | [::1] | any IPv4 in 127/8 — the loopback authority set. */
291
+ function isLoopbackHostname(hostname) {
292
+ if (hostname === 'localhost' || hostname === '[::1]') return true
293
+ const parts = hostname.split('.')
294
+ return parts.length === 4 && parts[0] === '127'
295
+ && parts.every((p) => /^\d{1,3}$/.test(p) && Number(p) <= 255)
296
+ }
297
+
298
+ /** True when the Host is loopback and any attached browser markers are same-origin. */
299
+ function isTrustedPanelRequest(headers) {
300
+ const host = typeof headers?.get === 'function' ? headers.get('host') : headers?.host
301
+ const hostname = parseHostHeader(host)
302
+ if (!hostname || !isLoopbackHostname(hostname)) return false
303
+ if (headers?.get && headers.get('sec-fetch-site') === 'cross-site') return false
304
+ if (!headers?.get && headers?.['sec-fetch-site'] === 'cross-site') return false
305
+ const originRaw = typeof headers?.get === 'function' ? headers.get('origin') : headers?.origin
306
+ if (originRaw === undefined || originRaw === null || originRaw === '') return true
307
+ try {
308
+ return new URL(originRaw).hostname.toLowerCase() === hostname
309
+ } catch {
310
+ return false
311
+ }
312
+ }
313
+
314
+ /** Write 403 and return true when the request fails the fence (handler calls first). */
315
+ function denyIfUntrusted(req, res) {
316
+ if (isTrustedPanelRequest(req.headers)) return false
317
+ try {
318
+ res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' })
319
+ res.end('forbidden')
320
+ } catch {
321
+ /* client gone */
322
+ }
323
+ return true
324
+ }
325
+
326
+ /**
327
+ * Cordis plugin body.
328
+ * @param ctx - registrant context.
329
+ * @param config - validated composition configuration.
330
+ */
331
+ function apply(ctx, config) {
332
+ const systemProxyReader = makeSystemProxyReader(execFileAsync)
333
+ let source = () => config
334
+ let snapshot = undefined
335
+ let httpProxyPolicy = undefined
336
+ let syncing = null
337
+ let disposed = false
338
+ let haveApplied = false
339
+ let lastAppliedKey = undefined
340
+ let pollTimer = undefined
341
+ let lastSystemFacts = null
342
+
343
+ /** Read all inputs (config + system facts), apply, and record the snapshot. */
344
+ const sync = async () => {
345
+ if (disposed) return
346
+ const resolved = source()
347
+ let systemProxy = null
348
+ // System facts are only consulted in mode 'system': custom mode keeps the
349
+ // configured whitelist alone, so no registry read is needed there (§17.3f).
350
+ if (resolved.mode === 'system' && resolved.enabled === true) {
351
+ try {
352
+ systemProxy = await systemProxyReader()
353
+ lastSystemFacts = systemProxy
354
+ } catch (error) {
355
+ ctx.logger.warn('dsh-proxy-pro: reading the Windows system proxy failed: %s', String(error))
356
+ }
357
+ }
358
+ const effective = resolveProxyState(resolved, systemProxy)
359
+ const active = effective.active === true && effective.url !== null
360
+ // Idempotency (LESSONS §17): when the effective transport is unchanged we
361
+ // refresh the rich snapshot but MUST NOT rebuild env/dispatcher — sync is
362
+ // a rebuild only when the effective state actually changed. Pollers may
363
+ // call requestSync() freely; the cost of an unchanged pass is a snapshot.
364
+ // http/https are part of the key: a https-only change (same url) must
365
+ // still rebuild so HTTPS_PROXY actually switches (review fix, 2026-09-17).
366
+ const effKey = JSON.stringify([active, effective.url ?? null, effective.http ?? null, effective.https ?? null, effective.noProxy ?? null, effective.reason ?? 'unknown'])
367
+ if (lastAppliedKey !== undefined && lastAppliedKey === effKey) {
368
+ snapshot = { config: resolved, effective, systemProxy, at: new Date().toISOString() }
369
+ ctx.emit('dsh-proxy-pro/status', summarize(snapshot))
370
+ return
371
+ }
372
+ // Guard: while we have never applied anything, OFF stays a transport
373
+ // no-op so boot with the default switch never touches env/dispatcher
374
+ // (coexistence with dsh-plugin-proxy during migration, NFR-4).
375
+ if (!active && !haveApplied) {
376
+ snapshot = { config: resolved, effective, systemProxy, at: new Date().toISOString() }
377
+ ctx.emit('dsh-proxy-pro/status', summarize(snapshot))
378
+ return
379
+ }
380
+ if (active) haveApplied = true
381
+ // Single transport channel: installProxyFromEnvironment owns process.env,
382
+ // the global dispatcher, and the module-level policy that proxyRouteFor()
383
+ // reads. Uninstall the previous policy before (re)installing so the
384
+ // package's active/installed state never goes stale (LESSONS §4).
385
+ try {
386
+ if (httpProxyPolicy !== undefined) {
387
+ // Do NOT await the disposer: it restores dispatcher + env synchronously
388
+ // (setGlobalDispatcher / restoreEnv run before the first await inside),
389
+ // then awaits agent.close() — undici's graceful close drains every
390
+ // keep-alive connection, which with the whole desktop's traffic in
391
+ // flight can block this turn for seconds. Fire it, keep the install
392
+ // serial, and let the pool drain in the background (LESSONS §17.3b).
393
+ const dispose = httpProxyPolicy
394
+ httpProxyPolicy = undefined
395
+ void dispose().catch(() => {})
396
+ }
397
+ const envLike = makeEnvLike(effective)
398
+ httpProxyPolicy = await installProxyFromEnvironment(envLike, (m) => ctx.logger.warn('dsh-proxy-pro: dsh-http-proxy: %s', m))
399
+ lastAppliedKey = effKey
400
+ } catch (error) {
401
+ ctx.logger.warn('dsh-proxy-pro: applying the proxy policy failed: %s', String(error))
402
+ }
403
+ snapshot = { config: resolved, effective, systemProxy, at: new Date().toISOString() }
404
+ ctx.emit('dsh-proxy-pro/status', summarize(snapshot))
405
+ }
406
+
407
+ /** Coalesced sync: concurrent triggers share one in-flight pass. */
408
+ const requestSync = () => {
409
+ if (disposed) return Promise.resolve()
410
+ if (syncing !== null) return syncing
411
+ syncing = sync().finally(() => { syncing = null })
412
+ return syncing
413
+ }
414
+
415
+ // Configuration source: the `proxy` settings namespace layered over the
416
+ // composition entry; every committed change re-syncs live.
417
+ installSettingsSection(ctx, PROXY_NS, Config, config, {
418
+ setSource: (current) => {
419
+ source = current
420
+ void requestSync().catch(() => {})
421
+ },
422
+ onChange: () => {
423
+ void requestSync().catch(() => {})
424
+ },
425
+ })
426
+
427
+ // ---------- Agent tools ----------
428
+
429
+ ctx.tools.register(defineTool({
430
+ name: 'proxy_status',
431
+ 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.',
432
+ parameters: {},
433
+ output: {
434
+ schema: PROXY_STATUS_SCHEMA,
435
+ render: (_args, value) => {
436
+ const head = value.active
437
+ ? `Proxy ON — ${value.url} (${value.source === 'system' ? 'system' : 'custom'})`
438
+ : `Proxy OFF — direct connection (${value.reason})`
439
+ const lines = [head, `NO_PROXY bypass: ${value.noProxy || '(none)'}`]
440
+ return [{ type: 'text', text: lines.join('\n') }]
441
+ },
442
+ },
443
+ execute: async () => {
444
+ await requestSync()
445
+ return summarize(snapshot)
446
+ },
447
+ presentCall: () => ({
448
+ card: 'generic',
449
+ title: 'Proxy status',
450
+ kind: 'other',
451
+ rawInput: {},
452
+ }),
453
+ }))
454
+
455
+ ctx.tools.register(defineTool({
456
+ name: 'proxy_set',
457
+ 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.',
458
+ parameters: PROXY_SET_PARAMETERS,
459
+ output: {
460
+ schema: PROXY_STATUS_SCHEMA,
461
+ render: (_args, value) => [{
462
+ type: 'text',
463
+ text: value.active
464
+ ? `Proxy enabled — ${value.url} (${value.source === 'system' ? 'system' : 'custom'})`
465
+ : `Proxy disabled — traffic now goes direct.`,
466
+ }],
467
+ },
468
+ execute: async (args) => {
469
+ const settings = ctx.get('settings')
470
+ if (settings === undefined) {
471
+ throw new Error('proxy_set: the settings service is unavailable in this composition — change the proxy config instead')
472
+ }
473
+ await settings.update(PROXY_NS, { enabled: args.enabled === true })
474
+ await requestSync()
475
+ return summarize(snapshot)
476
+ },
477
+ presentCall: (args) => ({
478
+ card: 'generic',
479
+ title: `Proxy ${args.enabled === true ? 'on' : 'off'}`,
480
+ kind: 'other',
481
+ rawInput: args,
482
+ }),
483
+ }))
484
+
485
+ ctx.tools.register(defineTool({
486
+ name: 'proxy_test',
487
+ 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.',
488
+ parameters: PROXY_TEST_PARAMETERS,
489
+ output: {
490
+ schema: PROXY_TEST_SCHEMA,
491
+ render: (_args, value) => {
492
+ const lines = [
493
+ `Route: ${value.route}`,
494
+ `NO_PROXY bypassed: ${value.bypassed}`,
495
+ `Probe: ${value.probe?.ok === true ? `reachable (HTTP ${value.probe.status})` : value.probe?.short ?? 'unknown'}`,
496
+ ]
497
+ if (value.hint) lines.push(`Hint: ${value.hint}`)
498
+ return [{ type: 'text', text: lines.join('\n') }]
499
+ },
500
+ },
501
+ execute: async (args) => {
502
+ await requestSync()
503
+ const result = await probeTarget(String(args.url ?? ''), snapshot)
504
+ result.hint = keepaliveHint(result.probe?.short ?? '', snapshot?.effective?.url ?? '')
505
+ return result
506
+ },
507
+ presentCall: (args) => ({
508
+ card: 'generic',
509
+ title: 'Proxy test',
510
+ kind: 'other',
511
+ rawInput: args,
512
+ }),
513
+ }))
514
+
515
+ ctx.tools.register(defineTool({
516
+ name: 'proxy_config',
517
+ 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.',
518
+ parameters: PROXY_CONFIG_PARAMETERS,
519
+ output: {
520
+ schema: PROXY_STATUS_SCHEMA,
521
+ render: (_args, value) => [{
522
+ type: 'text',
523
+ text: value.active
524
+ ? `Proxy config — mode=${value.mode}, ${value.url} (${value.source === 'system' ? 'system' : 'custom'}), bypass: ${value.noProxy || '(none)'}`
525
+ : `Proxy config — inactive (${value.reason}); mode=${value.mode}, bypass: ${value.noProxy || '(none)'}`,
526
+ }],
527
+ },
528
+ execute: async (args) => {
529
+ const settings = ctx.get('settings')
530
+ if (settings === undefined) {
531
+ throw new Error('proxy_config: the settings service is unavailable in this composition — change the proxy configuration in Settings instead')
532
+ }
533
+ const patch = {}
534
+ if (args.enabled !== undefined) patch.enabled = args.enabled === true
535
+ if (args.mode !== undefined) patch.mode = args.mode
536
+ if (args.customUrl !== undefined) {
537
+ const raw = String(args.customUrl).trim()
538
+ if (raw === '') {
539
+ patch.customUrl = ''
540
+ } else {
541
+ const normalized = normalizeProxyUrl(raw)
542
+ if (normalized === null) {
543
+ 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)`)
544
+ }
545
+ patch.customUrl = normalized
546
+ }
547
+ }
548
+ if (args.noProxy !== undefined) patch.noProxy = String(args.noProxy).trim()
549
+ if (Object.keys(patch).length > 0) await settings.update(PROXY_NS, patch)
550
+ await requestSync()
551
+ return summarize(snapshot)
552
+ },
553
+ presentCall: (args) => ({
554
+ card: 'generic',
555
+ title: 'Proxy config',
556
+ kind: 'other',
557
+ rawInput: args,
558
+ }),
559
+ }))
560
+
561
+ // The model must always know whether the proxy is in force.
562
+ ctx.systemPrompt.section({
563
+ name: 'dsh-proxy-pro:status',
564
+ order: SECTION_ORDER,
565
+ text: () => renderStatusText(snapshot),
566
+ })
567
+
568
+ // ---------- Status HTTP API (for the client UI) ----------
569
+ const sendJson = (res, status, body) => {
570
+ try {
571
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
572
+ res.end(JSON.stringify(body))
573
+ } catch {
574
+ /* client gone */
575
+ }
576
+ }
577
+ const readJsonBody = async (req, maxBytes = 2 * 1024 * 1024) => {
578
+ const chunks = []
579
+ let size = 0
580
+ for await (const chunk of req) {
581
+ size += chunk.length
582
+ if (size > maxBytes) throw new Error('request body too large')
583
+ chunks.push(chunk)
584
+ }
585
+ const text = Buffer.concat(chunks).toString('utf8')
586
+ if (!text) return {}
587
+ try {
588
+ return JSON.parse(text)
589
+ } catch {
590
+ return {}
591
+ }
592
+ }
593
+
594
+ const webServer = ctx.get('webServer')
595
+ if (webServer !== undefined) {
596
+ try {
597
+ const unregisterRoute = webServer.register({
598
+ kind: 'prefix',
599
+ path: ROUTE_BASE,
600
+ handler: async (req, res) => {
601
+ if (denyIfUntrusted(req, res)) return
602
+ const url = new URL(req.url ?? '/', 'http://localhost')
603
+ const is = (p, m) => url.pathname === `${ROUTE_BASE}${p}` && req.method === m
604
+ try {
605
+ if (is('/status', 'GET')) {
606
+ // Pull-triggered refresh: requestSync is idempotent — when the
607
+ // effKey is unchanged it only refreshes the snapshot (no
608
+ // dispatcher/env rebuild); only a real system-proxy change
609
+ // reinstalls. So refreshing here is cheap and every pull serves
610
+ // fresh facts. This is safe where the pre-096e431 /status was
611
+ // not: that one reinstalled UNCONDITIONALLY on every pull and
612
+ // dragged the desktop down (LESSONS §17). The 30s host poll
613
+ // still owns unattended following (page-closed changes); this
614
+ // exists only to serve the viewer.
615
+ await requestSync()
616
+ sendJson(res, 200, { ok: true, status: summarize(snapshot) })
617
+ return
618
+ }
619
+ if (is('/route', 'GET')) {
620
+ const target = url.searchParams.get('url') ?? ''
621
+ const useProxy = url.searchParams.get('useProxy')
622
+ const channel = useProxy === '1' ? 'proxy' : useProxy === '0' ? 'direct' : 'policy'
623
+ const result = await probeTarget(target, snapshot, channel)
624
+ result.hint = keepaliveHint(result.probe?.short ?? '', snapshot?.effective?.url ?? '')
625
+ sendJson(res, 200, { ok: true, ...result })
626
+ return
627
+ }
628
+ if (is('/toggle', 'POST')) {
629
+ const body = await readJsonBody(req)
630
+ const settings = ctx.get('settings')
631
+ if (settings === undefined) {
632
+ sendJson(res, 503, { ok: false, error: 'settings service unavailable' })
633
+ return
634
+ }
635
+ await settings.update(PROXY_NS, { enabled: body.enabled === true })
636
+ await requestSync()
637
+ sendJson(res, 200, { ok: true, status: summarize(snapshot) })
638
+ return
639
+ }
640
+ sendJson(res, 404, { ok: false, error: 'not found' })
641
+ } catch (error) {
642
+ sendJson(res, 500, { ok: false, error: String(error?.message ?? error) })
643
+ }
644
+ },
645
+ })
646
+ ctx.effect(() => () => { try { unregisterRoute?.() } catch { /* best effort */ } }, 'dsh-proxy-pro: route cleanup')
647
+ } catch (error) {
648
+ ctx.logger.warn('dsh-proxy-pro: webServer unavailable — status API disabled: %s', String(error))
649
+ }
650
+ }
651
+
652
+ // Follow the Windows system proxy at runtime (change-only re-apply).
653
+ if (config.systemPollMs > 0) {
654
+ pollTimer = setInterval(async () => {
655
+ if (disposed) return
656
+ const resolved = source()
657
+ if (resolved.mode !== 'system' || resolved.enabled !== true) return
658
+ let facts
659
+ try {
660
+ facts = await systemProxyReader()
661
+ } catch {
662
+ return
663
+ }
664
+ if (lastSystemFacts !== null && systemFactsEqual(lastSystemFacts, facts)) return
665
+ void requestSync().catch(() => {})
666
+ }, config.systemPollMs)
667
+ pollTimer.unref?.()
668
+ }
669
+
670
+ // Tear down: restore env, dispatcher, and dsh-http-proxy policy. If a sync
671
+ // is still in flight (e.g. a status-API request), uninstall chained AFTER it
672
+ // — otherwise a late install would leak a policy that teardown already
673
+ // skipped (review fix, 2026-09-17).
674
+ ctx.effect(() => {
675
+ return () => {
676
+ disposed = true
677
+ if (pollTimer !== undefined) clearInterval(pollTimer)
678
+ const detach = () => {
679
+ if (httpProxyPolicy === undefined) return
680
+ const policy = httpProxyPolicy
681
+ httpProxyPolicy = undefined
682
+ void Promise.resolve(policy()).catch(() => {})
683
+ }
684
+ if (syncing !== null) {
685
+ void Promise.resolve(syncing).catch(() => {}).finally(detach)
686
+ } else {
687
+ detach()
688
+ }
689
+ }
690
+ }, 'dsh-proxy-pro: cleanup')
691
+ }
692
+
693
+ export { Config, apply, inject, name }