@fonlan/dsh-model-parameters 0.1.7 → 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.
- package/README.en.md +21 -0
- package/README.md +11 -0
- package/lib/client.js +40 -1
- package/lib/client.js.map +1 -1
- package/lib/index.js +586 -22
- package/lib/types/index.d.ts +3 -0
- package/lib/types/server/catalog.d.ts +10 -2
- package/lib/types/server/system-proxy.d.ts +112 -0
- package/package.json +16 -15
- package/src/client/api.ts +10 -0
- package/src/client/locales.ts +12 -0
- package/src/client/settings-section.tsx +25 -1
- package/src/index.ts +42 -0
- package/src/server/catalog.ts +34 -18
- package/src/server/rpc.ts +2 -0
- package/src/server/system-proxy.ts +683 -0
|
@@ -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
|
+
}
|