@fonlan/dsh-model-parameters 0.1.9 → 0.2.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.
@@ -0,0 +1,683 @@
1
+ /**
2
+ * System-proxy discovery and transport for the models.dev catalog download.
3
+ *
4
+ * The harness already routes plain `fetch()` through undici's global
5
+ * dispatcher, which resolves `http_proxy` / `https_proxy` / `all_proxy` from
6
+ * the launch environment (`@deepseek-ai/dsh-http-proxy`). That dispatcher
7
+ * deliberately does NOT read the operating system's proxy configuration, so on
8
+ * a machine where the user only enabled a proxy in Windows Internet Settings or
9
+ * macOS Network settings the catalog download went direct and failed behind the
10
+ * proxy.
11
+ *
12
+ * This module supplies the missing half:
13
+ * 1. when the environment names a proxy, defer to the harness so the existing
14
+ * routing (including its diagnostics and loopback bypass) is unchanged;
15
+ * 2. otherwise read the OS proxy — Windows registry `Internet Settings`,
16
+ * macOS `scutil --proxy` — and tunnel the download through it with Node's
17
+ * own https stack (CONNECT, proxy Basic auth, redirects);
18
+ * 3. otherwise download directly.
19
+ *
20
+ * Deliberate limits: SOCKS proxies and PAC scripts are never evaluated (a
21
+ * PAC-only configuration is reported and the download stays direct — set
22
+ * `HTTP_PROXY`/`HTTPS_PROXY` instead); a bypass entry in CIDR form
23
+ * (`10.0.0.0/8`) is not matched; proxy credentials stored in the OS credential
24
+ * manager are not sent.
25
+ */
26
+ import { execFile } from 'node:child_process'
27
+ import { existsSync } from 'node:fs'
28
+ import { request as httpRequest, type AgentOptions, type IncomingHttpHeaders, type IncomingMessage } from 'node:http'
29
+ import { request as httpsRequest, Agent as HttpsAgent } from 'node:https'
30
+ import { isIP, type Socket } from 'node:net'
31
+ import { join } from 'node:path'
32
+ import { connect as connectTls, type ConnectionOptions, TLSSocket } from 'node:tls'
33
+ import type { Duplex } from 'node:stream'
34
+
35
+ /** Structural logger subset satisfied by cordis' LoggerService. */
36
+ export interface ProxyLogger {
37
+ info(format: any, ...param: any[]): void
38
+ warn(format: any, ...param: any[]): void
39
+ }
40
+
41
+ /** Where a detected proxy configuration came from. */
42
+ export type ProxySource = 'env' | 'windows' | 'macos' | 'none'
43
+
44
+ /** One proxy configuration read from the operating system. */
45
+ export interface SystemProxyConfig {
46
+ /** Proxy URL for `http:` targets. */
47
+ http?: string
48
+ /** Proxy URL for `https:` targets. */
49
+ https?: string
50
+ /** The OS bypass/exceptions list, comma separated. */
51
+ noProxy?: string
52
+ source: 'windows' | 'macos'
53
+ /** Notes about settings this module cannot honor (PAC, SOCKS, malformed entries). */
54
+ diagnostics: string[]
55
+ }
56
+
57
+ /** What the catalog download will do for one URL. */
58
+ export interface CatalogRoute {
59
+ /** True when this plugin must tunnel the request itself. */
60
+ tunnel: boolean
61
+ /** Proxy URL, present only when {@link CatalogRoute.tunnel} is true. */
62
+ proxy?: string
63
+ source: ProxySource
64
+ /** Effective bypass list (OS exceptions; loopback is always bypassed). */
65
+ noProxy: string
66
+ diagnostics: string[]
67
+ }
68
+
69
+ /** Structured routing outcome, safe to cross the settings API (credentials stripped). */
70
+ export interface CatalogTransport {
71
+ kind: 'direct' | 'env' | 'macos' | 'windows'
72
+ /** Masked proxy URL (no credentials), when a proxy is actually used. */
73
+ proxy?: string
74
+ }
75
+
76
+ /** A downloaded and parsed catalog document plus the route it took. */
77
+ export interface CatalogFetchResult {
78
+ body: unknown
79
+ transport: CatalogTransport
80
+ }
81
+
82
+ /** Download one JSON document; {@link CatalogManager} takes one for tests. */
83
+ export type CatalogFetch = (url: string, timeoutMs: number) => Promise<CatalogFetchResult>
84
+
85
+ /** Runs one detection command; injectable so the OS parsers stay testable. */
86
+ export type CommandRunner = (command: string, args: string[], timeoutMs: number) => Promise<string>
87
+
88
+ /** Detection inputs; every field exists so tests can pin a platform and its probes. */
89
+ export interface DetectOptions {
90
+ platform?: NodeJS.Platform
91
+ env?: NodeJS.ProcessEnv
92
+ run?: CommandRunner
93
+ timeoutMs?: number
94
+ }
95
+
96
+ const PROXY_ENV_NAMES = [
97
+ 'https_proxy',
98
+ 'HTTPS_PROXY',
99
+ 'http_proxy',
100
+ 'HTTP_PROXY',
101
+ 'all_proxy',
102
+ 'ALL_PROXY',
103
+ ] as const
104
+
105
+ /** Per-user Internet Settings key WinINET (and every proxy app that toggles "system proxy") writes. */
106
+ const WINDOWS_INTERNET_SETTINGS = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings'
107
+
108
+ /** How long one detection probe may take. */
109
+ const DETECT_TIMEOUT_MS = 5_000
110
+
111
+ /** Redirect statuses a download follows. */
112
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308])
113
+
114
+ const MAX_REDIRECTS = 4
115
+
116
+ // ── environment ──────────────────────────────────────────────────────────────
117
+
118
+ /** The first proxy environment variable that carries a value, if any. */
119
+ function proxyEnvironmentName(env: NodeJS.ProcessEnv): string | undefined {
120
+ return PROXY_ENV_NAMES.find((name) => (env[name] ?? '').trim() !== '')
121
+ }
122
+
123
+ // ── OS probes ────────────────────────────────────────────────────────────────
124
+
125
+ const runCommand: CommandRunner = (command, args, timeoutMs) =>
126
+ new Promise<string>((resolve, reject) => {
127
+ execFile(command, args, { encoding: 'utf8', timeout: timeoutMs, windowsHide: true, maxBuffer: 1024 * 1024 }, (error, stdout, stderr) => {
128
+ if (error !== null) {
129
+ reject(new Error(`${command} ${args.join(' ')} failed: ${stderr.trim() === '' ? error.message : stderr.trim()}`))
130
+ return
131
+ }
132
+ resolve(stdout)
133
+ })
134
+ })
135
+
136
+ /**
137
+ * Parse the value table `reg query <key>` prints. Value names and the
138
+ * `REG_*` type tokens are not localized, so the same table parses under every
139
+ * Windows display language. Binary values (the `DefaultConnectionSettings`
140
+ * blob) are read as empty — nothing in this module consumes them.
141
+ */
142
+ export function parseRegistryQuery(output: string): Map<string, string> {
143
+ const values = new Map<string, string>()
144
+ for (const line of output.split(/\r?\n/)) {
145
+ const match = /^\s{2,}(\S+)\s+(REG_[A-Z_]+)\s*(.*)$/.exec(line)
146
+ if (match === null) continue
147
+ const [, name, type, raw] = match
148
+ if (type === 'REG_BINARY') {
149
+ values.set(name, '')
150
+ continue
151
+ }
152
+ values.set(name, type === 'REG_DWORD' ? String(Number.parseInt(raw.trim(), 16) || 0) : raw.trim())
153
+ }
154
+ return values
155
+ }
156
+
157
+ /**
158
+ * Turn the Internet Settings values into a proxy configuration.
159
+ * `ProxyServer` is either `host:port` (both schemes) or a
160
+ * `http=host:port;https=host:port` list.
161
+ * @returns the configuration, or undefined when the OS names no proxy at all.
162
+ */
163
+ export function windowsProxyFromRegistry(values: Map<string, string>): SystemProxyConfig | undefined {
164
+ const enabled = (values.get('ProxyEnable') ?? '0') !== '0'
165
+ const server = (values.get('ProxyServer') ?? '').trim()
166
+ const override = windowsBypassList(values.get('ProxyOverride') ?? '')
167
+ const autoConfig = (values.get('AutoConfigURL') ?? '').trim()
168
+ const diagnostics: string[] = []
169
+ const config: SystemProxyConfig = { source: 'windows', noProxy: override, diagnostics }
170
+
171
+ if (enabled && server !== '') {
172
+ if (server.includes('=')) {
173
+ for (const entry of server.split(';')) {
174
+ const separator = entry.indexOf('=')
175
+ if (separator === -1) continue
176
+ const scheme = entry.slice(0, separator).trim().toLowerCase()
177
+ const hostPort = entry.slice(separator + 1).trim()
178
+ if (hostPort === '') continue
179
+ if (scheme !== 'http' && scheme !== 'https') {
180
+ if (scheme === 'socks') {
181
+ diagnostics.push('Windows names a SOCKS proxy, which is not supported; the catalog download stays direct')
182
+ }
183
+ continue
184
+ }
185
+ const url = proxyUrlFromHostPort(hostPort)
186
+ if (url === undefined) {
187
+ diagnostics.push(`Windows proxy entry "${scheme}=${hostPort}" is not a usable proxy URL; ignored`)
188
+ continue
189
+ }
190
+ if (scheme === 'http') config.http = url
191
+ else config.https = url
192
+ }
193
+ } else {
194
+ const url = proxyUrlFromHostPort(server)
195
+ if (url === undefined) {
196
+ diagnostics.push(`Windows proxy server "${server}" is not a usable proxy URL; the catalog download stays direct`)
197
+ } else {
198
+ config.http = url
199
+ config.https = url
200
+ }
201
+ }
202
+ }
203
+
204
+ if (autoConfig !== '') {
205
+ diagnostics.push('Windows uses an automatic proxy configuration (PAC) script, which is not evaluated; set HTTP_PROXY/HTTPS_PROXY to route the catalog download')
206
+ }
207
+
208
+ const configured = config.http !== undefined || config.https !== undefined
209
+ return configured || diagnostics.length > 0 ? config : undefined
210
+ }
211
+
212
+ /** `ProxyOverride` is `;`-separated and may carry `<local>`; our matcher consumes both forms. */
213
+ function windowsBypassList(override: string): string {
214
+ return override
215
+ .split(';')
216
+ .map((entry) => entry.trim())
217
+ .filter((entry) => entry !== '')
218
+ .join(',')
219
+ }
220
+
221
+ /** macOS `scutil --proxy` prints a flat key/value dictionary with nested arrays. */
222
+ export function parseScutilProxyOutput(output: string): SystemProxyConfig | undefined {
223
+ const values = new Map<string, string>()
224
+ const exceptions: string[] = []
225
+ for (const line of output.split(/\r?\n/)) {
226
+ const match = /^\s*([^:<]+?)\s*:\s*(.+?)\s*$/.exec(line)
227
+ if (match === null) continue
228
+ const key = match[1].trim()
229
+ const value = match[2].trim()
230
+ // Array elements print as `0 : *.local`; every top-level key is a name.
231
+ if (/^\d+$/.test(key)) {
232
+ exceptions.push(value)
233
+ continue
234
+ }
235
+ values.set(key, value)
236
+ }
237
+
238
+ const flag = (key: string): boolean => values.get(key) === '1' || values.get(key) === 'true'
239
+ const port = (key: string, fallback: number): string => {
240
+ const value = values.get(key)
241
+ return value !== undefined && /^\d+$/.test(value) ? value : String(fallback)
242
+ }
243
+ const diagnostics: string[] = []
244
+ const config: SystemProxyConfig = { source: 'macos', noProxy: exceptions.join(','), diagnostics }
245
+
246
+ const httpHost = values.get('HTTPProxy')
247
+ if (flag('HTTPEnable') && httpHost !== undefined && httpHost !== '') {
248
+ config.http = `${/^https?:\/\//.test(httpHost) ? '' : 'http://'}${httpHost}:${port('HTTPPort', 80)}`
249
+ }
250
+ const httpsHost = values.get('HTTPSProxy')
251
+ if (flag('HTTPSEnable') && httpsHost !== undefined && httpsHost !== '') {
252
+ config.https = `${/^https?:\/\//.test(httpsHost) ? '' : 'http://'}${httpsHost}:${port('HTTPSPort', 443)}`
253
+ }
254
+
255
+ if (flag('ProxyAutoConfigEnable')) {
256
+ diagnostics.push('macOS uses an automatic proxy configuration (PAC) file, which is not evaluated; set HTTP_PROXY/HTTPS_PROXY to route the catalog download')
257
+ }
258
+ if (flag('ProxyAutoDiscoveryEnable')) {
259
+ diagnostics.push('macOS runs automatic proxy discovery (WPAD), which is not evaluated; set HTTP_PROXY/HTTPS_PROXY to route the catalog download')
260
+ }
261
+ if (flag('SOCKSEnable')) {
262
+ diagnostics.push('macOS names a SOCKS proxy, which is not supported; the catalog download stays direct for that route')
263
+ }
264
+
265
+ const configured = config.http !== undefined || config.https !== undefined
266
+ return configured || diagnostics.length > 0 ? config : undefined
267
+ }
268
+
269
+ async function detectWindowsProxy(run: CommandRunner, env: NodeJS.ProcessEnv, timeoutMs: number): Promise<SystemProxyConfig | undefined> {
270
+ const systemRoot = env.SystemRoot ?? env.SYSTEMROOT
271
+ const executable = systemRoot !== undefined && existsSync(join(systemRoot, 'System32', 'reg.exe'))
272
+ ? join(systemRoot, 'System32', 'reg.exe')
273
+ : 'reg'
274
+ let output: string
275
+ try {
276
+ output = await run(executable, ['query', WINDOWS_INTERNET_SETTINGS], timeoutMs)
277
+ } catch {
278
+ return undefined
279
+ }
280
+ return windowsProxyFromRegistry(parseRegistryQuery(output))
281
+ }
282
+
283
+ async function detectMacProxy(run: CommandRunner, timeoutMs: number): Promise<SystemProxyConfig | undefined> {
284
+ let output: string
285
+ try {
286
+ output = await run('scutil', ['--proxy'], timeoutMs)
287
+ } catch {
288
+ return undefined
289
+ }
290
+ return parseScutilProxyOutput(output)
291
+ }
292
+
293
+ /**
294
+ * Read the operating system's proxy configuration (never the environment).
295
+ * Linux has no portable API for this; its users configure the environment,
296
+ * which the harness routes.
297
+ * @param options - platform, probe runner and timeout (tests pin all three).
298
+ * @returns the detected configuration, or undefined when the OS names none.
299
+ */
300
+ export async function detectSystemProxy(options: DetectOptions = {}): Promise<SystemProxyConfig | undefined> {
301
+ const platform = options.platform ?? process.platform
302
+ const env = options.env ?? process.env
303
+ const run = options.run ?? runCommand
304
+ const timeoutMs = options.timeoutMs ?? DETECT_TIMEOUT_MS
305
+ if (platform === 'win32') return detectWindowsProxy(run, env, timeoutMs)
306
+ if (platform === 'darwin') return detectMacProxy(run, timeoutMs)
307
+ return undefined
308
+ }
309
+
310
+ // ── bypass matching ──────────────────────────────────────────────────────────
311
+
312
+ /** A host this machine owns: a proxy cannot reach it, so it always goes direct. */
313
+ export function isLoopbackHost(hostname: string): boolean {
314
+ const host = hostname.replace(/^\[|\]$/g, '').replace(/\.$/, '').toLowerCase()
315
+ if (host === 'localhost' || host.endsWith('.localhost')) return true
316
+ if (host === '::1' || host === '::' || host === '0.0.0.0') return true
317
+ return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host)
318
+ }
319
+
320
+ /** Split `host:port`, `[::1]:port` or a bare IPv6 literal without reading `::1` as host `:port`. */
321
+ function splitHostPort(entry: string): { host: string; port?: string } {
322
+ if (entry.startsWith('[')) {
323
+ const close = entry.indexOf(']')
324
+ if (close !== -1) {
325
+ const rest = entry.slice(close + 1)
326
+ const host = entry.slice(1, close)
327
+ return rest.startsWith(':') ? { host, port: rest.slice(1) } : { host }
328
+ }
329
+ }
330
+ const colon = entry.indexOf(':')
331
+ if (colon !== -1 && entry.indexOf(':', colon + 1) === -1) {
332
+ return { host: entry.slice(0, colon), port: entry.slice(colon + 1) }
333
+ }
334
+ return { host: entry }
335
+ }
336
+
337
+ /** `*` / `?` entries appear in OS bypass lists (`10.*`, `*.corp`); CIDR does not. */
338
+ function globMatch(pattern: string, host: string): boolean {
339
+ const source = pattern
340
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
341
+ .replace(/\*/g, '.*')
342
+ .replace(/\?/g, '.')
343
+ return new RegExp(`^${source}$`).test(host)
344
+ }
345
+
346
+ /**
347
+ * Decide whether a bypass list exempts one URL. Semantics follow the harness's
348
+ * own matcher — an entry covers its subdomains, a leading `.`/`*.` means the
349
+ * same, an entry may carry a `:port`, `*` bypasses everything — plus the two
350
+ * forms OS lists add: `host*` wildcards and `<local>` (a dotless hostname).
351
+ * Loopback is always bypassed. CIDR notation is not matched.
352
+ */
353
+ export function shouldBypassProxy(noProxy: string, url: URL): boolean {
354
+ const host = url.hostname.replace(/^\[|\]$/g, '').replace(/\.$/, '').toLowerCase()
355
+ if (isLoopbackHost(host)) return true
356
+ const port = url.port !== '' ? url.port : url.protocol === 'https:' ? '443' : '80'
357
+ for (const raw of noProxy.split(/[,\s;]+/)) {
358
+ const entry = raw.trim().toLowerCase()
359
+ if (entry === '') continue
360
+ if (entry === '*') return true
361
+ if (entry === '<local>') {
362
+ if (!host.includes('.')) return true
363
+ continue
364
+ }
365
+ const split = splitHostPort(entry)
366
+ if (split.port !== undefined && split.port !== port) continue
367
+ const candidate = split.host.replace(/^\*?\./, '').replace(/\.$/, '')
368
+ if (candidate === '') continue
369
+ if (candidate.includes('*') || candidate.includes('?')) {
370
+ if (globMatch(candidate, host)) return true
371
+ continue
372
+ }
373
+ if (host === candidate || host.endsWith(`.${candidate}`)) return true
374
+ }
375
+ return false
376
+ }
377
+
378
+ // ── route resolution ─────────────────────────────────────────────────────────
379
+
380
+ /**
381
+ * Decide how the catalog download for `target` is routed: defer to the harness
382
+ * for environment proxies, tunnel a detected OS proxy, or go direct. The OS is
383
+ * consulted only when the environment names no proxy, so an exported variable
384
+ * keeps winning exactly as it does for the rest of the harness.
385
+ */
386
+ export async function resolveCatalogRoute(target: URL | string, options: DetectOptions = {}): Promise<CatalogRoute> {
387
+ const url = typeof target === 'string' ? new URL(target) : target
388
+ const env = options.env ?? process.env
389
+ if (proxyEnvironmentName(env) !== undefined) {
390
+ return {
391
+ tunnel: false,
392
+ source: 'env',
393
+ noProxy: (env.no_proxy ?? env.NO_PROXY ?? '').trim(),
394
+ diagnostics: [],
395
+ }
396
+ }
397
+ const detected = await detectSystemProxy(options)
398
+ if (detected === undefined) {
399
+ return { tunnel: false, source: 'none', noProxy: '', diagnostics: [] }
400
+ }
401
+ const noProxy = detected.noProxy ?? ''
402
+ // The OS configures HTTP and HTTPS proxies separately; honoring one for the
403
+ // other would send a scheme through a proxy the user did not pick.
404
+ const proxy = url.protocol === 'https:' ? detected.https : detected.http
405
+ const diagnostics = [...detected.diagnostics]
406
+ if (proxy === undefined) {
407
+ const other = url.protocol === 'https:' ? detected.http : detected.https
408
+ if (other !== undefined) {
409
+ const missing = url.protocol === 'https:' ? 'HTTPS' : 'HTTP'
410
+ const present = url.protocol === 'https:' ? 'HTTP' : 'HTTPS'
411
+ diagnostics.push(`${detected.source === 'windows' ? 'Windows' : 'macOS'} configures an ${present} proxy but no ${missing} proxy; the catalog download connects directly`)
412
+ }
413
+ return { tunnel: false, source: detected.source, noProxy, diagnostics }
414
+ }
415
+ if (shouldBypassProxy(noProxy, url)) {
416
+ return { tunnel: false, source: detected.source, noProxy, diagnostics }
417
+ }
418
+ return { tunnel: true, proxy, source: detected.source, noProxy, diagnostics }
419
+ }
420
+
421
+ /** Strip any proxy credentials before the route crosses the settings API or logs. */
422
+ export function maskProxyUrl(proxy: string): string | undefined {
423
+ try {
424
+ const url = new URL(proxy)
425
+ url.username = ''
426
+ url.password = ''
427
+ return `${url.protocol}//${url.host}`
428
+ } catch {
429
+ return undefined
430
+ }
431
+ }
432
+
433
+ /** Describe a route for the settings page. */
434
+ export function transportOf(route: CatalogRoute): CatalogTransport {
435
+ if (route.tunnel && route.proxy !== undefined) {
436
+ const kind = route.source === 'windows' ? 'windows' : route.source === 'macos' ? 'macos' : 'env'
437
+ return { kind, proxy: maskProxyUrl(route.proxy) }
438
+ }
439
+ if (route.source === 'env') return { kind: 'env' }
440
+ return { kind: 'direct' }
441
+ }
442
+
443
+ function describeTransport(transport: CatalogTransport): string {
444
+ switch (transport.kind) {
445
+ case 'env':
446
+ return 'environment proxy variables (routed by the harness)'
447
+ case 'windows':
448
+ return `Windows system proxy ${transport.proxy ?? ''}`.trim()
449
+ case 'macos':
450
+ return `macOS system proxy ${transport.proxy ?? ''}`.trim()
451
+ default:
452
+ return 'direct (no system proxy)'
453
+ }
454
+ }
455
+
456
+ // ── transport ────────────────────────────────────────────────────────────────
457
+
458
+ function proxyUrlFromHostPort(value: string): string | undefined {
459
+ try {
460
+ const parsed = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(value) ? value : `http://${value}`)
461
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return undefined
462
+ if (parsed.hostname === '') return undefined
463
+ return `${parsed.protocol}//${parsed.host}`
464
+ } catch {
465
+ return undefined
466
+ }
467
+ }
468
+
469
+ function errorMessage(error: unknown): string {
470
+ if (!(error instanceof Error)) return String(error)
471
+ const cause = (error as { cause?: unknown }).cause
472
+ if (cause instanceof Error && cause.message !== '' && !error.message.includes(cause.message)) {
473
+ return `${error.message}: ${cause.message}`
474
+ }
475
+ return error.message
476
+ }
477
+
478
+ async function fetchJsonDirect(url: string, timeoutMs: number): Promise<unknown> {
479
+ const controller = new AbortController()
480
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
481
+ try {
482
+ const response = await fetch(url, { signal: controller.signal, headers: { accept: 'application/json' } })
483
+ if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText}`)
484
+ return (await response.json()) as unknown
485
+ } catch (error) {
486
+ if (controller.signal.aborted) throw new Error(`catalog download timed out after ${timeoutMs}ms`)
487
+ throw new Error(errorMessage(error))
488
+ } finally {
489
+ clearTimeout(timer)
490
+ }
491
+ }
492
+
493
+ /** A URL hostname without the brackets WHATWG puts around IPv6 literals. */
494
+ function bareHostname(hostname: string): string {
495
+ return hostname.replace(/^\[|\]$/g, '')
496
+ }
497
+
498
+ /** Open a CONNECT tunnel to `host:port` through the proxy, authenticating when the URL carries credentials. */
499
+ function openProxyTunnel(proxy: URL, host: string, port: number, timeoutMs: number): Promise<Socket> {
500
+ const authority = `${isIP(host) === 6 ? `[${host}]` : host}:${port}`
501
+ const headers: Record<string, string> = { host: authority }
502
+ if (proxy.username !== '' || proxy.password !== '') {
503
+ const credentials = `${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password)}`
504
+ headers['proxy-authorization'] = `Basic ${Buffer.from(credentials).toString('base64')}`
505
+ }
506
+ const secureProxy = proxy.protocol === 'https:'
507
+ const request = (secureProxy ? httpsRequest : httpRequest) as typeof httpRequest
508
+ const connectRequest = request({
509
+ host: bareHostname(proxy.hostname),
510
+ port: Number(proxy.port) || (secureProxy ? 443 : 80),
511
+ method: 'CONNECT',
512
+ path: authority,
513
+ headers,
514
+ agent: false,
515
+ timeout: timeoutMs,
516
+ })
517
+ return new Promise<Socket>((resolve, reject) => {
518
+ connectRequest.once('connect', (response: IncomingMessage, socket: Socket, head: Buffer) => {
519
+ if (response.statusCode !== 200) {
520
+ socket.destroy()
521
+ reject(new Error(`proxy ${bareHostname(proxy.hostname)} refused CONNECT with HTTP ${response.statusCode ?? 0}`))
522
+ return
523
+ }
524
+ if (head.length > 0) socket.unshift(head)
525
+ resolve(socket)
526
+ })
527
+ connectRequest.once('timeout', () => {
528
+ connectRequest.destroy(new Error(`proxy CONNECT timed out after ${timeoutMs}ms`))
529
+ })
530
+ connectRequest.once('error', (error) => reject(error))
531
+ connectRequest.end()
532
+ })
533
+ }
534
+
535
+ /** An https agent whose every socket is a TLS session opened inside a proxy tunnel. */
536
+ class ProxyTunnelAgent extends HttpsAgent {
537
+ constructor(
538
+ private readonly proxy: URL,
539
+ private readonly timeoutMs: number,
540
+ ) {
541
+ super({ keepAlive: false, maxSockets: 1 })
542
+ }
543
+
544
+ override createConnection(options: AgentOptions, callback?: (error: Error | null, stream: Duplex) => void): Duplex | null | undefined {
545
+ const host = typeof options.host === 'string' ? options.host : ''
546
+ const port = Number(options.port ?? 443)
547
+ const servername = (options as ConnectionOptions).servername
548
+ this.open(host, port, typeof servername === 'string' ? servername : undefined).then(
549
+ (socket) => callback?.(null, socket),
550
+ (error: unknown) => callback?.(error instanceof Error ? error : new Error(String(error)), undefined as unknown as Duplex),
551
+ )
552
+ return undefined
553
+ }
554
+
555
+ private async open(host: string, port: number, servername: string | undefined): Promise<TLSSocket> {
556
+ const raw = await openProxyTunnel(this.proxy, host, port, this.timeoutMs)
557
+ // SNI must never carry an IP literal; fall back to the target hostname only when it is a name.
558
+ const sni = servername !== undefined && isIP(servername) === 0
559
+ ? servername
560
+ : isIP(host) === 0 ? host : undefined
561
+ const secure = connectTls({ socket: raw, servername: sni })
562
+ await new Promise<void>((resolve, reject) => {
563
+ secure.once('secureConnect', () => resolve())
564
+ secure.once('error', (error) => reject(error))
565
+ })
566
+ return secure
567
+ }
568
+ }
569
+
570
+ interface RawResponse {
571
+ status: number
572
+ statusText: string
573
+ headers: IncomingHttpHeaders
574
+ body: string
575
+ }
576
+
577
+ /** One HTTPS GET through the tunnel; the whole exchange (connect included) is bounded by `timeoutMs`. */
578
+ function requestThroughTunnel(target: URL, proxy: URL, timeoutMs: number): Promise<RawResponse> {
579
+ return new Promise<RawResponse>((resolve, reject) => {
580
+ const agent = new ProxyTunnelAgent(proxy, timeoutMs)
581
+ let settled = false
582
+ let request: ReturnType<typeof httpsRequest> | undefined
583
+ const finish = (error: Error | null, value?: RawResponse): void => {
584
+ if (settled) return
585
+ settled = true
586
+ clearTimeout(timer)
587
+ request?.destroy()
588
+ agent.destroy()
589
+ if (error === null && value !== undefined) resolve(value)
590
+ else reject(error ?? new Error('catalog download failed'))
591
+ }
592
+ const timer = setTimeout(() => finish(new Error(`catalog download timed out after ${timeoutMs}ms`)), timeoutMs)
593
+ request = httpsRequest(target, {
594
+ agent,
595
+ method: 'GET',
596
+ headers: { accept: 'application/json', 'accept-encoding': 'identity' },
597
+ })
598
+ request.once('response', (response) => {
599
+ const chunks: Buffer[] = []
600
+ response.on('data', (chunk: Buffer) => chunks.push(chunk))
601
+ response.once('end', () => {
602
+ finish(null, {
603
+ status: response.statusCode ?? 0,
604
+ statusText: response.statusMessage ?? '',
605
+ headers: response.headers,
606
+ body: Buffer.concat(chunks).toString('utf8'),
607
+ })
608
+ })
609
+ response.once('error', (error) => finish(error))
610
+ })
611
+ request.once('error', (error) => finish(error))
612
+ request.end()
613
+ })
614
+ }
615
+
616
+ /**
617
+ * Download and parse one HTTPS JSON document through an `http(s)://` proxy,
618
+ * following redirects. Throws on a non-2xx answer, a malformed body, or a
619
+ * transport timeout so callers keep the previously cached catalog.
620
+ */
621
+ export async function fetchJsonViaProxy(url: string, proxy: string, timeoutMs: number): Promise<unknown> {
622
+ let proxyUrl: URL
623
+ try {
624
+ proxyUrl = new URL(proxy)
625
+ } catch {
626
+ throw new Error(`proxy URL "${maskProxyUrl(proxy) ?? 'invalid'}" is not a URL`)
627
+ }
628
+ if (proxyUrl.protocol !== 'http:' && proxyUrl.protocol !== 'https:') {
629
+ throw new Error(`proxy URL "${maskProxyUrl(proxy) ?? 'invalid'}" uses ${proxyUrl.protocol}//, which is not supported`)
630
+ }
631
+ let current = new URL(url)
632
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
633
+ if (current.protocol !== 'https:') {
634
+ throw new Error(`proxy transport supports https targets only (got ${current.protocol}//)`)
635
+ }
636
+ const response = await requestThroughTunnel(current, proxyUrl, timeoutMs)
637
+ if (REDIRECT_STATUSES.has(response.status)) {
638
+ const location = response.headers.location
639
+ if (typeof location !== 'string' || location === '') {
640
+ throw new Error(`HTTP ${response.status} redirect without a Location header`)
641
+ }
642
+ current = new URL(location, current)
643
+ continue
644
+ }
645
+ if (response.status < 200 || response.status >= 300) {
646
+ throw new Error(`HTTP ${response.status}${response.statusText === '' ? '' : ` ${response.statusText}`}`)
647
+ }
648
+ return JSON.parse(response.body) as unknown
649
+ }
650
+ throw new Error('too many redirects while downloading the catalog')
651
+ }
652
+
653
+ /**
654
+ * Download the catalog for the settings page: defer to the harness when the
655
+ * environment names a proxy, otherwise tunnel a detected OS proxy, otherwise
656
+ * fetch directly. Never throws for routing reasons — transport failures do
657
+ * throw, and the caller keeps its cached copy.
658
+ * @param options - detection overrides; production passes nothing, tests pin
659
+ * the platform and its OS probes.
660
+ */
661
+ export async function fetchJsonWithSystemProxy(
662
+ url: string,
663
+ timeoutMs: number,
664
+ logger?: ProxyLogger,
665
+ options: DetectOptions = {},
666
+ ): Promise<CatalogFetchResult> {
667
+ const route = await resolveCatalogRoute(url, options)
668
+ for (const diagnostic of route.diagnostics) logger?.warn(`model-parameters: ${diagnostic}`)
669
+ const transport = transportOf(route)
670
+ logger?.info(`model-parameters: catalog download via ${describeTransport(transport)}`)
671
+ try {
672
+ const body = route.tunnel && route.proxy !== undefined && new URL(url).protocol === 'https:'
673
+ ? await fetchJsonViaProxy(url, route.proxy, timeoutMs)
674
+ : await fetchJsonDirect(url, timeoutMs)
675
+ return { body, transport }
676
+ } catch (error) {
677
+ // The attempt's route rides the failure so the settings page can show what
678
+ // was tried together with the error, instead of only the last good route.
679
+ const wrapped = error instanceof Error ? error : new Error(String(error))
680
+ ;(wrapped as { transport?: CatalogTransport }).transport = transport
681
+ throw wrapped
682
+ }
683
+ }