@apifuse/provider-sdk 2.2.0-beta.28 → 2.2.0-beta.29

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.
Files changed (38) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/bin/apifuse-dev.ts +32 -3
  3. package/bin/apifuse-pack-types.ts +24 -1
  4. package/bin/apifuse-record.ts +39 -6
  5. package/dist/auth.d.ts +14 -0
  6. package/dist/auth.js +38 -0
  7. package/dist/config/loader.d.ts +2 -0
  8. package/dist/index.d.ts +1 -1
  9. package/dist/index.js +1 -1
  10. package/dist/runtime/browser.js +45 -2
  11. package/dist/runtime/proxy-telemetry.js +3 -0
  12. package/dist/runtime/resolver-public.d.ts +1 -0
  13. package/dist/runtime/resolver-public.js +1 -0
  14. package/dist/runtime/resolver-vendors/browser.js +14 -4
  15. package/dist/runtime/resolver-vendors/twocaptcha.js +84 -17
  16. package/dist/runtime/resolver-vendors/types.d.ts +2 -2
  17. package/dist/runtime/resolver-vendors/types.js +3 -1
  18. package/dist/runtime/resolver.d.ts +12 -3
  19. package/dist/runtime/resolver.js +80 -12
  20. package/dist/runtime/stealth.d.ts +1 -0
  21. package/dist/runtime/stealth.js +1 -1
  22. package/dist/server/serve-implementation.js +20 -2
  23. package/dist/testing/index.d.ts +1 -0
  24. package/dist/testing/index.js +1 -0
  25. package/package.json +4 -4
  26. package/src/auth.ts +78 -0
  27. package/src/config/loader.ts +3 -0
  28. package/src/index.ts +0 -1
  29. package/src/runtime/browser.ts +50 -2
  30. package/src/runtime/proxy-telemetry.ts +5 -0
  31. package/src/runtime/resolver-public.ts +18 -0
  32. package/src/runtime/resolver-vendors/browser.ts +14 -4
  33. package/src/runtime/resolver-vendors/twocaptcha.ts +102 -19
  34. package/src/runtime/resolver-vendors/types.ts +7 -2
  35. package/src/runtime/resolver.ts +104 -17
  36. package/src/runtime/stealth.ts +1 -1
  37. package/src/server/serve-implementation.ts +20 -2
  38. package/src/testing/index.ts +1 -0
