@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/LICENSE +21 -0
- package/README.md +153 -0
- package/cordis.patch.yml +14 -0
- package/docs/ACCEPTANCE.md +61 -0
- package/docs/LESSONS.md +551 -0
- package/docs/SPEC.md +381 -0
- package/lib/client.js +457 -0
- package/lib/index.js +693 -0
- package/lib/proxy-core.js +268 -0
- package/lib/tool-schemas.js +103 -0
- package/package.json +62 -0
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 }
|