@reclaimprotocol/agent 0.1.38 → 0.1.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/mcp/api/operations.gen.d.ts +7 -0
- package/lib/mcp/api/operations.gen.js +49 -3
- package/lib/mcp/instructions.js +10 -8
- package/lib/mcp/tools/agent/example-app.d.ts +5 -1
- package/lib/mcp/tools/agent/example-app.js +138 -31
- package/lib/mcp/tools/authenticate/eth-key.d.ts +15 -0
- package/lib/mcp/tools/authenticate/eth-key.js +72 -6
- package/lib/mcp/tools/authenticate/issue-org-keypair.d.ts +3 -2
- package/lib/mcp/tools/authenticate/issue-org-keypair.js +83 -18
- package/lib/mcp/tools/authenticate/issue-org-token.js +9 -2
- 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/SKILL.builder.md +30 -11
- package/skill/SKILL.old.md +23 -5
- package/skill/builder-mode.md +29 -164
- package/skill/old-mode.md +40 -95
- package/skill/start-integration.md +12 -9
- package/skill/verification-client-integration.md +39 -9
- package/src/mcp/api/operations.gen.ts +51 -3
- package/src/mcp/instructions.ts +10 -8
- package/src/mcp/tools/agent/example-app.ts +156 -37
- package/src/mcp/tools/authenticate/eth-key.ts +114 -6
- package/src/mcp/tools/authenticate/issue-org-keypair.ts +97 -19
- package/src/mcp/tools/authenticate/issue-org-token.ts +13 -2
- package/src/provider/draft.ts +4 -3
- package/src/provider/matcher.ts +1 -1
|
@@ -11,24 +11,33 @@ import { badGateway, badRequest, conflict } from '@hapi/boom'
|
|
|
11
11
|
import assert from 'node:assert'
|
|
12
12
|
import { createHash } from 'node:crypto'
|
|
13
13
|
import { existsSync, readFileSync } from 'node:fs'
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
rename,
|
|
18
|
-
rm,
|
|
19
|
-
writeFile
|
|
20
|
-
} from 'node:fs/promises'
|
|
21
|
-
import { dirname, isAbsolute, join } from 'node:path'
|
|
14
|
+
import { mkdir, realpath, rm, writeFile } from 'node:fs/promises'
|
|
15
|
+
import { connect } from 'node:net'
|
|
16
|
+
import { isAbsolute, join, relative, sep } from 'node:path'
|
|
22
17
|
import { defineTool, type RegisteredTool } from '../../server.ts'
|
|
18
|
+
import { assertNoSymlinks, upsertEnvVar } from '../authenticate/eth-key.ts'
|
|
23
19
|
|
|
24
20
|
const REPO = 'reclaimprotocol/builder-example'
|
|
25
|
-
|
|
21
|
+
/** GitHub tree paths are always `/`-separated, on every OS. */
|
|
22
|
+
const BACKEND_PACKAGE = 'packages/backend/package.json'
|
|
26
23
|
const CLIENT_PACKAGE = '@reclaimprotocol/client'
|
|
27
24
|
/** The example is ~50 small files; anything far past that isn't it. */
|
|
28
25
|
const MAX_FILES = 500
|
|
29
26
|
const MAX_BYTES = 10_000_000
|
|
27
|
+
const BACKEND_ENV_FILE = 'packages/backend/.env'
|
|
28
|
+
const DEFAULT_BACKEND_PORT = 3000
|
|
29
|
+
/** Ports probed for the backend: 3000, then 3001 through 3020. */
|
|
30
|
+
const BACKEND_PORT_TRIES = 21
|
|
31
|
+
const LOOPBACK_HOSTS = ['127.0.0.1', '::1']
|
|
32
|
+
const PROBE_TIMEOUT_MS = 500
|
|
33
|
+
/** Files a workspace may already have. An existing copy is kept, not a
|
|
34
|
+
* conflict. The env-file tools add their own entries to `.gitignore`. */
|
|
35
|
+
const KEEP_EXISTING = new Set(['AGENTS.md', '.gitignore'])
|
|
30
36
|
|
|
31
|
-
export function exampleAppTool(
|
|
37
|
+
export function exampleAppTool(
|
|
38
|
+
fetchFn: typeof fetch = fetch,
|
|
39
|
+
isPortFree: (port: number) => Promise<boolean> = isLoopbackPortFree,
|
|
40
|
+
): RegisteredTool {
|
|
32
41
|
return defineTool<{ projectDir: string }>(
|
|
33
42
|
{
|
|
34
43
|
name: 'create_example_app',
|
|
@@ -37,12 +46,14 @@ export function exampleAppTool(fetchFn: typeof fetch = fetch): RegisteredTool {
|
|
|
37
46
|
+ 'a Fastify backend and a Vite frontend that create sessions, '
|
|
38
47
|
+ 'verify callbacks, and render every verification state) into '
|
|
39
48
|
+ '`projectDir`, and sets its backend to the latest '
|
|
40
|
-
+ '`@reclaimprotocol/client`.
|
|
49
|
+
+ '`@reclaimprotocol/client`. It picks a free backend port (3000, else '
|
|
50
|
+
+ 'the next free one, written to `packages/backend/.env`). Use it '
|
|
51
|
+
+ 'when the workspace has no app '
|
|
41
52
|
+ 'yet, with the workspace root you were started in as '
|
|
42
53
|
+ '`projectDir`; don\'t clone the repository yourself. Refuses '
|
|
43
54
|
+ 'when a file it would write already exists with different '
|
|
44
|
-
+ 'content. Returns the commit, file count, client version,
|
|
45
|
-
+ 'next steps.',
|
|
55
|
+
+ 'content. Returns the commit, file count, client version, '
|
|
56
|
+
+ '`backendPort`, and next steps.',
|
|
46
57
|
inputSchema: {
|
|
47
58
|
type: 'object',
|
|
48
59
|
required: ['projectDir'],
|
|
@@ -56,11 +67,12 @@ export function exampleAppTool(fetchFn: typeof fetch = fetch): RegisteredTool {
|
|
|
56
67
|
},
|
|
57
68
|
annotations: { openWorldHint: true, destructiveHint: false },
|
|
58
69
|
},
|
|
59
|
-
async(
|
|
70
|
+
async(args) => {
|
|
60
71
|
assert(
|
|
61
|
-
isAbsolute(projectDir),
|
|
72
|
+
isAbsolute(args.projectDir),
|
|
62
73
|
badRequest('`projectDir` must be an absolute path.'),
|
|
63
74
|
)
|
|
75
|
+
const projectDir = await realpath(args.projectDir)
|
|
64
76
|
const commit = await latestCommit()
|
|
65
77
|
const files = await listFiles(commit)
|
|
66
78
|
const clientVersion = await latestClientVersion()
|
|
@@ -73,7 +85,17 @@ export function exampleAppTool(fetchFn: typeof fetch = fetch): RegisteredTool {
|
|
|
73
85
|
: bytes,
|
|
74
86
|
}
|
|
75
87
|
}))
|
|
76
|
-
const
|
|
88
|
+
for(const path of [...staged.map((f) => f.path), BACKEND_ENV_FILE]) {
|
|
89
|
+
assertNoSymlinks(projectDir, path)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const kept = staged
|
|
93
|
+
.filter(({ path }) => {
|
|
94
|
+
return KEEP_EXISTING.has(path) && existsSync(join(projectDir, path))
|
|
95
|
+
})
|
|
96
|
+
.map(({ path }) => path)
|
|
97
|
+
const toWrite = staged.filter(({ path }) => !kept.includes(path))
|
|
98
|
+
const conflicts = toWrite
|
|
77
99
|
.filter(({ path, bytes }) => {
|
|
78
100
|
const dest = join(projectDir, path)
|
|
79
101
|
return existsSync(dest) && !readFileSync(dest).equals(bytes)
|
|
@@ -88,44 +110,72 @@ export function exampleAppTool(fetchFn: typeof fetch = fetch): RegisteredTool {
|
|
|
88
110
|
),
|
|
89
111
|
)
|
|
90
112
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
try {
|
|
94
|
-
for(const { path, bytes } of staged) {
|
|
95
|
-
await mkdir(dirname(join(tmp, path)), { recursive: true })
|
|
96
|
-
await writeFile(join(tmp, path), bytes)
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
for(const { path } of staged) {
|
|
100
|
-
const dest = join(projectDir, path)
|
|
101
|
-
await mkdir(dirname(dest), { recursive: true })
|
|
102
|
-
await rename(join(tmp, path), dest)
|
|
103
|
-
}
|
|
104
|
-
} finally {
|
|
105
|
-
await rm(tmp, { recursive: true, force: true })
|
|
113
|
+
for(const { path, bytes } of toWrite) {
|
|
114
|
+
await installFile(projectDir, path, bytes)
|
|
106
115
|
}
|
|
107
116
|
|
|
117
|
+
const backendPort = await findBackendPort()
|
|
118
|
+
// Always written, so a re-run never leaves a stale port behind.
|
|
119
|
+
upsertEnvVar(projectDir, BACKEND_ENV_FILE, 'PORT', String(backendPort))
|
|
120
|
+
|
|
121
|
+
const dev = backendPort === DEFAULT_BACKEND_PORT
|
|
122
|
+
? 'npm run dev'
|
|
123
|
+
: `API_URL=http://localhost:${backendPort} npm run dev`
|
|
124
|
+
|
|
108
125
|
return {
|
|
109
126
|
projectDir,
|
|
110
127
|
repository: `https://github.com/${REPO}`,
|
|
111
128
|
commit,
|
|
112
|
-
files:
|
|
129
|
+
files: toWrite.length,
|
|
130
|
+
kept,
|
|
113
131
|
clientVersion,
|
|
132
|
+
backendPort,
|
|
114
133
|
next: [
|
|
115
134
|
'Call issue_org_token with this projectDir and envFile '
|
|
116
135
|
+ '"packages/backend/.env".',
|
|
117
|
-
'Run `npm install
|
|
118
|
-
+
|
|
119
|
-
+
|
|
120
|
-
|
|
136
|
+
'Run `npm install` from projectDir, then start the dev '
|
|
137
|
+
+ `server with \`${dev}\`. The backend listens on port `
|
|
138
|
+
+ `${backendPort}`
|
|
139
|
+
+ (backendPort === DEFAULT_BACKEND_PORT
|
|
140
|
+
? '.'
|
|
141
|
+
: ` (port ${DEFAULT_BACKEND_PORT} is in use; the frontend `
|
|
142
|
+
+ 'proxy needs `API_URL`).'),
|
|
143
|
+
'Run the dev server in the background and send its output to a '
|
|
144
|
+
+ 'log file in projectDir, for example `npm run dev > '
|
|
145
|
+
+ 'reclaim-dev.log 2>&1`, so callback errors stay readable even '
|
|
146
|
+
+ 'when a shell output filter buffers output.',
|
|
147
|
+
'Give the developer the frontend URL Vite prints in that log. '
|
|
148
|
+
+ 'Don\'t assume port 5173: Vite moves to the next free port '
|
|
149
|
+
+ 'when it is taken.',
|
|
150
|
+
`Call proxy_callback_events({ port: ${backendPort}, path: `
|
|
121
151
|
+ '"/reclaim/callback" }).',
|
|
152
|
+
'If get_org_keypair shows `canEncryptResult: true`, the '
|
|
153
|
+
+ 'callback needs `orgEthPrivateKey`; see the plan.',
|
|
122
154
|
'Put the provider in packages/backend/config.json and restart '
|
|
123
155
|
+ 'the backend.',
|
|
156
|
+
'The backend stores verifications in memory: restarting it '
|
|
157
|
+
+ 'drops sessions in progress, so start a new verification '
|
|
158
|
+
+ 'after any restart.',
|
|
124
159
|
],
|
|
125
160
|
}
|
|
126
161
|
},
|
|
127
162
|
)
|
|
128
163
|
|
|
164
|
+
async function findBackendPort() {
|
|
165
|
+
for(let i = 0; i < BACKEND_PORT_TRIES; i++) {
|
|
166
|
+
const port = DEFAULT_BACKEND_PORT + i
|
|
167
|
+
if(await isPortFree(port)) {
|
|
168
|
+
return port
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
throw conflict(
|
|
173
|
+
`No free port from ${DEFAULT_BACKEND_PORT} to `
|
|
174
|
+
+ `${DEFAULT_BACKEND_PORT + BACKEND_PORT_TRIES - 1}. Ask the developer `
|
|
175
|
+
+ 'to stop another dev server.',
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
129
179
|
async function latestCommit() {
|
|
130
180
|
const res = await fetchFn(
|
|
131
181
|
`https://api.github.com/repos/${REPO}/commits/main`,
|
|
@@ -190,7 +240,7 @@ export function exampleAppTool(fetchFn: typeof fetch = fetch): RegisteredTool {
|
|
|
190
240
|
|
|
191
241
|
async function latestClientVersion() {
|
|
192
242
|
const res = await fetchFn(
|
|
193
|
-
`https://registry.npmjs.org/${CLIENT_PACKAGE
|
|
243
|
+
`https://registry.npmjs.org/${encodeURIComponent(CLIENT_PACKAGE)}/latest`,
|
|
194
244
|
)
|
|
195
245
|
assert(res.ok, badGateway(`npm ${CLIENT_PACKAGE}: HTTP ${res.status}`))
|
|
196
246
|
const body: unknown = await res.json()
|
|
@@ -204,6 +254,54 @@ export function exampleAppTool(fetchFn: typeof fetch = fetch): RegisteredTool {
|
|
|
204
254
|
}
|
|
205
255
|
}
|
|
206
256
|
|
|
257
|
+
/**
|
|
258
|
+
* Write one file under `root` without following a symlink out of it or
|
|
259
|
+
* replacing anything: each parent is created one level at a time and must be
|
|
260
|
+
* a real directory, the file is created exclusively (`wx` fails on any path
|
|
261
|
+
* that appeared since the conflict scan, symlinks included), and its real
|
|
262
|
+
* path is checked to still be inside `root` afterwards. A file already there
|
|
263
|
+
* with the same bytes (a re-run) is left as is.
|
|
264
|
+
*/
|
|
265
|
+
async function installFile(root: string, path: string, bytes: Buffer) {
|
|
266
|
+
const parts = path.split('/')
|
|
267
|
+
let dir = root
|
|
268
|
+
for(const part of parts.slice(0, -1)) {
|
|
269
|
+
dir = join(dir, part)
|
|
270
|
+
await mkdir(dir).catch((err: unknown) => {
|
|
271
|
+
if(!isEexist(err)) {
|
|
272
|
+
throw err
|
|
273
|
+
}
|
|
274
|
+
})
|
|
275
|
+
assertNoSymlinks(root, relative(root, dir))
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const dest = join(dir, parts.at(-1)!)
|
|
279
|
+
try {
|
|
280
|
+
await writeFile(dest, bytes, { flag: 'wx' })
|
|
281
|
+
} catch(err) {
|
|
282
|
+
if(!isEexist(err)) {
|
|
283
|
+
throw err
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
assertNoSymlinks(root, path)
|
|
287
|
+
assert(
|
|
288
|
+
readFileSync(dest).equals(bytes),
|
|
289
|
+
conflict(`${path} appeared with different content; nothing replaced`),
|
|
290
|
+
)
|
|
291
|
+
return
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const real = await realpath(dest)
|
|
295
|
+
if(!real.startsWith(root + sep)) {
|
|
296
|
+
await rm(dest, { force: true })
|
|
297
|
+
throw badRequest(`${path} resolved outside the workspace; removed it`)
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function isEexist(err: unknown) {
|
|
302
|
+
return err instanceof Error && 'code' in err && err.code === 'EEXIST'
|
|
303
|
+
}
|
|
304
|
+
|
|
207
305
|
/** The backend's package.json with the client range set to `^version`. */
|
|
208
306
|
function withClientVersion(bytes: Buffer, version: string) {
|
|
209
307
|
const text = bytes.toString('utf8')
|
|
@@ -225,3 +323,24 @@ function gitBlobSha(bytes: Buffer) {
|
|
|
225
323
|
.update(bytes)
|
|
226
324
|
.digest('hex')
|
|
227
325
|
}
|
|
326
|
+
|
|
327
|
+
/** True when nothing accepts connections on `port` at 127.0.0.1 or ::1.
|
|
328
|
+
* Connecting, rather than binding, also catches servers bound to `0.0.0.0`:
|
|
329
|
+
* macOS lets a loopback bind succeed alongside them. Exported for tests. */
|
|
330
|
+
export async function isLoopbackPortFree(port: number) {
|
|
331
|
+
const results = await Promise.all(LOOPBACK_HOSTS.map((host) => {
|
|
332
|
+
return new Promise<boolean>((resolve) => {
|
|
333
|
+
const socket = connect({ port, host, timeout: PROBE_TIMEOUT_MS })
|
|
334
|
+
socket.once('connect', () => {
|
|
335
|
+
socket.destroy()
|
|
336
|
+
resolve(false)
|
|
337
|
+
})
|
|
338
|
+
socket.once('timeout', () => {
|
|
339
|
+
socket.destroy()
|
|
340
|
+
resolve(true)
|
|
341
|
+
})
|
|
342
|
+
socket.once('error', () => resolve(true))
|
|
343
|
+
})
|
|
344
|
+
}))
|
|
345
|
+
return results.every(Boolean)
|
|
346
|
+
}
|
|
@@ -15,12 +15,17 @@
|
|
|
15
15
|
* signing key only — unrelated to org auth (the org secret) or encryption
|
|
16
16
|
* (the eth decrypt key).
|
|
17
17
|
*/
|
|
18
|
+
import { badRequest } from '@hapi/boom'
|
|
18
19
|
import { deriveFromPrivateKey, generateKeypair } from '@reclaimprotocol/client'
|
|
19
20
|
import assert from 'node:assert'
|
|
20
21
|
import {
|
|
21
|
-
|
|
22
|
+
closeSync,
|
|
23
|
+
constants,
|
|
22
24
|
existsSync,
|
|
25
|
+
fchmodSync,
|
|
26
|
+
lstatSync,
|
|
23
27
|
mkdirSync,
|
|
28
|
+
openSync,
|
|
24
29
|
readFileSync,
|
|
25
30
|
writeFileSync,
|
|
26
31
|
} from 'node:fs'
|
|
@@ -29,7 +34,8 @@ import {
|
|
|
29
34
|
isAbsolute,
|
|
30
35
|
join,
|
|
31
36
|
relative,
|
|
32
|
-
resolve
|
|
37
|
+
resolve,
|
|
38
|
+
sep
|
|
33
39
|
} from 'node:path'
|
|
34
40
|
import { privateKey, privateKeyFile } from '../../../consts.ts'
|
|
35
41
|
import { findProjectRoot } from '../../../paths.ts'
|
|
@@ -178,7 +184,7 @@ export function ensureGitignored(projectDir: string, entry: string) {
|
|
|
178
184
|
}
|
|
179
185
|
|
|
180
186
|
const prefix = current && !current.endsWith('\n') ? '\n' : ''
|
|
181
|
-
|
|
187
|
+
writeInside(projectDir, path, current + prefix + entry + '\n')
|
|
182
188
|
}
|
|
183
189
|
|
|
184
190
|
/**
|
|
@@ -205,13 +211,60 @@ export function upsertEnvVar(
|
|
|
205
211
|
next = existing + prefix + line + '\n'
|
|
206
212
|
}
|
|
207
213
|
|
|
208
|
-
|
|
209
|
-
writeFileSync(envPath, next, { mode: 0o600 })
|
|
210
|
-
chmodSync(envPath, 0o600)
|
|
214
|
+
writeInside(projectDir, envPath, next, { secret: true })
|
|
211
215
|
ensureGitignored(projectDir, relative(projectDir, envPath))
|
|
212
216
|
return envPath
|
|
213
217
|
}
|
|
214
218
|
|
|
219
|
+
/** Remove the `name=` line from a dotenv file, if there is one. */
|
|
220
|
+
export function removeEnvVar(
|
|
221
|
+
projectDir: string,
|
|
222
|
+
envFile: string,
|
|
223
|
+
name: string,
|
|
224
|
+
) {
|
|
225
|
+
const envPath = resolve(projectDir, envFile)
|
|
226
|
+
if(!existsSync(envPath)) {
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const existing = readFileSync(envPath, 'utf8')
|
|
231
|
+
const re = new RegExp(`^\\s*${name}\\s*=.*(\\n|$)`, 'm')
|
|
232
|
+
writeInside(projectDir, envPath, existing.replace(re, ''), { secret: true })
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Before `name` is overwritten, append its current value to
|
|
237
|
+
* `<envFile>.backup` (same directory) as a timestamped comment plus the
|
|
238
|
+
* `NAME=value` line. Appends, so repeated rotations keep history. Writes with
|
|
239
|
+
* mode 0600 and adds the backup to `.gitignore`. Returns the backup's absolute
|
|
240
|
+
* path, or `undefined` when the env file holds no value for `name`. Never
|
|
241
|
+
* returns or logs the value.
|
|
242
|
+
*/
|
|
243
|
+
export function backupEnvVar(
|
|
244
|
+
projectDir: string,
|
|
245
|
+
envFile: string,
|
|
246
|
+
name: string,
|
|
247
|
+
reason: string,
|
|
248
|
+
): string | undefined {
|
|
249
|
+
const envPath = resolve(projectDir, envFile)
|
|
250
|
+
assertNoSymlinks(projectDir, relative(projectDir, envPath))
|
|
251
|
+
const value = readDotenvKey(envPath, name)
|
|
252
|
+
if(!value) {
|
|
253
|
+
return undefined
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const backupPath = `${envPath}.backup`
|
|
257
|
+
const stamp = new Date().toISOString()
|
|
258
|
+
writeInside(
|
|
259
|
+
projectDir,
|
|
260
|
+
backupPath,
|
|
261
|
+
`# ${stamp} ${reason}\n${name}=${value}\n`,
|
|
262
|
+
{ secret: true, append: true },
|
|
263
|
+
)
|
|
264
|
+
ensureGitignored(projectDir, relative(projectDir, backupPath))
|
|
265
|
+
return backupPath
|
|
266
|
+
}
|
|
267
|
+
|
|
215
268
|
/**
|
|
216
269
|
* Persist a raw eth private key to the project `.env` as
|
|
217
270
|
* `RECLAIM_PRIVATE_KEY=0x…` (upsert; mode 0600; `.env` added to `.gitignore`).
|
|
@@ -226,3 +279,58 @@ export function writeEthKey(
|
|
|
226
279
|
const path = upsertEnvVar(projectDir, ENV_FILE, ENV_VAR, hex)
|
|
227
280
|
return { path, address: deriveFromPrivateKey(hex).address }
|
|
228
281
|
}
|
|
282
|
+
|
|
283
|
+
/** Throws when `path` (relative to `root`), or any of its existing parents
|
|
284
|
+
* under `root`, is a symlink: writing through one could land outside the
|
|
285
|
+
* project. */
|
|
286
|
+
export function assertNoSymlinks(root: string, path: string) {
|
|
287
|
+
assert(
|
|
288
|
+
path && !path.startsWith('..') && !isAbsolute(path),
|
|
289
|
+
badRequest(`${path} is not inside ${root}`),
|
|
290
|
+
)
|
|
291
|
+
let current = root
|
|
292
|
+
for(const part of path.split(/[\\/]/)) {
|
|
293
|
+
current += sep + part
|
|
294
|
+
const stat = lstatSync(current, { throwIfNoEntry: false })
|
|
295
|
+
assert(
|
|
296
|
+
!stat?.isSymbolicLink(),
|
|
297
|
+
badRequest(
|
|
298
|
+
`${current} is a symlink; Reclaim tools only write to real files `
|
|
299
|
+
+ 'and directories inside the project',
|
|
300
|
+
),
|
|
301
|
+
)
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Write `data` to `path` inside `projectDir` without following a symlink: the
|
|
307
|
+
* file and its parents must be real, and the open uses `O_NOFOLLOW`, so a
|
|
308
|
+
* symlink that appears after the check still fails. `secret` writes with
|
|
309
|
+
* mode 0600; `append` adds to the file instead of replacing it.
|
|
310
|
+
*/
|
|
311
|
+
function writeInside(
|
|
312
|
+
projectDir: string,
|
|
313
|
+
path: string,
|
|
314
|
+
data: string,
|
|
315
|
+
{ secret = false, append = false } = {},
|
|
316
|
+
) {
|
|
317
|
+
const rel = relative(projectDir, path)
|
|
318
|
+
assertNoSymlinks(projectDir, rel)
|
|
319
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
320
|
+
assertNoSymlinks(projectDir, rel)
|
|
321
|
+
const fd = openSync(
|
|
322
|
+
path,
|
|
323
|
+
constants.O_WRONLY | constants.O_CREAT | constants.O_NOFOLLOW
|
|
324
|
+
| (append ? constants.O_APPEND : constants.O_TRUNC),
|
|
325
|
+
secret ? 0o600 : 0o666,
|
|
326
|
+
)
|
|
327
|
+
try {
|
|
328
|
+
if(secret) {
|
|
329
|
+
fchmodSync(fd, 0o600)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
writeFileSync(fd, data)
|
|
333
|
+
} finally {
|
|
334
|
+
closeSync(fd)
|
|
335
|
+
}
|
|
336
|
+
}
|
|
@@ -1,16 +1,24 @@
|
|
|
1
|
+
import { conflict } from '@hapi/boom'
|
|
1
2
|
import { deriveFromPrivateKey, generateKeypair } from '@reclaimprotocol/client'
|
|
2
|
-
import type
|
|
3
|
+
import { isProblemError, type ReclaimClient } from '@reclaimprotocol/client/api'
|
|
3
4
|
import assert from 'node:assert'
|
|
4
5
|
import { isAbsolute, relative, resolve } from 'node:path'
|
|
5
6
|
import { defineTool, type RegisteredTool } from '../../server.ts'
|
|
6
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
backupEnvVar,
|
|
9
|
+
normalizeEthHex,
|
|
10
|
+
readDotenvKey,
|
|
11
|
+
removeEnvVar,
|
|
12
|
+
upsertEnvVar,
|
|
13
|
+
} from './eth-key.ts'
|
|
7
14
|
|
|
8
15
|
/**
|
|
9
16
|
* Creates the organization's eth key pair for session binding and encrypted
|
|
10
17
|
* results: writes the private key to the app's server env file and registers
|
|
11
18
|
* only the public key with Builder, so the private key never passes through
|
|
12
19
|
* the agent's transcript. Reuses a key the env file already holds, so a
|
|
13
|
-
* repeat call (for example, to turn on encryption) keeps the same key.
|
|
20
|
+
* repeat call (for example, to turn on encryption) keeps the same key. With
|
|
21
|
+
* `replace`, always generates a fresh key and rotates the org's key to it.
|
|
14
22
|
*/
|
|
15
23
|
export function issueOrgKeypairTool(client: ReclaimClient): RegisteredTool {
|
|
16
24
|
return defineTool<{
|
|
@@ -19,6 +27,7 @@ export function issueOrgKeypairTool(client: ReclaimClient): RegisteredTool {
|
|
|
19
27
|
envFile?: string
|
|
20
28
|
envVar?: string
|
|
21
29
|
encryptResults?: boolean
|
|
30
|
+
replace?: boolean
|
|
22
31
|
}>(
|
|
23
32
|
{
|
|
24
33
|
name: 'issue_org_keypair',
|
|
@@ -30,8 +39,12 @@ export function issueOrgKeypairTool(client: ReclaimClient): RegisteredTool {
|
|
|
30
39
|
+ 'private key to a server env file in the project (reusing a key '
|
|
31
40
|
+ 'already there) and registers only the public key. Returns the '
|
|
32
41
|
+ 'address, file, and variable; never the key. Returns 409 when the '
|
|
33
|
-
+ 'org already has a different key
|
|
34
|
-
+ '
|
|
42
|
+
+ 'org already has a different key. Set `replace: true` only when '
|
|
43
|
+
+ 'the developer asked to rotate or replace the key: it generates '
|
|
44
|
+
+ 'a new key and swaps it in one step, and apps holding the old '
|
|
45
|
+
+ 'key can no longer decrypt new results. The old key is first '
|
|
46
|
+
+ 'appended to `<envFile>.backup` (gitignored); tell the developer '
|
|
47
|
+
+ 'its path.',
|
|
35
48
|
inputSchema: {
|
|
36
49
|
type: 'object',
|
|
37
50
|
required: ['orgId', 'projectDir'],
|
|
@@ -55,11 +68,18 @@ export function issueOrgKeypairTool(client: ReclaimClient): RegisteredTool {
|
|
|
55
68
|
encryptResults: {
|
|
56
69
|
type: 'boolean',
|
|
57
70
|
description:
|
|
58
|
-
'Encrypt delivered results to this key. Default false
|
|
71
|
+
'Encrypt delivered results to this key. Default false, or the '
|
|
72
|
+
+ 'org\'s current setting with `replace`.',
|
|
73
|
+
},
|
|
74
|
+
replace: {
|
|
75
|
+
type: 'boolean',
|
|
76
|
+
description:
|
|
77
|
+
'Rotate: generate a new key and replace the org\'s current '
|
|
78
|
+
+ 'one. Default false.',
|
|
59
79
|
},
|
|
60
80
|
},
|
|
61
81
|
},
|
|
62
|
-
annotations: { openWorldHint: true, destructiveHint:
|
|
82
|
+
annotations: { openWorldHint: true, destructiveHint: true },
|
|
63
83
|
},
|
|
64
84
|
async(args) => {
|
|
65
85
|
assert(
|
|
@@ -75,24 +95,60 @@ export function issueOrgKeypairTool(client: ReclaimClient): RegisteredTool {
|
|
|
75
95
|
new Error('`envFile` must be inside `projectDir`.'),
|
|
76
96
|
)
|
|
77
97
|
|
|
78
|
-
const
|
|
98
|
+
const replace = args.replace ?? false
|
|
99
|
+
// Rotation always generates a fresh key: the env file's key may or
|
|
100
|
+
// may not be the one the org holds, and reuse would be a no-op.
|
|
101
|
+
const existing = replace ? undefined : readDotenvKey(envPath, envVar)
|
|
79
102
|
const keypair = existing
|
|
80
103
|
? deriveFromPrivateKey(normalizeEthHex(existing))
|
|
81
104
|
: generateKeypair()
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
105
|
+
// A rotation keeps the org's current setting unless the caller sets it.
|
|
106
|
+
const canEncryptResult = args.encryptResults
|
|
107
|
+
?? (replace && await currentCanEncryptResult())
|
|
108
|
+
// Back up and save the key before the org switches to it, so a
|
|
109
|
+
// failed write can't leave the org on a key no app holds. A failed
|
|
110
|
+
// registration (for example, a 409) puts the env file back.
|
|
111
|
+
const old = readDotenvKey(envPath, envVar)
|
|
112
|
+
const backupFile = old && !existing
|
|
113
|
+
? backupEnvVar(
|
|
114
|
+
args.projectDir, envFile, envVar, 'replaced by issue_org_keypair',
|
|
115
|
+
)
|
|
116
|
+
: undefined
|
|
93
117
|
const path = upsertEnvVar(
|
|
94
118
|
args.projectDir, envFile, envVar, keypair.privateKey,
|
|
95
119
|
)
|
|
120
|
+
try {
|
|
121
|
+
await client.call('SetOrgKeypair', {
|
|
122
|
+
params: { orgId: args.orgId },
|
|
123
|
+
body: {
|
|
124
|
+
publicKey: keypair.publicKey,
|
|
125
|
+
canEncryptResult,
|
|
126
|
+
replace,
|
|
127
|
+
},
|
|
128
|
+
})
|
|
129
|
+
} catch(err) {
|
|
130
|
+
if(old) {
|
|
131
|
+
upsertEnvVar(args.projectDir, envFile, envVar, old)
|
|
132
|
+
} else {
|
|
133
|
+
removeEnvVar(args.projectDir, envFile, envVar)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if(!replace && isProblemError(err) && err.output.statusCode === 409) {
|
|
137
|
+
throw conflict(
|
|
138
|
+
'The org already has a different key. Ask the developer to '
|
|
139
|
+
+ 'choose: (a) reuse it: they paste its private key into '
|
|
140
|
+
+ `\`${envFile}\` as \`${envVar}\` themselves, never into the `
|
|
141
|
+
+ 'chat; (b) rotate it: call `issue_org_keypair` again with '
|
|
142
|
+
+ '`replace: true`, after which apps holding the old key can no '
|
|
143
|
+
+ 'longer decrypt new results; (c) turn off result encryption: '
|
|
144
|
+
+ 'call `update_org_keypair` with `canEncryptResult: false`, '
|
|
145
|
+
+ 'which keeps the key.',
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
throw err
|
|
150
|
+
}
|
|
151
|
+
|
|
96
152
|
return {
|
|
97
153
|
orgId: args.orgId,
|
|
98
154
|
address: keypair.address,
|
|
@@ -100,6 +156,28 @@ export function issueOrgKeypairTool(client: ReclaimClient): RegisteredTool {
|
|
|
100
156
|
envFile: path,
|
|
101
157
|
envVar,
|
|
102
158
|
reused: Boolean(existing),
|
|
159
|
+
backupFile: backupFile ?? null,
|
|
160
|
+
...(replace && !backupFile && !old
|
|
161
|
+
? {
|
|
162
|
+
note: 'Nothing was backed up: the old private key is not in '
|
|
163
|
+
+ 'this file. Another app may hold it.',
|
|
164
|
+
}
|
|
165
|
+
: {}),
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function currentCanEncryptResult() {
|
|
169
|
+
try {
|
|
170
|
+
const { data } = await client.call('GetOrgKeypair', {
|
|
171
|
+
params: { orgId: args.orgId },
|
|
172
|
+
})
|
|
173
|
+
return data.canEncryptResult
|
|
174
|
+
} catch(err) {
|
|
175
|
+
if(isProblemError(err) && err.output.statusCode === 404) {
|
|
176
|
+
return false
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
throw err
|
|
180
|
+
}
|
|
103
181
|
}
|
|
104
182
|
},
|
|
105
183
|
)
|
|
@@ -2,7 +2,7 @@ import type { ReclaimClient } from '@reclaimprotocol/client/api'
|
|
|
2
2
|
import assert from 'node:assert'
|
|
3
3
|
import { isAbsolute, relative, resolve } from 'node:path'
|
|
4
4
|
import { defineTool, type RegisteredTool } from '../../server.ts'
|
|
5
|
-
import { upsertEnvVar } from './eth-key.ts'
|
|
5
|
+
import { backupEnvVar, readDotenvKey, upsertEnvVar } from './eth-key.ts'
|
|
6
6
|
import { saveOrgKey } from './org-key-store.ts'
|
|
7
7
|
|
|
8
8
|
/**
|
|
@@ -24,7 +24,9 @@ export function issueOrgTokenTool(client: ReclaimClient): RegisteredTool {
|
|
|
24
24
|
+ 'server env file in the project, so the app can read it at runtime. '
|
|
25
25
|
+ 'Also saves it on this machine for the org-key tools. Returns the '
|
|
26
26
|
+ 'org, file, variable, and last four characters; never the key. '
|
|
27
|
-
+ 'Replaces an existing variable of the same name in the file
|
|
27
|
+
+ 'Replaces an existing variable of the same name in the file, first '
|
|
28
|
+
+ 'appending the old value to `<envFile>.backup` (gitignored); tell '
|
|
29
|
+
+ 'the developer its path. '
|
|
28
30
|
+ 'Adds the file to .gitignore. Use when the app needs a key, or '
|
|
29
31
|
+ 'before `create_verification_session`.',
|
|
30
32
|
inputSchema: {
|
|
@@ -69,6 +71,14 @@ export function issueOrgTokenTool(client: ReclaimClient): RegisteredTool {
|
|
|
69
71
|
const { data } = await client.call('IssueOrgToken', {
|
|
70
72
|
params: { orgId: args.orgId },
|
|
71
73
|
})
|
|
74
|
+
const old = readDotenvKey(
|
|
75
|
+
resolve(args.projectDir, envFile), envVar,
|
|
76
|
+
)
|
|
77
|
+
const backupFile = old && old !== data.orgSecret
|
|
78
|
+
? backupEnvVar(
|
|
79
|
+
args.projectDir, envFile, envVar, 'replaced by issue_org_token',
|
|
80
|
+
)
|
|
81
|
+
: undefined
|
|
72
82
|
const path = upsertEnvVar(
|
|
73
83
|
args.projectDir, envFile, envVar, data.orgSecret,
|
|
74
84
|
)
|
|
@@ -78,6 +88,7 @@ export function issueOrgTokenTool(client: ReclaimClient): RegisteredTool {
|
|
|
78
88
|
envFile: path,
|
|
79
89
|
envVar,
|
|
80
90
|
last4: data.last4,
|
|
91
|
+
backupFile: backupFile ?? null,
|
|
81
92
|
}
|
|
82
93
|
},
|
|
83
94
|
)
|
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
|
}
|