subconscious-cli 6.3.0 → 7.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/README.md +5 -2
- package/bin/agent-launch.js +1 -25
- package/bin/agents.js +2 -19
- package/bin/auth.js +31 -7
- package/bin/claude-config.js +13 -603
- package/bin/cli.js +42 -0
- package/bin/feedback.js +2 -1
- package/bin/models.js +4 -2
- package/bin/native/subc-tui-darwin-amd64 +0 -0
- package/bin/native/subc-tui-darwin-arm64 +0 -0
- package/bin/native/subc-tui-linux-amd64 +0 -0
- package/bin/native/subc-tui-linux-arm64 +0 -0
- package/bin/native/subc-tui-windows-amd64.exe +0 -0
- package/bin/native/subc-tui-windows-arm64.exe +0 -0
- package/bin/network.js +710 -0
- package/bin/profiles.js +7 -1
- package/bin/runbook/claude-code/agent.json +6 -27
- package/bin/runbook/claude-code/install.sh +3 -2
- package/bin/runbook/shared.json +10 -0
- package/bin/update-check.js +2 -1
- package/bin/upgrade.js +2 -1
- package/bin/usage.js +19 -3
- package/bin/windows/launch.js +5 -17
- package/package.json +1 -1
- package/bin/claude-config-drift.js +0 -58
- package/bin/claude-config-plugins.js +0 -103
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
|
+
}
|