@blockrun/mcp 0.23.2 → 0.24.1

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.
Files changed (2) hide show
  1. package/dist/index.js +551 -384
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -7,8 +7,8 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
7
7
 
8
8
  // src/utils/wallet.ts
9
9
  import fs from "fs";
10
- import path from "path";
11
- import os from "os";
10
+ import path2 from "path";
11
+ import os2 from "os";
12
12
  import {
13
13
  LLMClient,
14
14
  ImageClient,
@@ -20,13 +20,41 @@ import {
20
20
  loadSolanaWallet,
21
21
  getPaymentLinks,
22
22
  formatWalletCreatedMessage,
23
- formatNeedsFundingMessage,
24
- SOLANA_WALLET_FILE_PATH
23
+ formatNeedsFundingMessage
25
24
  } from "@blockrun/llm";
26
- var BLOCKRUN_DIR = path.join(os.homedir(), ".blockrun");
25
+
26
+ // src/utils/constants.ts
27
+ import * as path from "path";
28
+ import * as os from "os";
29
+ var WALLET_DIR = path.join(os.homedir(), ".blockrun");
30
+ var WALLET_FILE = path.join(WALLET_DIR, ".session");
31
+ var QR_FILE = path.join(WALLET_DIR, "qr.png");
32
+ var USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
33
+ var BASE_CHAIN_ID = "8453";
34
+ var BASE_RPC_URLS = [
35
+ "https://mainnet.base.org",
36
+ "https://base.llamarpc.com",
37
+ "https://1rpc.io/base"
38
+ ];
39
+ var MODEL_TIERS = {
40
+ fast: ["google/gemini-3.5-flash", "google/gemini-2.5-flash", "google/gemini-3.1-flash-lite", "openai/gpt-5-mini", "deepseek/deepseek-chat", "google/gemini-3-flash-preview"],
41
+ balanced: ["openai/gpt-5.5", "anthropic/claude-sonnet-4.6", "google/gemini-3.1-pro", "moonshot/kimi-k2.6", "openai/gpt-5.3", "openai/gpt-5.4"],
42
+ powerful: ["anthropic/claude-opus-4.8", "openai/gpt-5.4-pro", "anthropic/claude-opus-4.7", "anthropic/claude-opus-4.6", "openai/o3", "openai/gpt-5.4"],
43
+ cheap: ["zai/glm-5", "zai/glm-5-turbo", "nvidia/gpt-oss-120b", "nvidia/deepseek-v4-flash", "google/gemini-2.5-flash", "deepseek/deepseek-chat", "openai/gpt-5.4-nano"],
44
+ reasoning: ["anthropic/claude-opus-4.8", "openai/o3", "openai/o1", "openai/o3-mini", "deepseek/deepseek-reasoner", "moonshot/kimi-k2.6", "openai/gpt-5.3-codex"],
45
+ // 2026-06-07 sweep: dropped qwen3-next (NVIDIA EOL, 410), mistral-small-4-119b
46
+ // (timing out), deepseek-v3.2 + glm-4.7 (NIM hung). All redirect server-side
47
+ // anyway; these are the free models actually serving themselves.
48
+ free: ["nvidia/llama-4-maverick", "nvidia/qwen3-coder-480b", "nvidia/deepseek-v4-flash", "nvidia/gpt-oss-120b", "nvidia/gpt-oss-20b"],
49
+ coding: ["anthropic/claude-opus-4.8", "zai/glm-5", "openai/gpt-5.3-codex", "moonshot/kimi-k2.6", "nvidia/qwen3-coder-480b", "anthropic/claude-sonnet-4.6", "openai/gpt-5.4"],
50
+ glm: ["zai/glm-5", "zai/glm-5-turbo"]
51
+ };
52
+
53
+ // src/utils/wallet.ts
54
+ var BLOCKRUN_DIR = path2.join(os2.homedir(), ".blockrun");
27
55
  var CHAIN_PREFERENCE_FILES = [
28
- path.join(BLOCKRUN_DIR, ".chain"),
29
- path.join(BLOCKRUN_DIR, "payment-chain")
56
+ path2.join(BLOCKRUN_DIR, ".chain"),
57
+ path2.join(BLOCKRUN_DIR, "payment-chain")
30
58
  ];
31
59
  var _evmClient = null;
32
60
  var _imageClient = null;
@@ -51,12 +79,12 @@ function getChain() {
51
79
  if (preferred) return preferred;
52
80
  if (process.env.SOLANA_WALLET_KEY) return "solana";
53
81
  try {
54
- if (fs.existsSync(SOLANA_WALLET_FILE_PATH)) return "solana";
82
+ if (loadSolanaWallet()) return "solana";
55
83
  } catch {
56
84
  }
57
85
  return "base";
58
86
  }
59
- var CHAIN_FILE = path.join(BLOCKRUN_DIR, ".chain");
87
+ var CHAIN_FILE = path2.join(BLOCKRUN_DIR, ".chain");
60
88
  function resetChainCaches() {
61
89
  _evmClient = null;
62
90
  _solanaClient = null;
@@ -125,6 +153,10 @@ function buildClientWithTimeout(timeoutMs) {
125
153
  const privateKey = getOrCreateWalletKey();
126
154
  return new LLMClient({ privateKey, timeout: timeoutMs });
127
155
  }
156
+ function buildClient() {
157
+ if (getChain() === "solana") return buildSolanaClient();
158
+ return new LLMClient({ privateKey: getOrCreateWalletKey() });
159
+ }
128
160
  function getAnthropicClient() {
129
161
  if (!_anthropicClient) {
130
162
  const privateKey = getOrCreateWalletKey();
@@ -185,17 +217,15 @@ async function getSolanaUsdcBalance() {
185
217
  return null;
186
218
  }
187
219
  }
220
+ function parseBaseUsdcCallResult(raw) {
221
+ if (typeof raw !== "string" || !/^0x[0-9a-fA-F]+$/.test(raw)) return null;
222
+ return Number(BigInt(raw)) / 1e6;
223
+ }
188
224
  async function getBaseUsdcBalance(address) {
189
- const USDC_ADDRESS2 = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
190
- const BASE_RPC_URLS = [
191
- "https://mainnet.base.org",
192
- "https://base.llamarpc.com",
193
- "https://1rpc.io/base"
194
- ];
195
225
  const data = {
196
226
  jsonrpc: "2.0",
197
227
  method: "eth_call",
198
- params: [{ to: USDC_ADDRESS2, data: `0x70a08231000000000000000000000000${address.slice(2)}` }, "latest"],
228
+ params: [{ to: USDC_ADDRESS, data: `0x70a08231000000000000000000000000${address.slice(2)}` }, "latest"],
199
229
  id: 1
200
230
  };
201
231
  for (const rpcUrl of BASE_RPC_URLS) {
@@ -203,10 +233,12 @@ async function getBaseUsdcBalance(address) {
203
233
  const response = await fetch(rpcUrl, {
204
234
  method: "POST",
205
235
  headers: { "Content-Type": "application/json" },
206
- body: JSON.stringify(data)
236
+ body: JSON.stringify(data),
237
+ signal: AbortSignal.timeout(8e3)
207
238
  });
208
239
  const result = await response.json();
209
- if (result.result) return parseInt(result.result, 16) / 1e6;
240
+ const usd = parseBaseUsdcCallResult(result.result);
241
+ if (usd !== null) return usd;
210
242
  } catch {
211
243
  continue;
212
244
  }
@@ -220,7 +252,7 @@ async function getChainBalance(chain, address) {
220
252
  // src/utils/model-cache.ts
221
253
  var CACHE_TTL_MS = 5 * 60 * 1e3;
222
254
  async function loadModels(llm, cache) {
223
- if (!cache.models) {
255
+ if (cache.models === null || cache.models.length === 0) {
224
256
  cache.models = llm.listAllModels ? await llm.listAllModels() : await llm.listModels();
225
257
  setTimeout(() => {
226
258
  cache.models = null;
@@ -255,6 +287,25 @@ function checkBudget(budget, agentId, estimatedCost = 1e-3) {
255
287
  }
256
288
  return { allowed: true };
257
289
  }
290
+ function reserveBudget(budget, agentId, estimatedCost = 1e-3) {
291
+ const check = checkBudget(budget, agentId, estimatedCost);
292
+ if (!check.allowed) return { allowed: false, reason: check.reason, release: () => {
293
+ } };
294
+ const cost = Math.max(0, estimatedCost);
295
+ budget.spent += cost;
296
+ const agentBudget = agentId ? budget.agents.get(agentId) : void 0;
297
+ if (agentBudget) agentBudget.spent += cost;
298
+ let released = false;
299
+ return {
300
+ allowed: true,
301
+ release: () => {
302
+ if (released) return;
303
+ released = true;
304
+ budget.spent -= cost;
305
+ if (agentBudget) agentBudget.spent -= cost;
306
+ }
307
+ };
308
+ }
258
309
  function recordSpending(budget, cost, agentId) {
259
310
  budget.spent += cost;
260
311
  budget.calls += 1;
@@ -289,30 +340,6 @@ import QRCode from "qrcode";
289
340
  import open from "open";
290
341
  import * as fs2 from "fs";
291
342
  import * as path3 from "path";
292
-
293
- // src/utils/constants.ts
294
- import * as path2 from "path";
295
- import * as os2 from "os";
296
- var WALLET_DIR = path2.join(os2.homedir(), ".blockrun");
297
- var WALLET_FILE = path2.join(WALLET_DIR, ".session");
298
- var QR_FILE = path2.join(WALLET_DIR, "qr.png");
299
- var USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
300
- var BASE_CHAIN_ID = "8453";
301
- var MODEL_TIERS = {
302
- fast: ["google/gemini-3.5-flash", "google/gemini-2.5-flash", "google/gemini-3.1-flash-lite", "openai/gpt-5-mini", "deepseek/deepseek-chat", "google/gemini-3-flash-preview"],
303
- balanced: ["openai/gpt-5.5", "anthropic/claude-sonnet-4.6", "google/gemini-3.1-pro", "moonshot/kimi-k2.6", "openai/gpt-5.3", "openai/gpt-5.4"],
304
- powerful: ["anthropic/claude-opus-4.8", "openai/gpt-5.4-pro", "anthropic/claude-opus-4.7", "anthropic/claude-opus-4.6", "openai/o3", "openai/gpt-5.4"],
305
- cheap: ["zai/glm-5", "zai/glm-5-turbo", "nvidia/gpt-oss-120b", "nvidia/deepseek-v4-flash", "google/gemini-2.5-flash", "deepseek/deepseek-chat", "openai/gpt-5.4-nano"],
306
- reasoning: ["anthropic/claude-opus-4.8", "openai/o3", "openai/o1", "openai/o3-mini", "deepseek/deepseek-reasoner", "moonshot/kimi-k2.6", "openai/gpt-5.3-codex"],
307
- // 2026-06-07 sweep: dropped qwen3-next (NVIDIA EOL, 410), mistral-small-4-119b
308
- // (timing out), deepseek-v3.2 + glm-4.7 (NIM hung). All redirect server-side
309
- // anyway; these are the free models actually serving themselves.
310
- free: ["nvidia/llama-4-maverick", "nvidia/qwen3-coder-480b", "nvidia/deepseek-v4-flash", "nvidia/gpt-oss-120b", "nvidia/gpt-oss-20b"],
311
- coding: ["anthropic/claude-opus-4.8", "zai/glm-5", "openai/gpt-5.3-codex", "moonshot/kimi-k2.6", "nvidia/qwen3-coder-480b", "anthropic/claude-sonnet-4.6", "openai/gpt-5.4"],
312
- glm: ["zai/glm-5", "zai/glm-5-turbo"]
313
- };
314
-
315
- // src/utils/qr.ts
316
343
  var SOLANA_USDC_MINT = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
317
344
  var sharpModule;
318
345
  async function loadSharp() {
@@ -423,9 +450,13 @@ ${parts.join("\n")}`;
423
450
  }
424
451
  return base;
425
452
  }
453
+ function isPaymentRejectionError(message) {
454
+ const m = message.toLowerCase();
455
+ return m.includes("insufficient") || m.includes("balance") || m.includes("rejected");
456
+ }
426
457
  function formatError(message, opts) {
427
458
  const msgLower = message.toLowerCase();
428
- const hasStatus = (code) => new RegExp(`(^|[^0-9.])${code}([^0-9]|$)`).test(msgLower);
459
+ const hasStatus = (code) => new RegExp(`(^|[^0-9.])${code}($|[^0-9.])`).test(msgLower);
429
460
  const isPaymentError = hasStatus("402") || msgLower.includes("balance") || msgLower.includes("insufficient") || msgLower.includes("payment") && !hasStatus("500");
430
461
  const isModelUnavailable = msgLower.includes("not active for requested provider") || msgLower.includes("not found or not active");
431
462
  const isServerError = hasStatus("500") || msgLower.includes("api error after payment");
@@ -814,6 +845,7 @@ async function handleAnthropicNative(args) {
814
845
  temperature,
815
846
  stop,
816
847
  thinking,
848
+ responseFormat,
817
849
  budget,
818
850
  agentId,
819
851
  estimatedCost
@@ -829,6 +861,9 @@ async function handleAnthropicNative(args) {
829
861
  }
830
862
  apiMessages.push({ role: m.role, content: toAnthropicContent(m.content) });
831
863
  }
864
+ if (responseFormat?.type === "json_object") {
865
+ systemParts.push("Respond with only valid JSON. Do not wrap it in markdown code fences or add any prose before or after.");
866
+ }
832
867
  if (message.trim()) apiMessages.push({ role: "user", content: message });
833
868
  if (apiMessages.length === 0) {
834
869
  return { content: [{ type: "text", text: "No message content to send." }], isError: true };
@@ -898,7 +933,6 @@ ${thinkingText}` });
898
933
  function estimateChatCost(maxTokens, mode, model, routing, routingProfile) {
899
934
  if (mode === "free") return 0;
900
935
  if (model?.startsWith("nvidia/")) return 0;
901
- if (routing === "smart" && routingProfile === "free") return 0;
902
936
  const out = Math.max(maxTokens ?? 1024, 256);
903
937
  const frontierReserve = Math.max(0.01, out / 1e6 * 20);
904
938
  if (routing === "smart") {
@@ -944,7 +978,7 @@ Run blockrun_models to see all available models with pricing.`,
944
978
  model: z2.string().optional().describe("Specific model ID (e.g., 'zai/glm-5', 'openai/o3')"),
945
979
  mode: z2.enum(["fast", "balanced", "powerful", "cheap", "reasoning", "free", "coding", "glm"]).optional().describe("Routing mode: glm = Zhipu GLM-5/GLM-5-Turbo ($0.001/call, great for coding), coding = GLM-5 + code models, cheap = GLM-5 + budget, free = NVIDIA only (ignored if model specified)"),
946
980
  routing: z2.enum(["smart"]).optional().describe('Set to "smart" to auto-select the optimal model via ClawRouter (14-dimension AI routing)'),
947
- routing_profile: z2.enum(["free", "eco", "auto", "premium"]).optional().default("auto").describe('Cost/quality profile for ClawRouter: "free" (zero cost NVIDIA), "eco" (budget), "auto" (balanced, default), "premium" (best quality) (only applies when routing: "smart")'),
981
+ routing_profile: z2.enum(["free", "eco", "auto", "premium"]).optional().default("auto").describe('Cost/quality profile for ClawRouter: "eco" (budget), "auto" (balanced, default), "premium" (best quality). Note: "free" maps to "auto" (the SDK dropped the free profile) and still settles a PAID model \u2014 for zero-cost generation use mode:"free" or model:"nvidia/...". Only applies when routing:"smart".'),
948
982
  system: z2.string().optional().describe("Optional system prompt"),
949
983
  max_tokens: z2.number().optional().default(1024).describe("Max tokens in response"),
950
984
  temperature: z2.number().optional().default(1).describe("Creativity 0-2"),
@@ -968,144 +1002,155 @@ Run blockrun_models to see all available models with pricing.`,
968
1002
  }
969
1003
  },
970
1004
  async ({ message, model, mode, routing, routing_profile, system, max_tokens, temperature, response_format, stop, thinking, agent_id, messages }) => {
971
- const llm = getClient();
1005
+ const llm = buildClient();
972
1006
  const responseFormat = response_format ? { type: response_format } : void 0;
973
1007
  const estimatedCost = estimateChatCost(max_tokens, mode, model, routing, routing_profile);
974
- const budgetCheck = checkBudget(budget, agent_id, estimatedCost);
975
- if (!budgetCheck.allowed) {
1008
+ const gate = reserveBudget(budget, agent_id, estimatedCost);
1009
+ if (!gate.allowed) {
976
1010
  return {
977
- content: [{ type: "text", text: `${budgetCheck.reason}. Use blockrun_wallet with action: "report" to see usage, or action: "delegate" to increase agent budget.` }],
1011
+ content: [{ type: "text", text: `${gate.reason}. Use blockrun_wallet with action: "report" to see usage, or action: "delegate" to increase agent budget.` }],
978
1012
  isError: true
979
1013
  };
980
1014
  }
981
- if (model && isAnthropicModel(model)) {
982
- const solanaBlock = baseOnlyMessage("Native Anthropic (claude-*) calls");
983
- if (solanaBlock) {
984
- return { content: [{ type: "text", text: solanaBlock }], isError: true };
985
- }
986
- return handleAnthropicNative({
987
- client: getAnthropicClient(),
988
- model,
989
- message,
990
- system,
991
- messages,
992
- maxTokens: max_tokens,
993
- temperature,
994
- stop,
995
- thinking,
996
- budget,
997
- agentId: agent_id,
998
- estimatedCost
999
- });
1000
- }
1001
- if (routing === "smart") {
1002
- if (!(llm instanceof LLMClient2)) {
1003
- return {
1004
- content: [{ type: "text", text: "Smart routing (ClawRouter) is not available on Solana. Use a specific model or mode instead." }],
1005
- isError: true
1006
- };
1007
- }
1008
- try {
1009
- const { result, settledUsd } = await withSettledCost(llm, () => llm.smartChat(message, {
1015
+ try {
1016
+ if (model && isAnthropicModel(model)) {
1017
+ const solanaBlock = baseOnlyMessage("Native Anthropic (claude-*) calls");
1018
+ if (solanaBlock) {
1019
+ return { content: [{ type: "text", text: solanaBlock }], isError: true };
1020
+ }
1021
+ return await handleAnthropicNative({
1022
+ client: getAnthropicClient(),
1023
+ model,
1024
+ message,
1010
1025
  system,
1026
+ messages,
1011
1027
  maxTokens: max_tokens,
1012
- maxOutputTokens: max_tokens,
1013
1028
  temperature,
1014
- // @blockrun/llm 2.x dropped the "free" routing profile; the gateway
1015
- // already routes to the most cost-effective model by default, so we
1016
- // omit it and let ClawRouter pick (matches the SDK upgrade path).
1017
- routingProfile: routing_profile === "free" ? void 0 : routing_profile,
1029
+ stop,
1030
+ thinking,
1018
1031
  responseFormat,
1019
- stop
1020
- }));
1021
- recordActualSpend(budget, settledUsd, result.routing.costEstimate || estimatedCost, agent_id);
1022
- return {
1023
- content: [{ type: "text", text: `[${result.model} | ${result.routing.tier} | $${result.routing.costEstimate.toFixed(4)} | ${Math.round((result.routing.savings ?? 0) * 100)}% savings]
1032
+ budget,
1033
+ agentId: agent_id,
1034
+ estimatedCost
1035
+ });
1036
+ }
1037
+ if (routing === "smart") {
1038
+ if (messages && messages.length > 0) {
1039
+ return {
1040
+ content: [{ type: "text", text: formatError('routing:"smart" does not support multi-turn `messages` \u2014 smart routing answers a single prompt. Send the conversation via `messages` with an explicit `model`/`mode` (no routing), or send a single `message` with routing:"smart".') }],
1041
+ isError: true
1042
+ };
1043
+ }
1044
+ if (!(llm instanceof LLMClient2)) {
1045
+ return {
1046
+ content: [{ type: "text", text: "Smart routing (ClawRouter) is not available on Solana. Use a specific model or mode instead." }],
1047
+ isError: true
1048
+ };
1049
+ }
1050
+ try {
1051
+ const { result, settledUsd } = await withSettledCost(llm, () => llm.smartChat(message, {
1052
+ system,
1053
+ maxTokens: max_tokens,
1054
+ maxOutputTokens: max_tokens,
1055
+ temperature,
1056
+ // @blockrun/llm 2.x dropped the "free" routing profile; the gateway
1057
+ // already routes to the most cost-effective model by default, so we
1058
+ // omit it and let ClawRouter pick (matches the SDK upgrade path).
1059
+ routingProfile: routing_profile === "free" ? void 0 : routing_profile,
1060
+ responseFormat,
1061
+ stop
1062
+ }));
1063
+ recordActualSpend(budget, settledUsd, result.routing.costEstimate || estimatedCost, agent_id);
1064
+ return {
1065
+ content: [{ type: "text", text: `[${result.model} | ${result.routing.tier} | $${result.routing.costEstimate.toFixed(4)} | ${Math.round((result.routing.savings ?? 0) * 100)}% savings]
1024
1066
 
1025
1067
  ${result.response}` }],
1026
- structuredContent: {
1027
- model_used: result.model,
1028
- response: result.response,
1029
- routing: result.routing
1030
- }
1031
- };
1032
- } catch (error) {
1033
- return { content: [{ type: "text", text: formatError(extractErrorMessage(error)) }], isError: true };
1068
+ structuredContent: {
1069
+ model_used: result.model,
1070
+ response: result.response,
1071
+ routing: result.routing
1072
+ }
1073
+ };
1074
+ } catch (error) {
1075
+ return { content: [{ type: "text", text: formatError(extractErrorMessage(error)) }], isError: true };
1076
+ }
1034
1077
  }
1035
- }
1036
- if (messages && messages.length > 0) {
1037
- const targetModel = model || MODEL_TIERS[mode ?? "balanced"]?.[0] || "openai/gpt-5.5";
1038
- const fullMessages = [
1039
- ...system ? [{ role: "system", content: system }] : [],
1040
- ...messages,
1041
- { role: "user", content: message }
1042
- ];
1043
- try {
1044
- const { result, settledUsd } = await withSettledCost(llm, () => llm.chatCompletion(targetModel, fullMessages, {
1045
- maxTokens: max_tokens,
1046
- temperature,
1047
- responseFormat,
1048
- stop
1049
- }));
1050
- const reply = result.choices?.[0]?.message?.content || "";
1051
- recordActualSpend(budget, settledUsd, estimatedCost, agent_id);
1052
- return {
1053
- content: [{ type: "text", text: `[${targetModel} | ${fullMessages.length} msgs]
1078
+ if (messages && messages.length > 0) {
1079
+ const targetModel = model || MODEL_TIERS[mode ?? "balanced"]?.[0] || "openai/gpt-5.5";
1080
+ const fullMessages = [
1081
+ ...system ? [{ role: "system", content: system }] : [],
1082
+ ...messages,
1083
+ { role: "user", content: message }
1084
+ ];
1085
+ try {
1086
+ const { result, settledUsd } = await withSettledCost(llm, () => llm.chatCompletion(targetModel, fullMessages, {
1087
+ maxTokens: max_tokens,
1088
+ temperature,
1089
+ responseFormat,
1090
+ stop
1091
+ }));
1092
+ const reply = result.choices?.[0]?.message?.content || "";
1093
+ recordActualSpend(budget, settledUsd, estimatedCost, agent_id);
1094
+ return {
1095
+ content: [{ type: "text", text: `[${targetModel} | ${fullMessages.length} msgs]
1054
1096
 
1055
1097
  ${reply}` }],
1056
- structuredContent: { model_used: targetModel, response: reply, message_count: fullMessages.length }
1057
- };
1058
- } catch (error) {
1059
- return { content: [{ type: "text", text: formatError(extractErrorMessage(error)) }], isError: true };
1098
+ structuredContent: { model_used: targetModel, response: reply, message_count: fullMessages.length }
1099
+ };
1100
+ } catch (error) {
1101
+ return { content: [{ type: "text", text: formatError(extractErrorMessage(error)) }], isError: true };
1102
+ }
1060
1103
  }
1061
- }
1062
- if (model) {
1063
- try {
1064
- const { result: response, settledUsd } = await withSettledCost(llm, () => llm.chat(model, message, {
1065
- system,
1066
- maxTokens: max_tokens,
1067
- temperature,
1068
- responseFormat,
1069
- stop
1070
- }));
1071
- recordActualSpend(budget, settledUsd, estimatedCost, agent_id);
1072
- return { content: [{ type: "text", text: response }] };
1073
- } catch (error) {
1074
- return {
1075
- content: [{ type: "text", text: formatError(extractErrorMessage(error)) }],
1076
- isError: true
1077
- };
1104
+ if (model) {
1105
+ try {
1106
+ const { result: response, settledUsd } = await withSettledCost(llm, () => llm.chat(model, message, {
1107
+ system,
1108
+ maxTokens: max_tokens,
1109
+ temperature,
1110
+ responseFormat,
1111
+ stop
1112
+ }));
1113
+ recordActualSpend(budget, settledUsd, estimatedCost, agent_id);
1114
+ return { content: [{ type: "text", text: response }] };
1115
+ } catch (error) {
1116
+ return {
1117
+ content: [{ type: "text", text: formatError(extractErrorMessage(error)) }],
1118
+ isError: true
1119
+ };
1120
+ }
1078
1121
  }
1079
- }
1080
- const routingMode = mode || "balanced";
1081
- const models = MODEL_TIERS[routingMode];
1082
- let lastError = null;
1083
- for (const m of models) {
1084
- try {
1085
- const { result: response, settledUsd } = await withSettledCost(llm, () => llm.chat(m, message, {
1086
- system,
1087
- maxTokens: max_tokens,
1088
- temperature,
1089
- responseFormat,
1090
- stop
1091
- }));
1092
- recordActualSpend(budget, settledUsd, estimatedCost, agent_id);
1093
- return {
1094
- content: [{ type: "text", text: `[${m}]
1122
+ const routingMode = mode || "balanced";
1123
+ const models = MODEL_TIERS[routingMode];
1124
+ let lastError = null;
1125
+ for (const m of models) {
1126
+ try {
1127
+ const { result: response, settledUsd } = await withSettledCost(llm, () => llm.chat(m, message, {
1128
+ system,
1129
+ maxTokens: max_tokens,
1130
+ temperature,
1131
+ responseFormat,
1132
+ stop
1133
+ }));
1134
+ recordActualSpend(budget, settledUsd, estimatedCost, agent_id);
1135
+ return {
1136
+ content: [{ type: "text", text: `[${m}]
1095
1137
 
1096
1138
  ${response}` }],
1097
- structuredContent: { model_used: m, response }
1098
- };
1099
- } catch (error) {
1100
- lastError = error;
1101
- continue;
1139
+ structuredContent: { model_used: m, response }
1140
+ };
1141
+ } catch (error) {
1142
+ lastError = error;
1143
+ continue;
1144
+ }
1102
1145
  }
1146
+ const errorMessage = lastError ? extractErrorMessage(lastError) : "All models failed";
1147
+ return {
1148
+ content: [{ type: "text", text: formatError(errorMessage) }],
1149
+ isError: true
1150
+ };
1151
+ } finally {
1152
+ gate.release();
1103
1153
  }
1104
- const errorMessage = lastError ? extractErrorMessage(lastError) : "All models failed";
1105
- return {
1106
- content: [{ type: "text", text: formatError(errorMessage) }],
1107
- isError: true
1108
- };
1109
1154
  }
1110
1155
  );
1111
1156
  }
@@ -1174,6 +1219,41 @@ ${lines.join("\n")}` }],
1174
1219
  // src/tools/image.ts
1175
1220
  import { z as z4 } from "zod";
1176
1221
  import { PaymentError } from "@blockrun/llm";
1222
+
1223
+ // src/utils/ssrf.ts
1224
+ function ipv4Blocked(host) {
1225
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
1226
+ if (!m) return null;
1227
+ const o = m.slice(1).map(Number);
1228
+ if (o.some((n) => n > 255)) return true;
1229
+ const [a, b] = o;
1230
+ return a === 0 || // 0.0.0.0/8
1231
+ a === 127 || // loopback
1232
+ a === 10 || // private
1233
+ a === 172 && b >= 16 && b <= 31 || // private
1234
+ a === 192 && b === 168 || // private
1235
+ a === 169 && b === 254 || // link-local (incl. metadata)
1236
+ a === 100 && b >= 64 && b <= 127;
1237
+ }
1238
+ function isBlockedFetchHost(hostname) {
1239
+ let host = hostname.trim().toLowerCase();
1240
+ if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1);
1241
+ if (!host) return true;
1242
+ if (host === "localhost" || host.endsWith(".localhost")) return true;
1243
+ if (host.endsWith(".internal") || host.endsWith(".local")) return true;
1244
+ const v4 = ipv4Blocked(host);
1245
+ if (v4 !== null) return v4;
1246
+ if (host.includes(":")) {
1247
+ if (host === "::1" || host === "::") return true;
1248
+ if (host.startsWith("fc") || host.startsWith("fd") || host.startsWith("fe80")) return true;
1249
+ const mapped = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(host);
1250
+ if (mapped) return ipv4Blocked(mapped[1]) === true;
1251
+ return false;
1252
+ }
1253
+ return false;
1254
+ }
1255
+
1256
+ // src/tools/image.ts
1177
1257
  import { readFile } from "fs/promises";
1178
1258
  var REFERENCE_IMAGE_MAX_BYTES = 4e6;
1179
1259
  var IMAGE_EXT_MIME = {
@@ -1189,10 +1269,29 @@ async function toImageDataUri(ref) {
1189
1269
  const ctrl = new AbortController();
1190
1270
  const timeout = setTimeout(() => ctrl.abort(), 3e4);
1191
1271
  try {
1192
- const res = await fetch(ref, { signal: ctrl.signal });
1272
+ let url = ref;
1273
+ let res;
1274
+ for (let hop = 0; ; hop++) {
1275
+ const host = new URL(url).hostname;
1276
+ if (isBlockedFetchHost(host)) {
1277
+ throw new Error(`refusing to fetch a private/loopback/link-local address: ${host}`);
1278
+ }
1279
+ res = await fetch(url, { signal: ctrl.signal, redirect: "manual" });
1280
+ const location = res.headers.get("location");
1281
+ if (res.status >= 300 && res.status < 400 && location) {
1282
+ if (hop >= 5) throw new Error("too many redirects");
1283
+ url = new URL(location, url).toString();
1284
+ continue;
1285
+ }
1286
+ break;
1287
+ }
1193
1288
  if (!res.ok) throw new Error(`fetch failed: ${res.status} ${res.statusText}`);
1194
1289
  const mime2 = (res.headers.get("content-type") || "").toLowerCase().split(";")[0].trim();
1195
1290
  if (!mime2.startsWith("image/")) throw new Error(`URL returned non-image content-type: ${mime2 || "(none)"}`);
1291
+ const advertised = Number(res.headers.get("content-length"));
1292
+ if (Number.isFinite(advertised) && advertised > REFERENCE_IMAGE_MAX_BYTES) {
1293
+ throw new Error(`image too large: ${(advertised / 1e6).toFixed(1)}MB > ${REFERENCE_IMAGE_MAX_BYTES / 1e6}MB cap`);
1294
+ }
1196
1295
  const buffer2 = Buffer.from(await res.arrayBuffer());
1197
1296
  if (buffer2.byteLength > REFERENCE_IMAGE_MAX_BYTES) {
1198
1297
  throw new Error(`image too large: ${(buffer2.byteLength / 1e6).toFixed(1)}MB > ${REFERENCE_IMAGE_MAX_BYTES / 1e6}MB cap`);
@@ -1349,34 +1448,42 @@ Source images and masks accept a base64 data URI, an http(s) URL, or a local fil
1349
1448
  };
1350
1449
  }
1351
1450
  const estimatedCost = estimateCost(selectedModel, size);
1352
- const budgetCheck = checkBudget(budget, agent_id, estimatedCost);
1353
- if (!budgetCheck.allowed) {
1451
+ const gate = reserveBudget(budget, agent_id, estimatedCost);
1452
+ if (!gate.allowed) {
1354
1453
  return {
1355
- content: [{ type: "text", text: `${budgetCheck.reason}. Use blockrun_wallet action:"report" to see usage or action:"delegate" to increase agent budget.` }],
1454
+ content: [{ type: "text", text: `${gate.reason}. Use blockrun_wallet action:"report" to see usage or action:"delegate" to increase agent budget.` }],
1356
1455
  isError: true
1357
1456
  };
1358
1457
  }
1359
- response = await getImageClient().edit(prompt, normalizedImage, {
1360
- model: selectedModel,
1361
- size,
1362
- ...normalizedMask ? { mask: normalizedMask } : {}
1363
- });
1364
- recordSpending(budget, estimatedCost, agent_id);
1458
+ try {
1459
+ response = await getImageClient().edit(prompt, normalizedImage, {
1460
+ model: selectedModel,
1461
+ size,
1462
+ ...normalizedMask ? { mask: normalizedMask } : {}
1463
+ });
1464
+ recordSpending(budget, estimatedCost, agent_id);
1465
+ } finally {
1466
+ gate.release();
1467
+ }
1365
1468
  } else {
1366
1469
  const estimatedCost = estimateCost(selectedModel, size);
1367
- const budgetCheck = checkBudget(budget, agent_id, estimatedCost);
1368
- if (!budgetCheck.allowed) {
1470
+ const gate = reserveBudget(budget, agent_id, estimatedCost);
1471
+ if (!gate.allowed) {
1369
1472
  return {
1370
- content: [{ type: "text", text: `${budgetCheck.reason}. Use blockrun_wallet action:"report" to see usage or action:"delegate" to increase agent budget.` }],
1473
+ content: [{ type: "text", text: `${gate.reason}. Use blockrun_wallet action:"report" to see usage or action:"delegate" to increase agent budget.` }],
1371
1474
  isError: true
1372
1475
  };
1373
1476
  }
1374
- response = await getImageClient().generate(prompt, {
1375
- model: selectedModel,
1376
- size,
1377
- quality
1378
- });
1379
- recordSpending(budget, estimatedCost, agent_id);
1477
+ try {
1478
+ response = await getImageClient().generate(prompt, {
1479
+ model: selectedModel,
1480
+ size,
1481
+ quality
1482
+ });
1483
+ recordSpending(budget, estimatedCost, agent_id);
1484
+ } finally {
1485
+ gate.release();
1486
+ }
1380
1487
  }
1381
1488
  const imageUrl = response.data?.[0]?.url;
1382
1489
  if (!imageUrl) {
@@ -1417,11 +1524,7 @@ async function fetchWithTimeout(url, options, timeoutMs) {
1417
1524
  const controller = new AbortController();
1418
1525
  const id = setTimeout(() => controller.abort(), timeoutMs);
1419
1526
  id.unref?.();
1420
- try {
1421
- return await fetch(url, { ...options, signal: controller.signal });
1422
- } finally {
1423
- clearTimeout(id);
1424
- }
1527
+ return await fetch(url, { ...options, signal: controller.signal });
1425
1528
  }
1426
1529
  function isTimeoutError(err) {
1427
1530
  const name = err instanceof Error ? err.name : "";
@@ -1460,6 +1563,7 @@ Returns a time-limited CDN URL \u2014 download immediately if you need to keep t
1460
1563
  }
1461
1564
  },
1462
1565
  async ({ prompt, instrumental, lyrics, model, agent_id }) => {
1566
+ let gate;
1463
1567
  try {
1464
1568
  if (getChain() !== "base") {
1465
1569
  return {
@@ -1473,10 +1577,10 @@ Returns a time-limited CDN URL \u2014 download immediately if you need to keep t
1473
1577
  isError: true
1474
1578
  };
1475
1579
  }
1476
- const budgetCheck = checkBudget(budget, agent_id, MUSIC_COST);
1477
- if (!budgetCheck.allowed) {
1580
+ gate = reserveBudget(budget, agent_id, MUSIC_COST);
1581
+ if (!gate.allowed) {
1478
1582
  return {
1479
- content: [{ type: "text", text: `${budgetCheck.reason}. Use blockrun_wallet action:"report" to see usage or action:"delegate" to increase agent budget.` }],
1583
+ content: [{ type: "text", text: `${gate.reason}. Use blockrun_wallet action:"report" to see usage or action:"delegate" to increase agent budget.` }],
1480
1584
  isError: true
1481
1585
  };
1482
1586
  }
@@ -1491,8 +1595,8 @@ Returns a time-limited CDN URL \u2014 download immediately if you need to keep t
1491
1595
  body: JSON.stringify(body)
1492
1596
  }, 15e3);
1493
1597
  if (resp402.status !== 402) {
1494
- const data2 = await resp402.json();
1495
- throw new Error(`Expected 402, got ${resp402.status}: ${JSON.stringify(data2)}`);
1598
+ const data2 = await resp402.json().catch(() => ({}));
1599
+ throw new Error(`Unexpected status ${resp402.status} (the endpoint did not return a quote): ${JSON.stringify(data2)}`);
1496
1600
  }
1497
1601
  const prHeader = resp402.headers.get("payment-required") || resp402.headers.get("PAYMENT-REQUIRED");
1498
1602
  if (!prHeader) throw new Error("No PAYMENT-REQUIRED header in 402 response");
@@ -1553,7 +1657,7 @@ Returns a time-limited CDN URL \u2014 download immediately if you need to keep t
1553
1657
  };
1554
1658
  } catch (err) {
1555
1659
  const errMsg = err instanceof Error ? err.message : String(err);
1556
- if (errMsg.includes("balance") || errMsg.includes("payment") || errMsg.includes("402") || errMsg.includes("rejected")) {
1660
+ if (isPaymentRejectionError(errMsg)) {
1557
1661
  return {
1558
1662
  content: [{ type: "text", text: `Music generation requires payment. Run blockrun_wallet with action: "setup" for funding instructions.
1559
1663
  Error: ${errMsg}` }],
@@ -1571,6 +1675,8 @@ Error: ${errMsg}` }],
1571
1675
  content: [{ type: "text", text: formatError(`Music generation failed: ${errMsg}`) }],
1572
1676
  isError: true
1573
1677
  };
1678
+ } finally {
1679
+ gate?.release();
1574
1680
  }
1575
1681
  }
1576
1682
  );
@@ -1640,6 +1746,7 @@ Returns a hosted audio URL \u2014 download immediately if you need to keep the f
1640
1746
  }
1641
1747
  },
1642
1748
  async ({ action, input, voice, model, response_format, speed, duration_seconds, prompt_influence, agent_id }) => {
1749
+ let gate;
1643
1750
  try {
1644
1751
  if (action === "voices") {
1645
1752
  return await listVoices();
@@ -1684,10 +1791,10 @@ Returns a hosted audio URL \u2014 download immediately if you need to keep the f
1684
1791
  if (speed !== void 0) body.speed = speed;
1685
1792
  cost = speechCost(model, input.length);
1686
1793
  }
1687
- const budgetCheck = checkBudget(budget, agent_id, cost);
1688
- if (!budgetCheck.allowed) {
1794
+ gate = reserveBudget(budget, agent_id, cost);
1795
+ if (!gate.allowed) {
1689
1796
  return {
1690
- content: [{ type: "text", text: `${budgetCheck.reason}. Use blockrun_wallet action:"report" to see usage or action:"delegate" to increase agent budget.` }],
1797
+ content: [{ type: "text", text: `${gate.reason}. Use blockrun_wallet action:"report" to see usage or action:"delegate" to increase agent budget.` }],
1691
1798
  isError: true
1692
1799
  };
1693
1800
  }
@@ -1700,7 +1807,7 @@ Returns a hosted audio URL \u2014 download immediately if you need to keep the f
1700
1807
  }, 15e3);
1701
1808
  if (resp402.status !== 402) {
1702
1809
  const data2 = await resp402.json().catch(() => ({}));
1703
- throw new Error(`Expected 402, got ${resp402.status}: ${JSON.stringify(data2)}`);
1810
+ throw new Error(`Unexpected status ${resp402.status} (the endpoint did not return a quote): ${JSON.stringify(data2)}`);
1704
1811
  }
1705
1812
  const prHeader = resp402.headers.get("payment-required") || resp402.headers.get("PAYMENT-REQUIRED");
1706
1813
  if (!prHeader) throw new Error("No PAYMENT-REQUIRED header in 402 response");
@@ -1766,7 +1873,7 @@ Returns a hosted audio URL \u2014 download immediately if you need to keep the f
1766
1873
  };
1767
1874
  } catch (err) {
1768
1875
  const errMsg = err instanceof Error ? err.message : String(err);
1769
- if (errMsg.includes("balance") || errMsg.includes("payment") || errMsg.includes("402") || errMsg.includes("rejected")) {
1876
+ if (isPaymentRejectionError(errMsg)) {
1770
1877
  return {
1771
1878
  content: [{ type: "text", text: `Speech generation requires payment. Run blockrun_wallet with action: "setup" for funding instructions.
1772
1879
  Error: ${errMsg}` }],
@@ -1784,6 +1891,8 @@ Error: ${errMsg}` }],
1784
1891
  content: [{ type: "text", text: formatError(`Speech generation failed: ${errMsg}`) }],
1785
1892
  isError: true
1786
1893
  };
1894
+ } finally {
1895
+ gate?.release();
1787
1896
  }
1788
1897
  }
1789
1898
  );
@@ -1884,6 +1993,7 @@ Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GC
1884
1993
  }
1885
1994
  },
1886
1995
  async ({ prompt, image_url, real_face_asset_id, duration_seconds, generate_audio, resolution, aspect_ratio, last_frame_url, model, agent_id }) => {
1996
+ let gate;
1887
1997
  try {
1888
1998
  if (getChain() !== "base") {
1889
1999
  return {
@@ -1924,10 +2034,10 @@ Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GC
1924
2034
  const hasImageInput = Boolean(image_url || real_face_asset_id);
1925
2035
  const perSecond = (hasImageInput ? VIDEO_PRICE_PER_SECOND_IMAGE[selectedModel] : void 0) ?? VIDEO_PRICE_PER_SECOND[selectedModel] ?? 0.05;
1926
2036
  const estimatedCost = perSecond * billedSeconds;
1927
- const budgetCheck = checkBudget(budget, agent_id, estimatedCost);
1928
- if (!budgetCheck.allowed) {
2037
+ gate = reserveBudget(budget, agent_id, estimatedCost);
2038
+ if (!gate.allowed) {
1929
2039
  return {
1930
- content: [{ type: "text", text: `${budgetCheck.reason}. Use blockrun_wallet action:"report" to see usage or action:"delegate" to increase agent budget.` }],
2040
+ content: [{ type: "text", text: `${gate.reason}. Use blockrun_wallet action:"report" to see usage or action:"delegate" to increase agent budget.` }],
1931
2041
  isError: true
1932
2042
  };
1933
2043
  }
@@ -1948,14 +2058,21 @@ Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GC
1948
2058
  body: JSON.stringify(body)
1949
2059
  }, 15e3);
1950
2060
  if (resp402.status !== 402) {
1951
- const data = await resp402.json();
1952
- throw new Error(`Expected 402, got ${resp402.status}: ${JSON.stringify(data)}`);
2061
+ const data = await resp402.json().catch(() => ({}));
2062
+ throw new Error(`Unexpected status ${resp402.status} (the endpoint did not return a quote): ${JSON.stringify(data)}`);
1953
2063
  }
1954
2064
  const prHeader = resp402.headers.get("payment-required") || resp402.headers.get("PAYMENT-REQUIRED");
1955
2065
  if (!prHeader) throw new Error("No PAYMENT-REQUIRED header in 402 response");
1956
2066
  const paymentRequired = parsePaymentRequired3(prHeader);
1957
2067
  const details = extractPaymentDetails3(paymentRequired);
1958
2068
  const settledUsd = amountToUsd(details.amount);
2069
+ if (settledUsd !== null && settledUsd > estimatedCost) {
2070
+ gate?.release();
2071
+ gate = reserveBudget(budget, agent_id, settledUsd);
2072
+ if (!gate.allowed) {
2073
+ return { content: [{ type: "text", text: `${gate.reason}. Use blockrun_wallet action:"report" to see usage or action:"delegate" to increase agent budget.` }], isError: true };
2074
+ }
2075
+ }
1959
2076
  const paymentPayload = await createPaymentPayload3(
1960
2077
  privateKey,
1961
2078
  account.address,
@@ -2032,7 +2149,7 @@ Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GC
2032
2149
  const lines = [
2033
2150
  `\u{1F3AC} Video ready!`,
2034
2151
  `URL: ${completed.url}`,
2035
- `Duration: ${completed.duration_seconds ? `${completed.duration_seconds}s` : "8s"}`,
2152
+ `Duration: ${completed.duration_seconds ?? billedSeconds}s`,
2036
2153
  `Model: ${completed.modelReturned || selectedModel}`,
2037
2154
  ...completed.backed_up ? [`Backed up to BlockRun storage (URL is permanent)`] : completed.source_url ? [`Source URL: ${completed.source_url}`] : [],
2038
2155
  ...completed.request_id ? [`Request ID: ${completed.request_id}`] : [],
@@ -2053,7 +2170,7 @@ Returns a permanent blockrun-hosted MP4 URL (the gateway mirrors the asset to GC
2053
2170
  };
2054
2171
  } catch (err) {
2055
2172
  const errMsg = err instanceof Error ? err.message : String(err);
2056
- if (errMsg.includes("balance") || errMsg.includes("payment") || errMsg.includes("402") || errMsg.includes("rejected")) {
2173
+ if (isPaymentRejectionError(errMsg)) {
2057
2174
  return {
2058
2175
  content: [{ type: "text", text: `Video generation requires payment. Run blockrun_wallet with action: "setup" for funding instructions.
2059
2176
  Error: ${errMsg}` }],
@@ -2071,6 +2188,8 @@ Error: ${errMsg}` }],
2071
2188
  content: [{ type: "text", text: formatError(`Video generation failed: ${errMsg}`, { altModels: "bytedance/seedance-2.0, azure/sora-2" }) }],
2072
2189
  isError: true
2073
2190
  };
2191
+ } finally {
2192
+ gate?.release();
2074
2193
  }
2075
2194
  }
2076
2195
  );
@@ -2096,7 +2215,7 @@ async function payAndPostJson(url, reqBody, fallbackDescription) {
2096
2215
  }, 15e3);
2097
2216
  if (resp402.status !== 402) {
2098
2217
  const data2 = await resp402.json().catch(() => ({}));
2099
- throw new Error(`Expected 402, got ${resp402.status}: ${data2.message || data2.error || JSON.stringify(data2)}`);
2218
+ throw new Error(`Unexpected status ${resp402.status} (the endpoint did not return a quote): ${data2.message || data2.error || JSON.stringify(data2)}`);
2100
2219
  }
2101
2220
  const prHeader = resp402.headers.get("payment-required") || resp402.headers.get("PAYMENT-REQUIRED");
2102
2221
  if (!prHeader) throw new Error("No PAYMENT-REQUIRED header in 402 response");
@@ -2157,6 +2276,7 @@ Privacy: BlockRun does not store face/liveness data \u2014 only the asset id, na
2157
2276
  }
2158
2277
  },
2159
2278
  async ({ action, name, group_id, image_url, agent_id }) => {
2279
+ let gate;
2160
2280
  try {
2161
2281
  if (action === "init") {
2162
2282
  if (!name) {
@@ -2283,9 +2403,9 @@ Enroll one: blockrun_realface action:"init" name:"\u2026" (real person) or actio
2283
2403
  if (!name || !image_url) {
2284
2404
  return { content: [{ type: "text", text: formatError("portrait requires name and image_url (public HTTPS URL of an AI-generated character image).") }], isError: true };
2285
2405
  }
2286
- const budgetCheck = checkBudget(budget, agent_id, ENROLLMENT_PRICE_USD);
2287
- if (!budgetCheck.allowed) {
2288
- return { content: [{ type: "text", text: `${budgetCheck.reason}. Use blockrun_wallet action:"report" to see usage or action:"delegate" to increase agent budget.` }], isError: true };
2406
+ gate = reserveBudget(budget, agent_id, ENROLLMENT_PRICE_USD);
2407
+ if (!gate.allowed) {
2408
+ return { content: [{ type: "text", text: `${gate.reason}. Use blockrun_wallet action:"report" to see usage or action:"delegate" to increase agent budget.` }], isError: true };
2289
2409
  }
2290
2410
  const { status, data, settledUsd } = await payAndPostJson(
2291
2411
  `${BLOCKRUN_API4}/v1/portrait/enroll`,
@@ -2333,61 +2453,26 @@ Enroll one: blockrun_realface action:"init" name:"\u2026" (real person) or actio
2333
2453
  if (!name || !image_url || !group_id) {
2334
2454
  return { content: [{ type: "text", text: formatError("enroll requires name, image_url, and group_id (from init, after the group is active).") }], isError: true };
2335
2455
  }
2336
- const budgetCheck = checkBudget(budget, agent_id, ENROLLMENT_PRICE_USD);
2337
- if (!budgetCheck.allowed) {
2338
- return { content: [{ type: "text", text: `${budgetCheck.reason}. Use blockrun_wallet action:"report" to see usage or action:"delegate" to increase agent budget.` }], isError: true };
2456
+ gate = reserveBudget(budget, agent_id, ENROLLMENT_PRICE_USD);
2457
+ if (!gate.allowed) {
2458
+ return { content: [{ type: "text", text: `${gate.reason}. Use blockrun_wallet action:"report" to see usage or action:"delegate" to increase agent budget.` }], isError: true };
2339
2459
  }
2340
- const privateKey = getOrCreateWalletKey();
2341
- const account = privateKeyToAccount4(privateKey);
2342
- const enrollUrl = `${BLOCKRUN_API4}/v1/realface/enroll`;
2343
- const reqBody = JSON.stringify({ name, image_url, group_id });
2344
- const resp402 = await fetchWithTimeout(enrollUrl, {
2345
- method: "POST",
2346
- headers: { "Content-Type": "application/json" },
2347
- body: reqBody
2348
- }, 15e3);
2349
- if (resp402.status !== 402) {
2350
- const data2 = await resp402.json().catch(() => ({}));
2351
- throw new Error(`Expected 402, got ${resp402.status}: ${data2.message || data2.error || JSON.stringify(data2)}`);
2352
- }
2353
- const prHeader = resp402.headers.get("payment-required") || resp402.headers.get("PAYMENT-REQUIRED");
2354
- if (!prHeader) throw new Error("No PAYMENT-REQUIRED header in 402 response");
2355
- const paymentRequired = parsePaymentRequired4(prHeader);
2356
- const details = extractPaymentDetails4(paymentRequired);
2357
- const settledUsd = amountToUsd(details.amount);
2358
- const paymentPayload = await createPaymentPayload4(
2359
- privateKey,
2360
- account.address,
2361
- details.recipient,
2362
- details.amount,
2363
- details.network || "eip155:8453",
2364
- {
2365
- resourceUrl: details.resource?.url || enrollUrl,
2366
- resourceDescription: details.resource?.description || "BlockRun RealFace enrollment",
2367
- maxTimeoutSeconds: Math.max(details.maxTimeoutSeconds || 0, 120),
2368
- extra: details.extra
2369
- }
2460
+ const { status, data, settledUsd } = await payAndPostJson(
2461
+ `${BLOCKRUN_API4}/v1/realface/enroll`,
2462
+ JSON.stringify({ name, image_url, group_id }),
2463
+ "BlockRun RealFace enrollment"
2370
2464
  );
2371
- const resp = await fetchWithTimeout(enrollUrl, {
2372
- method: "POST",
2373
- headers: {
2374
- "Content-Type": "application/json",
2375
- "PAYMENT-SIGNATURE": paymentPayload
2376
- },
2377
- body: reqBody
2378
- }, 9e4);
2379
- const data = await resp.json().catch(() => ({}));
2380
- if (resp.status === 402) {
2465
+ if (status === 402) {
2381
2466
  throw new Error("Payment rejected. Check your wallet balance.");
2382
2467
  }
2383
- if (resp.status === 425) {
2468
+ if (status === 425) {
2384
2469
  return { content: [{ type: "text", text: formatError(`Group not active yet \u2014 ${data.message || "finish the phone liveness check first"}. No payment taken.`) }], isError: true };
2385
2470
  }
2386
- if (resp.status === 422) {
2471
+ if (status === 422) {
2387
2472
  return { content: [{ type: "text", text: formatError(`Face match failed \u2014 ${data.hint || "use a clearer front-facing photo of the same person"}. No payment taken.`) }], isError: true };
2388
2473
  }
2389
- if (!resp.ok) {
2390
- throw new Error(`Enroll error ${resp.status}: ${data.error || JSON.stringify(data)}`);
2474
+ if (status < 200 || status >= 300) {
2475
+ throw new Error(`Enroll error ${status}: ${data.error || JSON.stringify(data)}`);
2391
2476
  }
2392
2477
  const assetId = data.asset_id;
2393
2478
  if (!assetId) throw new Error(`Enroll response missing asset_id: ${JSON.stringify(data)}`);
@@ -2416,7 +2501,7 @@ Enroll one: blockrun_realface action:"init" name:"\u2026" (real person) or actio
2416
2501
  return { content: [{ type: "text", text: formatError(`Unknown action: ${action}`) }], isError: true };
2417
2502
  } catch (err) {
2418
2503
  const errMsg = err instanceof Error ? err.message : String(err);
2419
- if (errMsg.includes("balance") || errMsg.includes("payment") || errMsg.includes("402") || errMsg.includes("rejected")) {
2504
+ if (isPaymentRejectionError(errMsg)) {
2420
2505
  return {
2421
2506
  content: [{ type: "text", text: `RealFace enrollment requires payment. Run blockrun_wallet with action: "setup" for funding instructions.
2422
2507
  Error: ${errMsg}` }],
@@ -2424,6 +2509,8 @@ Error: ${errMsg}` }],
2424
2509
  };
2425
2510
  }
2426
2511
  return { content: [{ type: "text", text: formatError(`RealFace ${action} failed: ${errMsg}`) }], isError: true };
2512
+ } finally {
2513
+ gate?.release();
2427
2514
  }
2428
2515
  }
2429
2516
  );
@@ -2447,6 +2534,19 @@ function asStructuredContent(result) {
2447
2534
  return typeof result === "object" && result !== null && !Array.isArray(result) ? result : { result };
2448
2535
  }
2449
2536
 
2537
+ // src/utils/path-safety.ts
2538
+ function hasPathTraversal(path5) {
2539
+ let decoded = path5;
2540
+ try {
2541
+ decoded = decodeURIComponent(path5);
2542
+ } catch {
2543
+ }
2544
+ return decoded.split(/[/\\]/).some((seg) => seg === ".." || seg === ".");
2545
+ }
2546
+ function isValidNetworkSlug(slug) {
2547
+ return /^[a-z0-9-]+$/.test(slug);
2548
+ }
2549
+
2450
2550
  // src/tools/search.ts
2451
2551
  var SEARCH_PRICE_PER_SOURCE = 0.025;
2452
2552
  var SEARCH_DEFAULT_MAX_RESULTS = 10;
@@ -2477,23 +2577,30 @@ Full request shape + worked examples in the \`search\` skill (\`skills/search/SK
2477
2577
  async ({ path: path5, body, agent_id }) => {
2478
2578
  try {
2479
2579
  body = coerceBody(body);
2580
+ const cleanPath = (path5 ?? "").replace(/^\/+/, "").replace(/^v1\/search\/?/, "");
2581
+ if (hasPathTraversal(cleanPath)) {
2582
+ return { content: [{ type: "text", text: formatError(`Invalid path '${path5}'.`) }], isError: true };
2583
+ }
2480
2584
  const estimatedCost = estimateSearchCost(body);
2481
- const budgetCheck = checkBudget(budget, agent_id, estimatedCost);
2482
- if (!budgetCheck.allowed) {
2585
+ const gate = reserveBudget(budget, agent_id, estimatedCost);
2586
+ if (!gate.allowed) {
2483
2587
  return {
2484
- content: [{ type: "text", text: `${budgetCheck.reason}. Use blockrun_wallet action:"report" to see usage or action:"delegate" to increase agent budget.` }],
2588
+ content: [{ type: "text", text: `${gate.reason}. Use blockrun_wallet action:"report" to see usage or action:"delegate" to increase agent budget.` }],
2485
2589
  isError: true
2486
2590
  };
2487
2591
  }
2488
- const client = getClient();
2489
- const cleanPath = (path5 ?? "").replace(/^\/+/, "").replace(/^v1\/search\/?/, "");
2490
- const endpoint = cleanPath ? `/v1/search/${cleanPath}` : "/v1/search";
2491
- const result = await client.requestWithPaymentRaw(endpoint, body ?? {});
2492
- recordSpending(budget, estimatedCost, agent_id);
2493
- return {
2494
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
2495
- structuredContent: asStructuredContent(result)
2496
- };
2592
+ try {
2593
+ const client = getClient();
2594
+ const endpoint = cleanPath ? `/v1/search/${cleanPath}` : "/v1/search";
2595
+ const result = await client.requestWithPaymentRaw(endpoint, body ?? {});
2596
+ recordSpending(budget, estimatedCost, agent_id);
2597
+ return {
2598
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
2599
+ structuredContent: asStructuredContent(result)
2600
+ };
2601
+ } finally {
2602
+ gate.release();
2603
+ }
2497
2604
  } catch (err) {
2498
2605
  return { content: [{ type: "text", text: formatError(extractErrorMessage(err)) }], isError: true };
2499
2606
  }
@@ -2535,23 +2642,30 @@ Full request/response shapes + worked research workflows in the \`exa-research\`
2535
2642
  async ({ path: path5, body, agent_id }) => {
2536
2643
  try {
2537
2644
  body = coerceBody(body);
2645
+ const cleanPath = path5.replace(/^\/+/, "").replace(/^v1\/exa\//, "");
2646
+ if (hasPathTraversal(cleanPath)) {
2647
+ return { content: [{ type: "text", text: formatError(`Invalid path '${path5}'.`) }], isError: true };
2648
+ }
2538
2649
  const estimatedCost = estimateExaCost(path5, body);
2539
- const budgetCheck = checkBudget(budget, agent_id, estimatedCost);
2540
- if (!budgetCheck.allowed) {
2650
+ const gate = reserveBudget(budget, agent_id, estimatedCost);
2651
+ if (!gate.allowed) {
2541
2652
  return {
2542
- content: [{ type: "text", text: `${budgetCheck.reason}. Use blockrun_wallet action:"report" to see usage or action:"delegate" to increase agent budget.` }],
2653
+ content: [{ type: "text", text: `${gate.reason}. Use blockrun_wallet action:"report" to see usage or action:"delegate" to increase agent budget.` }],
2543
2654
  isError: true
2544
2655
  };
2545
2656
  }
2546
- const client = getClient();
2547
- const cleanPath = path5.replace(/^\/+/, "").replace(/^v1\/exa\//, "");
2548
- const endpoint = `/v1/exa/${cleanPath}`;
2549
- const result = await client.requestWithPaymentRaw(endpoint, body ?? {});
2550
- recordSpending(budget, estimatedCost, agent_id);
2551
- return {
2552
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
2553
- structuredContent: asStructuredContent(result)
2554
- };
2657
+ try {
2658
+ const client = getClient();
2659
+ const endpoint = `/v1/exa/${cleanPath}`;
2660
+ const result = await client.requestWithPaymentRaw(endpoint, body ?? {});
2661
+ recordSpending(budget, estimatedCost, agent_id);
2662
+ return {
2663
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
2664
+ structuredContent: asStructuredContent(result)
2665
+ };
2666
+ } finally {
2667
+ gate.release();
2668
+ }
2555
2669
  } catch (err) {
2556
2670
  return { content: [{ type: "text", text: formatError(extractErrorMessage(err)) }], isError: true };
2557
2671
  }
@@ -2634,21 +2748,28 @@ Pass query params via 'params' (GET). Use 'body' only for POST endpoints (e.g. p
2634
2748
  async ({ path: path5, params, body, agent_id }) => {
2635
2749
  try {
2636
2750
  body = coerceBody(body);
2751
+ if (hasPathTraversal(path5)) {
2752
+ return { content: [{ type: "text", text: formatError(`Invalid path '${path5}'.`) }], isError: true };
2753
+ }
2637
2754
  const estimatedCost = estimateMarketCost(path5, body);
2638
- const budgetCheck = checkBudget(budget, agent_id, estimatedCost);
2639
- if (!budgetCheck.allowed) {
2755
+ const gate = reserveBudget(budget, agent_id, estimatedCost);
2756
+ if (!gate.allowed) {
2640
2757
  return {
2641
- content: [{ type: "text", text: `${budgetCheck.reason}. Use blockrun_wallet action:"report" to see usage or action:"delegate" to increase agent budget.` }],
2758
+ content: [{ type: "text", text: `${gate.reason}. Use blockrun_wallet action:"report" to see usage or action:"delegate" to increase agent budget.` }],
2642
2759
  isError: true
2643
2760
  };
2644
2761
  }
2645
- const llm = getClient();
2646
- const result = body !== void 0 ? await llm.pmQuery(path5, body) : await llm.pm(path5, params);
2647
- recordSpending(budget, estimatedCost, agent_id);
2648
- return {
2649
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
2650
- structuredContent: asStructuredContent(result)
2651
- };
2762
+ try {
2763
+ const llm = getClient();
2764
+ const result = body !== void 0 ? await llm.pmQuery(path5, body) : await llm.pm(path5, params);
2765
+ recordSpending(budget, estimatedCost, agent_id);
2766
+ return {
2767
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
2768
+ structuredContent: asStructuredContent(result)
2769
+ };
2770
+ } finally {
2771
+ gate.release();
2772
+ }
2652
2773
  } catch (err) {
2653
2774
  return {
2654
2775
  content: [{ type: "text", text: formatError(extractErrorMessage(err)) }],
@@ -2730,51 +2851,55 @@ Examples:
2730
2851
  };
2731
2852
  }
2732
2853
  const estimatedCost = paid ? 1e-3 : 0;
2733
- const budgetCheck = checkBudget(budget, agent_id, estimatedCost);
2734
- if (!budgetCheck.allowed) {
2854
+ const gate = reserveBudget(budget, agent_id, estimatedCost);
2855
+ if (!gate.allowed) {
2735
2856
  return {
2736
- content: [{ type: "text", text: `${budgetCheck.reason}. Use blockrun_wallet action:"report" to see usage or action:"delegate" to increase agent budget.` }],
2857
+ content: [{ type: "text", text: `${gate.reason}. Use blockrun_wallet action:"report" to see usage or action:"delegate" to increase agent budget.` }],
2737
2858
  isError: true
2738
2859
  };
2739
2860
  }
2740
- const priceClient = getPriceClient(paid);
2741
- if (action === "price") {
2742
- if (!symbol) throw new Error("symbol is required for action='price'");
2743
- const result2 = await priceClient.price(category, symbol, {
2744
- market,
2745
- session
2746
- });
2747
- if (estimatedCost > 0) recordSpending(budget, estimatedCost, agent_id);
2748
- return {
2749
- content: [{ type: "text", text: JSON.stringify(result2, null, 2) }],
2750
- structuredContent: result2
2751
- };
2752
- }
2753
- if (action === "history") {
2754
- if (!symbol) throw new Error("symbol is required for action='history'");
2755
- if (from === void 0) throw new Error("from (unix seconds) is required for action='history'");
2756
- const result2 = await priceClient.history(category, symbol, {
2861
+ try {
2862
+ const priceClient = getPriceClient(paid);
2863
+ if (action === "price") {
2864
+ if (!symbol) throw new Error("symbol is required for action='price'");
2865
+ const result2 = await priceClient.price(category, symbol, {
2866
+ market,
2867
+ session
2868
+ });
2869
+ if (estimatedCost > 0) recordSpending(budget, estimatedCost, agent_id);
2870
+ return {
2871
+ content: [{ type: "text", text: JSON.stringify(result2, null, 2) }],
2872
+ structuredContent: result2
2873
+ };
2874
+ }
2875
+ if (action === "history") {
2876
+ if (!symbol) throw new Error("symbol is required for action='history'");
2877
+ if (from === void 0) throw new Error("from (unix seconds) is required for action='history'");
2878
+ const result2 = await priceClient.history(category, symbol, {
2879
+ market,
2880
+ session,
2881
+ resolution: resolution ?? "D",
2882
+ from,
2883
+ to
2884
+ });
2885
+ if (estimatedCost > 0) recordSpending(budget, estimatedCost, agent_id);
2886
+ return {
2887
+ content: [{ type: "text", text: JSON.stringify(result2, null, 2) }],
2888
+ structuredContent: result2
2889
+ };
2890
+ }
2891
+ const result = await priceClient.listSymbols(category, {
2757
2892
  market,
2758
- session,
2759
- resolution: resolution ?? "D",
2760
- from,
2761
- to
2893
+ query,
2894
+ limit
2762
2895
  });
2763
- if (estimatedCost > 0) recordSpending(budget, estimatedCost, agent_id);
2764
2896
  return {
2765
- content: [{ type: "text", text: JSON.stringify(result2, null, 2) }],
2766
- structuredContent: result2
2897
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
2898
+ structuredContent: result
2767
2899
  };
2900
+ } finally {
2901
+ gate.release();
2768
2902
  }
2769
- const result = await priceClient.listSymbols(category, {
2770
- market,
2771
- query,
2772
- limit
2773
- });
2774
- return {
2775
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
2776
- structuredContent: result
2777
- };
2778
2903
  } catch (err) {
2779
2904
  return {
2780
2905
  content: [{ type: "text", text: formatError(extractErrorMessage(err)) }],
@@ -2823,7 +2948,7 @@ Examples:
2823
2948
  isError: true
2824
2949
  };
2825
2950
  }
2826
- const response = await fetch(url);
2951
+ const response = await fetchWithTimeout(url, {}, 8e3);
2827
2952
  if (!response.ok) {
2828
2953
  throw new Error(`DexScreener API error: ${response.status}`);
2829
2954
  }
@@ -2905,22 +3030,29 @@ Full action shapes + GPU type details in the \`modal\` skill.`,
2905
3030
  try {
2906
3031
  body = coerceBody(body);
2907
3032
  const cleanPath = path5.replace(/^\/+/, "").replace(/^v1\/modal\//, "");
3033
+ if (hasPathTraversal(cleanPath)) {
3034
+ return { content: [{ type: "text", text: formatError(`Invalid path '${path5}'.`) }], isError: true };
3035
+ }
2908
3036
  const estimatedCost = estimateModalCost(cleanPath);
2909
- const budgetCheck = checkBudget(budget, agent_id, estimatedCost);
2910
- if (!budgetCheck.allowed) {
3037
+ const gate = reserveBudget(budget, agent_id, estimatedCost);
3038
+ if (!gate.allowed) {
2911
3039
  return {
2912
- content: [{ type: "text", text: `${budgetCheck.reason}. Use blockrun_wallet action:"report" to see usage or action:"delegate" to increase agent budget.` }],
3040
+ content: [{ type: "text", text: `${gate.reason}. Use blockrun_wallet action:"report" to see usage or action:"delegate" to increase agent budget.` }],
2913
3041
  isError: true
2914
3042
  };
2915
3043
  }
2916
- const client = buildClientWithTimeout(modalTimeoutMs(body));
2917
- const endpoint = `/v1/modal/${cleanPath}`;
2918
- const result = await client.requestWithPaymentRaw(endpoint, body ?? {});
2919
- recordSpending(budget, estimatedCost, agent_id);
2920
- return {
2921
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
2922
- structuredContent: asStructuredContent(result)
2923
- };
3044
+ try {
3045
+ const client = buildClientWithTimeout(modalTimeoutMs(body));
3046
+ const endpoint = `/v1/modal/${cleanPath}`;
3047
+ const result = await client.requestWithPaymentRaw(endpoint, body ?? {});
3048
+ recordSpending(budget, estimatedCost, agent_id);
3049
+ return {
3050
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
3051
+ structuredContent: asStructuredContent(result)
3052
+ };
3053
+ } finally {
3054
+ gate.release();
3055
+ }
2924
3056
  } catch (err) {
2925
3057
  return { content: [{ type: "text", text: formatError(extractErrorMessage(err)) }], isError: true };
2926
3058
  }
@@ -2971,22 +3103,29 @@ Voice call flow + voice preset details + full body shapes in the \`phone\` skill
2971
3103
  try {
2972
3104
  body = coerceBody(body);
2973
3105
  const cleanPath = path5.replace(/^\/+/, "").replace(/^v1\//, "");
3106
+ if (hasPathTraversal(cleanPath)) {
3107
+ return { content: [{ type: "text", text: formatError(`Invalid path '${path5}'.`) }], isError: true };
3108
+ }
2974
3109
  const estimatedCost = estimatePhoneCost(cleanPath, body !== void 0);
2975
- const budgetCheck = checkBudget(budget, agent_id, estimatedCost);
2976
- if (!budgetCheck.allowed) {
3110
+ const gate = reserveBudget(budget, agent_id, estimatedCost);
3111
+ if (!gate.allowed) {
2977
3112
  return {
2978
- content: [{ type: "text", text: `${budgetCheck.reason}. Use blockrun_wallet action:"report" to see usage or action:"delegate" to increase agent budget.` }],
3113
+ content: [{ type: "text", text: `${gate.reason}. Use blockrun_wallet action:"report" to see usage or action:"delegate" to increase agent budget.` }],
2979
3114
  isError: true
2980
3115
  };
2981
3116
  }
2982
- const client = getClient();
2983
- const endpoint = `/v1/${cleanPath}`;
2984
- const result = body !== void 0 ? await client.requestWithPaymentRaw(endpoint, body) : await client.getWithPaymentRaw(endpoint);
2985
- if (estimatedCost > 0) recordSpending(budget, estimatedCost, agent_id);
2986
- return {
2987
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
2988
- structuredContent: asStructuredContent(result)
2989
- };
3117
+ try {
3118
+ const client = getClient();
3119
+ const endpoint = `/v1/${cleanPath}`;
3120
+ const result = body !== void 0 ? await client.requestWithPaymentRaw(endpoint, body) : await client.getWithPaymentRaw(endpoint);
3121
+ if (estimatedCost > 0) recordSpending(budget, estimatedCost, agent_id);
3122
+ return {
3123
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
3124
+ structuredContent: asStructuredContent(result)
3125
+ };
3126
+ } finally {
3127
+ gate.release();
3128
+ }
2990
3129
  } catch (err) {
2991
3130
  return { content: [{ type: "text", text: formatError(extractErrorMessage(err)) }], isError: true };
2992
3131
  }
@@ -3069,22 +3208,29 @@ Each Surf endpoint pre-validates required params before settling \u2014 you get
3069
3208
  try {
3070
3209
  body = coerceBody(body);
3071
3210
  const cleanPath = path5.replace(/^\/+/, "").replace(/^v1\/surf\//, "").replace(/^api\/v1\/surf\//, "");
3211
+ if (hasPathTraversal(cleanPath)) {
3212
+ return { content: [{ type: "text", text: formatError(`Invalid path '${path5}'.`) }], isError: true };
3213
+ }
3072
3214
  const estimatedCost = estimateSurfCost(cleanPath);
3073
- const budgetCheck = checkBudget(budget, agent_id, estimatedCost);
3074
- if (!budgetCheck.allowed) {
3215
+ const gate = reserveBudget(budget, agent_id, estimatedCost);
3216
+ if (!gate.allowed) {
3075
3217
  return {
3076
- content: [{ type: "text", text: `${budgetCheck.reason}. Use blockrun_wallet action:"report" to see usage or action:"delegate" to increase agent budget.` }],
3218
+ content: [{ type: "text", text: `${gate.reason}. Use blockrun_wallet action:"report" to see usage or action:"delegate" to increase agent budget.` }],
3077
3219
  isError: true
3078
3220
  };
3079
3221
  }
3080
- const client = getClient();
3081
- const endpoint = `/v1/surf/${cleanPath}`;
3082
- const result = body !== void 0 ? await client.requestWithPaymentRaw(endpoint, body) : await client.getWithPaymentRaw(endpoint, params);
3083
- recordSpending(budget, estimatedCost, agent_id);
3084
- return {
3085
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
3086
- structuredContent: asStructuredContent(result)
3087
- };
3222
+ try {
3223
+ const client = getClient();
3224
+ const endpoint = `/v1/surf/${cleanPath}`;
3225
+ const result = body !== void 0 ? await client.requestWithPaymentRaw(endpoint, body) : await client.getWithPaymentRaw(endpoint, params);
3226
+ recordSpending(budget, estimatedCost, agent_id);
3227
+ return {
3228
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
3229
+ structuredContent: asStructuredContent(result)
3230
+ };
3231
+ } finally {
3232
+ gate.release();
3233
+ }
3088
3234
  } catch (err) {
3089
3235
  return {
3090
3236
  content: [{ type: "text", text: formatError(extractErrorMessage(err)) }],
@@ -3138,21 +3284,31 @@ Prefer blockrun_price (free quotes), blockrun_dex (free DEX data), or blockrun_s
3138
3284
  }
3139
3285
  const batchCount = Array.isArray(body) ? Math.max(body.length, 1) : 1;
3140
3286
  const estimatedCost = RPC_PRICE_USD * batchCount;
3141
- const budgetCheck = checkBudget(budget, agent_id, estimatedCost);
3142
- if (!budgetCheck.allowed) {
3287
+ const cleanNetwork = network.trim().toLowerCase().replace(/^\/+|\/+$/g, "");
3288
+ if (!isValidNetworkSlug(cleanNetwork)) {
3143
3289
  return {
3144
- content: [{ type: "text", text: `${budgetCheck.reason}. Use blockrun_wallet action:"report" to see usage or action:"delegate" to increase agent budget.` }],
3290
+ content: [{ type: "text", text: formatError(`Invalid network '${network}'. Use a chain slug like 'ethereum', 'base', or 'solana'.`) }],
3145
3291
  isError: true
3146
3292
  };
3147
3293
  }
3148
- const cleanNetwork = network.trim().toLowerCase().replace(/^\/+|\/+$/g, "");
3149
- const client = getClient();
3150
- const result = await client.requestWithPaymentRaw(`/v1/rpc/${cleanNetwork}`, body);
3151
- recordSpending(budget, estimatedCost, agent_id);
3152
- return {
3153
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
3154
- structuredContent: typeof result === "object" && result !== null && !Array.isArray(result) ? result : { result }
3155
- };
3294
+ const gate = reserveBudget(budget, agent_id, estimatedCost);
3295
+ if (!gate.allowed) {
3296
+ return {
3297
+ content: [{ type: "text", text: `${gate.reason}. Use blockrun_wallet action:"report" to see usage or action:"delegate" to increase agent budget.` }],
3298
+ isError: true
3299
+ };
3300
+ }
3301
+ try {
3302
+ const client = getClient();
3303
+ const result = await client.requestWithPaymentRaw(`/v1/rpc/${cleanNetwork}`, body);
3304
+ recordSpending(budget, estimatedCost, agent_id);
3305
+ return {
3306
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
3307
+ structuredContent: typeof result === "object" && result !== null && !Array.isArray(result) ? result : { result }
3308
+ };
3309
+ } finally {
3310
+ gate.release();
3311
+ }
3156
3312
  } catch (err) {
3157
3313
  return {
3158
3314
  content: [{ type: "text", text: formatError(extractErrorMessage(err)) }],
@@ -3195,21 +3351,28 @@ Use blockrun_price (free) for plain spot quotes, blockrun_dex (free) for DEX pai
3195
3351
  async ({ path: path5, agent_id }) => {
3196
3352
  try {
3197
3353
  const cleanPath = path5.replace(/^\/+/, "").replace(/^v1\/defillama\//, "").replace(/^api\/v1\/defillama\//, "");
3354
+ if (hasPathTraversal(cleanPath)) {
3355
+ return { content: [{ type: "text", text: formatError(`Invalid path '${path5}'.`) }], isError: true };
3356
+ }
3198
3357
  const estimatedCost = estimateDefiCost(cleanPath);
3199
- const budgetCheck = checkBudget(budget, agent_id, estimatedCost);
3200
- if (!budgetCheck.allowed) {
3358
+ const gate = reserveBudget(budget, agent_id, estimatedCost);
3359
+ if (!gate.allowed) {
3201
3360
  return {
3202
- content: [{ type: "text", text: `${budgetCheck.reason}. Use blockrun_wallet action:"report" to see usage or action:"delegate" to increase agent budget.` }],
3361
+ content: [{ type: "text", text: `${gate.reason}. Use blockrun_wallet action:"report" to see usage or action:"delegate" to increase agent budget.` }],
3203
3362
  isError: true
3204
3363
  };
3205
3364
  }
3206
- const client = getClient();
3207
- const result = await client.getWithPaymentRaw(`/v1/defillama/${cleanPath}`);
3208
- recordSpending(budget, estimatedCost, agent_id);
3209
- return {
3210
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
3211
- structuredContent: typeof result === "object" && result !== null && !Array.isArray(result) ? result : { result }
3212
- };
3365
+ try {
3366
+ const client = getClient();
3367
+ const result = await client.getWithPaymentRaw(`/v1/defillama/${cleanPath}`);
3368
+ recordSpending(budget, estimatedCost, agent_id);
3369
+ return {
3370
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
3371
+ structuredContent: typeof result === "object" && result !== null && !Array.isArray(result) ? result : { result }
3372
+ };
3373
+ } finally {
3374
+ gate.release();
3375
+ }
3213
3376
  } catch (err) {
3214
3377
  return {
3215
3378
  content: [{ type: "text", text: formatError(extractErrorMessage(err)) }],
@@ -3268,7 +3431,7 @@ function resolveProfileName(argv = process.argv.slice(2), env = process.env) {
3268
3431
  }
3269
3432
  function resolveTools(argv, env) {
3270
3433
  const requested = resolveProfileName(argv, env);
3271
- const spec = PROFILES[requested];
3434
+ const spec = Object.hasOwn(PROFILES, requested) ? PROFILES[requested] : void 0;
3272
3435
  if (!spec) {
3273
3436
  return { profile: DEFAULT_PROFILE, tools: new Set(ALL_TOOLS) };
3274
3437
  }
@@ -3359,6 +3522,10 @@ function looksLikeRawPrivateKey(value) {
3359
3522
  if (value.length >= 80 && value.length <= 100 && /^[1-9A-HJ-NP-Za-km-z]+$/.test(value)) return true;
3360
3523
  return false;
3361
3524
  }
3525
+ function looksLikeNamedSecretValue(value) {
3526
+ if (looksLikeRawPrivateKey(value)) return true;
3527
+ return typeof value === "string" && /^[0-9a-fA-F]{64}$/.test(value);
3528
+ }
3362
3529
  function looksLikeSolanaSecretKeyArray(value) {
3363
3530
  return Array.isArray(value) && value.length === 64 && value.every((n) => typeof n === "number" && Number.isInteger(n) && n >= 0 && n <= 255);
3364
3531
  }
@@ -3373,7 +3540,7 @@ function walk(obj, file, jsonPath, out) {
3373
3540
  }
3374
3541
  for (const [k, v] of Object.entries(obj)) {
3375
3542
  const next = jsonPath ? `${jsonPath}.${k}` : k;
3376
- if (/wallet[-_ ]?key|private[-_ ]?key|secret/i.test(k) && looksLikeRawPrivateKey(v)) {
3543
+ if (/wallet[-_ ]?key|private[-_ ]?key|secret/i.test(k) && looksLikeNamedSecretValue(v)) {
3377
3544
  out.push({ file, path: next });
3378
3545
  } else if (looksLikeRawPrivateKey(v)) {
3379
3546
  out.push({ file, path: next });