@reclaimprotocol/agent 0.1.5 → 0.1.6
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 +0 -24
- package/lib/authoring/find.d.ts +3 -0
- package/lib/authoring/find.js +54 -0
- package/lib/authoring/index.d.ts +3 -3
- package/lib/authoring/index.js +4 -3
- package/lib/authoring/propose.d.ts +3 -0
- package/lib/authoring/propose.js +109 -0
- package/lib/authoring/replay.d.ts +10 -0
- package/lib/authoring/replay.js +34 -0
- package/lib/authoring/tools.d.ts +13 -6
- package/lib/authoring/tools.js +108 -293
- package/lib/authoring/util.d.ts +27 -0
- package/lib/authoring/util.js +21 -0
- package/lib/consts.d.ts +6 -1
- package/lib/consts.js +9 -2
- package/lib/mcp/server.d.ts +2 -41
- package/lib/mcp/server.js +6 -34
- package/lib/mcp/start.js +4 -2
- package/lib/mcp/tools/agent/analyze.js +3 -3
- package/lib/mcp/tools/agent/build.d.ts +4 -4
- package/lib/mcp/tools/agent/build.js +13 -15
- package/lib/mcp/tools/agent/capture.d.ts +13 -1
- package/lib/mcp/tools/agent/capture.js +18 -85
- package/lib/mcp/tools/agent/eval.d.ts +5 -2
- package/lib/mcp/tools/agent/eval.js +11 -121
- package/lib/mcp/tools/agent/find.d.ts +10 -3
- package/lib/mcp/tools/agent/find.js +25 -31
- package/lib/mcp/tools/agent/proof.d.ts +13 -3
- package/lib/mcp/tools/agent/proof.js +81 -132
- package/lib/mcp/tools/agent/propose.d.ts +10 -9
- package/lib/mcp/tools/agent/propose.js +87 -64
- package/lib/mcp/tools/agent/replay.d.ts +10 -3
- package/lib/mcp/tools/agent/replay.js +29 -36
- package/lib/mcp/tools/agent/session.d.ts +1 -3
- package/lib/mcp/tools/agent/session.js +5 -5
- package/lib/mcp/tools/agent/shared.d.ts +5 -3
- package/lib/mcp/tools/agent/shared.js +29 -19
- package/lib/old/client.d.ts +35 -0
- package/lib/old/client.js +18 -0
- package/lib/old/login-cdp.d.ts +24 -5
- package/lib/old/login-cdp.js +29 -15
- package/lib/old/sdk-client.d.ts +39 -0
- package/lib/old/sdk-client.js +85 -0
- package/lib/old/to-register.d.ts +69 -0
- package/lib/old/to-register.js +52 -2
- package/lib/old/tools/authenticate.js +3 -7
- package/lib/old/tools/build.d.ts +6 -1
- package/lib/old/tools/build.js +11 -1
- package/lib/old/tools/get-provider-info.d.ts +16 -0
- package/lib/old/tools/get-provider-info.js +73 -0
- package/lib/old/tools/get-version-config.d.ts +17 -0
- package/lib/old/tools/get-version-config.js +47 -0
- package/lib/old/tools/publish.js +189 -2
- package/lib/old/tools/update-provider-metadata.d.ts +16 -0
- package/lib/old/tools/update-provider-metadata.js +61 -0
- package/lib/proof/attestor.d.ts +6 -3
- package/lib/proof/attestor.js +10 -1
- package/lib/proof/debug-response-dump.d.ts +4 -0
- package/lib/proof/debug-response-dump.js +55 -0
- package/lib/proof/patch-domhandler.d.ts +1 -0
- package/lib/proof/patch-domhandler.js +59 -0
- package/lib/proof/zk-files.js +7 -6
- package/lib/provider/dom-xpath.d.ts +17 -0
- package/lib/provider/dom-xpath.js +187 -0
- package/lib/provider/draft.d.ts +30 -2
- package/lib/provider/draft.js +117 -28
- package/lib/provider/index.d.ts +4 -0
- package/lib/provider/index.js +7 -0
- package/lib/provider/injection-doc.js +6 -0
- package/lib/provider/matcher.d.ts +17 -20
- package/lib/provider/matcher.js +394 -280
- package/lib/provider/schema.d.ts +7 -5
- package/lib/tools/registry.d.ts +14 -0
- package/lib/tools/registry.js +32 -0
- package/package.json +9 -1
- package/skill/SKILL.builder.md +42 -23
- package/skill/SKILL.old.md +6 -2
- package/skill/builder-mode.md +1 -1
- package/skill/injection-api.md +117 -0
- package/src/authoring/backend.ts +98 -0
- package/src/authoring/index.ts +20 -0
- package/src/authoring/tools.ts +273 -0
- package/src/authoring/util.ts +42 -0
- package/src/consts.ts +15 -2
- package/src/mcp/server.ts +11 -78
- package/src/mcp/start.ts +4 -1
- package/src/mcp/tools/agent/analyze.ts +3 -3
- package/src/mcp/tools/agent/build.ts +13 -21
- package/src/mcp/tools/agent/capture.ts +31 -115
- package/src/mcp/tools/agent/eval.ts +19 -155
- package/src/mcp/tools/agent/find.ts +33 -36
- package/src/mcp/tools/agent/proof.ts +87 -160
- package/src/mcp/tools/agent/propose.ts +93 -85
- package/src/mcp/tools/agent/replay.ts +32 -42
- package/src/mcp/tools/agent/session.ts +4 -5
- package/src/mcp/tools/agent/shared.ts +152 -0
- package/src/old/client.ts +56 -0
- package/src/old/login-cdp.ts +29 -23
- package/src/old/sdk-client.ts +105 -0
- package/src/old/to-register.ts +98 -6
- package/src/old/tools/authenticate.ts +3 -11
- package/src/old/tools/build.ts +12 -0
- package/src/old/tools/get-provider-info.ts +91 -0
- package/src/old/tools/get-version-config.ts +56 -0
- package/src/old/tools/publish.ts +244 -1
- package/src/old/tools/update-provider-metadata.ts +72 -0
- package/src/proof/attestor.ts +16 -4
- package/src/proof/debug-response-dump.ts +70 -0
- package/src/proof/patch-domhandler.ts +68 -0
- package/src/proof/zk-files.ts +7 -6
- package/src/provider/dom-xpath.ts +225 -0
- package/src/provider/draft.ts +168 -35
- package/src/provider/index.ts +8 -0
- package/src/provider/injection-doc.ts +6 -0
- package/src/provider/matcher.ts +477 -357
- package/src/provider/schema.ts +7 -5
- package/src/tools/registry.ts +92 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Side-effect module: make `domhandler` nodes DOM-like for the `xpath` engine.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS
|
|
4
|
+
// `@reclaimprotocol/attestor-core`'s published `lib` bundles its own inlined
|
|
5
|
+
// copy of `domhandler` and applies a DOM-compat patch (nodeName / localName +
|
|
6
|
+
// a NamedNodeMap `item()` shim) to THAT copy. But at proof time the HTML tree
|
|
7
|
+
// is built by the *external* `parse5-htmlparser2-tree-adapter`, which uses the
|
|
8
|
+
// separately-installed `domhandler` — a different, UNPATCHED copy. The `xpath`
|
|
9
|
+
// engine then fails `isNodeLike()` on every node (it needs a string `nodeName`)
|
|
10
|
+
// and throws "Context node does not appear to be a valid DOM node", so ALL
|
|
11
|
+
// xPath / xPath+jsonPath response redactions fail during `runAttestorProof`'s
|
|
12
|
+
// receipt validation. (Regex-only redactions escape it — they never touch the
|
|
13
|
+
// parse5/xpath path.) Running attestor-core from source works because there is
|
|
14
|
+
// then a single shared `domhandler`; the bug is only in the bundled `lib`.
|
|
15
|
+
//
|
|
16
|
+
// Rather than fork attestor-core, apply the identical patch to the external
|
|
17
|
+
// `domhandler` the adapter actually uses, so its tree nodes are DOM-like. This
|
|
18
|
+
// mirrors attestor-core's own `patch-parse5-tree`. Idempotent and
|
|
19
|
+
// version-agnostic. Import it before any proving path runs.
|
|
20
|
+
//
|
|
21
|
+
// Remove once attestor-core externalizes `domhandler` in its build (one shared
|
|
22
|
+
// copy → its own patch suffices).
|
|
23
|
+
import { Element, Node } from 'domhandler'
|
|
24
|
+
|
|
25
|
+
const PATCH_FLAG = Symbol.for('reclaim.domhandler.domCompatPatched')
|
|
26
|
+
const proto = Node.prototype as unknown as Record<PropertyKey, unknown>
|
|
27
|
+
|
|
28
|
+
if(!proto[PATCH_FLAG]) {
|
|
29
|
+
// `xpath`'s isNodeLike() requires a string `nodeName`; domhandler nodes carry
|
|
30
|
+
// the tag under `.name`.
|
|
31
|
+
Object.defineProperty(Node.prototype, 'nodeName', {
|
|
32
|
+
configurable: true,
|
|
33
|
+
get(this: { name?: string }) {
|
|
34
|
+
return this.name
|
|
35
|
+
},
|
|
36
|
+
})
|
|
37
|
+
Object.defineProperty(Node.prototype, 'localName', {
|
|
38
|
+
configurable: true,
|
|
39
|
+
get(this: { name?: string }) {
|
|
40
|
+
return this.name
|
|
41
|
+
},
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
// Attribute-predicate steps (`[@id=…]`, `contains(@class,…)`) walk the
|
|
45
|
+
// NamedNodeMap via `.item(i)`; give each returned attribute node the DOM
|
|
46
|
+
// shape `xpath` expects (nodeType 2 + localName).
|
|
47
|
+
const attrsGetter =
|
|
48
|
+
Object.getOwnPropertyDescriptor(Element.prototype, 'attributes')?.get
|
|
49
|
+
if(attrsGetter) {
|
|
50
|
+
Object.defineProperty(Element.prototype, 'attributes', {
|
|
51
|
+
configurable: true,
|
|
52
|
+
get(this: unknown) {
|
|
53
|
+
const attrs = attrsGetter.call(this) as
|
|
54
|
+
(Record<number, { name: string }> & {
|
|
55
|
+
item?: (i: number) => unknown
|
|
56
|
+
})
|
|
57
|
+
attrs.item = (i: number) => {
|
|
58
|
+
const attr = attrs[i]
|
|
59
|
+
return { ...attr, nodeType: 2, localName: attr.name }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return attrs
|
|
63
|
+
},
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
proto[PATCH_FLAG] = true
|
|
68
|
+
}
|
package/src/proof/zk-files.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Lazy download of the @reclaimprotocol/zk-symmetric-crypto circuit resources
|
|
2
|
-
//
|
|
3
|
-
//
|
|
2
|
+
// the proof engines need (the downloader fetches every engine — stwo, snarkjs,
|
|
3
|
+
// gnark — as one bundle). These are ~280MB and are NOT fetched in a
|
|
4
|
+
// `postinstall` (that blocked the MCP server's first launch past the connect
|
|
4
5
|
// timeout — see the install notes). Instead we fetch them on the first proof,
|
|
5
6
|
// once, so the server starts immediately and only `run_proof` pays the cost.
|
|
6
7
|
|
|
@@ -43,12 +44,12 @@ async function download(): Promise<void> {
|
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
// mainPath is <pkg>/lib/index.js → resources live at <pkg>/resources, the
|
|
46
|
-
// downloader at <pkg>/lib/scripts/download-files.js.
|
|
47
|
-
//
|
|
48
|
-
//
|
|
47
|
+
// downloader at <pkg>/lib/scripts/download-files.js. Proofs default to the
|
|
48
|
+
// stwo (WASM) engine, so its resource dir being present is the signal that
|
|
49
|
+
// the circuits are ready (the downloader fetches all engines together).
|
|
49
50
|
const libDir = dirname(mainPath)
|
|
50
51
|
const pkgDir = dirname(libDir)
|
|
51
|
-
if(existsSync(join(pkgDir, 'resources', '
|
|
52
|
+
if(existsSync(join(pkgDir, 'resources', 'stwo'))) {
|
|
52
53
|
return
|
|
53
54
|
}
|
|
54
55
|
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// Generate a verified xPath to the element holding a target value, using the
|
|
2
|
+
// SAME parser the attestor uses (parse5 + parse5-htmlparser2-tree-adapter) and
|
|
3
|
+
// self-verifying each candidate with the same `xpath` engine. Because the path
|
|
4
|
+
// is built from — and checked against — the attestor's own parse of the raw
|
|
5
|
+
// response body, it provably resolves to that element at proof time, on any
|
|
6
|
+
// site (no per-endpoint logic, no fragile byte-offset counting).
|
|
7
|
+
//
|
|
8
|
+
// The domhandler patch makes the tree's nodes DOM-like for `xpath` (see
|
|
9
|
+
// patch-domhandler.ts — mirrors attestor-core's patch-parse5-tree).
|
|
10
|
+
import { type Element, isTag, isText, type Node } from 'domhandler'
|
|
11
|
+
import { parse } from 'parse5'
|
|
12
|
+
import { adapter as htmlAdapter } from 'parse5-htmlparser2-tree-adapter'
|
|
13
|
+
import xpath from 'xpath'
|
|
14
|
+
import '../proof/patch-domhandler.ts'
|
|
15
|
+
|
|
16
|
+
/** Identifying attributes stable enough to anchor an xPath on (value-bearing
|
|
17
|
+
* attrs like `content`/`href`/`value` are excluded). */
|
|
18
|
+
const IDENT_ATTRS =
|
|
19
|
+
['name', 'property', 'itemprop', 'data-testid', 'rel', 'for']
|
|
20
|
+
|
|
21
|
+
export interface DomAnchor {
|
|
22
|
+
xPath: string
|
|
23
|
+
/** Byte range of the element in the source html (parse5 source locations),
|
|
24
|
+
* matching the slice the attestor reveals for this xPath. */
|
|
25
|
+
start: number
|
|
26
|
+
end: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A verified xPath to the tightest element containing one of `targets`, or
|
|
31
|
+
* undefined if the value isn't present in the parsed tree. Prefers a short
|
|
32
|
+
* stable selector (unique id → identifying attr → class), falling back to an
|
|
33
|
+
* absolute child-indexed path (`/html[1]/body[1]/…/option[1]`). Every candidate
|
|
34
|
+
* is checked with the real `xpath` engine and only returned if it resolves,
|
|
35
|
+
* first-match, to that exact element.
|
|
36
|
+
*/
|
|
37
|
+
export function elementAnchor(
|
|
38
|
+
html: string,
|
|
39
|
+
targets: string[],
|
|
40
|
+
): DomAnchor | undefined {
|
|
41
|
+
let doc: unknown
|
|
42
|
+
try {
|
|
43
|
+
doc = parse(html, {
|
|
44
|
+
treeAdapter: htmlAdapter,
|
|
45
|
+
sourceCodeLocationInfo: true,
|
|
46
|
+
})
|
|
47
|
+
} catch{
|
|
48
|
+
return undefined
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// `xpath` needs a named root node to start from (as attestor-core does).
|
|
52
|
+
;(doc as { name: string }).name = 'root'
|
|
53
|
+
|
|
54
|
+
const el = findElement(doc as Node, targets)
|
|
55
|
+
if(!el) {
|
|
56
|
+
return undefined
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const { startIndex, endIndex } = el
|
|
60
|
+
if(startIndex === null || endIndex === null) {
|
|
61
|
+
return undefined
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
for(const candidate of candidateXPaths(el)) {
|
|
65
|
+
if(matchesUniquely(doc, candidate, el)) {
|
|
66
|
+
// `[startIndex, endIndex)` is exactly the slice the attestor reveals for
|
|
67
|
+
// this xPath (its getNodeRange uses these same parse5 source locations).
|
|
68
|
+
return { xPath: candidate, start: startIndex, end: endIndex }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return undefined
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The tightest element that DIRECTLY holds a target. Prefers a COMPLETE-value
|
|
76
|
+
* occurrence — an attribute whose value IS the target, or a text child that IS
|
|
77
|
+
* the target — over one where the target is merely embedded in a longer string
|
|
78
|
+
* (e.g. a userid inside a `1234567+user@…` noreply address). The complete
|
|
79
|
+
* field is the semantic source and yields a portable anchor; the embedded one
|
|
80
|
+
* is incidental. Smallest byte span wins within the chosen tier. */
|
|
81
|
+
function findElement(root: Node, targets: string[]): Element | undefined {
|
|
82
|
+
const complete: Element[] = []
|
|
83
|
+
const embedded: Element[] = []
|
|
84
|
+
|
|
85
|
+
const walk = (node: Node): void => {
|
|
86
|
+
if(isTag(node)) {
|
|
87
|
+
const el = node
|
|
88
|
+
const texts = el.children.filter(isText).map((c) => c.data)
|
|
89
|
+
const attrs = Object.values(el.attribs ?? {})
|
|
90
|
+
const isTarget = (v: string): boolean => targets.some((t) => v === t)
|
|
91
|
+
const hasTarget =
|
|
92
|
+
(v: string): boolean => targets.some((t) => v.includes(t))
|
|
93
|
+
if(texts.some((t) => isTarget(t.trim())) || attrs.some(isTarget)) {
|
|
94
|
+
complete.push(el)
|
|
95
|
+
} else if(texts.some(hasTarget) || attrs.some(hasTarget)) {
|
|
96
|
+
embedded.push(el)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if('children' in node && Array.isArray(node.children)) {
|
|
101
|
+
for(const child of node.children) {
|
|
102
|
+
walk(child)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
walk(root)
|
|
108
|
+
return smallestElement(complete.length ? complete : embedded)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Byte span of an element (0 when source locations are missing). */
|
|
112
|
+
function elementSpan(e: Element): number {
|
|
113
|
+
return (e.endIndex ?? 0) - (e.startIndex ?? 0)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The element with the smallest byte span (the tightest / deepest match). */
|
|
117
|
+
function smallestElement(els: Element[]): Element | undefined {
|
|
118
|
+
return els
|
|
119
|
+
.filter((e) => e.startIndex !== null && e.endIndex !== null)
|
|
120
|
+
.sort((a, b) => elementSpan(a) - elementSpan(b))
|
|
121
|
+
.at(0)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Candidate xPaths for `el`, best (most stable / shortest) first. */
|
|
125
|
+
function* candidateXPaths(el: Element): Generator<string> {
|
|
126
|
+
const tag = el.name
|
|
127
|
+
const a = el.attribs ?? {}
|
|
128
|
+
if(a.id) {
|
|
129
|
+
yield `//${tag}[@id=${xpathLiteral(a.id)}]`
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
for(const name of IDENT_ATTRS) {
|
|
133
|
+
if(a[name]) {
|
|
134
|
+
yield `//${tag}[@${name}=${xpathLiteral(a[name])}]`
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if(a.class) {
|
|
139
|
+
const distinctive = a.class
|
|
140
|
+
.split(/\s+/)
|
|
141
|
+
.filter(Boolean)
|
|
142
|
+
.sort((x, y) => y.length - x.length)[0]
|
|
143
|
+
if(distinctive) {
|
|
144
|
+
yield `//${tag}[contains(@class, ${xpathLiteral(distinctive)})]`
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// A uniquely-identified DIRECT child pins the parent without a positional
|
|
149
|
+
// path (`//span[span[@id="labels"]]`). Self-verification (firstSelected)
|
|
150
|
+
// discards it when the child id/attr isn't unique — e.g. GitHub reuses
|
|
151
|
+
// `id="labels"` per email row — so it never silently matches the wrong node.
|
|
152
|
+
for(const child of el.children) {
|
|
153
|
+
if(!isTag(child)) {
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const ca = child.attribs ?? {}
|
|
158
|
+
if(ca.id) {
|
|
159
|
+
yield `//${tag}[${child.name}[@id=${xpathLiteral(ca.id)}]]`
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
for(const name of IDENT_ATTRS) {
|
|
163
|
+
if(ca[name]) {
|
|
164
|
+
yield `//${tag}[${child.name}[@${name}=${xpathLiteral(ca[name])}]]`
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
yield absolutePath(el)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Chrome "full XPath"-style absolute path: `/html[1]/body[1]/…/tag[n]`, where
|
|
173
|
+
* n is the element's 1-based position among same-tag siblings. */
|
|
174
|
+
function absolutePath(el: Element): string {
|
|
175
|
+
const parts: string[] = []
|
|
176
|
+
let node: Node | null = el
|
|
177
|
+
while(node && isTag(node)) {
|
|
178
|
+
const tag = node.name
|
|
179
|
+
let index = 1
|
|
180
|
+
for(let sib = node.prev; sib; sib = sib.prev) {
|
|
181
|
+
if(isTag(sib) && sib.name === tag) {
|
|
182
|
+
index++
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
parts.unshift(`/${tag}[${index}]`)
|
|
187
|
+
node = node.parent
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return parts.join('')
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** True when `expression` selects EXACTLY `el` (one match) against `doc`. The
|
|
194
|
+
* attestor applies the redaction regex to EVERY xPath match and throws if any
|
|
195
|
+
* fails, so a non-unique selector (e.g. a class shared by sibling rows) is
|
|
196
|
+
* unusable — it must resolve to the single target element. */
|
|
197
|
+
function matchesUniquely(doc: unknown, expression: string, el: Node): boolean {
|
|
198
|
+
try {
|
|
199
|
+
const engine = xpath as unknown as {
|
|
200
|
+
parse(e: string): { select(o: unknown): Node[] }
|
|
201
|
+
}
|
|
202
|
+
const nodes = engine.parse(expression).select({
|
|
203
|
+
node: doc,
|
|
204
|
+
allowAnyNamespaceForNoPrefix: true,
|
|
205
|
+
})
|
|
206
|
+
return nodes.length === 1 && nodes[0] === el
|
|
207
|
+
} catch{
|
|
208
|
+
return false
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Quote a string as an XPath 1.0 string literal (concat() when it holds both
|
|
213
|
+
* quote characters). */
|
|
214
|
+
function xpathLiteral(s: string): string {
|
|
215
|
+
if(!s.includes('"')) {
|
|
216
|
+
return `"${s}"`
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if(!s.includes("'")) {
|
|
220
|
+
return `'${s}'`
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const parts = s.split('"').map((p) => `"${p}"`)
|
|
224
|
+
return `concat(${parts.join(', \'"\', ')})`
|
|
225
|
+
}
|
package/src/provider/draft.ts
CHANGED
|
@@ -3,35 +3,129 @@ import type {
|
|
|
3
3
|
CapturedRequest,
|
|
4
4
|
OprfMode,
|
|
5
5
|
ReclaimProvider,
|
|
6
|
+
ResponseMatch,
|
|
7
|
+
ResponseRedaction,
|
|
6
8
|
SecretParamRef,
|
|
7
9
|
} from './schema.ts'
|
|
8
10
|
import { splitSecrets } from './secrets.ts'
|
|
9
11
|
|
|
12
|
+
/** One value to extract from the request: the captured `value`, a `name` (its
|
|
13
|
+
* param / capture-group), and an optional per-target OPRF `hash`. */
|
|
14
|
+
export interface DraftTarget {
|
|
15
|
+
value: string
|
|
16
|
+
name: string
|
|
17
|
+
hash?: OprfMode
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The realized extraction for one target — its value paired with the
|
|
21
|
+
* redaction that reveals it (absent when matched literally). Lets a consumer
|
|
22
|
+
* validate each value against its own selector (e.g. the cloud replay). */
|
|
23
|
+
export interface Extraction {
|
|
24
|
+
name: string
|
|
25
|
+
value: string
|
|
26
|
+
redaction?: ResponseRedaction
|
|
27
|
+
}
|
|
28
|
+
|
|
10
29
|
export interface DraftResult {
|
|
11
30
|
provider: ReclaimProvider
|
|
12
31
|
secretRefs: SecretParamRef[]
|
|
32
|
+
/** One entry per target, in `responseMatches` order. */
|
|
33
|
+
extractions: Extraction[]
|
|
13
34
|
}
|
|
14
35
|
|
|
15
|
-
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
|
|
36
|
+
/** Request headers that must NOT survive into the provider params. Two kinds:
|
|
37
|
+
* - Conditional headers (`if-none-match`/`if-modified-since`/…): a captured
|
|
38
|
+
* cached ETag/date makes the origin answer 304/412 when the attestor
|
|
39
|
+
* re-issues, and the attestor treats non-2xx as failure. We always want the
|
|
40
|
+
* full fresh response.
|
|
41
|
+
* - Transfer headers the ATTESTOR sets itself (`connection: close`,
|
|
42
|
+
* `accept-encoding: identity`). Passing our own duplicates/collides with the
|
|
43
|
+
* request the attestor builds, so leave them out — the attestor already
|
|
44
|
+
* forces `identity` (uncompressed, so its byte-matching works). */
|
|
45
|
+
const STRIP_REQUEST_HEADERS = new Set([
|
|
46
|
+
'if-none-match',
|
|
47
|
+
'if-modified-since',
|
|
48
|
+
'if-match',
|
|
49
|
+
'if-unmodified-since',
|
|
50
|
+
'if-range',
|
|
51
|
+
'connection',
|
|
52
|
+
'accept-encoding',
|
|
53
|
+
])
|
|
54
|
+
|
|
55
|
+
/** Drop the headers the attestor manages or that would 304 at proof time
|
|
56
|
+
* (see {@link STRIP_REQUEST_HEADERS}); keep every other captured header so the
|
|
57
|
+
* attestor's replay looks like the browser's request. */
|
|
58
|
+
function sanitizeRequestHeaders(
|
|
20
59
|
headers: Record<string, string>,
|
|
21
60
|
): Record<string, string> {
|
|
22
61
|
const out: Record<string, string> = {}
|
|
23
62
|
for(const [k, v] of Object.entries(headers)) {
|
|
24
|
-
if(k.toLowerCase()
|
|
63
|
+
if(STRIP_REQUEST_HEADERS.has(k.toLowerCase())) {
|
|
25
64
|
continue
|
|
26
65
|
}
|
|
27
66
|
|
|
28
67
|
out[k] = v
|
|
29
68
|
}
|
|
30
69
|
|
|
31
|
-
out['Accept-Encoding'] = 'identity'
|
|
32
70
|
return out
|
|
33
71
|
}
|
|
34
72
|
|
|
73
|
+
/** Reject a draft that bakes a captured per-user value as a LITERAL into a
|
|
74
|
+
* match or redaction — either a neighbouring target's value or a target's own
|
|
75
|
+
* value left un-templated. The matcher builds each target's window
|
|
76
|
+
* independently and drops the per-user WORDS it can see, but it cannot know a
|
|
77
|
+
* neighbouring value is itself user-specific (e.g. a name sharing a text node
|
|
78
|
+
* with an id — a `<b>{{id}}</b>` sitting beside a `<td>{{name}}</td>`).
|
|
79
|
+
* Cross-checking every value against every match here catches that: a portable
|
|
80
|
+
* provider contains NO captured value as a literal — each appears only as its
|
|
81
|
+
* `{{param}}`. Throwing discards the draft so the agent re-captures or
|
|
82
|
+
* re-proposes rather than shipping a provider pinned to this one user. */
|
|
83
|
+
export function assertPortableMatches(
|
|
84
|
+
matches: ResponseMatch[],
|
|
85
|
+
redactions: ResponseRedaction[],
|
|
86
|
+
targets: DraftTarget[],
|
|
87
|
+
): void {
|
|
88
|
+
// Guard only values long enough to be plausibly user-specific; shorter runs
|
|
89
|
+
// (single digits, 2-3 char tokens) collide with ordinary structural bytes.
|
|
90
|
+
const MIN_ALNUM = 4
|
|
91
|
+
const guarded = targets
|
|
92
|
+
.map((t) => t.value)
|
|
93
|
+
.filter((v) => v.replace(/[^A-Za-z0-9]/g, '').length >= MIN_ALNUM)
|
|
94
|
+
if(!guarded.length) {
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const stripParams = (s: string): string => s.replace(/\{\{[^}]*\}\}/g, '')
|
|
99
|
+
const scan = (text: string, where: string, own: string | null): void => {
|
|
100
|
+
const bare = stripParams(text)
|
|
101
|
+
for(const v of guarded) {
|
|
102
|
+
if(bare.includes(v)) {
|
|
103
|
+
const kind = v === own
|
|
104
|
+
? 'its own value un-templated'
|
|
105
|
+
: "a neighbouring target's per-user value"
|
|
106
|
+
throw new Error(
|
|
107
|
+
`drafted ${where} bakes ${kind} ("${v}") as a literal — the `
|
|
108
|
+
+ 'provider would be pinned to this user. Re-capture or re-propose.',
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
for(const [i, m] of matches.entries()) {
|
|
115
|
+
scan(
|
|
116
|
+
m.value,
|
|
117
|
+
`responseMatch for "${targets[i]?.name ?? '?'}"`,
|
|
118
|
+
targets[i]?.value ?? null,
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
for(const r of redactions) {
|
|
123
|
+
if(r.regex) {
|
|
124
|
+
scan(r.regex, 'responseRedaction regex', null)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
35
129
|
/** Derive a `paramValues` key (and capture-group name) from the provider
|
|
36
130
|
* name's last alphanumeric token. `"GitHub Username"` → `"username"`,
|
|
37
131
|
* `"x-followers"` → `"followers"`. */
|
|
@@ -43,7 +137,7 @@ function deriveParamName(providerName: string): string {
|
|
|
43
137
|
|
|
44
138
|
export function draftProvider(
|
|
45
139
|
req: CapturedRequest,
|
|
46
|
-
|
|
140
|
+
targets: DraftTarget[],
|
|
47
141
|
name: string,
|
|
48
142
|
hash?: OprfMode,
|
|
49
143
|
): DraftResult {
|
|
@@ -51,31 +145,70 @@ export function draftProvider(
|
|
|
51
145
|
throw new Error(`captured request ${req.requestId} has no response body`)
|
|
52
146
|
}
|
|
53
147
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
148
|
+
if(targets.length === 0) {
|
|
149
|
+
throw new Error('draftProvider needs at least one target')
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// One request → N matches + N redactions. Synthesize each target against the
|
|
153
|
+
// SAME response body; a distinct param name per target keeps their capture
|
|
154
|
+
// groups + paramValues from colliding.
|
|
155
|
+
const matches: ResponseMatch[] = []
|
|
156
|
+
const redactions: ResponseRedaction[] = []
|
|
157
|
+
const extractions: Extraction[] = []
|
|
158
|
+
let paramValues: Record<string, string> | undefined
|
|
159
|
+
const seenParams = new Set<string>()
|
|
160
|
+
for(const t of targets) {
|
|
161
|
+
const paramName = deriveParamName(t.name)
|
|
162
|
+
if(seenParams.has(paramName)) {
|
|
163
|
+
throw new Error(
|
|
164
|
+
`two targets resolve to the same param "${paramName}"; `
|
|
165
|
+
+ 'give them distinct names',
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
seenParams.add(paramName)
|
|
170
|
+
const synthesis = synthesizeMatcher(
|
|
171
|
+
req.responseBody,
|
|
172
|
+
req.contentType,
|
|
173
|
+
t.value,
|
|
174
|
+
paramName,
|
|
175
|
+
t.hash ?? hash,
|
|
66
176
|
)
|
|
177
|
+
if(!synthesis) {
|
|
178
|
+
throw new Error(
|
|
179
|
+
`target value "${t.value}" not found in`
|
|
180
|
+
+ ` response body for request ${req.requestId}`,
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
matches.push(synthesis.match)
|
|
185
|
+
if(synthesis.redaction) {
|
|
186
|
+
redactions.push(synthesis.redaction)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if(synthesis.paramValues) {
|
|
190
|
+
paramValues = { ...paramValues, ...synthesis.paramValues }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
extractions.push({
|
|
194
|
+
name: paramName,
|
|
195
|
+
value: t.value,
|
|
196
|
+
...(synthesis.redaction ? { redaction: synthesis.redaction } : {}),
|
|
197
|
+
})
|
|
67
198
|
}
|
|
68
199
|
|
|
200
|
+
// Portability gate: never ship a draft that bakes a per-user value literally.
|
|
201
|
+
assertPortableMatches(matches, redactions, targets)
|
|
202
|
+
|
|
69
203
|
const { secrets, publics } = splitSecrets(req.requestHeaders)
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
// in the TLS replay path)
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
const headersForProvider = withIdentityEncoding(publics)
|
|
204
|
+
// Sanitize the public headers before they enter the provider. Two problems
|
|
205
|
+
// a captured browser request carries: (1) `accept-encoding: gzip, …`, which
|
|
206
|
+
// makes the attestor match against compressed bytes (it does no transparent
|
|
207
|
+
// decompression in the TLS replay path) — pin it to identity; (2) conditional
|
|
208
|
+
// headers (`if-none-match`/`if-modified-since`), which make the origin answer
|
|
209
|
+
// 304 at proof time and fail the proof. Sanitizing here also keeps
|
|
210
|
+
// `replay_request` and the proof looking at the same full-200 body shape.
|
|
211
|
+
const headersForProvider = sanitizeRequestHeaders(publics)
|
|
79
212
|
const secretRefs: SecretParamRef[] = Object.keys(secrets).map((name) => ({
|
|
80
213
|
name,
|
|
81
214
|
source: name.toLowerCase() === 'cookie' ? 'cookie' : 'header',
|
|
@@ -87,19 +220,19 @@ export function draftProvider(
|
|
|
87
220
|
url: req.url,
|
|
88
221
|
method: req.method as ReclaimProvider['method'],
|
|
89
222
|
headers: headersForProvider,
|
|
90
|
-
responseMatches:
|
|
223
|
+
responseMatches: matches,
|
|
91
224
|
}
|
|
92
|
-
if(
|
|
93
|
-
provider.responseRedactions =
|
|
225
|
+
if(redactions.length) {
|
|
226
|
+
provider.responseRedactions = redactions
|
|
94
227
|
}
|
|
95
228
|
|
|
96
|
-
if(
|
|
97
|
-
provider.paramValues =
|
|
229
|
+
if(paramValues) {
|
|
230
|
+
provider.paramValues = paramValues
|
|
98
231
|
}
|
|
99
232
|
|
|
100
233
|
if(req.requestBody) {
|
|
101
234
|
provider.body = req.requestBody
|
|
102
235
|
}
|
|
103
236
|
|
|
104
|
-
return { provider, secretRefs }
|
|
237
|
+
return { provider, secretRefs, extractions }
|
|
105
238
|
}
|
package/src/provider/index.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
// Provider primitives shared with the app's capture pipeline:
|
|
2
2
|
// the attestor-derived recipe types, secret classification, and the GraphQL
|
|
3
3
|
// op parser. Exposed as `@reclaimprotocol/agent/provider`.
|
|
4
|
+
// Provider synthesis from a captured request (the `propose_provider` core):
|
|
5
|
+
// drafts the canonical responseMatches/redactions + paramValues + secret refs.
|
|
6
|
+
export { draftProvider } from './draft.ts'
|
|
7
|
+
export type { DraftResult } from './draft.ts'
|
|
8
|
+
export { detectConcerns } from './constraints.ts'
|
|
4
9
|
export { extractGraphqlOp, looksLikeGraphqlEndpoint } from './graphql.ts'
|
|
5
10
|
export type {
|
|
6
11
|
CapturedRequest,
|
|
@@ -13,6 +18,9 @@ export type {
|
|
|
13
18
|
SecretParamRef,
|
|
14
19
|
} from './schema.ts'
|
|
15
20
|
export { classifyHeader, splitSecrets } from './secrets.ts'
|
|
21
|
+
// Recipe → version `requests[]` (a `RequestSelection`) — the same mapping the
|
|
22
|
+
// MCP publish uses, so the cloud authoring publish stays byte-identical.
|
|
23
|
+
export { providerToRequestSelection } from './to-version.ts'
|
|
16
24
|
// Re-exported attestor-core provider primitives (the registry, the param hash,
|
|
17
25
|
// and the param type) so the builder imports them via the agent rather than
|
|
18
26
|
// depending on @reclaimprotocol/attestor-core directly.
|
|
@@ -11,6 +11,12 @@
|
|
|
11
11
|
* standard APIs; the client already intercepts page requests) and treats
|
|
12
12
|
* `window.Reclaim` as a rarely-needed edge-case bridge, pointing at the
|
|
13
13
|
* authoritative `src/support/env.d.ts` rather than reproducing its surface.
|
|
14
|
+
* It also covers the HAWKEYE-style live-interception path (`injectionType` /
|
|
15
|
+
* `interceptorType`, `disableRequestReplay` / `isDocumentRequestReplayEnabled`)
|
|
16
|
+
* and `bodySniff`, reverse-engineered from the old-devtools Prisma schema,
|
|
17
|
+
* controller code, and dashboard field hints — neither publish tool exposes
|
|
18
|
+
* these as inputs (both only build the standard replay flow), so this is
|
|
19
|
+
* read-the-field-and-recognize-it documentation, not authoring guidance.
|
|
14
20
|
*/
|
|
15
21
|
import { readFileSync } from 'node:fs'
|
|
16
22
|
import { fileURLToPath } from 'node:url'
|