@reclaimprotocol/agent 0.1.22 → 0.1.24
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/README.md +55 -20
- package/lib/authoring/backend.d.ts +13 -10
- package/lib/authoring/tools.js +106 -9
- package/lib/cdp/browser-proxy.d.ts +78 -0
- package/lib/cdp/browser-proxy.js +186 -0
- package/lib/cdp/capture-engine.js +8 -7
- package/lib/cdp/client.d.ts +10 -0
- package/lib/cdp/geo.d.ts +10 -0
- package/lib/cdp/geo.js +36 -0
- package/lib/cdp/index.d.ts +1 -0
- package/lib/cdp/index.js +1 -0
- package/lib/live/cloudflared.d.ts +15 -0
- package/lib/live/cloudflared.js +268 -0
- package/lib/live/page.d.ts +20 -0
- package/lib/live/page.js +134 -0
- package/lib/live/tunnel.d.ts +23 -0
- package/lib/live/tunnel.js +120 -0
- package/lib/live/viewer.d.ts +28 -0
- package/lib/live/viewer.js +232 -0
- package/lib/mcp/api/operations.gen.js +34 -26
- package/lib/mcp/api/to-zod.d.ts +1 -1
- package/lib/mcp/api/to-zod.js +1 -1
- package/lib/mcp/api/tools.js +1 -1
- package/lib/mcp/instructions.d.ts +15 -2
- package/lib/mcp/instructions.js +101 -38
- package/lib/mcp/start.js +4 -3
- package/lib/mcp/tools/agent/analyze.js +5 -1
- package/lib/mcp/tools/agent/attach.d.ts +8 -1
- package/lib/mcp/tools/agent/attach.js +166 -22
- package/lib/mcp/tools/agent/build.d.ts +13 -8
- package/lib/mcp/tools/agent/build.js +22 -11
- package/lib/mcp/tools/agent/capture.js +5 -1
- package/lib/mcp/tools/agent/eval.js +3 -1
- package/lib/mcp/tools/agent/find.js +6 -5
- package/lib/mcp/tools/agent/how-it-works.d.ts +15 -7
- package/lib/mcp/tools/agent/how-it-works.js +57 -17
- package/lib/mcp/tools/agent/live-view.d.ts +21 -0
- package/lib/mcp/tools/agent/live-view.js +153 -0
- package/lib/mcp/tools/agent/pod-proof.d.ts +8 -0
- package/lib/mcp/tools/agent/pod-proof.js +64 -0
- package/lib/mcp/tools/agent/propose.js +29 -3
- package/lib/mcp/tools/agent/proxy.d.ts +3 -0
- package/lib/mcp/tools/agent/proxy.js +199 -0
- package/lib/mcp/tools/agent/publish.js +49 -77
- package/lib/mcp/tools/agent/session.d.ts +2 -1
- package/lib/mcp/tools/agent/session.js +26 -7
- 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 +143 -121
- 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.d.ts +1 -1
- package/lib/mcp/tools/mode/mode.js +26 -7
- package/lib/old/client.d.ts +9 -0
- package/lib/old/client.js +19 -1
- package/lib/old/instructions.d.ts +9 -1
- package/lib/old/instructions.js +4 -6
- package/lib/old/login-cdp.d.ts +2 -2
- package/lib/old/login-cdp.js +46 -6
- package/lib/old/to-register.d.ts +20 -8
- package/lib/old/to-register.js +7 -1
- package/lib/old/tools/authenticate.js +9 -1
- package/lib/old/tools/get-provider-info.js +6 -5
- package/lib/old/tools/issue-app-credentials.js +3 -2
- package/lib/old/tools/me-providers.js +3 -1
- package/lib/old/tools/publish.d.ts +1 -1
- package/lib/old/tools/publish.js +132 -129
- package/lib/old/tools/update-provider-metadata.d.ts +1 -1
- package/lib/old/tools/update-provider-metadata.js +1 -1
- package/lib/old/tools/verify-sdk-setup.js +1 -1
- package/lib/proof/attestor.d.ts +3 -3
- package/lib/proof/debug-response-dump.js +4 -4
- package/lib/proof/index.d.ts +1 -0
- package/lib/proof/index.js +1 -0
- package/lib/proof/page-replay.d.ts +36 -0
- package/lib/proof/page-replay.js +76 -0
- package/lib/proof/pod-prover.d.ts +47 -0
- package/lib/proof/pod-prover.js +155 -0
- package/lib/proof/replay.d.ts +28 -0
- package/lib/proof/replay.js +39 -18
- package/lib/provider/dom-xpath.js +11 -9
- package/lib/provider/draft.d.ts +13 -8
- package/lib/provider/draft.js +47 -13
- package/lib/provider/injection-doc.d.ts +23 -1
- package/lib/provider/injection-doc.js +34 -21
- package/lib/provider/match-redaction-warning.d.ts +2 -2
- package/lib/provider/match-redaction-warning.js +3 -3
- package/lib/provider/matcher.js +122 -12
- package/lib/provider/secrets.d.ts +3 -3
- package/lib/provider/secrets.js +3 -3
- package/lib/provider/to-version.d.ts +12 -9
- package/lib/provider/to-version.js +16 -4
- package/lib/tools/registry.d.ts +5 -5
- package/lib/tools/registry.js +5 -5
- package/package.json +4 -3
- package/samples/demo-server/README.md +7 -6
- package/skill/SKILL.builder.md +356 -189
- package/skill/SKILL.common.md +314 -79
- package/skill/SKILL.old.md +340 -155
- package/skill/builder-mode.md +58 -17
- package/skill/hash-validation.md +170 -0
- package/skill/interception.md +235 -0
- package/skill/js-sdk-integration.md +13 -7
- package/skill/old-mode.md +53 -12
- package/skill/user-script.md +61 -0
- package/src/authoring/backend.ts +13 -10
- package/src/authoring/tools.ts +128 -9
- package/src/cdp/capture-engine.ts +8 -7
- package/src/cdp/client.ts +8 -0
- package/src/cdp/geo.ts +52 -0
- package/src/cdp/index.ts +1 -0
- package/src/consts.ts +1 -0
- package/src/live/cloudflared.ts +348 -0
- package/src/live/page.ts +145 -0
- package/src/live/tunnel.ts +157 -0
- package/src/live/viewer.ts +293 -0
- package/src/mcp/api/operations.gen.ts +34 -26
- package/src/mcp/api/to-zod.ts +1 -1
- package/src/mcp/api/tools.ts +1 -1
- package/src/mcp/instructions.ts +121 -48
- package/src/mcp/start.ts +4 -3
- package/src/mcp/tools/agent/analyze.ts +5 -1
- package/src/mcp/tools/agent/attach.ts +194 -22
- package/src/mcp/tools/agent/build.ts +26 -15
- package/src/mcp/tools/agent/capture.ts +5 -1
- package/src/mcp/tools/agent/eval.ts +4 -1
- package/src/mcp/tools/agent/find.ts +6 -5
- package/src/mcp/tools/agent/how-it-works.ts +62 -21
- package/src/mcp/tools/agent/live-view.ts +198 -0
- package/src/mcp/tools/agent/pod-proof.ts +90 -0
- package/src/mcp/tools/agent/propose.ts +31 -3
- package/src/mcp/tools/agent/publish.ts +54 -78
- package/src/mcp/tools/agent/session.ts +26 -5
- package/src/mcp/tools/agent/shared.ts +19 -8
- package/src/mcp/tools/authenticate/authenticate.ts +167 -165
- package/src/mcp/tools/authenticate/builder-access.ts +8 -3
- package/src/mcp/tools/mode/mode.ts +28 -7
- package/src/old/client.ts +30 -1
- package/src/old/instructions.ts +4 -10
- package/src/old/login-cdp.ts +50 -6
- package/src/old/to-register.ts +28 -8
- package/src/old/tools/authenticate.ts +9 -1
- package/src/old/tools/get-provider-info.ts +6 -5
- package/src/old/tools/issue-app-credentials.ts +3 -2
- package/src/old/tools/me-providers.ts +3 -1
- package/src/old/tools/publish.ts +140 -130
- package/src/old/tools/update-provider-metadata.ts +1 -1
- package/src/old/tools/verify-sdk-setup.ts +1 -1
- package/src/proof/attestor.ts +3 -3
- package/src/proof/debug-response-dump.ts +4 -4
- package/src/proof/index.ts +8 -0
- package/src/proof/page-replay.ts +131 -0
- package/src/proof/pod-prover.ts +231 -0
- package/src/proof/replay.ts +60 -19
- package/src/provider/dom-xpath.ts +11 -9
- package/src/provider/draft.ts +56 -13
- package/src/provider/injection-doc.ts +37 -24
- package/src/provider/match-redaction-warning.ts +3 -3
- package/src/provider/matcher.ts +151 -10
- package/src/provider/secrets.ts +3 -3
- package/src/provider/to-version.ts +30 -13
- package/src/tools/registry.ts +5 -5
- package/skill/injection-api.md +0 -335
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PopcornPodProver — run the zkTLS proof inside a Popcorn browser session's
|
|
3
|
+
* OWN TEE pod instead of the in-process attestor SDK (`runAttestorProof`).
|
|
4
|
+
* POSTs the recipe + captured secrets to the pod's `/reclaim/prove` (the same
|
|
5
|
+
* attestor TEE+MPC protocol) and re-shapes the response into the shared
|
|
6
|
+
* {@link ProofResult}, so every caller consumes proof results identically
|
|
7
|
+
* regardless of which path produced them.
|
|
8
|
+
*
|
|
9
|
+
* This is the pod's OWN api for creating a proof from a live session — used
|
|
10
|
+
* by both the app's server-driven verification runner and the MCP's
|
|
11
|
+
* `run_proof_via_pod` tool. Proving here (rather than locally) matters
|
|
12
|
+
* specifically when the session is country-routed: Popcorn allocated the
|
|
13
|
+
* browser with an exit country, and this is the SAME enclave holding that
|
|
14
|
+
* browser's TLS session, so the proof reaches the target from the identical
|
|
15
|
+
* exit IP the browser used — a local attestor SDK proof runs from the
|
|
16
|
+
* app/agent's OWN network instead.
|
|
17
|
+
*
|
|
18
|
+
* Contract (verified against ../popcorn-images oapi.go + ../reclaim-tee):
|
|
19
|
+
* POST {apiUrl}/reclaim/prove body { provider_params_json, config_json? }
|
|
20
|
+
* provider_params_json = JSON of { name, params, secretParams, context? }
|
|
21
|
+
* → 200 { session_id, claim, signature } (ReclaimProveResult, snake_case):
|
|
22
|
+
* claim has provider/parameters/owner/timestamp_s/context/identifier/epoch;
|
|
23
|
+
* signature has attestor_address + claim_signature(b64) +
|
|
24
|
+
* result_signature(b64). A proof failure/timeout is a 500 whose `message`
|
|
25
|
+
* carries the reason.
|
|
26
|
+
*
|
|
27
|
+
* The pod API is unauthenticated (gateway/session is the trust boundary); call
|
|
28
|
+
* it over the same internal host used for CDP. Consumers verify
|
|
29
|
+
* `claim_signature` only (see `@reclaimprotocol/client`'s `verifyNestedProof`),
|
|
30
|
+
* so this hands back a {@link AttestorClaim} whose hex `claimSignature` the
|
|
31
|
+
* shared `wrapProofAsJws` (app-side) turns into the nested result proof.
|
|
32
|
+
* `result_signature` is unused by any consumer.
|
|
33
|
+
*/
|
|
34
|
+
import { bytesToHex } from '@noble/hashes/utils.js'
|
|
35
|
+
import type { ProviderParams } from '@reclaimprotocol/attestor-core'
|
|
36
|
+
import type { AttestorClaim, ProofResult } from './attestor.ts'
|
|
37
|
+
|
|
38
|
+
/** Attestor HTTP provider params — the recipe the agent synthesis produces. */
|
|
39
|
+
export type HttpParams = ProviderParams<'http'>
|
|
40
|
+
|
|
41
|
+
/** Captured secrets for the replay, exactly as `/reclaim/prove` wants them. */
|
|
42
|
+
export interface HttpSecretParams {
|
|
43
|
+
cookieStr?: string
|
|
44
|
+
authorisationHeader?: string
|
|
45
|
+
headers?: Record<string, string>
|
|
46
|
+
paramValues?: Record<string, string>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The pod's `/reclaim/prove` 200 body (ReclaimProveResult, snake_case; fields
|
|
51
|
+
* optional because we validate). Note `timestamp_s` and the whole `signature`
|
|
52
|
+
* object are snake_case; matching them is exactly what fixes the false
|
|
53
|
+
* "no claim/signature".
|
|
54
|
+
*/
|
|
55
|
+
interface ReclaimProveResponse {
|
|
56
|
+
session_id?: string
|
|
57
|
+
claim?: {
|
|
58
|
+
provider?: string
|
|
59
|
+
parameters?: string
|
|
60
|
+
owner?: string
|
|
61
|
+
timestamp_s?: number
|
|
62
|
+
context?: string
|
|
63
|
+
identifier?: string
|
|
64
|
+
epoch?: number
|
|
65
|
+
}
|
|
66
|
+
signature?: {
|
|
67
|
+
attestor_address?: string
|
|
68
|
+
claim_signature?: string
|
|
69
|
+
result_signature?: string
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface PopcornProveOptions {
|
|
74
|
+
/** Pod API base (the session's `apiUrl`). */
|
|
75
|
+
apiUrl: string
|
|
76
|
+
params: HttpParams
|
|
77
|
+
secretParams: HttpSecretParams
|
|
78
|
+
/** Session context, stringified into the claim context if given. */
|
|
79
|
+
context?: Record<string, unknown>
|
|
80
|
+
fetchImpl?: typeof fetch
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface ValidateExtractionResult {
|
|
84
|
+
valid: boolean
|
|
85
|
+
extractedValue?: string
|
|
86
|
+
error?: string
|
|
87
|
+
steps?: string[]
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The cloud "replay": validate that a drafted recipe's selectors extract the
|
|
92
|
+
* expected value from a captured response, inside the TEE — `POST {apiUrl}
|
|
93
|
+
* /reclaim/validate-extraction`. Reuses the prover's own redaction engine, so
|
|
94
|
+
* a pass means the selectors won't diverge at proof time. No proof/secrets.
|
|
95
|
+
*/
|
|
96
|
+
export async function popcornValidateExtraction(opts: {
|
|
97
|
+
apiUrl: string
|
|
98
|
+
responseBody: string
|
|
99
|
+
expectedValue: string
|
|
100
|
+
xPath?: string
|
|
101
|
+
jsonPath?: string
|
|
102
|
+
regex?: string
|
|
103
|
+
fetchImpl?: typeof fetch
|
|
104
|
+
}): Promise<ValidateExtractionResult> {
|
|
105
|
+
const doFetch = opts.fetchImpl ?? fetch
|
|
106
|
+
const base = opts.apiUrl.replace(/\/$/, '')
|
|
107
|
+
try {
|
|
108
|
+
const res = await doFetch(`${base}/reclaim/validate-extraction`, {
|
|
109
|
+
method: 'POST',
|
|
110
|
+
headers: { 'Content-Type': 'application/json' },
|
|
111
|
+
body: JSON.stringify({
|
|
112
|
+
responseBody: opts.responseBody,
|
|
113
|
+
expectedValue: opts.expectedValue,
|
|
114
|
+
...(opts.xPath ? { xPath: opts.xPath } : {}),
|
|
115
|
+
...(opts.jsonPath ? { jsonPath: opts.jsonPath } : {}),
|
|
116
|
+
...(opts.regex ? { regex: opts.regex } : {}),
|
|
117
|
+
}),
|
|
118
|
+
})
|
|
119
|
+
if(!res.ok) {
|
|
120
|
+
const detail = await res.text().catch(() => '')
|
|
121
|
+
return { valid: false, error: `${res.status}: ${detail.slice(0, 300)}` }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const body = (await res.json()) as ValidateExtractionResult
|
|
125
|
+
return body
|
|
126
|
+
} catch(err) {
|
|
127
|
+
const error = err instanceof Error ? err.message : String(err)
|
|
128
|
+
return { valid: false, error }
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Decode standard base64 (Go `base64.StdEncoding` from Popcorn) to bytes. */
|
|
133
|
+
function b64ToBytes(b64: string): Uint8Array {
|
|
134
|
+
return new Uint8Array(Buffer.from(b64, 'base64'))
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Prove a single request through the Popcorn TEE. Returns the shared
|
|
139
|
+
* {@link ProofResult}; on any failure `verified` is false and `error` carries
|
|
140
|
+
* the reason (never throws for an expected proof failure).
|
|
141
|
+
*/
|
|
142
|
+
export async function popcornProve(
|
|
143
|
+
opts: PopcornProveOptions,
|
|
144
|
+
): Promise<ProofResult> {
|
|
145
|
+
const { apiUrl, params, secretParams, context } = opts
|
|
146
|
+
const doFetch = opts.fetchImpl ?? fetch
|
|
147
|
+
const base = apiUrl.replace(/\/$/, '')
|
|
148
|
+
|
|
149
|
+
const providerParamsJson = JSON.stringify({
|
|
150
|
+
name: 'http',
|
|
151
|
+
params,
|
|
152
|
+
secretParams,
|
|
153
|
+
...(context ? { context: JSON.stringify(context) } : {}),
|
|
154
|
+
})
|
|
155
|
+
const fail = (error: string): ProofResult => ({
|
|
156
|
+
proof: undefined,
|
|
157
|
+
extractedValue: '',
|
|
158
|
+
extractedParameters: {},
|
|
159
|
+
verified: false,
|
|
160
|
+
error,
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
let resp: ReclaimProveResponse
|
|
164
|
+
let raw = ''
|
|
165
|
+
try {
|
|
166
|
+
const res = await doFetch(`${base}/reclaim/prove`, {
|
|
167
|
+
method: 'POST',
|
|
168
|
+
headers: { 'Content-Type': 'application/json' },
|
|
169
|
+
body: JSON.stringify({ provider_params_json: providerParamsJson }),
|
|
170
|
+
})
|
|
171
|
+
// Read the body as text first: a proof failure/timeout is a 500 whose
|
|
172
|
+
// `message` we want to surface verbatim, and on an unexpected 200 shape we
|
|
173
|
+
// echo the raw body instead of a blind "no claim/signature".
|
|
174
|
+
raw = await res.text().catch(() => '')
|
|
175
|
+
if(!res.ok) {
|
|
176
|
+
return fail(`/reclaim/prove ${res.status}: ${raw.slice(0, 300)}`)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
resp = JSON.parse(raw) as ReclaimProveResponse
|
|
180
|
+
} catch(err) {
|
|
181
|
+
return fail(err instanceof Error ? err.message : String(err))
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const claim = resp.claim
|
|
185
|
+
const sig = resp.signature
|
|
186
|
+
if(!claim || !sig?.attestor_address || !sig?.claim_signature) {
|
|
187
|
+
return fail(
|
|
188
|
+
'popcorn /reclaim/prove returned no claim/signature: '
|
|
189
|
+
+ raw.slice(0, 300),
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// claim.context is the JSON string carrying { extractedParameters, ... } —
|
|
194
|
+
// the same place the in-process path reads them from.
|
|
195
|
+
let extractedParameters: Record<string, string> = {}
|
|
196
|
+
if(typeof claim.context === 'string') {
|
|
197
|
+
try {
|
|
198
|
+
const ctx = JSON.parse(claim.context) as {
|
|
199
|
+
extractedParameters?: Record<string, string>
|
|
200
|
+
}
|
|
201
|
+
extractedParameters = ctx.extractedParameters ?? {}
|
|
202
|
+
} catch{
|
|
203
|
+
// leave empty if context isn't valid JSON
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const attestorClaim: AttestorClaim = {
|
|
208
|
+
claim: {
|
|
209
|
+
provider: claim.provider ?? '',
|
|
210
|
+
parameters: claim.parameters ?? '',
|
|
211
|
+
owner: claim.owner ?? '',
|
|
212
|
+
timestampS: claim.timestamp_s ?? 0,
|
|
213
|
+
context: claim.context ?? '',
|
|
214
|
+
identifier: claim.identifier ?? '',
|
|
215
|
+
epoch: claim.epoch ?? 0,
|
|
216
|
+
},
|
|
217
|
+
attestorAddress: sig.attestor_address,
|
|
218
|
+
// Consumer-side verification expects hex; Popcorn signs in base64.
|
|
219
|
+
claimSignature: '0x' + bytesToHex(b64ToBytes(sig.claim_signature)),
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
proof: resp,
|
|
224
|
+
verified: true,
|
|
225
|
+
extractedParameters,
|
|
226
|
+
extractedValue: Object.values(extractedParameters)[0] ?? '',
|
|
227
|
+
...(claim.identifier ? { identifier: claim.identifier } : {}),
|
|
228
|
+
...(claim.owner ? { owner: claim.owner } : {}),
|
|
229
|
+
attestorClaim,
|
|
230
|
+
}
|
|
231
|
+
}
|
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 {
|
|
@@ -183,9 +183,10 @@ export function elementAnchor(
|
|
|
183
183
|
/** The tightest element that DIRECTLY holds a target. Prefers a COMPLETE-value
|
|
184
184
|
* occurrence — an attribute whose value IS the target, or a text child that IS
|
|
185
185
|
* the target — over one where the target is merely embedded in a longer string
|
|
186
|
-
* (
|
|
187
|
-
* field is the semantic source and yields a portable anchor; the
|
|
188
|
-
* is incidental. Smallest byte span wins within the chosen
|
|
186
|
+
* (for example, a userid inside a `1234567+user@…` noreply address). The
|
|
187
|
+
* complete field is the semantic source and yields a portable anchor; the
|
|
188
|
+
* embedded one is incidental. Smallest byte span wins within the chosen
|
|
189
|
+
* tier. */
|
|
189
190
|
function findElement(root: Node, targets: string[]): Element | undefined {
|
|
190
191
|
const complete: Element[] = []
|
|
191
192
|
const embedded: Element[] = []
|
|
@@ -250,8 +251,8 @@ function* candidateXPaths(el: Element): Generator<Candidate> {
|
|
|
250
251
|
|
|
251
252
|
// A uniquely-identified DIRECT child pins the parent without a positional
|
|
252
253
|
// path (`//span[span[@id="labels"]]`). Self-verification discards it when the
|
|
253
|
-
// child id/attr isn't unique —
|
|
254
|
-
// — so it never silently matches the wrong node.
|
|
254
|
+
// child id/attr isn't unique — for example, GitHub reuses `id="labels"` per
|
|
255
|
+
// email row — so it never silently matches the wrong node.
|
|
255
256
|
for(const child of el.children) {
|
|
256
257
|
if(!isTag(child)) {
|
|
257
258
|
continue
|
|
@@ -295,8 +296,9 @@ function distinctiveClass(cls: string | undefined): string | undefined {
|
|
|
295
296
|
.sort((x, y) => y.length - x.length)[0]
|
|
296
297
|
}
|
|
297
298
|
|
|
298
|
-
/** Positional path from `anc` (exclusive) down to `el` (inclusive),
|
|
299
|
-
* `/ul[1]/li[3]/a[1]
|
|
299
|
+
/** Positional path from `anc` (exclusive) down to `el` (inclusive), for
|
|
300
|
+
* example `/ul[1]/li[3]/a[1]`. Empty string when `anc` is not an ancestor
|
|
301
|
+
* of `el`. */
|
|
300
302
|
function relativeSteps(anc: Element, el: Element): string {
|
|
301
303
|
const parts: string[] = []
|
|
302
304
|
let node: Node | null = el
|
|
@@ -392,8 +394,8 @@ function absolutePath(el: Element): string {
|
|
|
392
394
|
|
|
393
395
|
/** True when `expression` selects EXACTLY `el` (one match) against `doc`. The
|
|
394
396
|
* attestor applies the redaction regex to EVERY xPath match and throws if any
|
|
395
|
-
* fails, so a non-unique selector (
|
|
396
|
-
* unusable — it must resolve to the single target element. */
|
|
397
|
+
* fails, so a non-unique selector (for example, a class shared by sibling
|
|
398
|
+
* rows) is unusable — it must resolve to the single target element. */
|
|
397
399
|
function matchesUniquely(doc: unknown, expression: string, el: Node): boolean {
|
|
398
400
|
try {
|
|
399
401
|
const engine = xpath as unknown as {
|
package/src/provider/draft.ts
CHANGED
|
@@ -57,7 +57,8 @@ export interface DraftTarget {
|
|
|
57
57
|
|
|
58
58
|
/** The realized extraction for one target — its value paired with the
|
|
59
59
|
* redaction that reveals it (absent when matched literally). Lets a consumer
|
|
60
|
-
* validate each value against its own selector (
|
|
60
|
+
* validate each value against its own selector (for example, the cloud
|
|
61
|
+
* replay). */
|
|
61
62
|
export interface Extraction {
|
|
62
63
|
name: string
|
|
63
64
|
value: string
|
|
@@ -110,12 +111,14 @@ function sanitizeRequestHeaders(
|
|
|
110
111
|
* match or redaction — either a neighbouring target's value or a target's own
|
|
111
112
|
* value left un-templated. The matcher builds each target's window
|
|
112
113
|
* independently and drops the per-user WORDS it can see, but it cannot know a
|
|
113
|
-
* neighbouring value is itself user-specific (
|
|
114
|
-
* with an id — a `<b>{{id}}</b>` sitting beside a
|
|
115
|
-
* Cross-checking every value against every match here
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
114
|
+
* neighbouring value is itself user-specific (for example, a name sharing a
|
|
115
|
+
* text node with an id — a `<b>{{id}}</b>` sitting beside a
|
|
116
|
+
* `<td>{{name}}</td>`). Cross-checking every value against every match here
|
|
117
|
+
* catches
|
|
118
|
+
* that: a portable provider contains NO captured value as a literal — each
|
|
119
|
+
* appears only as its `{{param}}`. Throwing discards the draft so the agent
|
|
120
|
+
* re-captures or re-proposes rather than shipping a provider pinned to this
|
|
121
|
+
* one user. */
|
|
119
122
|
export function assertPortableMatches(
|
|
120
123
|
matches: ResponseMatch[],
|
|
121
124
|
redactions: ResponseRedaction[],
|
|
@@ -275,12 +278,45 @@ function deriveParamName(providerName: string): string {
|
|
|
275
278
|
return last.toLowerCase()
|
|
276
279
|
}
|
|
277
280
|
|
|
281
|
+
/** A `paramName` doubles as a regex NAMED CAPTURE GROUP (`(?<name>...)`) and a
|
|
282
|
+
* `paramValues` object key — both require a plain identifier. Nothing
|
|
283
|
+
* downstream validates this: an invalid name builds a syntactically broken
|
|
284
|
+
* regex that silently fails to extract at verification time instead of
|
|
285
|
+
* erroring here, at draft time, where it's actually fixable. */
|
|
286
|
+
const VALID_PARAM_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
287
|
+
|
|
288
|
+
/** Resolve a target's `paramName`: shortened (the default — last alphanumeric
|
|
289
|
+
* token, lowercased) or, with `shorten: false`, the name verbatim — the
|
|
290
|
+
* caller's choice, validated so it's still safe as a capture-group
|
|
291
|
+
* identifier. */
|
|
292
|
+
function resolveParamName(targetName: string, shorten: boolean): string {
|
|
293
|
+
if(!shorten) {
|
|
294
|
+
if(!VALID_PARAM_NAME.test(targetName)) {
|
|
295
|
+
throw new Error(
|
|
296
|
+
`target name "${targetName}" isn't safe as a param name with `
|
|
297
|
+
+ 'shortenNames:false — it becomes a regex capture-group name AND '
|
|
298
|
+
+ 'a paramValues key, so it must start with a letter/underscore and '
|
|
299
|
+
+ 'contain only letters, digits, and underscores (we recommend '
|
|
300
|
+
+ 'lower_snake_case, for example, "employer_name"). Rename it, or drop '
|
|
301
|
+
+ '`shortenNames: false` to auto-derive a safe one.',
|
|
302
|
+
)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return targetName
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return deriveParamName(targetName)
|
|
309
|
+
}
|
|
310
|
+
|
|
278
311
|
export function draftProvider(
|
|
279
312
|
req: CapturedRequest,
|
|
280
313
|
targets: DraftTarget[],
|
|
281
314
|
name: string,
|
|
282
315
|
hash?: OprfMode,
|
|
316
|
+
opts?: { shortenNames?: boolean },
|
|
283
317
|
): DraftResult {
|
|
318
|
+
const shortenNames = opts?.shortenNames ?? true
|
|
319
|
+
|
|
284
320
|
if(!req.responseBody) {
|
|
285
321
|
throw new Error(`captured request ${req.requestId} has no response body`)
|
|
286
322
|
}
|
|
@@ -297,17 +333,24 @@ export function draftProvider(
|
|
|
297
333
|
const extractions: Extraction[] = []
|
|
298
334
|
const effectiveHashes: (OprfMode | undefined)[] = []
|
|
299
335
|
let paramValues: Record<string, string> | undefined
|
|
300
|
-
const seenParams = new
|
|
336
|
+
const seenParams = new Map<string, string>()
|
|
301
337
|
for(const t of targets) {
|
|
302
|
-
const paramName =
|
|
303
|
-
|
|
338
|
+
const paramName = resolveParamName(t.name, shortenNames)
|
|
339
|
+
const priorName = seenParams.get(paramName)
|
|
340
|
+
if(priorName !== undefined) {
|
|
304
341
|
throw new Error(
|
|
305
|
-
`
|
|
306
|
-
+
|
|
342
|
+
`targets "${priorName}" and "${t.name}" both resolve to the same `
|
|
343
|
+
+ `param name "${paramName}"`
|
|
344
|
+
+ (shortenNames
|
|
345
|
+
? ' (names are shortened to their last alphanumeric token) — '
|
|
346
|
+
+ 'give them more distinct names, for example '
|
|
347
|
+
+ '"firstName"/"surname" instead of "first_name"/"last_name"'
|
|
348
|
+
: ' — give them distinct names, or drop `shortenNames: false` '
|
|
349
|
+
+ 'to auto-derive distinct ones'),
|
|
307
350
|
)
|
|
308
351
|
}
|
|
309
352
|
|
|
310
|
-
seenParams.
|
|
353
|
+
seenParams.set(paramName, t.name)
|
|
311
354
|
// Precedence: explicit per-target hash → explicit request-wide hash →
|
|
312
355
|
// the anti-leak floor (force-OPRF values whose SHAPE is unambiguous PII).
|
|
313
356
|
// The model makes the contextual calls (which named fields are sensitive)
|
|
@@ -1,28 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* the builder, `customInjection` on
|
|
4
|
-
*
|
|
5
|
-
* `skill/injection-api.md` so it can be edited as markdown without touching
|
|
6
|
-
* code — the same pattern as `mcp/instructions.ts`. The path resolves the same
|
|
7
|
-
* from `src/provider` (dev) and `lib/provider` (published): both are two levels
|
|
8
|
-
* below the package root.
|
|
2
|
+
* Short field hints for the user-script inputs on both publish tools
|
|
3
|
+
* (`webSettings.jsUserScripts` on the builder, `customInjection` on
|
|
4
|
+
* old-devtools).
|
|
9
5
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* `
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* recognition + authoring guidance for the remaining internals.
|
|
6
|
+
* These used to inline the whole `skill/injection-api.md` guide (~22 KB) into
|
|
7
|
+
* the tool's input schema. That guide is needed by a small minority of
|
|
8
|
+
* providers, but an input-schema description is sent to the model on EVERY
|
|
9
|
+
* request — it was roughly half the old-devtools tool surface on its own. The
|
|
10
|
+
* prose now lives in `skill/user-script.md`, `skill/interception.md` and
|
|
11
|
+
* `skill/hash-validation.md`, served on demand as `how_it_works` topics; what
|
|
12
|
+
* stays here is the pointer plus the one warning that must be seen BEFORE the
|
|
13
|
+
* field is filled in, since acting on it wrongly is worse than not acting.
|
|
14
|
+
*
|
|
15
|
+
* Shared by both publish tools so the two can't drift apart.
|
|
21
16
|
*/
|
|
22
|
-
import { readFileSync } from 'node:fs'
|
|
23
|
-
import { fileURLToPath } from 'node:url'
|
|
24
17
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
'
|
|
28
|
-
|
|
18
|
+
/** What the script is and where the real guidance lives. */
|
|
19
|
+
export const USER_SCRIPT_HINT =
|
|
20
|
+
'Plain JavaScript injected into the provider page before every page load. '
|
|
21
|
+
+ 'Use standard browser APIs (window.location, element.click(), forms) to '
|
|
22
|
+
+ 'drive login/navigation so the target request fires — the client already '
|
|
23
|
+
+ 'intercepts page requests, and window.Reclaim is only for rare edge cases, '
|
|
24
|
+
+ 'not navigation. Full guide: how_it_works({ topic: "user-script" }).'
|
|
25
|
+
|
|
26
|
+
/** Prefixes the `allowedJsRequests` / `allowedInjectedRequestData` field. The
|
|
27
|
+
* trap it describes is silent and total — a mis-declared entry makes
|
|
28
|
+
* `verifyProof({ providerId })` reject EVERY real proof — so the warning
|
|
29
|
+
* itself stays in the schema even though its explanation moved to a topic. */
|
|
30
|
+
export const ALLOWED_REQUESTS_HINT =
|
|
31
|
+
'⚠️ Only populate this if you intend to gate '
|
|
32
|
+
+ '`verifyProof(proof, { providerId })`: each entry feeds a client-side '
|
|
33
|
+
+ 'hash-equality check and its `url` is hashed VERBATIM (no regex/template '
|
|
34
|
+
+ 'resolution, even with urlType REGEX/TEMPLATE). If the real captured URL '
|
|
35
|
+
+ 'carries ANY per-session part (nonce, CSRF token, cache-busting '
|
|
36
|
+
+ 'timestamp), no declared spec can ever hash-match a real proof — '
|
|
37
|
+
+ 'populating it then makes verifyProof reject every real proof, which is '
|
|
38
|
+
+ 'worse than leaving it empty. Diff the same endpoint\'s captured URL '
|
|
39
|
+
+ 'across two sessions first. Mechanics, the body-templating rules and the '
|
|
40
|
+
+ 'dangerouslyDisableContentValidation fallback: '
|
|
41
|
+
+ 'how_it_works({ topic: "hash-validation" }).'
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* index and may not behave correctly when there are fewer matches than
|
|
7
7
|
* redactions. This is NOT enforced — the author keeps full control over what
|
|
8
8
|
* they publish in either old-devtools or builder mode. The tools surface these
|
|
9
|
-
* strings so the dev can make an informed decision (
|
|
10
|
-
* per redaction) rather than being blocked.
|
|
9
|
+
* strings so the dev can make an informed decision (for example, add a
|
|
10
|
+
* functional match per redaction) rather than being blocked.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
/** One request's match/redaction counts, with a human label for the message. */
|
|
@@ -26,7 +26,7 @@ export function matchRedactionWarnings(requests: RequestCounts[]): string[] {
|
|
|
26
26
|
warnings.push(
|
|
27
27
|
`${req.label}: ${req.matchCount} response match(es) but ` +
|
|
28
28
|
`${req.redactionCount} response redaction(s). Some verification ` +
|
|
29
|
-
'clients (
|
|
29
|
+
'clients (for example, the InApp SDK) may not work correctly when ' +
|
|
30
30
|
'responseMatches is shorter than responseRedactions — they pair the ' +
|
|
31
31
|
'two by index. This is allowed; add a functional match per redaction ' +
|
|
32
32
|
'if you hit verification issues.',
|