@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.
- package/lib/authoring/backend.d.ts +9 -7
- package/lib/authoring/tools.js +98 -1
- package/lib/mcp/tools/agent/attach.js +29 -1
- package/lib/mcp/tools/agent/propose.js +27 -3
- package/lib/mcp/tools/agent/publish.js +7 -5
- package/lib/mcp/tools/agent/shared.js +17 -8
- package/lib/mcp/tools/authenticate/authenticate.d.ts +11 -0
- package/lib/mcp/tools/authenticate/authenticate.js +128 -119
- package/lib/mcp/tools/authenticate/builder-access.d.ts +8 -3
- package/lib/mcp/tools/authenticate/builder-access.js +8 -3
- package/lib/mcp/tools/mode/mode.js +32 -1
- package/lib/old/client.d.ts +9 -0
- package/lib/old/client.js +19 -1
- package/lib/old/to-register.d.ts +17 -6
- package/lib/old/to-register.js +6 -0
- package/lib/old/tools/me-providers.js +3 -1
- package/lib/old/tools/publish.js +82 -38
- package/lib/proof/page-replay.d.ts +36 -0
- package/lib/proof/page-replay.js +76 -0
- package/lib/proof/replay.d.ts +28 -0
- package/lib/proof/replay.js +39 -18
- package/lib/provider/draft.d.ts +3 -1
- package/lib/provider/draft.js +39 -7
- package/lib/provider/matcher.js +116 -7
- package/lib/provider/to-version.d.ts +8 -6
- package/lib/provider/to-version.js +15 -3
- package/package.json +3 -2
- package/skill/SKILL.builder.md +4 -2
- package/skill/SKILL.common.md +10 -1
- package/skill/SKILL.old.md +1 -1
- package/skill/injection-api.md +44 -15
- package/skill/old-mode.md +3 -1
- package/src/authoring/backend.ts +9 -7
- package/src/authoring/tools.ts +119 -1
- package/src/mcp/tools/agent/attach.ts +40 -1
- package/src/mcp/tools/agent/propose.ts +29 -3
- package/src/mcp/tools/agent/publish.ts +7 -6
- package/src/mcp/tools/agent/shared.ts +19 -8
- package/src/mcp/tools/authenticate/authenticate.ts +150 -163
- package/src/mcp/tools/authenticate/builder-access.ts +8 -3
- package/src/mcp/tools/mode/mode.ts +34 -1
- package/src/old/client.ts +30 -1
- package/src/old/to-register.ts +25 -6
- package/src/old/tools/me-providers.ts +3 -1
- package/src/old/tools/publish.ts +86 -40
- package/src/proof/page-replay.ts +131 -0
- package/src/proof/replay.ts +60 -19
- package/src/provider/draft.ts +46 -6
- package/src/provider/matcher.ts +145 -5
- package/src/provider/to-version.ts +24 -9
package/src/old/tools/publish.ts
CHANGED
|
@@ -99,12 +99,15 @@ export function createVersionFromCaptureTool(
|
|
|
99
99
|
stepsToFollow?: string
|
|
100
100
|
useIncognitoWebview?: boolean
|
|
101
101
|
extensionConfig?: unknown
|
|
102
|
-
|
|
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
|
-
'
|
|
126
|
-
'
|
|
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
|
|
143
|
-
'
|
|
144
|
-
'
|
|
145
|
-
'
|
|
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
|
-
|
|
462
|
-
type: '
|
|
470
|
+
injectionType: {
|
|
471
|
+
type: 'string',
|
|
472
|
+
enum: ['NONE', 'MSWJS', 'XHOOK', 'CDP', 'HAWKEYE'],
|
|
463
473
|
description:
|
|
464
|
-
'
|
|
465
|
-
'
|
|
466
|
-
'
|
|
467
|
-
'
|
|
468
|
-
'
|
|
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
|
-
|
|
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,
|
|
562
|
-
//
|
|
563
|
-
//
|
|
564
|
-
//
|
|
565
|
-
|
|
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
|
-
|
|
581
|
-
|
|
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
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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
|
-
|
|
629
|
+
opts,
|
|
601
630
|
followVersion,
|
|
602
631
|
DEFAULT_VERSION_INFO,
|
|
603
632
|
)
|
|
604
633
|
await client.updateProviderConfig(newProviderId, configBody)
|
|
605
634
|
notes.push(
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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
|
-
}
|
|
644
|
+
} catch(err) {
|
|
645
|
+
const detail = isBoom(err) ? err.message : String(err)
|
|
612
646
|
notes.push(
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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
|
+
}
|
package/src/proof/replay.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
):
|
|
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
|
|
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
|
-
|
|
42
|
-
}
|
|
43
|
-
if(provider.body !== undefined) {
|
|
44
|
-
fetchInit.body = expandTemplate(provider.body, params)
|
|
57
|
+
...(body !== undefined ? { body } : {}),
|
|
45
58
|
}
|
|
59
|
+
}
|
|
46
60
|
|
|
47
|
-
|
|
48
|
-
|
|
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(
|
|
74
|
+
if(status === 401 || status === 403) {
|
|
51
75
|
hints.push('auth/permission failed')
|
|
52
76
|
}
|
|
53
77
|
|
|
54
|
-
if(
|
|
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(
|
|
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
|
|
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 {
|
package/src/provider/draft.ts
CHANGED
|
@@ -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
|
|
333
|
+
const seenParams = new Map<string, string>()
|
|
301
334
|
for(const t of targets) {
|
|
302
|
-
const paramName =
|
|
303
|
-
|
|
335
|
+
const paramName = resolveParamName(t.name, shortenNames)
|
|
336
|
+
const priorName = seenParams.get(paramName)
|
|
337
|
+
if(priorName !== undefined) {
|
|
304
338
|
throw new Error(
|
|
305
|
-
`
|
|
306
|
-
+
|
|
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.
|
|
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)
|