@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.
@@ -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:96339e8dff91).
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({
@@ -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, and which token authorizes what.',
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, and '
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, and '
68
- + 'creating a verification session.',
69
- 'integration': 'After start_integration: org key, callback (local '
70
- + 'proxy_callback_events or deployed webhook), frontend and backend '
71
- + 'over the HTTP session API; `api=2` and result verification.',
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, mkdtemp, rename, rm, writeFile } from 'node:fs/promises';
15
- import { dirname, isAbsolute, join } from 'node:path';
14
+ import { mkdir, realpath, rm, writeFile } from 'node:fs/promises';
15
+ import { connect } from 'node:net';
16
+ import { isAbsolute, join, relative, sep } from 'node:path';
16
17
  import { defineTool } from "../../server.js";
18
+ import { assertNoSymlinks, upsertEnvVar } from "../authenticate/eth-key.js";
17
19
  const REPO = 'reclaimprotocol/builder-example';
18
- const BACKEND_PACKAGE = join('packages', 'backend', 'package.json');
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
- export function exampleAppTool(fetchFn = fetch) {
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`. Use it when the workspace has no app '
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, and '
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 ({ projectDir }) => {
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 conflicts = staged
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
- // Stage inside projectDir, so the moves below stay on one filesystem.
71
- const tmp = await mkdtemp(join(projectDir, '.reclaim-example-'));
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: staged.length,
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`, then `npm run dev`, from projectDir. The '
96
- + 'backend listens on port 3000; the app opens at '
97
- + 'http://localhost:5173.',
98
- 'Call proxy_callback_events({ port: 3000, path: '
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.replace('/', '%2f')}/latest`);
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 { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, } from 'node:fs';
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
- writeFileSync(path, current + prefix + entry + '\n');
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
- mkdirSync(dirname(envPath), { recursive: true });
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 { ReclaimClient } from '@reclaimprotocol/client/api';
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;