@reclaimprotocol/agent 0.1.36 → 0.1.38

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.
Files changed (104) hide show
  1. package/README.md +8 -8
  2. package/lib/authoring/backend.d.ts +12 -1
  3. package/lib/authoring/backend.js +3 -0
  4. package/lib/authoring/tools.js +33 -5
  5. package/lib/authoring/util.d.ts +43 -0
  6. package/lib/authoring/util.js +54 -0
  7. package/lib/cdp/chrome-for-testing.d.ts +14 -0
  8. package/lib/cdp/chrome-for-testing.js +43 -0
  9. package/lib/cdp/geo.d.ts +3 -0
  10. package/lib/cdp/geo.js +27 -15
  11. package/lib/cdp/launcher.d.ts +79 -1
  12. package/lib/cdp/launcher.js +244 -24
  13. package/lib/logger.js +3 -1
  14. package/lib/mcp/instructions.js +3 -3
  15. package/lib/mcp/start.js +17 -2
  16. package/lib/mcp/tools/agent/attach.d.ts +6 -0
  17. package/lib/mcp/tools/agent/attach.js +58 -118
  18. package/lib/mcp/tools/agent/build.js +8 -0
  19. package/lib/mcp/tools/agent/capture.js +33 -10
  20. package/lib/mcp/tools/agent/check-attestor.js +15 -7
  21. package/lib/mcp/tools/agent/draft-tools.d.ts +3 -0
  22. package/lib/mcp/tools/agent/draft-tools.js +170 -0
  23. package/lib/mcp/tools/agent/example-app.d.ts +2 -0
  24. package/lib/mcp/tools/agent/example-app.js +167 -0
  25. package/lib/mcp/tools/agent/pod-proof.js +5 -1
  26. package/lib/mcp/tools/agent/proof.d.ts +28 -0
  27. package/lib/mcp/tools/agent/proof.js +72 -0
  28. package/lib/mcp/tools/agent/shared.d.ts +8 -1
  29. package/lib/mcp/tools/agent/shared.js +98 -19
  30. package/lib/mcp/tools/agent/start-integration.d.ts +2 -1
  31. package/lib/mcp/tools/agent/start-integration.js +10 -6
  32. package/lib/mcp/tools/authenticate/authenticate.d.ts +48 -10
  33. package/lib/mcp/tools/authenticate/authenticate.js +212 -116
  34. package/lib/mcp/tools/authenticate/build.d.ts +2 -2
  35. package/lib/mcp/tools/authenticate/build.js +6 -3
  36. package/lib/mcp/tools/authenticate/builder-access.js +5 -2
  37. package/lib/mcp/tools/authenticate/eth-key.d.ts +13 -2
  38. package/lib/mcp/tools/authenticate/eth-key.js +29 -5
  39. package/lib/mcp/tools/authenticate/index.d.ts +1 -1
  40. package/lib/mcp/tools/authenticate/index.js +1 -1
  41. package/lib/mcp/tools/authenticate/issue-org-keypair.d.ts +10 -0
  42. package/lib/mcp/tools/authenticate/issue-org-keypair.js +82 -0
  43. package/lib/mcp/tools/authenticate/oauth-config.d.ts +31 -0
  44. package/lib/mcp/tools/authenticate/oauth-config.js +67 -0
  45. package/lib/mcp/tools/authenticate/resolve-owner-key.d.ts +4 -5
  46. package/lib/mcp/tools/authenticate/resolve-owner-key.js +40 -36
  47. package/lib/mcp/tools/authenticate/session-cache.d.ts +25 -1
  48. package/lib/mcp/tools/authenticate/session-cache.js +95 -4
  49. package/lib/mcp/tools/mode/mode.js +6 -6
  50. package/lib/old/login-cdp.js +2 -3
  51. package/lib/proof/attestor.js +2 -3
  52. package/lib/proof/check-attestor.d.ts +12 -2
  53. package/lib/proof/check-attestor.js +40 -10
  54. package/lib/proof/check-matchers.d.ts +36 -0
  55. package/lib/proof/check-matchers.js +161 -0
  56. package/lib/proof/zk-files.d.ts +50 -11
  57. package/lib/proof/zk-files.js +259 -63
  58. package/lib/provider/draft.d.ts +24 -1
  59. package/lib/provider/draft.js +135 -0
  60. package/lib/provider/matcher.js +171 -4
  61. package/package.json +6 -4
  62. package/skill/SKILL.builder.md +7 -2
  63. package/skill/SKILL.common.md +90 -70
  64. package/skill/SKILL.old.md +3 -3
  65. package/skill/builder-mode.md +35 -5
  66. package/skill/old-mode.md +18 -13
  67. package/skill/start-integration.md +50 -33
  68. package/skill/verification-client-integration.md +21 -6
  69. package/src/authoring/backend.ts +12 -1
  70. package/src/authoring/tools.ts +48 -7
  71. package/src/authoring/util.ts +64 -0
  72. package/src/cdp/chrome-for-testing.ts +66 -0
  73. package/src/cdp/geo.ts +36 -21
  74. package/src/cdp/launcher.ts +328 -24
  75. package/src/logger.ts +3 -1
  76. package/src/mcp/instructions.ts +3 -3
  77. package/src/mcp/start.ts +20 -1
  78. package/src/mcp/tools/agent/attach.ts +65 -128
  79. package/src/mcp/tools/agent/build.ts +9 -0
  80. package/src/mcp/tools/agent/capture.ts +54 -11
  81. package/src/mcp/tools/agent/check-attestor.ts +15 -7
  82. package/src/mcp/tools/agent/draft-tools.ts +234 -0
  83. package/src/mcp/tools/agent/example-app.ts +227 -0
  84. package/src/mcp/tools/agent/pod-proof.ts +5 -1
  85. package/src/mcp/tools/agent/proof.ts +110 -1
  86. package/src/mcp/tools/agent/shared.ts +130 -19
  87. package/src/mcp/tools/agent/start-integration.ts +10 -6
  88. package/src/mcp/tools/authenticate/authenticate.ts +276 -131
  89. package/src/mcp/tools/authenticate/build.ts +6 -3
  90. package/src/mcp/tools/authenticate/builder-access.ts +5 -2
  91. package/src/mcp/tools/authenticate/eth-key.ts +41 -7
  92. package/src/mcp/tools/authenticate/index.ts +1 -1
  93. package/src/mcp/tools/authenticate/issue-org-keypair.ts +106 -0
  94. package/src/mcp/tools/authenticate/oauth-config.ts +85 -0
  95. package/src/mcp/tools/authenticate/resolve-owner-key.ts +42 -39
  96. package/src/mcp/tools/authenticate/session-cache.ts +125 -5
  97. package/src/mcp/tools/mode/mode.ts +6 -6
  98. package/src/old/login-cdp.ts +2 -3
  99. package/src/proof/attestor.ts +2 -3
  100. package/src/proof/check-attestor.ts +55 -12
  101. package/src/proof/check-matchers.ts +227 -0
  102. package/src/proof/zk-files.ts +357 -65
  103. package/src/provider/draft.ts +215 -7
  104. package/src/provider/matcher.ts +237 -4
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Offline matcher check: run a recipe's `responseRedactions` +
3
+ * `responseMatches` against a captured response with attestor-core's own HTTP
4
+ * provider, without a network call or a TLS session.
5
+ *
6
+ * It replays the two halves of a real proof on a synthetic transcript:
7
+ * 1. prover side: `providers.http.createRequest` builds the request and
8
+ * `getResponseRedactions` picks the response slices to reveal (xPath /
9
+ * jsonPath / regex, exactly as at proof time);
10
+ * 2. attestor side: `providers.http.assertValidProviderReceipt` checks the
11
+ * redacted transcript against `responseMatches` and extracts params.
12
+ *
13
+ * Hashed (OPRF) slices stay in plaintext here: OPRF needs the attestor, so the
14
+ * check treats the value as its own nullifier. Everything else is the same
15
+ * engine the attestor runs.
16
+ */
17
+ import {
18
+ CURRENT_ATTESTOR_VERSION,
19
+ type Logger,
20
+ PROVIDER_CTX,
21
+ providers,
22
+ type RedactedOrHashedArraySlice,
23
+ redactSlices,
24
+ } from '@reclaimprotocol/attestor-core'
25
+ // Side-effect: make external domhandler nodes DOM-like so attestor-core's
26
+ // bundled `xpath` accepts them (see patch-domhandler.ts).
27
+ import './patch-domhandler.ts'
28
+ import { LOGGER } from '../logger.ts'
29
+ import type { CapturedRequest, ReclaimProvider } from '../provider/schema.ts'
30
+ import { buildHttpParams } from './proof-core.ts'
31
+
32
+ /** Longest revealed slice returned; the full slice is still checked. */
33
+ const MAX_SLICE_CHARS = 400
34
+ /** Most revealed slices returned. */
35
+ const MAX_SLICES = 10
36
+ /** Longest error message returned (attestor errors can embed the base64 of
37
+ * a whole element). */
38
+ const MAX_REASON_CHARS = 600
39
+
40
+ export interface RevealedSlice {
41
+ /** Byte offsets in the response body. */
42
+ from: number
43
+ to: number
44
+ /** Revealed text, truncated to {@link MAX_SLICE_CHARS}. */
45
+ text: string
46
+ /** Set when the slice is an OPRF-hashed value (plaintext here). */
47
+ hashed?: true
48
+ }
49
+
50
+ export interface MatcherCheckResult {
51
+ /** True when the attestor would accept this response. */
52
+ ok: boolean
53
+ /** Where it failed: building the request, selecting what to reveal, or
54
+ * checking `responseMatches` on the revealed bytes. */
55
+ failedAt?: 'request' | 'redaction' | 'match'
56
+ /** Attestor-core's error, truncated. */
57
+ reason?: string
58
+ /** Params the attestor would extract (regex groups + `paramValues` the
59
+ * matches use). */
60
+ extractedParameters: Record<string, string>
61
+ /** The response body slices revealed to the attestor. */
62
+ revealed: RevealedSlice[]
63
+ revealedSliceCount: number
64
+ revealedBytes: number
65
+ bodyBytes: number
66
+ /** Set when an OPRF-hashed redaction was checked in plaintext. */
67
+ note?: string
68
+ }
69
+
70
+ /** Placeholder secret header: the request builder needs one secret, and the
71
+ * check never sends the request. */
72
+ const OFFLINE_SECRET = { 'X-Reclaim-Offline-Check': '1' }
73
+
74
+ /**
75
+ * Check `provider`'s matchers against `captured`'s response body, offline.
76
+ * Uses the captured status and content type, so a non-2xx capture fails the
77
+ * same way it would at proof time.
78
+ */
79
+ export function checkMatchersOffline(
80
+ provider: ReclaimProvider,
81
+ captured: Pick<CapturedRequest, 'status' | 'contentType' | 'responseBody'>,
82
+ ): MatcherCheckResult {
83
+ const http = providers.http
84
+ const params = buildHttpParams(provider)
85
+ const logger = silentLogger()
86
+ const body = Buffer.from(captured.responseBody ?? '', 'utf8')
87
+ const head = Buffer.from(
88
+ `HTTP/1.1 ${captured.status} ${captured.status < 300 ? 'OK' : 'ERR'}\r\n`
89
+ + `Content-Type: ${captured.contentType || 'text/plain'}\r\n`
90
+ + `Content-Length: ${body.length}\r\n\r\n`,
91
+ )
92
+ const response = new Uint8Array(Buffer.concat([head, body]))
93
+ const empty = {
94
+ extractedParameters: {},
95
+ revealed: [],
96
+ revealedSliceCount: 0,
97
+ revealedBytes: 0,
98
+ bodyBytes: body.length,
99
+ }
100
+
101
+ let request: Uint8Array
102
+ try {
103
+ const req = http.createRequest(
104
+ { headers: OFFLINE_SECRET }, structuredClone(params), logger,
105
+ )
106
+ const data = typeof req.data === 'string'
107
+ ? new Uint8Array(Buffer.from(req.data))
108
+ : req.data
109
+ request = redactSlices(data, req.redactions)
110
+ } catch(err) {
111
+ return { ...empty, ok: false, failedAt: 'request', reason: reasonOf(err) }
112
+ }
113
+
114
+ let slices: RedactedOrHashedArraySlice[]
115
+ try {
116
+ slices = http.getResponseRedactions?.({
117
+ response, params: structuredClone(params), logger, ctx: PROVIDER_CTX,
118
+ }) ?? []
119
+ } catch(err) {
120
+ return { ...empty, ok: false, failedAt: 'redaction', reason: reasonOf(err) }
121
+ }
122
+
123
+ const hidden = slices.filter((s) => !s.hash)
124
+ const hashed = slices.filter((s) => s.hash)
125
+ const view = revealedSlices(head.length, response.length, hidden, hashed)
126
+ const redacted = redactSlices(response, hidden)
127
+ const seen = {
128
+ revealed: view.slice(0, MAX_SLICES).map((s) => ({
129
+ from: s.from,
130
+ to: s.to,
131
+ text: truncate(
132
+ Buffer.from(response.subarray(head.length + s.from, head.length + s.to))
133
+ .toString('utf8'),
134
+ MAX_SLICE_CHARS,
135
+ ),
136
+ ...(s.hashed ? { hashed: true as const } : {}),
137
+ })),
138
+ revealedSliceCount: view.length,
139
+ revealedBytes: view.reduce((n, s) => n + s.to - s.from, 0),
140
+ bodyBytes: body.length,
141
+ ...(hashed.length
142
+ ? {
143
+ note: 'OPRF-hashed values were checked in plaintext; at proof time '
144
+ + 'the attestor sees their nullifier instead.',
145
+ }
146
+ : {}),
147
+ }
148
+
149
+ try {
150
+ const out = http.assertValidProviderReceipt({
151
+ clientVersion: CURRENT_ATTESTOR_VERSION,
152
+ receipt: [
153
+ { sender: 'client', message: request },
154
+ { sender: 'server', message: redacted },
155
+ ],
156
+ params: structuredClone(params),
157
+ logger,
158
+ ctx: PROVIDER_CTX,
159
+ })
160
+ // The http provider validates synchronously; a thenable here would mean
161
+ // a provider we don't run offline.
162
+ const extracted = out instanceof Promise ? undefined : out
163
+ return {
164
+ ok: true,
165
+ extractedParameters: extracted?.extractedParameters ?? {},
166
+ ...seen,
167
+ }
168
+ } catch(err) {
169
+ return {
170
+ ok: false,
171
+ failedAt: 'match',
172
+ reason: reasonOf(err),
173
+ extractedParameters: {},
174
+ ...seen,
175
+ }
176
+ }
177
+ }
178
+
179
+ /** Body-relative revealed ranges: the complement of `hidden` within the body,
180
+ * merged with the hashed slices (revealed as their nullifier at proof time).
181
+ */
182
+ function revealedSlices(
183
+ bodyStart: number,
184
+ total: number,
185
+ hidden: RedactedOrHashedArraySlice[],
186
+ hashed: RedactedOrHashedArraySlice[],
187
+ ): { from: number, to: number, hashed?: true }[] {
188
+ const out: { from: number, to: number, hashed?: true }[] = []
189
+ let cursor = bodyStart
190
+ const sorted = [...hidden].sort((a, b) => a.fromIndex - b.fromIndex)
191
+ for(const s of sorted) {
192
+ if(s.fromIndex > cursor) {
193
+ out.push({ from: cursor - bodyStart, to: s.fromIndex - bodyStart })
194
+ }
195
+
196
+ cursor = Math.max(cursor, s.toIndex)
197
+ }
198
+
199
+ if(cursor < total) {
200
+ out.push({ from: cursor - bodyStart, to: total - bodyStart })
201
+ }
202
+
203
+ for(const h of hashed) {
204
+ out.push({
205
+ from: h.fromIndex - bodyStart, to: h.toIndex - bodyStart, hashed: true,
206
+ })
207
+ }
208
+
209
+ return out
210
+ .filter((s) => s.to > s.from && s.from >= 0)
211
+ .sort((a, b) => a.from - b.from)
212
+ }
213
+
214
+ function reasonOf(err: unknown): string {
215
+ return truncate(err instanceof Error ? err.message : String(err),
216
+ MAX_REASON_CHARS)
217
+ }
218
+
219
+ function truncate(s: string, max: number): string {
220
+ return s.length > max ? `${s.slice(0, max)}… (${s.length} chars)` : s
221
+ }
222
+
223
+ /** A logger for attestor-core calls that records nothing: the check reports
224
+ * its outcome in the result, and the provider logs whole responses. */
225
+ function silentLogger(): Logger {
226
+ return LOGGER.child({ scope: 'check-matchers' }, { level: 'silent' })
227
+ }
@@ -1,84 +1,376 @@
1
- // Lazy download of the @reclaimprotocol/zk-symmetric-crypto circuit resources
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
5
- // timeout — see the install notes). Instead we fetch them on the first proof,
6
- // once, so the server starts immediately and only `run_proof` pays the cost.
7
-
8
- import { spawn } from 'node:child_process'
9
- import { existsSync } from 'node:fs'
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 require = createRequire(import.meta.url)
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
- let inFlight: Promise<void> | undefined
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
- * Ensure the zk circuit resources are on disk before a proof runs. Idempotent
19
- * and concurrency-safe: the download runs at most once per process, and a
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 ensureZkFiles() {
31
- inFlight ??= download().catch((err) => {
32
- inFlight = undefined
33
- throw err
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
- async function download() {
39
- let mainPath: string
40
- try {
41
- mainPath = require.resolve('@reclaimprotocol/zk-symmetric-crypto')
42
- } catch{
43
- return
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
- // mainPath is <pkg>/lib/index.js → resources live at <pkg>/resources, the
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).
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
- const downloadScript = join(libDir, 'scripts', 'download-files.js')
57
- if(!existsSync(downloadScript)) {
58
- throw new Error(
59
- 'zk circuit downloader not found in '
60
- + '@reclaimprotocol/zk-symmetric-crypto; '
61
- + 'run `npm run download:zk-files` and retry.',
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
- process.stderr.write(
66
- '[reclaim-agent] downloading zkTLS proof circuits '
67
- + '(~280MB, one-time)…\n',
68
- )
69
- // stdio fd 1→2: send the downloader's stdout to our stderr so its progress
70
- // never lands on the MCP server's JSON-RPC stdout.
71
- await new Promise<void>((resolve, reject) => {
72
- const child = spawn(process.execPath, [downloadScript], {
73
- stdio: ['ignore', 2, 2],
74
- })
75
- child.on('error', reject)
76
- child.on('exit', (code) => {
77
- if(code === 0) {
78
- resolve()
79
- } else {
80
- reject(new Error(`zk download exited ${code}`))
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
  }