@reclaimprotocol/agent 0.1.21 → 0.1.23

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 (50) hide show
  1. package/lib/authoring/backend.d.ts +9 -7
  2. package/lib/authoring/tools.js +98 -1
  3. package/lib/mcp/tools/agent/attach.js +29 -1
  4. package/lib/mcp/tools/agent/propose.js +27 -3
  5. package/lib/mcp/tools/agent/publish.js +7 -5
  6. package/lib/mcp/tools/agent/shared.js +17 -8
  7. package/lib/mcp/tools/authenticate/authenticate.d.ts +11 -0
  8. package/lib/mcp/tools/authenticate/authenticate.js +128 -119
  9. package/lib/mcp/tools/authenticate/builder-access.d.ts +8 -3
  10. package/lib/mcp/tools/authenticate/builder-access.js +8 -3
  11. package/lib/mcp/tools/mode/mode.js +32 -1
  12. package/lib/old/client.d.ts +9 -0
  13. package/lib/old/client.js +19 -1
  14. package/lib/old/to-register.d.ts +17 -6
  15. package/lib/old/to-register.js +6 -0
  16. package/lib/old/tools/me-providers.js +3 -1
  17. package/lib/old/tools/publish.js +82 -38
  18. package/lib/proof/page-replay.d.ts +36 -0
  19. package/lib/proof/page-replay.js +76 -0
  20. package/lib/proof/replay.d.ts +28 -0
  21. package/lib/proof/replay.js +39 -18
  22. package/lib/provider/draft.d.ts +3 -1
  23. package/lib/provider/draft.js +39 -7
  24. package/lib/provider/matcher.js +116 -7
  25. package/lib/provider/to-version.d.ts +8 -6
  26. package/lib/provider/to-version.js +15 -3
  27. package/package.json +3 -2
  28. package/skill/SKILL.builder.md +4 -2
  29. package/skill/SKILL.common.md +10 -1
  30. package/skill/SKILL.old.md +1 -1
  31. package/skill/injection-api.md +44 -15
  32. package/skill/old-mode.md +3 -1
  33. package/src/authoring/backend.ts +9 -7
  34. package/src/authoring/tools.ts +119 -1
  35. package/src/mcp/tools/agent/attach.ts +40 -1
  36. package/src/mcp/tools/agent/propose.ts +29 -3
  37. package/src/mcp/tools/agent/publish.ts +7 -6
  38. package/src/mcp/tools/agent/shared.ts +19 -8
  39. package/src/mcp/tools/authenticate/authenticate.ts +150 -163
  40. package/src/mcp/tools/authenticate/builder-access.ts +8 -3
  41. package/src/mcp/tools/mode/mode.ts +34 -1
  42. package/src/old/client.ts +30 -1
  43. package/src/old/to-register.ts +25 -6
  44. package/src/old/tools/me-providers.ts +3 -1
  45. package/src/old/tools/publish.ts +86 -40
  46. package/src/proof/page-replay.ts +131 -0
  47. package/src/proof/replay.ts +60 -19
  48. package/src/provider/draft.ts +46 -6
  49. package/src/provider/matcher.ts +145 -5
  50. package/src/provider/to-version.ts +24 -9
