@reclaimprotocol/agent 0.1.39 → 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 +87 -24
- 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/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/mcp/api/operations.gen.ts +51 -3
- package/src/mcp/instructions.ts +10 -8
- package/src/mcp/tools/agent/example-app.ts +97 -30
- 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
|
@@ -444,6 +444,13 @@ export declare const MCP_TOOLS: {
|
|
|
444
444
|
openWorldHint: boolean;
|
|
445
445
|
};
|
|
446
446
|
};
|
|
447
|
+
UpdateOrgKeypair: {
|
|
448
|
+
description: string;
|
|
449
|
+
inputSchema: import("zod").ZodType<unknown, unknown, import("zod/v4/core").$ZodTypeInternals<unknown, unknown>>;
|
|
450
|
+
annotations: {
|
|
451
|
+
openWorldHint: boolean;
|
|
452
|
+
};
|
|
453
|
+
};
|
|
447
454
|
UpdateProvider: {
|
|
448
455
|
description: string;
|
|
449
456
|
inputSchema: import("zod").ZodType<unknown, unknown, import("zod/v4/core").$ZodTypeInternals<unknown, unknown>>;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// AUTO-GENERATED by packages/app/scripts/mcp-tools-gen.ts
|
|
2
|
-
// from packages/app/openapi.yaml (spec v0.1.0, source sha256:
|
|
2
|
+
// from packages/app/openapi.yaml (spec v0.1.0, source sha256:ecdb0ee6d82e).
|
|
3
3
|
// The hash catches drift the version misses (a spec can change without a
|
|
4
4
|
// version bump) — do not remove it as "redundant" with the version.
|
|
5
5
|
// Run `npm run openapi:gen --workspace=packages/app` to regenerate. Do not edit by hand.
|
|
@@ -5058,7 +5058,7 @@ export const MCP_TOOLS = {
|
|
|
5058
5058
|
annotations: { "destructiveHint": true, "idempotentHint": true, "openWorldHint": false },
|
|
5059
5059
|
},
|
|
5060
5060
|
"DeleteOrgKeypair": {
|
|
5061
|
-
description: "Clear the organization's encryption keypair\n\nRemoves the organization's result encryption public key. Results are then delivered unencrypted until you set a new key. Run it only when the developer asks.",
|
|
5061
|
+
description: "Clear the organization's encryption keypair\n\nRemoves the organization's result encryption public key. Results are then delivered unencrypted until you set a new key. Run it only when the developer asks. To rotate the key, use `issue_org_keypair` with `replace: true` instead of deleting first.",
|
|
5062
5062
|
inputSchema: toZod({
|
|
5063
5063
|
"type": "object",
|
|
5064
5064
|
"properties": {
|
|
@@ -6578,7 +6578,7 @@ export const MCP_TOOLS = {
|
|
|
6578
6578
|
annotations: { "destructiveHint": true, "idempotentHint": true, "openWorldHint": false },
|
|
6579
6579
|
},
|
|
6580
6580
|
"SetOrgKeypair": {
|
|
6581
|
-
description: "Set (or rotate) the organization's encryption keypair\n\nRegisters the organization's result encryption public key and turns on encrypted result delivery. Send only the public key; the private key stays with the developer. If the organization already has a different key, the call returns 409 unless you pass replace: true. Pass it only when the developer asked to rotate the key, because results encrypted to the old key can no longer be decrypted. Setting the same key again succeeds.",
|
|
6581
|
+
description: "Set (or rotate) the organization's encryption keypair\n\nRegisters the organization's result encryption public key and turns on encrypted result delivery. Send only the public key; the private key stays with the developer. If the organization already has a different key, the call returns 409 unless you pass replace: true. Pass it only when the developer asked to rotate the key, because results encrypted to the old key can no longer be decrypted. Setting the same key again succeeds. To generate and store the private key in the project, use `issue_org_keypair` instead.",
|
|
6582
6582
|
inputSchema: toZod({
|
|
6583
6583
|
"type": "object",
|
|
6584
6584
|
"properties": {
|
|
@@ -6855,6 +6855,52 @@ export const MCP_TOOLS = {
|
|
|
6855
6855
|
}),
|
|
6856
6856
|
annotations: { "openWorldHint": false },
|
|
6857
6857
|
},
|
|
6858
|
+
"UpdateOrgKeypair": {
|
|
6859
|
+
description: "Turn result encryption on or off\n\nTurns result encryption on or off for the organization's existing key pair without changing the key. Use it when the developer asks to turn encryption on or off. When on, the app's callback needs `orgEthPrivateKey` to decrypt results. Returns 404 when the organization has no key pair; create one with `issue_org_keypair`.",
|
|
6860
|
+
inputSchema: toZod({
|
|
6861
|
+
"type": "object",
|
|
6862
|
+
"properties": {
|
|
6863
|
+
"params": {
|
|
6864
|
+
"type": "object",
|
|
6865
|
+
"properties": {
|
|
6866
|
+
"orgId": {
|
|
6867
|
+
"type": "string",
|
|
6868
|
+
"format": "uuid"
|
|
6869
|
+
}
|
|
6870
|
+
},
|
|
6871
|
+
"required": [
|
|
6872
|
+
"orgId"
|
|
6873
|
+
],
|
|
6874
|
+
"additionalProperties": false
|
|
6875
|
+
},
|
|
6876
|
+
"query": {
|
|
6877
|
+
"type": "object",
|
|
6878
|
+
"properties": {}
|
|
6879
|
+
},
|
|
6880
|
+
"body": {
|
|
6881
|
+
"type": "object",
|
|
6882
|
+
"additionalProperties": false,
|
|
6883
|
+
"description": "Body for `PATCH /orgs/{orgId}/keypair`.",
|
|
6884
|
+
"required": [
|
|
6885
|
+
"canEncryptResult"
|
|
6886
|
+
],
|
|
6887
|
+
"properties": {
|
|
6888
|
+
"canEncryptResult": {
|
|
6889
|
+
"type": "boolean",
|
|
6890
|
+
"description": "Set to true to encrypt results to the organization's public key. Set to false to deliver results as plaintext."
|
|
6891
|
+
}
|
|
6892
|
+
}
|
|
6893
|
+
}
|
|
6894
|
+
},
|
|
6895
|
+
"required": [
|
|
6896
|
+
"params",
|
|
6897
|
+
"query",
|
|
6898
|
+
"body"
|
|
6899
|
+
],
|
|
6900
|
+
"additionalProperties": false
|
|
6901
|
+
}),
|
|
6902
|
+
annotations: { "openWorldHint": false },
|
|
6903
|
+
},
|
|
6858
6904
|
"UpdateProvider": {
|
|
6859
6905
|
description: "Update a provider\n\nUpdates a provider's metadata, such as title, description, or tags. Partial update — pass only the fields to change. Requires active membership. To change the recipe (initialUrl or requests), cut a new version with create_provider_version instead.",
|
|
6860
6906
|
inputSchema: toZod({
|
package/lib/mcp/instructions.js
CHANGED
|
@@ -48,7 +48,8 @@ const TOPIC_SUMMARIES = {
|
|
|
48
48
|
'overview': 'Terminology, your stance, and the authoring checklist.',
|
|
49
49
|
'contributions': 'Contributing a public provider as a volunteer: the '
|
|
50
50
|
+ 'candidate-and-review workflow and the extra rules it adds.',
|
|
51
|
-
'auth': 'Signing in to the backend,
|
|
51
|
+
'auth': 'Signing in to the backend, which token authorizes what, and '
|
|
52
|
+
+ 'the backend mode (Builder or legacy devtools).',
|
|
52
53
|
'devtools-migration': 'Importing a public legacy provider with the same '
|
|
53
54
|
+ 'UUID.',
|
|
54
55
|
'browser': 'The default local browser, the opt-in modes (attach, the '
|
|
@@ -60,15 +61,16 @@ const TOPIC_SUMMARIES = {
|
|
|
60
61
|
+ 'drafted shape.',
|
|
61
62
|
'params': 'Extracted vs consumer-supplied vs secret params, the URL '
|
|
62
63
|
+ 'character budget, and runtime resolution.',
|
|
63
|
-
'prove': 'replay_request, the mandatory auth-bound check, run_proof
|
|
64
|
-
+ 'diagnosing failures.',
|
|
64
|
+
'prove': 'replay_request, the mandatory auth-bound check, run_proof '
|
|
65
|
+
+ '(including the egress country), and diagnosing failures.',
|
|
65
66
|
'publish': 'Creating a provider, provider versions, multi-request '
|
|
66
67
|
+ 'providers, and going live.',
|
|
67
|
-
'discover': 'Reusing existing providers: finding, resolving,
|
|
68
|
-
+ 'creating a verification session.',
|
|
69
|
-
'integration': 'After start_integration: org key, callback
|
|
70
|
-
+ 'proxy_callback_events or deployed webhook), frontend and
|
|
71
|
-
+ 'over the HTTP session API
|
|
68
|
+
'discover': 'Reusing existing providers: finding, resolving, copying one '
|
|
69
|
+
+ 'that needs changes, and creating a verification session.',
|
|
70
|
+
'integration': 'After start_integration: org key and keypair, callback '
|
|
71
|
+
+ '(local proxy_callback_events or deployed webhook), frontend and '
|
|
72
|
+
+ 'backend over the HTTP session API, js-sdk projects, trying a '
|
|
73
|
+
+ 'session without an app; `api=2` and result verification.',
|
|
72
74
|
'user-script': 'The navigation script (jsUserScripts): sign-in redirect, '
|
|
73
75
|
+ 'multi-page walk, test_user_script with keep.',
|
|
74
76
|
'interception': 'injectionType / interceptorType, live interception, the '
|
|
@@ -1,2 +1,6 @@
|
|
|
1
1
|
import { type RegisteredTool } from '../../server.ts';
|
|
2
|
-
export declare function exampleAppTool(fetchFn?: typeof fetch): RegisteredTool;
|
|
2
|
+
export declare function exampleAppTool(fetchFn?: typeof fetch, isPortFree?: (port: number) => Promise<boolean>): RegisteredTool;
|
|
3
|
+
/** True when nothing accepts connections on `port` at 127.0.0.1 or ::1.
|
|
4
|
+
* Connecting, rather than binding, also catches servers bound to `0.0.0.0`:
|
|
5
|
+
* macOS lets a loopback bind succeed alongside them. Exported for tests. */
|
|
6
|
+
export declare function isLoopbackPortFree(port: number): Promise<boolean>;
|
|
@@ -10,10 +10,12 @@
|
|
|
10
10
|
import { badGateway, badRequest, conflict } from '@hapi/boom';
|
|
11
11
|
import assert from 'node:assert';
|
|
12
12
|
import { createHash } from 'node:crypto';
|
|
13
|
-
import { existsSync,
|
|
13
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
14
14
|
import { mkdir, realpath, rm, writeFile } from 'node:fs/promises';
|
|
15
|
+
import { connect } from 'node:net';
|
|
15
16
|
import { isAbsolute, join, relative, sep } from 'node:path';
|
|
16
17
|
import { defineTool } from "../../server.js";
|
|
18
|
+
import { assertNoSymlinks, upsertEnvVar } from "../authenticate/eth-key.js";
|
|
17
19
|
const REPO = 'reclaimprotocol/builder-example';
|
|
18
20
|
/** GitHub tree paths are always `/`-separated, on every OS. */
|
|
19
21
|
const BACKEND_PACKAGE = 'packages/backend/package.json';
|
|
@@ -21,19 +23,30 @@ const CLIENT_PACKAGE = '@reclaimprotocol/client';
|
|
|
21
23
|
/** The example is ~50 small files; anything far past that isn't it. */
|
|
22
24
|
const MAX_FILES = 500;
|
|
23
25
|
const MAX_BYTES = 10000000;
|
|
24
|
-
|
|
26
|
+
const BACKEND_ENV_FILE = 'packages/backend/.env';
|
|
27
|
+
const DEFAULT_BACKEND_PORT = 3000;
|
|
28
|
+
/** Ports probed for the backend: 3000, then 3001 through 3020. */
|
|
29
|
+
const BACKEND_PORT_TRIES = 21;
|
|
30
|
+
const LOOPBACK_HOSTS = ['127.0.0.1', '::1'];
|
|
31
|
+
const PROBE_TIMEOUT_MS = 500;
|
|
32
|
+
/** Files a workspace may already have. An existing copy is kept, not a
|
|
33
|
+
* conflict. The env-file tools add their own entries to `.gitignore`. */
|
|
34
|
+
const KEEP_EXISTING = new Set(['AGENTS.md', '.gitignore']);
|
|
35
|
+
export function exampleAppTool(fetchFn = fetch, isPortFree = isLoopbackPortFree) {
|
|
25
36
|
return defineTool({
|
|
26
37
|
name: 'create_example_app',
|
|
27
38
|
description: 'Writes the Reclaim example app (reclaimprotocol/builder-example: '
|
|
28
39
|
+ 'a Fastify backend and a Vite frontend that create sessions, '
|
|
29
40
|
+ 'verify callbacks, and render every verification state) into '
|
|
30
41
|
+ '`projectDir`, and sets its backend to the latest '
|
|
31
|
-
+ '`@reclaimprotocol/client`.
|
|
42
|
+
+ '`@reclaimprotocol/client`. It picks a free backend port (3000, else '
|
|
43
|
+
+ 'the next free one, written to `packages/backend/.env`). Use it '
|
|
44
|
+
+ 'when the workspace has no app '
|
|
32
45
|
+ 'yet, with the workspace root you were started in as '
|
|
33
46
|
+ '`projectDir`; don\'t clone the repository yourself. Refuses '
|
|
34
47
|
+ 'when a file it would write already exists with different '
|
|
35
|
-
+ 'content. Returns the commit, file count, client version,
|
|
36
|
-
+ 'next steps.',
|
|
48
|
+
+ 'content. Returns the commit, file count, client version, '
|
|
49
|
+
+ '`backendPort`, and next steps.',
|
|
37
50
|
inputSchema: {
|
|
38
51
|
type: 'object',
|
|
39
52
|
required: ['projectDir'],
|
|
@@ -60,10 +73,16 @@ export function exampleAppTool(fetchFn = fetch) {
|
|
|
60
73
|
: bytes,
|
|
61
74
|
};
|
|
62
75
|
}));
|
|
63
|
-
for (const
|
|
76
|
+
for (const path of [...staged.map((f) => f.path), BACKEND_ENV_FILE]) {
|
|
64
77
|
assertNoSymlinks(projectDir, path);
|
|
65
78
|
}
|
|
66
|
-
const
|
|
79
|
+
const kept = staged
|
|
80
|
+
.filter(({ path }) => {
|
|
81
|
+
return KEEP_EXISTING.has(path) && existsSync(join(projectDir, path));
|
|
82
|
+
})
|
|
83
|
+
.map(({ path }) => path);
|
|
84
|
+
const toWrite = staged.filter(({ path }) => !kept.includes(path));
|
|
85
|
+
const conflicts = toWrite
|
|
67
86
|
.filter(({ path, bytes }) => {
|
|
68
87
|
const dest = join(projectDir, path);
|
|
69
88
|
return existsSync(dest) && !readFileSync(dest).equals(bytes);
|
|
@@ -72,28 +91,63 @@ export function exampleAppTool(fetchFn = fetch) {
|
|
|
72
91
|
assert(!conflicts.length, conflict('These files already exist with different content: '
|
|
73
92
|
+ `${conflicts.join(', ')}. Ask the developer whether to use `
|
|
74
93
|
+ 'another directory or move them.'));
|
|
75
|
-
for (const { path, bytes } of
|
|
94
|
+
for (const { path, bytes } of toWrite) {
|
|
76
95
|
await installFile(projectDir, path, bytes);
|
|
77
96
|
}
|
|
97
|
+
const backendPort = await findBackendPort();
|
|
98
|
+
// Always written, so a re-run never leaves a stale port behind.
|
|
99
|
+
upsertEnvVar(projectDir, BACKEND_ENV_FILE, 'PORT', String(backendPort));
|
|
100
|
+
const dev = backendPort === DEFAULT_BACKEND_PORT
|
|
101
|
+
? 'npm run dev'
|
|
102
|
+
: `API_URL=http://localhost:${backendPort} npm run dev`;
|
|
78
103
|
return {
|
|
79
104
|
projectDir,
|
|
80
105
|
repository: `https://github.com/${REPO}`,
|
|
81
106
|
commit,
|
|
82
|
-
files:
|
|
107
|
+
files: toWrite.length,
|
|
108
|
+
kept,
|
|
83
109
|
clientVersion,
|
|
110
|
+
backendPort,
|
|
84
111
|
next: [
|
|
85
112
|
'Call issue_org_token with this projectDir and envFile '
|
|
86
113
|
+ '"packages/backend/.env".',
|
|
87
|
-
'Run `npm install
|
|
88
|
-
+
|
|
89
|
-
+
|
|
90
|
-
|
|
114
|
+
'Run `npm install` from projectDir, then start the dev '
|
|
115
|
+
+ `server with \`${dev}\`. The backend listens on port `
|
|
116
|
+
+ `${backendPort}`
|
|
117
|
+
+ (backendPort === DEFAULT_BACKEND_PORT
|
|
118
|
+
? '.'
|
|
119
|
+
: ` (port ${DEFAULT_BACKEND_PORT} is in use; the frontend `
|
|
120
|
+
+ 'proxy needs `API_URL`).'),
|
|
121
|
+
'Run the dev server in the background and send its output to a '
|
|
122
|
+
+ 'log file in projectDir, for example `npm run dev > '
|
|
123
|
+
+ 'reclaim-dev.log 2>&1`, so callback errors stay readable even '
|
|
124
|
+
+ 'when a shell output filter buffers output.',
|
|
125
|
+
'Give the developer the frontend URL Vite prints in that log. '
|
|
126
|
+
+ 'Don\'t assume port 5173: Vite moves to the next free port '
|
|
127
|
+
+ 'when it is taken.',
|
|
128
|
+
`Call proxy_callback_events({ port: ${backendPort}, path: `
|
|
91
129
|
+ '"/reclaim/callback" }).',
|
|
130
|
+
'If get_org_keypair shows `canEncryptResult: true`, the '
|
|
131
|
+
+ 'callback needs `orgEthPrivateKey`; see the plan.',
|
|
92
132
|
'Put the provider in packages/backend/config.json and restart '
|
|
93
133
|
+ 'the backend.',
|
|
134
|
+
'The backend stores verifications in memory: restarting it '
|
|
135
|
+
+ 'drops sessions in progress, so start a new verification '
|
|
136
|
+
+ 'after any restart.',
|
|
94
137
|
],
|
|
95
138
|
};
|
|
96
139
|
});
|
|
140
|
+
async function findBackendPort() {
|
|
141
|
+
for (let i = 0; i < BACKEND_PORT_TRIES; i++) {
|
|
142
|
+
const port = DEFAULT_BACKEND_PORT + i;
|
|
143
|
+
if (await isPortFree(port)) {
|
|
144
|
+
return port;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
throw conflict(`No free port from ${DEFAULT_BACKEND_PORT} to `
|
|
148
|
+
+ `${DEFAULT_BACKEND_PORT + BACKEND_PORT_TRIES - 1}. Ask the developer `
|
|
149
|
+
+ 'to stop another dev server.');
|
|
150
|
+
}
|
|
97
151
|
async function latestCommit() {
|
|
98
152
|
const res = await fetchFn(`https://api.github.com/repos/${REPO}/commits/main`, { headers: { Accept: 'application/vnd.github.sha' } });
|
|
99
153
|
assert(res.ok, badGateway(`${REPO} commit lookup: HTTP ${res.status}`));
|
|
@@ -184,17 +238,6 @@ async function installFile(root, path, bytes) {
|
|
|
184
238
|
function isEexist(err) {
|
|
185
239
|
return err instanceof Error && 'code' in err && err.code === 'EEXIST';
|
|
186
240
|
}
|
|
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
241
|
/** The backend's package.json with the client range set to `^version`. */
|
|
199
242
|
function withClientVersion(bytes, version) {
|
|
200
243
|
const text = bytes.toString('utf8');
|
|
@@ -209,3 +252,23 @@ function gitBlobSha(bytes) {
|
|
|
209
252
|
.update(bytes)
|
|
210
253
|
.digest('hex');
|
|
211
254
|
}
|
|
255
|
+
/** True when nothing accepts connections on `port` at 127.0.0.1 or ::1.
|
|
256
|
+
* Connecting, rather than binding, also catches servers bound to `0.0.0.0`:
|
|
257
|
+
* macOS lets a loopback bind succeed alongside them. Exported for tests. */
|
|
258
|
+
export async function isLoopbackPortFree(port) {
|
|
259
|
+
const results = await Promise.all(LOOPBACK_HOSTS.map((host) => {
|
|
260
|
+
return new Promise((resolve) => {
|
|
261
|
+
const socket = connect({ port, host, timeout: PROBE_TIMEOUT_MS });
|
|
262
|
+
socket.once('connect', () => {
|
|
263
|
+
socket.destroy();
|
|
264
|
+
resolve(false);
|
|
265
|
+
});
|
|
266
|
+
socket.once('timeout', () => {
|
|
267
|
+
socket.destroy();
|
|
268
|
+
resolve(true);
|
|
269
|
+
});
|
|
270
|
+
socket.once('error', () => resolve(true));
|
|
271
|
+
});
|
|
272
|
+
}));
|
|
273
|
+
return results.every(Boolean);
|
|
274
|
+
}
|
|
@@ -38,6 +38,17 @@ export declare function ensureGitignored(projectDir: string, entry: string): voi
|
|
|
38
38
|
* `envFile` is relative to `projectDir`.
|
|
39
39
|
*/
|
|
40
40
|
export declare function upsertEnvVar(projectDir: string, envFile: string, name: string, value: string): string;
|
|
41
|
+
/** Remove the `name=` line from a dotenv file, if there is one. */
|
|
42
|
+
export declare function removeEnvVar(projectDir: string, envFile: string, name: string): void;
|
|
43
|
+
/**
|
|
44
|
+
* Before `name` is overwritten, append its current value to
|
|
45
|
+
* `<envFile>.backup` (same directory) as a timestamped comment plus the
|
|
46
|
+
* `NAME=value` line. Appends, so repeated rotations keep history. Writes with
|
|
47
|
+
* mode 0600 and adds the backup to `.gitignore`. Returns the backup's absolute
|
|
48
|
+
* path, or `undefined` when the env file holds no value for `name`. Never
|
|
49
|
+
* returns or logs the value.
|
|
50
|
+
*/
|
|
51
|
+
export declare function backupEnvVar(projectDir: string, envFile: string, name: string, reason: string): string | undefined;
|
|
41
52
|
/**
|
|
42
53
|
* Persist a raw eth private key to the project `.env` as
|
|
43
54
|
* `RECLAIM_PRIVATE_KEY=0x…` (upsert; mode 0600; `.env` added to `.gitignore`).
|
|
@@ -48,3 +59,7 @@ export declare function writeEthKey(privateKeyHex: string, projectDir?: string):
|
|
|
48
59
|
path: string;
|
|
49
60
|
address: string;
|
|
50
61
|
};
|
|
62
|
+
/** Throws when `path` (relative to `root`), or any of its existing parents
|
|
63
|
+
* under `root`, is a symlink: writing through one could land outside the
|
|
64
|
+
* project. */
|
|
65
|
+
export declare function assertNoSymlinks(root: string, path: string): void;
|
|
@@ -15,10 +15,11 @@
|
|
|
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
|
-
import {
|
|
21
|
-
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
21
|
+
import { closeSync, constants, existsSync, fchmodSync, lstatSync, mkdirSync, openSync, readFileSync, writeFileSync, } from 'node:fs';
|
|
22
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
22
23
|
import { privateKey, privateKeyFile } from "../../../consts.js";
|
|
23
24
|
import { findProjectRoot } from "../../../paths.js";
|
|
24
25
|
const ENV_VAR = 'RECLAIM_PRIVATE_KEY';
|
|
@@ -111,7 +112,7 @@ export function ensureGitignored(projectDir, entry) {
|
|
|
111
112
|
return;
|
|
112
113
|
}
|
|
113
114
|
const prefix = current && !current.endsWith('\n') ? '\n' : '';
|
|
114
|
-
|
|
115
|
+
writeInside(projectDir, path, current + prefix + entry + '\n');
|
|
115
116
|
}
|
|
116
117
|
/**
|
|
117
118
|
* Set `name=value` in a dotenv file, replacing an existing line or appending
|
|
@@ -132,12 +133,41 @@ export function upsertEnvVar(projectDir, envFile, name, value) {
|
|
|
132
133
|
const prefix = existing && !existing.endsWith('\n') ? '\n' : '';
|
|
133
134
|
next = existing + prefix + line + '\n';
|
|
134
135
|
}
|
|
135
|
-
|
|
136
|
-
writeFileSync(envPath, next, { mode: 0o600 });
|
|
137
|
-
chmodSync(envPath, 0o600);
|
|
136
|
+
writeInside(projectDir, envPath, next, { secret: true });
|
|
138
137
|
ensureGitignored(projectDir, relative(projectDir, envPath));
|
|
139
138
|
return envPath;
|
|
140
139
|
}
|
|
140
|
+
/** Remove the `name=` line from a dotenv file, if there is one. */
|
|
141
|
+
export function removeEnvVar(projectDir, envFile, name) {
|
|
142
|
+
const envPath = resolve(projectDir, envFile);
|
|
143
|
+
if (!existsSync(envPath)) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const existing = readFileSync(envPath, 'utf8');
|
|
147
|
+
const re = new RegExp(`^\\s*${name}\\s*=.*(\\n|$)`, 'm');
|
|
148
|
+
writeInside(projectDir, envPath, existing.replace(re, ''), { secret: true });
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Before `name` is overwritten, append its current value to
|
|
152
|
+
* `<envFile>.backup` (same directory) as a timestamped comment plus the
|
|
153
|
+
* `NAME=value` line. Appends, so repeated rotations keep history. Writes with
|
|
154
|
+
* mode 0600 and adds the backup to `.gitignore`. Returns the backup's absolute
|
|
155
|
+
* path, or `undefined` when the env file holds no value for `name`. Never
|
|
156
|
+
* returns or logs the value.
|
|
157
|
+
*/
|
|
158
|
+
export function backupEnvVar(projectDir, envFile, name, reason) {
|
|
159
|
+
const envPath = resolve(projectDir, envFile);
|
|
160
|
+
assertNoSymlinks(projectDir, relative(projectDir, envPath));
|
|
161
|
+
const value = readDotenvKey(envPath, name);
|
|
162
|
+
if (!value) {
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
const backupPath = `${envPath}.backup`;
|
|
166
|
+
const stamp = new Date().toISOString();
|
|
167
|
+
writeInside(projectDir, backupPath, `# ${stamp} ${reason}\n${name}=${value}\n`, { secret: true, append: true });
|
|
168
|
+
ensureGitignored(projectDir, relative(projectDir, backupPath));
|
|
169
|
+
return backupPath;
|
|
170
|
+
}
|
|
141
171
|
/**
|
|
142
172
|
* Persist a raw eth private key to the project `.env` as
|
|
143
173
|
* `RECLAIM_PRIVATE_KEY=0x…` (upsert; mode 0600; `.env` added to `.gitignore`).
|
|
@@ -149,3 +179,39 @@ export function writeEthKey(privateKeyHex, projectDir = findProjectRoot(process.
|
|
|
149
179
|
const path = upsertEnvVar(projectDir, ENV_FILE, ENV_VAR, hex);
|
|
150
180
|
return { path, address: deriveFromPrivateKey(hex).address };
|
|
151
181
|
}
|
|
182
|
+
/** Throws when `path` (relative to `root`), or any of its existing parents
|
|
183
|
+
* under `root`, is a symlink: writing through one could land outside the
|
|
184
|
+
* project. */
|
|
185
|
+
export function assertNoSymlinks(root, path) {
|
|
186
|
+
assert(path && !path.startsWith('..') && !isAbsolute(path), badRequest(`${path} is not inside ${root}`));
|
|
187
|
+
let current = root;
|
|
188
|
+
for (const part of path.split(/[\\/]/)) {
|
|
189
|
+
current += sep + part;
|
|
190
|
+
const stat = lstatSync(current, { throwIfNoEntry: false });
|
|
191
|
+
assert(!stat?.isSymbolicLink(), badRequest(`${current} is a symlink; Reclaim tools only write to real files `
|
|
192
|
+
+ 'and directories inside the project'));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Write `data` to `path` inside `projectDir` without following a symlink: the
|
|
197
|
+
* file and its parents must be real, and the open uses `O_NOFOLLOW`, so a
|
|
198
|
+
* symlink that appears after the check still fails. `secret` writes with
|
|
199
|
+
* mode 0600; `append` adds to the file instead of replacing it.
|
|
200
|
+
*/
|
|
201
|
+
function writeInside(projectDir, path, data, { secret = false, append = false } = {}) {
|
|
202
|
+
const rel = relative(projectDir, path);
|
|
203
|
+
assertNoSymlinks(projectDir, rel);
|
|
204
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
205
|
+
assertNoSymlinks(projectDir, rel);
|
|
206
|
+
const fd = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_NOFOLLOW
|
|
207
|
+
| (append ? constants.O_APPEND : constants.O_TRUNC), secret ? 0o600 : 0o666);
|
|
208
|
+
try {
|
|
209
|
+
if (secret) {
|
|
210
|
+
fchmodSync(fd, 0o600);
|
|
211
|
+
}
|
|
212
|
+
writeFileSync(fd, data);
|
|
213
|
+
}
|
|
214
|
+
finally {
|
|
215
|
+
closeSync(fd);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type ReclaimClient } from '@reclaimprotocol/client/api';
|
|
2
2
|
import { type RegisteredTool } from '../../server.ts';
|
|
3
3
|
/**
|
|
4
4
|
* Creates the organization's eth key pair for session binding and encrypted
|
|
5
5
|
* results: writes the private key to the app's server env file and registers
|
|
6
6
|
* only the public key with Builder, so the private key never passes through
|
|
7
7
|
* the agent's transcript. Reuses a key the env file already holds, so a
|
|
8
|
-
* repeat call (for example, to turn on encryption) keeps the same key.
|
|
8
|
+
* repeat call (for example, to turn on encryption) keeps the same key. With
|
|
9
|
+
* `replace`, always generates a fresh key and rotates the org's key to it.
|
|
9
10
|
*/
|
|
10
11
|
export declare function issueOrgKeypairTool(client: ReclaimClient): RegisteredTool;
|
|
@@ -1,14 +1,17 @@
|
|
|
1
|
+
import { conflict } from '@hapi/boom';
|
|
1
2
|
import { deriveFromPrivateKey, generateKeypair } from '@reclaimprotocol/client';
|
|
3
|
+
import { isProblemError } from '@reclaimprotocol/client/api';
|
|
2
4
|
import assert from 'node:assert';
|
|
3
5
|
import { isAbsolute, relative, resolve } from 'node:path';
|
|
4
6
|
import { defineTool } from "../../server.js";
|
|
5
|
-
import { normalizeEthHex, readDotenvKey, upsertEnvVar } from "./eth-key.js";
|
|
7
|
+
import { backupEnvVar, normalizeEthHex, readDotenvKey, removeEnvVar, upsertEnvVar, } from "./eth-key.js";
|
|
6
8
|
/**
|
|
7
9
|
* Creates the organization's eth key pair for session binding and encrypted
|
|
8
10
|
* results: writes the private key to the app's server env file and registers
|
|
9
11
|
* only the public key with Builder, so the private key never passes through
|
|
10
12
|
* the agent's transcript. Reuses a key the env file already holds, so a
|
|
11
|
-
* repeat call (for example, to turn on encryption) keeps the same key.
|
|
13
|
+
* repeat call (for example, to turn on encryption) keeps the same key. With
|
|
14
|
+
* `replace`, always generates a fresh key and rotates the org's key to it.
|
|
12
15
|
*/
|
|
13
16
|
export function issueOrgKeypairTool(client) {
|
|
14
17
|
return defineTool({
|
|
@@ -20,8 +23,12 @@ export function issueOrgKeypairTool(client) {
|
|
|
20
23
|
+ 'private key to a server env file in the project (reusing a key '
|
|
21
24
|
+ 'already there) and registers only the public key. Returns the '
|
|
22
25
|
+ 'address, file, and variable; never the key. Returns 409 when the '
|
|
23
|
-
+ 'org already has a different key
|
|
24
|
-
+ '
|
|
26
|
+
+ 'org already has a different key. Set `replace: true` only when '
|
|
27
|
+
+ 'the developer asked to rotate or replace the key: it generates '
|
|
28
|
+
+ 'a new key and swaps it in one step, and apps holding the old '
|
|
29
|
+
+ 'key can no longer decrypt new results. The old key is first '
|
|
30
|
+
+ 'appended to `<envFile>.backup` (gitignored); tell the developer '
|
|
31
|
+
+ 'its path.',
|
|
25
32
|
inputSchema: {
|
|
26
33
|
type: 'object',
|
|
27
34
|
required: ['orgId', 'projectDir'],
|
|
@@ -42,11 +49,17 @@ export function issueOrgKeypairTool(client) {
|
|
|
42
49
|
},
|
|
43
50
|
encryptResults: {
|
|
44
51
|
type: 'boolean',
|
|
45
|
-
description: 'Encrypt delivered results to this key. Default false
|
|
52
|
+
description: 'Encrypt delivered results to this key. Default false, or the '
|
|
53
|
+
+ 'org\'s current setting with `replace`.',
|
|
54
|
+
},
|
|
55
|
+
replace: {
|
|
56
|
+
type: 'boolean',
|
|
57
|
+
description: 'Rotate: generate a new key and replace the org\'s current '
|
|
58
|
+
+ 'one. Default false.',
|
|
46
59
|
},
|
|
47
60
|
},
|
|
48
61
|
},
|
|
49
|
-
annotations: { openWorldHint: true, destructiveHint:
|
|
62
|
+
annotations: { openWorldHint: true, destructiveHint: true },
|
|
50
63
|
}, async (args) => {
|
|
51
64
|
assert(isAbsolute(args.projectDir), new Error('`projectDir` must be an absolute path.'));
|
|
52
65
|
const envFile = args.envFile || '.env';
|
|
@@ -54,22 +67,53 @@ export function issueOrgKeypairTool(client) {
|
|
|
54
67
|
const envPath = resolve(args.projectDir, envFile);
|
|
55
68
|
const inside = relative(args.projectDir, envPath);
|
|
56
69
|
assert(inside && !inside.startsWith('..') && !isAbsolute(inside), new Error('`envFile` must be inside `projectDir`.'));
|
|
57
|
-
const
|
|
70
|
+
const replace = args.replace ?? false;
|
|
71
|
+
// Rotation always generates a fresh key: the env file's key may or
|
|
72
|
+
// may not be the one the org holds, and reuse would be a no-op.
|
|
73
|
+
const existing = replace ? undefined : readDotenvKey(envPath, envVar);
|
|
58
74
|
const keypair = existing
|
|
59
75
|
? deriveFromPrivateKey(normalizeEthHex(existing))
|
|
60
76
|
: generateKeypair();
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
});
|
|
77
|
+
// A rotation keeps the org's current setting unless the caller sets it.
|
|
78
|
+
const canEncryptResult = args.encryptResults
|
|
79
|
+
?? (replace && await currentCanEncryptResult());
|
|
80
|
+
// Back up and save the key before the org switches to it, so a
|
|
81
|
+
// failed write can't leave the org on a key no app holds. A failed
|
|
82
|
+
// registration (for example, a 409) puts the env file back.
|
|
83
|
+
const old = readDotenvKey(envPath, envVar);
|
|
84
|
+
const backupFile = old && !existing
|
|
85
|
+
? backupEnvVar(args.projectDir, envFile, envVar, 'replaced by issue_org_keypair')
|
|
86
|
+
: undefined;
|
|
72
87
|
const path = upsertEnvVar(args.projectDir, envFile, envVar, keypair.privateKey);
|
|
88
|
+
try {
|
|
89
|
+
await client.call('SetOrgKeypair', {
|
|
90
|
+
params: { orgId: args.orgId },
|
|
91
|
+
body: {
|
|
92
|
+
publicKey: keypair.publicKey,
|
|
93
|
+
canEncryptResult,
|
|
94
|
+
replace,
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
if (old) {
|
|
100
|
+
upsertEnvVar(args.projectDir, envFile, envVar, old);
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
removeEnvVar(args.projectDir, envFile, envVar);
|
|
104
|
+
}
|
|
105
|
+
if (!replace && isProblemError(err) && err.output.statusCode === 409) {
|
|
106
|
+
throw conflict('The org already has a different key. Ask the developer to '
|
|
107
|
+
+ 'choose: (a) reuse it: they paste its private key into '
|
|
108
|
+
+ `\`${envFile}\` as \`${envVar}\` themselves, never into the `
|
|
109
|
+
+ 'chat; (b) rotate it: call `issue_org_keypair` again with '
|
|
110
|
+
+ '`replace: true`, after which apps holding the old key can no '
|
|
111
|
+
+ 'longer decrypt new results; (c) turn off result encryption: '
|
|
112
|
+
+ 'call `update_org_keypair` with `canEncryptResult: false`, '
|
|
113
|
+
+ 'which keeps the key.');
|
|
114
|
+
}
|
|
115
|
+
throw err;
|
|
116
|
+
}
|
|
73
117
|
return {
|
|
74
118
|
orgId: args.orgId,
|
|
75
119
|
address: keypair.address,
|
|
@@ -77,6 +121,27 @@ export function issueOrgKeypairTool(client) {
|
|
|
77
121
|
envFile: path,
|
|
78
122
|
envVar,
|
|
79
123
|
reused: Boolean(existing),
|
|
124
|
+
backupFile: backupFile ?? null,
|
|
125
|
+
...(replace && !backupFile && !old
|
|
126
|
+
? {
|
|
127
|
+
note: 'Nothing was backed up: the old private key is not in '
|
|
128
|
+
+ 'this file. Another app may hold it.',
|
|
129
|
+
}
|
|
130
|
+
: {}),
|
|
80
131
|
};
|
|
132
|
+
async function currentCanEncryptResult() {
|
|
133
|
+
try {
|
|
134
|
+
const { data } = await client.call('GetOrgKeypair', {
|
|
135
|
+
params: { orgId: args.orgId },
|
|
136
|
+
});
|
|
137
|
+
return data.canEncryptResult;
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
if (isProblemError(err) && err.output.statusCode === 404) {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
throw err;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
81
146
|
});
|
|
82
147
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import assert from 'node:assert';
|
|
2
2
|
import { isAbsolute, relative, resolve } from 'node:path';
|
|
3
3
|
import { defineTool } from "../../server.js";
|
|
4
|
-
import { upsertEnvVar } from "./eth-key.js";
|
|
4
|
+
import { backupEnvVar, readDotenvKey, upsertEnvVar } from "./eth-key.js";
|
|
5
5
|
import { saveOrgKey } from "./org-key-store.js";
|
|
6
6
|
/**
|
|
7
7
|
* Issues an organization API key and stores it on disk, so the key never
|
|
@@ -15,7 +15,9 @@ export function issueOrgTokenTool(client) {
|
|
|
15
15
|
+ 'server env file in the project, so the app can read it at runtime. '
|
|
16
16
|
+ 'Also saves it on this machine for the org-key tools. Returns the '
|
|
17
17
|
+ 'org, file, variable, and last four characters; never the key. '
|
|
18
|
-
+ 'Replaces an existing variable of the same name in the file
|
|
18
|
+
+ 'Replaces an existing variable of the same name in the file, first '
|
|
19
|
+
+ 'appending the old value to `<envFile>.backup` (gitignored); tell '
|
|
20
|
+
+ 'the developer its path. '
|
|
19
21
|
+ 'Adds the file to .gitignore. Use when the app needs a key, or '
|
|
20
22
|
+ 'before `create_verification_session`.',
|
|
21
23
|
inputSchema: {
|
|
@@ -48,6 +50,10 @@ export function issueOrgTokenTool(client) {
|
|
|
48
50
|
const { data } = await client.call('IssueOrgToken', {
|
|
49
51
|
params: { orgId: args.orgId },
|
|
50
52
|
});
|
|
53
|
+
const old = readDotenvKey(resolve(args.projectDir, envFile), envVar);
|
|
54
|
+
const backupFile = old && old !== data.orgSecret
|
|
55
|
+
? backupEnvVar(args.projectDir, envFile, envVar, 'replaced by issue_org_token')
|
|
56
|
+
: undefined;
|
|
51
57
|
const path = upsertEnvVar(args.projectDir, envFile, envVar, data.orgSecret);
|
|
52
58
|
saveOrgKey(args.orgId, data.orgSecret);
|
|
53
59
|
return {
|
|
@@ -55,6 +61,7 @@ export function issueOrgTokenTool(client) {
|
|
|
55
61
|
envFile: path,
|
|
56
62
|
envVar,
|
|
57
63
|
last4: data.last4,
|
|
64
|
+
backupFile: backupFile ?? null,
|
|
58
65
|
};
|
|
59
66
|
});
|
|
60
67
|
}
|