@@ -290,7 +290,13 @@ function formatExpression<T>(fn: string | (() => T)): string {
290
290
 
291
291
  function toLaunchOptions(options: BrowserClientOptions): LaunchOptions {
292
292
  return {
293
- args: options.extraArgs,
293
+ // `extraArgs` is optional, but playwright-extra's stealth evasions mutate
294
+ // `options.args` unguarded (navigator.webdriver does
295
+ // `options.args.findIndex(...)` in beforeLaunch). Forwarding `undefined`
296
+ // therefore crashes every stealth launch that omits extraArgs with
297
+ // "TypeError: undefined is not an object (evaluating 'options.args.findIndex')".
298
+ // Always hand the launcher a concrete array.
299
+ args: options.extraArgs ?? [],
294
300
  executablePath: options.executablePath,
295
301
  headless: options.headless ?? true,
296
302
  proxy: options.proxy ? { server: options.proxy } : undefined,
@@ -975,6 +981,20 @@ function getCdpExecutionContext(params: unknown): {
975
981
  };
976
982
  }
977
983
 
984
+ function getCdpDestroyedExecutionContextId(params: unknown): number | undefined {
985
+ if (!isRecord(params)) {
986
+ return undefined;
987
+ }
988
+
989
+ return typeof params.executionContextId === "number"
990
+ ? params.executionContextId
991
+ : undefined;
992
+ }
993
+
994
+ function isMissingExecutionContextError(error: unknown): boolean {
995
+ return error instanceof Error && /\b(?:cannot|failed to) find context\b/i.test(error.message);
996
+ }
997
+
978
998
  class CdpBrowserLocator implements BrowserLocator {
979
999
  constructor(
980
1000
  private readonly frame: {
@@ -1120,7 +1140,20 @@ class CdpPoolBrowserPage implements BrowserPageContract {
1120
1140
  async evaluateInFrame<T>(frameId: string, fn: string | (() => T)): Promise<T> {
1121
1141
  await this.initialize();
1122
1142
  const contextId = await this.getFrameExecutionContextId(frameId);
1123
- return await this.evaluateWithContext<T>(fn, contextId);
1143
+ try {
1144
+ return await this.evaluateWithContext<T>(fn, contextId);
1145
+ } catch (error) {
1146
+ if (!isMissingExecutionContextError(error)) {
1147
+ throw error;
1148
+ }
1149
+
1150
+ if (this.frameExecutionContexts.get(frameId) === contextId) {
1151
+ this.frameExecutionContexts.delete(frameId);
1152
+ }
1153
+
1154
+ const refreshedContextId = await this.getFrameExecutionContextId(frameId);
1155
+ return await this.evaluateWithContext<T>(fn, refreshedContextId);
1156
+ }
1124
1157
  }
1125
1158
 
1126
1159
  async waitForSelectorInFrame(
@@ -1384,6 +1417,21 @@ class CdpPoolBrowserPage implements BrowserPageContract {
1384
1417
  this.frameExecutionContexts.set(context.frameId, context.id);
1385
1418
  }
1386
1419
  });
1420
+ this.pageClient.on("Runtime.executionContextDestroyed", (params) => {
1421
+ const destroyedContextId = getCdpDestroyedExecutionContextId(params);
1422
+ if (destroyedContextId === undefined) {
1423
+ return;
1424
+ }
1425
+
1426
+ for (const [frameId, contextId] of this.frameExecutionContexts) {
1427
+ if (contextId === destroyedContextId) {
1428
+ this.frameExecutionContexts.delete(frameId);
1429
+ }
1430
+ }
1431
+ });
1432
+ this.pageClient.on("Runtime.executionContextsCleared", () => {
1433
+ this.frameExecutionContexts.clear();
1434
+ });
1387
1435
  await this.pageClient.send("Page.enable");
1388
1436
  await this.pageClient.send("Runtime.enable");
1389
1437
  this.initialized = true;
@@ -4,6 +4,7 @@ import type {
4
4
  ProxyProtocol,
5
5
  ProxyResolutionTelemetryEvent,
6
6
  ProxyTelemetrySink,
7
+ ProxyUserAgentSource,
7
8
  ProxyVendorFailoverTelemetryEvent,
8
9
  ProxyVendorName,
9
10
  SmartproxyAllocatorBodyClass,
@@ -15,6 +16,7 @@ type ProviderTelemetryHeader = {
15
16
  v: 1;
16
17
  proxy?: {
17
18
  provider: ProxyVendorName;
19
+ userAgentSource?: ProxyUserAgentSource;
18
20
  protocol?: ProxyProtocol;
19
21
  cacheStatus: ProxyCacheStatus;
20
22
  cacheHit: boolean;
@@ -98,6 +100,7 @@ export class ProxyTelemetryCollector implements ProxyTelemetrySink {
98
100
  recordProxyResolution(event: ProxyResolutionTelemetryEvent): void {
99
101
  this.#events.push({
100
102
  provider: event.provider,
103
+ ...(event.userAgentSource ? { userAgentSource: event.userAgentSource } : {}),
101
104
  ...(event.protocol ? { protocol: event.protocol } : {}),
102
105
  cacheStatus: event.cacheStatus,
103
106
  cacheHit: event.cacheHit,
@@ -175,6 +178,7 @@ export class ProxyTelemetryCollector implements ProxyTelemetrySink {
175
178
  const aggregate = rest.reduce<ProxyResolutionTelemetryEvent>(
176
179
  (acc, event) => ({
177
180
  provider: event.provider,
181
+ userAgentSource: event.userAgentSource ?? acc.userAgentSource,
178
182
  cacheStatus: worseStatus(acc.cacheStatus, event.cacheStatus),
179
183
  cacheHit: acc.cacheHit && event.cacheHit,
180
184
  resolutionMs: acc.resolutionMs + event.resolutionMs,
@@ -196,6 +200,7 @@ export class ProxyTelemetryCollector implements ProxyTelemetrySink {
196
200
  v: 1,
197
201
  proxy: {
198
202
  provider: serving.provider,
203
+ ...(aggregate.userAgentSource ? { userAgentSource: aggregate.userAgentSource } : {}),
199
204
  ...(serving.protocol ? { protocol: serving.protocol } : {}),
200
205
  cacheStatus: aggregate.cacheStatus,
201
206
  cacheHit: aggregate.cacheHit,
@@ -0,0 +1,18 @@
1
+ export {
2
+ APIFUSE__CDP_POOL__URL,
3
+ APIFUSE__RESOLVER__2CAPTCHA__API_KEY,
4
+ APIFUSE__RESOLVER__CAPMONSTER__API_KEY,
5
+ APIFUSE__RESOLVER__CAPSOLVER__API_KEY,
6
+ APIFUSE__RESOLVER__TIMEOUT_MS,
7
+ bindResolverSignal,
8
+ createResolverClient,
9
+ createResolverClientFromEnv,
10
+ createUnsupportedResolverClient,
11
+ DEFAULT_RESOLVER_TIMEOUT_MS,
12
+ invalidateResolverSolution,
13
+ RESOLVER_ADAPTER_REGISTRY,
14
+ RESOLVER_INSTRUMENTATION_METADATA,
15
+ type ResolverAdapterFactory,
16
+ type ResolverInstrumentationMetadata,
17
+ type ResolverRuntimeOptions,
18
+ } from "./resolver.js";
@@ -316,8 +316,9 @@ export function createBrowserResolverVendorAdapter(
316
316
  },
317
317
 
318
318
  async solve(challenge, identity, callerSignal, traceRecorder) {
319
- void identity;
320
- if (!options.cdpUrl?.trim()) {
319
+ const cdpUrl = options.cdpUrl?.trim();
320
+ const proxyUrl = identity?.proxyUrl;
321
+ if (!cdpUrl && proxyUrl === undefined) {
321
322
  throw new ResolverVendorUnavailableError(BROWSER_VENDOR_ID, "missing_credentials");
322
323
  }
323
324
  if (!isSupportedKind(challenge.kind)) {
@@ -326,6 +327,14 @@ export function createBrowserResolverVendorAdapter(
326
327
  assertResolverHostAllowed(challenge.pageUrl, options.allowedHosts);
327
328
  const challengeKind = challenge.kind;
328
329
  callerSignal.throwIfAborted();
330
+ if (proxyUrl !== undefined && proxyUrl.length === 0) {
331
+ throw new ResolverVendorUnavailableError(BROWSER_VENDOR_ID, "missing_proxy_identity");
332
+ }
333
+ if (cdpUrl && proxyUrl !== undefined) {
334
+ // The current pool acquire protocol cannot bind a proxy to its browser context.
335
+ // Refuse so the chain can choose a vendor that can honor the resolved identity.
336
+ throw new ResolverVendorUnavailableError(BROWSER_VENDOR_ID, "not_implemented");
337
+ }
329
338
 
330
339
  const solveController = new AbortController();
331
340
  const onCallerAbort = () => solveController.abort(abortReason(callerSignal));
@@ -340,8 +349,9 @@ export function createBrowserResolverVendorAdapter(
340
349
  try {
341
350
  client = createClient({
342
351
  allowedHosts: [...options.allowedHosts],
343
- cdpUrl: options.cdpUrl.trim(),
344
- requireCdpPool: true,
352
+ cdpUrl: cdpUrl ?? "",
353
+ ...(proxyUrl === undefined ? {} : { proxy: proxyUrl }),
354
+ requireCdpPool: cdpUrl !== undefined,
345
355
  });
346
356
  const contextOperation = client.withIsolatedContext(async (page) => {
347
357
  handlerEntered = true;
@@ -4,6 +4,7 @@ import { assertResolverHostAllowed } from "./hosts.js";
4
4
  import {
5
5
  type ResolverIdentity,
6
6
  type ResolverVendorAdapter,
7
+ ResolverChallengeVerdictError,
7
8
  ResolverVendorUnavailableError,
8
9
  resolverVendorSupports,
9
10
  } from "./types.js";
@@ -64,10 +65,53 @@ function abortReason(signal: AbortSignal): unknown {
64
65
  return signal.reason ?? new DOMException("The operation was aborted", "AbortError");
65
66
  }
66
67
 
68
+ function containsSensitiveValue(value: unknown, sensitiveValues: readonly string[]): boolean {
69
+ const secrets = sensitiveValues.filter((secret) => secret.length > 0);
70
+ if (secrets.length === 0) return false;
71
+ const seen = new Set<object>();
72
+
73
+ const inspect = (candidate: unknown): boolean => {
74
+ if (typeof candidate === "string") {
75
+ return secrets.some((secret) => candidate.includes(secret));
76
+ }
77
+ if (
78
+ candidate === null ||
79
+ (typeof candidate !== "object" && typeof candidate !== "function")
80
+ ) {
81
+ return false;
82
+ }
83
+ if (seen.has(candidate)) return false;
84
+ seen.add(candidate);
85
+
86
+ try {
87
+ for (const property of Reflect.ownKeys(candidate)) {
88
+ if (typeof property === "string" && inspect(property)) return true;
89
+ const descriptor = Object.getOwnPropertyDescriptor(candidate, property);
90
+ if (!descriptor) return true;
91
+ if ("value" in descriptor && inspect(descriptor.value)) return true;
92
+ if (descriptor.get !== undefined || descriptor.set !== undefined) return true;
93
+ }
94
+ } catch {
95
+ return true;
96
+ }
97
+ return false;
98
+ };
99
+
100
+ return inspect(value);
101
+ }
102
+
103
+ function safeCauseOptions(
104
+ error: unknown,
105
+ sensitiveValues: readonly string[],
106
+ ): { readonly cause?: unknown } {
107
+ return containsSensitiveValue(error, sensitiveValues) ? {} : { cause: error };
108
+ }
109
+
67
110
  function raceWithAbort<T>(
68
111
  operation: () => Promise<T>,
69
112
  signal: AbortSignal,
70
113
  phase?: TwoCaptchaOperationPhase,
114
+ sensitiveValues: readonly string[] = [],
71
115
  ): Promise<T> {
72
116
  if (signal.aborted) return Promise.reject(abortReason(signal));
73
117
 
@@ -91,7 +135,7 @@ function raceWithAbort<T>(
91
135
  }
92
136
  reject(
93
137
  new ResolverVendorUnavailableError(TWOCAPTCHA_VENDOR_ID, "transport_failure", {
94
- cause: error,
138
+ ...safeCauseOptions(error, sensitiveValues),
95
139
  phase,
96
140
  }),
97
141
  );
@@ -158,10 +202,17 @@ function isAllocationExhausted(payload: JsonRecord): boolean {
158
202
  );
159
203
  }
160
204
 
205
+ function isNegativeVerdict(payload: JsonRecord): boolean {
206
+ return errorText(payload, "errorCode").toLowerCase() === "error_captcha_unsolvable";
207
+ }
208
+
161
209
  function unavailableForPayload(
162
210
  payload: JsonRecord,
163
211
  phase: TwoCaptchaOperationPhase,
164
- ): ResolverVendorUnavailableError {
212
+ ): ResolverVendorUnavailableError | ResolverChallengeVerdictError {
213
+ if (isNegativeVerdict(payload)) {
214
+ return new ResolverChallengeVerdictError(TWOCAPTCHA_VENDOR_ID, "solve_failed");
215
+ }
165
216
  return new ResolverVendorUnavailableError(
166
217
  TWOCAPTCHA_VENDOR_ID,
167
218
  isAllocationExhausted(payload) ? "allocation_exhausted" : "transport_failure",
@@ -175,6 +226,7 @@ async function postJson(
175
226
  body: JsonRecord,
176
227
  signal: AbortSignal,
177
228
  phase: TwoCaptchaOperationPhase,
229
+ sensitiveValues: readonly string[],
178
230
  ): Promise<{ readonly ok: boolean; readonly payload: JsonRecord }> {
179
231
  const response = await raceWithAbort(
180
232
  () =>
@@ -187,6 +239,7 @@ async function postJson(
187
239
  }),
188
240
  signal,
189
241
  phase,
242
+ sensitiveValues,
190
243
  );
191
244
 
192
245
  let responseText: string;
@@ -222,9 +275,12 @@ function taskIdFrom(payload: JsonRecord): string | number | undefined {
222
275
  return typeof taskId === "string" || typeof taskId === "number" ? taskId : undefined;
223
276
  }
224
277
 
225
- function tokenFrom(payload: JsonRecord): string | undefined {
278
+ function tokenFrom(payload: JsonRecord, challenge: ProviderChallenge): string | undefined {
226
279
  const solution = payload.solution;
227
280
  if (!isJsonRecord(solution)) return undefined;
281
+ if (challenge.kind === "aws_waf") {
282
+ return typeof solution.existing_token === "string" ? solution.existing_token : undefined;
283
+ }
228
284
  if (typeof solution.gRecaptchaResponse === "string") return solution.gRecaptchaResponse;
229
285
  return typeof solution.token === "string" ? solution.token : undefined;
230
286
  }
@@ -282,8 +338,18 @@ export function createTwoCaptchaResolverVendorAdapter(
282
338
  if (!resolverVendorSupports(TWOCAPTCHA_VENDOR_ID, challenge.kind)) {
283
339
  throw new TypeError(`2captcha resolver does not support ${challenge.kind}`);
284
340
  }
285
- if (challenge.kind !== "recaptcha_v2") {
286
- // AWS WAF remains deferred because its challenge variant has no required site key.
341
+ if (challenge.kind !== "recaptcha_v2" && challenge.kind !== "aws_waf") {
342
+ throw new ResolverVendorUnavailableError(TWOCAPTCHA_VENDOR_ID, "not_implemented", {
343
+ phase: "create_task",
344
+ });
345
+ }
346
+ if (
347
+ challenge.kind === "aws_waf" &&
348
+ (!challenge.siteKey?.trim() ||
349
+ !challenge.captchaScript?.trim() ||
350
+ !challenge.context?.trim() ||
351
+ !challenge.iv?.trim())
352
+ ) {
287
353
  throw new ResolverVendorUnavailableError(TWOCAPTCHA_VENDOR_ID, "not_implemented", {
288
354
  phase: "create_task",
289
355
  });
@@ -310,22 +376,33 @@ export function createTwoCaptchaResolverVendorAdapter(
310
376
 
311
377
  try {
312
378
  const createTask = async () => {
379
+ const task =
380
+ challenge.kind === "aws_waf"
381
+ ? {
382
+ type: proxy ? "AmazonTask" : "AmazonTaskProxyless",
383
+ websiteURL: challenge.pageUrl,
384
+ websiteKey: challenge.siteKey,
385
+ captchaScript: challenge.captchaScript,
386
+ context: challenge.context,
387
+ iv: challenge.iv,
388
+ ...(identity ? { userAgent: identity.userAgent } : {}),
389
+ ...(proxy ?? {}),
390
+ }
391
+ : {
392
+ type: proxy ? "RecaptchaV2Task" : "RecaptchaV2TaskProxyless",
393
+ websiteURL: challenge.pageUrl,
394
+ websiteKey: challenge.siteKey,
395
+ isInvisible: false,
396
+ ...(identity ? { userAgent: identity.userAgent } : {}),
397
+ ...(proxy ?? {}),
398
+ };
313
399
  const createResult = await postJson(
314
400
  fetchImpl,
315
401
  endpoint(baseUrl, "createTask"),
316
- {
317
- clientKey: apiKey,
318
- task: {
319
- type: proxy ? "RecaptchaV2Task" : "RecaptchaV2TaskProxyless",
320
- websiteURL: challenge.pageUrl,
321
- websiteKey: challenge.siteKey,
322
- isInvisible: false,
323
- ...(identity ? { userAgent: identity.userAgent } : {}),
324
- ...(proxy ?? {}),
325
- },
326
- },
402
+ { clientKey: apiKey, task },
327
403
  solveController.signal,
328
404
  phase,
405
+ [apiKey],
329
406
  );
330
407
  const taskId = taskIdFrom(createResult.payload);
331
408
  if (!createResult.ok || createResult.payload.errorId !== 0 || taskId === undefined) {
@@ -359,6 +436,7 @@ export function createTwoCaptchaResolverVendorAdapter(
359
436
  { clientKey: apiKey, taskId },
360
437
  solveController.signal,
361
438
  phase,
439
+ [apiKey],
362
440
  );
363
441
  if (!pollResult.ok || pollResult.payload.errorId !== 0) {
364
442
  throw unavailableForPayload(pollResult.payload, phase);
@@ -372,7 +450,7 @@ export function createTwoCaptchaResolverVendorAdapter(
372
450
  );
373
451
  }
374
452
 
375
- const token = tokenFrom(pollResult.payload);
453
+ const token = tokenFrom(pollResult.payload, challenge);
376
454
  if (!token?.trim()) {
377
455
  throw new ResolverVendorUnavailableError(
378
456
  TWOCAPTCHA_VENDOR_ID,
@@ -403,9 +481,14 @@ export function createTwoCaptchaResolverVendorAdapter(
403
481
  phase,
404
482
  });
405
483
  }
406
- if (error instanceof ResolverVendorUnavailableError) throw error;
484
+ if (
485
+ error instanceof ResolverVendorUnavailableError ||
486
+ error instanceof ResolverChallengeVerdictError
487
+ ) {
488
+ throw error;
489
+ }
407
490
  throw new ResolverVendorUnavailableError(TWOCAPTCHA_VENDOR_ID, "transport_failure", {
408
- cause: error,
491
+ ...safeCauseOptions(error, [apiKey]),
409
492
  phase,
410
493
  });
411
494
  } finally {
@@ -113,13 +113,14 @@ export interface ResolverVendorAdapter {
113
113
  export type ResolverVendorUnavailableReason =
114
114
  | "missing_credentials"
115
115
  | "missing_proxy_identity"
116
+ | "missing_client_profile"
116
117
  | "missing_transport"
117
118
  | "allocation_exhausted"
118
119
  | "transport_failure"
119
120
  | "timeout"
120
121
  | "not_implemented";
121
122
 
122
- export type ResolverChallengeVerdictReason = "human_puzzle";
123
+ export type ResolverChallengeVerdictReason = "human_puzzle" | "solve_failed";
123
124
 
124
125
  type ResolverErrorOptions = {
125
126
  /** Raw cause; adapters must not place bodies, cookies, headers, credentials, or proxy URLs here. */
@@ -159,7 +160,11 @@ export class ResolverChallengeVerdictError extends Error {
159
160
  readonly reason: ResolverChallengeVerdictReason,
160
161
  options: ResolverErrorOptions = {},
161
162
  ) {
162
- super(`Resolver vendor ${vendor} returned a challenge verdict: ${reason}`);
163
+ super(
164
+ reason === "solve_failed"
165
+ ? `Resolver vendor ${vendor} attempted the challenge but did not solve it`
166
+ : `Resolver vendor ${vendor} returned a challenge verdict: ${reason}`,
167
+ );
163
168
  this.name = "ResolverChallengeVerdictError";
164
169
  if (options.cause !== undefined) {
165
170
  this.cause = options.cause;
@@ -1,6 +1,12 @@
1
1
  import { createHash } from "node:crypto";
2
2
 
3
+ import {
4
+ resolveProxyConfigAsync,
5
+ type ProxyResolutionOptions,
6
+ type ProxyUserAgentSource,
7
+ } from "../config/loader.js";
3
8
  import { ProviderError } from "../errors.js";
9
+ import { getStealthProfile } from "../stealth/profiles.js";
4
10
  import type {
5
11
  ChallengeSolution,
6
12
  ProviderCache,
@@ -42,6 +48,7 @@ import {
42
48
  APIFUSE__RESOLVER__TIMEOUT_MS,
43
49
  DEFAULT_RESOLVER_TIMEOUT_MS,
44
50
  } from "./resolver-config.js";
51
+ import { DEFAULT_PROFILE } from "./stealth.js";
45
52
  import type { TraceRecorder } from "./trace.js";
46
53
 
47
54
  export {
@@ -94,8 +101,14 @@ type ResolverChainClient = ResolverContext & {
94
101
  export interface ResolverRuntimeOptions {
95
102
  readonly allowedHosts?: readonly string[];
96
103
  readonly cache?: ProviderCache;
97
- /** Provider-declared proxy intent. The SDK never accepts a caller-built identity. */
98
- readonly proxyMode?: ProviderProxyMode;
104
+ /** Inputs for SDK-owned lazy proxy resolution. The SDK never accepts a caller-built identity. */
105
+ readonly proxyIntent?: {
106
+ readonly mode: ProviderProxyMode;
107
+ readonly upstream: NonNullable<ProxyResolutionOptions["upstream"]>;
108
+ readonly affinityKey?: ProxyResolutionOptions["affinityKey"];
109
+ readonly telemetry?: ProxyResolutionOptions["telemetry"];
110
+ readonly userAgent?: string;
111
+ };
99
112
  /** Server-owned context/proxy scope used only for identity-bound cache entries. */
100
113
  readonly identityScope?: string;
101
114
  /** SDK-owned transport already bound to the resolved proxy lease and client profile. */
@@ -212,6 +225,24 @@ export function swapResolverAdapterFactoryForTests(
212
225
  };
213
226
  }
214
227
 
228
+ let resolveDefaultResolverUserAgent: () => string | undefined = () =>
229
+ getStealthProfile(DEFAULT_PROFILE).userAgent;
230
+
231
+ /** Internal test seam; deliberately not re-exported from the package root. */
232
+ export function swapResolverDefaultUserAgentForTests(
233
+ resolver: (() => string | undefined) | undefined,
234
+ ): () => void {
235
+ const original = resolveDefaultResolverUserAgent;
236
+ resolveDefaultResolverUserAgent =
237
+ resolver ?? (() => getStealthProfile(DEFAULT_PROFILE).userAgent);
238
+ let restored = false;
239
+ return () => {
240
+ if (restored) return;
241
+ restored = true;
242
+ resolveDefaultResolverUserAgent = original;
243
+ };
244
+ }
245
+
215
246
  // This is the sole allowlist for declared vendors whose registry entry may be absent.
216
247
  // Remove a vendor here when its adapter is registered.
217
248
  const KNOWN_UNIMPLEMENTED_RESOLVER_VENDORS: ReadonlySet<ProviderResolverVendor> = new Set([
@@ -340,6 +371,7 @@ function adapterRequiresTransport(
340
371
  function sanitizeDiagnosticUrl(rawUrl: string): string {
341
372
  try {
342
373
  const parsed = new URL(rawUrl);
374
+ if (parsed.username || parsed.password) return "[REDACTED_PROXY_URL]";
343
375
  return `${parsed.protocol}//${parsed.host}`;
344
376
  } catch {
345
377
  return "[REDACTED_URL]";
@@ -355,7 +387,12 @@ function sanitizeCauseMessage(message: string): string {
355
387
  .split(/\s+/)
356
388
  .filter(Boolean)
357
389
  .map((token) => {
358
- if (token === "[REDACTED]" || /^[a-z][a-z\d+.-]*:\/\/[^\s]+$/i.test(token)) return token;
390
+ if (
391
+ token === "[REDACTED]" ||
392
+ token === "[REDACTED_PROXY_URL]" ||
393
+ /^[a-z][a-z\d+.-]*:\/\/[^\s]+$/i.test(token)
394
+ )
395
+ return token;
359
396
  const word = token.replace(/^[^a-z\d]+|[^a-z\d]+$/gi, "");
360
397
  return word.length > 0 &&
361
398
  word.length <= 32 &&
@@ -704,13 +741,58 @@ export async function invalidateResolverSolution(
704
741
  });
705
742
  }
706
743
 
744
+ async function resolveResolverIdentity(
745
+ proxyIntent: NonNullable<ResolverRuntimeOptions["proxyIntent"]>,
746
+ ): Promise<{
747
+ readonly identity?: ResolverIdentity;
748
+ readonly unavailableReason?: ResolverVendorUnavailableReason;
749
+ readonly userAgentSource?: ProxyUserAgentSource;
750
+ }> {
751
+ const userAgentSource: ProxyUserAgentSource = proxyIntent.userAgent ? "declared" : "defaulted";
752
+ let proxyUrl: string | undefined;
753
+ try {
754
+ const resolved = await resolveProxyConfigAsync({
755
+ upstream: proxyIntent.upstream,
756
+ affinityKey: proxyIntent.affinityKey,
757
+ telemetry: proxyIntent.telemetry
758
+ ? {
759
+ ...proxyIntent.telemetry,
760
+ recordProxyResolution(event) {
761
+ proxyIntent.telemetry?.recordProxyResolution({
762
+ ...event,
763
+ userAgentSource,
764
+ });
765
+ },
766
+ }
767
+ : undefined,
768
+ });
769
+ proxyUrl = resolved.url;
770
+ if (!proxyUrl) return { unavailableReason: "missing_proxy_identity", userAgentSource };
771
+ } catch {
772
+ // Lease failures contain infrastructure detail that must not cross the resolver
773
+ // boundary. A required policy is classified by the existing fail-closed guard.
774
+ return { unavailableReason: "missing_proxy_identity", userAgentSource };
775
+ }
776
+
777
+ try {
778
+ const userAgent = proxyIntent.userAgent || resolveDefaultResolverUserAgent();
779
+ if (!userAgent) return { unavailableReason: "missing_client_profile", userAgentSource };
780
+ return {
781
+ identity: { proxyUrl, userAgent },
782
+ userAgentSource,
783
+ };
784
+ } catch {
785
+ return { unavailableReason: "missing_client_profile", userAgentSource };
786
+ }
787
+ }
788
+
707
789
  function createResolverChainClient(options: {
708
790
  readonly kinds: readonly ProviderChallengeKind[];
709
791
  readonly entries: readonly ResolverChainEntry[];
710
792
  readonly unavailableReason?: string;
711
793
  readonly cache?: ProviderCache;
712
794
  readonly identity?: ResolverIdentity;
713
- readonly proxyMode?: ProviderProxyMode;
795
+ readonly proxyIntent?: ResolverRuntimeOptions["proxyIntent"];
714
796
  readonly identityScope?: string;
715
797
  readonly transport?: ResolverVendorTransport;
716
798
  readonly createTransport?: ResolverRuntimeOptions["createTransport"];
@@ -735,16 +817,21 @@ function createResolverChainClient(options: {
735
817
  const supportingEntries = options.entries.filter((entry) => entry.supports(challenge.kind));
736
818
  if (supportingEntries.length === 0) throwUnsupportedKind(challenge.kind);
737
819
  signal.throwIfAborted();
738
- // A required proxy policy is checked before the cache. Solutions minted under a
739
- // previous release are shared and long-lived, so consulting the cache first would
740
- // keep reporting success without a proxy identity until every old entry expired.
820
+ const identityResolution = options.proxyIntent
821
+ ? await resolveResolverIdentity(options.proxyIntent)
822
+ : { identity: options.identity };
823
+ const identity = identityResolution.identity;
824
+ signal.throwIfAborted();
825
+ // Resolve a required proxy lease before consulting the cache. Solutions minted
826
+ // under a previous release are shared and long-lived, but a portable cached token
827
+ // must not bypass the upstream admission policy when no lease can be resolved.
741
828
  const requiredProxyIdentityMissing =
742
- options.proxyMode === "required" && options.identity === undefined;
829
+ options.proxyIntent?.mode === "required" && identity === undefined;
743
830
  if (requiredProxyIdentityMissing) {
744
831
  throwExhausted(
745
832
  supportingEntries.map((entry) => ({
746
833
  vendor: entry.id,
747
- reason: "missing_proxy_identity" as const,
834
+ reason: identityResolution.unavailableReason ?? "missing_proxy_identity",
748
835
  })),
749
836
  );
750
837
  }
@@ -752,12 +839,11 @@ function createResolverChainClient(options: {
752
839
  const cached = await findCachedSolution(
753
840
  options.cache,
754
841
  challenge,
755
- options.identity,
842
+ identity,
756
843
  options.identityScope,
757
844
  );
758
845
  if (cached) return cached;
759
846
  }
760
-
761
847
  const attempts: ResolverChainAttempt[] = [];
762
848
  for (const entry of supportingEntries) {
763
849
  const adapter = entry.createAdapter();
@@ -778,7 +864,7 @@ function createResolverChainClient(options: {
778
864
  const transport = unrestrictedTransport
779
865
  ? restrictResolverTransport(unrestrictedTransport, options.allowedHosts ?? [])
780
866
  : undefined;
781
- return adapter.solve(challenge, options.identity, signal, traceRecorder, transport);
867
+ return adapter.solve(challenge, identity, signal, traceRecorder, transport);
782
868
  };
783
869
  const solution = traceRecorder
784
870
  ? await traceRecorder.runSpan("resolver.vendor.attempt", solveAttempt, {
@@ -786,6 +872,7 @@ function createResolverChainClient(options: {
786
872
  vendor: adapter.id,
787
873
  challenge_kind: challenge.kind,
788
874
  client_profile: options.clientProfile,
875
+ resolver_identity_source: identityResolution.userAgentSource,
789
876
  },
790
877
  onError(error) {
791
878
  return error instanceof ResolverVendorUnavailableError
@@ -801,9 +888,9 @@ function createResolverChainClient(options: {
801
888
  solutionExpiryMs(solution) !== undefined
802
889
  ) {
803
890
  const issuingIdentity =
804
- adapter.getIssuingIdentity?.(solution, options.identity, challenge) ??
891
+ adapter.getIssuingIdentity?.(solution, identity, challenge) ??
805
892
  resolverChallengeIssuingIdentity(challenge, {
806
- ...(options.identity ? { proxyUrl: options.identity.proxyUrl } : {}),
893
+ ...(identity ? { proxyUrl: identity.proxyUrl } : {}),
807
894
  userAgent: solution.userAgent,
808
895
  });
809
896
  if (issuingIdentity) {
@@ -837,7 +924,7 @@ export function createResolverClient(options: {
837
924
  readonly unavailableReason?: string;
838
925
  readonly cache?: ProviderCache;
839
926
  readonly identity?: ResolverIdentity;
840
- readonly proxyMode?: ProviderProxyMode;
927
+ readonly proxyIntent?: ResolverRuntimeOptions["proxyIntent"];
841
928
  readonly transport?: ResolverVendorTransport;
842
929
  readonly createTransport?: ResolverRuntimeOptions["createTransport"];
843
930
  readonly clientProfile?: string;
@@ -853,7 +940,7 @@ export function createResolverClient(options: {
853
940
  unavailableReason: options.unavailableReason,
854
941
  cache: options.cache,
855
942
  identity: options.identity,
856
- proxyMode: options.proxyMode,
943
+ proxyIntent: options.proxyIntent,
857
944
  transport: options.transport,
858
945
  createTransport: options.createTransport,
859
946
  clientProfile: options.clientProfile,
@@ -945,7 +1032,7 @@ function createResolverClientFromEnvInternal(
945
1032
  };
946
1033
  }),
947
1034
  cache: options.cache,
948
- proxyMode: options.proxyMode,
1035
+ proxyIntent: options.proxyIntent,
949
1036
  identityScope: options.identityScope,
950
1037
  transport: options.transport,
951
1038
  createTransport: options.createTransport,
@@ -67,7 +67,7 @@ import {
67
67
  serializeRequestUrl,
68
68
  } from "./request-options.js";
69
69
 
70
- const DEFAULT_PROFILE = "chrome-146";
70
+ export const DEFAULT_PROFILE = "chrome-146";
71
71
 
72
72
  const MISSING_PROXY_WARNING =
73
73
  "[provider-sdk] Provider requested proxy routing, but no proxy URL was configured. Continuing without proxy.";