@finchagentic/mcp 4.0.0 → 4.2.0

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.
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.VAULT_TOOLS = void 0;
4
+ exports.buildVaultList = buildVaultList;
5
+ exports.buildVaultSearch = buildVaultSearch;
4
6
  exports.handleVaultTool = handleVaultTool;
5
7
  const zod_1 = require("zod");
6
8
  const convex_js_1 = require("../convex.js");
@@ -11,7 +13,7 @@ const VAULT_TYPES = ["research", "execution", "workflow", "prompt", "file", "mem
11
13
  exports.VAULT_TOOLS = [
12
14
  {
13
15
  name: "vault_save",
14
- description: "Save or update a versioned artifact in Noel-Vault. Same key = update (git-style: prior version snapshotted, patched to v+1). " +
16
+ description: "Save or update a versioned artifact in Finch Vault. Same key = update (git-style: prior version snapshotted, patched to v+1). " +
15
17
  "Types: research | execution | workflow | prompt | file | memory. " +
16
18
  "Entries up to 10MB - content over 600KB auto-offloads to blob storage. " +
17
19
  "For quick unstructured notes, use memory_add instead.",
@@ -33,7 +35,7 @@ exports.VAULT_TOOLS = [
33
35
  },
34
36
  {
35
37
  name: "vault_read",
36
- description: "Read a Noel-Vault entry by its key. Returns full content, version, tags, and any linked entries.",
38
+ description: "Read a Finch Vault entry by its key. Returns full content, version, tags, and any linked entries.",
37
39
  inputSchema: {
38
40
  type: "object",
39
41
  properties: {
@@ -44,7 +46,7 @@ exports.VAULT_TOOLS = [
44
46
  },
45
47
  {
46
48
  name: "vault_list",
47
- description: "List Noel-Vault entries. Filter by type, agent, or pinned status. Returns previews, not full content.",
49
+ description: "List Finch Vault entries. Filter by type, agent, or pinned status. Returns previews, not full content.",
48
50
  inputSchema: {
49
51
  type: "object",
50
52
  properties: {
@@ -58,14 +60,14 @@ exports.VAULT_TOOLS = [
58
60
  },
59
61
  {
60
62
  name: "vault_search",
61
- description: "Search Noel-Vault using semantic AI search (powered by Supermemory) when available, " +
62
- "with automatic fallback to full-text search. Semantic search understands meaning - " +
63
- "'low risk DeFi yield' matches 'conservative staking strategies' without exact keywords. " +
63
+ description: "Search Finch Vault using full-text (keyword) search over titles, content, and tags. " +
64
+ "This is lexical matching, not embeddings - exact and near words in your query rank " +
65
+ "highest, so short specific phrases work better than long abstract descriptions. " +
64
66
  "Optionally filter by type. Returns ranked results with previews.",
65
67
  inputSchema: {
66
68
  type: "object",
67
69
  properties: {
68
- query: { type: "string", description: "Search query - natural language works best with semantic mode" },
70
+ query: { type: "string", description: "Search query - specific keywords work better than abstract phrasing (this is full-text search, not semantic)" },
69
71
  type: { type: "string", enum: [...VAULT_TYPES], description: "Narrow search to a specific type" },
70
72
  limit: { type: "number", description: "Max results (default 20)" },
71
73
  },
@@ -74,7 +76,7 @@ exports.VAULT_TOOLS = [
74
76
  },
75
77
  {
76
78
  name: "vault_history",
77
- description: "Get the full version history of a Noel-Vault entry - like git log. " +
79
+ description: "Get the full version history of a Finch Vault entry - like git log. " +
78
80
  "Shows each version with its commit message, author agent, size, and timestamp.",
79
81
  inputSchema: {
80
82
  type: "object",
@@ -86,7 +88,7 @@ exports.VAULT_TOOLS = [
86
88
  },
87
89
  {
88
90
  name: "vault_diff",
89
- description: "Compare two versions of a Noel-Vault entry - like git diff. " +
91
+ description: "Compare two versions of a Finch Vault entry - like git diff. " +
90
92
  "Shows lines added (+) and removed (-) between fromVersion and toVersion.",
91
93
  inputSchema: {
92
94
  type: "object",
@@ -100,7 +102,7 @@ exports.VAULT_TOOLS = [
100
102
  },
101
103
  {
102
104
  name: "vault_export",
103
- description: "Export your entire Noel-Vault or a specific type as a structured bundle. " +
105
+ description: "Export your entire Finch Vault or a specific type as a structured bundle. " +
104
106
  "Useful for archiving, syncing to GitHub, or passing context to another agent.",
105
107
  inputSchema: {
106
108
  type: "object",
@@ -139,7 +141,7 @@ exports.VAULT_TOOLS = [
139
141
  },
140
142
  {
141
143
  name: "vault_pin",
142
- description: "Pin or unpin a Noel-Vault entry. Pinned entries always appear first in vault_list and are " +
144
+ description: "Pin or unpin a Finch Vault entry. Pinned entries always appear first in vault_list and are " +
143
145
  "prioritized in memory_context and search results. Use for your most important research, key prompts, or canonical references.",
144
146
  inputSchema: {
145
147
  type: "object",
@@ -152,7 +154,7 @@ exports.VAULT_TOOLS = [
152
154
  },
153
155
  {
154
156
  name: "vault_unpublish",
155
- description: "Make a previously shared Noel-Vault entry private again, removing it from the public community " +
157
+ description: "Make a previously shared Finch Vault entry private again, removing it from the public community " +
156
158
  "listing. Use this to reverse vault publishing or packet_share. Note that anyone who already " +
157
159
  "copied the content while it was public still has it — unpublishing stops future discovery, " +
158
160
  "it does not retract what was taken.",
@@ -166,7 +168,7 @@ exports.VAULT_TOOLS = [
166
168
  },
167
169
  {
168
170
  name: "vault_delete",
169
- description: "PERMANENT. Delete a Noel-Vault entry and ALL of its version history — this cannot be undone. " +
171
+ description: "PERMANENT. Delete a Finch Vault entry and ALL of its version history — this cannot be undone. " +
170
172
  "Requires confirm: true. Use vault_list to browse first, and show the user the exact entry " +
171
173
  "(key + title) you are about to destroy before confirming.",
172
174
  inputSchema: {
@@ -180,7 +182,7 @@ exports.VAULT_TOOLS = [
180
182
  },
181
183
  {
182
184
  name: "vault_tag",
183
- description: "Add or replace tags on an existing Noel-Vault entry without modifying its content. " +
185
+ description: "Add or replace tags on an existing Finch Vault entry without modifying its content. " +
184
186
  "Useful for organizing entries retroactively. Set replace=true to overwrite all existing tags.",
185
187
  inputSchema: {
186
188
  type: "object",
@@ -194,7 +196,7 @@ exports.VAULT_TOOLS = [
194
196
  },
195
197
  {
196
198
  name: "vault_link",
197
- description: "Create a semantic relationship between two Noel-Vault entries - building a knowledge graph. " +
199
+ description: "Create a semantic relationship between two Finch Vault entries - building a knowledge graph. " +
198
200
  "Relations: references | derived_from | supersedes | related | continues. " +
199
201
  "Example: link a synthesis entry as 'derived_from' several research entries, or mark a newer analysis as 'supersedes' an older one. " +
200
202
  "Duplicate links are updated in-place.",
@@ -214,7 +216,7 @@ exports.VAULT_TOOLS = [
214
216
  },
215
217
  {
216
218
  name: "vault_related",
217
- description: "Traverse the Noel-Vault knowledge graph - get all entries linked to a given entry. " +
219
+ description: "Traverse the Finch Vault knowledge graph - get all entries linked to a given entry. " +
218
220
  "Returns both outbound links (entries this entry references) and inbound links (entries that reference this one). " +
219
221
  "Filter by relation type to find only derived entries, superseded versions, continuations, etc.",
220
222
  inputSchema: {
@@ -280,6 +282,35 @@ function formatBytes(n) {
280
282
  function formatDate(ts) {
281
283
  return new Date(ts).toUTCString();
282
284
  }
285
+ // ── Structured output builders (schemas in output-schemas.ts) ───────────────
286
+ function buildVaultList(entries, type) {
287
+ return {
288
+ type: type ?? null,
289
+ count: entries.length,
290
+ entries: entries.map((e) => ({
291
+ key: e.key,
292
+ title: e.title ?? null,
293
+ type: e.type ?? null,
294
+ version: e.version ?? null,
295
+ size: e.size ?? null,
296
+ updatedAt: e.updatedAt ?? null,
297
+ isPinned: !!e.isPinned,
298
+ })),
299
+ };
300
+ }
301
+ function buildVaultSearch(query, results) {
302
+ return {
303
+ query,
304
+ count: results.length,
305
+ results: results.map((r) => ({
306
+ key: r.key,
307
+ title: r.title ?? null,
308
+ type: r.type ?? null,
309
+ score: r.score ?? null,
310
+ preview: r.preview ?? null,
311
+ })),
312
+ };
313
+ }
283
314
  // ─── Handler ─────────────────────────────────────────────────────────────────
284
315
  async function handleVaultTool(name, args) {
285
316
  // When the user has opted into a fully-local, user-owned vault
@@ -292,6 +323,20 @@ async function handleVaultTool(name, args) {
292
323
  const parsed = SaveSchema.safeParse(args);
293
324
  if (!parsed.success)
294
325
  return { content: [{ type: "text", text: `Invalid input: ${parsed.error.issues[0].message}` }], isError: true };
326
+ if (parsed.data.type === "credential") {
327
+ // vault_save writes plaintext to disk/DB - "credential" is only a
328
+ // valid FILTER value for vault_list/search/export (which correctly
329
+ // exclude it), never a valid type to actually SAVE through here.
330
+ // vault_store_credential is the only path that encrypts at rest.
331
+ return {
332
+ content: [{
333
+ type: "text",
334
+ text: "Use `vault_store_credential` to save a secret - it encrypts at rest (AES-256-GCM). " +
335
+ "`vault_save` writes plaintext, so `type: \"credential\"` is refused here.",
336
+ }],
337
+ isError: true,
338
+ };
339
+ }
295
340
  // Auto-generate title from content if not provided
296
341
  const firstLine = parsed.data.content.split("\n")[0].replace(/^#+\s*/, "").slice(0, 80);
297
342
  const autoTitle = parsed.data.title ?? (firstLine || `${parsed.data.type} - ${new Date().toISOString().slice(0, 10)}`);
@@ -302,7 +347,7 @@ async function handleVaultTool(name, args) {
302
347
  if (data.error)
303
348
  return { content: [{ type: "text", text: `Error: ${data.error}` }], isError: true };
304
349
  const { key, version, changed } = data;
305
- // Mirror to semantic memory (fire-and-forget). Skip when running a local
350
+ // Mirror to searchable memory (fire-and-forget). Skip when running a local
306
351
  // vault WITHOUT local memory - otherwise the mirror would phone home to
307
352
  // the hosted proxy (needs auth the local-only user doesn't have, and
308
353
  // ships their data off-machine, defeating the whole point). vault_search
@@ -335,7 +380,7 @@ async function handleVaultTool(name, args) {
335
380
  `Key: \`${key}\``,
336
381
  `Version: v${version}`,
337
382
  changed && version > 1 ? `Previous version auto-snapshotted.` : "",
338
- mirrorToMemory ? `🧠 Synced to semantic memory` : (localVault ? `💾 Stored locally at ~/.finch/vault` : ""),
383
+ mirrorToMemory ? `🧠 Synced to searchable memory` : (localVault ? `💾 Stored locally at ~/.finch/vault` : ""),
339
384
  ...linkSummary,
340
385
  ``,
341
386
  `Use \`vault_read\` to retrieve, \`vault_history\` to see all versions.`,
@@ -361,7 +406,7 @@ async function handleVaultTool(name, args) {
361
406
  `vault_read: entry \`${vaultReadKey}\` not found.`,
362
407
  ``,
363
408
  `Try searching for it:`,
364
- `- \`vault_search query="${searchTerms}"\` — semantic search across all entries`,
409
+ `- \`vault_search query="${searchTerms}"\` — full-text search across all entries`,
365
410
  `- \`vault_list\` — browse all entries`,
366
411
  `- \`vault_search query="${vaultReadKey.split("/")[0]}"\` — search by type prefix`,
367
412
  ].join("\n") }], isError: true };
@@ -383,17 +428,14 @@ async function handleVaultTool(name, args) {
383
428
  }
384
429
  return { content: [{ type: "text", text: `vault_read error: ${data.error}` }], isError: true };
385
430
  }
386
- // Large entries are offloaded to Convex File Storage. The doc holds a
387
- // preview only; pull the real content from /vault/blob.
388
- let fullContent = data.content ?? "";
389
- if (data.contentFileId) {
390
- try {
391
- fullContent = await (0, convex_js_1.callConvexRaw)(`/vault/blob?id=${encodeURIComponent(data.contentFileId)}`, "vault_read");
392
- }
393
- catch (err) {
394
- fullContent = (data.content ?? "") + `\n\n_(could not load full blob: ${err.message})_`;
395
- }
396
- }
431
+ // NOTE: there is no blob-storage tier on the backend (see
432
+ // app/convex/vault.ts MAX_CONTENT_BYTES comment) - oversized content is
433
+ // rejected at save time, not offloaded to file storage, so `contentFileId`
434
+ // never comes back on a vault entry. A `/vault/blob` fallback used to live
435
+ // here but the route was never registered in http.ts either, so it was
436
+ // dead in both directions - removed rather than fixed against a storage
437
+ // tier that doesn't exist. Re-add only alongside building that tier.
438
+ const fullContent = data.content ?? "";
397
439
  const sizeLabel = data.originalSize ? formatBytes(data.originalSize) : formatBytes(data.size);
398
440
  const backlinksBlock = Array.isArray(data.backlinks) && data.backlinks.length > 0
399
441
  ? `\n🔙 Linked from (${data.backlinks.length}):\n${data.backlinks.map((b) => ` ← \`${b.key}\`${b.title ? ` - ${b.title}` : ""}`).join("\n")}`
@@ -433,21 +475,35 @@ async function handleVaultTool(name, args) {
433
475
  if (data.error)
434
476
  return { content: [{ type: "text", text: `Error: ${data.error}` }], isError: true };
435
477
  const entries = data.entries ?? [];
436
- if (!entries.length)
437
- return { content: [{ type: "text", text: `No vault entries found${parsed.data.type ? ` of type '${parsed.data.type}'` : ""}.` }] };
438
- const header = `📚 **Noel-Vault** (${entries.length} entries)`;
478
+ if (!entries.length) {
479
+ return {
480
+ content: [{ type: "text", text: `No vault entries found${parsed.data.type ? ` of type '${parsed.data.type}'` : ""}.` }],
481
+ structuredContent: buildVaultList([], parsed.data.type),
482
+ };
483
+ }
484
+ const header = `📚 **Finch Vault** (${entries.length} entries)`;
439
485
  const rows = entries.map((e) => `${e.isPinned ? "📌 " : ""}[\`${e.key}\`] ${e.title} - v${e.version} · ${e.type} · ${formatBytes(e.size)} · ${formatDate(e.updatedAt)}`);
440
- return { content: [{ type: "text", text: [header, "", ...rows].join("\n") }] };
486
+ return {
487
+ content: [{ type: "text", text: [header, "", ...rows].join("\n") }],
488
+ structuredContent: buildVaultList(entries, parsed.data.type),
489
+ };
441
490
  }
442
491
  case "vault_search": {
443
492
  const parsed = SearchSchema.safeParse(args);
444
493
  if (!parsed.success)
445
494
  return { content: [{ type: "text", text: `Invalid input: ${parsed.error.issues[0].message}` }], isError: true };
446
- // Try semantic search first (proxied through Convex). Large vault
495
+ // Full-text search, proxied through Convex (searchSupermemory is a
496
+ // legacy name - it calls Finch's own /memory/search endpoint, not a
497
+ // third-party semantic service; there is no embedding step here). Large vault
447
498
  // entries are indexed as multiple chunks tagged with isVaultChunk +
448
499
  // vaultKey - group chunks back to their parent entry so the result
449
500
  // list shows one row per entry, not one row per chunk.
450
- {
501
+ //
502
+ // Skipped entirely when vaultBackend is local - a local vault's whole
503
+ // point is "no network," so the query string must never leave the
504
+ // machine, not even to check for results before falling back to the
505
+ // (always-local) full-text branch below.
506
+ if (!localVault) {
451
507
  const limit = parsed.data.limit ?? 20;
452
508
  // Over-fetch so that after chunk dedup we still have ~limit rows.
453
509
  const smResults = await (0, memory_js_1.searchSupermemory)(parsed.data.query, Math.min(50, limit * 3));
@@ -487,7 +543,7 @@ async function handleVaultTool(name, args) {
487
543
  const grouped = Array.from(groups.values())
488
544
  .sort((a, b) => b.bestScore - a.bestScore)
489
545
  .slice(0, limit);
490
- const header = `🔍 **Vault Search** [Semantic]: "${parsed.data.query}" - ${grouped.length} entry/entries`;
546
+ const header = `🔍 **Vault Search**: "${parsed.data.query}" - ${grouped.length} entry/entries`;
491
547
  const rows = grouped.map((g, i) => {
492
548
  const score = g.bestScore ? ` ${(g.bestScore * 100).toFixed(0)}%` : "";
493
549
  const chunkBadge = g.isVaultChunk && g.chunkHits > 1
@@ -498,7 +554,10 @@ async function handleVaultTool(name, args) {
498
554
  ` ${g.bestPreview}${g.bestPreview.length >= 200 ? "…" : ""}`,
499
555
  ].join("\n");
500
556
  });
501
- return { content: [{ type: "text", text: [header, "", ...rows].join("\n") }] };
557
+ return {
558
+ content: [{ type: "text", text: [header, "", ...rows].join("\n") }],
559
+ structuredContent: buildVaultSearch(parsed.data.query, grouped.map((g) => ({ key: g.key, title: g.title, type: g.type, score: g.bestScore, preview: g.bestPreview }))),
560
+ };
502
561
  }
503
562
  }
504
563
  }
@@ -514,14 +573,21 @@ async function handleVaultTool(name, args) {
514
573
  if (data.error)
515
574
  return { content: [{ type: "text", text: `Error: ${data.error}` }], isError: true };
516
575
  const results = data.results ?? [];
517
- if (!results.length)
518
- return { content: [{ type: "text", text: `No vault entries found for: "${parsed.data.query}"` }] };
576
+ if (!results.length) {
577
+ return {
578
+ content: [{ type: "text", text: `No vault entries found for: "${parsed.data.query}"` }],
579
+ structuredContent: buildVaultSearch(parsed.data.query, []),
580
+ };
581
+ }
519
582
  const header = `🔍 **Vault Search**: "${parsed.data.query}" - ${results.length} result(s)`;
520
583
  const rows = results.map((r, i) => [
521
584
  `${i + 1}. [\`${r.key}\`] **${r.title}** (${r.type} · v${r.version})`,
522
585
  ` ${r.preview}`,
523
586
  ].join("\n"));
524
- return { content: [{ type: "text", text: [header, "", ...rows].join("\n") }] };
587
+ return {
588
+ content: [{ type: "text", text: [header, "", ...rows].join("\n") }],
589
+ structuredContent: buildVaultSearch(parsed.data.query, results),
590
+ };
525
591
  }
526
592
  case "vault_history": {
527
593
  const parsed = HistorySchema.safeParse(args);
@@ -712,7 +778,21 @@ async function handleVaultTool(name, args) {
712
778
  : await (0, convex_js_1.callConvex)("/vault/delete", "POST", { key: parsed.data.key }, "vault_delete");
713
779
  if (data.error)
714
780
  return { content: [{ type: "text", text: `Error: ${data.error}` }], isError: true };
715
- return { content: [{ type: "text", text: `🗑️ Deleted: \`${parsed.data.key}\` (${data.versionsRemoved ?? 0} versions removed)` }] };
781
+ // vault_save mirrors non-credential entries into memory for search - a
782
+ // "PERMANENT... cannot be undone" delete that leaves that mirror intact
783
+ // is not actually permanent. Only relevant for the local memory-file
784
+ // backend (the hosted Convex path cleans its own memories table inside
785
+ // the /vault/delete mutation itself, same request, no separate call).
786
+ let memoriesRemoved = 0;
787
+ const localMem = (0, local_memory_js_1.getLocalMemoryConfig)();
788
+ if (localMem) {
789
+ memoriesRemoved = (0, local_memory_js_1.localMemoryDeleteByVaultKey)(localMem, parsed.data.key);
790
+ }
791
+ else if (typeof data.memoriesRemoved === "number") {
792
+ memoriesRemoved = data.memoriesRemoved;
793
+ }
794
+ const memoryNote = memoriesRemoved > 0 ? ` + ${memoriesRemoved} memory mirror${memoriesRemoved === 1 ? "" : "s"} removed` : "";
795
+ return { content: [{ type: "text", text: `🗑️ Deleted: \`${parsed.data.key}\` (${data.versionsRemoved ?? 0} versions removed${memoryNote})` }] };
716
796
  }
717
797
  case "vault_tag": {
718
798
  const parsed = TagSchema.safeParse(args);
package/dist/wallet.js CHANGED
@@ -36,8 +36,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.BASE_CHAIN_ID = exports.MEV_PROTECT_ENABLED = exports.BROADCAST_RPC = exports.BASE_RPC = void 0;
37
37
  exports.clearWalletCache = clearWalletCache;
38
38
  exports.getMachineKey = getMachineKey;
39
+ exports.warnIfNoPassphrase = warnIfNoPassphrase;
39
40
  exports.getOrCreateWallet = getOrCreateWallet;
40
41
  exports.signRequest = signRequest;
42
+ exports.waitForReceipt = waitForReceipt;
41
43
  exports.signAndBroadcast = signAndBroadcast;
42
44
  const ethers_1 = require("ethers");
43
45
  const fs = __importStar(require("fs"));
@@ -64,14 +66,75 @@ exports.MEV_PROTECT_ENABLED = !!process.env.FINCH_BROADCAST_RPC;
64
66
  exports.BASE_CHAIN_ID = 8453;
65
67
  const WALLET_DIR = path.join(os.homedir(), ".finch");
66
68
  const WALLET_FILE = path.join(WALLET_DIR, "wallet.json");
69
+ // Per-install random secret folded into the no-passphrase key derivation
70
+ // (see getMachineKey). Generated once via crypto.randomBytes and stored
71
+ // 0600 next to the wallet - real entropy, unlike hostname/platform/arch
72
+ // which are guessable/public and give an attacker who copies the wallet
73
+ // file everything they need to also derive the key.
74
+ const LOCAL_SECRET_FILE = path.join(WALLET_DIR, ".local-secret");
67
75
  let _cachedWallet = null;
68
76
  function clearWalletCache() { _cachedWallet = null; }
77
+ function getOrCreateLocalSecret() {
78
+ try {
79
+ const existing = fs.readFileSync(LOCAL_SECRET_FILE, "utf8").trim();
80
+ if (existing)
81
+ return existing;
82
+ }
83
+ catch { /* doesn't exist yet, or unreadable - (re)create below */ }
84
+ const secret = crypto.randomBytes(32).toString("hex");
85
+ if (!fs.existsSync(WALLET_DIR))
86
+ fs.mkdirSync(WALLET_DIR, { recursive: true });
87
+ fs.writeFileSync(LOCAL_SECRET_FILE, secret, { mode: 0o600 });
88
+ return secret;
89
+ }
69
90
  function getMachineKey() {
70
- // If a passphrase is set, use it as the primary secret for stronger encryption.
71
- // Without it, the key is derived from public machine info only - this is
72
- // convenience encryption (prevents casual reads), not security against
73
- // an attacker who has read access to both the file and system info.
91
+ // A passphrase is meant to make the wallet portable (move the encrypted
92
+ // file + set the same passphrase elsewhere and it still decrypts) - so when
93
+ // one is set, derive the key from ONLY the passphrase, no machine binding.
74
94
  const passphrase = process.env.FINCH_WALLET_PASSPHRASE ?? "";
95
+ if (passphrase) {
96
+ return crypto.createHash("sha256").update(passphrase).digest("hex").slice(0, 32);
97
+ }
98
+ // Without a passphrase, this is convenience-only encryption - it still
99
+ // can't stop an attacker who obtains BOTH files (the encrypted wallet and
100
+ // this local secret), the same as any locally-stored key material. What it
101
+ // does stop is the weaker, more common case this used to be vulnerable to:
102
+ // hostname/platform/arch alone are public/guessable, so a copy of just the
103
+ // wallet file (backup sync, stolen disk, malware scraping known paths) used
104
+ // to be enough to brute-force the key offline. Folding in a random,
105
+ // file-local secret means the wallet file alone is no longer sufficient.
106
+ return crypto
107
+ .createHash("sha256")
108
+ .update(getOrCreateLocalSecret() + os.hostname() + os.platform() + os.arch())
109
+ .digest("hex")
110
+ .slice(0, 32);
111
+ }
112
+ /** Pre-entropy-fix no-passphrase key: machine info only, no local secret.
113
+ * Kept solely so wallets encrypted before this fix still open; migrated to
114
+ * the new scheme in place on first successful decrypt, same pattern as
115
+ * getLegacyMachineKey below. */
116
+ function getLegacyMachineOnlyKey() {
117
+ return crypto
118
+ .createHash("sha256")
119
+ .update(os.hostname() + os.platform() + os.arch())
120
+ .digest("hex")
121
+ .slice(0, 32);
122
+ }
123
+ /**
124
+ * Pre-fix key derivation: always folded in machine info even when a
125
+ * passphrase was set, so a wallet encrypted before this fix can only ever be
126
+ * decrypted on the exact machine that created it - never portable. Kept
127
+ * solely so those existing wallets still open; getOrCreateWallet migrates
128
+ * them to the portable scheme in place on first successful decrypt.
129
+ *
130
+ * `passphrase` is a parameter, not read from env, because the whole point of
131
+ * this fallback is testing what the file was ACTUALLY encrypted with - which
132
+ * may not be today's FINCH_WALLET_PASSPHRASE. The most common case this
133
+ * exists for: a user who never set a passphrase before (so the file was
134
+ * encrypted with "" + machine info) setting one for the FIRST time just now -
135
+ * at that moment env has the new passphrase, but the file predates it.
136
+ */
137
+ function getLegacyMachineKey(passphrase) {
75
138
  return crypto
76
139
  .createHash("sha256")
77
140
  .update(passphrase + os.hostname() + os.platform() + os.arch())
@@ -84,13 +147,34 @@ function warnIfNoPassphrase() {
84
147
  return;
85
148
  _passphraseWarned = true;
86
149
  // stderr only - stdout is reserved for MCP JSON-RPC framing when running as a server.
87
- process.stderr.write("\n⚠️ FINCH_WALLET_PASSPHRASE is not set. Your Base mainnet wallet " +
88
- `(${WALLET_FILE}) is encrypted with a key derived only from this machine's ` +
89
- "hostname/platform/arch - low entropy, and crackable by anyone who copies the " +
90
- "file (backup sync, stolen disk, malware). Set FINCH_WALLET_PASSPHRASE to a " +
91
- "strong secret for real protection. This wallet holds real funds.\n\n");
150
+ process.stderr.write("\n⚠️ FINCH_WALLET_PASSPHRASE is not set. Your local wallet " +
151
+ `(${WALLET_FILE}, used on both Base and Robinhood Chain) is encrypted with a key derived from a random ` +
152
+ `per-install secret (${LOCAL_SECRET_FILE}) plus this machine's hostname/platform/arch. That stops the wallet ` +
153
+ "file alone from being crackable, but anyone who copies BOTH files together (backup sync, stolen disk, " +
154
+ "malware) still gets the wallet. Set FINCH_WALLET_PASSPHRASE to a strong secret you keep out of that backup " +
155
+ "for real protection. This wallet holds real funds.\n\n");
92
156
  }
157
+ let _walletCreationPromise = null;
93
158
  async function getOrCreateWallet() {
159
+ if (_cachedWallet)
160
+ return _cachedWallet;
161
+ // In-process mutex: two concurrent first-run callers (before _cachedWallet
162
+ // is set) must not each independently generate + write their own random
163
+ // wallet - only one write can ever survive on disk, and the loser would go
164
+ // on signing in-memory with a keypair that no longer matches what's
165
+ // persisted, silently switching the user's wallet identity mid-session.
166
+ // Chain all concurrent first-run callers through one shared promise.
167
+ if (_walletCreationPromise)
168
+ return _walletCreationPromise;
169
+ _walletCreationPromise = loadOrCreateWallet();
170
+ try {
171
+ return await _walletCreationPromise;
172
+ }
173
+ finally {
174
+ _walletCreationPromise = null;
175
+ }
176
+ }
177
+ async function loadOrCreateWallet() {
94
178
  if (_cachedWallet)
95
179
  return _cachedWallet;
96
180
  warnIfNoPassphrase();
@@ -101,27 +185,101 @@ async function getOrCreateWallet() {
101
185
  _cachedWallet = wallet;
102
186
  return wallet;
103
187
  }
104
- catch (err) {
105
- // A wallet file already exists but couldn't be decrypted - this almost
106
- // always means FINCH_WALLET_PASSPHRASE (or the machine info the key
107
- // is derived from) doesn't match what encrypted it. Silently creating
108
- // a fresh wallet here would overwrite the existing encrypted file,
109
- // orphaning it and any funds it controls. Refuse instead.
110
- throw new Error(`Could not decrypt existing wallet at ${WALLET_FILE}: ${err?.message ?? "unknown error"}\n\n` +
111
- `This usually means FINCH_WALLET_PASSPHRASE doesn't match the passphrase ` +
112
- `used when this wallet was encrypted (or this is a different machine). ` +
113
- `Refusing to auto-create a replacement wallet, since that would silently ` +
114
- `orphan the existing one and any funds it holds.\n\n` +
115
- `If you're sure this wallet should be abandoned, move or delete ${WALLET_FILE} manually first.`);
188
+ catch (currentErr) {
189
+ // Fall back to the pre-fix machine-bound scheme, for wallets encrypted
190
+ // before passphrases became portable. Only relevant on the ORIGINAL
191
+ // machine (it still needs that machine's hostname/platform/arch) - it
192
+ // can't rescue a wallet file copied to a new machine from before this
193
+ // fix; there was no passphrase-only secret saved anywhere to recover.
194
+ //
195
+ // Try two legacy candidates: today's passphrase (in case it was already
196
+ // set when this file was encrypted) and "" (the common case - a user
197
+ // setting FINCH_WALLET_PASSPHRASE for the first time, whose existing
198
+ // file predates having any passphrase at all).
199
+ const legacyCandidates = [...new Set([process.env.FINCH_WALLET_PASSPHRASE ?? "", ""])];
200
+ let legacyWallet = null;
201
+ for (const candidate of legacyCandidates) {
202
+ try {
203
+ legacyWallet = await ethers_1.ethers.Wallet.fromEncryptedJson(encrypted, getLegacyMachineKey(candidate));
204
+ break;
205
+ }
206
+ catch { /* try next candidate */ }
207
+ }
208
+ if (legacyWallet) {
209
+ _cachedWallet = legacyWallet;
210
+ // Migrate in place to the portable scheme now that we've proven we
211
+ // hold the right key, so this only ever needs to happen once.
212
+ try {
213
+ const migrated = await legacyWallet.encrypt(getMachineKey());
214
+ fs.writeFileSync(WALLET_FILE, migrated, { mode: 0o600 });
215
+ process.stderr.write(`\nMigrated ${WALLET_FILE} to the portable passphrase scheme.\n\n`);
216
+ }
217
+ catch {
218
+ /* migration is best-effort - the legacy key still works next run either way */
219
+ }
220
+ return legacyWallet;
221
+ }
222
+ // Third tier: no passphrase ever set, and this wallet predates the fix
223
+ // that folds a random local secret into the no-passphrase key (it was
224
+ // encrypted with hostname/platform/arch alone). Try that exact old
225
+ // derivation before giving up.
226
+ if (!process.env.FINCH_WALLET_PASSPHRASE) {
227
+ try {
228
+ const oldNoPassWallet = await ethers_1.ethers.Wallet.fromEncryptedJson(encrypted, getLegacyMachineOnlyKey());
229
+ _cachedWallet = oldNoPassWallet;
230
+ try {
231
+ const migrated = await oldNoPassWallet.encrypt(getMachineKey());
232
+ fs.writeFileSync(WALLET_FILE, migrated, { mode: 0o600 });
233
+ process.stderr.write(`\nMigrated ${WALLET_FILE} to the higher-entropy no-passphrase scheme.\n\n`);
234
+ }
235
+ catch {
236
+ /* migration is best-effort - the legacy key still works next run either way */
237
+ }
238
+ return oldNoPassWallet;
239
+ }
240
+ catch { /* not this scheme either - fall through to the hard failure below */ }
241
+ }
242
+ {
243
+ // A wallet file already exists but couldn't be decrypted under either
244
+ // scheme - this almost always means FINCH_WALLET_PASSPHRASE doesn't
245
+ // match what encrypted it, or this is a different machine and no
246
+ // passphrase was ever set. Silently creating a fresh wallet here
247
+ // would overwrite the existing encrypted file, orphaning it and any
248
+ // funds it controls. Refuse instead.
249
+ throw new Error(`Could not decrypt existing wallet at ${WALLET_FILE}: ${currentErr?.message ?? "unknown error"}\n\n` +
250
+ `This usually means FINCH_WALLET_PASSPHRASE doesn't match the passphrase ` +
251
+ `used when this wallet was encrypted, or this file was copied from a ` +
252
+ `different machine and no passphrase was set when it was created (in ` +
253
+ `which case the key was machine-bound and cannot be recovered elsewhere). ` +
254
+ `Refusing to auto-create a replacement wallet, since that would silently ` +
255
+ `orphan the existing one and any funds it holds.\n\n` +
256
+ `If you're sure this wallet should be abandoned, move or delete ${WALLET_FILE} manually first.`);
257
+ }
116
258
  }
117
259
  }
118
260
  const wallet = ethers_1.ethers.Wallet.createRandom();
119
261
  if (!fs.existsSync(WALLET_DIR))
120
262
  fs.mkdirSync(WALLET_DIR, { recursive: true });
121
263
  const encrypted = await wallet.encrypt(getMachineKey());
122
- fs.writeFileSync(WALLET_FILE, encrypted, { mode: 0o600 });
123
- _cachedWallet = wallet;
124
- return wallet;
264
+ try {
265
+ // Exclusive create ("wx") - guards the cross-process version of the race
266
+ // the in-process mutex above already closes: two separate `finch`
267
+ // invocations racing on the very first run, before this file exists.
268
+ fs.writeFileSync(WALLET_FILE, encrypted, { mode: 0o600, flag: "wx" });
269
+ _cachedWallet = wallet;
270
+ return wallet;
271
+ }
272
+ catch (writeErr) {
273
+ if (writeErr?.code !== "EEXIST")
274
+ throw writeErr;
275
+ // Another process won the race and created the file first. Use theirs -
276
+ // never sign with the wallet we generated in memory once it's clear it
277
+ // isn't the one actually persisted to disk.
278
+ const encryptedExisting = fs.readFileSync(WALLET_FILE, "utf8");
279
+ const existingWallet = await ethers_1.ethers.Wallet.fromEncryptedJson(encryptedExisting, getMachineKey());
280
+ _cachedWallet = existingWallet;
281
+ return existingWallet;
282
+ }
125
283
  }
126
284
  async function signRequest(toolName) {
127
285
  const wallet = await getOrCreateWallet();
@@ -161,6 +319,36 @@ async function broadcastTx(signedTx) {
161
319
  throw new Error(`broadcast failed: ${data.error.message}`);
162
320
  return data.result;
163
321
  }
322
+ /**
323
+ * Poll for the mined receipt and report the ACTUAL on-chain outcome.
324
+ *
325
+ * eth_sendRawTransaction only confirms the mempool accepted the tx, not that
326
+ * it succeeded - a stale quote, slippage beyond the router's own guard, or an
327
+ * allowance edge case can all revert on-chain while still broadcasting fine.
328
+ * Callers must gate their success/failure message on this, not on
329
+ * signAndBroadcast() returning a hash.
330
+ */
331
+ async function waitForReceipt(txHash, timeoutMs = 60000, pollMs = 2000) {
332
+ const deadline = Date.now() + timeoutMs;
333
+ while (Date.now() < deadline) {
334
+ try {
335
+ const receipt = await rpcPost("eth_getTransactionReceipt", [txHash]);
336
+ if (receipt) {
337
+ return {
338
+ mined: true,
339
+ ok: parseInt(receipt.status, 16) === 1,
340
+ blockNumber: parseInt(receipt.blockNumber, 16),
341
+ gasUsed: receipt.gasUsed ? BigInt(receipt.gasUsed).toString() : undefined,
342
+ };
343
+ }
344
+ }
345
+ catch {
346
+ /* transient RPC error - keep polling until timeout */
347
+ }
348
+ await new Promise((r) => setTimeout(r, pollMs));
349
+ }
350
+ return { mined: false };
351
+ }
164
352
  async function signAndBroadcast(wallet, txData) {
165
353
  let data = txData.data || "0x";
166
354
  if (txData.permit2?.eip712) {