@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.
- package/LICENSE +1 -1
- package/README.md +96 -249
- package/dist/agent-loop.js +139 -69
- package/dist/annotations.js +14 -13
- package/dist/cli.js +78 -21
- package/dist/config.js +16 -16
- package/dist/convex.js +15 -18
- package/dist/index.js +4 -5
- package/dist/llm.js +24 -49
- package/dist/local-memory-file.js +147 -0
- package/dist/local-memory.js +42 -9
- package/dist/output-schemas.js +71 -17
- package/dist/resources.js +8 -13
- package/dist/server.js +27 -13
- package/dist/token-gate.js +2 -2
- package/dist/tool-filter.js +13 -4
- package/dist/tools/agents.js +66 -394
- package/dist/tools/base-mcp.js +7 -19
- package/dist/tools/base.js +34 -20
- package/dist/tools/chronicle.js +4 -4
- package/dist/tools/deep-research.js +10 -5
- package/dist/tools/defi.js +36 -35
- package/dist/tools/equity.js +9 -1
- package/dist/tools/insight.js +25 -29
- package/dist/tools/memory.js +76 -108
- package/dist/tools/monitor.js +7 -7
- package/dist/tools/os.js +10 -6
- package/dist/tools/packets.js +5 -5
- package/dist/tools/research.js +2 -2
- package/dist/tools/rh-mcp.js +25 -2
- package/dist/tools/rh-orders.js +201 -123
- package/dist/tools/scanner.js +30 -0
- package/dist/tools/stake.js +329 -0
- package/dist/tools/vault.js +122 -42
- package/dist/wallet.js +212 -24
- package/package.json +9 -21
- package/dist/tools/framework.js +0 -150
package/dist/tools/vault.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
62
|
-
"
|
|
63
|
-
"
|
|
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 -
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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}"\` —
|
|
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
|
-
//
|
|
387
|
-
//
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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 {
|
|
438
|
-
|
|
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 {
|
|
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
|
-
//
|
|
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
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
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
|
|
88
|
-
`(${WALLET_FILE}) is encrypted with a key derived
|
|
89
|
-
|
|
90
|
-
"file (backup sync, stolen disk,
|
|
91
|
-
"strong secret
|
|
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 (
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
// a
|
|
109
|
-
//
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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) {
|