@@ -99,12 +99,15 @@ export function createVersionFromCaptureTool(
99
99
  stepsToFollow?: string
100
100
  useIncognitoWebview?: boolean
101
101
  extensionConfig?: unknown
102
- disableRequestReplay?: boolean
102
+ injectionType?: 'NONE' | 'MSWJS' | 'XHOOK' | 'CDP' | 'HAWKEYE'
103
103
  }>(
104
104
  {
105
105
  name: 'create_provider_version_from_capture',
106
106
  description:
107
107
  'Publish drafted ReclaimProvider(s) to the devtools backend. ' +
108
+ 'Requires its OWN old-devtools login via reclaim_authenticate — ' +
109
+ 'separate from authenticate_builder/Builder auth, which does not ' +
110
+ 'authorize this call. Check status with get_devtools_mode. \n\n' +
108
111
  'Pass the `provider` object from propose_provider VERBATIM and the ' +
109
112
  '`initialUrl` the user landed on (the login/landing page, NOT the ' +
110
113
  'API url). \n\n' +
@@ -122,8 +125,9 @@ export function createVersionFromCaptureTool(
122
125
  "PUBLIC provider you don't own, omit `providerId` to register your " +
123
126
  'own copy (you cannot edit someone else\'s provider here). \n\n' +
124
127
  'For data gated behind login/navigation, pass `customInjection` — a ' +
125
- 'page-injection script (the devtools name for the builder\'s ' +
126
- 'jsUserScripts) that drives the page to where the request fires. \n\n' +
128
+ 'USER SCRIPT (same concept as the builder\'s `jsUserScripts` — ' +
129
+ 'devtools just calls the field `customInjection`) that drives the ' +
130
+ 'page to where the request fires. \n\n' +
127
131
  'BY DESIGN, this backend only ever CREATES A NEW VERSION (POST) — ' +
128
132
  'there is no partial-patch/edit endpoint, and this tool does NOT ' +
129
133
  'read or carry anything forward from the previous version. Every ' +
@@ -139,15 +143,20 @@ export function createVersionFromCaptureTool(
139
143
  'values plus your change) back into this call. ' +
140
144
  '`allowedInjectedRequestData`/`useIncognitoWebview`/' +
141
145
  '`extensionConfig` only take effect on add-version — the register ' +
142
- 'endpoint (no `providerId`) ignores them. `disableRequestReplay` ' +
143
- 'is also add-version-only, but when it is true the tool follows ' +
144
- 'a register up with an add-version call automatically so it takes ' +
145
- 'effect on new providers too. Per-request fields you may set on ' +
146
+ 'endpoint (no `providerId`) ignores them, so on a fresh register ' +
147
+ 'this tool automatically follows up with an add-version call that ' +
148
+ 'applies them (also where document replay gets force-disabled — ' +
149
+ 'see below). Per-request fields you may set on ' +
146
150
  '`provider`/`providers`: `credentials` (omit/same-origin/include, ' +
147
151
  'default include), `urlType` (REGEX/CONSTANT/TEMPLATE, default ' +
148
152
  'derived from {{placeholders}}), and `isOptional` on each ' +
149
153
  'responseMatch (default false). \n\n' +
150
154
  'Captured requests must be GET/POST. OPRF redactions are supported. ' +
155
+ 'Document replay — the HAWKEYE-level flag controlling whether the ' +
156
+ 'page\'s own document/navigation request gets genuinely re-sent ' +
157
+ 'through the interceptor at verification time — is ALWAYS ' +
158
+ 'published disabled (`disableRequestReplay: true`); there is no ' +
159
+ 'way to enable it here. ' +
151
160
  'On success, if a browser is still attached, the response\'s ' +
152
161
  '`_notes` reminds you to confirm with the developer before ' +
153
162
  'calling `dispose_browser` — never close it unprompted, they ' +
@@ -458,14 +467,25 @@ export function createVersionFromCaptureTool(
458
467
  'Free-form JSON passed through to the client. Add-version ' +
459
468
  'ONLY.',
460
469
  },
461
- disableRequestReplay: {
462
- type: 'boolean',
470
+ injectionType: {
471
+ type: 'string',
472
+ enum: ['NONE', 'MSWJS', 'XHOOK', 'CDP', 'HAWKEYE'],
463
473
  description:
464
- 'Skip replaying the main document request (HAWKEYE ' +
465
- 'provider-level flag). Honored on add-version; for a ' +
466
- 'provider (no `providerId`) the register endpoint ignores ' +
467
- 'it, so when true the tool automatically follows the ' +
468
- 'register up with an add-version call that applies it.',
474
+ 'Interception mechanism for this version. Omit to leave it ' +
475
+ 'unset — the devtools backend\'s own column default ' +
476
+ '(`HAWKEYE`) applies, which is inert and harmless for the ' +
477
+ 'standard capture→replay flow with no interceptor script ' +
478
+ 'installed. Only set this to `HAWKEYE`/`MSWJS`/`XHOOK` ' +
479
+ 'when you are ALSO supplying a `customInjection` script ' +
480
+ 'that calls ' +
481
+ '`window.reclaimInterceptor.addResponseMiddleware(...)` for ' +
482
+ 'genuine live interception, or set `NONE` explicitly if you ' +
483
+ 'want to be certain no interception is configured — see ' +
484
+ 'injection-api.md\'s "Recognizing this on a provider you ' +
485
+ 'didn\'t author" section for what these values actually do. ' +
486
+ 'Builder calls the equivalent concept `interceptorType` (no ' +
487
+ '`NONE` there — omitting the whole `interceptorOptions` ' +
488
+ 'object is its no-interception signal instead).',
469
489
  },
470
490
  },
471
491
  required: ['initialUrl'],
@@ -505,7 +525,13 @@ export function createVersionFromCaptureTool(
505
525
  stepsToFollow: args.stepsToFollow,
506
526
  useIncognitoWebview: args.useIncognitoWebview,
507
527
  extensionConfig: args.extensionConfig,
508
- disableRequestReplay: args.disableRequestReplay,
528
+ // Author-settable, no forced default — the backend's own HAWKEYE
529
+ // column default applies when omitted, same as before this field
530
+ // existed at all.
531
+ injectionType: args.injectionType,
532
+ // Always disabled — see the tool description above; there is no
533
+ // author-facing way to enable document replay.
534
+ disableRequestReplay: true,
509
535
  allowedInjectedRequestData: args.allowedInjectedRequestData
510
536
  ? injectedRequestDataFrom(
511
537
  args.allowedInjectedRequestData,
@@ -558,12 +584,12 @@ export function createVersionFromCaptureTool(
558
584
  )
559
585
  }
560
586
 
561
- // disableRequestReplay is honored ONLY on add-version, so a
562
- // register can't set it — when true we immediately follow the
563
- // register with an add-version call (which also lands any other
564
- // register-ignored fields).
565
- const followUp = args.disableRequestReplay === true
566
-
587
+ // disableRequestReplay is honored ONLY on add-version, and it's now
588
+ // always forced true — so every register is immediately followed by
589
+ // an add-version call that actually applies it (which also lands
590
+ // an explicit injectionType, when passed — unverified whether
591
+ // register persists it — and any other register-ignored fields the
592
+ // caller passed).
567
593
  const REGISTER_IGNORED_KEYS = [
568
594
  'allowedInjectedRequestData',
569
595
  'useIncognitoWebview',
@@ -576,46 +602,66 @@ export function createVersionFromCaptureTool(
576
602
  `${ignoredOnRegister.join(', ')} ` +
577
603
  `${ignoredOnRegister.length > 1 ? 'are' : 'is'} ` +
578
604
  'ignored on register (no providerId) — the devtools backend only ' +
579
- 'reads them when adding a version. ' +
580
- (followUp
581
- ? 'They were applied by the follow-up version the tool ' +
582
- 'created to set disableRequestReplay.'
583
- : 'Publish once to create the provider, then call this ' +
584
- 'again WITH the returned providerId to set them.'),
605
+ 'reads them when adding a version. They were applied by the ' +
606
+ 'automatic follow-up version this tool always creates to ' +
607
+ 'force-disable document replay.',
585
608
  )
586
609
  }
587
610
 
588
611
  const body = providerToRegisterBody(providers, opts)
589
612
  const registered = await client.registerProvider(body)
590
613
 
591
- if(followUp) {
592
- const newProviderId = extractProviderId(registered)
593
- if(newProviderId) {
614
+ const newProviderId = extractProviderId(registered)
615
+ if(newProviderId) {
616
+ // The provider already exists as of the register call above —
617
+ // a failure past this point must NOT reject the tool and
618
+ // discard `registered`/`newProviderId`, or a retry without
619
+ // `providerId` would register a DUPLICATE provider. Catch and
620
+ // warn instead, telling the caller to resume WITH this
621
+ // providerId.
622
+ try {
594
623
  const versionsResp = await client.listVersions(newProviderId)
595
624
  const followVersion = bumpPatchString(
596
625
  extractVersions(versionsResp),
597
626
  )
598
627
  const configBody = providerToConfigBody(
599
628
  providers,
600
- { ...opts, disableRequestReplay: true },
629
+ opts,
601
630
  followVersion,
602
631
  DEFAULT_VERSION_INFO,
603
632
  )
604
633
  await client.updateProviderConfig(newProviderId, configBody)
605
634
  notes.push(
606
- 'disableRequestReplay is ignored by the register endpoint — ' +
607
- `applied it via a follow-up version (${followVersion}) on ` +
608
- 'the new provider, which also lands any register-ignored ' +
609
- 'fields you passed.',
635
+ `Created an automatic follow-up version (${followVersion}) to ` +
636
+ 'force-disable document replay (disableRequestReplay)' +
637
+ (opts.injectionType !== undefined
638
+ ? ` and set injectionType (${opts.injectionType})`
639
+ : '') +
640
+ ' — the register endpoint ignores/may ignore those fields, so ' +
641
+ 'every fresh register gets one. It also landed any other ' +
642
+ 'register-ignored fields you passed.',
610
643
  )
611
- } else {
644
+ } catch(err) {
645
+ const detail = isBoom(err) ? err.message : String(err)
612
646
  notes.push(
613
- 'Could not apply disableRequestReplay: the register ' +
614
- 'response did not include the new providerId. Call this ' +
615
- 'tool again WITH `providerId` and `disableRequestReplay: ' +
616
- 'true` to set it.',
647
+ `Provider ${newProviderId} WAS created, but the automatic ` +
648
+ `follow-up version (to force-disable document replay${
649
+ opts.injectionType !== undefined
650
+ ? ' and set injectionType'
651
+ : ''
652
+ }) failed: ${detail}. Do NOT retry without providerId — that ` +
653
+ 'would register a duplicate. Instead, re-run ' +
654
+ 'create_provider_version_from_capture WITH ' +
655
+ `providerId: "${newProviderId}" to add the follow-up version.`,
617
656
  )
618
657
  }
658
+ } else {
659
+ notes.push(
660
+ 'Could not force-disable document replay: the ' +
661
+ 'register response did not include the new providerId. Call ' +
662
+ 'this tool again WITH `providerId` to create a version that ' +
663
+ 'applies them.',
664
+ )
619
665
  }
620
666
 
621
667
  return withNotes(registered)
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Replay a drafted provider's AUTHENTICATED request FROM INSIDE the live
3
+ * attached browser tab, over CDP `Runtime.evaluate` — so cookies/session are
4
+ * always the real browser's, for every browser source the MCP can attach to
5
+ * (local Chrome, remote Popcorn, or a caller-supplied custom CDP endpoint all
6
+ * funnel through one `AuthoringBackend.sendCdp`). Ports the pattern already
7
+ * proven in the cloud/Popcorn authoring backend
8
+ * (`packages/app/src/author/backend.ts` `pageFetch`/`authenticatedFetch`),
9
+ * adapted to the MCP's single-page `sendCdp(method, params)` (no separate
10
+ * `pageSession` argument). The ANONYMOUS auth-bound check does NOT go through
11
+ * the page — see `replayProviderInPage`'s doc comment below for why.
12
+ */
13
+ import type { ReclaimProvider } from '../provider/schema.ts'
14
+ import {
15
+ buildReplayRequest,
16
+ evaluateReplay,
17
+ replayProvider,
18
+ type ReplayResult
19
+ } from './replay.ts'
20
+
21
+ /** Everything this module needs from an `AuthoringBackend` — just the raw CDP
22
+ * seam, so callers can pass a minimal object instead of a full backend. */
23
+ export interface PageReplayTarget {
24
+ sendCdp(method: string, params?: Record<string, unknown>): Promise<unknown>
25
+ }
26
+
27
+ interface PageFetchResult {
28
+ status?: number
29
+ body?: string
30
+ error?: string
31
+ }
32
+
33
+ /** Run `fetch(...)` for `provider`'s (already-templated) request inside the
34
+ * attached tab, via `Runtime.evaluate`. `credentials` selects the visitor:
35
+ * `'omit'` = anonymous (the auth-bound check), `'include'` = the logged-in
36
+ * user (the reproducibility gate). Same-origin only — the tab is already on
37
+ * the target site; a cross-origin/CORS failure comes back as `error`. */
38
+ async function pageFetch(
39
+ backend: PageReplayTarget,
40
+ provider: ReclaimProvider,
41
+ credentials: 'omit' | 'include',
42
+ extraSecrets: Record<string, string>,
43
+ ): Promise<PageFetchResult> {
44
+ const req = buildReplayRequest(provider, extraSecrets)
45
+ const expression = `(async () => {
46
+ try {
47
+ const r = await fetch(${JSON.stringify(req.url)}, {
48
+ method: ${JSON.stringify(req.method)},
49
+ headers: ${JSON.stringify(req.headers)},
50
+ credentials: ${JSON.stringify(credentials)},${req.body !== undefined
51
+ ? `\n\t\t\t\tbody: ${JSON.stringify(req.body)},`
52
+ : ''}
53
+ })
54
+ return { status: r.status, body: await r.text() }
55
+ } catch(e) { return { error: String((e && e.message) || e) } }
56
+ })()`
57
+ try {
58
+ const res = (await backend.sendCdp('Runtime.evaluate', {
59
+ expression,
60
+ awaitPromise: true,
61
+ returnByValue: true,
62
+ })) as {
63
+ result?: { value?: PageFetchResult }
64
+ exceptionDetails?: { exception?: { description?: string }, text?: string }
65
+ }
66
+ if(res.exceptionDetails) {
67
+ return {
68
+ error: res.exceptionDetails.exception?.description
69
+ ?? res.exceptionDetails.text
70
+ ?? 'page fetch evaluation error',
71
+ }
72
+ }
73
+
74
+ return res.result?.value ?? { error: 'page fetch returned no value' }
75
+ } catch(err) {
76
+ return { error: err instanceof Error ? err.message : String(err) }
77
+ }
78
+ }
79
+
80
+ /** Authenticated re-issue for the reproducibility gate. Cookies ride along
81
+ * via `credentials: 'include'` (a `Cookie` header can't be set from
82
+ * `fetch()` — it's a forbidden header name), so only NON-cookie secrets
83
+ * (Authorization, X-* tokens) are injected explicitly. */
84
+ function authenticatedPageFetch(
85
+ backend: PageReplayTarget,
86
+ provider: ReclaimProvider,
87
+ secrets: Record<string, string>,
88
+ ): Promise<PageFetchResult> {
89
+ const extra: Record<string, string> = {}
90
+ for(const [k, v] of Object.entries(secrets)) {
91
+ if(k.toLowerCase() !== 'cookie') {
92
+ extra[k] = v
93
+ }
94
+ }
95
+
96
+ return pageFetch(backend, provider, 'include', extra)
97
+ }
98
+
99
+ /**
100
+ * Replay `provider` and judge the response. Without `withoutSecrets`, replays
101
+ * as the logged-in session — run FROM INSIDE the live tab
102
+ * (`authenticatedPageFetch`) so cookies are the real browser's, which a
103
+ * detached call could never reproduce without manually re-deriving them.
104
+ * WITH `withoutSecrets` (the auth-bound check — "does this still work with
105
+ * no credentials, i.e. is it actually public"), replay instead through the
106
+ * detached Node-side `replayProvider`: the whole point of that check is to
107
+ * prove NO secret rides along, and the live page's own global `fetch` can't
108
+ * give that guarantee — a site (or a script installed via test_user_script)
109
+ * can wrap `fetch` and re-attach a token from localStorage/a service worker
110
+ * even under `credentials:'omit'`. A fresh, page-uninvolved fetch has no
111
+ * such surface.
112
+ */
113
+ export async function replayProviderInPage(
114
+ backend: PageReplayTarget,
115
+ provider: ReclaimProvider,
116
+ secrets: Record<string, string>,
117
+ opts?: { withoutSecrets?: boolean },
118
+ ): Promise<ReplayResult> {
119
+ if(opts?.withoutSecrets) {
120
+ return replayProvider(provider, {})
121
+ }
122
+
123
+ const result = await authenticatedPageFetch(backend, provider, secrets)
124
+ if(result.error !== undefined || result.status === undefined) {
125
+ throw new Error(
126
+ `in-browser replay failed: ${result.error ?? 'no response'}`,
127
+ )
128
+ }
129
+
130
+ return evaluateReplay(result.status, result.body ?? '', provider)
131
+ }
@@ -9,7 +9,7 @@ export interface ReplayResult {
9
9
 
10
10
  /** Expand `{{name}}` placeholders from `paramValues`. Matches the attestor
11
11
  * SDK's behavior so replay and proof both hit the same final URL. */
12
- function expandTemplate(
12
+ export function expandTemplate(
13
13
  s: string,
14
14
  params: Record<string, string> | undefined,
15
15
  ): string {
@@ -23,10 +23,22 @@ function expandTemplate(
23
23
  })
24
24
  }
25
25
 
26
- export async function replayProvider(
26
+ /** Build the request the wire replay actually sends: `{{name}}` placeholders
27
+ * in the URL/headers/body expanded from `provider.paramValues`, secrets
28
+ * layered onto the public headers. Shared by every replay transport (Node
29
+ * `fetch` here, or a `fetch` run inside the live browser tab) so they all
30
+ * send the identical request. */
31
+ export interface ReplayRequest {
32
+ url: string
33
+ method: string
34
+ headers: Record<string, string>
35
+ body?: string
36
+ }
37
+
38
+ export function buildReplayRequest(
27
39
  provider: ReclaimProvider,
28
40
  secrets: Record<string, string>,
29
- ): Promise<ReplayResult> {
41
+ ): ReplayRequest {
30
42
  const params = provider.paramValues
31
43
  const url = expandTemplate(provider.url, params)
32
44
  const expandedHeaders: Record<string, string> = {}
@@ -35,23 +47,35 @@ export async function replayProvider(
35
47
  }
36
48
 
37
49
  const headers = { ...expandedHeaders, ...secrets }
38
- const fetchInit: RequestInit = {
50
+ const body = provider.body !== undefined
51
+ ? expandTemplate(provider.body, params)
52
+ : undefined
53
+ return {
54
+ url,
39
55
  method: provider.method,
40
56
  headers,
41
- redirect: 'manual',
42
- }
43
- if(provider.body !== undefined) {
44
- fetchInit.body = expandTemplate(provider.body, params)
57
+ ...(body !== undefined ? { body } : {}),
45
58
  }
59
+ }
46
60
 
47
- const res = await fetch(url, fetchInit)
48
- const body = await res.text()
61
+ /**
62
+ * Judge a replayed response against `provider`'s matchers — the part of
63
+ * replay that's identical regardless of HOW the request was sent (detached
64
+ * Node `fetch` or a live in-browser `fetch`). Separated from the actual
65
+ * network call so both transports share one evaluation.
66
+ */
67
+ export function evaluateReplay(
68
+ status: number,
69
+ body: string,
70
+ provider: ReclaimProvider,
71
+ ): ReplayResult {
72
+ const params = provider.paramValues
49
73
  const hints: string[] = []
50
- if(res.status === 401 || res.status === 403) {
74
+ if(status === 401 || status === 403) {
51
75
  hints.push('auth/permission failed')
52
76
  }
53
77
 
54
- if(res.status >= 500) {
78
+ if(status >= 500) {
55
79
  hints.push('upstream server error')
56
80
  }
57
81
 
@@ -66,18 +90,14 @@ export async function replayProvider(
66
90
  value: expandTemplate(m.value, params),
67
91
  }
68
92
  if(!matchesBody(body, expanded)) {
69
- if(res.status >= 200 && res.status < 300) {
93
+ if(status >= 200 && status < 300) {
70
94
  hints.push(
71
95
  `matcher ${expanded.type} did not match — ` +
72
96
  'value may have changed or matcher is too tight',
73
97
  )
74
98
  }
75
99
 
76
- const result: ReplayResult = {
77
- status: res.status,
78
- body,
79
- matchesOriginal: false,
80
- }
100
+ const result: ReplayResult = { status, body, matchesOriginal: false }
81
101
  if(hints.length > 0) {
82
102
  result.hints = hints
83
103
  }
@@ -86,7 +106,28 @@ export async function replayProvider(
86
106
  }
87
107
  }
88
108
 
89
- return { status: res.status, body, matchesOriginal: res.status < 400 }
109
+ return { status, body, matchesOriginal: status < 400 }
110
+ }
111
+
112
+ /** Detached Node-side replay: sends the request directly from this process,
113
+ * NOT from any attached browser tab — its cookies/secrets are exactly the
114
+ * `secrets` passed in, never a real browser session. Used by tests and by
115
+ * any caller with no live browser to replay through (the MCP's authoring
116
+ * backend instead runs the SAME request inside the attached tab — see
117
+ * `proof/page-replay.ts` — so replay reflects real session cookies). */
118
+ export async function replayProvider(
119
+ provider: ReclaimProvider,
120
+ secrets: Record<string, string>,
121
+ ): Promise<ReplayResult> {
122
+ const req = buildReplayRequest(provider, secrets)
123
+ const res = await fetch(req.url, {
124
+ method: req.method,
125
+ headers: req.headers,
126
+ redirect: 'manual',
127
+ ...(req.body !== undefined ? { body: req.body } : {}),
128
+ })
129
+ const body = await res.text()
130
+ return evaluateReplay(res.status, body, provider)
90
131
  }
91
132
 
92
133
  function matchesBody(body: string, m: ResponseMatch): boolean {
@@ -275,12 +275,45 @@ function deriveParamName(providerName: string): string {
275
275
  return last.toLowerCase()
276
276
  }
277
277
 
278
+ /** A `paramName` doubles as a regex NAMED CAPTURE GROUP (`(?<name>...)`) and a
279
+ * `paramValues` object key — both require a plain identifier. Nothing
280
+ * downstream validates this: an invalid name builds a syntactically broken
281
+ * regex that silently fails to extract at verification time instead of
282
+ * erroring here, at draft time, where it's actually fixable. */
283
+ const VALID_PARAM_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
284
+
285
+ /** Resolve a target's `paramName`: shortened (the default — last alphanumeric
286
+ * token, lowercased) or, with `shorten: false`, the name verbatim — the
287
+ * caller's choice, validated so it's still safe as a capture-group
288
+ * identifier. */
289
+ function resolveParamName(targetName: string, shorten: boolean): string {
290
+ if(!shorten) {
291
+ if(!VALID_PARAM_NAME.test(targetName)) {
292
+ throw new Error(
293
+ `target name "${targetName}" isn't safe as a param name with `
294
+ + 'shortenNames:false — it becomes a regex capture-group name AND '
295
+ + 'a paramValues key, so it must start with a letter/underscore and '
296
+ + 'contain only letters, digits, and underscores (we recommend '
297
+ + 'lower_snake_case, e.g. "employer_name"). Rename it, or drop '
298
+ + '`shortenNames: false` to auto-derive a safe one.',
299
+ )
300
+ }
301
+
302
+ return targetName
303
+ }
304
+
305
+ return deriveParamName(targetName)
306
+ }
307
+
278
308
  export function draftProvider(
279
309
  req: CapturedRequest,
280
310
  targets: DraftTarget[],
281
311
  name: string,
282
312
  hash?: OprfMode,
313
+ opts?: { shortenNames?: boolean },
283
314
  ): DraftResult {
315
+ const shortenNames = opts?.shortenNames ?? true
316
+
284
317
  if(!req.responseBody) {
285
318
  throw new Error(`captured request ${req.requestId} has no response body`)
286
319
  }
@@ -297,17 +330,24 @@ export function draftProvider(
297
330
  const extractions: Extraction[] = []
298
331
  const effectiveHashes: (OprfMode | undefined)[] = []
299
332
  let paramValues: Record<string, string> | undefined
300
- const seenParams = new Set<string>()
333
+ const seenParams = new Map<string, string>()
301
334
  for(const t of targets) {
302
- const paramName = deriveParamName(t.name)
303
- if(seenParams.has(paramName)) {
335
+ const paramName = resolveParamName(t.name, shortenNames)
336
+ const priorName = seenParams.get(paramName)
337
+ if(priorName !== undefined) {
304
338
  throw new Error(
305
- `two targets resolve to the same param "${paramName}"; `
306
- + 'give them distinct names',
339
+ `targets "${priorName}" and "${t.name}" both resolve to the same `
340
+ + `param name "${paramName}"`
341
+ + (shortenNames
342
+ ? ' (names are shortened to their last alphanumeric token) — '
343
+ + 'give them more distinct names, e.g. "firstName"/"surname" '
344
+ + 'instead of "first_name"/"last_name"'
345
+ : ' — give them distinct names, or drop `shortenNames: false` '
346
+ + 'to auto-derive distinct ones'),
307
347
  )
308
348
  }
309
349
 
310
- seenParams.add(paramName)
350
+ seenParams.set(paramName, t.name)
311
351
  // Precedence: explicit per-target hash → explicit request-wide hash →
312
352
  // the anti-leak floor (force-OPRF values whose SHAPE is unambiguous PII).
313
353
  // The model makes the contextual calls (which named fields are sensitive)