@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.
@@ -4,18 +4,35 @@ import { connect as connectTlsSocket, type TLSSocket } from "node:tls";
4
4
 
5
5
  import { SocksClient } from "socks";
6
6
 
7
- import { ProxyResolutionError } from "../config/loader.js";
7
+ import {
8
+ assertTunnelingScheme,
9
+ ProxyResolutionError,
10
+ SMARTPROXY_APP_KEY_ENV,
11
+ type ProxyProtocol,
12
+ VENDOR_DEFAULT_PROTOCOL,
13
+ resolveWithVendor,
14
+ } from "../config/loader.js";
8
15
  import { TransportError } from "../errors.js";
9
16
  import {
17
+ type DynamicEgressRuleSnapshot,
10
18
  NativeEgressPolicyValidationError,
11
19
  parseNativeEgressPolicy,
12
- type DynamicEgressRuleSnapshot,
13
20
  type StaticEgressRuleSnapshot,
14
21
  } from "../native-egress-policy.js";
22
+ import {
23
+ canonicalizeEgressHost,
24
+ classifyEgressHost,
25
+ type EgressHostCanonicalizationFailure,
26
+ embeddedIpv4FromIpv6,
27
+ ipv4InCidr,
28
+ ipv6InCidr,
29
+ parseIpv6,
30
+ parseStrictIpv4,
31
+ } from "../native-address.js";
15
32
  import type {
16
33
  NativeNetworkClient,
17
- NativeNetworkConnection,
18
34
  NativeNetworkConnectInput,
35
+ NativeNetworkConnection,
19
36
  NativeNetworkDynamicGrantOptions,
20
37
  NativeNetworkEgressGrant,
21
38
  NativeProviderConfig,
@@ -26,12 +43,17 @@ import type {
26
43
  NativeTcpTlsMode,
27
44
  ProviderProxyPolicy,
28
45
  ProviderProxyProvider,
46
+ EnvContext,
29
47
  } from "../types.js";
48
+ import { createEnvContext } from "./env.js";
30
49
  import {
31
- hasNodemavenCredentials,
50
+ NODEMAVEN_FILTER_ENV,
51
+ NODEMAVEN_PASSWORD_ENV,
52
+ NODEMAVEN_USERNAME_ENV,
32
53
  nodemavenSessionWindow,
33
54
  synthesizeNodemavenProxy,
34
55
  } from "./proxy-nodemaven.js";
56
+ import { redactSensitiveError } from "./request-options.js";
35
57
 
36
58
  export type NativeNetworkErrorCode =
37
59
  | "native_connection_aborted"
@@ -51,12 +73,13 @@ export type NativeNetworkErrorCode =
51
73
  | "native_proxy_invalid";
52
74
 
53
75
  export class NativeNetworkError extends TransportError {
54
- constructor(message: string, code: NativeNetworkErrorCode) {
76
+ constructor(message: string, code: NativeNetworkErrorCode, cause?: Error) {
55
77
  const isEgressPolicyFailure =
56
78
  code.startsWith("native_egress_") || code === "native_dynamic_egress_unsupported";
57
79
  super(message, {
58
80
  code,
59
81
  status: 0,
82
+ ...(cause ? { cause } : {}),
60
83
  ...(isEgressPolicyFailure ? { category: "provider_error" as const, retryable: false } : {}),
61
84
  });
62
85
  this.name = "NativeNetworkError";
@@ -67,6 +90,11 @@ export class NativeNetworkError extends TransportError {
67
90
  }
68
91
  }
69
92
 
93
+ function safeDiagnosticEgressHost(value: unknown): string {
94
+ const canonical = canonicalizeEgressHost(value);
95
+ return canonical.ok ? canonical.host : `<invalid-host:${canonical.reason}>`;
96
+ }
97
+
70
98
  export class NativeProxyExpiredError extends NativeNetworkError {
71
99
  constructor(readonly expiresAt: string) {
72
100
  super("Native connection closed at sticky proxy expiry", "native_proxy_expired");
@@ -76,15 +104,19 @@ export class NativeProxyExpiredError extends NativeNetworkError {
76
104
 
77
105
  /** Raised before transport setup when a native destination is not authorized. */
78
106
  export class NativeEgressNotDeclaredError extends NativeNetworkError {
107
+ readonly host: string;
108
+
79
109
  constructor(
80
- readonly host: string,
110
+ host: string,
81
111
  readonly port: number,
82
112
  readonly tls: "required" | "disabled",
83
113
  ) {
114
+ const diagnosticHost = safeDiagnosticEgressHost(host);
84
115
  super(
85
- `Native ${tls === "required" ? "TLS" : "TCP"} egress is not declared for ${host}:${port}`,
116
+ `Native ${tls === "required" ? "TLS" : "TCP"} egress is not declared for ${diagnosticHost}:${port}`,
86
117
  "native_egress_not_declared",
87
118
  );
119
+ this.host = diagnosticHost;
88
120
  this.name = "NativeEgressNotDeclaredError";
89
121
  }
90
122
  }
@@ -94,16 +126,20 @@ export class NativeEgressNotDeclaredError extends NativeNetworkError {
94
126
  * its expiry remains in the client's bounded recent-expiry evidence window.
95
127
  */
96
128
  export class NativeEgressGrantExpiredError extends NativeNetworkError {
129
+ readonly host: string;
130
+
97
131
  constructor(
98
- readonly host: string,
132
+ host: string,
99
133
  readonly port: number,
100
134
  readonly tls: "required" | "disabled",
101
135
  readonly expiresAt: string,
102
136
  ) {
137
+ const diagnosticHost = safeDiagnosticEgressHost(host);
103
138
  super(
104
- `Native ${tls === "required" ? "TLS" : "TCP"} egress grant expired for ${host}:${port}`,
139
+ `Native ${tls === "required" ? "TLS" : "TCP"} egress grant expired for ${diagnosticHost}:${port}`,
105
140
  "native_egress_grant_expired",
106
141
  );
142
+ this.host = diagnosticHost;
107
143
  this.name = "NativeEgressGrantExpiredError";
108
144
  }
109
145
  }
@@ -111,10 +147,7 @@ export class NativeEgressGrantExpiredError extends NativeNetworkError {
111
147
  /** Raised when an established connection exceeds its opt-in read-idle window. */
112
148
  export class NativeIdleTimeoutError extends NativeNetworkError {
113
149
  constructor() {
114
- super(
115
- "Native network socket timed out while reading.",
116
- "native_connection_idle_timeout",
117
- );
150
+ super("Native network socket timed out while reading.", "native_connection_idle_timeout");
118
151
  this.name = "NativeIdleTimeoutError";
119
152
  }
120
153
  }
@@ -128,17 +161,38 @@ export type NativeGatewayProxySynthesisInput = {
128
161
  readonly policy: ProviderProxyPolicy;
129
162
  readonly affinityKey?: string;
130
163
  readonly now: number;
164
+ readonly protocol: ProxyProtocol;
165
+ readonly credentials: VendorCredentialResolver;
131
166
  };
132
167
 
168
+ export type VendorCredentialLookup =
169
+ | { readonly kind: "present"; readonly values: Readonly<Record<string, string>> }
170
+ | { readonly kind: "absent"; readonly missing: readonly string[] };
171
+
172
+ export type VendorCredentialResolver = (vendor: ProviderProxyProvider) => VendorCredentialLookup;
173
+
174
+ export type NativeGatewayProxySkipReason =
175
+ | { readonly kind: "credentials_absent"; readonly missing: readonly string[] }
176
+ | { readonly kind: "protocol_unsupported"; readonly protocol: string }
177
+ | { readonly kind: "allocation_failed"; readonly cause: Error }
178
+ | { readonly kind: "credential_lookup_failed"; readonly cause: Error };
179
+
180
+ export type NativeGatewayProxySynthesisResult =
181
+ | NativeGatewayProxy
182
+ | { readonly kind: "skipped"; readonly reason: NativeGatewayProxySkipReason }
183
+ | undefined;
184
+
133
185
  /** A vendor adapter in the ordered native gateway resolution chain. */
134
186
  export type NativeGatewayProxySynthesizer = (
135
187
  input: NativeGatewayProxySynthesisInput,
136
- ) => NativeGatewayProxy | undefined;
188
+ ) => NativeGatewayProxySynthesisResult | Promise<NativeGatewayProxySynthesisResult>;
137
189
 
138
190
  export type NativeGatewayProxyResolutionInput = {
139
191
  readonly policy: ProviderProxyPolicy;
140
192
  readonly affinityKey?: string;
141
193
  readonly now?: number;
194
+ readonly protocol?: ProxyProtocol;
195
+ readonly credentials?: VendorCredentialResolver;
142
196
  readonly gatewaySynthesizers?: readonly NativeGatewayProxySynthesizer[];
143
197
  };
144
198
 
@@ -147,6 +201,10 @@ export type NativeNetworkClientOptions = {
147
201
  readonly affinityKey?: string;
148
202
  /** Stable credential/account identity; hashed before vendor synthesis. */
149
203
  readonly credentialIdentity?: string;
204
+ /** Vendor credential lookup; defaults to the process EnvContext. */
205
+ readonly credentials?: VendorCredentialResolver;
206
+ /** Explicit CONNECT/SOCKS5 override; vendors otherwise choose their default. */
207
+ readonly proxyProtocol?: ProxyProtocol;
150
208
  /** Vendor adapters in priority order within each policy vendor slot. */
151
209
  readonly gatewaySynthesizers?: readonly NativeGatewayProxySynthesizer[];
152
210
  /** Warning-level lifecycle diagnostic sink. */
@@ -160,19 +218,74 @@ export type NativeNetworkClientOptions = {
160
218
  readonly grantTcpEgress?: (input: NativeNetworkDynamicGrantOptions) => NativeNetworkEgressGrant;
161
219
  };
162
220
 
163
- function synthesizeNodemavenGateway(
221
+ const VENDOR_CREDENTIAL_NAMES: Readonly<Partial<Record<ProviderProxyProvider, readonly string[]>>> =
222
+ {
223
+ smartproxy: [SMARTPROXY_APP_KEY_ENV],
224
+ nodemaven: [NODEMAVEN_USERNAME_ENV, NODEMAVEN_PASSWORD_ENV],
225
+ };
226
+
227
+ /** Build a resolver over the SDK's existing injectable environment context. */
228
+ export function createEnvVendorCredentialResolver(
229
+ env: EnvContext = createEnvContext(),
230
+ ): VendorCredentialResolver {
231
+ return (vendor) => {
232
+ const names = VENDOR_CREDENTIAL_NAMES[vendor] ?? [];
233
+ const values: Record<string, string> = {};
234
+ const missing: string[] = [];
235
+ for (const name of names) {
236
+ const value = env.get(name)?.trim();
237
+ if (value) values[name] = value;
238
+ else missing.push(name);
239
+ }
240
+ if (missing.length > 0 || names.length === 0) return { kind: "absent", missing };
241
+ if (vendor === "nodemaven") {
242
+ const filter = env.get(NODEMAVEN_FILTER_ENV)?.trim();
243
+ if (filter) values[NODEMAVEN_FILTER_ENV] = filter;
244
+ }
245
+ return { kind: "present", values };
246
+ };
247
+ }
248
+
249
+ function skipped(reason: NativeGatewayProxySkipReason): NativeGatewayProxySynthesisResult {
250
+ return { kind: "skipped", reason };
251
+ }
252
+
253
+ function lookupCredentials(
164
254
  input: NativeGatewayProxySynthesisInput,
165
- ): NativeGatewayProxy | undefined {
166
- if (input.vendor !== "nodemaven" || !hasNodemavenCredentials()) return undefined;
255
+ ): VendorCredentialLookup | { readonly kind: "error"; readonly cause: Error } {
256
+ try {
257
+ return input.credentials(input.vendor);
258
+ } catch (error) {
259
+ return {
260
+ kind: "error",
261
+ cause: error instanceof Error ? error : new Error(String(error)),
262
+ };
263
+ }
264
+ }
167
265
 
168
- // NodeMaven defaults ctx.http to HTTP CONNECT because SOCKS5 adds ~500ms per
169
- // request. Native LOCO pays this once per long-lived connection and must reach
170
- // arbitrary destination ports, so SOCKS5 is mandatory here.
266
+ function synthesizeNodemavenGateway(
267
+ input: NativeGatewayProxySynthesisInput,
268
+ ): NativeGatewayProxySynthesisResult {
269
+ if (input.vendor !== "nodemaven") return undefined;
270
+ const lookup = lookupCredentials(input);
271
+ if (lookup.kind === "error") {
272
+ return skipped({ kind: "credential_lookup_failed", cause: lookup.cause });
273
+ }
274
+ if (lookup.kind === "absent") {
275
+ return skipped({ kind: "credentials_absent", missing: lookup.missing });
276
+ }
171
277
  const sessionWindow = nodemavenSessionWindow(input.policy, input.now);
172
278
  const synthesized = synthesizeNodemavenProxy({
173
279
  policy: input.policy,
280
+ credentials: {
281
+ username: lookup.values[NODEMAVEN_USERNAME_ENV] ?? "",
282
+ password: lookup.values[NODEMAVEN_PASSWORD_ENV] ?? "",
283
+ ...(lookup.values[NODEMAVEN_FILTER_ENV]
284
+ ? { filter: lookup.values[NODEMAVEN_FILTER_ENV] }
285
+ : {}),
286
+ },
174
287
  affinityKey: input.affinityKey,
175
- protocol: "socks5",
288
+ protocol: input.protocol,
176
289
  poolIndex: 0,
177
290
  refreshEpoch: sessionWindow.refreshEpoch,
178
291
  now: input.now,
@@ -187,7 +300,54 @@ function synthesizeNodemavenGateway(
187
300
  };
188
301
  }
189
302
 
303
+ async function synthesizeSmartproxyGateway(
304
+ input: NativeGatewayProxySynthesisInput,
305
+ ): Promise<NativeGatewayProxySynthesisResult> {
306
+ if (input.vendor !== "smartproxy") return undefined;
307
+ const lookup = lookupCredentials(input);
308
+ if (lookup.kind === "error") {
309
+ return skipped({ kind: "credential_lookup_failed", cause: lookup.cause });
310
+ }
311
+ if (lookup.kind === "absent") {
312
+ return skipped({ kind: "credentials_absent", missing: lookup.missing });
313
+ }
314
+ try {
315
+ const resolved = await resolveWithVendor(
316
+ "smartproxy",
317
+ input.policy,
318
+ {
319
+ proxyPolicy: input.policy,
320
+ affinityKey: input.affinityKey,
321
+ protocol: input.protocol,
322
+ },
323
+ {
324
+ protocol: input.protocol,
325
+ poolIndex: 0,
326
+ refreshEpoch: 0,
327
+ credentials: lookup.values,
328
+ ambientDefaults: false,
329
+ sharedCache: false,
330
+ },
331
+ );
332
+ if (!resolved.url) {
333
+ return skipped({ kind: "allocation_failed", cause: new Error("No endpoint returned") });
334
+ }
335
+ return {
336
+ url: resolved.url,
337
+ vendor: "smartproxy",
338
+ sticky: isStickyPolicy(input.policy),
339
+ };
340
+ } catch (error) {
341
+ const cause = error instanceof Error ? error : new Error(String(error));
342
+ return skipped({
343
+ kind: "allocation_failed",
344
+ cause: redactSensitiveError(cause, Object.values(lookup.values)),
345
+ });
346
+ }
347
+ }
348
+
190
349
  const DEFAULT_GATEWAY_SYNTHESIZERS: readonly NativeGatewayProxySynthesizer[] = [
350
+ synthesizeSmartproxyGateway,
191
351
  synthesizeNodemavenGateway,
192
352
  ];
193
353
 
@@ -216,35 +376,135 @@ function resolveNativeVendorChain(policy: ProviderProxyPolicy): ProviderProxyPro
216
376
  return chain;
217
377
  }
218
378
 
219
- /** Resolve the first configured native gateway without invoking an allocator API. */
220
- export function resolveNativeGatewayProxy(
379
+ type NativeGatewayVendorSkip = {
380
+ readonly vendor: ProviderProxyProvider;
381
+ readonly reason: NativeGatewayProxySkipReason | { readonly kind: "adapter_unavailable" };
382
+ };
383
+
384
+ function isProxyProtocol(value: unknown): value is ProxyProtocol {
385
+ return value === "http" || value === "socks5";
386
+ }
387
+
388
+ function isSkippedSynthesis(
389
+ result: Exclude<NativeGatewayProxySynthesisResult, undefined>,
390
+ ): result is { readonly kind: "skipped"; readonly reason: NativeGatewayProxySkipReason } {
391
+ return "kind" in result && result.kind === "skipped";
392
+ }
393
+
394
+ function sanitizeVendorResolutionCause(
395
+ error: unknown,
396
+ vendor: ProviderProxyProvider,
397
+ credentials: VendorCredentialResolver,
398
+ ): Error {
399
+ const cause = error instanceof Error ? error : new Error(String(error));
400
+ try {
401
+ const lookup = credentials(vendor);
402
+ return lookup.kind === "present"
403
+ ? redactSensitiveError(cause, Object.values(lookup.values))
404
+ : cause;
405
+ } catch {
406
+ return cause;
407
+ }
408
+ }
409
+
410
+ function sanitizeVendorSkipReason(
411
+ reason: NativeGatewayProxySkipReason,
412
+ vendor: ProviderProxyProvider,
413
+ credentials: VendorCredentialResolver,
414
+ ): NativeGatewayProxySkipReason {
415
+ return reason.kind === "allocation_failed" || reason.kind === "credential_lookup_failed"
416
+ ? {
417
+ ...reason,
418
+ cause: sanitizeVendorResolutionCause(reason.cause, vendor, credentials),
419
+ }
420
+ : reason;
421
+ }
422
+
423
+ function defaultVendorProtocol(vendor: ProviderProxyProvider): ProxyProtocol {
424
+ return vendor === "smartproxy" || vendor === "nodemaven"
425
+ ? VENDOR_DEFAULT_PROTOCOL[vendor]
426
+ : "http";
427
+ }
428
+
429
+ async function resolveNativeGatewayProxyDetailed(
221
430
  input: NativeGatewayProxyResolutionInput,
222
- ): NativeGatewayProxy | undefined {
223
- if (input.policy.mode === "disabled") return undefined;
431
+ ): Promise<{ proxy?: NativeGatewayProxy; skips: readonly NativeGatewayVendorSkip[] }> {
432
+ if (input.policy.mode === "disabled") return { skips: [] };
224
433
  const synthesizers = input.gatewaySynthesizers ?? DEFAULT_GATEWAY_SYNTHESIZERS;
434
+ const credentials = input.credentials ?? createEnvVendorCredentialResolver();
225
435
  const now = input.now ?? Date.now();
436
+ const skips: NativeGatewayVendorSkip[] = [];
226
437
  for (const vendor of resolveNativeVendorChain(input.policy)) {
438
+ const protocol = input.protocol ?? defaultVendorProtocol(vendor);
439
+ if (!isProxyProtocol(protocol)) {
440
+ skips.push({ vendor, reason: { kind: "protocol_unsupported", protocol: String(protocol) } });
441
+ continue;
442
+ }
443
+ let vendorSkip: NativeGatewayVendorSkip["reason"] | undefined;
227
444
  for (const synthesize of synthesizers) {
228
- const resolved = synthesize({
229
- vendor,
230
- policy: input.policy,
231
- affinityKey: input.affinityKey,
232
- now,
233
- });
234
- if (resolved && resolved.vendor === vendor) return resolved;
445
+ try {
446
+ const resolved = await synthesize({
447
+ vendor,
448
+ policy: input.policy,
449
+ affinityKey: input.affinityKey,
450
+ now,
451
+ protocol,
452
+ credentials,
453
+ });
454
+ if (!resolved) continue;
455
+ if (isSkippedSynthesis(resolved)) {
456
+ vendorSkip = sanitizeVendorSkipReason(resolved.reason, vendor, credentials);
457
+ continue;
458
+ }
459
+ if (resolved.vendor === vendor) {
460
+ assertTunnelingScheme(resolved.url);
461
+ return { proxy: resolved, skips };
462
+ }
463
+ } catch (error) {
464
+ vendorSkip = {
465
+ kind: "allocation_failed",
466
+ cause: sanitizeVendorResolutionCause(error, vendor, credentials),
467
+ };
468
+ }
235
469
  }
470
+ skips.push({ vendor, reason: vendorSkip ?? { kind: "adapter_unavailable" } });
236
471
  }
237
- return undefined;
472
+ return { skips };
238
473
  }
239
474
 
240
- function proxyRequiredError(policy: ProviderProxyPolicy): ProxyResolutionError {
475
+ /** Resolve the first configured native gateway, including allocation vendors. */
476
+ export async function resolveNativeGatewayProxy(
477
+ input: NativeGatewayProxyResolutionInput,
478
+ ): Promise<NativeGatewayProxy | undefined> {
479
+ return (await resolveNativeGatewayProxyDetailed(input)).proxy;
480
+ }
481
+
482
+ function formatVendorSkip(skip: NativeGatewayVendorSkip): string {
483
+ switch (skip.reason.kind) {
484
+ case "credentials_absent":
485
+ return `${skip.vendor}: credentials absent (missing ${skip.reason.missing.join(", ") || "unspecified variables"})`;
486
+ case "protocol_unsupported":
487
+ return `${skip.vendor}: protocol ${skip.reason.protocol} is unsupported`;
488
+ case "allocation_failed":
489
+ return `${skip.vendor}: allocation failed (${skip.reason.cause.message})`;
490
+ case "credential_lookup_failed":
491
+ return `${skip.vendor}: credential lookup failed (${skip.reason.cause.message})`;
492
+ case "adapter_unavailable":
493
+ return `${skip.vendor}: no native adapter is registered`;
494
+ }
495
+ }
496
+
497
+ function proxyRequiredError(
498
+ policy: ProviderProxyPolicy,
499
+ skips: readonly NativeGatewayVendorSkip[],
500
+ ): ProxyResolutionError {
241
501
  const chain = resolveNativeVendorChain(policy).filter(
242
502
  (vendor): vendor is "smartproxy" | "nodemaven" =>
243
503
  vendor === "smartproxy" || vendor === "nodemaven",
244
504
  );
245
505
  return new ProxyResolutionError(
246
506
  "PROXY_REQUIRED",
247
- `Native proxy egress is required but no gateway vendor in [${chain.join(", ") || "none"}] resolved.`,
507
+ `Native proxy egress is required but the vendor chain was exhausted: ${skips.map(formatVendorSkip).join("; ") || "no vendors declared"}.`,
248
508
  { vendorChain: chain },
249
509
  );
250
510
  }
@@ -268,8 +528,8 @@ function timeoutError(): NativeNetworkError {
268
528
  return new NativeNetworkError("Native connection timed out", "native_connection_timeout");
269
529
  }
270
530
 
271
- function failedError(): NativeNetworkError {
272
- return new NativeNetworkError("Native connection failed", "native_connection_failed");
531
+ function failedError(cause?: Error): NativeNetworkError {
532
+ return new NativeNetworkError("Native connection failed", "native_connection_failed", cause);
273
533
  }
274
534
 
275
535
  function assertCanStart(signal: AbortSignal | undefined, deadline: Deadline): void {
@@ -302,7 +562,7 @@ async function waitForSocketEvent(
302
562
  } else resolve();
303
563
  };
304
564
  const onReady = () => finish();
305
- const onError = () => finish(failedError());
565
+ const onError = (cause: Error) => finish(failedError(cause));
306
566
  const onClose = () => finish(failedError());
307
567
  const onAbort = () => finish(abortError());
308
568
 
@@ -357,11 +617,129 @@ function parseSocks5Proxy(proxyUrl: string): {
357
617
  }
358
618
  }
359
619
 
620
+ function parseHttpConnectProxy(proxyUrl: string): {
621
+ host: string;
622
+ port: number;
623
+ userId?: string;
624
+ password?: string;
625
+ } {
626
+ let parsed: URL;
627
+ try {
628
+ parsed = new URL(proxyUrl);
629
+ } catch {
630
+ throw new NativeNetworkError("Native proxy URL is invalid", "native_proxy_invalid");
631
+ }
632
+ const port = Number(parsed.port || "80");
633
+ if (
634
+ parsed.protocol !== "http:" ||
635
+ !parsed.hostname ||
636
+ !Number.isInteger(port) ||
637
+ port <= 0 ||
638
+ parsed.pathname !== "/" ||
639
+ parsed.search ||
640
+ parsed.hash
641
+ ) {
642
+ throw new NativeNetworkError("Native proxy URL is invalid", "native_proxy_invalid");
643
+ }
644
+ try {
645
+ return {
646
+ host: parsed.hostname,
647
+ port,
648
+ ...(parsed.username ? { userId: decodeURIComponent(parsed.username) } : {}),
649
+ ...(parsed.password ? { password: decodeURIComponent(parsed.password) } : {}),
650
+ };
651
+ } catch {
652
+ throw new NativeNetworkError("Native proxy URL is invalid", "native_proxy_invalid");
653
+ }
654
+ }
655
+
656
+ const SOCKS5_REPLY_CODES = {
657
+ Failure: 0x01,
658
+ NotAllowed: 0x02,
659
+ NetworkUnreachable: 0x03,
660
+ HostUnreachable: 0x04,
661
+ ConnectionRefused: 0x05,
662
+ TTLExpired: 0x06,
663
+ CommandNotSupported: 0x07,
664
+ AddressNotSupported: 0x08,
665
+ } as const;
666
+
667
+ function socks5ReplyCode(error: Error): number | undefined {
668
+ const match = /Socks5 proxy rejected connection - ([A-Za-z]+)/i.exec(error.message);
669
+ if (!match?.[1]) return undefined;
670
+ const reply = Object.entries(SOCKS5_REPLY_CODES).find(
671
+ ([name]) => name.toLowerCase() === match[1]?.toLowerCase(),
672
+ );
673
+ return reply?.[1];
674
+ }
675
+
676
+ function sanitizeProxyFailureCause(
677
+ error: Error,
678
+ proxyUrl: string,
679
+ credentials: { readonly userId?: string; readonly password?: string },
680
+ ): Error {
681
+ // socks' SocksClientError retains the live socket in options. It is neither
682
+ // useful diagnostic payload nor serializable, so preserve the original error
683
+ // while replacing only that options object with a socket-free snapshot.
684
+ const options = Reflect.get(error, "options");
685
+ if (options && typeof options === "object" && !Array.isArray(options)) {
686
+ const snapshot = { ...(options as Record<string, unknown>) };
687
+ delete snapshot.existing_socket;
688
+ try {
689
+ Reflect.set(error, "options", snapshot);
690
+ } catch {
691
+ // The recursive redactor below clones readonly diagnostics safely.
692
+ }
693
+ }
694
+
695
+ const replyCode = socks5ReplyCode(error);
696
+ if (replyCode !== undefined) {
697
+ try {
698
+ Object.defineProperty(error, "socks5ReplyCode", {
699
+ value: replyCode,
700
+ configurable: true,
701
+ enumerable: true,
702
+ writable: false,
703
+ });
704
+ } catch {
705
+ // The reply label remains in message if an exotic error is immutable.
706
+ }
707
+ }
708
+
709
+ let redactedProxyUrl = proxyUrl;
710
+ try {
711
+ const parsed = new URL(proxyUrl);
712
+ parsed.username = "[REDACTED]";
713
+ parsed.password = "[REDACTED]";
714
+ redactedProxyUrl = parsed.toString();
715
+ } catch {
716
+ // parseSocks5Proxy already validated this URL; keep a defensive fallback.
717
+ }
718
+ return redactSensitiveError(
719
+ error,
720
+ [
721
+ credentials.userId,
722
+ credentials.password,
723
+ credentials.userId !== undefined || credentials.password !== undefined
724
+ ? `${credentials.userId ?? ""}:${credentials.password ?? ""}`
725
+ : undefined,
726
+ credentials.userId !== undefined || credentials.password !== undefined
727
+ ? Buffer.from(`${credentials.userId ?? ""}:${credentials.password ?? ""}`).toString(
728
+ "base64",
729
+ )
730
+ : undefined,
731
+ ].filter((value): value is string => typeof value === "string" && value.length > 0),
732
+ proxyUrl,
733
+ redactedProxyUrl,
734
+ );
735
+ }
736
+
360
737
  async function waitForSocksHandshake(
361
738
  proxySocket: Socket,
362
739
  promise: ReturnType<typeof SocksClient.createConnection>,
363
740
  signal: AbortSignal | undefined,
364
741
  deadline: Deadline,
742
+ sanitizeFailure: (error: Error) => Error,
365
743
  ): Promise<Socket> {
366
744
  assertCanStart(signal, deadline);
367
745
  return await new Promise<Socket>((resolve, reject) => {
@@ -391,7 +769,9 @@ async function waitForSocksHandshake(
391
769
  finish(
392
770
  error instanceof Error && /\b(?:timed out|timeout)\b/i.test(error.message)
393
771
  ? timeoutError()
394
- : failedError(),
772
+ : failedError(
773
+ sanitizeFailure(error instanceof Error ? error : new Error(String(error))),
774
+ ),
395
775
  ),
396
776
  );
397
777
  });
@@ -423,7 +803,138 @@ async function connectSocksTunnel(
423
803
  existing_socket: proxySocket,
424
804
  ...(remaining === undefined ? {} : { timeout: Math.max(1, remaining) }),
425
805
  });
426
- return await waitForSocksHandshake(proxySocket, handshake, input.signal, deadline);
806
+ return await waitForSocksHandshake(proxySocket, handshake, input.signal, deadline, (error) =>
807
+ sanitizeProxyFailureCause(error, proxy.url, parsed),
808
+ );
809
+ }
810
+
811
+ function connectAuthority(host: string, port: number): string {
812
+ return `${host.includes(":") && !host.startsWith("[") ? `[${host}]` : host}:${port}`;
813
+ }
814
+
815
+ function connectStatusError(statusLine: string, statusCode?: number): Error {
816
+ const error = new Error(
817
+ `HTTP CONNECT proxy rejected tunnel: ${statusLine || "invalid response"}`,
818
+ );
819
+ Object.defineProperties(error, {
820
+ connectStatusLine: {
821
+ value: statusLine || "invalid response",
822
+ configurable: true,
823
+ enumerable: true,
824
+ },
825
+ ...(statusCode === undefined
826
+ ? {}
827
+ : {
828
+ connectStatusCode: {
829
+ value: statusCode,
830
+ configurable: true,
831
+ enumerable: true,
832
+ },
833
+ }),
834
+ });
835
+ return error;
836
+ }
837
+
838
+ async function waitForConnectResponse(
839
+ proxySocket: Socket,
840
+ signal: AbortSignal | undefined,
841
+ deadline: Deadline,
842
+ sanitizeFailure: (error: Error) => Error,
843
+ ): Promise<Socket> {
844
+ assertCanStart(signal, deadline);
845
+ return await new Promise<Socket>((resolve, reject) => {
846
+ let settled = false;
847
+ let buffered = Buffer.alloc(0);
848
+ let timer: ReturnType<typeof setTimeout> | undefined;
849
+ const cleanup = () => {
850
+ if (timer) clearTimeout(timer);
851
+ proxySocket.off("data", onData);
852
+ proxySocket.off("error", onError);
853
+ proxySocket.off("close", onClose);
854
+ signal?.removeEventListener("abort", onAbort);
855
+ };
856
+ const finish = (error?: Error) => {
857
+ if (settled) return;
858
+ settled = true;
859
+ cleanup();
860
+ if (error) {
861
+ proxySocket.on("error", () => undefined);
862
+ proxySocket.destroy();
863
+ reject(error);
864
+ } else resolve(proxySocket);
865
+ };
866
+ const onError = (cause: Error) => finish(failedError(sanitizeFailure(cause)));
867
+ const onClose = () =>
868
+ finish(failedError(sanitizeFailure(new Error("HTTP CONNECT proxy closed before response"))));
869
+ const onAbort = () => finish(abortError());
870
+ const onData = (chunk: Buffer) => {
871
+ buffered = Buffer.concat([buffered, chunk]);
872
+ if (buffered.length > 64 * 1024) {
873
+ finish(failedError(sanitizeFailure(connectStatusError("response headers too large"))));
874
+ return;
875
+ }
876
+ const headerEnd = buffered.indexOf("\r\n\r\n");
877
+ if (headerEnd < 0) return;
878
+ const header = buffered.subarray(0, headerEnd).toString("latin1");
879
+ const statusLine = header.split("\r\n", 1)[0] ?? "";
880
+ const match = /^HTTP\/1\.[01] ([0-9]{3})(?: |$)/.exec(statusLine);
881
+ const statusCode = match?.[1] ? Number(match[1]) : undefined;
882
+ if (statusCode === undefined || statusCode < 200 || statusCode >= 300) {
883
+ finish(failedError(sanitizeFailure(connectStatusError(statusLine, statusCode))));
884
+ return;
885
+ }
886
+ const remaining = buffered.subarray(headerEnd + 4);
887
+ cleanup();
888
+ if (remaining.length > 0) proxySocket.unshift(remaining);
889
+ finish();
890
+ };
891
+
892
+ proxySocket.on("data", onData);
893
+ proxySocket.once("error", onError);
894
+ proxySocket.once("close", onClose);
895
+ signal?.addEventListener("abort", onAbort, { once: true });
896
+ const remaining = remainingMs(deadline);
897
+ if (remaining !== undefined) timer = setTimeout(() => finish(timeoutError()), remaining);
898
+ });
899
+ }
900
+
901
+ async function connectHttpTunnel(
902
+ proxy: NativeGatewayProxy,
903
+ input: NativeNetworkConnectInput,
904
+ deadline: Deadline,
905
+ beforeDestinationConnect: () => void,
906
+ ): Promise<Socket> {
907
+ const parsed = parseHttpConnectProxy(proxy.url);
908
+ const proxySocket = await connectPlainSocket(parsed.host, parsed.port, input.signal, deadline);
909
+ const sanitizeFailure = (error: Error) => sanitizeProxyFailureCause(error, proxy.url, parsed);
910
+ try {
911
+ beforeDestinationConnect();
912
+ } catch (error) {
913
+ proxySocket.destroy();
914
+ throw error;
915
+ }
916
+ const authority = connectAuthority(input.host, input.port);
917
+ const authorization =
918
+ parsed.userId !== undefined || parsed.password !== undefined
919
+ ? `Proxy-Authorization: Basic ${Buffer.from(`${parsed.userId ?? ""}:${parsed.password ?? ""}`).toString("base64")}\r\n`
920
+ : "";
921
+ const response = waitForConnectResponse(proxySocket, input.signal, deadline, sanitizeFailure);
922
+ proxySocket.write(
923
+ `CONNECT ${authority} HTTP/1.1\r\nHost: ${authority}\r\n${authorization}Proxy-Connection: Keep-Alive\r\n\r\n`,
924
+ );
925
+ return await response;
926
+ }
927
+
928
+ async function connectProxyTunnel(
929
+ proxy: NativeGatewayProxy,
930
+ input: NativeNetworkConnectInput,
931
+ deadline: Deadline,
932
+ beforeDestinationConnect: () => void,
933
+ ): Promise<Socket> {
934
+ assertTunnelingScheme(proxy.url);
935
+ return new URL(proxy.url).protocol === "http:"
936
+ ? await connectHttpTunnel(proxy, input, deadline, beforeDestinationConnect)
937
+ : await connectSocksTunnel(proxy, input, deadline, beforeDestinationConnect);
427
938
  }
428
939
 
429
940
  async function upgradeTls(
@@ -473,12 +984,15 @@ export function createNativeNetworkConnection(
473
984
  const resetIdleTimer = () => {
474
985
  clearIdleTimer();
475
986
  if (idleTimeoutMs === undefined || socket.readableEnded || socket.destroyed) return;
476
- idleTimer = setTimeout(() => {
477
- idleTimer = undefined;
478
- if (socket.readableEnded || socket.destroyed) return;
479
- closeReason = new NativeIdleTimeoutError();
480
- socket.destroy(closeReason);
481
- }, Math.max(0, idleTimeoutMs));
987
+ idleTimer = setTimeout(
988
+ () => {
989
+ idleTimer = undefined;
990
+ if (socket.readableEnded || socket.destroyed) return;
991
+ closeReason = new NativeIdleTimeoutError();
992
+ socket.destroy(closeReason);
993
+ },
994
+ Math.max(0, idleTimeoutMs),
995
+ );
482
996
  idleTimer.unref?.();
483
997
  };
484
998
  const clearLifecycle = () => {
@@ -541,7 +1055,7 @@ export function createNativeNetworkConnection(
541
1055
 
542
1056
  const read = async (): Promise<Uint8Array | null> => {
543
1057
  if (closeReason) throw closeReason;
544
- if (terminalError) throw failedError();
1058
+ if (terminalError) throw failedError(terminalError);
545
1059
  const chunk = socket.read() as Buffer | null;
546
1060
  if (chunk) {
547
1061
  resetIdleTimer();
@@ -596,7 +1110,7 @@ export function createNativeNetworkConnection(
596
1110
  }
597
1111
  await new Promise<void>((resolve, reject) => {
598
1112
  socket.write(data, (error) => {
599
- if (error) reject(failedError());
1113
+ if (error) reject(failedError(error));
600
1114
  else resolve();
601
1115
  });
602
1116
  });
@@ -617,6 +1131,7 @@ export function createNativeNetworkConnection(
617
1131
  async function resolveConnectionProxy(
618
1132
  options: NativeNetworkClientOptions,
619
1133
  input: NativeNetworkConnectInput,
1134
+ deadline: Deadline,
620
1135
  ): Promise<NativeGatewayProxy | undefined> {
621
1136
  const policy = options.proxyPolicy;
622
1137
  if (!policy || policy.mode === "disabled") return undefined;
@@ -626,13 +1141,54 @@ async function resolveConnectionProxy(
626
1141
  (isStickyPolicy(policy) && options.credentialIdentity !== undefined
627
1142
  ? deriveNativeCredentialAffinityKey(options.credentialIdentity)
628
1143
  : undefined);
629
- const resolved = resolveNativeGatewayProxy({
630
- policy,
631
- affinityKey,
632
- gatewaySynthesizers: options.gatewaySynthesizers,
1144
+ const resolution = await waitForProxyResolution(
1145
+ resolveNativeGatewayProxyDetailed({
1146
+ policy,
1147
+ affinityKey,
1148
+ protocol: options.proxyProtocol,
1149
+ credentials: options.credentials,
1150
+ gatewaySynthesizers: options.gatewaySynthesizers,
1151
+ }),
1152
+ input.signal,
1153
+ deadline,
1154
+ );
1155
+ if (!resolution.proxy && policy.mode === "required") {
1156
+ throw proxyRequiredError(policy, resolution.skips);
1157
+ }
1158
+ return resolution.proxy;
1159
+ }
1160
+
1161
+ async function waitForProxyResolution<T>(
1162
+ promise: Promise<T>,
1163
+ signal: AbortSignal | undefined,
1164
+ deadline: Deadline,
1165
+ ): Promise<T> {
1166
+ assertCanStart(signal, deadline);
1167
+ return await new Promise<T>((resolve, reject) => {
1168
+ let settled = false;
1169
+ let timer: ReturnType<typeof setTimeout> | undefined;
1170
+ const cleanup = () => {
1171
+ if (timer) clearTimeout(timer);
1172
+ signal?.removeEventListener("abort", onAbort);
1173
+ };
1174
+ const finish = (value?: T, error?: unknown) => {
1175
+ if (settled) return;
1176
+ settled = true;
1177
+ cleanup();
1178
+ if (error !== undefined) reject(error);
1179
+ else resolve(value as T);
1180
+ };
1181
+ const onAbort = () => finish(undefined, abortError());
1182
+ signal?.addEventListener("abort", onAbort, { once: true });
1183
+ const remaining = remainingMs(deadline);
1184
+ if (remaining !== undefined) {
1185
+ timer = setTimeout(() => finish(undefined, timeoutError()), remaining);
1186
+ }
1187
+ void promise.then(
1188
+ (value) => finish(value),
1189
+ (error) => finish(undefined, error),
1190
+ );
633
1191
  });
634
- if (!resolved && policy.mode === "required") throw proxyRequiredError(policy);
635
- return resolved;
636
1192
  }
637
1193
 
638
1194
  type NativeConnectTls = "required" | "disabled";
@@ -648,10 +1204,6 @@ type StoredEgressGrant = {
648
1204
 
649
1205
  export const NATIVE_EGRESS_EXPIRED_EVIDENCE_LIMIT = 256;
650
1206
 
651
- function normalizeEgressHost(host: string): string {
652
- return host.trim().toLowerCase().replace(/\.$/, "");
653
- }
654
-
655
1207
  function invalidPolicy(message: string): NativeNetworkError {
656
1208
  return new NativeNetworkError(message, "native_egress_policy_invalid");
657
1209
  }
@@ -660,15 +1212,48 @@ function matchesDnsSuffix(host: string, suffix: string): boolean {
660
1212
  return host === suffix || host.endsWith(`.${suffix}`);
661
1213
  }
662
1214
 
663
- function matchesSourceHost(rule: DynamicEgressRuleSnapshot, host: string): boolean {
664
- const hasSelector = rule.sourceHost !== undefined || rule.sourceHostSuffixes.length > 0;
1215
+ /** Internal validator-independent source selector matcher. */
1216
+ export function matchesSourceHost(rule: DynamicEgressRuleSnapshot, host: string): boolean {
1217
+ const kind = classifyEgressHost(host);
1218
+ if (kind === "numeric-ambiguous") return false;
1219
+ const hasSelector =
1220
+ rule.sourceHost !== undefined ||
1221
+ rule.sourceHostSuffixes.length > 0 ||
1222
+ rule.sourceIpv4Cidrs.length > 0 ||
1223
+ rule.sourceIpv6Cidrs.length > 0;
665
1224
  if (!hasSelector) return false;
666
- return (
667
- host === rule.sourceHost ||
668
- rule.sourceHostSuffixes.some((suffix) => matchesDnsSuffix(host, suffix))
1225
+ if (host === rule.sourceHost) return true;
1226
+ return matchesHostFamilySelectors(
1227
+ host,
1228
+ rule.sourceHostSuffixes,
1229
+ rule.sourceIpv4Cidrs,
1230
+ rule.sourceIpv6Cidrs,
669
1231
  );
670
1232
  }
671
1233
 
1234
+ function matchesHostFamilySelectors(
1235
+ host: string,
1236
+ hostSuffixes: readonly string[],
1237
+ ipv4Cidrs: readonly string[],
1238
+ ipv6Cidrs: readonly string[],
1239
+ ): boolean {
1240
+ const kind = classifyEgressHost(host);
1241
+ if (kind === "ipv4") {
1242
+ const address = parseStrictIpv4(host);
1243
+ return address !== undefined && ipv4Cidrs.some((cidr) => ipv4InCidr(address, cidr));
1244
+ }
1245
+ if (kind === "ipv4-mapped-ipv6") {
1246
+ const address = parseIpv6(host);
1247
+ const embedded = address && embeddedIpv4FromIpv6(address);
1248
+ return embedded !== undefined && ipv4Cidrs.some((cidr) => ipv4InCidr(embedded, cidr));
1249
+ }
1250
+ if (kind === "ipv6") {
1251
+ const address = parseIpv6(host);
1252
+ return address !== undefined && ipv6Cidrs.some((cidr) => ipv6InCidr(address, cidr));
1253
+ }
1254
+ return kind === "dns" && hostSuffixes.some((suffix) => matchesDnsSuffix(host, suffix));
1255
+ }
1256
+
672
1257
  function matchesPortSelectors(
673
1258
  port: number,
674
1259
  ports: readonly number[],
@@ -694,43 +1279,40 @@ function matchesDynamicRuleSelectors(
694
1279
  rule: DynamicEgressRuleSnapshot,
695
1280
  input: NativeNetworkDynamicGrantOptions,
696
1281
  ): boolean {
697
- const sourceHost = normalizeEgressHost(input.sourceHost);
698
- const targetHost = normalizeEgressHost(input.host);
699
1282
  return (
700
- matchesSourceHost(rule, sourceHost) &&
1283
+ matchesSourceHost(rule, input.sourceHost) &&
701
1284
  matchesPortSelectors(input.sourcePort, rule.sourcePorts, rule.sourcePortRanges) &&
702
- rule.targetHostSuffixes.some((suffix) => matchesDnsSuffix(targetHost, suffix)) &&
1285
+ matchesDynamicTargetHost(rule, input.host) &&
703
1286
  matchesPortSelectors(input.port, rule.targetPorts, rule.targetPortRanges) &&
704
1287
  grantTlsFitsRule(input.tls, rule.tls)
705
1288
  );
706
1289
  }
707
1290
 
1291
+ function matchesDynamicTargetHost(rule: DynamicEgressRuleSnapshot, targetHost: string): boolean {
1292
+ return matchesHostFamilySelectors(
1293
+ targetHost,
1294
+ rule.targetHostSuffixes,
1295
+ rule.targetIpv4Cidrs,
1296
+ rule.targetIpv6Cidrs,
1297
+ );
1298
+ }
1299
+
708
1300
  function invalidGrant(message: string): NativeNetworkError {
709
1301
  return new NativeNetworkError(message, "native_egress_grant_invalid");
710
1302
  }
711
1303
 
712
- function hasControlCharacter(value: string): boolean {
713
- for (let index = 0; index < value.length; index += 1) {
714
- const code = value.charCodeAt(index);
715
- if (code <= 31 || code === 127) return true;
716
- }
717
- return false;
1304
+ function canonicalGrantHost(value: unknown): string {
1305
+ if (typeof value === "string" && value.includes("*"))
1306
+ throw invalidGrant("Native TCP egress grant hosts must be exact non-empty hostnames");
1307
+ const canonical = canonicalizeEgressHost(value);
1308
+ if (!canonical.ok)
1309
+ throw invalidGrant("Native TCP egress grant hosts must be exact non-empty hostnames");
1310
+ return canonical.host;
718
1311
  }
719
1312
 
720
1313
  function assertValidGrantInput(input: NativeNetworkDynamicGrantOptions): void {
721
- if (
722
- !normalizeEgressHost(input.sourceHost) ||
723
- !normalizeEgressHost(input.host) ||
724
- hasControlCharacter(input.sourceHost) ||
725
- hasControlCharacter(input.host) ||
726
- /\s/.test(input.sourceHost) ||
727
- /\s/.test(input.host) ||
728
- input.sourceHost.includes("://") ||
729
- input.host.includes("://") ||
730
- input.sourceHost.includes("*") ||
731
- input.host.includes("*")
732
- )
733
- throw invalidGrant("Native TCP egress grant hosts must be exact non-empty hostnames");
1314
+ canonicalGrantHost(input.sourceHost);
1315
+ canonicalGrantHost(input.host);
734
1316
  if (
735
1317
  !Number.isSafeInteger(input.sourcePort) ||
736
1318
  input.sourcePort < 1 ||
@@ -746,51 +1328,65 @@ function assertValidGrantInput(input: NativeNetworkDynamicGrantOptions): void {
746
1328
  throw invalidGrant("Native TCP egress grant ttlMs must be a positive integer");
747
1329
  }
748
1330
 
749
- /** Internal canonical snapshot shared by production and SDK transport test doubles. */
750
- export function snapshotNativeConnectInput(
1331
+ type NativeConnectInputRejectionReason =
1332
+ | EgressHostCanonicalizationFailure
1333
+ | "port-range"
1334
+ | "inspection-failure";
1335
+
1336
+ function invalidNativeConnectInput(
1337
+ field: keyof NativeNetworkConnectInput,
1338
+ reason: NativeConnectInputRejectionReason,
1339
+ ): NativeNetworkError {
1340
+ return new NativeNetworkError(
1341
+ `Native connection input rejected: field=${field}; reason=${reason}`,
1342
+ "native_egress_input_invalid",
1343
+ );
1344
+ }
1345
+
1346
+ function inspectNativeConnectInputField<K extends keyof NativeNetworkConnectInput>(
751
1347
  input: NativeNetworkConnectInput,
752
- ): NativeNetworkConnectInput {
1348
+ field: K,
1349
+ ): NativeNetworkConnectInput[K] {
753
1350
  try {
754
- const host = input.host;
755
- const port = input.port;
756
- const serverName = input.serverName;
757
- const rejectUnauthorized = input.rejectUnauthorized;
758
- const idleTimeoutMs = input.idleTimeoutMs;
759
- const timeoutMs = input.timeoutMs;
760
- const signal = input.signal;
761
- const affinityKey = input.affinityKey;
762
- const snapshot: NativeNetworkConnectInput = {
763
- host,
764
- port,
765
- ...(serverName === undefined ? {} : { serverName }),
766
- ...(rejectUnauthorized === undefined ? {} : { rejectUnauthorized }),
767
- ...(idleTimeoutMs === undefined ? {} : { idleTimeoutMs }),
768
- ...(timeoutMs === undefined ? {} : { timeoutMs }),
769
- ...(signal === undefined ? {} : { signal }),
770
- ...(affinityKey === undefined ? {} : { affinityKey }),
771
- };
772
- if (
773
- typeof snapshot.host !== "string" ||
774
- !snapshot.host.trim() ||
775
- hasControlCharacter(snapshot.host) ||
776
- !Number.isInteger(snapshot.port) ||
777
- snapshot.port < 1 ||
778
- snapshot.port > 65_535
779
- )
780
- throw new TypeError("invalid native connection target");
781
- return snapshot;
1351
+ return input[field];
782
1352
  } catch {
783
- throw new NativeNetworkError(
784
- "Native connection input could not be inspected safely",
785
- "native_egress_input_invalid",
786
- );
1353
+ throw invalidNativeConnectInput(field, "inspection-failure");
787
1354
  }
788
1355
  }
789
1356
 
1357
+ /** Internal canonical snapshot shared by production and SDK transport test doubles. */
1358
+ export function snapshotNativeConnectInput(
1359
+ input: NativeNetworkConnectInput,
1360
+ ): NativeNetworkConnectInput {
1361
+ const host = inspectNativeConnectInputField(input, "host");
1362
+ const canonicalHost = canonicalizeEgressHost(host);
1363
+ if (!canonicalHost.ok) throw invalidNativeConnectInput("host", canonicalHost.reason);
1364
+ const port = inspectNativeConnectInputField(input, "port");
1365
+ if (!Number.isInteger(port) || port < 1 || port > 65_535)
1366
+ throw invalidNativeConnectInput("port", "port-range");
1367
+ const serverName = inspectNativeConnectInputField(input, "serverName");
1368
+ const rejectUnauthorized = inspectNativeConnectInputField(input, "rejectUnauthorized");
1369
+ const idleTimeoutMs = inspectNativeConnectInputField(input, "idleTimeoutMs");
1370
+ const timeoutMs = inspectNativeConnectInputField(input, "timeoutMs");
1371
+ const signal = inspectNativeConnectInputField(input, "signal");
1372
+ const affinityKey = inspectNativeConnectInputField(input, "affinityKey");
1373
+ return {
1374
+ host: canonicalHost.host,
1375
+ port,
1376
+ ...(serverName === undefined ? {} : { serverName }),
1377
+ ...(rejectUnauthorized === undefined ? {} : { rejectUnauthorized }),
1378
+ ...(idleTimeoutMs === undefined ? {} : { idleTimeoutMs }),
1379
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
1380
+ ...(signal === undefined ? {} : { signal }),
1381
+ ...(affinityKey === undefined ? {} : { affinityKey }),
1382
+ };
1383
+ }
1384
+
790
1385
  /** Internal canonical snapshot shared by production and SDK transport test doubles. */
791
1386
  export function snapshotNativeGrantInput(
792
1387
  input: NativeNetworkDynamicGrantOptions,
793
1388
  ): NativeNetworkDynamicGrantOptions {
1389
+ let snapshot: NativeNetworkDynamicGrantOptions;
794
1390
  try {
795
1391
  const sourceHost = input.sourceHost;
796
1392
  const sourcePort = input.sourcePort;
@@ -798,7 +1394,7 @@ export function snapshotNativeGrantInput(
798
1394
  const port = input.port;
799
1395
  const tls = input.tls;
800
1396
  const ttlMs = input.ttlMs;
801
- return {
1397
+ snapshot = {
802
1398
  sourceHost,
803
1399
  sourcePort,
804
1400
  host,
@@ -812,6 +1408,12 @@ export function snapshotNativeGrantInput(
812
1408
  "native_egress_input_invalid",
813
1409
  );
814
1410
  }
1411
+ assertValidGrantInput(snapshot);
1412
+ return {
1413
+ ...snapshot,
1414
+ sourceHost: canonicalGrantHost(snapshot.sourceHost),
1415
+ host: canonicalGrantHost(snapshot.host),
1416
+ };
815
1417
  }
816
1418
 
817
1419
  /** Internal authorization seam shared by production and SDK transport test doubles. */
@@ -869,20 +1471,21 @@ export function createNativeEgressAuthorization(options: NativeNetworkClientOpti
869
1471
 
870
1472
  const assertConnect = (input: NativeNetworkConnectInput, tls: NativeConnectTls): void => {
871
1473
  if (!declared) return;
872
- const host = normalizeEgressHost(input.host);
873
1474
  const now = Date.now();
874
1475
  purgeInactive(now);
875
1476
  if (
876
1477
  staticRules.some(
877
1478
  (rule) =>
878
- rule.host === host && rule.ports.includes(input.port) && tlsModeAllows(rule.tls, tls),
1479
+ rule.host === input.host &&
1480
+ rule.ports.includes(input.port) &&
1481
+ tlsModeAllows(rule.tls, tls),
879
1482
  )
880
1483
  )
881
1484
  return;
882
1485
  const matching = grants.filter(
883
1486
  (grant) =>
884
1487
  !grant.revoked &&
885
- grant.host === host &&
1488
+ grant.host === input.host &&
886
1489
  grant.port === input.port &&
887
1490
  tlsModeAllows(grant.tls, tls),
888
1491
  );
@@ -890,9 +1493,7 @@ export function createNativeEgressAuthorization(options: NativeNetworkClientOpti
890
1493
  const expired = [...expiredEvidence.values()]
891
1494
  .filter(
892
1495
  (grant) =>
893
- grant.host === host &&
894
- grant.port === input.port &&
895
- tlsModeAllows(grant.tls, tls),
1496
+ grant.host === input.host && grant.port === input.port && tlsModeAllows(grant.tls, tls),
896
1497
  )
897
1498
  .sort((left, right) => (right.expiresAtMs ?? 0) - (left.expiresAtMs ?? 0))[0];
898
1499
  if (expired?.expiresAtMs !== undefined)
@@ -908,11 +1509,45 @@ export function createNativeEgressAuthorization(options: NativeNetworkClientOpti
908
1509
  const grantLocal = (input: NativeNetworkDynamicGrantOptions): NativeNetworkEgressGrant => {
909
1510
  assertValidGrantInput(input);
910
1511
  const ruleIndex = dynamicRules.findIndex((rule) => matchesDynamicRuleSelectors(rule, input));
911
- if (ruleIndex < 0)
1512
+ if (ruleIndex < 0) {
1513
+ const diagnosticSourceHost = safeDiagnosticEgressHost(input.sourceHost);
1514
+ const diagnosticTargetHost = safeDiagnosticEgressHost(input.host);
1515
+ const sourceMatchingRuleIndices = dynamicRules.flatMap((rule, index) =>
1516
+ matchesSourceHost(rule, input.sourceHost) &&
1517
+ matchesPortSelectors(input.sourcePort, rule.sourcePorts, rule.sourcePortRanges)
1518
+ ? [index]
1519
+ : [],
1520
+ );
1521
+ const targetKind = classifyEgressHost(diagnosticTargetHost);
1522
+ let selectorDetails: string;
1523
+ if (sourceMatchingRuleIndices.length > 0) {
1524
+ const failedByRule: string[] = [];
1525
+ for (const index of sourceMatchingRuleIndices) {
1526
+ const rule = dynamicRules[index];
1527
+ if (!rule) continue;
1528
+ const failedDimensions: string[] = [];
1529
+ if (!matchesDynamicTargetHost(rule, input.host)) failedDimensions.push("target-host");
1530
+ if (!matchesPortSelectors(input.port, rule.targetPorts, rule.targetPortRanges))
1531
+ failedDimensions.push("target-port");
1532
+ if (!grantTlsFitsRule(input.tls, rule.tls)) failedDimensions.push("tls");
1533
+ failedByRule.push(`rule ${index}: ${failedDimensions.join(", ")}`);
1534
+ }
1535
+ selectorDetails = `source-matching rule indices: [${sourceMatchingRuleIndices.join(", ")}]; failed selector dimensions by rule: ${failedByRule.join("; ")}`;
1536
+ } else {
1537
+ const failedByRule = dynamicRules.map((rule, index) => {
1538
+ const failedDimensions: string[] = [];
1539
+ if (!matchesSourceHost(rule, input.sourceHost)) failedDimensions.push("source-host");
1540
+ if (!matchesPortSelectors(input.sourcePort, rule.sourcePorts, rule.sourcePortRanges))
1541
+ failedDimensions.push("source-port");
1542
+ return `rule ${index}: ${failedDimensions.join(", ")}`;
1543
+ });
1544
+ selectorDetails = `source-matching rule indices: []; failed selector dimensions by rule: ${failedByRule.join("; ")}`;
1545
+ }
912
1546
  throw new NativeNetworkError(
913
- `Native TCP egress grant is not declared for ${input.host}:${input.port} (${input.tls})`,
1547
+ `Native TCP egress grant is not declared for source ${diagnosticSourceHost}:${input.sourcePort} to target ${diagnosticTargetHost}:${input.port} (${input.tls}); target kind: ${targetKind}; ${selectorDetails}`,
914
1548
  "native_egress_not_declared",
915
1549
  );
1550
+ }
916
1551
  const rule = dynamicRules[ruleIndex];
917
1552
  if (!rule) throw invalidGrant("Native TCP egress declaration is missing its matched rule");
918
1553
  if (input.ttlMs !== undefined && rule.ttlMs !== undefined && input.ttlMs > rule.ttlMs)
@@ -920,7 +1555,6 @@ export function createNativeEgressAuthorization(options: NativeNetworkClientOpti
920
1555
  `Native TCP egress grant ttlMs ${input.ttlMs} exceeds declared maximum ${rule.ttlMs}`,
921
1556
  );
922
1557
  const now = Date.now();
923
- const targetHost = normalizeEgressHost(input.host);
924
1558
  purgeInactive(now);
925
1559
  const activeForRule = grants.filter(
926
1560
  (grant) =>
@@ -939,7 +1573,7 @@ export function createNativeEgressAuthorization(options: NativeNetworkClientOpti
939
1573
  throw invalidGrant("Native TCP egress grant expiry exceeds the supported date range");
940
1574
  const stored: StoredEgressGrant = {
941
1575
  ruleIndex,
942
- host: targetHost,
1576
+ host: input.host,
943
1577
  port: input.port,
944
1578
  tls: input.tls,
945
1579
  ...(expiresAtMs === undefined ? {} : { expiresAtMs }),
@@ -1023,10 +1657,10 @@ export function createNativeNetworkClient(
1023
1657
  egress.assertConnect(request, "disabled");
1024
1658
  const deadline = deadlineFrom(request.timeoutMs);
1025
1659
  assertCanStart(request.signal, deadline);
1026
- const proxy = await resolveConnectionProxy(options, request);
1660
+ const proxy = await resolveConnectionProxy(options, request, deadline);
1027
1661
  egress.assertConnect(request, "disabled");
1028
1662
  const socket = proxy
1029
- ? await connectSocksTunnel(proxy, request, deadline, () =>
1663
+ ? await connectProxyTunnel(proxy, request, deadline, () =>
1030
1664
  egress.assertConnect(request, "disabled"),
1031
1665
  )
1032
1666
  : await connectPlainSocket(request.host, request.port, request.signal, deadline);
@@ -1037,10 +1671,10 @@ export function createNativeNetworkClient(
1037
1671
  egress.assertConnect(request, "required");
1038
1672
  const deadline = deadlineFrom(request.timeoutMs);
1039
1673
  assertCanStart(request.signal, deadline);
1040
- const proxy = await resolveConnectionProxy(options, request);
1674
+ const proxy = await resolveConnectionProxy(options, request, deadline);
1041
1675
  egress.assertConnect(request, "required");
1042
1676
  const tunnel = proxy
1043
- ? await connectSocksTunnel(proxy, request, deadline, () =>
1677
+ ? await connectProxyTunnel(proxy, request, deadline, () =>
1044
1678
  egress.assertConnect(request, "required"),
1045
1679
  )
1046
1680
  : undefined;