@reclaimprotocol/agent 0.1.37 → 0.1.39
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 +1 -1
- package/lib/mcp/tools/agent/build.js +2 -0
- package/lib/mcp/tools/agent/example-app.d.ts +2 -0
- package/lib/mcp/tools/agent/example-app.js +211 -0
- package/lib/provider/draft.js +4 -3
- package/lib/provider/matcher.js +1 -1
- package/package.json +2 -2
- package/samples/demo-server/package.json +2 -1
- package/samples/demo-server/server.js +6 -1
- package/skill/start-integration.md +6 -4
- package/skill/verification-client-integration.md +4 -4
- package/src/mcp/tools/agent/build.ts +2 -0
- package/src/mcp/tools/agent/example-app.ts +279 -0
- package/src/provider/draft.ts +4 -3
- package/src/provider/matcher.ts +1 -1
package/README.md
CHANGED
|
@@ -134,7 +134,7 @@ The MCP server targets one of two backends. An explicit `USE_OLD_DEVTOOLS` setti
|
|
|
134
134
|
- `get_me_providers` — list the providers you own (the only provider-listing tool in this mode).
|
|
135
135
|
- `session_analytics_logs` — the milestone events for a verification session, which show how far it got.
|
|
136
136
|
- `session_logs` — the log entries the in-app SDK emitted during that session, each with its event type, log level, and logger name. Filter by event type, level, or substring, page with `limit` and `offset`, or set `saveTo` to write every matching entry to an NDJSON file. The backend searches only the last 3 days unless you pass `startTime` and `endTime`, and it deletes entries after 30 days.
|
|
137
|
-
- **Builder (default).** Registers the auto-generated HTTP-API tools, including `create_org`, `issue_org_token`, and `create_org_callback_subscription`, plus authentication/credential tools, `get_verification_result`, `read_callback_events` (read the org's pull-listener events), `proxy_callback_events` (forward them to a localhost callback route; the pull listener lives only while the MCP server runs), and `check_attestor`. `issue_credentials` creates a local proof-owner key; `issue_org_token` issues the organization's API secret; `issue_org_keypair` creates the organization's key pair for session binding and encrypted results, writing the private key to the app's env file.
|
|
137
|
+
- **Builder (default).** Registers the auto-generated HTTP-API tools, including `create_org`, `issue_org_token`, and `create_org_callback_subscription`, plus authentication/credential tools, `get_verification_result`, `read_callback_events` (read the org's pull-listener events), `proxy_callback_events` (forward them to a localhost callback route; the pull listener lives only while the MCP server runs), and `check_attestor`. `issue_credentials` creates a local proof-owner key; `issue_org_token` issues the organization's API secret; `create_example_app` writes the builder-example app into a workspace that has none; `issue_org_keypair` creates the organization's key pair for session binding and encrypted results, writing the private key to the app's env file.
|
|
138
138
|
|
|
139
139
|
Relevant env vars: `RECLAIM_OLD_API_URL` (default `https://devapi.reclaimprotocol.org`), `RECLAIM_OLD_API_TOKEN` / `RECLAIM_OLD_ETH_UID` (headless identity), `RECLAIM_OLD_LOGIN_URL` (default `https://dev.reclaimprotocol.org`); builder mode uses `RECLAIM_API_URL` / `RECLAIM_API_TOKEN`.
|
|
140
140
|
|
|
@@ -17,6 +17,7 @@ import { captureTools, resetCaptures } from "./capture.js";
|
|
|
17
17
|
import { checkAttestorTool } from "./check-attestor.js";
|
|
18
18
|
import { draftTools } from "./draft-tools.js";
|
|
19
19
|
import { listTabsTool } from "./eval.js";
|
|
20
|
+
import { exampleAppTool } from "./example-app.js";
|
|
20
21
|
import { howItWorksTool } from "./how-it-works.js";
|
|
21
22
|
import { liveViewTools } from "./live-view.js";
|
|
22
23
|
import { podProofTools } from "./pod-proof.js";
|
|
@@ -81,6 +82,7 @@ export function buildAgentTools(client, builderClient = client, externalState) {
|
|
|
81
82
|
}
|
|
82
83
|
if (client) {
|
|
83
84
|
tools.push(startIntegrationTool());
|
|
85
|
+
tools.push(exampleAppTool());
|
|
84
86
|
tools.push(createVersionFromCaptureTool(client, state.attach, state.captures));
|
|
85
87
|
}
|
|
86
88
|
return tools;
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `create_example_app` — write reclaimprotocol/builder-example into the
|
|
3
|
+
* developer's workspace, so a project with no app starts from a ready-to-run
|
|
4
|
+
* backend and frontend instead of agent-written code. The MCP server fetches
|
|
5
|
+
* the files itself (each checked against its git blob hash) and writes them at
|
|
6
|
+
* the workspace root: the coding agent's later `npm install` and `npm run dev`
|
|
7
|
+
* then run in the workspace it started in, not in a clone. The backend's
|
|
8
|
+
* `@reclaimprotocol/client` is set to the latest published version.
|
|
9
|
+
*/
|
|
10
|
+
import { badGateway, badRequest, conflict } from '@hapi/boom';
|
|
11
|
+
import assert from 'node:assert';
|
|
12
|
+
import { createHash } from 'node:crypto';
|
|
13
|
+
import { existsSync, lstatSync, readFileSync } from 'node:fs';
|
|
14
|
+
import { mkdir, realpath, rm, writeFile } from 'node:fs/promises';
|
|
15
|
+
import { isAbsolute, join, relative, sep } from 'node:path';
|
|
16
|
+
import { defineTool } from "../../server.js";
|
|
17
|
+
const REPO = 'reclaimprotocol/builder-example';
|
|
18
|
+
/** GitHub tree paths are always `/`-separated, on every OS. */
|
|
19
|
+
const BACKEND_PACKAGE = 'packages/backend/package.json';
|
|
20
|
+
const CLIENT_PACKAGE = '@reclaimprotocol/client';
|
|
21
|
+
/** The example is ~50 small files; anything far past that isn't it. */
|
|
22
|
+
const MAX_FILES = 500;
|
|
23
|
+
const MAX_BYTES = 10000000;
|
|
24
|
+
export function exampleAppTool(fetchFn = fetch) {
|
|
25
|
+
return defineTool({
|
|
26
|
+
name: 'create_example_app',
|
|
27
|
+
description: 'Writes the Reclaim example app (reclaimprotocol/builder-example: '
|
|
28
|
+
+ 'a Fastify backend and a Vite frontend that create sessions, '
|
|
29
|
+
+ 'verify callbacks, and render every verification state) into '
|
|
30
|
+
+ '`projectDir`, and sets its backend to the latest '
|
|
31
|
+
+ '`@reclaimprotocol/client`. Use it when the workspace has no app '
|
|
32
|
+
+ 'yet, with the workspace root you were started in as '
|
|
33
|
+
+ '`projectDir`; don\'t clone the repository yourself. Refuses '
|
|
34
|
+
+ 'when a file it would write already exists with different '
|
|
35
|
+
+ 'content. Returns the commit, file count, client version, and '
|
|
36
|
+
+ 'next steps.',
|
|
37
|
+
inputSchema: {
|
|
38
|
+
type: 'object',
|
|
39
|
+
required: ['projectDir'],
|
|
40
|
+
properties: {
|
|
41
|
+
projectDir: {
|
|
42
|
+
type: 'string',
|
|
43
|
+
description: 'Absolute path of the workspace root you were started in.',
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
annotations: { openWorldHint: true, destructiveHint: false },
|
|
48
|
+
}, async (args) => {
|
|
49
|
+
assert(isAbsolute(args.projectDir), badRequest('`projectDir` must be an absolute path.'));
|
|
50
|
+
const projectDir = await realpath(args.projectDir);
|
|
51
|
+
const commit = await latestCommit();
|
|
52
|
+
const files = await listFiles(commit);
|
|
53
|
+
const clientVersion = await latestClientVersion();
|
|
54
|
+
const staged = await Promise.all(files.map(async (file) => {
|
|
55
|
+
const bytes = await download(commit, file);
|
|
56
|
+
return {
|
|
57
|
+
path: file.path,
|
|
58
|
+
bytes: file.path === BACKEND_PACKAGE
|
|
59
|
+
? withClientVersion(bytes, clientVersion)
|
|
60
|
+
: bytes,
|
|
61
|
+
};
|
|
62
|
+
}));
|
|
63
|
+
for (const { path } of staged) {
|
|
64
|
+
assertNoSymlinks(projectDir, path);
|
|
65
|
+
}
|
|
66
|
+
const conflicts = staged
|
|
67
|
+
.filter(({ path, bytes }) => {
|
|
68
|
+
const dest = join(projectDir, path);
|
|
69
|
+
return existsSync(dest) && !readFileSync(dest).equals(bytes);
|
|
70
|
+
})
|
|
71
|
+
.map(({ path }) => path);
|
|
72
|
+
assert(!conflicts.length, conflict('These files already exist with different content: '
|
|
73
|
+
+ `${conflicts.join(', ')}. Ask the developer whether to use `
|
|
74
|
+
+ 'another directory or move them.'));
|
|
75
|
+
for (const { path, bytes } of staged) {
|
|
76
|
+
await installFile(projectDir, path, bytes);
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
projectDir,
|
|
80
|
+
repository: `https://github.com/${REPO}`,
|
|
81
|
+
commit,
|
|
82
|
+
files: staged.length,
|
|
83
|
+
clientVersion,
|
|
84
|
+
next: [
|
|
85
|
+
'Call issue_org_token with this projectDir and envFile '
|
|
86
|
+
+ '"packages/backend/.env".',
|
|
87
|
+
'Run `npm install`, then `npm run dev`, from projectDir. The '
|
|
88
|
+
+ 'backend listens on port 3000; the app opens at '
|
|
89
|
+
+ 'http://localhost:5173.',
|
|
90
|
+
'Call proxy_callback_events({ port: 3000, path: '
|
|
91
|
+
+ '"/reclaim/callback" }).',
|
|
92
|
+
'Put the provider in packages/backend/config.json and restart '
|
|
93
|
+
+ 'the backend.',
|
|
94
|
+
],
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
async function latestCommit() {
|
|
98
|
+
const res = await fetchFn(`https://api.github.com/repos/${REPO}/commits/main`, { headers: { Accept: 'application/vnd.github.sha' } });
|
|
99
|
+
assert(res.ok, badGateway(`${REPO} commit lookup: HTTP ${res.status}`));
|
|
100
|
+
const text = await res.text();
|
|
101
|
+
const commit = text.trim();
|
|
102
|
+
assert(/^[0-9a-f]{40}$/.test(commit), badGateway('invalid commit SHA'));
|
|
103
|
+
return commit;
|
|
104
|
+
}
|
|
105
|
+
async function listFiles(commit) {
|
|
106
|
+
const res = await fetchFn(`https://api.github.com/repos/${REPO}/git/trees/${commit}?recursive=1`, { headers: { Accept: 'application/vnd.github+json' } });
|
|
107
|
+
assert(res.ok, badGateway(`${REPO} file listing: HTTP ${res.status}`));
|
|
108
|
+
const tree = await res.json();
|
|
109
|
+
assert(typeof tree === 'object' && tree !== null && 'tree' in tree
|
|
110
|
+
&& Array.isArray(tree.tree) && !('truncated' in tree && tree.truncated), badGateway(`unexpected ${REPO} file listing`));
|
|
111
|
+
const files = tree.tree
|
|
112
|
+
.filter((e) => {
|
|
113
|
+
return typeof e === 'object' && e !== null && 'type' in e
|
|
114
|
+
&& e.type === 'blob';
|
|
115
|
+
})
|
|
116
|
+
.map((entry) => {
|
|
117
|
+
assert(typeof entry === 'object' && entry !== null
|
|
118
|
+
&& 'path' in entry && typeof entry.path === 'string'
|
|
119
|
+
&& entry.path.split('/').every((s) => /^[\w@.-]+$/.test(s)
|
|
120
|
+
&& s !== '.' && s !== '..' && s !== '.git')
|
|
121
|
+
&& 'sha' in entry && typeof entry.sha === 'string'
|
|
122
|
+
&& 'size' in entry && typeof entry.size === 'number', badGateway(`unexpected entry in the ${REPO} file listing`));
|
|
123
|
+
return { path: entry.path, sha: entry.sha, size: entry.size };
|
|
124
|
+
});
|
|
125
|
+
assert(files.length > 0 && files.length <= MAX_FILES
|
|
126
|
+
&& files.reduce((n, f) => n + f.size, 0) <= MAX_BYTES, badGateway(`unexpected ${REPO} size`));
|
|
127
|
+
return files;
|
|
128
|
+
}
|
|
129
|
+
async function download(commit, file) {
|
|
130
|
+
const res = await fetchFn(`https://raw.githubusercontent.com/${REPO}/${commit}/${file.path}`);
|
|
131
|
+
assert(res.ok, badGateway(`${file.path}: HTTP ${res.status}`));
|
|
132
|
+
const bytes = Buffer.from(await res.arrayBuffer());
|
|
133
|
+
assert(gitBlobSha(bytes) === file.sha, badGateway(`${file.path} failed its integrity check`));
|
|
134
|
+
return bytes;
|
|
135
|
+
}
|
|
136
|
+
async function latestClientVersion() {
|
|
137
|
+
const res = await fetchFn(`https://registry.npmjs.org/${encodeURIComponent(CLIENT_PACKAGE)}/latest`);
|
|
138
|
+
assert(res.ok, badGateway(`npm ${CLIENT_PACKAGE}: HTTP ${res.status}`));
|
|
139
|
+
const body = await res.json();
|
|
140
|
+
assert(typeof body === 'object' && body !== null && 'version' in body
|
|
141
|
+
&& typeof body.version === 'string'
|
|
142
|
+
&& /^\d+\.\d+\.\d+$/.test(body.version), badGateway(`npm ${CLIENT_PACKAGE}: no version`));
|
|
143
|
+
return body.version;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Write one file under `root` without following a symlink out of it or
|
|
148
|
+
* replacing anything: each parent is created one level at a time and must be
|
|
149
|
+
* a real directory, the file is created exclusively (`wx` fails on any path
|
|
150
|
+
* that appeared since the conflict scan, symlinks included), and its real
|
|
151
|
+
* path is checked to still be inside `root` afterwards. A file already there
|
|
152
|
+
* with the same bytes (a re-run) is left as is.
|
|
153
|
+
*/
|
|
154
|
+
async function installFile(root, path, bytes) {
|
|
155
|
+
const parts = path.split('/');
|
|
156
|
+
let dir = root;
|
|
157
|
+
for (const part of parts.slice(0, -1)) {
|
|
158
|
+
dir = join(dir, part);
|
|
159
|
+
await mkdir(dir).catch((err) => {
|
|
160
|
+
if (!isEexist(err)) {
|
|
161
|
+
throw err;
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
assertNoSymlinks(root, relative(root, dir));
|
|
165
|
+
}
|
|
166
|
+
const dest = join(dir, parts.at(-1));
|
|
167
|
+
try {
|
|
168
|
+
await writeFile(dest, bytes, { flag: 'wx' });
|
|
169
|
+
}
|
|
170
|
+
catch (err) {
|
|
171
|
+
if (!isEexist(err)) {
|
|
172
|
+
throw err;
|
|
173
|
+
}
|
|
174
|
+
assertNoSymlinks(root, path);
|
|
175
|
+
assert(readFileSync(dest).equals(bytes), conflict(`${path} appeared with different content; nothing replaced`));
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const real = await realpath(dest);
|
|
179
|
+
if (!real.startsWith(root + sep)) {
|
|
180
|
+
await rm(dest, { force: true });
|
|
181
|
+
throw badRequest(`${path} resolved outside the workspace; removed it`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
function isEexist(err) {
|
|
185
|
+
return err instanceof Error && 'code' in err && err.code === 'EEXIST';
|
|
186
|
+
}
|
|
187
|
+
/** Throws when `path`, or any of its existing parents under `root`, is a
|
|
188
|
+
* symlink: writing through one would land outside the workspace. */
|
|
189
|
+
function assertNoSymlinks(root, path) {
|
|
190
|
+
let current = root;
|
|
191
|
+
for (const part of path.split(/[\\/]/)) {
|
|
192
|
+
current += sep + part;
|
|
193
|
+
const stat = lstatSync(current, { throwIfNoEntry: false });
|
|
194
|
+
assert(!stat?.isSymbolicLink(), badRequest(`${current} is a symlink; the example app is only written to `
|
|
195
|
+
+ 'real directories inside the workspace'));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/** The backend's package.json with the client range set to `^version`. */
|
|
199
|
+
function withClientVersion(bytes, version) {
|
|
200
|
+
const text = bytes.toString('utf8');
|
|
201
|
+
const next = text.replace(/("@reclaimprotocol\/client"\s*:\s*")[^"]*(")/, (_, open, close) => `${open}^${version}${close}`);
|
|
202
|
+
assert(next !== text || text.includes(`"^${version}"`), badGateway(`${BACKEND_PACKAGE} has no ${CLIENT_PACKAGE} dependency`));
|
|
203
|
+
return Buffer.from(next);
|
|
204
|
+
}
|
|
205
|
+
/** Git's blob object id: sha1 of `blob <size>\0<content>`. */
|
|
206
|
+
function gitBlobSha(bytes) {
|
|
207
|
+
return createHash('sha1')
|
|
208
|
+
.update(`blob ${bytes.length}\0`)
|
|
209
|
+
.update(bytes)
|
|
210
|
+
.digest('hex');
|
|
211
|
+
}
|
package/lib/provider/draft.js
CHANGED
|
@@ -80,10 +80,11 @@ export function assertPortableMatches(matches, redactions, targets) {
|
|
|
80
80
|
// proving an email's domain substring. Reject it regardless of the declared
|
|
81
81
|
// targets (the local part is usually not itself a target, so the value scan
|
|
82
82
|
// below wouldn't catch it). Strip params + named groups + regex escapes
|
|
83
|
-
// first, then look for `localpart@` as a surviving literal.
|
|
83
|
+
// first, then look for `localpart@` as a surviving literal. A placeholder
|
|
84
|
+
// never contains a brace, and `[^{}]` keeps the scan linear on `{{{{…`.
|
|
84
85
|
const EMAIL_LOCAL_BAKED = /[A-Za-z0-9._%+-]{3,}@/;
|
|
85
86
|
const deregex = (s) => s
|
|
86
|
-
.replace(/\{\{[^}]*\}\}/g, '')
|
|
87
|
+
.replace(/\{\{[^{}]*\}\}/g, '')
|
|
87
88
|
.replace(/\(\?<[^>]*>[^)]*\)/g, '')
|
|
88
89
|
.replace(/\\(.)/g, '$1');
|
|
89
90
|
const scanEmail = (text, where) => {
|
|
@@ -110,7 +111,7 @@ export function assertPortableMatches(matches, redactions, targets) {
|
|
|
110
111
|
if (!guarded.length) {
|
|
111
112
|
return;
|
|
112
113
|
}
|
|
113
|
-
const stripParams = (s) => s.replace(/\{\{[^}]*\}\}/g, '');
|
|
114
|
+
const stripParams = (s) => s.replace(/\{\{[^{}]*\}\}/g, '');
|
|
114
115
|
const scan = (text, where, own) => {
|
|
115
116
|
const bare = stripParams(text);
|
|
116
117
|
for (const v of guarded) {
|
package/lib/provider/matcher.js
CHANGED
|
@@ -572,7 +572,7 @@ function stableOpeningTail(openTag) {
|
|
|
572
572
|
* content (the JSON body the attestor exposes when a jsonPath is paired with
|
|
573
573
|
* the script's xPath); otherwise undefined. */
|
|
574
574
|
function scriptJsonContent(markup) {
|
|
575
|
-
const match = /^<script\b[^>]*>([\s\S]*)<\/script
|
|
575
|
+
const match = /^<script\b[^>]*>([\s\S]*)<\/script\b[^>]*>\s*$/i.exec(markup);
|
|
576
576
|
if (!match) {
|
|
577
577
|
return undefined;
|
|
578
578
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reclaimprotocol/agent",
|
|
3
3
|
"title": "Reclaim Agent",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.39",
|
|
5
5
|
"description": "Reclaim Protocol tools for AI coding agents. Reclaim lets your app verify data points about its users, such as a follower count, an account balance, or an order history. This package is the Reclaim MCP server: it gives your agent the tools to find, build, test, and fix data verification providers by capturing and replaying browser requests, drafting provider recipes, running zkTLS proofs, and publishing provider versions.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./lib/index.js",
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
"@noble/hashes": "^2.2.0",
|
|
85
85
|
"@puppeteer/browsers": "3.2.4",
|
|
86
86
|
"@reclaimprotocol/attestor-core": "^5.1.6",
|
|
87
|
-
"@reclaimprotocol/client": "0.1.
|
|
87
|
+
"@reclaimprotocol/client": "0.1.39",
|
|
88
88
|
"@reclaimprotocol/tls": "^0.1.3",
|
|
89
89
|
"chrome-remote-interface": "^0.33.0",
|
|
90
90
|
"domhandler": "^6.0.0",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import express from "express";
|
|
2
|
+
import { rateLimit } from "express-rate-limit";
|
|
2
3
|
import "dotenv/config";
|
|
3
4
|
import { ReclaimProofRequest, verifyProof } from "@reclaimprotocol/js-sdk";
|
|
4
5
|
import { randomUUID } from "node:crypto";
|
|
@@ -9,8 +10,12 @@ const providerId = __PROVIDER_ID__;
|
|
|
9
10
|
// Left unpinned by default — set this if you need to pin a specific version.
|
|
10
11
|
const providerVersion = __PROVIDER_VERSION__;
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
// One proxy hop (ngrok or a load balancer), so the rate limit below keys on
|
|
14
|
+
// the client's address rather than the proxy's.
|
|
15
|
+
app.set("trust proxy", 1);
|
|
13
16
|
app.use(express.json({ limit: "2mb" }));
|
|
17
|
+
// Every route verifies or creates something; cap each client's request rate.
|
|
18
|
+
app.use(rateLimit({ windowMs: 60_000, limit: 60 }));
|
|
14
19
|
|
|
15
20
|
app.get("/health", (_req, res) => {
|
|
16
21
|
res.json({ ok: true });
|
|
@@ -46,7 +46,7 @@ Build a new organization-private provider for exactly "{{goal}}". Do not search
|
|
|
46
46
|
|
|
47
47
|
Setup does not need the provider. Start it after step 2 has a working proof, or while you wait on the developer during step 2 (for example, while they sign in to the site). Don't let it delay a step that needs the developer.
|
|
48
48
|
|
|
49
|
-
**Delegation.**
|
|
49
|
+
**Delegation.** When the workspace already has an app and you can run subagents, hand this step to one subagent as soon as you are signed in, and keep working on step 2: step 2 needs the developer at the browser, step 3 does not. Give the subagent a self-contained brief:
|
|
50
50
|
|
|
51
51
|
```
|
|
52
52
|
Goal: let this app's users prove "{{goal}}" with Reclaim.
|
|
@@ -61,10 +61,12 @@ Without subagents, do step 2 first, and fill waits for the developer with this s
|
|
|
61
61
|
|
|
62
62
|
**No app yet?** If the workspace has no app to add verification to (it is empty, or has no backend or frontend), don't write one. Bootstrap from [builder-example](https://github.com/reclaimprotocol/builder-example), a ready-to-run Fastify backend and Vite frontend that already create sessions, verify callbacks, poll status, and render every UI state:
|
|
63
63
|
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
Do this yourself, not in a subagent.
|
|
65
|
+
|
|
66
|
+
- [ ] Call `create_example_app` with the workspace root you were started in as `projectDir`. It writes the example there and sets its backend to the latest `@reclaimprotocol/client`. Don't clone the repository or create a subdirectory: commands you run inside a clone count as running external code, and permission checks may block them.
|
|
67
|
+
- [ ] Call `issue_org_token` with the same `projectDir` and `envFile: "packages/backend/.env"`.
|
|
66
68
|
- [ ] Only when the developer asks for session binding or encrypted results: call `issue_org_keypair` with the same `projectDir` and `envFile` (add `encryptResults: true` for encrypted results). It writes `RECLAIM_ORG_ETH_PRIVATE_KEY` and registers the public key. Then pass `orgEthPrivateKey: process.env.RECLAIM_ORG_ETH_PRIVATE_KEY` to `reclaim.sessions.create` in `packages/backend/src/routes/verifications.ts` (binding) and to `reclaim.results.receive` in `packages/backend/src/routes/callback.ts` (decryption and TEE proof checks), and add an empty `RECLAIM_ORG_ETH_PRIVATE_KEY=` line to `packages/backend/example.env`.
|
|
67
|
-
- [ ] From the
|
|
69
|
+
- [ ] From the workspace root, run `npm install`, then `npm run dev`. The backend listens on port 3000; the app opens at <http://localhost:5173>. If a permission check blocks either command, don't work around it: end your turn, say which command was blocked and that it installs and runs the Reclaim example app, and ask the developer to approve it.
|
|
68
70
|
- [ ] Call `proxy_callback_events({ port: 3000, path: "/reclaim/callback" })`.
|
|
69
71
|
- [ ] Skip the rest of this step. In step 4, the provider goes in `packages/backend/config.json`: replace the example entry with `{ "id": "<provider ID>", "name": "<what it proves>" }`, then restart the backend, because it reads the file at startup.
|
|
70
72
|
|
|
@@ -14,10 +14,10 @@ proof works, or while you wait on the developer. When you need the developer
|
|
|
14
14
|
to answer or act, end your turn with the question and every link they need;
|
|
15
15
|
don't ask through a background or asynchronous question tool.
|
|
16
16
|
|
|
17
|
-
When the workspace has no app yet,
|
|
18
|
-
[builder-example](https://github.com/reclaimprotocol/builder-example)
|
|
19
|
-
`start_integration` plan describes, instead of writing a
|
|
20
|
-
frontend. Otherwise, inspect the app's frontend, backend, package manager,
|
|
17
|
+
When the workspace has no app yet, call `create_example_app` to write
|
|
18
|
+
[builder-example](https://github.com/reclaimprotocol/builder-example) into the
|
|
19
|
+
workspace root, as the `start_integration` plan describes, instead of writing a
|
|
20
|
+
new backend and frontend or cloning the repository. Otherwise, inspect the app's frontend, backend, package manager,
|
|
21
21
|
configuration, and callback conventions. Complete the integration in its codebase and run its
|
|
22
22
|
relevant checks. The MCP tools configure Builder resources; your file tools
|
|
23
23
|
edit the app.
|
|
@@ -20,6 +20,7 @@ import { type CaptureStore, captureTools, resetCaptures } from './capture.ts'
|
|
|
20
20
|
import { checkAttestorTool } from './check-attestor.ts'
|
|
21
21
|
import { draftTools } from './draft-tools.ts'
|
|
22
22
|
import { listTabsTool } from './eval.ts'
|
|
23
|
+
import { exampleAppTool } from './example-app.ts'
|
|
23
24
|
import { howItWorksTool } from './how-it-works.ts'
|
|
24
25
|
import { liveViewTools } from './live-view.ts'
|
|
25
26
|
import { podProofTools } from './pod-proof.ts'
|
|
@@ -101,6 +102,7 @@ export function buildAgentTools(
|
|
|
101
102
|
|
|
102
103
|
if(client) {
|
|
103
104
|
tools.push(startIntegrationTool())
|
|
105
|
+
tools.push(exampleAppTool())
|
|
104
106
|
tools.push(createVersionFromCaptureTool(
|
|
105
107
|
client, state.attach, state.captures,
|
|
106
108
|
))
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `create_example_app` — write reclaimprotocol/builder-example into the
|
|
3
|
+
* developer's workspace, so a project with no app starts from a ready-to-run
|
|
4
|
+
* backend and frontend instead of agent-written code. The MCP server fetches
|
|
5
|
+
* the files itself (each checked against its git blob hash) and writes them at
|
|
6
|
+
* the workspace root: the coding agent's later `npm install` and `npm run dev`
|
|
7
|
+
* then run in the workspace it started in, not in a clone. The backend's
|
|
8
|
+
* `@reclaimprotocol/client` is set to the latest published version.
|
|
9
|
+
*/
|
|
10
|
+
import { badGateway, badRequest, conflict } from '@hapi/boom'
|
|
11
|
+
import assert from 'node:assert'
|
|
12
|
+
import { createHash } from 'node:crypto'
|
|
13
|
+
import { existsSync, lstatSync, readFileSync } from 'node:fs'
|
|
14
|
+
import { mkdir, realpath, rm, writeFile } from 'node:fs/promises'
|
|
15
|
+
import { isAbsolute, join, relative, sep } from 'node:path'
|
|
16
|
+
import { defineTool, type RegisteredTool } from '../../server.ts'
|
|
17
|
+
|
|
18
|
+
const REPO = 'reclaimprotocol/builder-example'
|
|
19
|
+
/** GitHub tree paths are always `/`-separated, on every OS. */
|
|
20
|
+
const BACKEND_PACKAGE = 'packages/backend/package.json'
|
|
21
|
+
const CLIENT_PACKAGE = '@reclaimprotocol/client'
|
|
22
|
+
/** The example is ~50 small files; anything far past that isn't it. */
|
|
23
|
+
const MAX_FILES = 500
|
|
24
|
+
const MAX_BYTES = 10_000_000
|
|
25
|
+
|
|
26
|
+
export function exampleAppTool(fetchFn: typeof fetch = fetch): RegisteredTool {
|
|
27
|
+
return defineTool<{ projectDir: string }>(
|
|
28
|
+
{
|
|
29
|
+
name: 'create_example_app',
|
|
30
|
+
description:
|
|
31
|
+
'Writes the Reclaim example app (reclaimprotocol/builder-example: '
|
|
32
|
+
+ 'a Fastify backend and a Vite frontend that create sessions, '
|
|
33
|
+
+ 'verify callbacks, and render every verification state) into '
|
|
34
|
+
+ '`projectDir`, and sets its backend to the latest '
|
|
35
|
+
+ '`@reclaimprotocol/client`. Use it when the workspace has no app '
|
|
36
|
+
+ 'yet, with the workspace root you were started in as '
|
|
37
|
+
+ '`projectDir`; don\'t clone the repository yourself. Refuses '
|
|
38
|
+
+ 'when a file it would write already exists with different '
|
|
39
|
+
+ 'content. Returns the commit, file count, client version, and '
|
|
40
|
+
+ 'next steps.',
|
|
41
|
+
inputSchema: {
|
|
42
|
+
type: 'object',
|
|
43
|
+
required: ['projectDir'],
|
|
44
|
+
properties: {
|
|
45
|
+
projectDir: {
|
|
46
|
+
type: 'string',
|
|
47
|
+
description:
|
|
48
|
+
'Absolute path of the workspace root you were started in.',
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
annotations: { openWorldHint: true, destructiveHint: false },
|
|
53
|
+
},
|
|
54
|
+
async(args) => {
|
|
55
|
+
assert(
|
|
56
|
+
isAbsolute(args.projectDir),
|
|
57
|
+
badRequest('`projectDir` must be an absolute path.'),
|
|
58
|
+
)
|
|
59
|
+
const projectDir = await realpath(args.projectDir)
|
|
60
|
+
const commit = await latestCommit()
|
|
61
|
+
const files = await listFiles(commit)
|
|
62
|
+
const clientVersion = await latestClientVersion()
|
|
63
|
+
const staged = await Promise.all(files.map(async(file) => {
|
|
64
|
+
const bytes = await download(commit, file)
|
|
65
|
+
return {
|
|
66
|
+
path: file.path,
|
|
67
|
+
bytes: file.path === BACKEND_PACKAGE
|
|
68
|
+
? withClientVersion(bytes, clientVersion)
|
|
69
|
+
: bytes,
|
|
70
|
+
}
|
|
71
|
+
}))
|
|
72
|
+
for(const { path } of staged) {
|
|
73
|
+
assertNoSymlinks(projectDir, path)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const conflicts = staged
|
|
77
|
+
.filter(({ path, bytes }) => {
|
|
78
|
+
const dest = join(projectDir, path)
|
|
79
|
+
return existsSync(dest) && !readFileSync(dest).equals(bytes)
|
|
80
|
+
})
|
|
81
|
+
.map(({ path }) => path)
|
|
82
|
+
assert(
|
|
83
|
+
!conflicts.length,
|
|
84
|
+
conflict(
|
|
85
|
+
'These files already exist with different content: '
|
|
86
|
+
+ `${conflicts.join(', ')}. Ask the developer whether to use `
|
|
87
|
+
+ 'another directory or move them.',
|
|
88
|
+
),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
for(const { path, bytes } of staged) {
|
|
92
|
+
await installFile(projectDir, path, bytes)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
projectDir,
|
|
97
|
+
repository: `https://github.com/${REPO}`,
|
|
98
|
+
commit,
|
|
99
|
+
files: staged.length,
|
|
100
|
+
clientVersion,
|
|
101
|
+
next: [
|
|
102
|
+
'Call issue_org_token with this projectDir and envFile '
|
|
103
|
+
+ '"packages/backend/.env".',
|
|
104
|
+
'Run `npm install`, then `npm run dev`, from projectDir. The '
|
|
105
|
+
+ 'backend listens on port 3000; the app opens at '
|
|
106
|
+
+ 'http://localhost:5173.',
|
|
107
|
+
'Call proxy_callback_events({ port: 3000, path: '
|
|
108
|
+
+ '"/reclaim/callback" }).',
|
|
109
|
+
'Put the provider in packages/backend/config.json and restart '
|
|
110
|
+
+ 'the backend.',
|
|
111
|
+
],
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
async function latestCommit() {
|
|
117
|
+
const res = await fetchFn(
|
|
118
|
+
`https://api.github.com/repos/${REPO}/commits/main`,
|
|
119
|
+
{ headers: { Accept: 'application/vnd.github.sha' } },
|
|
120
|
+
)
|
|
121
|
+
assert(res.ok, badGateway(`${REPO} commit lookup: HTTP ${res.status}`))
|
|
122
|
+
const text = await res.text()
|
|
123
|
+
const commit = text.trim()
|
|
124
|
+
assert(/^[0-9a-f]{40}$/.test(commit), badGateway('invalid commit SHA'))
|
|
125
|
+
return commit
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function listFiles(commit: string) {
|
|
129
|
+
const res = await fetchFn(
|
|
130
|
+
`https://api.github.com/repos/${REPO}/git/trees/${commit}?recursive=1`,
|
|
131
|
+
{ headers: { Accept: 'application/vnd.github+json' } },
|
|
132
|
+
)
|
|
133
|
+
assert(res.ok, badGateway(`${REPO} file listing: HTTP ${res.status}`))
|
|
134
|
+
const tree: unknown = await res.json()
|
|
135
|
+
assert(
|
|
136
|
+
typeof tree === 'object' && tree !== null && 'tree' in tree
|
|
137
|
+
&& Array.isArray(tree.tree) && !('truncated' in tree && tree.truncated),
|
|
138
|
+
badGateway(`unexpected ${REPO} file listing`),
|
|
139
|
+
)
|
|
140
|
+
const files = tree.tree
|
|
141
|
+
.filter((e: unknown) => {
|
|
142
|
+
return typeof e === 'object' && e !== null && 'type' in e
|
|
143
|
+
&& e.type === 'blob'
|
|
144
|
+
})
|
|
145
|
+
.map((entry: unknown) => {
|
|
146
|
+
assert(
|
|
147
|
+
typeof entry === 'object' && entry !== null
|
|
148
|
+
&& 'path' in entry && typeof entry.path === 'string'
|
|
149
|
+
&& entry.path.split('/').every((s) => /^[\w@.-]+$/.test(s)
|
|
150
|
+
&& s !== '.' && s !== '..' && s !== '.git')
|
|
151
|
+
&& 'sha' in entry && typeof entry.sha === 'string'
|
|
152
|
+
&& 'size' in entry && typeof entry.size === 'number',
|
|
153
|
+
badGateway(`unexpected entry in the ${REPO} file listing`),
|
|
154
|
+
)
|
|
155
|
+
return { path: entry.path, sha: entry.sha, size: entry.size }
|
|
156
|
+
})
|
|
157
|
+
assert(
|
|
158
|
+
files.length > 0 && files.length <= MAX_FILES
|
|
159
|
+
&& files.reduce((n, f) => n + f.size, 0) <= MAX_BYTES,
|
|
160
|
+
badGateway(`unexpected ${REPO} size`),
|
|
161
|
+
)
|
|
162
|
+
return files
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function download(commit: string, file: { path: string, sha: string }) {
|
|
166
|
+
const res = await fetchFn(
|
|
167
|
+
`https://raw.githubusercontent.com/${REPO}/${commit}/${file.path}`,
|
|
168
|
+
)
|
|
169
|
+
assert(res.ok, badGateway(`${file.path}: HTTP ${res.status}`))
|
|
170
|
+
const bytes = Buffer.from(await res.arrayBuffer())
|
|
171
|
+
assert(
|
|
172
|
+
gitBlobSha(bytes) === file.sha,
|
|
173
|
+
badGateway(`${file.path} failed its integrity check`),
|
|
174
|
+
)
|
|
175
|
+
return bytes
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function latestClientVersion() {
|
|
179
|
+
const res = await fetchFn(
|
|
180
|
+
`https://registry.npmjs.org/${encodeURIComponent(CLIENT_PACKAGE)}/latest`,
|
|
181
|
+
)
|
|
182
|
+
assert(res.ok, badGateway(`npm ${CLIENT_PACKAGE}: HTTP ${res.status}`))
|
|
183
|
+
const body: unknown = await res.json()
|
|
184
|
+
assert(
|
|
185
|
+
typeof body === 'object' && body !== null && 'version' in body
|
|
186
|
+
&& typeof body.version === 'string'
|
|
187
|
+
&& /^\d+\.\d+\.\d+$/.test(body.version),
|
|
188
|
+
badGateway(`npm ${CLIENT_PACKAGE}: no version`),
|
|
189
|
+
)
|
|
190
|
+
return body.version
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Write one file under `root` without following a symlink out of it or
|
|
196
|
+
* replacing anything: each parent is created one level at a time and must be
|
|
197
|
+
* a real directory, the file is created exclusively (`wx` fails on any path
|
|
198
|
+
* that appeared since the conflict scan, symlinks included), and its real
|
|
199
|
+
* path is checked to still be inside `root` afterwards. A file already there
|
|
200
|
+
* with the same bytes (a re-run) is left as is.
|
|
201
|
+
*/
|
|
202
|
+
async function installFile(root: string, path: string, bytes: Buffer) {
|
|
203
|
+
const parts = path.split('/')
|
|
204
|
+
let dir = root
|
|
205
|
+
for(const part of parts.slice(0, -1)) {
|
|
206
|
+
dir = join(dir, part)
|
|
207
|
+
await mkdir(dir).catch((err: unknown) => {
|
|
208
|
+
if(!isEexist(err)) {
|
|
209
|
+
throw err
|
|
210
|
+
}
|
|
211
|
+
})
|
|
212
|
+
assertNoSymlinks(root, relative(root, dir))
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const dest = join(dir, parts.at(-1)!)
|
|
216
|
+
try {
|
|
217
|
+
await writeFile(dest, bytes, { flag: 'wx' })
|
|
218
|
+
} catch(err) {
|
|
219
|
+
if(!isEexist(err)) {
|
|
220
|
+
throw err
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
assertNoSymlinks(root, path)
|
|
224
|
+
assert(
|
|
225
|
+
readFileSync(dest).equals(bytes),
|
|
226
|
+
conflict(`${path} appeared with different content; nothing replaced`),
|
|
227
|
+
)
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const real = await realpath(dest)
|
|
232
|
+
if(!real.startsWith(root + sep)) {
|
|
233
|
+
await rm(dest, { force: true })
|
|
234
|
+
throw badRequest(`${path} resolved outside the workspace; removed it`)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function isEexist(err: unknown) {
|
|
239
|
+
return err instanceof Error && 'code' in err && err.code === 'EEXIST'
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Throws when `path`, or any of its existing parents under `root`, is a
|
|
243
|
+
* symlink: writing through one would land outside the workspace. */
|
|
244
|
+
function assertNoSymlinks(root: string, path: string) {
|
|
245
|
+
let current = root
|
|
246
|
+
for(const part of path.split(/[\\/]/)) {
|
|
247
|
+
current += sep + part
|
|
248
|
+
const stat = lstatSync(current, { throwIfNoEntry: false })
|
|
249
|
+
assert(
|
|
250
|
+
!stat?.isSymbolicLink(),
|
|
251
|
+
badRequest(
|
|
252
|
+
`${current} is a symlink; the example app is only written to `
|
|
253
|
+
+ 'real directories inside the workspace',
|
|
254
|
+
),
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** The backend's package.json with the client range set to `^version`. */
|
|
260
|
+
function withClientVersion(bytes: Buffer, version: string) {
|
|
261
|
+
const text = bytes.toString('utf8')
|
|
262
|
+
const next = text.replace(
|
|
263
|
+
/("@reclaimprotocol\/client"\s*:\s*")[^"]*(")/,
|
|
264
|
+
(_, open: string, close: string) => `${open}^${version}${close}`,
|
|
265
|
+
)
|
|
266
|
+
assert(
|
|
267
|
+
next !== text || text.includes(`"^${version}"`),
|
|
268
|
+
badGateway(`${BACKEND_PACKAGE} has no ${CLIENT_PACKAGE} dependency`),
|
|
269
|
+
)
|
|
270
|
+
return Buffer.from(next)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Git's blob object id: sha1 of `blob <size>\0<content>`. */
|
|
274
|
+
function gitBlobSha(bytes: Buffer) {
|
|
275
|
+
return createHash('sha1')
|
|
276
|
+
.update(`blob ${bytes.length}\0`)
|
|
277
|
+
.update(bytes)
|
|
278
|
+
.digest('hex')
|
|
279
|
+
}
|
package/src/provider/draft.ts
CHANGED
|
@@ -132,10 +132,11 @@ export function assertPortableMatches(
|
|
|
132
132
|
// proving an email's domain substring. Reject it regardless of the declared
|
|
133
133
|
// targets (the local part is usually not itself a target, so the value scan
|
|
134
134
|
// below wouldn't catch it). Strip params + named groups + regex escapes
|
|
135
|
-
// first, then look for `localpart@` as a surviving literal.
|
|
135
|
+
// first, then look for `localpart@` as a surviving literal. A placeholder
|
|
136
|
+
// never contains a brace, and `[^{}]` keeps the scan linear on `{{{{…`.
|
|
136
137
|
const EMAIL_LOCAL_BAKED = /[A-Za-z0-9._%+-]{3,}@/
|
|
137
138
|
const deregex = (s: string): string => s
|
|
138
|
-
.replace(/\{\{[^}]*\}\}/g, '')
|
|
139
|
+
.replace(/\{\{[^{}]*\}\}/g, '')
|
|
139
140
|
.replace(/\(\?<[^>]*>[^)]*\)/g, '')
|
|
140
141
|
.replace(/\\(.)/g, '$1')
|
|
141
142
|
const scanEmail = (text: string, where: string) => {
|
|
@@ -168,7 +169,7 @@ export function assertPortableMatches(
|
|
|
168
169
|
return
|
|
169
170
|
}
|
|
170
171
|
|
|
171
|
-
const stripParams = (s: string): string => s.replace(/\{\{[^}]*\}\}/g, '')
|
|
172
|
+
const stripParams = (s: string): string => s.replace(/\{\{[^{}]*\}\}/g, '')
|
|
172
173
|
const scan = (text: string, where: string, own: string | null) => {
|
|
173
174
|
const bare = stripParams(text)
|
|
174
175
|
for(const v of guarded) {
|
package/src/provider/matcher.ts
CHANGED
|
@@ -789,7 +789,7 @@ function stableOpeningTail(openTag: string): string {
|
|
|
789
789
|
* content (the JSON body the attestor exposes when a jsonPath is paired with
|
|
790
790
|
* the script's xPath); otherwise undefined. */
|
|
791
791
|
function scriptJsonContent(markup: string): string | undefined {
|
|
792
|
-
const match = /^<script\b[^>]*>([\s\S]*)<\/script
|
|
792
|
+
const match = /^<script\b[^>]*>([\s\S]*)<\/script\b[^>]*>\s*$/i.exec(markup)
|
|
793
793
|
if(!match) {
|
|
794
794
|
return undefined
|
|
795
795
|
}
|