@apifuse/provider-sdk 2.2.0-beta.68 → 2.2.0-beta.69

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 (52) hide show
  1. package/AUTHORING.md +6 -1
  2. package/CHANGELOG.md +4 -0
  3. package/README.md +10 -3
  4. package/dist/fixture-sanitization.js +159 -15
  5. package/dist/lint.js +56 -9
  6. package/dist/runtime/diagnostic-redactor.d.ts +6 -1
  7. package/dist/runtime/diagnostic-redactor.js +16 -14
  8. package/dist/runtime/http.js +15 -102
  9. package/dist/runtime/instrumentation.js +4 -1
  10. package/dist/runtime/redirect-hop-headers.d.ts +19 -0
  11. package/dist/runtime/redirect-hop-headers.js +58 -0
  12. package/dist/runtime/redirects.d.ts +33 -1
  13. package/dist/runtime/redirects.js +113 -0
  14. package/dist/runtime/resolver-span-attributes.d.ts +34 -0
  15. package/dist/runtime/resolver-span-attributes.js +93 -0
  16. package/dist/runtime/resolver-usage.d.ts +8 -0
  17. package/dist/runtime/resolver-usage.js +5 -11
  18. package/dist/runtime/resolver-vendors/browser.js +5 -4
  19. package/dist/runtime/resolver-vendors/capsolver.js +11 -10
  20. package/dist/runtime/resolver-vendors/twocaptcha.js +9 -8
  21. package/dist/runtime/resolver.js +7 -6
  22. package/dist/runtime/stealth-owned-headers.d.ts +21 -8
  23. package/dist/runtime/stealth-owned-headers.js +28 -8
  24. package/dist/runtime/stealth-telemetry.d.ts +1 -1
  25. package/dist/runtime/stealth.js +158 -46
  26. package/dist/runtime/trace.js +2 -2
  27. package/dist/server/serve-implementation.js +2 -1
  28. package/dist/testing/run.js +14 -7
  29. package/dist/trace-sanitization.js +1 -1
  30. package/dist/types.d.ts +30 -6
  31. package/package.json +2 -2
  32. package/src/fixture-sanitization.ts +175 -17
  33. package/src/lint.ts +54 -9
  34. package/src/runtime/diagnostic-redactor.ts +19 -13
  35. package/src/runtime/http.ts +22 -111
  36. package/src/runtime/instrumentation.ts +4 -1
  37. package/src/runtime/redirect-hop-headers.ts +71 -0
  38. package/src/runtime/redirects.ts +150 -1
  39. package/src/runtime/resolver-span-attributes.ts +139 -0
  40. package/src/runtime/resolver-usage.ts +8 -5
  41. package/src/runtime/resolver-vendors/browser.ts +5 -4
  42. package/src/runtime/resolver-vendors/capsolver.ts +11 -10
  43. package/src/runtime/resolver-vendors/twocaptcha.ts +9 -8
  44. package/src/runtime/resolver.ts +7 -6
  45. package/src/runtime/stealth-owned-headers.ts +34 -8
  46. package/src/runtime/stealth-telemetry.ts +6 -1
  47. package/src/runtime/stealth.ts +180 -58
  48. package/src/runtime/trace.ts +6 -2
  49. package/src/server/serve-implementation.ts +2 -1
  50. package/src/testing/run.ts +15 -2
  51. package/src/trace-sanitization.ts +1 -1
  52. package/src/types.ts +30 -6
@@ -38,7 +38,7 @@ import type {
38
38
  } from "../types.js";
39
39
  import { chrome149HeaderOrder } from "./chrome149-header-order.js";
40
40
  import { registerDiagnosticValue } from "./diagnostic-env.js";
