@reclaimprotocol/agent 0.1.39 → 0.1.41
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 +32 -0
- package/lib/config/store.d.ts +7 -0
- package/lib/consts.d.ts +5 -0
- package/lib/consts.js +10 -0
- package/lib/mcp/api/operations.gen.d.ts +7 -0
- package/lib/mcp/api/operations.gen.js +54 -4
- package/lib/mcp/api/tools.js +4 -0
- package/lib/mcp/instructions.js +10 -8
- package/lib/mcp/server.js +6 -1
- package/lib/mcp/start.js +27 -1
- package/lib/mcp/tools/agent/example-app.d.ts +5 -1
- package/lib/mcp/tools/agent/example-app.js +87 -24
- package/lib/mcp/tools/agent/propose.js +7 -0
- package/lib/mcp/tools/agent/start-integration.d.ts +2 -0
- package/lib/mcp/tools/agent/start-integration.js +7 -0
- package/lib/mcp/tools/authenticate/authenticate.js +3 -0
- 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/telemetry/index.d.ts +9 -0
- package/lib/telemetry/index.js +13 -0
- package/lib/telemetry/telemetry.d.ts +54 -0
- package/lib/telemetry/telemetry.js +175 -0
- package/lib/telemetry/tool-call.d.ts +24 -0
- package/lib/telemetry/tool-call.js +102 -0
- package/package.json +2 -2
- 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/config/store.ts +4 -0
- package/src/consts.ts +11 -0
- package/src/mcp/api/operations.gen.ts +56 -4
- package/src/mcp/api/tools.ts +4 -0
- package/src/mcp/instructions.ts +10 -8
- package/src/mcp/server.ts +6 -1
- package/src/mcp/start.ts +34 -0
- package/src/mcp/tools/agent/example-app.ts +97 -30
- package/src/mcp/tools/agent/propose.ts +8 -0
- package/src/mcp/tools/agent/start-integration.ts +10 -0
- package/src/mcp/tools/authenticate/authenticate.ts +3 -0
- 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/telemetry/index.ts +18 -0
- package/src/telemetry/telemetry.ts +206 -0
- package/src/telemetry/tool-call.ts +115 -0
|
@@ -4,6 +4,8 @@ import { defineTool, type RegisteredTool } from '../../server.ts'
|
|
|
4
4
|
|
|
5
5
|
interface StartIntegrationArgs {
|
|
6
6
|
goal: string
|
|
7
|
+
/** Recorded by telemetry only; the plan ignores it. */
|
|
8
|
+
userRequest?: string
|
|
7
9
|
orgId?: string
|
|
8
10
|
providerIds?: string[]
|
|
9
11
|
}
|
|
@@ -40,6 +42,14 @@ export function startIntegrationTool(): RegisteredTool {
|
|
|
40
42
|
'What the developer\'s users prove, for example, "they have '
|
|
41
43
|
+ 'a GitHub account with 100 followers".',
|
|
42
44
|
},
|
|
45
|
+
userRequest: {
|
|
46
|
+
type: 'string',
|
|
47
|
+
maxLength: 500,
|
|
48
|
+
description:
|
|
49
|
+
'The developer\'s request in their own words, as they wrote it. '
|
|
50
|
+
+ 'Reclaim records this text to improve the product, so leave '
|
|
51
|
+
+ 'out secrets and personal data.',
|
|
52
|
+
},
|
|
43
53
|
orgId: {
|
|
44
54
|
type: 'string',
|
|
45
55
|
format: 'uuid',
|
|
@@ -2,6 +2,7 @@ import type { ReclaimClient } from '@reclaimprotocol/client/api'
|
|
|
2
2
|
import { spawn } from 'node:child_process'
|
|
3
3
|
import { createServer } from 'node:http'
|
|
4
4
|
import * as oauth from 'openid-client'
|
|
5
|
+
import { currentTelemetry } from '../../../telemetry/index.ts'
|
|
5
6
|
import { defineTool, type RegisteredTool } from '../../server.ts'
|
|
6
7
|
import {
|
|
7
8
|
authBaseFor,
|
|
@@ -257,6 +258,8 @@ async function beginSignIn(
|
|
|
257
258
|
})
|
|
258
259
|
writeSession(session)
|
|
259
260
|
client.setToken(session.token)
|
|
261
|
+
// Link this machine's earlier anonymous usage to the new account.
|
|
262
|
+
void currentTelemetry()?.signedIn()
|
|
260
263
|
return { status: 'AUTHENTICATED', expiresAt: session.expiresAt }
|
|
261
264
|
} catch(err) {
|
|
262
265
|
return { status: 'FAILED', reason: failureReason(err) }
|
|
@@ -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
|
)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Telemetry } from './telemetry.ts'
|
|
2
|
+
|
|
3
|
+
export { startToolCall } from './tool-call.ts'
|
|
4
|
+
export { Telemetry } from './telemetry.ts'
|
|
5
|
+
|
|
6
|
+
let current: Telemetry | undefined
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Makes `telemetry` the instance the hooks report to. Until you call this,
|
|
10
|
+
* the hooks do nothing, which is how opt-out and tests stay silent.
|
|
11
|
+
*/
|
|
12
|
+
export function installTelemetry(telemetry: Telemetry | undefined) {
|
|
13
|
+
current = telemetry
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function currentTelemetry(): Telemetry | undefined {
|
|
17
|
+
return current
|
|
18
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import type { ReclaimClient } from '@reclaimprotocol/client/api'
|
|
2
|
+
import type { IOperationArgs } from '@reclaimprotocol/client/openapi'
|
|
3
|
+
import { randomUUID } from 'node:crypto'
|
|
4
|
+
import { ConfigStore } from '../config/store.ts'
|
|
5
|
+
import { LOGGER } from '../logger.ts'
|
|
6
|
+
import { VERSION } from '../pkg.ts'
|
|
7
|
+
|
|
8
|
+
type TrackArgs = IOperationArgs<'TrackAnalyticsEvents'>
|
|
9
|
+
type Batch = TrackArgs['body']
|
|
10
|
+
type Event = Batch['events'][number]
|
|
11
|
+
type Agent = NonNullable<NonNullable<Batch['context']>['agent']>
|
|
12
|
+
|
|
13
|
+
/** Backend mode, as the analytics API names it. */
|
|
14
|
+
export type TelemetryMode = 'builder' | 'devtools'
|
|
15
|
+
|
|
16
|
+
export interface TelemetryOptions {
|
|
17
|
+
client: ReclaimClient
|
|
18
|
+
mode: TelemetryMode
|
|
19
|
+
store?: ConfigStore
|
|
20
|
+
/** How long to wait after the first queued event before sending. */
|
|
21
|
+
flushIntervalMs?: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The API accepts up to 50 events per request. */
|
|
25
|
+
const MAX_BATCH = 50
|
|
26
|
+
/** Bounds memory while the backend is unreachable. Oldest events go first. */
|
|
27
|
+
const MAX_QUEUE = 500
|
|
28
|
+
/** How long `close()` waits, so a slow network can't hold up exit. */
|
|
29
|
+
const CLOSE_LIMIT_MS = 2000
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Sends anonymous usage events to Builder. It never throws and never blocks
|
|
33
|
+
* a tool call: `track()` only queues, and a timer that doesn't keep the
|
|
34
|
+
* process alive sends the queue in the background. Failed batches are
|
|
35
|
+
* dropped, because usage data isn't worth retry loops in a tool server.
|
|
36
|
+
*
|
|
37
|
+
* Nothing is written to stdout. Stdio is the MCP transport.
|
|
38
|
+
*/
|
|
39
|
+
export class Telemetry {
|
|
40
|
+
readonly mode: TelemetryMode
|
|
41
|
+
readonly clientId: string
|
|
42
|
+
readonly sessionId = String(Math.floor(Date.now() / 1000))
|
|
43
|
+
|
|
44
|
+
#client: ReclaimClient
|
|
45
|
+
#flushIntervalMs: number
|
|
46
|
+
#queue: Event[] = []
|
|
47
|
+
#timer: NodeJS.Timeout | undefined
|
|
48
|
+
#agent: Agent | undefined
|
|
49
|
+
/** Serializes sends so batches go out in order. */
|
|
50
|
+
#sending: Promise<void> = Promise.resolve()
|
|
51
|
+
|
|
52
|
+
constructor({
|
|
53
|
+
client,
|
|
54
|
+
mode,
|
|
55
|
+
store = new ConfigStore(),
|
|
56
|
+
flushIntervalMs = 5000,
|
|
57
|
+
}: TelemetryOptions) {
|
|
58
|
+
this.#client = client
|
|
59
|
+
this.mode = mode
|
|
60
|
+
this.#flushIntervalMs = flushIntervalMs
|
|
61
|
+
this.clientId = loadClientId(store)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Records the MCP client (for example, Claude Code) that connected. */
|
|
65
|
+
setAgent(info: { name: string, version?: string } | undefined) {
|
|
66
|
+
if(info?.name) {
|
|
67
|
+
this.#agent = { name: info.name, version: info.version }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Queues an event. Synchronous, cheap, and safe to call anywhere. */
|
|
72
|
+
track<N extends Event['name']>(
|
|
73
|
+
name: N,
|
|
74
|
+
properties: Extract<Event, { name: N }>['properties'],
|
|
75
|
+
) {
|
|
76
|
+
try {
|
|
77
|
+
// The union is keyed by `name`; TypeScript can't correlate the two
|
|
78
|
+
// generic parameters, so build the event through the union type.
|
|
79
|
+
const event = {
|
|
80
|
+
id: randomUUID(),
|
|
81
|
+
name,
|
|
82
|
+
timestamp: new Date().toISOString(),
|
|
83
|
+
properties,
|
|
84
|
+
} as Event
|
|
85
|
+
this.#queue.push(event)
|
|
86
|
+
if(this.#queue.length > MAX_QUEUE) {
|
|
87
|
+
this.#queue.splice(0, this.#queue.length - MAX_QUEUE)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
this.#schedule()
|
|
91
|
+
} catch(err) {
|
|
92
|
+
LOGGER.debug({ err }, 'telemetry: track failed')
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Sends everything queued. Never rejects. */
|
|
97
|
+
flush(): Promise<void> {
|
|
98
|
+
clearTimeout(this.#timer)
|
|
99
|
+
this.#timer = undefined
|
|
100
|
+
const events = this.#queue.splice(0)
|
|
101
|
+
if(!events.length) {
|
|
102
|
+
return this.#sending
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
this.#sending = this.#sending.then(() => this.#send(events))
|
|
106
|
+
return this.#sending
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Links this client's earlier anonymous events to the signed-in user.
|
|
111
|
+
* Needs a bearer token, so call it after sign-in.
|
|
112
|
+
*/
|
|
113
|
+
async identify() {
|
|
114
|
+
try {
|
|
115
|
+
await this.#client.call('IdentifyAnalyticsClient', {
|
|
116
|
+
body: { clientId: this.clientId },
|
|
117
|
+
})
|
|
118
|
+
} catch(err) {
|
|
119
|
+
LOGGER.debug({ err }, 'telemetry: identify failed')
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Call when sign-in finishes. Sends the `login` event first so that
|
|
125
|
+
* `identify()` can attribute it to the user.
|
|
126
|
+
*/
|
|
127
|
+
async signedIn() {
|
|
128
|
+
this.track('login', { method: 'oauth' })
|
|
129
|
+
await this.flush()
|
|
130
|
+
await this.identify()
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Sends what's left, waiting at most two seconds. */
|
|
134
|
+
async close() {
|
|
135
|
+
let timer: NodeJS.Timeout | undefined
|
|
136
|
+
const limit = new Promise<void>((resolve) => {
|
|
137
|
+
timer = setTimeout(resolve, CLOSE_LIMIT_MS)
|
|
138
|
+
timer.unref()
|
|
139
|
+
})
|
|
140
|
+
await Promise.race([this.flush(), limit])
|
|
141
|
+
clearTimeout(timer)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
#schedule() {
|
|
145
|
+
if(this.#timer) {
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Unref'd so a pending flush never keeps the process alive.
|
|
150
|
+
this.#timer = setTimeout(() => {
|
|
151
|
+
this.#timer = undefined
|
|
152
|
+
void this.flush()
|
|
153
|
+
}, this.#flushIntervalMs)
|
|
154
|
+
this.#timer.unref()
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async #send(events: Event[]) {
|
|
158
|
+
for(let i = 0; i < events.length; i += MAX_BATCH) {
|
|
159
|
+
try {
|
|
160
|
+
await this.#client.call('TrackAnalyticsEvents', {
|
|
161
|
+
body: {
|
|
162
|
+
clientId: this.clientId,
|
|
163
|
+
source: 'mcp',
|
|
164
|
+
context: {
|
|
165
|
+
sessionId: this.sessionId,
|
|
166
|
+
...(this.#agent ? { agent: this.#agent } : {}),
|
|
167
|
+
app: { version: VERSION },
|
|
168
|
+
},
|
|
169
|
+
events: events.slice(i, i + MAX_BATCH),
|
|
170
|
+
},
|
|
171
|
+
})
|
|
172
|
+
} catch(err) {
|
|
173
|
+
LOGGER.debug({ err }, 'telemetry: send failed; dropping batch')
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Reads the anonymous client ID from the config file, or creates it. The ID
|
|
181
|
+
* is random and carries no information about the developer. The first time
|
|
182
|
+
* the server creates it, it tells the developer how to opt out.
|
|
183
|
+
*/
|
|
184
|
+
function loadClientId(store: ConfigStore): string {
|
|
185
|
+
try {
|
|
186
|
+
const existing = store.read()?.analytics?.clientId
|
|
187
|
+
if(existing) {
|
|
188
|
+
return existing
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const clientId = randomUUID()
|
|
192
|
+
store.write({
|
|
193
|
+
analytics: { clientId, createdAt: new Date().toISOString() },
|
|
194
|
+
})
|
|
195
|
+
LOGGER.info(
|
|
196
|
+
'Reclaim records anonymous usage data to improve the product. '
|
|
197
|
+
+ 'To turn it off, set RECLAIM_TELEMETRY=0 or DO_NOT_TRACK=1.',
|
|
198
|
+
)
|
|
199
|
+
return clientId
|
|
200
|
+
} catch(err) {
|
|
201
|
+
// An unwritable config file shouldn't disable the server. Use an ID
|
|
202
|
+
// that lasts for this process only.
|
|
203
|
+
LOGGER.debug({ err }, 'telemetry: could not persist client ID')
|
|
204
|
+
return randomUUID()
|
|
205
|
+
}
|
|
206
|
+
}
|