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

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