41
- import { isStealthOwnedHeaderName } from "./stealth-owned-headers.js";
41
+ import { assertCallerHeadersSupported } from "./stealth-owned-headers.js";
42
42
  import {
43
43
  ENGINE_CEREMONY_EGRESS_LEASE,
44
44
  type CeremonyEgressBinding,
@@ -68,10 +68,15 @@ import {
68
68
  shouldRetryProxyTransportAttempt,
69
69
  validateUnsafeProxyTransportRetryMethods,
70
70
  } from "./proxy-retry-policy.js";
71
+ import { redirectHopHeaders } from "./redirect-hop-headers.js";
71
72
  import {
72
73
  evaluateRedirectHop,
74
+ invalidRedirectPolicy,
73
75
  isRedirectStatus,
74
76
  nextRedirectMethod,
77
+ nextSameOriginRedirectHop,
78
+ normalizeRedirectPolicy,
79
+ type RedirectPolicyTransport,
75
80
  resolveRedirectUrl,
76
81
  } from "./redirects.js";
77
82
  import {
@@ -139,12 +144,10 @@ function isBoundEgressTransportFailure(error: TransportError): boolean {
139
144
  );
140
145
  }
141
146
  const MAX_STEALTH_REDIRECT_HOPS = 10;
142
- const REDIRECT_BODY_HEADERS = new Set([
143
- "content-encoding",
144
- "content-language",
145
- "content-location",
146
- "content-type",
147
- ]);
147
+ const STEALTH_REDIRECT_POLICY_TRANSPORT: RedirectPolicyTransport = {
148
+ label: "ctx.stealth",
149
+ maxHops: MAX_STEALTH_REDIRECT_HOPS,
150
+ };
148
151
 
149
152
  function isRecord(value: unknown): value is Record<PropertyKey, unknown> {
150
153
  return typeof value === "object" && value !== null;
@@ -399,6 +402,8 @@ function normalizedHeaderEntries(
399
402
  // installs Accept-Language via DevTools next to User-Agent; real Chrome only
400
403
  // receives it from //net (URLRequestHttpJob::AddExtraHeaders), after
401
404
  // Accept-Encoding and before Cookie, as al-placement-capture.json B/C show.
405
+ // chrome-form-post-capture.json was taken without that option and needs no
406
+ // adjustment.
402
407
  const CHROME_HEADER_ORDERS = {
403
408
  navigation: [
404
409
  "sec-ch-ua",
@@ -435,7 +440,28 @@ const CHROME_HEADER_ORDERS = {
435
440
  "cookie",
436
441
  "priority",
437
442
  ],
438
- } as const;
443
+ "form-post": [
444
+ "content-length",
445
+ "cache-control",
446
+ "sec-ch-ua",
447
+ "sec-ch-ua-mobile",
448
+ "sec-ch-ua-platform",
449
+ "upgrade-insecure-requests",
450
+ "content-type",
451
+ "user-agent",
452
+ "origin",
453
+ "accept",
454
+ "sec-fetch-site",
455
+ "sec-fetch-mode",
456
+ "sec-fetch-user",
457
+ "sec-fetch-dest",
458
+ "referer",
459
+ "accept-encoding",
460
+ "accept-language",
461
+ "cookie",
462
+ "priority",
463
+ ],
464
+ } as const satisfies Record<Exclude<ChromeRequestClass, "xhr">, readonly string[]>;
439
465
 
440
466
  const CHROME_H1_HEADER_ORDERS = {
441
467
  navigation: [
@@ -492,7 +518,29 @@ const CHROME_H1_HEADER_ORDERS = {
492
518
  "accept-language",
493
519
  "cookie",
494
520
  ],
495
- } as const;
521
+ "form-post": [
522
+ "host",
523
+ "connection",
524
+ "content-length",
525
+ "cache-control",
526
+ "sec-ch-ua",
527
+ "sec-ch-ua-mobile",
528
+ "sec-ch-ua-platform",
529
+ "upgrade-insecure-requests",
530
+ "content-type",
531
+ "user-agent",
532
+ "origin",
533
+ "accept",
534
+ "sec-fetch-site",
535
+ "sec-fetch-mode",
536
+ "sec-fetch-user",
537
+ "sec-fetch-dest",
538
+ "referer",
539
+ "accept-encoding",
540
+ "accept-language",
541
+ "cookie",
542
+ ],
543
+ } as const satisfies Record<ChromeRequestClass, readonly string[]>;
496
544
 
497
545
  const CHROME_H1_HEADER_NAMES: Record<string, string> = {
498
546
  host: "Host",
@@ -516,7 +564,30 @@ const CHROME_H1_HEADER_NAMES: Record<string, string> = {
516
564
  range: "Range",
517
565
  };
518
566
 
519
- type ChromeRequestClass = keyof typeof CHROME_HEADER_ORDERS | "xhr";
567
+ type ChromeRequestClass = NonNullable<NonNullable<StealthFetchOptions["stealth"]>["requestClass"]>;
568
+
569
+ /**
570
+ * What each request class sends besides its header order, one row per public
571
+ * `stealth.requestClass`:
572
+ *
573
+ * - `fetchMetadata: "navigate"` is a document navigation: `Sec-Fetch-Mode:
574
+ * navigate`, `Sec-Fetch-Dest: document`, `Sec-Fetch-User: ?1` unless
575
+ * `userActivation` is false, `Upgrade-Insecure-Requests: 1`, and the
576
+ * profile's `Accept` and `Priority`. `"cors"` is a script fetch: `cors` /
577
+ * `empty`, `Accept: *\/*`, `Priority: u=1, i`.
578
+ * - `bodyful` sends `Content-Length`.
579
+ * - `validateCache` sends `Cache-Control: max-age=0`, which Chrome adds to a
580
+ * form submission (chrome-form-post-capture.json).
581
+ */
582
+ const CHROME_REQUEST_CLASSES = {
583
+ navigation: { fetchMetadata: "navigate", bodyful: false, validateCache: false },
584
+ "form-post": { fetchMetadata: "navigate", bodyful: true, validateCache: true },
585
+ xhr: { fetchMetadata: "cors", bodyful: false, validateCache: false },
586
+ post: { fetchMetadata: "cors", bodyful: true, validateCache: false },
587
+ } as const satisfies Record<
588
+ ChromeRequestClass,
589
+ { fetchMetadata: "navigate" | "cors"; bodyful: boolean; validateCache: boolean }
590
+ >;
520
591
 
521
592
  function chromeHeaderOrder(
522
593
  requestClass: ChromeRequestClass,
@@ -547,33 +618,11 @@ function chromeHeaderOrder(
547
618
  return order;
548
619
  }
549
620
 
550
- function normalizedCallerHeaderEntries(
551
- headers: Record<string, string | string[] | undefined>,
552
- ): HeaderTuple[] {
553
- const entries = normalizedHeaderEntries(headers);
554
- assertCallerHeadersSupported(entries);
555
- return entries;
556
- }
557
-
558
621
  function normalizedCallerHeaderEntriesFromRecord(headers: Record<string, string>): HeaderTuple[] {
559
622
  // The record has been through normalizeHeaders (lowercase, merged names); the
560
623
  // lowercase here only guards later insertions such as the cookie jar's header.
561
- const entries: HeaderTuple[] = Object.entries(headers).map(([name, value]) => [
562
- name.toLowerCase(),
563
- value,
564
- ]);
565
- assertCallerHeadersSupported(entries);
566
- return entries;
567
- }
568
-
569
- function assertCallerHeadersSupported(entries: readonly HeaderTuple[]): void {
570
- for (const [name] of entries) {
571
- if (isStealthOwnedHeaderName(name)) {
572
- throw new SDKError(`Stealth transport owns the "${name}" header; remove it from headers.`, {
573
- code: "STEALTH_HEADER_OVERRIDE_UNSUPPORTED",
574
- });
575
- }
576
- }
624
+ assertCallerHeadersSupported(headers);
625
+ return Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value]);
577
626
  }
578
627
 
579
628
  function chromeRequestClass(
@@ -621,9 +670,10 @@ function buildChromeHeaderTuples(options: {
621
670
  ),
622
671
  );
623
672
  const requestClass = chromeRequestClass(options.method, options.requestClass);
673
+ const shape = CHROME_REQUEST_CLASSES[requestClass];
624
674
  const referer = caller.get("referer");
625
675
  const fetchSite = secFetchSite(options.requestUrl, referer);
626
- const isNavigation = requestClass === "navigation";
676
+ const isNavigation = shape.fetchMetadata === "navigate";
627
677
  const values = new Map<string, string>([
628
678
  ["sec-ch-ua", requiredEmulationHeader(emulation, "sec-ch-ua")],
629
679
  ["sec-ch-ua-mobile", requiredEmulationHeader(emulation, "sec-ch-ua-mobile")],
@@ -649,12 +699,16 @@ function buildChromeHeaderTuples(options: {
649
699
  values.set("upgrade-insecure-requests", "1");
650
700
  if (options.userActivation !== false) values.set("sec-fetch-user", "?1");
651
701
  }
702
+ if (shape.validateCache) values.set("cache-control", "max-age=0");
652
703
  for (const [name, value] of callerEntries) values.set(name, value);
653
704
  for (const name of ["content-type", "origin", "referer"] as const) {
654
705
  const value = caller.get(name);
655
706
  if (value !== undefined) values.set(name, value);
656
707
  }
657
- if (requestClass === "post") {
708
+ // A bodyful class keeps its class across a 301/302/303 that rewrites the method
709
+ // to GET (a form submission's redirect stays a validated navigation), but the
710
+ // rewritten request has no body and Chrome sends no Content-Length for it.
711
+ if (shape.bodyful && options.method !== "GET" && options.method !== "HEAD") {
658
712
  values.set(
659
713
  "content-length",
660
714
  caller.get("content-length") ?? String(Buffer.byteLength(options.body ?? "")),
@@ -1194,10 +1248,30 @@ function locationHeader(headers: Record<string, string>): string | undefined {
1194
1248
  return undefined;
1195
1249
  }
1196
1250
 
1197
- function withoutRedirectBodyHeaders(headers: Record<string, string>): Record<string, string> {
1198
- return Object.fromEntries(
1199
- Object.entries(headers).filter(([name]) => !REDIRECT_BODY_HEADERS.has(name.toLowerCase())),
1200
- );
1251
+ /**
1252
+ * Snapshot a caller's `redirectPolicy` before the first send. The policy acts
1253
+ * on redirects the transport follows, so it requires the default
1254
+ * `redirect: "follow"`.
1255
+ */
1256
+ function snapshotStealthRedirectPolicy(options: StealthFetchOptions) {
1257
+ let policy: ReturnType<typeof normalizeRedirectPolicy>;
1258
+ try {
1259
+ policy = normalizeRedirectPolicy(options.redirectPolicy, STEALTH_REDIRECT_POLICY_TRANSPORT);
1260
+ } catch (error) {
1261
+ if (error instanceof TransportError) throw error;
1262
+ throw invalidRedirectPolicy(
1263
+ STEALTH_REDIRECT_POLICY_TRANSPORT,
1264
+ "could not be read safely",
1265
+ error instanceof Error ? error : undefined,
1266
+ );
1267
+ }
1268
+ if (policy && options.redirect !== undefined && options.redirect !== "follow") {
1269
+ throw invalidRedirectPolicy(
1270
+ STEALTH_REDIRECT_POLICY_TRANSPORT,
1271
+ `it applies to followed redirects and cannot be combined with redirect: "${options.redirect}"`,
1272
+ );
1273
+ }
1274
+ return policy;
1201
1275
  }
1202
1276
 
1203
1277
  function assertStealthRedirectUrl(url: string): void {
@@ -1318,7 +1392,9 @@ function startStealthExchange(
1318
1392
  requestClass:
1319
1393
  requestClass === "navigation" && options.stealth?.userActivation === false
1320
1394
  ? "script_navigation"
1321
- : requestClass,
1395
+ : requestClass === "form-post"
1396
+ ? "form_post"
1397
+ : requestClass,
1322
1398
  ...(status === undefined ? {} : { status }),
1323
1399
  ...(errorCode ? { errorCode } : {}),
1324
1400
  ...(error === undefined
@@ -1359,6 +1435,10 @@ async function fetchStealthRedirectChain(
1359
1435
  let followedHops = 0;
1360
1436
  let response: StealthTransportResponse;
1361
1437
  const deadline = options.timeout ? performance.now() + options.timeout : undefined;
1438
+ // Snapshotted and validated by session.fetch before the first send.
1439
+ const redirectPolicy = options.redirectPolicy;
1440
+ const initialOrigin = new URL(requestUrl).origin;
1441
+ const visitedRequests = new Set([`${method} ${requestUrl}`]);
1362
1442
 
1363
1443
  let finishExchange: ReturnType<typeof startStealthExchange> | undefined;
1364
1444
  let status: number | undefined;
@@ -1415,28 +1495,48 @@ async function fetchStealthRedirectChain(
1415
1495
  });
1416
1496
  }
1417
1497
 
1418
- const nextUrl = resolveRedirectUrl(
1419
- response.headers.get("location") ?? undefined,
1420
- response.url ?? currentUrl,
1421
- );
1422
- if (!nextUrl) break;
1423
- if (followedHops >= MAX_STEALTH_REDIRECT_HOPS) {
1498
+ let nextUrl: string;
1499
+ let nextMethod: StealthMethod;
1500
+ if (redirectPolicy) {
1424
1501
  discardStealthRedirectBody(response);
1425
- throw new TransportError(
1426
- `Stealth request exceeded the ${MAX_STEALTH_REDIRECT_HOPS}-redirect limit`,
1427
- { code: "transport_network_error", status: 0 },
1502
+ ({ nextUrl, nextMethod } = nextSameOriginRedirectHop({
1503
+ policy: redirectPolicy,
1504
+ initialOrigin,
1505
+ currentUrl,
1506
+ responseUrl: response.url ?? currentUrl,
1507
+ status: response.status,
1508
+ method: currentMethod,
1509
+ location: response.headers.get("location") ?? undefined,
1510
+ followedHops,
1511
+ visitedRequests,
1512
+ }));
1513
+ } else {
1514
+ const location = resolveRedirectUrl(
1515
+ response.headers.get("location") ?? undefined,
1516
+ response.url ?? currentUrl,
1428
1517
  );
1518
+ if (!location) break;
1519
+ if (followedHops >= MAX_STEALTH_REDIRECT_HOPS) {
1520
+ discardStealthRedirectBody(response);
1521
+ throw new TransportError(
1522
+ `Stealth request exceeded the ${MAX_STEALTH_REDIRECT_HOPS}-redirect limit`,
1523
+ { code: "transport_network_error", status: 0 },
1524
+ );
1525
+ }
1526
+ discardStealthRedirectBody(response);
1527
+ nextUrl = location;
1528
+ nextMethod = nextRedirectMethod(response.status, currentMethod);
1429
1529
  }
1430
1530
  assertStealthRedirectUrl(nextUrl);
1431
- discardStealthRedirectBody(response);
1432
- const nextMethod = nextRedirectMethod(response.status, currentMethod);
1433
- if (nextMethod !== currentMethod) {
1434
- currentBody = undefined;
1435
- currentHeaders = withoutRedirectBodyHeaders(currentHeaders);
1436
- }
1531
+ currentHeaders = redirectHopHeaders(
1532
+ { from: currentUrl, to: nextUrl, methodChanged: nextMethod !== currentMethod },
1533
+ currentHeaders,
1534
+ );
1535
+ if (nextMethod !== currentMethod) currentBody = undefined;
1437
1536
  currentMethod = nextMethod;
1438
1537
  currentUrl = nextUrl;
1439
1538
  followedHops += 1;
1539
+ visitedRequests.add(`${currentMethod} ${currentUrl}`);
1440
1540
  emitStealthTelemetry(telemetry?.sink, (sink) => sink.recordRedirectHop());
1441
1541
  finishExchange(status);
1442
1542
  finishExchange = undefined;
@@ -1558,10 +1658,15 @@ function createSessionFetcher(
1558
1658
  let entry = clients.get(cacheKey);
1559
1659
  if (!entry) {
1560
1660
  entry = {
1661
+ // The header shape only partitions sessions; it is never handed to wreq as
1662
+ // session `defaultHeaders`. wreq merges those into every request that lacks
1663
+ // one of their names, so a header the SDK left off on purpose — a credential
1664
+ // dropped on a cross-origin redirect hop, the body headers after POST -> GET,
1665
+ // a cookie for another host — would come back with the first request's value.
1666
+ // Every request already sends its complete ordered header list.
1561
1667
  session: wreq.createSession({
1562
1668
  browser,
1563
1669
  os,
1564
- ...(defaultHeaders ? { defaultHeaders } : {}),
1565
1670
  ...(proxyUrl ? { proxy: proxyUrl } : {}),
1566
1671
  ...(ignoreTlsErrors ? { insecure: true } : {}),
1567
1672
  timeout: 30_000,
@@ -1761,7 +1866,11 @@ function createSessionFetcher(
1761
1866
  const { hasExplicitRetryPolicy, method, stealthRetryOptions } = (() => {
1762
1867
  try {
1763
1868
  const method = normalizeMethod(options.method ?? "GET");
1764
- normalizedCallerHeaderEntries(options.headers ?? {});
1869
+ assertCallerHeadersSupported(options.headers ?? {});
1870
+ // Replace the caller's object with the validated copy: the walker, a
1871
+ // retry and an explicit replay all read this snapshot.
1872
+ const redirectPolicy = snapshotStealthRedirectPolicy(options);
1873
+ if (redirectPolicy) options.redirectPolicy = redirectPolicy;
1765
1874
  const hasExplicitRetryPolicy = options.retry !== undefined;
1766
1875
  const stealthRetryOptions =
1767
1876
  normalizeProxyTransportRetryOptions(options.retry, {
@@ -2527,11 +2636,13 @@ function createSessionFetcher(
2527
2636
  stopWhen,
2528
2637
  params,
2529
2638
  sensitiveParams,
2639
+ headers: initialHeaders,
2530
2640
  ...fetchOptions
2531
2641
  } = options;
2532
2642
  const hops: StealthRedirectHop[] = [];
2533
2643
  let method = normalizeMethod(options.method ?? "GET");
2534
2644
  let body = options.body;
2645
+ let headers = initialHeaders;
2535
2646
  let response: StealthResponse | undefined;
2536
2647
  const visitedRequests = new Set<string>();
2537
2648
  const initialParams = params
@@ -2593,6 +2704,7 @@ function createSessionFetcher(
2593
2704
  try {
2594
2705
  response = await session.fetch(currentUrl, {
2595
2706
  ...fetchOptions,
2707
+ ...(headers ? { headers } : {}),
2596
2708
  body,
2597
2709
  method,
2598
2710
  ...(hopIndex === 0 && initialParams ? { params: initialParams } : {}),
@@ -2689,6 +2801,16 @@ function createSessionFetcher(
2689
2801
  cookieStore: cookieJar.serialize(),
2690
2802
  };
2691
2803
  }
2804
+ if (headers) {
2805
+ headers = redirectHopHeaders(
2806
+ {
2807
+ from: currentUrl,
2808
+ to: decision.nextUrl,
2809
+ methodChanged: decision.nextMethod !== method,
2810
+ },
2811
+ headers,
2812
+ );
2813
+ }
2692
2814
  if (decision.nextMethod !== method) {
2693
2815
  body = undefined;
2694
2816
  }
@@ -191,7 +191,11 @@ function normalizeAttributes(
191
191
  ? redactedKey === REDACTION_FAILED
192
192
  ? REDACTION_FAILED
193
193
  : REDACTED_QUERY_VALUE
194
- : normalizeAttributeValue(value, diagnosticAttributeRedactor(key, redact), retainBigints);
194
+ : normalizeAttributeValue(
195
+ value,
196
+ diagnosticAttributeRedactor(key, redact, value),
197
+ retainBigints,
198
+ );
195
199
  return [normalizedKey, normalizedValue] as const;
196
200
  })
197
201
  .filter(
@@ -226,7 +230,7 @@ function prepareResourceAttributesForExport(
226
230
  ? redactedKey === REDACTION_FAILED
227
231
  ? REDACTION_FAILED
228
232
  : REDACTED_QUERY_VALUE
229
- : sanitizeSpanNameForOutput(value, diagnosticAttributeRedactor(key, redact)),
233
+ : sanitizeSpanNameForOutput(value, diagnosticAttributeRedactor(key, redact, value)),
230
234
  ];
231
235
  }),
232
236
  );
@@ -127,6 +127,7 @@ import {
127
127
  tenantOpaqueKeys,
128
128
  } from "../runtime/request-telemetry.js";
129
129
  import type * as ResolverRuntimeModule from "../runtime/resolver.js";
130
+ import { resolverSpanAttributes } from "../runtime/resolver-span-attributes.js";
130
131
  import {
131
132
  bindResolverTelemetry,
132
133
  createUnsupportedResolverClient,
@@ -702,7 +703,7 @@ function createStealthChallengeDetection(
702
703
  const solve = () => resolver.solve(challenge, solveSignal, recorder ?? undefined);
703
704
  return recorder
704
705
  ? recorder.runSpan("resolver.solve", solve, {
705
- attributes: { challenge_kind: challenge.kind },
706
+ attributes: resolverSpanAttributes({ challenge_kind: challenge.kind }),
706
707
  })
707
708
  : solve();
708
709
  },
@@ -10,6 +10,7 @@ import { createTestHandleContext } from "../runtime/handle.js";
10
10
  */
11
11
  const STANDARD_TEST_CONNECTION_ID = "standard-test-connection";
12
12
  import { createMemoryProviderRuntimeState } from "../runtime/state.js";
13
+ import { assertCallerHeadersSupported } from "../runtime/stealth-owned-headers.js";
13
14
  import { createUnsupportedOcrClient } from "../runtime/ocr.js";
14
15
  import { createUnsupportedSttClient } from "../runtime/stt.js";
15
16
  import {
@@ -356,8 +357,19 @@ function createUpstreamContext(
356
357
  options?: unknown,
357
358
  ): Promise<HttpResponse> =>
358
359
  toHttpResponse(await dispatch({ transport: "http", method, url, body, options }));
359
- const stealthCall = async (url: string, options?: { method?: string; body?: unknown }) =>
360
- toStealthResponse(
360
+ // The production transport rejects owned headers (User-Agent, Sec-Fetch-*,
361
+ // sec-ch-ua*, ...) before any byte is sent; the fake applies the same
362
+ // function, so a provider test fails exactly where production would.
363
+ const stealthCall = async (
364
+ url: string,
365
+ options?: {
366
+ method?: string;
367
+ body?: unknown;
368
+ headers?: Readonly<Record<string, string | readonly string[] | undefined>>;
369
+ },
370
+ ) => {
371
+ assertCallerHeadersSupported(options?.headers ?? {});
372
+ return toStealthResponse(
361
373
  await dispatch({
362
374
  transport: "stealth",
363
375
  method: options?.method?.toUpperCase() ?? "GET",
@@ -367,6 +379,7 @@ function createUpstreamContext(
367
379
  }),
368
380
  url,
369
381
  );
382
+ };
370
383
 
371
384
  const createBrowserPage = (): BrowserPage => {
372
385
  let currentUrl = "about:blank";
@@ -52,7 +52,7 @@ export function sanitizeTraceAttributes(
52
52
  const valueRedactor =
53
53
  typeof value === "bigint" || isDiagnosticBigintAttribute(attributes, key)
54
54
  ? diagnosticStructuredRedactor(redact)
55
- : diagnosticAttributeRedactor(key, redact);
55
+ : diagnosticAttributeRedactor(key, redact, value);
56
56
  const sanitizedValue = keyChanged
57
57
  ? redactedKey === REDACTION_FAILED
58
58
  ? REDACTION_FAILED
package/src/types.ts CHANGED
@@ -1414,7 +1414,23 @@ export interface StealthFetchOptions extends Omit<RequestOptions, "redirectPolic
1414
1414
  headers?: Record<string, string | string[]>;
1415
1415
  method?: HttpMethod;
1416
1416
  body?: string | Buffer;
1417
+ /**
1418
+ * Defaults to `"follow"`. Every followed hop drops the request-body headers
1419
+ * when the method changes, `Authorization` and `Proxy-Authorization` when it
1420
+ * leaves the current origin, and an explicit `Cookie` when it leaves the
1421
+ * current site (scheme plus registrable domain); after that the session
1422
+ * cookie jar supplies cookies for the new site.
1423
+ */
1417
1424
  redirect?: "follow" | "manual" | "error";
1425
+ /**
1426
+ * Opt-in same-origin redirect enforcement, the ctx.http `redirectPolicy`
1427
+ * for the stealth transport. A redirect whose target leaves the initial
1428
+ * origin, exceeds `maxHops` (at most 10, the stealth redirect limit),
1429
+ * loops, or has no usable `Location` throws `HttpRedirectError` before any
1430
+ * request is sent to that target. Requires the default
1431
+ * `redirect: "follow"`.
1432
+ */
1433
+ redirectPolicy?: HttpRedirectPolicy;
1418
1434
  /**
1419
1435
  * Maximum decoded response-body bytes to buffer. When set, the stealth
1420
1436
  * transport aborts the response and throws `response_too_large` if the
@@ -1440,13 +1456,21 @@ export interface StealthFetchOptions extends Omit<RequestOptions, "redirectPolic
1440
1456
  * replacement for hand-set `Sec-Fetch-*` headers, which the transport
1441
1457
  * rejects; it drives `Sec-Fetch-Mode`/`Dest`/`User`, `Accept`, `Priority`,
1442
1458
  * and `Upgrade-Insecure-Requests`.
1459
+ *
1460
+ * `form-post` is an HTML `<form method="post">` submission: a navigation
1461
+ * (`navigate`/`document`, the document `Accept`, `Upgrade-Insecure-Requests`)
1462
+ * that carries a body, in the header order Chrome uses for it, with
1463
+ * `Cache-Control: max-age=0` and the caller's `Content-Type`, `Origin` and
1464
+ * `Referer` in place. It is never inferred, since a form submission and a
1465
+ * script `fetch()` POST look the same to the transport.
1443
1466
  */
1444
- requestClass?: "navigation" | "xhr" | "post";
1467
+ requestClass?: "navigation" | "form-post" | "xhr" | "post";
1445
1468
  /**
1446
- * Whether a `navigation` request carries a user gesture and therefore
1447
- * `Sec-Fetch-User: ?1` (default `true`). Set `false` for script-driven
1448
- * navigations such as `location.replace`, which Chrome sends without it.
1449
- * Ignored for the `xhr` and `post` request classes.
1469
+ * Whether a `navigation` or `form-post` request carries a user gesture and
1470
+ * therefore `Sec-Fetch-User: ?1` (default `true`). Set `false` for
1471
+ * script-driven navigations such as `location.replace` or
1472
+ * `form.submit()`, which Chrome sends without it. Ignored for the `xhr` and
1473
+ * `post` request classes.
1450
1474
  */
1451
1475
  userActivation?: boolean;
1452
1476
  /**
@@ -1551,7 +1575,7 @@ export interface StealthRedirectHop {
1551
1575
  }
1552
1576
 
1553
1577
  export interface StealthRedirectRunOptions
1554
- extends Omit<StealthFetchOptions, "redirect"> {
1578
+ extends Omit<StealthFetchOptions, "redirect" | "redirectPolicy"> {
1555
1579
  url: string;
1556
1580
  maxHops?: number;
1557
1581
  stopWhen?: (hop: StealthRedirectHop) => boolean | Promise<boolean>;