@reclaimprotocol/agent 0.1.35 → 0.1.37
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 +8 -8
- package/lib/authoring/backend.d.ts +12 -1
- package/lib/authoring/backend.js +3 -0
- package/lib/authoring/tools.js +33 -5
- package/lib/authoring/util.d.ts +43 -0
- package/lib/authoring/util.js +54 -0
- package/lib/cdp/chrome-for-testing.d.ts +14 -0
- package/lib/cdp/chrome-for-testing.js +43 -0
- package/lib/cdp/geo.d.ts +3 -0
- package/lib/cdp/geo.js +27 -15
- package/lib/cdp/launcher.d.ts +79 -1
- package/lib/cdp/launcher.js +244 -24
- package/lib/logger.js +3 -1
- package/lib/mcp/api/operations.gen.js +1 -1
- package/lib/mcp/instructions.js +3 -3
- package/lib/mcp/start.js +17 -2
- package/lib/mcp/tools/agent/attach.d.ts +6 -0
- package/lib/mcp/tools/agent/attach.js +58 -118
- package/lib/mcp/tools/agent/build.js +6 -0
- package/lib/mcp/tools/agent/capture.js +33 -10
- package/lib/mcp/tools/agent/check-attestor.js +15 -7
- package/lib/mcp/tools/agent/draft-tools.d.ts +3 -0
- package/lib/mcp/tools/agent/draft-tools.js +170 -0
- package/lib/mcp/tools/agent/pod-proof.js +5 -1
- package/lib/mcp/tools/agent/proof.d.ts +28 -0
- package/lib/mcp/tools/agent/proof.js +72 -0
- package/lib/mcp/tools/agent/shared.d.ts +8 -1
- package/lib/mcp/tools/agent/shared.js +98 -19
- package/lib/mcp/tools/agent/start-integration.d.ts +2 -1
- package/lib/mcp/tools/agent/start-integration.js +10 -6
- package/lib/mcp/tools/authenticate/authenticate.d.ts +48 -10
- package/lib/mcp/tools/authenticate/authenticate.js +212 -116
- package/lib/mcp/tools/authenticate/build.d.ts +2 -2
- package/lib/mcp/tools/authenticate/build.js +6 -3
- package/lib/mcp/tools/authenticate/builder-access.js +5 -2
- package/lib/mcp/tools/authenticate/eth-key.d.ts +13 -2
- package/lib/mcp/tools/authenticate/eth-key.js +29 -5
- package/lib/mcp/tools/authenticate/index.d.ts +1 -1
- package/lib/mcp/tools/authenticate/index.js +1 -1
- package/lib/mcp/tools/authenticate/issue-org-keypair.d.ts +10 -0
- package/lib/mcp/tools/authenticate/issue-org-keypair.js +82 -0
- package/lib/mcp/tools/authenticate/oauth-config.d.ts +31 -0
- package/lib/mcp/tools/authenticate/oauth-config.js +67 -0
- package/lib/mcp/tools/authenticate/resolve-owner-key.d.ts +4 -5
- package/lib/mcp/tools/authenticate/resolve-owner-key.js +40 -36
- package/lib/mcp/tools/authenticate/session-cache.d.ts +25 -1
- package/lib/mcp/tools/authenticate/session-cache.js +95 -4
- package/lib/mcp/tools/mode/mode.js +6 -6
- package/lib/mcp/tools/verification/proxy-callback-events.d.ts +21 -3
- package/lib/mcp/tools/verification/proxy-callback-events.js +128 -45
- package/lib/old/login-cdp.js +2 -3
- package/lib/proof/attestor.js +2 -3
- package/lib/proof/check-attestor.d.ts +12 -2
- package/lib/proof/check-attestor.js +40 -10
- package/lib/proof/check-matchers.d.ts +36 -0
- package/lib/proof/check-matchers.js +161 -0
- package/lib/proof/zk-files.d.ts +50 -11
- package/lib/proof/zk-files.js +259 -63
- package/lib/provider/draft.d.ts +24 -1
- package/lib/provider/draft.js +135 -0
- package/lib/provider/matcher.js +171 -4
- package/package.json +6 -4
- package/skill/SKILL.builder.md +7 -2
- package/skill/SKILL.common.md +90 -70
- package/skill/SKILL.old.md +3 -3
- package/skill/builder-mode.md +44 -6
- package/skill/old-mode.md +18 -13
- package/skill/start-integration.md +51 -35
- package/skill/verification-client-integration.md +87 -15
- package/src/authoring/backend.ts +12 -1
- package/src/authoring/tools.ts +48 -7
- package/src/authoring/util.ts +64 -0
- package/src/cdp/chrome-for-testing.ts +66 -0
- package/src/cdp/geo.ts +36 -21
- package/src/cdp/launcher.ts +328 -24
- package/src/logger.ts +3 -1
- package/src/mcp/api/operations.gen.ts +1 -1
- package/src/mcp/instructions.ts +3 -3
- package/src/mcp/start.ts +20 -1
- package/src/mcp/tools/agent/attach.ts +65 -128
- package/src/mcp/tools/agent/build.ts +7 -0
- package/src/mcp/tools/agent/capture.ts +54 -11
- package/src/mcp/tools/agent/check-attestor.ts +15 -7
- package/src/mcp/tools/agent/draft-tools.ts +234 -0
- package/src/mcp/tools/agent/pod-proof.ts +5 -1
- package/src/mcp/tools/agent/proof.ts +110 -1
- package/src/mcp/tools/agent/shared.ts +130 -19
- package/src/mcp/tools/agent/start-integration.ts +10 -6
- package/src/mcp/tools/authenticate/authenticate.ts +276 -131
- package/src/mcp/tools/authenticate/build.ts +6 -3
- package/src/mcp/tools/authenticate/builder-access.ts +5 -2
- package/src/mcp/tools/authenticate/eth-key.ts +41 -7
- package/src/mcp/tools/authenticate/index.ts +1 -1
- package/src/mcp/tools/authenticate/issue-org-keypair.ts +106 -0
- package/src/mcp/tools/authenticate/oauth-config.ts +85 -0
- package/src/mcp/tools/authenticate/resolve-owner-key.ts +42 -39
- package/src/mcp/tools/authenticate/session-cache.ts +125 -5
- package/src/mcp/tools/mode/mode.ts +6 -6
- package/src/mcp/tools/verification/proxy-callback-events.ts +151 -41
- package/src/old/login-cdp.ts +2 -3
- package/src/proof/attestor.ts +2 -3
- package/src/proof/check-attestor.ts +55 -12
- package/src/proof/check-matchers.ts +227 -0
- package/src/proof/zk-files.ts +357 -65
- package/src/provider/draft.ts +215 -7
- package/src/provider/matcher.ts +237 -4
package/src/proof/zk-files.ts
CHANGED
|
@@ -1,84 +1,376 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
1
|
+
// Download of the @reclaimprotocol/zk-symmetric-crypto circuit resources the
|
|
2
|
+
// proof engines read from disk. Never a `postinstall`: that blocked the MCP
|
|
3
|
+
// server's first launch past the connect timeout. Instead the MCP server starts
|
|
4
|
+
// the download in the background at launch (`startZkFilesDownload`), and every
|
|
5
|
+
// proof awaits it (`ensureZkFiles`) as the safety net.
|
|
6
|
+
//
|
|
7
|
+
// Proofs default to the stwo engine. Its resources are ~1.5MB, so we fetch only
|
|
8
|
+
// `resources/stwo` file by file from the package's pinned commit and check each
|
|
9
|
+
// file's git blob hash against the listing for that commit. When that fails
|
|
10
|
+
// (GitHub API rate limit, a changed layout), we fall back to the package's own
|
|
11
|
+
// downloader, which fetches the whole repository archive (~350MB).
|
|
12
|
+
// (In zk-symmetric-crypto 5.1.x the stwo prover loads its WASM from the npm
|
|
13
|
+
// package itself; `resources/stwo` is kept as the readiness signal.)
|
|
14
|
+
|
|
15
|
+
import { badGateway, internal } from '@hapi/boom'
|
|
16
|
+
import assert from 'node:assert'
|
|
17
|
+
import { spawn as nodeSpawn, type StdioOptions } from 'node:child_process'
|
|
18
|
+
import { createHash } from 'node:crypto'
|
|
19
|
+
import { existsSync, statSync } from 'node:fs'
|
|
20
|
+
import {
|
|
21
|
+
mkdir,
|
|
22
|
+
mkdtemp,
|
|
23
|
+
rename,
|
|
24
|
+
rm,
|
|
25
|
+
writeFile
|
|
26
|
+
} from 'node:fs/promises'
|
|
10
27
|
import { createRequire } from 'node:module'
|
|
11
28
|
import { dirname, join } from 'node:path'
|
|
29
|
+
import { pathToFileURL } from 'node:url'
|
|
12
30
|
|
|
13
|
-
const
|
|
31
|
+
const REPO = 'reclaimprotocol/zk-symmetric-crypto'
|
|
32
|
+
/** The stwo resource dir; its presence means the circuits are ready. */
|
|
33
|
+
const STWO_DIR = join('resources', 'stwo')
|
|
34
|
+
/** Where the package's downloader writes the archive, relative to its cwd. */
|
|
35
|
+
const ARCHIVE_PATH = join('zk-symmetric-crypto-download', 'repo.zip')
|
|
14
36
|
|
|
15
|
-
|
|
37
|
+
export type ZkFilesState =
|
|
38
|
+
| { status: 'idle' }
|
|
39
|
+
| {
|
|
40
|
+
status: 'downloading'
|
|
41
|
+
/** `stwo-files`: the ~1.5MB stwo files. `full-archive`: the ~350MB
|
|
42
|
+
* repository archive (fallback). */
|
|
43
|
+
method: 'stwo-files' | 'full-archive'
|
|
44
|
+
elapsedMs: number
|
|
45
|
+
bytesDownloaded?: number
|
|
46
|
+
totalBytes?: number
|
|
47
|
+
}
|
|
48
|
+
| { status: 'ready' }
|
|
49
|
+
| { status: 'failed', reason: string }
|
|
50
|
+
|
|
51
|
+
/** Minimal child process surface `download-files.js` is driven through. */
|
|
52
|
+
export interface ChildLike {
|
|
53
|
+
once(event: 'exit', listener: (code: number | null) => void): unknown
|
|
54
|
+
once(event: 'error', listener: (err: Error) => void): unknown
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface Progress {
|
|
58
|
+
method: 'stwo-files' | 'full-archive'
|
|
59
|
+
startedAt: number
|
|
60
|
+
bytesDownloaded?: number
|
|
61
|
+
totalBytes?: number
|
|
62
|
+
/** The archive file whose size is the progress (full-archive only). */
|
|
63
|
+
archivePath?: string
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ZkFilesDeps {
|
|
67
|
+
/** The zk-symmetric-crypto package root, or undefined when not installed. */
|
|
68
|
+
resolvePackageDir?: () => string | undefined
|
|
69
|
+
/** The pinned repository commit the package's resources come from. */
|
|
70
|
+
readCommit?: (pkgDir: string) => Promise<string>
|
|
71
|
+
fetch?: typeof fetch
|
|
72
|
+
spawn?: (
|
|
73
|
+
command: string,
|
|
74
|
+
args: string[],
|
|
75
|
+
options: { cwd: string, stdio: StdioOptions },
|
|
76
|
+
) => ChildLike
|
|
77
|
+
/** Progress lines. Defaults to stderr: stdout carries MCP JSON-RPC. */
|
|
78
|
+
log?: (message: string) => void
|
|
79
|
+
}
|
|
16
80
|
|
|
17
81
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* failed attempt clears the latch so a later proof can retry.
|
|
21
|
-
*
|
|
22
|
-
* Resolves silently when the resources are already present (the warm npx cache,
|
|
23
|
-
* or after a manual `npm run download:zk-files`) and when
|
|
24
|
-
* `@reclaimprotocol/zk-symmetric-crypto` isn't installed at all (SDK-only
|
|
25
|
-
* consumers have nothing to prove).
|
|
26
|
-
*
|
|
27
|
-
* All output goes to stderr (fd 2): the MCP server speaks JSON-RPC over stdout,
|
|
28
|
-
* so a stray write to fd 1 mid-session would corrupt the protocol.
|
|
82
|
+
* Build a downloader with its own state. The module exports one default
|
|
83
|
+
* instance; tests build their own with injected `spawn` and `fetch`.
|
|
29
84
|
*/
|
|
30
|
-
export function
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
85
|
+
export function makeZkFiles(deps: ZkFilesDeps = {}) {
|
|
86
|
+
const resolvePackageDir = deps.resolvePackageDir ?? resolveZkPackageDir
|
|
87
|
+
const readCommit = deps.readCommit ?? readPinnedCommit
|
|
88
|
+
const fetchFn = deps.fetch ?? fetch
|
|
89
|
+
const spawn = deps.spawn ?? nodeSpawn
|
|
90
|
+
const log = deps.log ?? ((message: string) => {
|
|
91
|
+
process.stderr.write(`[reclaim-agent] ${message}\n`)
|
|
34
92
|
})
|
|
35
|
-
return inFlight
|
|
36
|
-
}
|
|
37
93
|
|
|
38
|
-
|
|
39
|
-
let
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
94
|
+
let inFlight: Promise<void> | undefined
|
|
95
|
+
let failure: string | undefined
|
|
96
|
+
let ready = false
|
|
97
|
+
let progress: Progress | undefined
|
|
98
|
+
|
|
99
|
+
return { ensure, start, state }
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Ensure the circuit resources are on disk. Idempotent and
|
|
103
|
+
* concurrency-safe: one download runs at a time per process, and a failed
|
|
104
|
+
* attempt clears the latch so a later call retries.
|
|
105
|
+
*/
|
|
106
|
+
function ensure() {
|
|
107
|
+
inFlight ??= download()
|
|
108
|
+
.then(() => {
|
|
109
|
+
ready = true
|
|
110
|
+
failure = undefined
|
|
111
|
+
}, (err: unknown) => {
|
|
112
|
+
inFlight = undefined
|
|
113
|
+
failure = messageOf(err)
|
|
114
|
+
throw err
|
|
115
|
+
})
|
|
116
|
+
.finally(() => {
|
|
117
|
+
progress = undefined
|
|
118
|
+
})
|
|
119
|
+
return inFlight
|
|
44
120
|
}
|
|
45
121
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
// the circuits are ready (the downloader fetches all engines together).
|
|
50
|
-
const libDir = dirname(mainPath)
|
|
51
|
-
const pkgDir = dirname(libDir)
|
|
52
|
-
if(existsSync(join(pkgDir, 'resources', 'stwo'))) {
|
|
53
|
-
return
|
|
122
|
+
/** Start `ensure` in the background; `state` reports the outcome. */
|
|
123
|
+
function start() {
|
|
124
|
+
ensure().catch(() => {})
|
|
54
125
|
}
|
|
55
126
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
127
|
+
function state(): ZkFilesState {
|
|
128
|
+
if(ready) {
|
|
129
|
+
return { status: 'ready' }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if(progress) {
|
|
133
|
+
const bytesDownloaded = progress.archivePath
|
|
134
|
+
? fileSize(progress.archivePath)
|
|
135
|
+
: progress.bytesDownloaded
|
|
136
|
+
return {
|
|
137
|
+
status: 'downloading',
|
|
138
|
+
method: progress.method,
|
|
139
|
+
elapsedMs: Date.now() - progress.startedAt,
|
|
140
|
+
...(bytesDownloaded !== undefined ? { bytesDownloaded } : {}),
|
|
141
|
+
...(progress.totalBytes !== undefined
|
|
142
|
+
? { totalBytes: progress.totalBytes }
|
|
143
|
+
: {}),
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if(failure !== undefined) {
|
|
148
|
+
return { status: 'failed', reason: failure }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return { status: 'idle' }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function download() {
|
|
155
|
+
const pkgDir = resolvePackageDir()
|
|
156
|
+
// SDK-only consumers don't install the package: nothing to prove.
|
|
157
|
+
if(!pkgDir || existsSync(join(pkgDir, STWO_DIR))) {
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
progress = { method: 'stwo-files', startedAt: Date.now() }
|
|
163
|
+
await fetchStwoFiles(pkgDir, progress)
|
|
164
|
+
} catch(err) {
|
|
165
|
+
const stwoError = messageOf(err)
|
|
166
|
+
log(
|
|
167
|
+
`fetching the stwo proof files failed (${stwoError}); `
|
|
168
|
+
+ 'downloading the full circuit archive (~350MB, one-time)…',
|
|
169
|
+
)
|
|
170
|
+
progress = {
|
|
171
|
+
method: 'full-archive',
|
|
172
|
+
startedAt: Date.now(),
|
|
173
|
+
archivePath: join(pkgDir, ARCHIVE_PATH),
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
await runArchiveDownloader(pkgDir)
|
|
177
|
+
} catch(archiveErr) {
|
|
178
|
+
throw badGateway(
|
|
179
|
+
`stwo files: ${stwoError}; full archive: ${messageOf(archiveErr)}`,
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
assert(
|
|
185
|
+
existsSync(join(pkgDir, STWO_DIR)),
|
|
186
|
+
internal(
|
|
187
|
+
`zk circuit download finished but ${join(pkgDir, STWO_DIR)} `
|
|
188
|
+
+ 'is missing; run `npm run download:zk-files` and retry.',
|
|
189
|
+
),
|
|
62
190
|
)
|
|
63
191
|
}
|
|
64
192
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
193
|
+
/**
|
|
194
|
+
* Fetch `resources/stwo` at the pinned commit, file by file, into a temp
|
|
195
|
+
* dir inside the package, then rename it into place (same filesystem).
|
|
196
|
+
*/
|
|
197
|
+
async function fetchStwoFiles(pkgDir: string, stats: Progress) {
|
|
198
|
+
const commit = await readCommit(pkgDir)
|
|
199
|
+
assert(/^[0-9a-f]{40}$/.test(commit), internal('invalid pinned commit'))
|
|
200
|
+
const files = await listStwoFiles(commit)
|
|
201
|
+
stats.bytesDownloaded = 0
|
|
202
|
+
stats.totalBytes = files.reduce((sum, f) => sum + f.size, 0)
|
|
203
|
+
log(
|
|
204
|
+
`downloading zkTLS stwo proof files (${stats.totalBytes} bytes, `
|
|
205
|
+
+ 'one-time)…',
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
const tmp = await mkdtemp(join(pkgDir, '.stwo-download-'))
|
|
209
|
+
try {
|
|
210
|
+
for(const file of files) {
|
|
211
|
+
const res = await fetchFn(
|
|
212
|
+
`https://raw.githubusercontent.com/${REPO}/${commit}/`
|
|
213
|
+
+ `resources/stwo/${file.name}`,
|
|
214
|
+
)
|
|
215
|
+
assert(
|
|
216
|
+
res.ok,
|
|
217
|
+
badGateway(`stwo file ${file.name}: HTTP ${res.status}`),
|
|
218
|
+
)
|
|
219
|
+
const bytes = new Uint8Array(await res.arrayBuffer())
|
|
220
|
+
assert(
|
|
221
|
+
gitBlobSha(bytes) === file.sha,
|
|
222
|
+
badGateway(`stwo file ${file.name} failed its integrity check`),
|
|
223
|
+
)
|
|
224
|
+
await writeFile(join(tmp, file.name), bytes)
|
|
225
|
+
stats.bytesDownloaded = (stats.bytesDownloaded ?? 0) + bytes.length
|
|
81
226
|
}
|
|
227
|
+
|
|
228
|
+
const dest = join(pkgDir, STWO_DIR)
|
|
229
|
+
await mkdir(dirname(dest), { recursive: true })
|
|
230
|
+
// Another process may have finished first; its copy is as good.
|
|
231
|
+
await rename(tmp, dest).catch((err: unknown) => {
|
|
232
|
+
if(!existsSync(dest)) {
|
|
233
|
+
throw err
|
|
234
|
+
}
|
|
235
|
+
})
|
|
236
|
+
} finally {
|
|
237
|
+
await rm(tmp, { recursive: true, force: true })
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The file listing of `resources/stwo` at `commit`, with blob hashes. */
|
|
242
|
+
async function listStwoFiles(commit: string) {
|
|
243
|
+
const res = await fetchFn(
|
|
244
|
+
`https://api.github.com/repos/${REPO}/contents/resources/stwo`
|
|
245
|
+
+ `?ref=${commit}`,
|
|
246
|
+
{ headers: { Accept: 'application/vnd.github+json' } },
|
|
247
|
+
)
|
|
248
|
+
assert(res.ok, badGateway(`stwo file listing: HTTP ${res.status}`))
|
|
249
|
+
const listing: unknown = await res.json()
|
|
250
|
+
assert(
|
|
251
|
+
Array.isArray(listing) && listing.length > 0,
|
|
252
|
+
badGateway('stwo file listing is empty'),
|
|
253
|
+
)
|
|
254
|
+
return listing.map((entry: unknown) => {
|
|
255
|
+
assert(
|
|
256
|
+
typeof entry === 'object' && entry !== null
|
|
257
|
+
&& 'type' in entry && entry.type === 'file'
|
|
258
|
+
&& 'name' in entry && typeof entry.name === 'string'
|
|
259
|
+
&& /^[\w.-]+$/.test(entry.name) && !entry.name.startsWith('.')
|
|
260
|
+
&& 'size' in entry && typeof entry.size === 'number'
|
|
261
|
+
&& 'sha' in entry && typeof entry.sha === 'string',
|
|
262
|
+
badGateway('unexpected entry in the stwo file listing'),
|
|
263
|
+
)
|
|
264
|
+
return { name: entry.name, size: entry.size, sha: entry.sha }
|
|
82
265
|
})
|
|
83
|
-
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Run the package's `download-files.js`. Its temp paths are relative, so
|
|
270
|
+
* cwd is the package dir: the archive and extracted tree then sit on the
|
|
271
|
+
* same filesystem as the final `rename()` (no EXDEV) and never land in the
|
|
272
|
+
* developer's project.
|
|
273
|
+
*/
|
|
274
|
+
async function runArchiveDownloader(pkgDir: string) {
|
|
275
|
+
const script = join(pkgDir, 'lib', 'scripts', 'download-files.js')
|
|
276
|
+
assert(
|
|
277
|
+
existsSync(script),
|
|
278
|
+
internal(
|
|
279
|
+
'zk circuit downloader not found in '
|
|
280
|
+
+ '@reclaimprotocol/zk-symmetric-crypto; '
|
|
281
|
+
+ 'run `npm run download:zk-files` and retry.',
|
|
282
|
+
),
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
await new Promise<void>((resolve, reject) => {
|
|
286
|
+
// stdio fd 1→2: the downloader's progress must never reach the MCP
|
|
287
|
+
// server's JSON-RPC stdout.
|
|
288
|
+
const child = spawn(process.execPath, [script], {
|
|
289
|
+
cwd: pkgDir,
|
|
290
|
+
stdio: ['ignore', 2, 2],
|
|
291
|
+
})
|
|
292
|
+
child.once('error', reject)
|
|
293
|
+
child.once('exit', (code) => {
|
|
294
|
+
if(code === 0) {
|
|
295
|
+
resolve()
|
|
296
|
+
} else {
|
|
297
|
+
reject(internal(`zk circuit download exited with code ${code}`))
|
|
298
|
+
}
|
|
299
|
+
})
|
|
300
|
+
})
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const DEFAULT = makeZkFiles()
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Ensure the zk circuit resources are on disk before a proof runs. Resolves
|
|
308
|
+
* at once when they're present or the package isn't installed.
|
|
309
|
+
*/
|
|
310
|
+
export function ensureZkFiles() {
|
|
311
|
+
return DEFAULT.ensure()
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Start the download in the background (MCP server launch). */
|
|
315
|
+
export function startZkFilesDownload() {
|
|
316
|
+
DEFAULT.start()
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Where the download stands: idle, downloading, ready, or failed. */
|
|
320
|
+
export function zkFilesState() {
|
|
321
|
+
return DEFAULT.state()
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* The zk-symmetric-crypto copy attestor-core loads (it may be nested under
|
|
326
|
+
* attestor-core), else the hoisted one.
|
|
327
|
+
*/
|
|
328
|
+
function resolveZkPackageDir() {
|
|
329
|
+
const require = createRequire(import.meta.url)
|
|
330
|
+
for(const from of [
|
|
331
|
+
() => createRequire(require.resolve('@reclaimprotocol/attestor-core')),
|
|
332
|
+
() => require,
|
|
333
|
+
]) {
|
|
334
|
+
try {
|
|
335
|
+
// <pkg>/lib/index.js → <pkg>
|
|
336
|
+
return dirname(dirname(
|
|
337
|
+
from().resolve('@reclaimprotocol/zk-symmetric-crypto'),
|
|
338
|
+
))
|
|
339
|
+
} catch{
|
|
340
|
+
// try the next resolution root
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return undefined
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async function readPinnedCommit(pkgDir: string) {
|
|
348
|
+
const mod: { GIT_COMMIT_HASH?: unknown } = await import(
|
|
349
|
+
pathToFileURL(join(pkgDir, 'lib', 'index.js')).href
|
|
350
|
+
)
|
|
351
|
+
assert(
|
|
352
|
+
typeof mod.GIT_COMMIT_HASH === 'string',
|
|
353
|
+
internal('zk-symmetric-crypto exports no GIT_COMMIT_HASH'),
|
|
354
|
+
)
|
|
355
|
+
return mod.GIT_COMMIT_HASH
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Git's blob object id: sha1 of `blob <size>\0<content>`. */
|
|
359
|
+
function gitBlobSha(bytes: Uint8Array) {
|
|
360
|
+
return createHash('sha1')
|
|
361
|
+
.update(`blob ${bytes.length}\0`)
|
|
362
|
+
.update(bytes)
|
|
363
|
+
.digest('hex')
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function fileSize(path: string) {
|
|
367
|
+
try {
|
|
368
|
+
return statSync(path).size
|
|
369
|
+
} catch{
|
|
370
|
+
return undefined
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function messageOf(err: unknown) {
|
|
375
|
+
return err instanceof Error ? err.message : String(err)
|
|
84
376
|
}
|
package/src/provider/draft.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
|
+
import { badRequest } from '@hapi/boom'
|
|
2
|
+
import assert from 'node:assert'
|
|
1
3
|
import { isOprfHashable, OPRF_MAX_BYTES, synthesizeMatcher } from './matcher.ts'
|
|
2
4
|
import { normalizeCandidates } from './normalize.ts'
|
|
3
|
-
import
|
|
4
|
-
CapturedRequest,
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
import {
|
|
6
|
+
type CapturedRequest,
|
|
7
|
+
DYNAMIC_GEO,
|
|
8
|
+
type OprfMode,
|
|
9
|
+
type ReclaimProvider,
|
|
10
|
+
type ResponseMatch,
|
|
11
|
+
type ResponseRedaction,
|
|
12
|
+
type SecretParamRef,
|
|
10
13
|
} from './schema.ts'
|
|
11
14
|
import {
|
|
12
15
|
secretUrlCharBudget,
|
|
@@ -433,3 +436,208 @@ export function draftProvider(
|
|
|
433
436
|
|
|
434
437
|
return { provider, secretRefs, extractions }
|
|
435
438
|
}
|
|
439
|
+
|
|
440
|
+
/** Fields `update_draft` may replace on a drafted provider. */
|
|
441
|
+
export interface DraftEdit {
|
|
442
|
+
responseMatches?: ResponseMatch[]
|
|
443
|
+
responseRedactions?: ResponseRedaction[]
|
|
444
|
+
paramValues?: Record<string, string>
|
|
445
|
+
/** ISO country the request goes out from, or `{{DYNAMIC_GEO}}` /
|
|
446
|
+
* `{{DYNAMIC_GEO_SECRET}}` for the verifying user's own country. */
|
|
447
|
+
geoLocation?: string
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const GEO_TEMPLATES = new Set([DYNAMIC_GEO, '{{DYNAMIC_GEO_SECRET}}'])
|
|
451
|
+
|
|
452
|
+
const MATCH_TYPES = new Set(['contains', 'regex'])
|
|
453
|
+
const OPRF_MODES = new Set(['oprf', 'oprf-raw', 'oprf-mpc'])
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Apply an agent's edit to a drafted provider: validate the new matchers the
|
|
457
|
+
* way the attestor reads them (regexes compile with its `sgi` flags, every
|
|
458
|
+
* redaction has a selector, a hashed redaction has exactly one named group),
|
|
459
|
+
* then run the same portability gate as {@link draftProvider} so an edit can't
|
|
460
|
+
* bake a captured value in as a literal. Returns the updated provider, its
|
|
461
|
+
* extractions (re-paired with the redaction that names each param), and
|
|
462
|
+
* advisory warnings (for example, a `{{param}}` nothing resolves).
|
|
463
|
+
*/
|
|
464
|
+
export function editDraftProvider(
|
|
465
|
+
provider: ReclaimProvider,
|
|
466
|
+
extractions: Extraction[],
|
|
467
|
+
edit: DraftEdit,
|
|
468
|
+
): {
|
|
469
|
+
provider: ReclaimProvider
|
|
470
|
+
extractions: Extraction[]
|
|
471
|
+
warnings: string[]
|
|
472
|
+
} {
|
|
473
|
+
const matches = edit.responseMatches ?? provider.responseMatches
|
|
474
|
+
const redactions = edit.responseRedactions
|
|
475
|
+
?? provider.responseRedactions ?? []
|
|
476
|
+
const paramValues = edit.paramValues ?? provider.paramValues ?? {}
|
|
477
|
+
const geoLocation = GEO_TEMPLATES.has(edit.geoLocation ?? '')
|
|
478
|
+
? edit.geoLocation
|
|
479
|
+
: edit.geoLocation?.toUpperCase()
|
|
480
|
+
assert(
|
|
481
|
+
geoLocation === undefined || GEO_TEMPLATES.has(geoLocation)
|
|
482
|
+
|| /^[A-Z]{2}$/.test(geoLocation),
|
|
483
|
+
badRequest(
|
|
484
|
+
'geoLocation must be a 2-letter ISO country code, {{DYNAMIC_GEO}}, '
|
|
485
|
+
+ 'or {{DYNAMIC_GEO_SECRET}}',
|
|
486
|
+
),
|
|
487
|
+
)
|
|
488
|
+
assert(
|
|
489
|
+
matches.length > 0,
|
|
490
|
+
badRequest('responseMatches must have at least one entry'),
|
|
491
|
+
)
|
|
492
|
+
for(const [i, m] of matches.entries()) {
|
|
493
|
+
assert(
|
|
494
|
+
MATCH_TYPES.has(m.type) && typeof m.value === 'string' && m.value,
|
|
495
|
+
badRequest(
|
|
496
|
+
`responseMatches[${i}] needs type "contains" or "regex" and a `
|
|
497
|
+
+ 'non-empty value',
|
|
498
|
+
),
|
|
499
|
+
)
|
|
500
|
+
if(m.type === 'regex') {
|
|
501
|
+
assertRegex(m.value, `responseMatches[${i}].value`)
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
for(const [i, r] of redactions.entries()) {
|
|
506
|
+
assert(
|
|
507
|
+
r.xPath || r.jsonPath || r.regex,
|
|
508
|
+
badRequest(`responseRedactions[${i}] needs an xPath, jsonPath, or regex`),
|
|
509
|
+
)
|
|
510
|
+
if(r.regex) {
|
|
511
|
+
assertRegex(r.regex, `responseRedactions[${i}].regex`)
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
assert(
|
|
515
|
+
!r.hash || (OPRF_MODES.has(r.hash) && groupNames(r.regex).length === 1),
|
|
516
|
+
badRequest(
|
|
517
|
+
`responseRedactions[${i}] is hashed (${r.hash}), so it needs a hash `
|
|
518
|
+
+ 'of oprf, oprf-raw, or oprf-mpc and a regex with exactly one named '
|
|
519
|
+
+ 'capture group',
|
|
520
|
+
),
|
|
521
|
+
)
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// An edit can't lower privacy: a draft that redacts keeps redacting, and
|
|
525
|
+
// every group it hashed stays hashed with the same mode.
|
|
526
|
+
const original = provider.responseRedactions ?? []
|
|
527
|
+
assert(
|
|
528
|
+
!original.length || redactions.length,
|
|
529
|
+
badRequest(
|
|
530
|
+
'responseRedactions can\'t be emptied: the attestor would see the '
|
|
531
|
+
+ 'whole response',
|
|
532
|
+
),
|
|
533
|
+
)
|
|
534
|
+
for(const r of original.filter((o) => o.hash)) {
|
|
535
|
+
for(const name of groupNames(r.regex)) {
|
|
536
|
+
const kept = redactions.find((e) => groupNames(e.regex).includes(name))
|
|
537
|
+
assert(
|
|
538
|
+
kept?.hash === r.hash,
|
|
539
|
+
badRequest(
|
|
540
|
+
`capture group ${name} is ${r.hash}-hashed in the draft; keep a `
|
|
541
|
+
+ 'redaction for it with the same hash',
|
|
542
|
+
),
|
|
543
|
+
)
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const groups = matcherGroups(matches, redactions)
|
|
548
|
+
// A group the draft never extracted has a value nothing below checks, and
|
|
549
|
+
// no extraction would track it: new data points need a fresh draft.
|
|
550
|
+
const known = new Set([
|
|
551
|
+
...extractions.map((e) => e.name),
|
|
552
|
+
...matcherGroups(
|
|
553
|
+
provider.responseMatches,
|
|
554
|
+
provider.responseRedactions ?? [],
|
|
555
|
+
),
|
|
556
|
+
])
|
|
557
|
+
const unknown = [...groups].filter((name) => !known.has(name))
|
|
558
|
+
assert(
|
|
559
|
+
!unknown.length,
|
|
560
|
+
badRequest(
|
|
561
|
+
`capture group(s) ${unknown.join(', ')} are not drafted extractions; `
|
|
562
|
+
+ 'propose a new draft to add a data point',
|
|
563
|
+
),
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
// Always the captured values, so editing a paramValues entry can't hide
|
|
567
|
+
// one from the portability gate; edited values are checked as well.
|
|
568
|
+
const targets: DraftTarget[] = [
|
|
569
|
+
...extractions.map(({ name, value }) => ({ name, value })),
|
|
570
|
+
...Object.entries(paramValues)
|
|
571
|
+
.filter(([name, value]) => !extractions
|
|
572
|
+
.some((e) => e.name === name && e.value === value))
|
|
573
|
+
.map(([name, value]) => ({ name, value })),
|
|
574
|
+
]
|
|
575
|
+
assertPortableMatches(matches, redactions, targets)
|
|
576
|
+
|
|
577
|
+
// Same OPRF gate as draftProvider, against the URL as the attestor signs it.
|
|
578
|
+
const hashed = redactions.flatMap(({ regex, hash }) => hash
|
|
579
|
+
? groupNames(regex).flatMap((name) => targets
|
|
580
|
+
.filter((t) => t.name === name)
|
|
581
|
+
.map((t) => ({ ...t, hash })))
|
|
582
|
+
: [])
|
|
583
|
+
const url = provider.url
|
|
584
|
+
.replace(/\{\{([^{}]+)\}\}/g, (t, name: string) => paramValues[name] ?? t)
|
|
585
|
+
assertNoOprfUrlConflict(url, hashed, hashed.map((t) => t.hash))
|
|
586
|
+
|
|
587
|
+
const { responseRedactions: _r, paramValues: _p, ...rest } = provider
|
|
588
|
+
void _r
|
|
589
|
+
void _p
|
|
590
|
+
const next: ReclaimProvider = {
|
|
591
|
+
...rest,
|
|
592
|
+
responseMatches: matches,
|
|
593
|
+
...(redactions.length ? { responseRedactions: redactions } : {}),
|
|
594
|
+
...(Object.keys(paramValues).length ? { paramValues } : {}),
|
|
595
|
+
...(geoLocation ? { geoLocation } : {}),
|
|
596
|
+
}
|
|
597
|
+
const warnings = matches
|
|
598
|
+
.flatMap((m) => [...m.value.matchAll(/\{\{([^{}]+)\}\}/g)].map((t) => t[1]))
|
|
599
|
+
.filter((name) => !(name in paramValues) && !groups.has(name))
|
|
600
|
+
.map((name) => `{{${name}}} has no paramValues entry or named capture `
|
|
601
|
+
+ 'group, so the attestor would look for it literally.')
|
|
602
|
+
const nextExtractions = extractions.map((e) => {
|
|
603
|
+
const redaction = redactions
|
|
604
|
+
.find((r) => groupNames(r.regex).includes(e.name))
|
|
605
|
+
// The captured value, never an edited one, so a later edit's gate still
|
|
606
|
+
// sees it.
|
|
607
|
+
return {
|
|
608
|
+
name: e.name,
|
|
609
|
+
value: e.value,
|
|
610
|
+
...(redaction ? { redaction } : {}),
|
|
611
|
+
}
|
|
612
|
+
})
|
|
613
|
+
return { provider: next, extractions: nextExtractions, warnings }
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** Named capture groups across regex matches and redactions. */
|
|
617
|
+
function matcherGroups(
|
|
618
|
+
matches: ResponseMatch[],
|
|
619
|
+
redactions: ResponseRedaction[],
|
|
620
|
+
) {
|
|
621
|
+
return new Set([
|
|
622
|
+
...matches.flatMap((m) => m.type === 'regex' ? groupNames(m.value) : []),
|
|
623
|
+
...redactions.flatMap((r) => groupNames(r.regex)),
|
|
624
|
+
])
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** Named capture groups in `regex` (none when absent). */
|
|
628
|
+
function groupNames(regex: string | undefined): string[] {
|
|
629
|
+
return [...(regex ?? '').matchAll(/\(\?<([A-Za-z_][A-Za-z0-9_]*)>/g)]
|
|
630
|
+
.map((m) => m[1])
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/** Throw a 400 unless `regex` compiles the way the attestor compiles it. */
|
|
634
|
+
function assertRegex(regex: string, where: string) {
|
|
635
|
+
let error: string | undefined
|
|
636
|
+
try {
|
|
637
|
+
new RegExp(regex, 'sgi')
|
|
638
|
+
} catch(err) {
|
|
639
|
+
error = err instanceof Error ? err.message : String(err)
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
assert(!error, badRequest(`${where} is not a valid regex: ${error}`))
|
|
643
|
+
}
|