subconscious-cli 6.3.1 → 8.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/bin/network.js ADDED
@@ -0,0 +1,710 @@
1
+ // Network helpers shared by the CLI.
2
+ //
3
+ // Two problems this module solves:
4
+ //
5
+ // 1. Node's built-in `fetch()` (undici) reports any connection-level failure
6
+ // as the bare string "fetch failed". The real reason lives on
7
+ // `error.cause` (`ENOTFOUND`, `ECONNREFUSED`, `ETIMEDOUT`,
8
+ // `CERT_HAS_EXPIRED`, ...). Users hit "subc login -> fetch failed" with no
9
+ // way to tell a DNS problem from a proxy problem, so `describeFetchError`
10
+ // walks the cause chain and surfaces the codes and host.
11
+ //
12
+ // 2. Node's `fetch()` ignores the conventional proxy environment variables
13
+ // (`HTTPS_PROXY`, `NO_PROXY`, ...). Corporate networks route egress
14
+ // through a proxy, so without honoring them `subc login` fails with no
15
+ // recourse. `createProxyFetch` returns a `fetch`-compatible function that
16
+ // tunnels HTTPS through an HTTP proxy via CONNECT when one is configured.
17
+ //
18
+ // The CLI ships with zero runtime dependencies, so the proxy tunnel is built on
19
+ // Node's `node:net`/`node:tls`/`node:https` rather than pulling in `undici`.
20
+
21
+ import { spawn } from 'node:child_process';
22
+ import https from 'node:https';
23
+ import net from 'node:net';
24
+ import os from 'node:os';
25
+ import tls from 'node:tls';
26
+ import zlib from 'node:zlib';
27
+ import { c } from './colors.js';
28
+
29
+ const PROXY_PROTOCOLS = new Set(['http:', 'https:']);
30
+
31
+ function errorCode(error) {
32
+ if (!error || typeof error !== 'object') return null;
33
+ if (typeof error.code === 'string' && error.code) return error.code;
34
+ if (typeof error.errno === 'string' && error.errno) return error.errno;
35
+ return null;
36
+ }
37
+
38
+ function collectErrors(error) {
39
+ const seen = new Set();
40
+ const out = [];
41
+ const visit = (node) => {
42
+ if (!node || typeof node !== 'object' || seen.has(node)) return;
43
+ seen.add(node);
44
+ out.push(node);
45
+ // AggregateError (e.g. happy-eyeballs) carries its failures on `errors`.
46
+ if (Array.isArray(node.errors)) {
47
+ for (const child of node.errors) visit(child);
48
+ }
49
+ if (node.cause && typeof node.cause === 'object') visit(node.cause);
50
+ };
51
+ visit(error);
52
+ return out;
53
+ }
54
+
55
+ export function describeFetchError(error) {
56
+ if (!error) return 'unknown network error';
57
+ const base =
58
+ error instanceof Error && error.message ? error.message : String(error);
59
+ const parts = [];
60
+ for (const node of collectErrors(error)) {
61
+ const code = errorCode(node);
62
+ const detail =
63
+ node instanceof Error && node.message && node.message !== base
64
+ ? node.message
65
+ : null;
66
+ if (code && !parts.some((p) => p.code === code)) {
67
+ parts.push({ code, detail });
68
+ } else if (detail && !parts.some((p) => p.detail === detail)) {
69
+ parts.push({ code: null, detail });
70
+ }
71
+ }
72
+ if (parts.length === 0) return base;
73
+ const rendered = parts
74
+ .map(({ code, detail }) => {
75
+ if (code && detail && detail !== code) return `${code}: ${detail}`;
76
+ return code || detail;
77
+ })
78
+ .join('; ');
79
+ return `${base} (${rendered})`;
80
+ }
81
+
82
+ // TLS failures whose cause is an untrusted issuing CA, typically a corporate
83
+ // middlebox (Zscaler, Netskope, ...) that re-signs traffic with its own root.
84
+ // Node does not trust that root unless it is added to the trust store.
85
+ const TLS_TRUST_ERROR_CODES = new Set([
86
+ 'SELF_SIGNED_CERT_IN_CHAIN',
87
+ 'DEPTH_ZERO_SELF_SIGNED_CERT',
88
+ 'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
89
+ 'UNABLE_TO_GET_ISSUER_CERT',
90
+ 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
91
+ 'CERT_UNTRUSTED',
92
+ 'CERT_HAS_EXPIRED',
93
+ 'ERR_TLS_CERT_ALTNAME_INVALID',
94
+ ]);
95
+
96
+ export function isTlsTrustError(error) {
97
+ return collectErrors(error).some((node) =>
98
+ TLS_TRUST_ERROR_CODES.has(errorCode(node)),
99
+ );
100
+ }
101
+
102
+ // Actionable guidance for the TLS-interception case. `NODE_EXTRA_CA_CERTS`
103
+ // *adds* to Node's built-in roots and is honored by both global fetch and this
104
+ // module's tunnel, so it is the one recommendation that is safe everywhere.
105
+ // Node must be restarted with the variable set: it is read at startup.
106
+ export function tlsTrustHint(error) {
107
+ if (!isTlsTrustError(error)) return null;
108
+ return [
109
+ 'The connection was re-signed by an untrusted CA — a corporate TLS-inspecting proxy (Zscaler, Netskope, ...).',
110
+ 'Ask IT for the proxy root certificate, then restart the CLI with it trusted:',
111
+ ' export NODE_EXTRA_CA_CERTS=/path/to/corporate-root-ca.pem',
112
+ ].join('\n ');
113
+ }
114
+
115
+ /** Whether this Node build can add the OS certificate store to the trust store. */
116
+ function supportsSystemCa() {
117
+ return process.allowedNodeEnvironmentFlags?.has('--use-system-ca') === true;
118
+ }
119
+
120
+ /**
121
+ * Re-exec once with `--use-system-ca` when the OS certificate store is opted
122
+ * in and this Node build supports the flag. Node 18 and 20 reject the flag
123
+ * outright, so it is probed rather than assumed. Node reads the option at
124
+ * startup, which is why this has to happen before any request.
125
+ *
126
+ * Resolves true once the child has exited and its status is mirrored, so the
127
+ * caller must stop. The child re-enters here with the flag already present and
128
+ * resolves false.
129
+ */
130
+ export async function ensureSystemCa(options = {}) {
131
+ if (process.execArgv.includes('--use-system-ca')) return false;
132
+ if (!(options.supported ?? supportsSystemCa())) return false;
133
+ const run = options.spawnImpl || spawn;
134
+ try {
135
+ await runMirrored(
136
+ () =>
137
+ run(
138
+ process.execPath,
139
+ ['--use-system-ca', ...process.execArgv, ...process.argv.slice(1)],
140
+ { stdio: 'inherit' },
141
+ ),
142
+ options.interactive,
143
+ );
144
+ return true;
145
+ } catch (error) {
146
+ console.error(
147
+ `${c.red}Could not restart with the OS certificate store: ${error.message}${c.reset}`,
148
+ );
149
+ return false;
150
+ }
151
+ }
152
+
153
+ const FORWARDED_SIGNALS = ['SIGTERM', 'SIGHUP'];
154
+
155
+ // Run the child, pass SIGTERM/SIGHUP on to it, and mirror its exit code or
156
+ // signal. At a terminal, Ctrl-C already reaches the whole process group, so
157
+ // forwarding SIGINT would deliver it twice. Without a terminal, a script or
158
+ // harness signals this pid alone, so SIGINT is forwarded too.
159
+ function runMirrored(start, interactive = process.stdin.isTTY === true) {
160
+ return new Promise((resolve, reject) => {
161
+ const child = start();
162
+ const forward = (signal) => {
163
+ if (child.exitCode === null && child.signalCode === null) {
164
+ child.kill(signal);
165
+ }
166
+ };
167
+ const onSigint = interactive ? () => {} : forward;
168
+ for (const signal of FORWARDED_SIGNALS) process.on(signal, forward);
169
+ process.on('SIGINT', onSigint);
170
+ const stop = () => {
171
+ for (const signal of FORWARDED_SIGNALS) process.off(signal, forward);
172
+ process.off('SIGINT', onSigint);
173
+ };
174
+ child.on('error', (error) => {
175
+ stop();
176
+ reject(error);
177
+ });
178
+ child.on('exit', (code, signal) => {
179
+ stop();
180
+ if (signal) {
181
+ process.exitCode = 128 + (os.constants.signals[signal] ?? 0);
182
+ process.kill(process.pid, signal);
183
+ } else if (code) {
184
+ process.exitCode = code;
185
+ }
186
+ resolve();
187
+ });
188
+ });
189
+ }
190
+
191
+ /**
192
+ * Fail before any request when SYSTEM_CA=on is set on a Node that cannot
193
+ * honor it. Only network commands call this, so help, config and update-key
194
+ * still work and the user can turn the setting off.
195
+ */
196
+ export function refuseUnsupportedSystemCa(systemCaEnabled, profilePath) {
197
+ if (!systemCaEnabled || supportsSystemCa()) return false;
198
+ console.error(
199
+ `${c.red}SYSTEM_CA=on needs Node 22.15 or newer (the --use-system-ca flag); this is ${process.version}.${c.reset}`,
200
+ );
201
+ const where = profilePath ? ` from ${profilePath}` : '';
202
+ console.error(
203
+ `${c.dim}Remove SYSTEM_CA=on${where} with subc config edit, or upgrade Node, then retry.${c.reset}`,
204
+ );
205
+ process.exitCode = 1;
206
+ return true;
207
+ }
208
+
209
+ // One line for a failure that a command can print and keep going. Adds the
210
+ // real cause for Node's opaque "fetch failed", and the CA fix when the cause is
211
+ // an untrusted issuer (the common corporate-machine failure).
212
+ export function describeFailure(error) {
213
+ const message = describeFetchError(error);
214
+ if (error?.name === 'TimeoutError') return message;
215
+ const hint = tlsTrustHint(error);
216
+ return hint
217
+ ? `${message}\n Ask IT for the proxy root certificate, then restart with NODE_EXTRA_CA_CERTS set, or set SYSTEM_CA=on in your profile.`
218
+ : message;
219
+ }
220
+
221
+ // Parse a proxy URL from an environment variable. Returns null when unset or
222
+ // malformed so a bad value never masks the real connection error.
223
+ function parseProxyUrl(value) {
224
+ if (!value || typeof value !== 'string') return null;
225
+ let url;
226
+ try {
227
+ url = new URL(value.includes('://') ? value : `http://${value}`);
228
+ } catch {
229
+ return null;
230
+ }
231
+ if (!PROXY_PROTOCOLS.has(url.protocol)) return null;
232
+ return url;
233
+ }
234
+
235
+ // Match a hostname against a single NO_PROXY entry. Supports "*", a bare
236
+ // hostname, a ".suffix" domain match, and an optional ":port" suffix.
237
+ function hostMatchesNoProxy(hostname, entry) {
238
+ const trimmed = entry.trim().toLowerCase();
239
+ if (!trimmed) return false;
240
+ if (trimmed === '*') return true;
241
+
242
+ const colon = trimmed.lastIndexOf(':');
243
+ let host = trimmed;
244
+ if (colon !== -1 && trimmed.indexOf(']') === -1) {
245
+ // Keep IPv6 literals intact; only treat a trailing ":port" as a port.
246
+ host = trimmed.slice(0, colon);
247
+ }
248
+ host = host.replace(/^\./, '');
249
+ return hostname === host || hostname.endsWith(`.${host}`);
250
+ }
251
+
252
+ export function proxyForUrl(targetUrl, env = process.env) {
253
+ let url;
254
+ try {
255
+ url = new URL(targetUrl);
256
+ } catch {
257
+ return null;
258
+ }
259
+ const hostname = url.hostname.toLowerCase();
260
+
261
+ const noProxy = env.NO_PROXY || env.no_proxy;
262
+ if (noProxy) {
263
+ for (const entry of noProxy.split(',')) {
264
+ if (hostMatchesNoProxy(hostname, entry)) return null;
265
+ }
266
+ }
267
+
268
+ const candidate =
269
+ url.protocol === 'https:'
270
+ ? env.HTTPS_PROXY || env.https_proxy || env.HTTP_PROXY || env.http_proxy
271
+ : env.HTTP_PROXY || env.http_proxy;
272
+ return parseProxyUrl(candidate);
273
+ }
274
+
275
+ function proxyAuthorizationHeader(proxyUrl) {
276
+ const user = decodeURIComponent(proxyUrl.username || '');
277
+ const pass = decodeURIComponent(proxyUrl.password || '');
278
+ if (user || pass) {
279
+ const token = Buffer.from(`${user}:${pass}`).toString('base64');
280
+ return `Basic ${token}`;
281
+ }
282
+ return null;
283
+ }
284
+
285
+ // The reason a signal aborted, as global fetch reports it: a TimeoutError for
286
+ // AbortSignal.timeout(), an AbortError for a plain abort().
287
+ function abortReason(signal) {
288
+ return (
289
+ signal.reason ??
290
+ new DOMException('This operation was aborted', 'AbortError')
291
+ );
292
+ }
293
+
294
+ // Open a TCP tunnel to `targetHost:targetPort` through the HTTP proxy, then
295
+ // upgrade it to TLS for the target host. Resolves to a connected TLSSocket.
296
+ // CONNECT establishes a tunnel to an HTTPS origin. It is the only form that
297
+ // makes sense here: `createProxyFetch` never tunnels a plain `http:` target
298
+ // (see there for why).
299
+ //
300
+ // `timeoutMs` bounds the whole setup, CONNECT and TLS handshake together.
301
+ // `signal` is the caller's: a proxy that answers CONNECT and then forwards
302
+ // nothing must not outlive the caller's deadline.
303
+ export function connectThroughProxy(
304
+ proxyUrl,
305
+ targetHost,
306
+ targetPort,
307
+ options = {},
308
+ ) {
309
+ const timeoutMs = options.timeoutMs ?? 8000;
310
+ const signal = options.signal;
311
+ const proxyPort =
312
+ Number(proxyUrl.port) || (proxyUrl.protocol === 'https:' ? 443 : 80);
313
+
314
+ return new Promise((resolve, reject) => {
315
+ if (signal?.aborted) {
316
+ reject(abortReason(signal));
317
+ return;
318
+ }
319
+
320
+ // The promise must always settle. A proxy that blocks a destination just
321
+ // closes the socket: with no 'error' event, no response, and no other work
322
+ // left, the event loop empties and the CLI exits 0 having printed nothing.
323
+ let settled = false;
324
+ let tlsSocket = null;
325
+ const cleanup = () => {
326
+ settled = true;
327
+ clearTimeout(timer);
328
+ signal?.removeEventListener('abort', onAbort);
329
+ socket.removeListener('error', onError);
330
+ socket.removeListener('close', onClose);
331
+ };
332
+ const fail = (error) => {
333
+ if (settled) return;
334
+ cleanup();
335
+ tlsSocket?.destroy();
336
+ socket.destroy();
337
+ reject(error);
338
+ };
339
+ const succeed = () => {
340
+ if (settled) return;
341
+ cleanup();
342
+ resolve(tlsSocket);
343
+ };
344
+ const onError = (error) => fail(error);
345
+ const onClose = () => {
346
+ const error = new Error(
347
+ 'The proxy closed the connection without responding to CONNECT',
348
+ );
349
+ error.code = 'EPROXY';
350
+ fail(error);
351
+ };
352
+ const onAbort = () => fail(abortReason(signal));
353
+
354
+ // When the proxy itself is HTTPS, speak TLS to the proxy first.
355
+ const socket =
356
+ proxyUrl.protocol === 'https:'
357
+ ? tls.connect({
358
+ host: proxyUrl.hostname,
359
+ port: proxyPort,
360
+ servername: proxyUrl.hostname,
361
+ })
362
+ : net.connect({ host: proxyUrl.hostname, port: proxyPort });
363
+
364
+ const timer = setTimeout(() => {
365
+ const error = new Error('Timed out opening the proxy tunnel');
366
+ error.code = 'ETIMEDOUT';
367
+ fail(error);
368
+ }, timeoutMs);
369
+ signal?.addEventListener('abort', onAbort, { once: true });
370
+ socket.once('error', onError);
371
+ socket.once('close', onClose);
372
+
373
+ const sendConnect = () => {
374
+ const auth = proxyAuthorizationHeader(proxyUrl);
375
+ const lines = [
376
+ `CONNECT ${targetHost}:${targetPort} HTTP/1.1`,
377
+ `Host: ${targetHost}:${targetPort}`,
378
+ ];
379
+ if (auth) lines.push(`Proxy-Authorization: ${auth}`);
380
+ socket.write(`${lines.join('\r\n')}\r\n\r\n`);
381
+ };
382
+
383
+ if (proxyUrl.protocol === 'https:') {
384
+ socket.once('secureConnect', sendConnect);
385
+ } else {
386
+ socket.once('connect', sendConnect);
387
+ }
388
+
389
+ let buffer = '';
390
+ const onData = (chunk) => {
391
+ buffer += chunk.toString('latin1');
392
+ const headerEnd = buffer.indexOf('\r\n\r\n');
393
+ if (headerEnd === -1) return;
394
+ socket.removeListener('data', onData);
395
+ const statusLine = buffer.slice(0, headerEnd).split('\r\n')[0];
396
+ const match = /^HTTP\/\d\.\d\s+(\d{3})/.exec(statusLine);
397
+ const status = match ? Number(match[1]) : 0;
398
+ if (status !== 200) {
399
+ const error = new Error(
400
+ `Proxy CONNECT failed (${status || 'no response'})`,
401
+ );
402
+ error.code = 'EPROXY';
403
+ fail(error);
404
+ return;
405
+ }
406
+ const remainder = buffer.slice(headerEnd + 4);
407
+ if (remainder.length > 0)
408
+ socket.unshift(Buffer.from(remainder, 'latin1'));
409
+
410
+ tlsSocket = tls.connect({
411
+ socket,
412
+ servername: net.isIP(targetHost) ? undefined : targetHost,
413
+ // Without a servername (IP targets) Node checks the cert against the
414
+ // socket's host, which is the proxy. Check it against the target.
415
+ checkServerIdentity: (_host, cert) =>
416
+ tls.checkServerIdentity(targetHost, cert),
417
+ rejectUnauthorized: options.rejectUnauthorized ?? true,
418
+ ca: options.ca,
419
+ });
420
+ tlsSocket.once('secureConnect', succeed);
421
+ tlsSocket.once('error', (error) => fail(error));
422
+ };
423
+ socket.on('data', onData);
424
+ });
425
+ }
426
+
427
+ const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
428
+ const MAX_REDIRECTS = 20;
429
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
430
+ // Statuses whose Response must be built with a null body.
431
+ const NULL_BODY_STATUSES = new Set([204, 205, 304]);
432
+
433
+ // A proxy can re-encode the response (some middleboxes gzip application/json
434
+ // on the way through). Node's global fetch decompresses transparently; the
435
+ // tunnel must do the same, or res.json() silently fails to parse.
436
+ function decodeBody(buffer, encoding) {
437
+ const normalized = String(encoding || '')
438
+ .trim()
439
+ .toLowerCase();
440
+ try {
441
+ if (normalized === 'gzip' || normalized === 'x-gzip')
442
+ return zlib.gunzipSync(buffer);
443
+ if (normalized === 'deflate') return zlib.inflateSync(buffer);
444
+ if (normalized === 'br') return zlib.brotliDecompressSync(buffer);
445
+ } catch (error) {
446
+ throw new Error(
447
+ `Could not decode a ${normalized} response body: ${error.message}`,
448
+ );
449
+ }
450
+ return buffer;
451
+ }
452
+
453
+ function readResponseBody(response) {
454
+ return new Promise((resolve, reject) => {
455
+ const chunks = [];
456
+ let total = 0;
457
+ response.on('data', (chunk) => {
458
+ total += chunk.length;
459
+ if (total > MAX_RESPONSE_BYTES) {
460
+ response.destroy();
461
+ reject(new Error('Response body was too large'));
462
+ return;
463
+ }
464
+ chunks.push(chunk);
465
+ });
466
+ response.once('end', () => {
467
+ try {
468
+ resolve(
469
+ decodeBody(
470
+ Buffer.concat(chunks),
471
+ response.headers['content-encoding'],
472
+ ),
473
+ );
474
+ } catch (error) {
475
+ reject(error);
476
+ }
477
+ });
478
+ response.once('error', reject);
479
+ response.once('close', () => {
480
+ if (!response.complete) {
481
+ reject(new Error('The connection closed before the response ended'));
482
+ }
483
+ });
484
+ });
485
+ }
486
+
487
+ // Build a real Response, so callers get the same object global fetch returns
488
+ // (`headers.get()`, `arrayBuffer()`, `url`, ...) whether or not a proxy is set.
489
+ function toResponse(response, body, url, redirected) {
490
+ const headers = new Headers();
491
+ for (const [name, value] of Object.entries(response.headers)) {
492
+ if (value === undefined) continue;
493
+ for (const item of Array.isArray(value) ? value : [value]) {
494
+ headers.append(name, item);
495
+ }
496
+ }
497
+ // The body is already decoded, so these no longer describe it.
498
+ headers.delete('content-encoding');
499
+ headers.delete('content-length');
500
+ const status = response.statusCode;
501
+ const result = new Response(NULL_BODY_STATUSES.has(status) ? null : body, {
502
+ status,
503
+ statusText: response.statusMessage,
504
+ headers,
505
+ });
506
+ Object.defineProperty(result, 'url', { value: url });
507
+ Object.defineProperty(result, 'redirected', { value: redirected });
508
+ return result;
509
+ }
510
+
511
+ // Send one request through a fresh tunnel and buffer the whole response. The
512
+ // tunnel carries exactly one request and closes when it settles: an idle open
513
+ // socket would keep the event loop alive and make the CLI hang on exit.
514
+ function requestThroughProxy(targetUrl, init, proxyUrl, options) {
515
+ const url = new URL(targetUrl);
516
+ const targetPort = Number(url.port) || 443;
517
+ // URL keeps the brackets on an IPv6 literal; the proxy expects a bare host.
518
+ const targetHost = url.hostname.replace(/^\[|\]$/g, '');
519
+ const signal = init.signal;
520
+
521
+ return new Promise((resolve, reject) => {
522
+ if (signal?.aborted) {
523
+ reject(abortReason(signal));
524
+ return;
525
+ }
526
+ let done = false;
527
+ let tlsSocket = null;
528
+ let request = null;
529
+ const settle = (callback, value) => {
530
+ if (done) return;
531
+ done = true;
532
+ signal?.removeEventListener('abort', onAbort);
533
+ request?.destroy();
534
+ tlsSocket?.destroy();
535
+ callback(value);
536
+ };
537
+ const onAbort = () => settle(reject, abortReason(signal));
538
+ signal?.addEventListener('abort', onAbort, { once: true });
539
+
540
+ connectThroughProxy(proxyUrl, targetHost, targetPort, {
541
+ ...options,
542
+ signal,
543
+ }).then(
544
+ (socket) => {
545
+ if (done) {
546
+ socket.destroy();
547
+ return;
548
+ }
549
+ tlsSocket = socket;
550
+ const headers = {
551
+ ...Object.fromEntries(new Headers(init.headers ?? {})),
552
+ host: url.host,
553
+ };
554
+ request = https.request({
555
+ method: init.method || 'GET',
556
+ path: `${url.pathname}${url.search}`,
557
+ headers,
558
+ createConnection: () => socket,
559
+ });
560
+ request.once('error', (error) => settle(reject, error));
561
+ request.once('response', (response) => {
562
+ readResponseBody(response).then(
563
+ (body) =>
564
+ settle(
565
+ resolve,
566
+ toResponse(response, body, targetUrl, options.redirected),
567
+ ),
568
+ (error) => settle(reject, error),
569
+ );
570
+ });
571
+ if (init.body) request.write(init.body);
572
+ request.end();
573
+ },
574
+ (error) => settle(reject, error),
575
+ );
576
+ });
577
+ }
578
+
579
+ // The request to send after a redirect, following the fetch spec: 303 (and
580
+ // 301/302 after a POST) becomes a bodiless GET, and credentials do not cross
581
+ // to a different origin.
582
+ function redirectInit(init, status, fromUrl, toUrl) {
583
+ const method = (init.method || 'GET').toUpperCase();
584
+ const toGet =
585
+ (status === 303 && method !== 'GET' && method !== 'HEAD') ||
586
+ ((status === 301 || status === 302) && method === 'POST');
587
+ const headers = new Headers(init.headers ?? {});
588
+ if (toGet) {
589
+ headers.delete('content-type');
590
+ headers.delete('content-length');
591
+ }
592
+ if (new URL(fromUrl).origin !== new URL(toUrl).origin) {
593
+ headers.delete('authorization');
594
+ }
595
+ return {
596
+ ...init,
597
+ method: toGet ? 'GET' : init.method,
598
+ body: toGet ? undefined : init.body,
599
+ headers: Object.fromEntries(headers),
600
+ };
601
+ }
602
+
603
+ function redirectError(message) {
604
+ return new TypeError('fetch failed', { cause: new Error(message) });
605
+ }
606
+
607
+ // Return a `fetch`-compatible function that routes through the configured proxy
608
+ // (from `env`) when one applies to the target URL. Falls back to the provided
609
+ // base fetch (global `fetch` by default) otherwise. Redirects are followed per
610
+ // hop, so each new URL gets its own proxy and NO_PROXY decision.
611
+ //
612
+ // Only `https:` targets are tunneled. A CONNECT tunnel would speak TLS to a
613
+ // plaintext origin, which breaks the `SUBCONSCIOUS_URL=http://localhost:3000`
614
+ // local-development flow. Plain `http:` requests therefore go out directly
615
+ // through the base fetch, which does not use the proxy env vars.
616
+ export function createProxyFetch(options = {}) {
617
+ const env = options.env || process.env;
618
+ const baseFetch = options.fetchImpl || globalThis.fetch;
619
+ const tunnel = {
620
+ timeoutMs: options.timeoutMs,
621
+ ca: options.ca,
622
+ rejectUnauthorized: options.rejectUnauthorized,
623
+ };
624
+ const proxyFetch = async (url, init = {}, hops = 0) => {
625
+ const target = String(url);
626
+ if (!/^https:/i.test(target)) return baseFetch(url, init);
627
+ const proxy = proxyForUrl(target, env);
628
+ if (!proxy) return baseFetch(url, init);
629
+
630
+ const response = await requestThroughProxy(target, init, proxy, {
631
+ ...tunnel,
632
+ redirected: hops > 0,
633
+ });
634
+ const mode = init.redirect ?? 'follow';
635
+ const location = response.headers.get('location');
636
+ if (!REDIRECT_STATUSES.has(response.status) || !location) return response;
637
+ if (mode === 'manual') return response;
638
+ if (mode === 'error') throw redirectError('unexpected redirect');
639
+ if (hops >= MAX_REDIRECTS) throw redirectError('redirect count exceeded');
640
+ const next = new URL(location, target).href;
641
+ return proxyFetch(
642
+ next,
643
+ redirectInit(init, response.status, target, next),
644
+ hops + 1,
645
+ );
646
+ };
647
+ return (url, init) => proxyFetch(url, init);
648
+ }
649
+
650
+ // A process-wide proxy-aware `fetch`. Callers across the CLI use this as the
651
+ // default so every outbound request honors HTTPS_PROXY/NO_PROXY, not just
652
+ // login. Tests inject their own `fetchImpl` where they need to stub the wire.
653
+ export function proxyAwareFetch(url, init) {
654
+ return createProxyFetch()(url, init);
655
+ }
656
+
657
+ // A single env var can carry the whole proxy setup for a command; expose the
658
+ // detection so callers can tell the user when their request is being proxied.
659
+ // Credentials are intentionally omitted so they never reach logs or terminals.
660
+ export function describeProxy(url, env = process.env) {
661
+ const proxy = proxyForUrl(url, env);
662
+ if (!proxy) return null;
663
+ return `${proxy.protocol}//${proxy.host}`;
664
+ }
665
+
666
+ // Failures a proxy setting can explain: the host was never reached.
667
+ const CONNECTION_ERROR_CODES = new Set([
668
+ 'ENOTFOUND',
669
+ 'EAI_AGAIN',
670
+ 'ECONNREFUSED',
671
+ 'ECONNRESET',
672
+ 'ETIMEDOUT',
673
+ 'EHOSTUNREACH',
674
+ 'ENETUNREACH',
675
+ 'EPROXY',
676
+ 'UND_ERR_CONNECT_TIMEOUT',
677
+ 'UND_ERR_SOCKET',
678
+ ]);
679
+
680
+ function isConnectionError(error) {
681
+ if (error?.name === 'TimeoutError') return true;
682
+ return collectErrors(error).some((node) =>
683
+ CONNECTION_ERROR_CODES.has(errorCode(node)),
684
+ );
685
+ }
686
+
687
+ function isHttpsUrl(url) {
688
+ try {
689
+ return new URL(String(url)).protocol === 'https:';
690
+ } catch {
691
+ return false;
692
+ }
693
+ }
694
+
695
+ // One line about proxies for a request to `url` that failed with `error`.
696
+ // With a proxy configured, name it so the user can check it. Without one,
697
+ // suggest setting it: an agent driving subc sees only this output, so the
698
+ // failure itself has to say where the fix is documented. Null when a proxy
699
+ // cannot explain the failure (an HTTP error, or a TLS trust failure, which
700
+ // tlsTrustHint covers).
701
+ export function proxyHint(error, url, env = process.env) {
702
+ if (isTlsTrustError(error) || !isConnectionError(error)) return null;
703
+ // Only https requests go through the proxy, so it cannot explain the rest.
704
+ if (!isHttpsUrl(url)) return null;
705
+ const proxy = describeProxy(url, env);
706
+ if (proxy) {
707
+ return `Tried via proxy ${proxy} (from HTTPS_PROXY/NO_PROXY).`;
708
+ }
709
+ return 'If this network needs a proxy, set HTTPS_PROXY=http://host:port and retry. See subc login help.';
710
+ }