@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
|
@@ -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>;
|
|
@@ -11,28 +11,42 @@ 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 { mkdir,
|
|
15
|
-
import {
|
|
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';
|
|
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. */
|
|
21
|
+
const BACKEND_PACKAGE = 'packages/backend/package.json';
|
|
19
22
|
const CLIENT_PACKAGE = '@reclaimprotocol/client';
|
|
20
23
|
/** The example is ~50 small files; anything far past that isn't it. */
|
|
21
24
|
const MAX_FILES = 500;
|
|
22
25
|
const MAX_BYTES = 10000000;
|
|
23
|
-
|
|
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) {
|
|
24
36
|
return defineTool({
|
|
25
37
|
name: 'create_example_app',
|
|
26
38
|
description: 'Writes the Reclaim example app (reclaimprotocol/builder-example: '
|
|
27
39
|
+ 'a Fastify backend and a Vite frontend that create sessions, '
|
|
28
40
|
+ 'verify callbacks, and render every verification state) into '
|
|
29
41
|
+ '`projectDir`, and sets its backend to the latest '
|
|
30
|
-
+ '`@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 '
|
|
31
45
|
+ 'yet, with the workspace root you were started in as '
|
|
32
46
|
+ '`projectDir`; don\'t clone the repository yourself. Refuses '
|
|
33
47
|
+ 'when a file it would write already exists with different '
|
|
34
|
-
+ 'content. Returns the commit, file count, client version,
|
|
35
|
-
+ 'next steps.',
|
|
48
|
+
+ 'content. Returns the commit, file count, client version, '
|
|
49
|
+
+ '`backendPort`, and next steps.',
|
|
36
50
|
inputSchema: {
|
|
37
51
|
type: 'object',
|
|
38
52
|
required: ['projectDir'],
|
|
@@ -44,8 +58,9 @@ export function exampleAppTool(fetchFn = fetch) {
|
|
|
44
58
|
},
|
|
45
59
|
},
|
|
46
60
|
annotations: { openWorldHint: true, destructiveHint: false },
|
|
47
|
-
}, async (
|
|
48
|
-
assert(isAbsolute(projectDir), badRequest('`projectDir` must be an absolute path.'));
|
|
61
|
+
}, async (args) => {
|
|
62
|
+
assert(isAbsolute(args.projectDir), badRequest('`projectDir` must be an absolute path.'));
|
|
63
|
+
const projectDir = await realpath(args.projectDir);
|
|
49
64
|
const commit = await latestCommit();
|
|
50
65
|
const files = await listFiles(commit);
|
|
51
66
|
const clientVersion = await latestClientVersion();
|
|
@@ -58,7 +73,16 @@ export function exampleAppTool(fetchFn = fetch) {
|
|
|
58
73
|
: bytes,
|
|
59
74
|
};
|
|
60
75
|
}));
|
|
61
|
-
const
|
|
76
|
+
for (const path of [...staged.map((f) => f.path), BACKEND_ENV_FILE]) {
|
|
77
|
+
assertNoSymlinks(projectDir, path);
|
|
78
|
+
}
|
|
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
|
|
62
86
|
.filter(({ path, bytes }) => {
|
|
63
87
|
const dest = join(projectDir, path);
|
|
64
88
|
return existsSync(dest) && !readFileSync(dest).equals(bytes);
|
|
@@ -67,41 +91,63 @@ export function exampleAppTool(fetchFn = fetch) {
|
|
|
67
91
|
assert(!conflicts.length, conflict('These files already exist with different content: '
|
|
68
92
|
+ `${conflicts.join(', ')}. Ask the developer whether to use `
|
|
69
93
|
+ 'another directory or move them.'));
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
try {
|
|
73
|
-
for (const { path, bytes } of staged) {
|
|
74
|
-
await mkdir(dirname(join(tmp, path)), { recursive: true });
|
|
75
|
-
await writeFile(join(tmp, path), bytes);
|
|
76
|
-
}
|
|
77
|
-
for (const { path } of staged) {
|
|
78
|
-
const dest = join(projectDir, path);
|
|
79
|
-
await mkdir(dirname(dest), { recursive: true });
|
|
80
|
-
await rename(join(tmp, path), dest);
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
finally {
|
|
84
|
-
await rm(tmp, { recursive: true, force: true });
|
|
94
|
+
for (const { path, bytes } of toWrite) {
|
|
95
|
+
await installFile(projectDir, path, bytes);
|
|
85
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`;
|
|
86
103
|
return {
|
|
87
104
|
projectDir,
|
|
88
105
|
repository: `https://github.com/${REPO}`,
|
|
89
106
|
commit,
|
|
90
|
-
files:
|
|
107
|
+
files: toWrite.length,
|
|
108
|
+
kept,
|
|
91
109
|
clientVersion,
|
|
110
|
+
backendPort,
|
|
92
111
|
next: [
|
|
93
112
|
'Call issue_org_token with this projectDir and envFile '
|
|
94
113
|
+ '"packages/backend/.env".',
|
|
95
|
-
'Run `npm install
|
|
96
|
-
+
|
|
97
|
-
+
|
|
98
|
-
|
|
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: `
|
|
99
129
|
+ '"/reclaim/callback" }).',
|
|
130
|
+
'If get_org_keypair shows `canEncryptResult: true`, the '
|
|
131
|
+
+ 'callback needs `orgEthPrivateKey`; see the plan.',
|
|
100
132
|
'Put the provider in packages/backend/config.json and restart '
|
|
101
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.',
|
|
102
137
|
],
|
|
103
138
|
};
|
|
104
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
|
+
}
|
|
105
151
|
async function latestCommit() {
|
|
106
152
|
const res = await fetchFn(`https://api.github.com/repos/${REPO}/commits/main`, { headers: { Accept: 'application/vnd.github.sha' } });
|
|
107
153
|
assert(res.ok, badGateway(`${REPO} commit lookup: HTTP ${res.status}`));
|
|
@@ -142,7 +188,7 @@ export function exampleAppTool(fetchFn = fetch) {
|
|
|
142
188
|
return bytes;
|
|
143
189
|
}
|
|
144
190
|
async function latestClientVersion() {
|
|
145
|
-
const res = await fetchFn(`https://registry.npmjs.org/${CLIENT_PACKAGE
|
|
191
|
+
const res = await fetchFn(`https://registry.npmjs.org/${encodeURIComponent(CLIENT_PACKAGE)}/latest`);
|
|
146
192
|
assert(res.ok, badGateway(`npm ${CLIENT_PACKAGE}: HTTP ${res.status}`));
|
|
147
193
|
const body = await res.json();
|
|
148
194
|
assert(typeof body === 'object' && body !== null && 'version' in body
|
|
@@ -151,6 +197,47 @@ export function exampleAppTool(fetchFn = fetch) {
|
|
|
151
197
|
return body.version;
|
|
152
198
|
}
|
|
153
199
|
}
|
|
200
|
+
/**
|
|
201
|
+
* Write one file under `root` without following a symlink out of it or
|
|
202
|
+
* replacing anything: each parent is created one level at a time and must be
|
|
203
|
+
* a real directory, the file is created exclusively (`wx` fails on any path
|
|
204
|
+
* that appeared since the conflict scan, symlinks included), and its real
|
|
205
|
+
* path is checked to still be inside `root` afterwards. A file already there
|
|
206
|
+
* with the same bytes (a re-run) is left as is.
|
|
207
|
+
*/
|
|
208
|
+
async function installFile(root, path, bytes) {
|
|
209
|
+
const parts = path.split('/');
|
|
210
|
+
let dir = root;
|
|
211
|
+
for (const part of parts.slice(0, -1)) {
|
|
212
|
+
dir = join(dir, part);
|
|
213
|
+
await mkdir(dir).catch((err) => {
|
|
214
|
+
if (!isEexist(err)) {
|
|
215
|
+
throw err;
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
assertNoSymlinks(root, relative(root, dir));
|
|
219
|
+
}
|
|
220
|
+
const dest = join(dir, parts.at(-1));
|
|
221
|
+
try {
|
|
222
|
+
await writeFile(dest, bytes, { flag: 'wx' });
|
|
223
|
+
}
|
|
224
|
+
catch (err) {
|
|
225
|
+
if (!isEexist(err)) {
|
|
226
|
+
throw err;
|
|
227
|
+
}
|
|
228
|
+
assertNoSymlinks(root, path);
|
|
229
|
+
assert(readFileSync(dest).equals(bytes), conflict(`${path} appeared with different content; nothing replaced`));
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const real = await realpath(dest);
|
|
233
|
+
if (!real.startsWith(root + sep)) {
|
|
234
|
+
await rm(dest, { force: true });
|
|
235
|
+
throw badRequest(`${path} resolved outside the workspace; removed it`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
function isEexist(err) {
|
|
239
|
+
return err instanceof Error && 'code' in err && err.code === 'EEXIST';
|
|
240
|
+
}
|
|
154
241
|
/** The backend's package.json with the client range set to `^version`. */
|
|
155
242
|
function withClientVersion(bytes, version) {
|
|
156
243
|
const text = bytes.toString('utf8');
|
|
@@ -165,3 +252,23 @@ function gitBlobSha(bytes) {
|
|
|
165
252
|
.update(bytes)
|
|
166
253
|
.digest('hex');
|
|
167
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;
|