@reclaimprotocol/agent 0.1.9 → 0.1.11
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 +9 -111
- package/lib/authoring/tools.js +67 -0
- package/lib/mcp/tools/agent/find.js +41 -16
- package/lib/mcp/tools/agent/propose.js +4 -4
- package/lib/old/client.d.ts +0 -5
- package/lib/old/client.js +0 -10
- package/lib/old/to-register.d.ts +2 -0
- package/lib/old/to-register.js +9 -5
- package/lib/old/tools/build.js +0 -2
- package/lib/old/tools/get-provider-info.d.ts +10 -7
- package/lib/old/tools/get-provider-info.js +21 -15
- package/lib/old/tools/publish.js +64 -121
- package/lib/provider/dom-xpath.js +178 -25
- package/lib/provider/draft.d.ts +6 -0
- package/lib/provider/draft.js +54 -5
- package/lib/provider/matcher.d.ts +4 -0
- package/lib/provider/matcher.js +6 -0
- package/lib/provider/to-version.d.ts +13 -0
- package/package.json +2 -2
- package/src/authoring/tools.ts +76 -0
- package/src/mcp/tools/agent/find.ts +44 -16
- package/src/mcp/tools/agent/propose.ts +4 -4
- package/src/old/client.ts +0 -18
- package/src/old/to-register.ts +11 -5
- package/src/old/tools/build.ts +0 -2
- package/src/old/tools/get-provider-info.ts +21 -15
- package/src/old/tools/publish.ts +70 -154
- package/src/provider/dom-xpath.ts +227 -27
- package/src/provider/draft.ts +59 -5
- package/src/provider/matcher.ts +7 -0
- package/src/provider/to-version.ts +13 -0
- package/src/old/tools/get-version-config.ts +0 -56
package/lib/old/tools/publish.js
CHANGED
|
@@ -4,53 +4,6 @@ import { INJECTION_GUIDE } from "../../provider/injection-doc.js";
|
|
|
4
4
|
import { countsFor, matchRedactionWarnings, } from "../../provider/match-redaction-warning.js";
|
|
5
5
|
import { bumpPatchString, extractVersions, injectedRequestDataFrom, providerToConfigBody, providerToRegisterBody, } from "../to-register.js";
|
|
6
6
|
const DEFAULT_VERSION_INFO = 'Updated from MCP browser capture';
|
|
7
|
-
/** Fields with no partial-patch equivalent on this backend: a version always
|
|
8
|
-
* replaces the WHOLE `providerConfig`, so omitting one of these on
|
|
9
|
-
* add-version must carry forward the current value rather than publish it
|
|
10
|
-
* blank. `allowedInjectedRequestData` is handled alongside these (further
|
|
11
|
-
* down, inline) since it needs translation when freshly authored, unlike
|
|
12
|
-
* these — which pass through unchanged either way. */
|
|
13
|
-
const PRESERVABLE_KEYS = [
|
|
14
|
-
'customInjection',
|
|
15
|
-
'userAgent',
|
|
16
|
-
'pageTitle',
|
|
17
|
-
'stepsToFollow',
|
|
18
|
-
'useIncognitoWebview',
|
|
19
|
-
'extensionConfig',
|
|
20
|
-
];
|
|
21
|
-
/** Pull `providerConfig` out of a `get_provider_version_config` /
|
|
22
|
-
* `getProviderConfig` response, tolerant of a missing/malformed shape. */
|
|
23
|
-
function extractProviderConfig(resp) {
|
|
24
|
-
if (resp && typeof resp === 'object') {
|
|
25
|
-
const cfg = resp.providerConfig;
|
|
26
|
-
if (cfg && typeof cfg === 'object') {
|
|
27
|
-
return cfg;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
return undefined;
|
|
31
|
-
}
|
|
32
|
-
/** Prefer the caller's explicit value; otherwise fall back to `current`'s
|
|
33
|
-
* value (if any) and record `fieldName` as carried forward.
|
|
34
|
-
*
|
|
35
|
-
* `current === null` is treated the SAME as `undefined` (nothing to carry),
|
|
36
|
-
* not as a real value to preserve. These are all optional fields with no
|
|
37
|
-
* Prisma default — a version where one was never set reads back as
|
|
38
|
-
* `null`, not a missing key, because Prisma normalizes an absent optional
|
|
39
|
-
* field to `null` on read. Carrying that `null` "forward" would falsely
|
|
40
|
-
* claim the field was preserved when really nothing was ever there — it
|
|
41
|
-
* most likely means an EARLIER version already lost it (e.g. published
|
|
42
|
-
* before this field had carry-forward support at all), not that this call
|
|
43
|
-
* is doing anything wrong. */
|
|
44
|
-
function resolveField(explicit, current, fieldName, carried) {
|
|
45
|
-
if (explicit !== undefined) {
|
|
46
|
-
return explicit;
|
|
47
|
-
}
|
|
48
|
-
if (current !== undefined && current !== null) {
|
|
49
|
-
carried.push(fieldName);
|
|
50
|
-
return current;
|
|
51
|
-
}
|
|
52
|
-
return undefined;
|
|
53
|
-
}
|
|
54
7
|
/**
|
|
55
8
|
* The old backend lets you add a version only to a provider YOU own. A
|
|
56
9
|
* `providerId` for a public provider (or anyone else's) comes back as
|
|
@@ -111,23 +64,19 @@ export function createVersionFromCaptureTool(client) {
|
|
|
111
64
|
'For data gated behind login/navigation, pass `customInjection` — a ' +
|
|
112
65
|
'page-injection script (old-devtools\' name for the builder\'s ' +
|
|
113
66
|
'jsUserScripts) that drives the page to where the request fires. \n\n' +
|
|
114
|
-
'
|
|
115
|
-
'
|
|
116
|
-
'
|
|
117
|
-
'
|
|
118
|
-
'
|
|
119
|
-
'
|
|
120
|
-
'
|
|
121
|
-
'
|
|
122
|
-
'
|
|
123
|
-
'
|
|
124
|
-
'
|
|
125
|
-
'
|
|
126
|
-
'
|
|
127
|
-
'explicit value (`""`, `[]`, `false`, ...) to intentionally clear ' +
|
|
128
|
-
'one of them instead, or call get_provider_version_config FIRST ' +
|
|
129
|
-
'to see exactly what is live today, rather than assuming this ' +
|
|
130
|
-
'call recovered something that may already be gone. ' +
|
|
67
|
+
'BY DESIGN, this backend only ever CREATES A NEW VERSION (POST) — ' +
|
|
68
|
+
'there is no partial-patch/edit endpoint, and this tool does NOT ' +
|
|
69
|
+
'read or carry anything forward from the previous version. Every ' +
|
|
70
|
+
'new version replaces the WHOLE providerConfig with exactly what ' +
|
|
71
|
+
'you pass in THIS call. Therefore any field you want present in the ' +
|
|
72
|
+
'new version MUST be supplied here — `customInjection`, `userAgent`, ' +
|
|
73
|
+
'`pageTitle`, `allowedInjectedRequestData`, `stepsToFollow`, ' +
|
|
74
|
+
'`useIncognitoWebview`, `extensionConfig`, and `requestData` (via ' +
|
|
75
|
+
'`provider`/`providers`). Anything you OMIT is published blank/' +
|
|
76
|
+
'default in the new version, NOT preserved. To change one field ' +
|
|
77
|
+
'while keeping the rest, first read the current version with ' +
|
|
78
|
+
'get_provider_info, then pass the full desired config (the existing ' +
|
|
79
|
+
'values plus your change) back into this call. ' +
|
|
131
80
|
'`allowedInjectedRequestData`/`useIncognitoWebview`/' +
|
|
132
81
|
'`extensionConfig` only take effect on add-version — the register ' +
|
|
133
82
|
'endpoint (no `providerId`) ignores them. \n\n' +
|
|
@@ -250,6 +199,26 @@ export function createVersionFromCaptureTool(client) {
|
|
|
250
199
|
type: 'boolean',
|
|
251
200
|
description: 'Fail validation if none match. Default true.',
|
|
252
201
|
},
|
|
202
|
+
templateParams: {
|
|
203
|
+
type: 'array',
|
|
204
|
+
items: { type: 'string' },
|
|
205
|
+
description: 'Names of `${var}` placeholders used in this ' +
|
|
206
|
+
'template\'s `responseMatches`/`responseRedactions` ' +
|
|
207
|
+
'that should be substituted at verify time from the ' +
|
|
208
|
+
'submitted proof\'s own witness params. Omit (or ' +
|
|
209
|
+
'leave empty) if the template has no such ' +
|
|
210
|
+
'placeholders.',
|
|
211
|
+
},
|
|
212
|
+
templateParamsMode: {
|
|
213
|
+
type: 'string',
|
|
214
|
+
enum: ['separate', 'merge'],
|
|
215
|
+
description: 'How multiple values for `templateParams` expand ' +
|
|
216
|
+
'into request specs. `separate` (default): one ' +
|
|
217
|
+
'independent spec per value, each matching its own ' +
|
|
218
|
+
'proof. `merge`: fold all values into ONE spec with ' +
|
|
219
|
+
'one match/redaction entry per value, matching a ' +
|
|
220
|
+
'single proof that bundles them all into one claim.',
|
|
221
|
+
},
|
|
253
222
|
},
|
|
254
223
|
required: ['url', 'method'],
|
|
255
224
|
},
|
|
@@ -281,22 +250,22 @@ export function createVersionFromCaptureTool(client) {
|
|
|
281
250
|
'(an array of 1+ drafts).', { statusCode: 400 });
|
|
282
251
|
}
|
|
283
252
|
const providers = hasMany ? args.providers : [args.provider];
|
|
284
|
-
const opts = {
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
}
|
|
253
|
+
const opts = {
|
|
254
|
+
initialUrl: args.initialUrl,
|
|
255
|
+
description: args.description,
|
|
256
|
+
geoLocation: args.geoLocation,
|
|
257
|
+
useProxy: args.useProxy,
|
|
258
|
+
providerType: args.providerType,
|
|
259
|
+
customInjection: args.customInjection,
|
|
260
|
+
userAgent: args.userAgent,
|
|
261
|
+
pageTitle: args.pageTitle,
|
|
262
|
+
stepsToFollow: args.stepsToFollow,
|
|
263
|
+
useIncognitoWebview: args.useIncognitoWebview,
|
|
264
|
+
extensionConfig: args.extensionConfig,
|
|
265
|
+
allowedInjectedRequestData: args.allowedInjectedRequestData
|
|
266
|
+
? injectedRequestDataFrom(args.allowedInjectedRequestData)
|
|
267
|
+
: undefined
|
|
268
|
+
};
|
|
300
269
|
// Non-blocking advisory per request — never alters or blocks the
|
|
301
270
|
// publish; the author keeps full control. Attaches under `_notes`.
|
|
302
271
|
const notes = matchRedactionWarnings(providers.map((p, i) => countsFor(`Request ${i + 1}`, p)));
|
|
@@ -325,20 +294,13 @@ export function createVersionFromCaptureTool(client) {
|
|
|
325
294
|
const ignoredOnRegister = REGISTER_IGNORED_KEYS
|
|
326
295
|
.filter((k) => args[k] !== undefined);
|
|
327
296
|
if (ignoredOnRegister.length) {
|
|
328
|
-
notes.push(`${ignoredOnRegister.join(', ')}
|
|
297
|
+
notes.push(`${ignoredOnRegister.join(', ')} ` +
|
|
298
|
+
`${ignoredOnRegister.length > 1 ? 'are' : 'is'} ` +
|
|
299
|
+
'ignored on register (no providerId) — the old backend only ' +
|
|
329
300
|
'reads them when adding a version. Publish once to create ' +
|
|
330
301
|
'the provider, then call this again WITH the returned ' +
|
|
331
302
|
'providerId to set them.');
|
|
332
303
|
}
|
|
333
|
-
if (args.userAgent !== undefined) {
|
|
334
|
-
opts.userAgent = args.userAgent;
|
|
335
|
-
}
|
|
336
|
-
if (args.pageTitle !== undefined) {
|
|
337
|
-
opts.pageTitle = args.pageTitle;
|
|
338
|
-
}
|
|
339
|
-
if (args.stepsToFollow !== undefined) {
|
|
340
|
-
opts.stepsToFollow = args.stepsToFollow;
|
|
341
|
-
}
|
|
342
304
|
const body = providerToRegisterBody(providers, opts);
|
|
343
305
|
return withNotes(await client.registerProvider(body));
|
|
344
306
|
}
|
|
@@ -348,42 +310,23 @@ export function createVersionFromCaptureTool(client) {
|
|
|
348
310
|
// workaround instead of a bare permission error.
|
|
349
311
|
const providerId = args.providerId;
|
|
350
312
|
try {
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
//
|
|
355
|
-
//
|
|
356
|
-
|
|
357
|
-
|| args.allowedInjectedRequestData === undefined;
|
|
358
|
-
const current = needsCurrent
|
|
359
|
-
? extractProviderConfig(await client.getProviderConfig(providerId))
|
|
360
|
-
: undefined;
|
|
361
|
-
const carried = [];
|
|
362
|
-
for (const key of PRESERVABLE_KEYS) {
|
|
363
|
-
const resolved = resolveField(args[key], current?.[key], key, carried);
|
|
364
|
-
if (resolved !== undefined) {
|
|
365
|
-
opts[key] = resolved;
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
const freshInjected = args.allowedInjectedRequestData !== undefined
|
|
369
|
-
? injectedRequestDataFrom(args.allowedInjectedRequestData)
|
|
370
|
-
: undefined;
|
|
371
|
-
const resolvedInjected = resolveField(freshInjected, current?.allowedInjectedRequestData, 'allowedInjectedRequestData', carried);
|
|
372
|
-
if (resolvedInjected !== undefined) {
|
|
373
|
-
opts.allowedInjectedRequestData = resolvedInjected;
|
|
374
|
-
}
|
|
375
|
-
if (carried.length) {
|
|
376
|
-
notes.push('Not passed, carried forward unchanged from the current ' +
|
|
377
|
-
`published version: ${carried.join(', ')}. Pass an explicit ` +
|
|
378
|
-
'value ("", [], false, ...) to override any of them, or ' +
|
|
379
|
-
'call get_provider_version_config first to see what is ' +
|
|
380
|
-
'live today.');
|
|
381
|
-
}
|
|
313
|
+
// By design this always POSTs a brand-new version and publishes
|
|
314
|
+
// EXACTLY the config assembled above — nothing is read back or
|
|
315
|
+
// carried forward. Any field the caller omitted is already absent
|
|
316
|
+
// from `opts` and will be blank/default in the new version. To
|
|
317
|
+
// preserve existing fields, the caller must have read them via
|
|
318
|
+
// get_provider_info and passed them back in.
|
|
382
319
|
if (args.customInjection !== undefined && opts.customInjection?.trim()) {
|
|
383
320
|
notes.push('customInjection will run in the verification client before ' +
|
|
384
321
|
'every page load (before navigating to initialUrl), matching ' +
|
|
385
322
|
'the builder\'s jsUserScripts.');
|
|
386
323
|
}
|
|
324
|
+
notes.push('A NEW version was created with only the fields passed in this ' +
|
|
325
|
+
'call — nothing was carried forward from the previous version. ' +
|
|
326
|
+
'If you intended to keep other fields (customInjection, ' +
|
|
327
|
+
'userAgent, allowedInjectedRequestData, ...), read the prior ' +
|
|
328
|
+
'version with get_provider_info and re-publish with them ' +
|
|
329
|
+
'included.');
|
|
387
330
|
const versionsResp = await client.listVersions(providerId);
|
|
388
331
|
const version = bumpPatchString(extractVersions(versionsResp));
|
|
389
332
|
const versionInfo = args.notes && args.notes.trim().length >= 10
|
|
@@ -15,6 +15,76 @@ import "../proof/patch-domhandler.js";
|
|
|
15
15
|
/** Identifying attributes stable enough to anchor an xPath on (value-bearing
|
|
16
16
|
* attrs like `content`/`href`/`value` are excluded). */
|
|
17
17
|
const IDENT_ATTRS = ['name', 'property', 'itemprop', 'data-testid', 'rel', 'for'];
|
|
18
|
+
/** Base weight by anchor kind: purpose-built test hooks and ids beat classes,
|
|
19
|
+
* which beat a positional path. Modulated by {@link tokenQuality}. */
|
|
20
|
+
const TYPE_WEIGHT = {
|
|
21
|
+
'ident-attr': 0.95,
|
|
22
|
+
id: 0.9,
|
|
23
|
+
class: 0.6,
|
|
24
|
+
positional: 0.3,
|
|
25
|
+
};
|
|
26
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
27
|
+
/**
|
|
28
|
+
* How likely an identifier token is to survive a future render, by its SHAPE —
|
|
29
|
+
* framework-agnostic, so it catches generated ids/classes from any tool (this
|
|
30
|
+
* year's or next) without naming them. Returns `[0.02, 1]`.
|
|
31
|
+
*
|
|
32
|
+
* Volatile shapes (machine-generated): a `:` (React `useId` `:r1:`, Radix), a
|
|
33
|
+
* UUID, digit-heavy or trailing-digit tokens (`ember123`, `tab-2`), hex/hash
|
|
34
|
+
* runs (`css-1a2b3c`), or opaque wordless strings. Durable shapes (human
|
|
35
|
+
* authored): real word segments, kebab/snake of words (`settings-header`,
|
|
36
|
+
* `primary_email_select`).
|
|
37
|
+
*/
|
|
38
|
+
function tokenQuality(token) {
|
|
39
|
+
const t = token.trim();
|
|
40
|
+
if (!t) {
|
|
41
|
+
return 0.3;
|
|
42
|
+
}
|
|
43
|
+
// Kill signals — these tokens are (almost) always per-render generated.
|
|
44
|
+
if (t.includes(':') || UUID_RE.test(t)) {
|
|
45
|
+
return 0.05;
|
|
46
|
+
}
|
|
47
|
+
let q = 1;
|
|
48
|
+
const len = t.length;
|
|
49
|
+
const digits = (t.match(/\d/g) ?? []).length;
|
|
50
|
+
const digitRatio = digits / len;
|
|
51
|
+
// A trailing digit run after letters is the classic generated-id tail.
|
|
52
|
+
if (/[A-Za-z][-_]?\d+$/.test(t)) {
|
|
53
|
+
q *= 0.3;
|
|
54
|
+
}
|
|
55
|
+
if (digitRatio > 0.3) {
|
|
56
|
+
q *= 0.4;
|
|
57
|
+
}
|
|
58
|
+
else if (digitRatio > 0.15) {
|
|
59
|
+
q *= 0.7;
|
|
60
|
+
}
|
|
61
|
+
// A pure hex/short-hash run (`1a2b3c`, `deadbeef`) — no semantic content.
|
|
62
|
+
if (/^[0-9a-f]{6,}$/i.test(t)) {
|
|
63
|
+
q *= 0.15;
|
|
64
|
+
}
|
|
65
|
+
// A real word — an alpha run ≥3 that ITSELF contains a vowel — is the
|
|
66
|
+
// strongest durability signal; its absence means the token is opaque /
|
|
67
|
+
// hash-like (`css-1a2b3c`, `sc-bdfBwQ`). Checking per-segment matters: a
|
|
68
|
+
// stray vowel elsewhere in a hash must not count as "has a word".
|
|
69
|
+
const hasWord = (t.match(/[A-Za-z]+/g) ?? [])
|
|
70
|
+
.some((run) => run.length >= 3 && /[aeiou]/i.test(run));
|
|
71
|
+
if (!hasWord) {
|
|
72
|
+
q *= 0.3;
|
|
73
|
+
}
|
|
74
|
+
if (len > 40) {
|
|
75
|
+
q *= 0.6;
|
|
76
|
+
}
|
|
77
|
+
return Math.max(0.02, Math.min(1, q));
|
|
78
|
+
}
|
|
79
|
+
/** Stability score for a candidate: base weight for its kind × the shape
|
|
80
|
+
* quality of its identifier, minus a small depth nudge so a nearer/shorter
|
|
81
|
+
* anchor wins an otherwise-tie. Positional paths score on kind alone. */
|
|
82
|
+
function anchorStability(c) {
|
|
83
|
+
const base = TYPE_WEIGHT[c.kind];
|
|
84
|
+
const quality = c.kind === 'positional' ? 1 : tokenQuality(c.token ?? '');
|
|
85
|
+
const depthNudge = 1 - Math.min(c.depth, 8) * 0.01;
|
|
86
|
+
return base * quality * depthNudge;
|
|
87
|
+
}
|
|
18
88
|
/**
|
|
19
89
|
* A verified xPath to the tightest element containing one of `targets`, or
|
|
20
90
|
* undefined if the value isn't present in the parsed tree. Prefers a short
|
|
@@ -45,14 +115,24 @@ export function elementAnchor(html, targets) {
|
|
|
45
115
|
if (startIndex === null || endIndex === null) {
|
|
46
116
|
return undefined;
|
|
47
117
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
118
|
+
// Score EVERY candidate that provably resolves (first-match, uniquely) to the
|
|
119
|
+
// target, and take the most durable — not merely the first that resolves.
|
|
120
|
+
// Because each survivor already matches uniquely NOW, the choice only trades
|
|
121
|
+
// on FUTURE stability; it can never pick a wrong/non-resolving node.
|
|
122
|
+
const verified = [...candidateXPaths(el)]
|
|
123
|
+
.map((c, i) => ({ c, i }))
|
|
124
|
+
.filter(({ c }) => matchesUniquely(doc, c.xPath, el));
|
|
125
|
+
if (!verified.length) {
|
|
126
|
+
return undefined;
|
|
54
127
|
}
|
|
55
|
-
|
|
128
|
+
verified.sort((a, b) => {
|
|
129
|
+
return anchorStability(b.c) - anchorStability(a.c) ||
|
|
130
|
+
a.c.depth - b.c.depth ||
|
|
131
|
+
a.i - b.i;
|
|
132
|
+
});
|
|
133
|
+
// `[startIndex, endIndex)` is exactly the slice the attestor reveals for this
|
|
134
|
+
// xPath (its getNodeRange uses these same parse5 source locations).
|
|
135
|
+
return { xPath: verified[0].c.xPath, start: startIndex, end: endIndex };
|
|
56
136
|
}
|
|
57
137
|
/** The tightest element that DIRECTLY holds a target. Prefers a COMPLETE-value
|
|
58
138
|
* occurrence — an attribute whose value IS the target, or a text child that IS
|
|
@@ -97,46 +177,119 @@ function smallestElement(els) {
|
|
|
97
177
|
.sort((a, b) => elementSpan(a) - elementSpan(b))
|
|
98
178
|
.at(0);
|
|
99
179
|
}
|
|
100
|
-
/**
|
|
180
|
+
/** Every candidate xPath for `el`, each tagged with what it anchors on. The
|
|
181
|
+
* caller verifies + scores them ({@link anchorStability}) rather than trusting
|
|
182
|
+
* emission order, so the DURABILITY winner is chosen, not the first that
|
|
183
|
+
* resolves. Order here is only the final tie-break. */
|
|
101
184
|
function* candidateXPaths(el) {
|
|
102
185
|
const tag = el.name;
|
|
103
186
|
const a = el.attribs ?? {};
|
|
104
187
|
if (a.id) {
|
|
105
|
-
yield `//${tag}[@id=${xpathLiteral(a.id)}]
|
|
188
|
+
yield { xPath: `//${tag}[@id=${xpathLiteral(a.id)}]`, kind: 'id',
|
|
189
|
+
token: a.id, depth: 0 };
|
|
106
190
|
}
|
|
107
191
|
for (const name of IDENT_ATTRS) {
|
|
108
192
|
if (a[name]) {
|
|
109
|
-
yield `//${tag}[@${name}=${xpathLiteral(a[name])}]
|
|
110
|
-
|
|
111
|
-
}
|
|
112
|
-
if (a.class) {
|
|
113
|
-
const distinctive = a.class
|
|
114
|
-
.split(/\s+/)
|
|
115
|
-
.filter(Boolean)
|
|
116
|
-
.sort((x, y) => y.length - x.length)[0];
|
|
117
|
-
if (distinctive) {
|
|
118
|
-
yield `//${tag}[contains(@class, ${xpathLiteral(distinctive)})]`;
|
|
193
|
+
yield { xPath: `//${tag}[@${name}=${xpathLiteral(a[name])}]`,
|
|
194
|
+
kind: 'ident-attr', token: a[name], depth: 0 };
|
|
119
195
|
}
|
|
120
196
|
}
|
|
121
197
|
// A uniquely-identified DIRECT child pins the parent without a positional
|
|
122
|
-
// path (`//span[span[@id="labels"]]`). Self-verification
|
|
123
|
-
//
|
|
124
|
-
//
|
|
198
|
+
// path (`//span[span[@id="labels"]]`). Self-verification discards it when the
|
|
199
|
+
// child id/attr isn't unique — e.g. GitHub reuses `id="labels"` per email row
|
|
200
|
+
// — so it never silently matches the wrong node.
|
|
125
201
|
for (const child of el.children) {
|
|
126
202
|
if (!isTag(child)) {
|
|
127
203
|
continue;
|
|
128
204
|
}
|
|
129
205
|
const ca = child.attribs ?? {};
|
|
130
206
|
if (ca.id) {
|
|
131
|
-
yield `//${tag}[${child.name}[@id=${xpathLiteral(ca.id)}]]
|
|
207
|
+
yield { xPath: `//${tag}[${child.name}[@id=${xpathLiteral(ca.id)}]]`,
|
|
208
|
+
kind: 'id', token: ca.id, depth: 0 };
|
|
132
209
|
}
|
|
133
210
|
for (const name of IDENT_ATTRS) {
|
|
134
211
|
if (ca[name]) {
|
|
135
|
-
yield
|
|
212
|
+
yield {
|
|
213
|
+
xPath: `//${tag}[${child.name}[@${name}=${xpathLiteral(ca[name])}]]`,
|
|
214
|
+
kind: 'ident-attr', token: ca[name], depth: 0,
|
|
215
|
+
};
|
|
136
216
|
}
|
|
137
217
|
}
|
|
138
218
|
}
|
|
139
|
-
|
|
219
|
+
// The value element's own distinctive class.
|
|
220
|
+
const distinctive = distinctiveClass(a.class);
|
|
221
|
+
if (distinctive) {
|
|
222
|
+
yield { xPath: `//${tag}[contains(@class, ${xpathLiteral(distinctive)})]`,
|
|
223
|
+
kind: 'class', token: distinctive, depth: 0 };
|
|
224
|
+
}
|
|
225
|
+
// Ancestor-anchored (id/attr, then class), descending positionally to `el`.
|
|
226
|
+
yield* ancestorAnchoredCandidates(el);
|
|
227
|
+
yield { xPath: absolutePath(el), kind: 'positional', depth: 99 };
|
|
228
|
+
}
|
|
229
|
+
/** The longest whitespace-delimited token of a class attribute (the most
|
|
230
|
+
* distinctive one), or undefined when there's no class. */
|
|
231
|
+
function distinctiveClass(cls) {
|
|
232
|
+
return (cls ?? '')
|
|
233
|
+
.split(/\s+/)
|
|
234
|
+
.filter(Boolean)
|
|
235
|
+
.sort((x, y) => y.length - x.length)[0];
|
|
236
|
+
}
|
|
237
|
+
/** Positional path from `anc` (exclusive) down to `el` (inclusive), e.g.
|
|
238
|
+
* `/ul[1]/li[3]/a[1]`; empty string when `anc` is not an ancestor of `el`. */
|
|
239
|
+
function relativeSteps(anc, el) {
|
|
240
|
+
const parts = [];
|
|
241
|
+
let node = el;
|
|
242
|
+
while (node && isTag(node) && node !== anc) {
|
|
243
|
+
const tag = node.name;
|
|
244
|
+
let index = 1;
|
|
245
|
+
for (let sib = node.prev; sib; sib = sib.prev) {
|
|
246
|
+
if (isTag(sib) && sib.name === tag) {
|
|
247
|
+
index++;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
parts.unshift(`/${tag}[${index}]`);
|
|
251
|
+
node = node.parent;
|
|
252
|
+
}
|
|
253
|
+
return node === anc ? parts.join('') : '';
|
|
254
|
+
}
|
|
255
|
+
/** Ancestors of `el` (nearest first) with their depth, skipping
|
|
256
|
+
* `html`/`body`/`head` (≈ the absolute path) and non-ancestors. Capped depth
|
|
257
|
+
* keeps paths short. */
|
|
258
|
+
function* anchorAncestors(el) {
|
|
259
|
+
let depth = 1;
|
|
260
|
+
for (let anc = el.parent; anc && isTag(anc) && depth <= 10; anc = anc.parent, depth++) {
|
|
261
|
+
const tag = anc.name;
|
|
262
|
+
if (tag === 'html' || tag === 'body' || tag === 'head') {
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
const rel = relativeSteps(anc, el);
|
|
266
|
+
if (rel) {
|
|
267
|
+
yield { tag, attribs: anc.attribs ?? {}, rel, depth };
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
/** Ancestor-anchored candidates, descending positionally to `el`
|
|
272
|
+
* (`//div[@id="nav"]/ul[1]/li[1]/a[1]`): an id / identifying-attr or a
|
|
273
|
+
* distinctive-class anchor per ancestor, tagged with its kind + depth so the
|
|
274
|
+
* scorer weighs a stable ancestor id above the value's own utility class. */
|
|
275
|
+
function* ancestorAnchoredCandidates(el) {
|
|
276
|
+
for (const { tag, attribs, rel, depth } of anchorAncestors(el)) {
|
|
277
|
+
if (attribs.id) {
|
|
278
|
+
yield { xPath: `//${tag}[@id=${xpathLiteral(attribs.id)}]${rel}`,
|
|
279
|
+
kind: 'id', token: attribs.id, depth };
|
|
280
|
+
}
|
|
281
|
+
for (const name of IDENT_ATTRS) {
|
|
282
|
+
if (attribs[name]) {
|
|
283
|
+
const xp = `//${tag}[@${name}=${xpathLiteral(attribs[name])}]${rel}`;
|
|
284
|
+
yield { xPath: xp, kind: 'ident-attr', token: attribs[name], depth };
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
const dc = distinctiveClass(attribs.class);
|
|
288
|
+
if (dc) {
|
|
289
|
+
yield { xPath: `//${tag}[contains(@class, ${xpathLiteral(dc)})]${rel}`,
|
|
290
|
+
kind: 'class', token: dc, depth };
|
|
291
|
+
}
|
|
292
|
+
}
|
|
140
293
|
}
|
|
141
294
|
/** Chrome "full XPath"-style absolute path: `/html[1]/body[1]/…/tag[n]`, where
|
|
142
295
|
* n is the element's 1-based position among same-tag siblings. */
|
package/lib/provider/draft.d.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import type { CapturedRequest, OprfMode, ReclaimProvider, ResponseMatch, ResponseRedaction, SecretParamRef } from './schema.ts';
|
|
2
|
+
/** OPRF mode to apply when the caller passed none: the anti-leak floor —
|
|
3
|
+
* force-hash a value whose SHAPE is unambiguous PII, but ONLY when it's
|
|
4
|
+
* OPRF-hashable AND within the 62-byte MPC-circuit cap (UTF-8). Undefined =
|
|
5
|
+
* leave plaintext (a longer value can't be OPRF'd; contextual PII + the proven
|
|
6
|
+
* identity are the model's/plaintext call). */
|
|
7
|
+
export declare function defaultSensitiveHash(value: string): OprfMode | undefined;
|
|
2
8
|
/** One value to extract from the request: the captured `value`, a `name` (its
|
|
3
9
|
* param / capture-group), and an optional per-target OPRF `hash`. */
|
|
4
10
|
export interface DraftTarget {
|
package/lib/provider/draft.js
CHANGED
|
@@ -1,6 +1,35 @@
|
|
|
1
|
-
import { synthesizeMatcher } from "./matcher.js";
|
|
1
|
+
import { isOprfHashable, OPRF_MAX_BYTES, synthesizeMatcher } from "./matcher.js";
|
|
2
2
|
import { splitSecrets } from "./secrets.js";
|
|
3
|
-
/**
|
|
3
|
+
/** OPRF mode for the auto-hash floor — TEE-native (attestor rejects oprf-raw,
|
|
4
|
+
* isn't set up for gnark oprf). */
|
|
5
|
+
const DEFAULT_SENSITIVE_HASH = 'oprf-mpc';
|
|
6
|
+
// The deterministic ANTI-LEAK FLOOR: values whose SHAPE is unambiguously
|
|
7
|
+
// sensitive PII, force-hashed no matter the field name or the model's judgment.
|
|
8
|
+
// Deliberately only FIXED-FORMAT identifiers — OPRF is byte-exact, so a
|
|
9
|
+
// self-mutable value (a display name re-cased) would be grindable and pointless
|
|
10
|
+
// to hash; and a bare number is ambiguous (a private account id vs a PUBLIC
|
|
11
|
+
// user id / follower count that's meant to be proven in the clear). Those
|
|
12
|
+
// contextual calls are the model's, via an explicit per-target `hash`. This
|
|
13
|
+
// floor is universal (not domain-specific) and high-precision.
|
|
14
|
+
const EMAIL_VALUE = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/;
|
|
15
|
+
const SSN_VALUE = /^\d{3}-\d{2}-\d{4}$/;
|
|
16
|
+
const IBAN_VALUE = /^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/;
|
|
17
|
+
function isCanonicalPii(value) {
|
|
18
|
+
return EMAIL_VALUE.test(value)
|
|
19
|
+
|| SSN_VALUE.test(value) || IBAN_VALUE.test(value);
|
|
20
|
+
}
|
|
21
|
+
/** OPRF mode to apply when the caller passed none: the anti-leak floor —
|
|
22
|
+
* force-hash a value whose SHAPE is unambiguous PII, but ONLY when it's
|
|
23
|
+
* OPRF-hashable AND within the 62-byte MPC-circuit cap (UTF-8). Undefined =
|
|
24
|
+
* leave plaintext (a longer value can't be OPRF'd; contextual PII + the proven
|
|
25
|
+
* identity are the model's/plaintext call). */
|
|
26
|
+
export function defaultSensitiveHash(value) {
|
|
27
|
+
const hashable = isCanonicalPii(value)
|
|
28
|
+
&& isOprfHashable(value)
|
|
29
|
+
&& Buffer.byteLength(value, 'utf8') <= OPRF_MAX_BYTES;
|
|
30
|
+
return hashable ? DEFAULT_SENSITIVE_HASH : undefined;
|
|
31
|
+
}
|
|
32
|
+
/** Request headers that must NOT survive into the provider params. Three kinds:
|
|
4
33
|
* - Conditional headers (`if-none-match`/`if-modified-since`/…): a captured
|
|
5
34
|
* cached ETag/date makes the origin answer 304/412 when the attestor
|
|
6
35
|
* re-issues, and the attestor treats non-2xx as failure. We always want the
|
|
@@ -8,7 +37,14 @@ import { splitSecrets } from "./secrets.js";
|
|
|
8
37
|
* - Transfer headers the ATTESTOR sets itself (`connection: close`,
|
|
9
38
|
* `accept-encoding: identity`). Passing our own duplicates/collides with the
|
|
10
39
|
* request the attestor builds, so leave them out — the attestor already
|
|
11
|
-
* forces `identity` (uncompressed, so its byte-matching works).
|
|
40
|
+
* forces `identity` (uncompressed, so its byte-matching works).
|
|
41
|
+
* - Host / framing headers the TRANSPORT owns (`host`, `content-length`,
|
|
42
|
+
* `transfer-encoding`) and HTTP/2 pseudo-headers (`:authority`/`:method`/…,
|
|
43
|
+
* which CDP surfaces on h2 captures). The attestor derives `Host` from the
|
|
44
|
+
* URL and sets its own; a baked `Host` produces a DUPLICATE
|
|
45
|
+
* (`found: h,h`) that fails the receipt check / 400s the origin. A stale
|
|
46
|
+
* `content-length` breaks a re-serialized body. Pseudo-headers aren't valid
|
|
47
|
+
* real headers at all. */
|
|
12
48
|
const STRIP_REQUEST_HEADERS = new Set([
|
|
13
49
|
'if-none-match',
|
|
14
50
|
'if-modified-since',
|
|
@@ -17,14 +53,22 @@ const STRIP_REQUEST_HEADERS = new Set([
|
|
|
17
53
|
'if-range',
|
|
18
54
|
'connection',
|
|
19
55
|
'accept-encoding',
|
|
56
|
+
'host',
|
|
57
|
+
'content-length',
|
|
58
|
+
'transfer-encoding',
|
|
20
59
|
]);
|
|
60
|
+
/** True for an HTTP/2 pseudo-header (`:authority`, `:method`, `:path`,
|
|
61
|
+
* `:scheme`) — never a valid request header the attestor can replay. */
|
|
62
|
+
function isPseudoHeader(name) {
|
|
63
|
+
return name.startsWith(':');
|
|
64
|
+
}
|
|
21
65
|
/** Drop the headers the attestor manages or that would 304 at proof time
|
|
22
66
|
* (see {@link STRIP_REQUEST_HEADERS}); keep every other captured header so the
|
|
23
67
|
* attestor's replay looks like the browser's request. */
|
|
24
68
|
function sanitizeRequestHeaders(headers) {
|
|
25
69
|
const out = {};
|
|
26
70
|
for (const [k, v] of Object.entries(headers)) {
|
|
27
|
-
if (STRIP_REQUEST_HEADERS.has(k.toLowerCase())) {
|
|
71
|
+
if (STRIP_REQUEST_HEADERS.has(k.toLowerCase()) || isPseudoHeader(k)) {
|
|
28
72
|
continue;
|
|
29
73
|
}
|
|
30
74
|
out[k] = v;
|
|
@@ -103,7 +147,12 @@ export function draftProvider(req, targets, name, hash) {
|
|
|
103
147
|
+ 'give them distinct names');
|
|
104
148
|
}
|
|
105
149
|
seenParams.add(paramName);
|
|
106
|
-
|
|
150
|
+
// Precedence: explicit per-target hash → explicit request-wide hash →
|
|
151
|
+
// the anti-leak floor (force-OPRF values whose SHAPE is unambiguous PII).
|
|
152
|
+
// The model makes the contextual calls (which named fields are sensitive)
|
|
153
|
+
// via an explicit per-target hash; this floor only guarantees the obvious.
|
|
154
|
+
const effectiveHash = t.hash ?? hash ?? defaultSensitiveHash(t.value);
|
|
155
|
+
const synthesis = synthesizeMatcher(req.responseBody, req.contentType, t.value, paramName, effectiveHash);
|
|
107
156
|
if (!synthesis) {
|
|
108
157
|
throw new Error(`target value "${t.value}" not found in`
|
|
109
158
|
+ ` response body for request ${req.requestId}`);
|
|
@@ -31,3 +31,7 @@ export interface MatcherSynthesis {
|
|
|
31
31
|
* proof time. Throws when `hash` is set on a free-form value (no safe class).
|
|
32
32
|
*/
|
|
33
33
|
export declare function synthesizeMatcher(body: string, contentType: string, target: string, paramName: string, hash?: OprfMode): MatcherSynthesis | undefined;
|
|
34
|
+
/** True when `target` has a character class safe to OPRF-hash. Free-form values
|
|
35
|
+
* (names with spaces, arbitrary unicode/punctuation) return false — the caller
|
|
36
|
+
* must NOT default them to a hash (it would throw in synthesizeMatcher). */
|
|
37
|
+
export declare function isOprfHashable(target: string): boolean;
|
package/lib/provider/matcher.js
CHANGED
|
@@ -548,3 +548,9 @@ function inferValueClass(target) {
|
|
|
548
548
|
}
|
|
549
549
|
return undefined;
|
|
550
550
|
}
|
|
551
|
+
/** True when `target` has a character class safe to OPRF-hash. Free-form values
|
|
552
|
+
* (names with spaces, arbitrary unicode/punctuation) return false — the caller
|
|
553
|
+
* must NOT default them to a hash (it would throw in synthesizeMatcher). */
|
|
554
|
+
export function isOprfHashable(target) {
|
|
555
|
+
return inferValueClass(target) !== undefined;
|
|
556
|
+
}
|
|
@@ -25,6 +25,19 @@ export interface AllowedJsRequest extends DraftRequest {
|
|
|
25
25
|
multiple?: boolean;
|
|
26
26
|
/** Fail validation if no request matches this template. Default true. */
|
|
27
27
|
required?: boolean;
|
|
28
|
+
/** Names of `${var}` placeholders in this template's `responseMatches`/
|
|
29
|
+
* `responseRedactions` that get substituted at verify time from the
|
|
30
|
+
* submitted proof's own witness params (e.g. an index list a
|
|
31
|
+
* `Reclaim.requestClaim` call attached alongside its `{{var}}`
|
|
32
|
+
* extractions). Omit (or leave empty) when the template has no such
|
|
33
|
+
* placeholders — verification then matches it literally as-is. */
|
|
34
|
+
templateParams?: string[];
|
|
35
|
+
/** How multiple values for `templateParams` expand into request specs.
|
|
36
|
+
* `'separate'` (default): one independent spec per value, each expected
|
|
37
|
+
* to match its own proof. `'merge'`: all values folded into ONE spec
|
|
38
|
+
* with one `responseMatches`/`responseRedactions` entry per value,
|
|
39
|
+
* matching a single proof that bundles them all into one claim. */
|
|
40
|
+
templateParamsMode?: 'separate' | 'merge';
|
|
28
41
|
}
|
|
29
42
|
/** Optional extras for a create-version body beyond the request list. */
|
|
30
43
|
export interface CreateVersionOptions {
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reclaimprotocol/agent",
|
|
3
3
|
"title": "Reclaim Agent",
|
|
4
|
-
"version": "0.1.
|
|
5
|
-
"description": "Reclaim Protocol
|
|
4
|
+
"version": "0.1.11",
|
|
5
|
+
"description": "Reclaim Protocol's tools for agents — Reclaim Protocol lets your app verify **data points** about its users — a follower count, an account balance, an order history. This package gives your AI coding agent the tools to **find, build, test and fix the _data verification providers_** (the recipes that verify a data point) by inspecting and replaying captured requests, checking sessions, evaluate proofs, publishing updates, and much more.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./lib/index.js",
|
|
8
8
|
"types": "./lib/index.d.ts",
|