@apifuse/provider-sdk 2.2.0-beta.16 → 2.2.0-beta.18

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.
@@ -2,16 +2,20 @@ import { createHash } from "node:crypto";
2
2
  import { Socket } from "node:net";
3
3
  import { connect as connectTlsSocket } from "node:tls";
4
4
  import { SocksClient } from "socks";
5
- import { ProxyResolutionError } from "../config/loader.js";
5
+ import { assertTunnelingScheme, ProxyResolutionError, SMARTPROXY_APP_KEY_ENV, VENDOR_DEFAULT_PROTOCOL, resolveWithVendor, } from "../config/loader.js";
6
6
  import { TransportError } from "../errors.js";
7
7
  import { NativeEgressPolicyValidationError, parseNativeEgressPolicy, } from "../native-egress-policy.js";
8
- import { hasNodemavenCredentials, nodemavenSessionWindow, synthesizeNodemavenProxy, } from "./proxy-nodemaven.js";
8
+ import { canonicalizeEgressHost, classifyEgressHost, embeddedIpv4FromIpv6, ipv4InCidr, ipv6InCidr, parseIpv6, parseStrictIpv4, } from "../native-address.js";
9
+ import { createEnvContext } from "./env.js";
10
+ import { NODEMAVEN_FILTER_ENV, NODEMAVEN_PASSWORD_ENV, NODEMAVEN_USERNAME_ENV, nodemavenSessionWindow, synthesizeNodemavenProxy, } from "./proxy-nodemaven.js";
11
+ import { redactSensitiveError } from "./request-options.js";
9
12
  export class NativeNetworkError extends TransportError {
10
- constructor(message, code) {
13
+ constructor(message, code, cause) {
11
14
  const isEgressPolicyFailure = code.startsWith("native_egress_") || code === "native_dynamic_egress_unsupported";
12
15
  super(message, {
13
16
  code,
14
17
  status: 0,
18
+ ...(cause ? { cause } : {}),
15
19
  ...(isEgressPolicyFailure ? { category: "provider_error", retryable: false } : {}),
16
20
  });
17
21
  this.name = "NativeNetworkError";
@@ -20,6 +24,10 @@ export class NativeNetworkError extends TransportError {
20
24
  return super.code;
21
25
  }
22
26
  }
27
+ function safeDiagnosticEgressHost(value) {
28
+ const canonical = canonicalizeEgressHost(value);
29
+ return canonical.ok ? canonical.host : `<invalid-host:${canonical.reason}>`;
30
+ }
23
31
  export class NativeProxyExpiredError extends NativeNetworkError {
24
32
  expiresAt;
25
33
  constructor(expiresAt) {
@@ -30,14 +38,15 @@ export class NativeProxyExpiredError extends NativeNetworkError {
30
38
  }
31
39
  /** Raised before transport setup when a native destination is not authorized. */
32
40
  export class NativeEgressNotDeclaredError extends NativeNetworkError {
33
- host;
34
41
  port;
35
42
  tls;
43
+ host;
36
44
  constructor(host, port, tls) {
37
- super(`Native ${tls === "required" ? "TLS" : "TCP"} egress is not declared for ${host}:${port}`, "native_egress_not_declared");
38
- this.host = host;
45
+ const diagnosticHost = safeDiagnosticEgressHost(host);
46
+ super(`Native ${tls === "required" ? "TLS" : "TCP"} egress is not declared for ${diagnosticHost}:${port}`, "native_egress_not_declared");
39
47
  this.port = port;
40
48
  this.tls = tls;
49
+ this.host = diagnosticHost;
41
50
  this.name = "NativeEgressNotDeclaredError";
42
51
  }
43
52
  }
@@ -46,16 +55,17 @@ export class NativeEgressNotDeclaredError extends NativeNetworkError {
46
55
  * its expiry remains in the client's bounded recent-expiry evidence window.
47
56
  */
48
57
  export class NativeEgressGrantExpiredError extends NativeNetworkError {
49
- host;
50
58
  port;
51
59
  tls;
52
60
  expiresAt;
61
+ host;
53
62
  constructor(host, port, tls, expiresAt) {
54
- super(`Native ${tls === "required" ? "TLS" : "TCP"} egress grant expired for ${host}:${port}`, "native_egress_grant_expired");
55
- this.host = host;
63
+ const diagnosticHost = safeDiagnosticEgressHost(host);
64
+ super(`Native ${tls === "required" ? "TLS" : "TCP"} egress grant expired for ${diagnosticHost}:${port}`, "native_egress_grant_expired");
56
65
  this.port = port;
57
66
  this.tls = tls;
58
67
  this.expiresAt = expiresAt;
68
+ this.host = diagnosticHost;
59
69
  this.name = "NativeEgressGrantExpiredError";
60
70
  }
61
71
  }
@@ -66,17 +76,69 @@ export class NativeIdleTimeoutError extends NativeNetworkError {
66
76
  this.name = "NativeIdleTimeoutError";
67
77
  }
68
78
  }
79
+ const VENDOR_CREDENTIAL_NAMES = {
80
+ smartproxy: [SMARTPROXY_APP_KEY_ENV],
81
+ nodemaven: [NODEMAVEN_USERNAME_ENV, NODEMAVEN_PASSWORD_ENV],
82
+ };
83
+ /** Build a resolver over the SDK's existing injectable environment context. */
84
+ export function createEnvVendorCredentialResolver(env = createEnvContext()) {
85
+ return (vendor) => {
86
+ const names = VENDOR_CREDENTIAL_NAMES[vendor] ?? [];
87
+ const values = {};
88
+ const missing = [];
89
+ for (const name of names) {
90
+ const value = env.get(name)?.trim();
91
+ if (value)
92
+ values[name] = value;
93
+ else
94
+ missing.push(name);
95
+ }
96
+ if (missing.length > 0 || names.length === 0)
97
+ return { kind: "absent", missing };
98
+ if (vendor === "nodemaven") {
99
+ const filter = env.get(NODEMAVEN_FILTER_ENV)?.trim();
100
+ if (filter)
101
+ values[NODEMAVEN_FILTER_ENV] = filter;
102
+ }
103
+ return { kind: "present", values };
104
+ };
105
+ }
106
+ function skipped(reason) {
107
+ return { kind: "skipped", reason };
108
+ }
109
+ function lookupCredentials(input) {
110
+ try {
111
+ return input.credentials(input.vendor);
112
+ }
113
+ catch (error) {
114
+ return {
115
+ kind: "error",
116
+ cause: error instanceof Error ? error : new Error(String(error)),
117
+ };
118
+ }
119
+ }
69
120
  function synthesizeNodemavenGateway(input) {
70
- if (input.vendor !== "nodemaven" || !hasNodemavenCredentials())
121
+ if (input.vendor !== "nodemaven")
71
122
  return undefined;
72
- // NodeMaven defaults ctx.http to HTTP CONNECT because SOCKS5 adds ~500ms per
73
- // request. Native LOCO pays this once per long-lived connection and must reach
74
- // arbitrary destination ports, so SOCKS5 is mandatory here.
123
+ const lookup = lookupCredentials(input);
124
+ if (lookup.kind === "error") {
125
+ return skipped({ kind: "credential_lookup_failed", cause: lookup.cause });
126
+ }
127
+ if (lookup.kind === "absent") {
128
+ return skipped({ kind: "credentials_absent", missing: lookup.missing });
129
+ }
75
130
  const sessionWindow = nodemavenSessionWindow(input.policy, input.now);
76
131
  const synthesized = synthesizeNodemavenProxy({
77
132
  policy: input.policy,
133
+ credentials: {
134
+ username: lookup.values[NODEMAVEN_USERNAME_ENV] ?? "",
135
+ password: lookup.values[NODEMAVEN_PASSWORD_ENV] ?? "",
136
+ ...(lookup.values[NODEMAVEN_FILTER_ENV]
137
+ ? { filter: lookup.values[NODEMAVEN_FILTER_ENV] }
138
+ : {}),
139
+ },
78
140
  affinityKey: input.affinityKey,
79
- protocol: "socks5",
141
+ protocol: input.protocol,
80
142
  poolIndex: 0,
81
143
  refreshEpoch: sessionWindow.refreshEpoch,
82
144
  now: input.now,
@@ -90,7 +152,48 @@ function synthesizeNodemavenGateway(input) {
90
152
  expiresAt: synthesized.expiresAt,
91
153
  };
92
154
  }
155
+ async function synthesizeSmartproxyGateway(input) {
156
+ if (input.vendor !== "smartproxy")
157
+ return undefined;
158
+ const lookup = lookupCredentials(input);
159
+ if (lookup.kind === "error") {
160
+ return skipped({ kind: "credential_lookup_failed", cause: lookup.cause });
161
+ }
162
+ if (lookup.kind === "absent") {
163
+ return skipped({ kind: "credentials_absent", missing: lookup.missing });
164
+ }
165
+ try {
166
+ const resolved = await resolveWithVendor("smartproxy", input.policy, {
167
+ proxyPolicy: input.policy,
168
+ affinityKey: input.affinityKey,
169
+ protocol: input.protocol,
170
+ }, {
171
+ protocol: input.protocol,
172
+ poolIndex: 0,
173
+ refreshEpoch: 0,
174
+ credentials: lookup.values,
175
+ ambientDefaults: false,
176
+ sharedCache: false,
177
+ });
178
+ if (!resolved.url) {
179
+ return skipped({ kind: "allocation_failed", cause: new Error("No endpoint returned") });
180
+ }
181
+ return {
182
+ url: resolved.url,
183
+ vendor: "smartproxy",
184
+ sticky: isStickyPolicy(input.policy),
185
+ };
186
+ }
187
+ catch (error) {
188
+ const cause = error instanceof Error ? error : new Error(String(error));
189
+ return skipped({
190
+ kind: "allocation_failed",
191
+ cause: redactSensitiveError(cause, Object.values(lookup.values)),
192
+ });
193
+ }
194
+ }
93
195
  const DEFAULT_GATEWAY_SYNTHESIZERS = [
196
+ synthesizeSmartproxyGateway,
94
197
  synthesizeNodemavenGateway,
95
198
  ];
96
199
  /** Domain-separated, process-independent affinity derived from credential identity. */
@@ -116,29 +219,104 @@ function resolveNativeVendorChain(policy) {
116
219
  }
117
220
  return chain;
118
221
  }
119
- /** Resolve the first configured native gateway without invoking an allocator API. */
120
- export function resolveNativeGatewayProxy(input) {
222
+ function isProxyProtocol(value) {
223
+ return value === "http" || value === "socks5";
224
+ }
225
+ function isSkippedSynthesis(result) {
226
+ return "kind" in result && result.kind === "skipped";
227
+ }
228
+ function sanitizeVendorResolutionCause(error, vendor, credentials) {
229
+ const cause = error instanceof Error ? error : new Error(String(error));
230
+ try {
231
+ const lookup = credentials(vendor);
232
+ return lookup.kind === "present"
233
+ ? redactSensitiveError(cause, Object.values(lookup.values))
234
+ : cause;
235
+ }
236
+ catch {
237
+ return cause;
238
+ }
239
+ }
240
+ function sanitizeVendorSkipReason(reason, vendor, credentials) {
241
+ return reason.kind === "allocation_failed" || reason.kind === "credential_lookup_failed"
242
+ ? {
243
+ ...reason,
244
+ cause: sanitizeVendorResolutionCause(reason.cause, vendor, credentials),
245
+ }
246
+ : reason;
247
+ }
248
+ function defaultVendorProtocol(vendor) {
249
+ return vendor === "smartproxy" || vendor === "nodemaven"
250
+ ? VENDOR_DEFAULT_PROTOCOL[vendor]
251
+ : "http";
252
+ }
253
+ async function resolveNativeGatewayProxyDetailed(input) {
121
254
  if (input.policy.mode === "disabled")
122
- return undefined;
255
+ return { skips: [] };
123
256
  const synthesizers = input.gatewaySynthesizers ?? DEFAULT_GATEWAY_SYNTHESIZERS;
257
+ const credentials = input.credentials ?? createEnvVendorCredentialResolver();
124
258
  const now = input.now ?? Date.now();
259
+ const skips = [];
125
260
  for (const vendor of resolveNativeVendorChain(input.policy)) {
261
+ const protocol = input.protocol ?? defaultVendorProtocol(vendor);
262
+ if (!isProxyProtocol(protocol)) {
263
+ skips.push({ vendor, reason: { kind: "protocol_unsupported", protocol: String(protocol) } });
264
+ continue;
265
+ }
266
+ let vendorSkip;
126
267
  for (const synthesize of synthesizers) {
127
- const resolved = synthesize({
128
- vendor,
129
- policy: input.policy,
130
- affinityKey: input.affinityKey,
131
- now,
132
- });
133
- if (resolved && resolved.vendor === vendor)
134
- return resolved;
268
+ try {
269
+ const resolved = await synthesize({
270
+ vendor,
271
+ policy: input.policy,
272
+ affinityKey: input.affinityKey,
273
+ now,
274
+ protocol,
275
+ credentials,
276
+ });
277
+ if (!resolved)
278
+ continue;
279
+ if (isSkippedSynthesis(resolved)) {
280
+ vendorSkip = sanitizeVendorSkipReason(resolved.reason, vendor, credentials);
281
+ continue;
282
+ }
283
+ if (resolved.vendor === vendor) {
284
+ assertTunnelingScheme(resolved.url);
285
+ return { proxy: resolved, skips };
286
+ }
287
+ }
288
+ catch (error) {
289
+ vendorSkip = {
290
+ kind: "allocation_failed",
291
+ cause: sanitizeVendorResolutionCause(error, vendor, credentials),
292
+ };
293
+ }
135
294
  }
295
+ skips.push({ vendor, reason: vendorSkip ?? { kind: "adapter_unavailable" } });
136
296
  }
137
- return undefined;
297
+ return { skips };
298
+ }
299
+ /** Resolve the first configured native gateway, including allocation vendors. */
300
+ export async function resolveNativeGatewayProxy(input) {
301
+ return (await resolveNativeGatewayProxyDetailed(input)).proxy;
138
302
  }
139
- function proxyRequiredError(policy) {
303
+ function formatVendorSkip(skip) {
304
+ switch (skip.reason.kind) {
305
+ case "credentials_absent":
306
+ return `${skip.vendor}: credentials absent (missing ${skip.reason.missing.join(", ") || "unspecified variables"})`;
307
+ case "protocol_unsupported":
308
+ return `${skip.vendor}: protocol ${skip.reason.protocol} is unsupported`;
309
+ case "allocation_failed":
310
+ return `${skip.vendor}: allocation failed (${skip.reason.cause.message})`;
311
+ case "credential_lookup_failed":
312
+ return `${skip.vendor}: credential lookup failed (${skip.reason.cause.message})`;
313
+ case "adapter_unavailable":
314
+ return `${skip.vendor}: no native adapter is registered`;
315
+ }
316
+ }
317
+ function proxyRequiredError(policy, skips) {
140
318
  const chain = resolveNativeVendorChain(policy).filter((vendor) => vendor === "smartproxy" || vendor === "nodemaven");
141
- return new ProxyResolutionError("PROXY_REQUIRED", `Native proxy egress is required but no gateway vendor in [${chain.join(", ") || "none"}] resolved.`, { vendorChain: chain });
319
+ return new ProxyResolutionError("PROXY_REQUIRED", `Native proxy egress is required but the vendor chain was exhausted: ${skips.map(formatVendorSkip).join("; ") || "no vendors declared"}.`, { vendorChain: chain });
142
320
  }
143
321
  function deadlineFrom(timeoutMs) {
144
322
  if (timeoutMs === undefined)
@@ -154,8 +332,8 @@ function abortError() {
154
332
  function timeoutError() {
155
333
  return new NativeNetworkError("Native connection timed out", "native_connection_timeout");
156
334
  }
157
- function failedError() {
158
- return new NativeNetworkError("Native connection failed", "native_connection_failed");
335
+ function failedError(cause) {
336
+ return new NativeNetworkError("Native connection failed", "native_connection_failed", cause);
159
337
  }
160
338
  function assertCanStart(signal, deadline) {
161
339
  if (signal?.aborted)
@@ -186,7 +364,7 @@ async function waitForSocketEvent(socket, event, signal, deadline) {
186
364
  resolve();
187
365
  };
188
366
  const onReady = () => finish();
189
- const onError = () => finish(failedError());
367
+ const onError = (cause) => finish(failedError(cause));
190
368
  const onClose = () => finish(failedError());
191
369
  const onAbort = () => finish(abortError());
192
370
  socket.once(event, onReady);
@@ -230,7 +408,104 @@ function parseSocks5Proxy(proxyUrl) {
230
408
  throw new NativeNetworkError("Native proxy URL is invalid", "native_proxy_invalid");
231
409
  }
232
410
  }
233
- async function waitForSocksHandshake(proxySocket, promise, signal, deadline) {
411
+ function parseHttpConnectProxy(proxyUrl) {
412
+ let parsed;
413
+ try {
414
+ parsed = new URL(proxyUrl);
415
+ }
416
+ catch {
417
+ throw new NativeNetworkError("Native proxy URL is invalid", "native_proxy_invalid");
418
+ }
419
+ const port = Number(parsed.port || "80");
420
+ if (parsed.protocol !== "http:" ||
421
+ !parsed.hostname ||
422
+ !Number.isInteger(port) ||
423
+ port <= 0 ||
424
+ parsed.pathname !== "/" ||
425
+ parsed.search ||
426
+ parsed.hash) {
427
+ throw new NativeNetworkError("Native proxy URL is invalid", "native_proxy_invalid");
428
+ }
429
+ try {
430
+ return {
431
+ host: parsed.hostname,
432
+ port,
433
+ ...(parsed.username ? { userId: decodeURIComponent(parsed.username) } : {}),
434
+ ...(parsed.password ? { password: decodeURIComponent(parsed.password) } : {}),
435
+ };
436
+ }
437
+ catch {
438
+ throw new NativeNetworkError("Native proxy URL is invalid", "native_proxy_invalid");
439
+ }
440
+ }
441
+ const SOCKS5_REPLY_CODES = {
442
+ Failure: 0x01,
443
+ NotAllowed: 0x02,
444
+ NetworkUnreachable: 0x03,
445
+ HostUnreachable: 0x04,
446
+ ConnectionRefused: 0x05,
447
+ TTLExpired: 0x06,
448
+ CommandNotSupported: 0x07,
449
+ AddressNotSupported: 0x08,
450
+ };
451
+ function socks5ReplyCode(error) {
452
+ const match = /Socks5 proxy rejected connection - ([A-Za-z]+)/i.exec(error.message);
453
+ if (!match?.[1])
454
+ return undefined;
455
+ const reply = Object.entries(SOCKS5_REPLY_CODES).find(([name]) => name.toLowerCase() === match[1]?.toLowerCase());
456
+ return reply?.[1];
457
+ }
458
+ function sanitizeProxyFailureCause(error, proxyUrl, credentials) {
459
+ // socks' SocksClientError retains the live socket in options. It is neither
460
+ // useful diagnostic payload nor serializable, so preserve the original error
461
+ // while replacing only that options object with a socket-free snapshot.
462
+ const options = Reflect.get(error, "options");
463
+ if (options && typeof options === "object" && !Array.isArray(options)) {
464
+ const snapshot = { ...options };
465
+ delete snapshot.existing_socket;
466
+ try {
467
+ Reflect.set(error, "options", snapshot);
468
+ }
469
+ catch {
470
+ // The recursive redactor below clones readonly diagnostics safely.
471
+ }
472
+ }
473
+ const replyCode = socks5ReplyCode(error);
474
+ if (replyCode !== undefined) {
475
+ try {
476
+ Object.defineProperty(error, "socks5ReplyCode", {
477
+ value: replyCode,
478
+ configurable: true,
479
+ enumerable: true,
480
+ writable: false,
481
+ });
482
+ }
483
+ catch {
484
+ // The reply label remains in message if an exotic error is immutable.
485
+ }
486
+ }
487
+ let redactedProxyUrl = proxyUrl;
488
+ try {
489
+ const parsed = new URL(proxyUrl);
490
+ parsed.username = "[REDACTED]";
491
+ parsed.password = "[REDACTED]";
492
+ redactedProxyUrl = parsed.toString();
493
+ }
494
+ catch {
495
+ // parseSocks5Proxy already validated this URL; keep a defensive fallback.
496
+ }
497
+ return redactSensitiveError(error, [
498
+ credentials.userId,
499
+ credentials.password,
500
+ credentials.userId !== undefined || credentials.password !== undefined
501
+ ? `${credentials.userId ?? ""}:${credentials.password ?? ""}`
502
+ : undefined,
503
+ credentials.userId !== undefined || credentials.password !== undefined
504
+ ? Buffer.from(`${credentials.userId ?? ""}:${credentials.password ?? ""}`).toString("base64")
505
+ : undefined,
506
+ ].filter((value) => typeof value === "string" && value.length > 0), proxyUrl, redactedProxyUrl);
507
+ }
508
+ async function waitForSocksHandshake(proxySocket, promise, signal, deadline, sanitizeFailure) {
234
509
  assertCanStart(signal, deadline);
235
510
  return await new Promise((resolve, reject) => {
236
511
  let settled = false;
@@ -261,7 +536,7 @@ async function waitForSocksHandshake(proxySocket, promise, signal, deadline) {
261
536
  timer = setTimeout(() => finish(timeoutError()), remaining);
262
537
  void promise.then((result) => finish(undefined, result.socket), (error) => finish(error instanceof Error && /\b(?:timed out|timeout)\b/i.test(error.message)
263
538
  ? timeoutError()
264
- : failedError()));
539
+ : failedError(sanitizeFailure(error instanceof Error ? error : new Error(String(error))))));
265
540
  });
266
541
  }
267
542
  async function connectSocksTunnel(proxy, input, deadline, beforeDestinationConnect) {
@@ -286,7 +561,117 @@ async function connectSocksTunnel(proxy, input, deadline, beforeDestinationConne
286
561
  existing_socket: proxySocket,
287
562
  ...(remaining === undefined ? {} : { timeout: Math.max(1, remaining) }),
288
563
  });
289
- return await waitForSocksHandshake(proxySocket, handshake, input.signal, deadline);
564
+ return await waitForSocksHandshake(proxySocket, handshake, input.signal, deadline, (error) => sanitizeProxyFailureCause(error, proxy.url, parsed));
565
+ }
566
+ function connectAuthority(host, port) {
567
+ return `${host.includes(":") && !host.startsWith("[") ? `[${host}]` : host}:${port}`;
568
+ }
569
+ function connectStatusError(statusLine, statusCode) {
570
+ const error = new Error(`HTTP CONNECT proxy rejected tunnel: ${statusLine || "invalid response"}`);
571
+ Object.defineProperties(error, {
572
+ connectStatusLine: {
573
+ value: statusLine || "invalid response",
574
+ configurable: true,
575
+ enumerable: true,
576
+ },
577
+ ...(statusCode === undefined
578
+ ? {}
579
+ : {
580
+ connectStatusCode: {
581
+ value: statusCode,
582
+ configurable: true,
583
+ enumerable: true,
584
+ },
585
+ }),
586
+ });
587
+ return error;
588
+ }
589
+ async function waitForConnectResponse(proxySocket, signal, deadline, sanitizeFailure) {
590
+ assertCanStart(signal, deadline);
591
+ return await new Promise((resolve, reject) => {
592
+ let settled = false;
593
+ let buffered = Buffer.alloc(0);
594
+ let timer;
595
+ const cleanup = () => {
596
+ if (timer)
597
+ clearTimeout(timer);
598
+ proxySocket.off("data", onData);
599
+ proxySocket.off("error", onError);
600
+ proxySocket.off("close", onClose);
601
+ signal?.removeEventListener("abort", onAbort);
602
+ };
603
+ const finish = (error) => {
604
+ if (settled)
605
+ return;
606
+ settled = true;
607
+ cleanup();
608
+ if (error) {
609
+ proxySocket.on("error", () => undefined);
610
+ proxySocket.destroy();
611
+ reject(error);
612
+ }
613
+ else
614
+ resolve(proxySocket);
615
+ };
616
+ const onError = (cause) => finish(failedError(sanitizeFailure(cause)));
617
+ const onClose = () => finish(failedError(sanitizeFailure(new Error("HTTP CONNECT proxy closed before response"))));
618
+ const onAbort = () => finish(abortError());
619
+ const onData = (chunk) => {
620
+ buffered = Buffer.concat([buffered, chunk]);
621
+ if (buffered.length > 64 * 1024) {
622
+ finish(failedError(sanitizeFailure(connectStatusError("response headers too large"))));
623
+ return;
624
+ }
625
+ const headerEnd = buffered.indexOf("\r\n\r\n");
626
+ if (headerEnd < 0)
627
+ return;
628
+ const header = buffered.subarray(0, headerEnd).toString("latin1");
629
+ const statusLine = header.split("\r\n", 1)[0] ?? "";
630
+ const match = /^HTTP\/1\.[01] ([0-9]{3})(?: |$)/.exec(statusLine);
631
+ const statusCode = match?.[1] ? Number(match[1]) : undefined;
632
+ if (statusCode === undefined || statusCode < 200 || statusCode >= 300) {
633
+ finish(failedError(sanitizeFailure(connectStatusError(statusLine, statusCode))));
634
+ return;
635
+ }
636
+ const remaining = buffered.subarray(headerEnd + 4);
637
+ cleanup();
638
+ if (remaining.length > 0)
639
+ proxySocket.unshift(remaining);
640
+ finish();
641
+ };
642
+ proxySocket.on("data", onData);
643
+ proxySocket.once("error", onError);
644
+ proxySocket.once("close", onClose);
645
+ signal?.addEventListener("abort", onAbort, { once: true });
646
+ const remaining = remainingMs(deadline);
647
+ if (remaining !== undefined)
648
+ timer = setTimeout(() => finish(timeoutError()), remaining);
649
+ });
650
+ }
651
+ async function connectHttpTunnel(proxy, input, deadline, beforeDestinationConnect) {
652
+ const parsed = parseHttpConnectProxy(proxy.url);
653
+ const proxySocket = await connectPlainSocket(parsed.host, parsed.port, input.signal, deadline);
654
+ const sanitizeFailure = (error) => sanitizeProxyFailureCause(error, proxy.url, parsed);
655
+ try {
656
+ beforeDestinationConnect();
657
+ }
658
+ catch (error) {
659
+ proxySocket.destroy();
660
+ throw error;
661
+ }
662
+ const authority = connectAuthority(input.host, input.port);
663
+ const authorization = parsed.userId !== undefined || parsed.password !== undefined
664
+ ? `Proxy-Authorization: Basic ${Buffer.from(`${parsed.userId ?? ""}:${parsed.password ?? ""}`).toString("base64")}\r\n`
665
+ : "";
666
+ const response = waitForConnectResponse(proxySocket, input.signal, deadline, sanitizeFailure);
667
+ proxySocket.write(`CONNECT ${authority} HTTP/1.1\r\nHost: ${authority}\r\n${authorization}Proxy-Connection: Keep-Alive\r\n\r\n`);
668
+ return await response;
669
+ }
670
+ async function connectProxyTunnel(proxy, input, deadline, beforeDestinationConnect) {
671
+ assertTunnelingScheme(proxy.url);
672
+ return new URL(proxy.url).protocol === "http:"
673
+ ? await connectHttpTunnel(proxy, input, deadline, beforeDestinationConnect)
674
+ : await connectSocksTunnel(proxy, input, deadline, beforeDestinationConnect);
290
675
  }
291
676
  async function upgradeTls(socket, input, deadline) {
292
677
  assertCanStart(input.signal, deadline);
@@ -399,7 +784,7 @@ export function createNativeNetworkConnection(socket, proxy, options, idleTimeou
399
784
  if (closeReason)
400
785
  throw closeReason;
401
786
  if (terminalError)
402
- throw failedError();
787
+ throw failedError(terminalError);
403
788
  const chunk = socket.read();
404
789
  if (chunk) {
405
790
  resetIdleTimer();
@@ -456,7 +841,7 @@ export function createNativeNetworkConnection(socket, proxy, options, idleTimeou
456
841
  await new Promise((resolve, reject) => {
457
842
  socket.write(data, (error) => {
458
843
  if (error)
459
- reject(failedError());
844
+ reject(failedError(error));
460
845
  else
461
846
  resolve();
462
847
  });
@@ -474,7 +859,7 @@ export function createNativeNetworkConnection(socket, proxy, options, idleTimeou
474
859
  },
475
860
  };
476
861
  }
477
- async function resolveConnectionProxy(options, input) {
862
+ async function resolveConnectionProxy(options, input, deadline) {
478
863
  const policy = options.proxyPolicy;
479
864
  if (!policy || policy.mode === "disabled")
480
865
  return undefined;
@@ -483,31 +868,85 @@ async function resolveConnectionProxy(options, input) {
483
868
  (isStickyPolicy(policy) && options.credentialIdentity !== undefined
484
869
  ? deriveNativeCredentialAffinityKey(options.credentialIdentity)
485
870
  : undefined);
486
- const resolved = resolveNativeGatewayProxy({
871
+ const resolution = await waitForProxyResolution(resolveNativeGatewayProxyDetailed({
487
872
  policy,
488
873
  affinityKey,
874
+ protocol: options.proxyProtocol,
875
+ credentials: options.credentials,
489
876
  gatewaySynthesizers: options.gatewaySynthesizers,
877
+ }), input.signal, deadline);
878
+ if (!resolution.proxy && policy.mode === "required") {
879
+ throw proxyRequiredError(policy, resolution.skips);
880
+ }
881
+ return resolution.proxy;
882
+ }
883
+ async function waitForProxyResolution(promise, signal, deadline) {
884
+ assertCanStart(signal, deadline);
885
+ return await new Promise((resolve, reject) => {
886
+ let settled = false;
887
+ let timer;
888
+ const cleanup = () => {
889
+ if (timer)
890
+ clearTimeout(timer);
891
+ signal?.removeEventListener("abort", onAbort);
892
+ };
893
+ const finish = (value, error) => {
894
+ if (settled)
895
+ return;
896
+ settled = true;
897
+ cleanup();
898
+ if (error !== undefined)
899
+ reject(error);
900
+ else
901
+ resolve(value);
902
+ };
903
+ const onAbort = () => finish(undefined, abortError());
904
+ signal?.addEventListener("abort", onAbort, { once: true });
905
+ const remaining = remainingMs(deadline);
906
+ if (remaining !== undefined) {
907
+ timer = setTimeout(() => finish(undefined, timeoutError()), remaining);
908
+ }
909
+ void promise.then((value) => finish(value), (error) => finish(undefined, error));
490
910
  });
491
- if (!resolved && policy.mode === "required")
492
- throw proxyRequiredError(policy);
493
- return resolved;
494
911
  }
495
912
  export const NATIVE_EGRESS_EXPIRED_EVIDENCE_LIMIT = 256;
496
- function normalizeEgressHost(host) {
497
- return host.trim().toLowerCase().replace(/\.$/, "");
498
- }
499
913
  function invalidPolicy(message) {
500
914
  return new NativeNetworkError(message, "native_egress_policy_invalid");
501
915
  }
502
916
  function matchesDnsSuffix(host, suffix) {
503
917
  return host === suffix || host.endsWith(`.${suffix}`);
504
918
  }
505
- function matchesSourceHost(rule, host) {
506
- const hasSelector = rule.sourceHost !== undefined || rule.sourceHostSuffixes.length > 0;
919
+ /** Internal validator-independent source selector matcher. */
920
+ export function matchesSourceHost(rule, host) {
921
+ const kind = classifyEgressHost(host);
922
+ if (kind === "numeric-ambiguous")
923
+ return false;
924
+ const hasSelector = rule.sourceHost !== undefined ||
925
+ rule.sourceHostSuffixes.length > 0 ||
926
+ rule.sourceIpv4Cidrs.length > 0 ||
927
+ rule.sourceIpv6Cidrs.length > 0;
507
928
  if (!hasSelector)
508
929
  return false;
509
- return (host === rule.sourceHost ||
510
- rule.sourceHostSuffixes.some((suffix) => matchesDnsSuffix(host, suffix)));
930
+ if (host === rule.sourceHost)
931
+ return true;
932
+ return matchesHostFamilySelectors(host, rule.sourceHostSuffixes, rule.sourceIpv4Cidrs, rule.sourceIpv6Cidrs);
933
+ }
934
+ function matchesHostFamilySelectors(host, hostSuffixes, ipv4Cidrs, ipv6Cidrs) {
935
+ const kind = classifyEgressHost(host);
936
+ if (kind === "ipv4") {
937
+ const address = parseStrictIpv4(host);
938
+ return address !== undefined && ipv4Cidrs.some((cidr) => ipv4InCidr(address, cidr));
939
+ }
940
+ if (kind === "ipv4-mapped-ipv6") {
941
+ const address = parseIpv6(host);
942
+ const embedded = address && embeddedIpv4FromIpv6(address);
943
+ return embedded !== undefined && ipv4Cidrs.some((cidr) => ipv4InCidr(embedded, cidr));
944
+ }
945
+ if (kind === "ipv6") {
946
+ const address = parseIpv6(host);
947
+ return address !== undefined && ipv6Cidrs.some((cidr) => ipv6InCidr(address, cidr));
948
+ }
949
+ return kind === "dns" && hostSuffixes.some((suffix) => matchesDnsSuffix(host, suffix));
511
950
  }
512
951
  function matchesPortSelectors(port, ports, ranges) {
513
952
  if (ports.length === 0 && ranges.length === 0)
@@ -523,37 +962,29 @@ function grantTlsFitsRule(grant, rule) {
523
962
  (grant === "disabled" && rule === "disabled"));
524
963
  }
525
964
  function matchesDynamicRuleSelectors(rule, input) {
526
- const sourceHost = normalizeEgressHost(input.sourceHost);
527
- const targetHost = normalizeEgressHost(input.host);
528
- return (matchesSourceHost(rule, sourceHost) &&
965
+ return (matchesSourceHost(rule, input.sourceHost) &&
529
966
  matchesPortSelectors(input.sourcePort, rule.sourcePorts, rule.sourcePortRanges) &&
530
- rule.targetHostSuffixes.some((suffix) => matchesDnsSuffix(targetHost, suffix)) &&
967
+ matchesDynamicTargetHost(rule, input.host) &&
531
968
  matchesPortSelectors(input.port, rule.targetPorts, rule.targetPortRanges) &&
532
969
  grantTlsFitsRule(input.tls, rule.tls));
533
970
  }
971
+ function matchesDynamicTargetHost(rule, targetHost) {
972
+ return matchesHostFamilySelectors(targetHost, rule.targetHostSuffixes, rule.targetIpv4Cidrs, rule.targetIpv6Cidrs);
973
+ }
534
974
  function invalidGrant(message) {
535
975
  return new NativeNetworkError(message, "native_egress_grant_invalid");
536
976
  }
537
- function hasControlCharacter(value) {
538
- for (let index = 0; index < value.length; index += 1) {
539
- const code = value.charCodeAt(index);
540
- if (code <= 31 || code === 127)
541
- return true;
542
- }
543
- return false;
977
+ function canonicalGrantHost(value) {
978
+ if (typeof value === "string" && value.includes("*"))
979
+ throw invalidGrant("Native TCP egress grant hosts must be exact non-empty hostnames");
980
+ const canonical = canonicalizeEgressHost(value);
981
+ if (!canonical.ok)
982
+ throw invalidGrant("Native TCP egress grant hosts must be exact non-empty hostnames");
983
+ return canonical.host;
544
984
  }
545
985
  function assertValidGrantInput(input) {
546
- if (!normalizeEgressHost(input.sourceHost) ||
547
- !normalizeEgressHost(input.host) ||
548
- hasControlCharacter(input.sourceHost) ||
549
- hasControlCharacter(input.host) ||
550
- /\s/.test(input.sourceHost) ||
551
- /\s/.test(input.host) ||
552
- input.sourceHost.includes("://") ||
553
- input.host.includes("://") ||
554
- input.sourceHost.includes("*") ||
555
- input.host.includes("*"))
556
- throw invalidGrant("Native TCP egress grant hosts must be exact non-empty hostnames");
986
+ canonicalGrantHost(input.sourceHost);
987
+ canonicalGrantHost(input.host);
557
988
  if (!Number.isSafeInteger(input.sourcePort) ||
558
989
  input.sourcePort < 1 ||
559
990
  input.sourcePort > 65_535 ||
@@ -566,42 +997,46 @@ function assertValidGrantInput(input) {
566
997
  if (input.ttlMs !== undefined && (!Number.isSafeInteger(input.ttlMs) || input.ttlMs <= 0))
567
998
  throw invalidGrant("Native TCP egress grant ttlMs must be a positive integer");
568
999
  }
569
- /** Internal canonical snapshot shared by production and SDK transport test doubles. */
570
- export function snapshotNativeConnectInput(input) {
1000
+ function invalidNativeConnectInput(field, reason) {
1001
+ return new NativeNetworkError(`Native connection input rejected: field=${field}; reason=${reason}`, "native_egress_input_invalid");
1002
+ }
1003
+ function inspectNativeConnectInputField(input, field) {
571
1004
  try {
572
- const host = input.host;
573
- const port = input.port;
574
- const serverName = input.serverName;
575
- const rejectUnauthorized = input.rejectUnauthorized;
576
- const idleTimeoutMs = input.idleTimeoutMs;
577
- const timeoutMs = input.timeoutMs;
578
- const signal = input.signal;
579
- const affinityKey = input.affinityKey;
580
- const snapshot = {
581
- host,
582
- port,
583
- ...(serverName === undefined ? {} : { serverName }),
584
- ...(rejectUnauthorized === undefined ? {} : { rejectUnauthorized }),
585
- ...(idleTimeoutMs === undefined ? {} : { idleTimeoutMs }),
586
- ...(timeoutMs === undefined ? {} : { timeoutMs }),
587
- ...(signal === undefined ? {} : { signal }),
588
- ...(affinityKey === undefined ? {} : { affinityKey }),
589
- };
590
- if (typeof snapshot.host !== "string" ||
591
- !snapshot.host.trim() ||
592
- hasControlCharacter(snapshot.host) ||
593
- !Number.isInteger(snapshot.port) ||
594
- snapshot.port < 1 ||
595
- snapshot.port > 65_535)
596
- throw new TypeError("invalid native connection target");
597
- return snapshot;
1005
+ return input[field];
598
1006
  }
599
1007
  catch {
600
- throw new NativeNetworkError("Native connection input could not be inspected safely", "native_egress_input_invalid");
1008
+ throw invalidNativeConnectInput(field, "inspection-failure");
601
1009
  }
602
1010
  }
603
1011
  /** Internal canonical snapshot shared by production and SDK transport test doubles. */
1012
+ export function snapshotNativeConnectInput(input) {
1013
+ const host = inspectNativeConnectInputField(input, "host");
1014
+ const canonicalHost = canonicalizeEgressHost(host);
1015
+ if (!canonicalHost.ok)
1016
+ throw invalidNativeConnectInput("host", canonicalHost.reason);
1017
+ const port = inspectNativeConnectInputField(input, "port");
1018
+ if (!Number.isInteger(port) || port < 1 || port > 65_535)
1019
+ throw invalidNativeConnectInput("port", "port-range");
1020
+ const serverName = inspectNativeConnectInputField(input, "serverName");
1021
+ const rejectUnauthorized = inspectNativeConnectInputField(input, "rejectUnauthorized");
1022
+ const idleTimeoutMs = inspectNativeConnectInputField(input, "idleTimeoutMs");
1023
+ const timeoutMs = inspectNativeConnectInputField(input, "timeoutMs");
1024
+ const signal = inspectNativeConnectInputField(input, "signal");
1025
+ const affinityKey = inspectNativeConnectInputField(input, "affinityKey");
1026
+ return {
1027
+ host: canonicalHost.host,
1028
+ port,
1029
+ ...(serverName === undefined ? {} : { serverName }),
1030
+ ...(rejectUnauthorized === undefined ? {} : { rejectUnauthorized }),
1031
+ ...(idleTimeoutMs === undefined ? {} : { idleTimeoutMs }),
1032
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
1033
+ ...(signal === undefined ? {} : { signal }),
1034
+ ...(affinityKey === undefined ? {} : { affinityKey }),
1035
+ };
1036
+ }
1037
+ /** Internal canonical snapshot shared by production and SDK transport test doubles. */
604
1038
  export function snapshotNativeGrantInput(input) {
1039
+ let snapshot;
605
1040
  try {
606
1041
  const sourceHost = input.sourceHost;
607
1042
  const sourcePort = input.sourcePort;
@@ -609,7 +1044,7 @@ export function snapshotNativeGrantInput(input) {
609
1044
  const port = input.port;
610
1045
  const tls = input.tls;
611
1046
  const ttlMs = input.ttlMs;
612
- return {
1047
+ snapshot = {
613
1048
  sourceHost,
614
1049
  sourcePort,
615
1050
  host,
@@ -621,6 +1056,12 @@ export function snapshotNativeGrantInput(input) {
621
1056
  catch {
622
1057
  throw new NativeNetworkError("Native TCP egress grant input could not be inspected safely", "native_egress_input_invalid");
623
1058
  }
1059
+ assertValidGrantInput(snapshot);
1060
+ return {
1061
+ ...snapshot,
1062
+ sourceHost: canonicalGrantHost(snapshot.sourceHost),
1063
+ host: canonicalGrantHost(snapshot.host),
1064
+ };
624
1065
  }
625
1066
  /** Internal authorization seam shared by production and SDK transport test doubles. */
626
1067
  export function createNativeEgressAuthorization(options) {
@@ -678,21 +1119,20 @@ export function createNativeEgressAuthorization(options) {
678
1119
  const assertConnect = (input, tls) => {
679
1120
  if (!declared)
680
1121
  return;
681
- const host = normalizeEgressHost(input.host);
682
1122
  const now = Date.now();
683
1123
  purgeInactive(now);
684
- if (staticRules.some((rule) => rule.host === host && rule.ports.includes(input.port) && tlsModeAllows(rule.tls, tls)))
1124
+ if (staticRules.some((rule) => rule.host === input.host &&
1125
+ rule.ports.includes(input.port) &&
1126
+ tlsModeAllows(rule.tls, tls)))
685
1127
  return;
686
1128
  const matching = grants.filter((grant) => !grant.revoked &&
687
- grant.host === host &&
1129
+ grant.host === input.host &&
688
1130
  grant.port === input.port &&
689
1131
  tlsModeAllows(grant.tls, tls));
690
1132
  if (matching.length > 0)
691
1133
  return;
692
1134
  const expired = [...expiredEvidence.values()]
693
- .filter((grant) => grant.host === host &&
694
- grant.port === input.port &&
695
- tlsModeAllows(grant.tls, tls))
1135
+ .filter((grant) => grant.host === input.host && grant.port === input.port && tlsModeAllows(grant.tls, tls))
696
1136
  .sort((left, right) => (right.expiresAtMs ?? 0) - (left.expiresAtMs ?? 0))[0];
697
1137
  if (expired?.expiresAtMs !== undefined)
698
1138
  throw new NativeEgressGrantExpiredError(input.host, input.port, tls, new Date(expired.expiresAtMs).toISOString());
@@ -701,15 +1141,51 @@ export function createNativeEgressAuthorization(options) {
701
1141
  const grantLocal = (input) => {
702
1142
  assertValidGrantInput(input);
703
1143
  const ruleIndex = dynamicRules.findIndex((rule) => matchesDynamicRuleSelectors(rule, input));
704
- if (ruleIndex < 0)
705
- throw new NativeNetworkError(`Native TCP egress grant is not declared for ${input.host}:${input.port} (${input.tls})`, "native_egress_not_declared");
1144
+ if (ruleIndex < 0) {
1145
+ const diagnosticSourceHost = safeDiagnosticEgressHost(input.sourceHost);
1146
+ const diagnosticTargetHost = safeDiagnosticEgressHost(input.host);
1147
+ const sourceMatchingRuleIndices = dynamicRules.flatMap((rule, index) => matchesSourceHost(rule, input.sourceHost) &&
1148
+ matchesPortSelectors(input.sourcePort, rule.sourcePorts, rule.sourcePortRanges)
1149
+ ? [index]
1150
+ : []);
1151
+ const targetKind = classifyEgressHost(diagnosticTargetHost);
1152
+ let selectorDetails;
1153
+ if (sourceMatchingRuleIndices.length > 0) {
1154
+ const failedByRule = [];
1155
+ for (const index of sourceMatchingRuleIndices) {
1156
+ const rule = dynamicRules[index];
1157
+ if (!rule)
1158
+ continue;
1159
+ const failedDimensions = [];
1160
+ if (!matchesDynamicTargetHost(rule, input.host))
1161
+ failedDimensions.push("target-host");
1162
+ if (!matchesPortSelectors(input.port, rule.targetPorts, rule.targetPortRanges))
1163
+ failedDimensions.push("target-port");
1164
+ if (!grantTlsFitsRule(input.tls, rule.tls))
1165
+ failedDimensions.push("tls");
1166
+ failedByRule.push(`rule ${index}: ${failedDimensions.join(", ")}`);
1167
+ }
1168
+ selectorDetails = `source-matching rule indices: [${sourceMatchingRuleIndices.join(", ")}]; failed selector dimensions by rule: ${failedByRule.join("; ")}`;
1169
+ }
1170
+ else {
1171
+ const failedByRule = dynamicRules.map((rule, index) => {
1172
+ const failedDimensions = [];
1173
+ if (!matchesSourceHost(rule, input.sourceHost))
1174
+ failedDimensions.push("source-host");
1175
+ if (!matchesPortSelectors(input.sourcePort, rule.sourcePorts, rule.sourcePortRanges))
1176
+ failedDimensions.push("source-port");
1177
+ return `rule ${index}: ${failedDimensions.join(", ")}`;
1178
+ });
1179
+ selectorDetails = `source-matching rule indices: []; failed selector dimensions by rule: ${failedByRule.join("; ")}`;
1180
+ }
1181
+ throw new NativeNetworkError(`Native TCP egress grant is not declared for source ${diagnosticSourceHost}:${input.sourcePort} to target ${diagnosticTargetHost}:${input.port} (${input.tls}); target kind: ${targetKind}; ${selectorDetails}`, "native_egress_not_declared");
1182
+ }
706
1183
  const rule = dynamicRules[ruleIndex];
707
1184
  if (!rule)
708
1185
  throw invalidGrant("Native TCP egress declaration is missing its matched rule");
709
1186
  if (input.ttlMs !== undefined && rule.ttlMs !== undefined && input.ttlMs > rule.ttlMs)
710
1187
  throw invalidGrant(`Native TCP egress grant ttlMs ${input.ttlMs} exceeds declared maximum ${rule.ttlMs}`);
711
1188
  const now = Date.now();
712
- const targetHost = normalizeEgressHost(input.host);
713
1189
  purgeInactive(now);
714
1190
  const activeForRule = grants.filter((grant) => grant.ruleIndex === ruleIndex &&
715
1191
  !grant.revoked &&
@@ -722,7 +1198,7 @@ export function createNativeEgressAuthorization(options) {
722
1198
  throw invalidGrant("Native TCP egress grant expiry exceeds the supported date range");
723
1199
  const stored = {
724
1200
  ruleIndex,
725
- host: targetHost,
1201
+ host: input.host,
726
1202
  port: input.port,
727
1203
  tls: input.tls,
728
1204
  ...(expiresAtMs === undefined ? {} : { expiresAtMs }),
@@ -797,10 +1273,10 @@ export function createNativeNetworkClient(options = {}) {
797
1273
  egress.assertConnect(request, "disabled");
798
1274
  const deadline = deadlineFrom(request.timeoutMs);
799
1275
  assertCanStart(request.signal, deadline);
800
- const proxy = await resolveConnectionProxy(options, request);
1276
+ const proxy = await resolveConnectionProxy(options, request, deadline);
801
1277
  egress.assertConnect(request, "disabled");
802
1278
  const socket = proxy
803
- ? await connectSocksTunnel(proxy, request, deadline, () => egress.assertConnect(request, "disabled"))
1279
+ ? await connectProxyTunnel(proxy, request, deadline, () => egress.assertConnect(request, "disabled"))
804
1280
  : await connectPlainSocket(request.host, request.port, request.signal, deadline);
805
1281
  return createNativeNetworkConnection(socket, proxy, options, request.idleTimeoutMs);
806
1282
  },
@@ -809,10 +1285,10 @@ export function createNativeNetworkClient(options = {}) {
809
1285
  egress.assertConnect(request, "required");
810
1286
  const deadline = deadlineFrom(request.timeoutMs);
811
1287
  assertCanStart(request.signal, deadline);
812
- const proxy = await resolveConnectionProxy(options, request);
1288
+ const proxy = await resolveConnectionProxy(options, request, deadline);
813
1289
  egress.assertConnect(request, "required");
814
1290
  const tunnel = proxy
815
- ? await connectSocksTunnel(proxy, request, deadline, () => egress.assertConnect(request, "required"))
1291
+ ? await connectProxyTunnel(proxy, request, deadline, () => egress.assertConnect(request, "required"))
816
1292
  : undefined;
817
1293
  const socket = await upgradeTls(tunnel, request, deadline);
818
1294
  return createNativeNetworkConnection(socket, proxy, options, request.idleTimeoutMs);