@elisym/mcp 0.14.0 → 0.15.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.
package/dist/index.js CHANGED
@@ -1,14 +1,14 @@
1
1
  #!/usr/bin/env node
2
- import { LIMITS, DEFAULT_KIND_OFFSET, SolanaPaymentStrategy, makeCensor, DEFAULT_REDACT_PATHS, validateAgentName, RELAYS, toDTag, JobWaitTimeoutError, formatAssetAmount, estimateNetworkBaseline, formatSol as formatSol$1, USDC_SOLANA_DEVNET, estimateSolFeeLamports, formatFeeBreakdown, resolveAssetFromPaymentRequest as resolveAssetFromPaymentRequest$1, parseAssetAmount, ElisymIdentity, ElisymClient, NATIVE_SOL, resolveKnownAsset, formatNetworkBaseline, getProtocolProgramId, getProtocolConfig, assetKey, assetByKey, KNOWN_ASSETS } from '@elisym/sdk';
2
+ import { LIMITS, DEFAULT_KIND_OFFSET, SolanaPaymentStrategy, makeCensor, DEFAULT_REDACT_PATHS, validateAgentName, RELAYS, toDTag, JobWaitTimeoutError, decodeJobPayload, utf8ByteLength, formatAssetAmount, estimateNetworkBaseline, formatSol as formatSol$1, USDC_SOLANA_DEVNET, estimateSolFeeLamports, formatFeeBreakdown, resolveAssetFromPaymentRequest as resolveAssetFromPaymentRequest$1, parseAssetAmount, ElisymIdentity, ElisymClient, NATIVE_SOL, resolveKnownAsset, getProtocolProgramId, getProtocolConfig, assetKey, assetByKey, formatNetworkBaseline, KNOWN_ASSETS } from '@elisym/sdk';
3
3
  import { listAgents, createAgentDir, writeYamlInitial, writeExampleSkillTemplate, writeSecrets, resolveAgent, loadAgent, globalConfigPath, writeYaml, writeFileAtomic as writeFileAtomic$1 } from '@elisym/sdk/agent-store';
4
- import { loadGlobalConfig, writeGlobalConfig } from '@elisym/sdk/node';
4
+ import { createIrohTransport, loadGlobalConfig, writeGlobalConfig } from '@elisym/sdk/node';
5
5
  import { getBase58Encoder, getBase58Decoder, generateKeyPairSigner, createSolanaRpc, address, createSolanaRpcSubscriptions, sendAndConfirmTransactionFactory, getSignatureFromTransaction, pipe, createTransactionMessage, setTransactionMessageFeePayerSigner, setTransactionMessageLifetimeUsingBlockhash, appendTransactionMessageInstructions, signTransactionMessageWithSigners, createKeyPairSignerFromBytes, isAddress } from '@solana/kit';
6
6
  import { Command } from 'commander';
7
7
  import { generateSecretKey, nip19, getPublicKey } from 'nostr-tools';
8
- import { stat, readFile, writeFile, rename, unlink } from 'node:fs/promises';
9
- import { homedir, platform } from 'node:os';
10
- import { dirname, join, resolve, isAbsolute, relative } from 'node:path';
11
- import { readFileSync } from 'node:fs';
8
+ import { realpath, readFile, stat, rm, writeFile, rename, unlink } from 'node:fs/promises';
9
+ import { tmpdir, homedir, platform } from 'node:os';
10
+ import { dirname, join, resolve, isAbsolute, basename, relative } from 'node:path';
11
+ import { readFileSync, mkdtempSync } from 'node:fs';
12
12
  import { fileURLToPath } from 'node:url';
13
13
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
14
14
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
@@ -346,6 +346,9 @@ function checkLen(field, value, max) {
346
346
  var MAX_INPUT_LEN = LIMITS.MAX_INPUT_LENGTH;
347
347
  var MAX_CAPABILITIES = LIMITS.MAX_CAPABILITIES;
348
348
  var MAX_TIMEOUT_SECS = LIMITS.MAX_TIMEOUT_SECS;
349
+ LIMITS.NIP44_MAX_PLAINTEXT_BYTES;
350
+ LIMITS.MAX_ENCRYPTED_INLINE_BYTES;
351
+ LIMITS.MAX_REINLINE_TEXT_BYTES;
349
352
  var MAX_NPUB_LEN = 128;
350
353
  var MAX_EVENT_ID_LEN = 128;
351
354
  var MAX_PAYMENT_REQ_LEN = 1e4;
@@ -684,6 +687,32 @@ async function installToConfig(path, entry, agentRebind) {
684
687
  await safeRewriteJson(path, raw, config);
685
688
  return "installed";
686
689
  }
690
+ function ensureIrohTransport(agent) {
691
+ if (agent.irohTransport) {
692
+ return agent.irohTransport;
693
+ }
694
+ let storePath;
695
+ if (agent.agentDir !== void 0) {
696
+ storePath = join(agent.agentDir, ".iroh");
697
+ } else {
698
+ storePath = mkdtempSync(join(tmpdir(), "elisym-iroh-"));
699
+ agent.irohStoreDir = storePath;
700
+ }
701
+ agent.irohTransport = createIrohTransport({ storePath });
702
+ return agent.irohTransport;
703
+ }
704
+ async function shutdownIrohTransport(agent) {
705
+ if (agent.irohTransport) {
706
+ await agent.irohTransport.shutdown().catch(() => {
707
+ });
708
+ agent.irohTransport = void 0;
709
+ }
710
+ if (agent.irohStoreDir !== void 0) {
711
+ await rm(agent.irohStoreDir, { recursive: true, force: true }).catch(() => {
712
+ });
713
+ agent.irohStoreDir = void 0;
714
+ }
715
+ }
687
716
  function createLogger(destination) {
688
717
  const opts = {
689
718
  name: "elisym-mcp",
@@ -1009,8 +1038,27 @@ Solana: ${solanaSigner.address}
1009
1038
  ];
1010
1039
  var execFileP = promisify(execFile);
1011
1040
  var MAX_INPUT_PATH_LEN = 4096;
1012
- var SENSITIVE_NAME_RE = /(^|[/\\])(\.secrets\.json|\.env(\..+)?|id_rsa|id_dsa|id_ecdsa|id_ed25519|.*-keypair\.json|.*\.pem|.*\.key)$/i;
1013
- var SENSITIVE_DIR_SEGMENTS = /* @__PURE__ */ new Set([".elisym", ".ssh", ".aws", ".gnupg"]);
1041
+ var SENSITIVE_NAME_RE = /(^|[/\\])(\.secrets\.json|\.env(\..+)?|id_rsa|id_dsa|id_ecdsa|id_ed25519|.*-keypair\.json|.*\.pem|.*\.key|\.bashrc|\.bash_profile|\.bash_login|\.bash_logout|\.bash_aliases|\.profile|\.zshrc|\.zprofile|\.zshenv|\.zlogin|\.zlogout|config\.fish|\.gitconfig|\.npmrc|\.netrc|crontab|sudoers|bash\.bashrc|.*\.service|.*\.desktop)$/i;
1042
+ var SENSITIVE_DIR_SEGMENTS = /* @__PURE__ */ new Set([
1043
+ ".elisym",
1044
+ ".ssh",
1045
+ ".aws",
1046
+ ".gnupg",
1047
+ ".git",
1048
+ "launchagents",
1049
+ "launchdaemons",
1050
+ "autostart",
1051
+ "systemd",
1052
+ "sudoers.d",
1053
+ "cron.d",
1054
+ "cron.daily",
1055
+ "cron.hourly",
1056
+ "cron.weekly",
1057
+ "cron.monthly",
1058
+ "crontabs",
1059
+ "profile.d",
1060
+ "init.d"
1061
+ ]);
1014
1062
  function isSensitiveInputPath(absPath) {
1015
1063
  if (SENSITIVE_NAME_RE.test(absPath)) {
1016
1064
  return true;
@@ -1021,8 +1069,38 @@ function isSensitiveInputPath(absPath) {
1021
1069
  const segments = absPath.split(/[/\\]+/);
1022
1070
  return segments.some((segment) => SENSITIVE_DIR_SEGMENTS.has(segment.toLowerCase()));
1023
1071
  }
1072
+ async function resolveOutputPath(outputPath, options) {
1073
+ if (outputPath.length > MAX_INPUT_PATH_LEN) {
1074
+ throw new Error(
1075
+ `output_path too long: ${outputPath.length} chars (max ${MAX_INPUT_PATH_LEN}).`
1076
+ );
1077
+ }
1078
+ const cwd = resolve(process.cwd());
1079
+ const logicalPath = isAbsolute(outputPath) ? resolve(outputPath) : resolve(cwd, outputPath);
1080
+ const realParent = await realpath(dirname(logicalPath)).catch(() => dirname(logicalPath));
1081
+ const absPath = resolve(realParent, basename(logicalPath));
1082
+ const realDest = await realpath(logicalPath).catch(() => void 0);
1083
+ const writeTarget = realDest ?? absPath;
1084
+ const sensitiveCandidates = realDest !== void 0 ? [absPath, logicalPath, realDest] : [absPath, logicalPath];
1085
+ if (sensitiveCandidates.some((candidate) => isSensitiveInputPath(candidate))) {
1086
+ throw new Error(
1087
+ `Refusing to write a job result to a sensitive path: ${writeTarget}. Choose a destination outside secret/config/auto-run locations.`
1088
+ );
1089
+ }
1090
+ if (!options?.allowOutsideCwd) {
1091
+ const realCwd = await realpath(cwd).catch(() => cwd);
1092
+ const rel = relative(realCwd, writeTarget);
1093
+ const insideCwd = rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
1094
+ if (!insideCwd) {
1095
+ throw new Error(
1096
+ `output_path "${writeTarget}" resolves outside the working directory (${realCwd}). Choose a destination under the working directory or pass allow_outside_cwd: true.`
1097
+ );
1098
+ }
1099
+ }
1100
+ return writeTarget;
1101
+ }
1024
1102
  var GIT_TIMEOUT_MS = 3e4;
1025
- var GIT_MAX_BUFFER = MAX_INPUT_LEN * 2;
1103
+ var GIT_MAX_BUFFER = LIMITS.MAX_REINLINE_TEXT_BYTES + MAX_INPUT_LEN;
1026
1104
  var GIT_SAFETY_ARGS = [
1027
1105
  "-c",
1028
1106
  "core.fsmonitor=",
@@ -1040,23 +1118,34 @@ function isValidGitRef(ref) {
1040
1118
  }
1041
1119
  return /^[A-Za-z0-9._/@~^-]+$/.test(ref);
1042
1120
  }
1043
- async function readJobInputFile(inputPath, options) {
1121
+ async function validateInputPath(inputPath, options) {
1044
1122
  if (inputPath.length > MAX_INPUT_PATH_LEN) {
1045
1123
  throw new Error(`input_path too long: ${inputPath.length} chars (max ${MAX_INPUT_PATH_LEN}).`);
1046
1124
  }
1047
1125
  const cwd = resolve(process.cwd());
1048
- const absPath = isAbsolute(inputPath) ? resolve(inputPath) : resolve(cwd, inputPath);
1049
- if (isSensitiveInputPath(absPath)) {
1126
+ const logicalPath = isAbsolute(inputPath) ? resolve(inputPath) : resolve(cwd, inputPath);
1127
+ let absPath;
1128
+ try {
1129
+ absPath = await realpath(logicalPath);
1130
+ } catch (e) {
1131
+ const code = e.code;
1132
+ if (code === "ENOENT") {
1133
+ throw new Error(`input_path does not exist: ${logicalPath}`);
1134
+ }
1135
+ throw new Error(`Cannot resolve input_path "${logicalPath}": ${e.message}`);
1136
+ }
1137
+ if (isSensitiveInputPath(absPath) || isSensitiveInputPath(logicalPath)) {
1050
1138
  throw new Error(
1051
1139
  `Refusing to read a sensitive file as job input: ${absPath}. Secret keys, .env, SSH/keypair files, ~/.elisym and /proc are blocked.`
1052
1140
  );
1053
1141
  }
1054
1142
  if (!options?.allowOutsideCwd) {
1055
- const rel = relative(cwd, absPath);
1143
+ const realCwd = await realpath(cwd).catch(() => cwd);
1144
+ const rel = relative(realCwd, absPath);
1056
1145
  const insideCwd = rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
1057
1146
  if (!insideCwd) {
1058
1147
  throw new Error(
1059
- `input_path "${absPath}" resolves outside the working directory (${cwd}). Move the file under the working directory or pass allow_outside_cwd: true.`
1148
+ `input_path "${absPath}" resolves outside the working directory (${realCwd}). Move the file under the working directory or pass allow_outside_cwd: true.`
1060
1149
  );
1061
1150
  }
1062
1151
  }
@@ -1073,18 +1162,36 @@ async function readJobInputFile(inputPath, options) {
1073
1162
  if (!stats.isFile()) {
1074
1163
  throw new Error(`input_path is not a regular file: ${absPath}`);
1075
1164
  }
1076
- if (stats.size > MAX_INPUT_LEN) {
1077
- throw new Error(
1078
- `input_path too large: ${stats.size} bytes (max ${MAX_INPUT_LEN}). Trim the file or split the job.`
1079
- );
1165
+ return { absPath, size: stats.size };
1166
+ }
1167
+ async function isProbablyText(absPath, size) {
1168
+ if (size > LIMITS.MAX_REINLINE_TEXT_BYTES) {
1169
+ return false;
1170
+ }
1171
+ const bytes = await readFile(absPath);
1172
+ if (bytes.includes(0)) {
1173
+ return false;
1080
1174
  }
1081
- const content = await readFile(absPath, "utf-8");
1082
- if (content.length > MAX_INPUT_LEN) {
1175
+ try {
1176
+ const decoder = new TextDecoder("utf-8", { fatal: true });
1177
+ decoder.decode(bytes);
1178
+ return true;
1179
+ } catch {
1180
+ return false;
1181
+ }
1182
+ }
1183
+ async function prepareFileInput(inputPath, options) {
1184
+ const { absPath, size } = await validateInputPath(inputPath, options);
1185
+ if (size === 0) {
1186
+ throw new Error("input_path is an empty file - nothing to send.");
1187
+ }
1188
+ if (size > LIMITS.MAX_FILE_SIZE) {
1083
1189
  throw new Error(
1084
- `input_path content too long after decoding: ${content.length} chars (max ${MAX_INPUT_LEN}).`
1190
+ `input_path too large: ${size} bytes (max ${LIMITS.MAX_FILE_SIZE} for a file transfer).`
1085
1191
  );
1086
1192
  }
1087
- return content;
1193
+ const mime = await isProbablyText(absPath, size) ? "text/plain" : "application/octet-stream";
1194
+ return { absPath, size, name: basename(absPath), mime };
1088
1195
  }
1089
1196
  async function execGit(repoPath, args) {
1090
1197
  try {
@@ -1137,11 +1244,45 @@ async function assertGitRepo(repoPath) {
1137
1244
  throw new Error(`"${repoPath}" is not inside a git work tree: ${message}`);
1138
1245
  }
1139
1246
  }
1140
- async function computeGitDiff(repoPath, base) {
1247
+ async function validateRepoPath(repoPath, options) {
1141
1248
  if (repoPath.length > MAX_INPUT_PATH_LEN) {
1142
1249
  throw new Error(`repo_path too long: ${repoPath.length} chars (max ${MAX_INPUT_PATH_LEN}).`);
1143
1250
  }
1144
- const absRepo = isAbsolute(repoPath) ? repoPath : resolve(process.cwd(), repoPath);
1251
+ const cwd = resolve(process.cwd());
1252
+ const logicalPath = isAbsolute(repoPath) ? resolve(repoPath) : resolve(cwd, repoPath);
1253
+ let absPath;
1254
+ try {
1255
+ absPath = await realpath(logicalPath);
1256
+ } catch (e) {
1257
+ const code = e.code;
1258
+ if (code === "ENOENT") {
1259
+ throw new Error(`repo_path does not exist: ${logicalPath}`);
1260
+ }
1261
+ throw new Error(`Cannot resolve repo_path "${logicalPath}": ${e.message}`);
1262
+ }
1263
+ if (isSensitiveInputPath(absPath) || isSensitiveInputPath(logicalPath)) {
1264
+ throw new Error(
1265
+ `Refusing to review a sensitive path: ${absPath}. Secret keys, .env, SSH/keypair files, ~/.elisym and /proc are blocked.`
1266
+ );
1267
+ }
1268
+ if (!options?.allowOutsideCwd) {
1269
+ const realCwd = await realpath(cwd).catch(() => cwd);
1270
+ const rel = relative(realCwd, absPath);
1271
+ const insideCwd = rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
1272
+ if (!insideCwd) {
1273
+ throw new Error(
1274
+ `repo_path "${absPath}" resolves outside the working directory (${realCwd}). Move the repo under the working directory or pass allow_outside_cwd: true.`
1275
+ );
1276
+ }
1277
+ }
1278
+ const stats = await stat(absPath);
1279
+ if (!stats.isDirectory()) {
1280
+ throw new Error(`repo_path is not a directory: ${absPath}`);
1281
+ }
1282
+ return absPath;
1283
+ }
1284
+ async function computeGitDiff(repoPath, base, options) {
1285
+ const absRepo = await validateRepoPath(repoPath, options);
1145
1286
  await assertGitRepo(absRepo);
1146
1287
  let args;
1147
1288
  let describedRange;
@@ -1151,18 +1292,18 @@ async function computeGitDiff(repoPath, base) {
1151
1292
  `Invalid "base": ${base}. Use a branch/tag/commit ref (letters, digits, ". _ / @ ~ ^ -", no leading "-", no "..").`
1152
1293
  );
1153
1294
  }
1154
- args = ["diff", "--no-ext-diff", "--end-of-options", `${base}...HEAD`];
1295
+ args = ["diff", "--no-ext-diff", "--no-textconv", "--end-of-options", `${base}...HEAD`];
1155
1296
  describedRange = `${base}...HEAD`;
1156
1297
  } else if (await isDirty(absRepo)) {
1157
- args = ["diff", "--no-ext-diff", "HEAD"];
1298
+ args = ["diff", "--no-ext-diff", "--no-textconv", "HEAD"];
1158
1299
  describedRange = "HEAD (working tree, uncommitted changes)";
1159
1300
  } else {
1160
1301
  const detected = await detectDefaultBase(absRepo);
1161
1302
  if (detected) {
1162
- args = ["diff", "--no-ext-diff", "--end-of-options", `${detected}...HEAD`];
1303
+ args = ["diff", "--no-ext-diff", "--no-textconv", "--end-of-options", `${detected}...HEAD`];
1163
1304
  describedRange = `${detected}...HEAD`;
1164
1305
  } else {
1165
- args = ["diff", "--no-ext-diff", "HEAD"];
1306
+ args = ["diff", "--no-ext-diff", "--no-textconv", "HEAD"];
1166
1307
  describedRange = "HEAD (no main/master detected)";
1167
1308
  }
1168
1309
  }
@@ -1172,9 +1313,10 @@ async function computeGitDiff(repoPath, base) {
1172
1313
  `No changes in range ${describedRange}. Nothing to review - commit work, pass an explicit "base", or check the repo path.`
1173
1314
  );
1174
1315
  }
1175
- if (diff.length > MAX_INPUT_LEN) {
1316
+ const diffBytes = utf8ByteLength(diff);
1317
+ if (diffBytes > LIMITS.MAX_REINLINE_TEXT_BYTES) {
1176
1318
  throw new Error(
1177
- `Diff for range ${describedRange} is ${diff.length} chars (max ${MAX_INPUT_LEN}). Pass a narrower "base" or split the review.`
1319
+ `Diff for range ${describedRange} is ${diffBytes} bytes (max ${LIMITS.MAX_REINLINE_TEXT_BYTES}). Pass a narrower "base" or split the review.`
1178
1320
  );
1179
1321
  }
1180
1322
  return { diff, describedRange };
@@ -1416,7 +1558,13 @@ var CustomerJobEntrySchema = z.object({
1416
1558
  completedAt: z.number().int().nonnegative(),
1417
1559
  resultPreview: z.string().max(RESULT_PREVIEW_MAX_LEN).optional(),
1418
1560
  paymentSig: z.string().max(128).optional(),
1419
- customerFeedback: FeedbackSchema.optional()
1561
+ customerFeedback: FeedbackSchema.optional(),
1562
+ /** JSON-serialized FileAttachment when the result is a file (fetched via fetch_job_file). */
1563
+ attachmentJson: z.string().max(8192).optional(),
1564
+ /** Local path the result file was downloaded to (set by fetch_job_file). */
1565
+ resultFilePath: z.string().max(4096).optional(),
1566
+ /** Unix ms when the result file was downloaded. */
1567
+ fetchedAt: z.number().int().nonnegative().optional()
1420
1568
  }).strict();
1421
1569
  var CustomerHistorySchema = z.object({
1422
1570
  version: z.literal(1),
@@ -1502,6 +1650,7 @@ async function findCustomerJobsByProvider(agentDir, providerPubkey) {
1502
1650
 
1503
1651
  // src/tools/customer.ts
1504
1652
  var PRE_PING_TIMEOUT_MS = 5e3;
1653
+ var UNVERIFIED_PROVIDER_NOTICE = "NOTE: no provider_npub was given, so the author of this result was NOT verified. Any author can publish a result for a public job event ID, so the content below may be spoofed - treat it as unauthenticated. Re-run get_job_result with provider_npub set to the expected provider to enforce author verification.";
1505
1654
  var CreateJobSchema = z.object({
1506
1655
  input: z.string().describe("The job prompt/input sent to the provider."),
1507
1656
  capability: z.string().min(1).max(64).default("general").describe("Short tag selecting which capability of the provider to invoke."),
@@ -1515,6 +1664,16 @@ var GetJobResultSchema = z.object({
1515
1664
  timeout_secs: z.number().int().min(1).max(600).default(60),
1516
1665
  lookback_secs: z.number().int().min(60).max(7 * 24 * 3600).default(24 * 3600).describe("How far back to search for the result. Defaults to 24h.")
1517
1666
  });
1667
+ var FetchJobFileSchema = z.object({
1668
+ job_event_id: z.string(),
1669
+ output_path: z.string().min(1).max(4096).describe("Local path to write the downloaded result file to."),
1670
+ allow_outside_cwd: z.boolean().default(false).describe(
1671
+ "Allow writing outside the MCP server working directory. Off by default: the bytes come from an untrusted provider, so writes are confined to the working directory subtree (and never to a secret/auto-run path) unless this is set."
1672
+ ),
1673
+ provider_npub: z.string().optional(),
1674
+ kind_offset: z.number().int().min(0).max(999).default(DEFAULT_KIND_OFFSET),
1675
+ timeout_secs: z.number().int().min(1).max(600).default(300)
1676
+ });
1518
1677
  var ListMyJobsSchema = z.object({
1519
1678
  limit: z.number().int().min(1).max(50).default(20),
1520
1679
  kind_offset: z.number().int().min(0).max(999).default(DEFAULT_KIND_OFFSET),
@@ -1560,20 +1719,30 @@ var SubmitDiffReviewSchema = z.object({
1560
1719
  prompt: z.string().max(MAX_INPUT_LEN).default("").describe('Optional instructions prepended above the diff (e.g. "focus on auth flow").'),
1561
1720
  kind_offset: z.number().int().min(0).max(999).default(DEFAULT_KIND_OFFSET),
1562
1721
  timeout_secs: z.number().int().min(1).max(600).default(300),
1563
- max_price_lamports: z.number().int().optional()
1722
+ max_price_lamports: z.number().int().optional(),
1723
+ allow_outside_cwd: z.boolean().default(false).describe(
1724
+ "Allow reviewing a repo outside the MCP server working directory. Off by default - the diff is forwarded to the provider before payment and is invisible in the transcript, so the repo is confined to the working dir subtree unless this is set. Sensitive paths (secret keys, .env, SSH/keypair, ~/.elisym, /proc) are always refused."
1725
+ )
1564
1726
  });
1565
- function providerSolanaAddress(provider, dTag) {
1727
+ function paymentCardForCapability(provider, dTag) {
1566
1728
  const cards = provider.cards ?? [];
1567
1729
  const candidates = dTag ? cards.filter(
1568
- (c) => toDTag(c.name) === dTag || c.capabilities?.some((cap) => toDTag(cap) === dTag)
1730
+ (card) => toDTag(card.name) === dTag || card.capabilities?.some((capability) => toDTag(capability) === dTag)
1569
1731
  ) : cards;
1570
1732
  for (const card of candidates.length > 0 ? candidates : cards) {
1571
1733
  if (card.payment?.chain === "solana" && card.payment?.address) {
1572
- return card.payment.address;
1734
+ return card;
1573
1735
  }
1574
1736
  }
1575
1737
  return void 0;
1576
1738
  }
1739
+ function providerSolanaAddress(provider, dTag) {
1740
+ return paymentCardForCapability(provider, dTag)?.payment?.address;
1741
+ }
1742
+ function advertisedPriceForCapability(provider, dTag) {
1743
+ const card = paymentCardForCapability(provider, dTag);
1744
+ return { price: card?.payment?.job_price ?? 0, asset: assetFromCardPayment(card?.payment) };
1745
+ }
1577
1746
  function wsUrlFor(httpUrl) {
1578
1747
  return httpUrl.replace(/^https:\/\//, "wss://").replace(/^http:\/\//, "ws://");
1579
1748
  }
@@ -1623,6 +1792,29 @@ ${formatNetworkBaseline(baseline)}`;
1623
1792
  return "";
1624
1793
  }
1625
1794
  }
1795
+ async function confirmPriceGate(opts) {
1796
+ const { agent, providerLabel, capability, price, asset, maxPriceLamports, toolName } = opts;
1797
+ if (maxPriceLamports !== void 0 && price > maxPriceLamports) {
1798
+ return errorResult(
1799
+ `Price ${formatAssetAmount(asset, BigInt(price))} exceeds max ${formatAssetAmount(asset, BigInt(maxPriceLamports))}`
1800
+ );
1801
+ }
1802
+ if (price > 0 && maxPriceLamports === void 0) {
1803
+ const gasLine = await gasHintForCardAsset(agent, asset);
1804
+ const subject = toolName === "buy_capability" ? `Capability "${capability}" from "${providerLabel}"` : `Job for capability "${capability}" from "${providerLabel}"`;
1805
+ const { text } = sanitizeUntrusted(
1806
+ `${subject} costs ${formatAssetAmount(asset, BigInt(price))}.${gasLine}
1807
+
1808
+ To confirm, call ${toolName} again with max_price_lamports set (e.g. ${price} or higher).`,
1809
+ "text"
1810
+ );
1811
+ return { content: [{ type: "text", text }] };
1812
+ }
1813
+ return null;
1814
+ }
1815
+ function rejectWithProviderError(reject, providerError) {
1816
+ reject(new Error(`Job error: ${sanitizeUntrusted(providerError, "text").text}`));
1817
+ }
1626
1818
  var paymentStrategy = new SolanaPaymentStrategy();
1627
1819
  async function executePaymentFlow(agent, paymentRequest, jobId, providerPubkey, expectedRecipient) {
1628
1820
  let requestData;
@@ -1749,7 +1941,7 @@ function makePaymentFeedbackHandler(opts) {
1749
1941
  opts.resolveNoWallet(
1750
1942
  `Payment required but no Solana wallet configured.
1751
1943
  Amount: ${signedAmount !== void 0 ? formatAssetAmount(asset, BigInt(signedAmount)) : "unknown"}
1752
- Payment request: ${paymentRequest}`
1944
+ Payment request: ${sanitizeUntrusted(paymentRequest, "structured").text}`
1753
1945
  );
1754
1946
  return;
1755
1947
  }
@@ -1788,6 +1980,57 @@ Payment request: ${paymentRequest}`
1788
1980
  };
1789
1981
  return { onFeedback, onResultReceived };
1790
1982
  }
1983
+ function formatFileResultMetadata(jobId, attachment) {
1984
+ const name = sanitizeField(attachment.name, 200);
1985
+ const mime = sanitizeField(attachment.mime, 100);
1986
+ const details = sanitizeUntrusted(
1987
+ `name: ${name}
1988
+ size: ${attachment.size} bytes
1989
+ type: ${mime}`,
1990
+ "text"
1991
+ ).text;
1992
+ return `Job completed. The result is a FILE (not inlined here):
1993
+ ${details}
1994
+ Download it with fetch_job_file(job_event_id="${jobId}", output_path="<local path>").`;
1995
+ }
1996
+ function decodeResultPreview(rawContent) {
1997
+ try {
1998
+ const decoded = decodeJobPayload(rawContent);
1999
+ if (decoded.attachment) {
2000
+ return `[file result: ${decoded.attachment.name} (${decoded.attachment.size} bytes). Download with fetch_job_file.]`;
2001
+ }
2002
+ return decoded.text ?? rawContent;
2003
+ } catch {
2004
+ return rawContent;
2005
+ }
2006
+ }
2007
+ async function prepareTextInput(agent, text) {
2008
+ if (utf8ByteLength(text) <= LIMITS.MAX_ENCRYPTED_INLINE_BYTES) {
2009
+ return { input: text };
2010
+ }
2011
+ const byteLength = utf8ByteLength(text);
2012
+ if (agent.agentDir === void 0) {
2013
+ return {
2014
+ error: `Input is ${byteLength} bytes, over the ${LIMITS.MAX_ENCRYPTED_INLINE_BYTES}-byte inline limit, so it must be sent via P2P transfer - which requires a persistent agent (this is an ephemeral session).`
2015
+ };
2016
+ }
2017
+ try {
2018
+ const seeded = await ensureIrohTransport(agent).seedBytes(Buffer.from(text, "utf8"));
2019
+ return {
2020
+ input: "",
2021
+ attachment: {
2022
+ name: "input.txt",
2023
+ size: seeded.size,
2024
+ mime: "text/plain",
2025
+ transports: [{ kind: "iroh", ticket: seeded.ticket }]
2026
+ }
2027
+ };
2028
+ } catch (e) {
2029
+ return {
2030
+ error: `Failed to seed input for transfer: ${e instanceof Error ? e.message : String(e)}`
2031
+ };
2032
+ }
2033
+ }
1791
2034
  async function executeSubmitAndPay(ctx, agent, params) {
1792
2035
  const ping = await agent.client.ping.pingAgent(params.providerPubkey, PRE_PING_TIMEOUT_MS);
1793
2036
  if (!ping.online) {
@@ -1814,17 +2057,35 @@ async function executeSubmitAndPay(ctx, agent, params) {
1814
2057
  `Cannot buy from yourself - your agent's Solana wallet (${buyerWallet}) matches the provider's payment address. Use a different agent or provider.`
1815
2058
  );
1816
2059
  }
2060
+ const { price: advertisedPrice, asset: advertisedAsset } = advertisedPriceForCapability(
2061
+ provider,
2062
+ params.dTag
2063
+ );
2064
+ const priceGate = await confirmPriceGate({
2065
+ agent,
2066
+ providerLabel: sanitizeField(provider.name || params.providerNpub, 64),
2067
+ capability: params.capability,
2068
+ price: advertisedPrice,
2069
+ asset: advertisedAsset,
2070
+ maxPriceLamports: params.maxPriceLamports,
2071
+ toolName: params.toolName
2072
+ });
2073
+ if (priceGate) {
2074
+ return priceGate;
2075
+ }
1817
2076
  const submittedAt = Date.now();
1818
2077
  const jobId = await agent.client.marketplace.submitJobRequest(agent.identity, {
1819
2078
  input: params.input,
1820
2079
  capability: params.dTag,
1821
2080
  providerPubkey: params.providerPubkey,
1822
- kindOffset: params.kindOffset
2081
+ kindOffset: params.kindOffset,
2082
+ attachment: params.attachment
1823
2083
  });
1824
2084
  let paymentSig;
1825
2085
  let paidAmountSubunits;
1826
2086
  let paidAssetKey;
1827
2087
  let paymentWarnings = [];
2088
+ let resultAttachment;
1828
2089
  try {
1829
2090
  const result = await awaitJobResult(
1830
2091
  agent,
@@ -1855,7 +2116,12 @@ async function executeSubmitAndPay(ctx, agent, params) {
1855
2116
  providerPubkey: params.providerPubkey,
1856
2117
  customerPublicKey: agent.identity.publicKey,
1857
2118
  callbacks: {
1858
- onResult(content) {
2119
+ onResult(content, _eventId, attachment) {
2120
+ if (attachment) {
2121
+ resultAttachment = attachment;
2122
+ payHandler.onResultReceived(formatFileResultMetadata(jobId, attachment));
2123
+ return;
2124
+ }
1859
2125
  const kind = isLikelyBase64(content) ? "binary" : "text";
1860
2126
  const sanitized = sanitizeUntrusted(content, kind);
1861
2127
  payHandler.onResultReceived(`Job completed.
@@ -1864,7 +2130,7 @@ ${sanitized.text}`);
1864
2130
  },
1865
2131
  onFeedback: payHandler.onFeedback,
1866
2132
  onError(error) {
1867
- reject(new Error(`Job error: ${error}`));
2133
+ rejectWithProviderError(reject, error);
1868
2134
  },
1869
2135
  onTimeout(timeoutMs) {
1870
2136
  reject(new JobWaitTimeoutError(timeoutMs));
@@ -1887,7 +2153,8 @@ ${sanitized.text}`);
1887
2153
  submittedAt,
1888
2154
  completedAt: Date.now(),
1889
2155
  resultPreview: result.slice(0, RESULT_PREVIEW_MAX_LEN),
1890
- paymentSig
2156
+ paymentSig,
2157
+ attachmentJson: resultAttachment ? JSON.stringify(resultAttachment) : void 0
1891
2158
  });
1892
2159
  const warningBlock = paymentWarnings.length > 0 ? `${paymentWarnings.join("\n")}
1893
2160
  ` : "";
@@ -2018,7 +2285,11 @@ var customerTools = [
2018
2285
  providerPubkey,
2019
2286
  customerPublicKey: agent.identity.publicKey,
2020
2287
  callbacks: {
2021
- onResult(content, _eventId) {
2288
+ onResult(content, _eventId, attachment) {
2289
+ if (attachment) {
2290
+ resolve(formatFileResultMetadata(input.job_event_id, attachment));
2291
+ return;
2292
+ }
2022
2293
  const kind = isLikelyBase64(content) ? "binary" : "text";
2023
2294
  const sanitized = sanitizeUntrusted(content, kind);
2024
2295
  resolve(sanitized.text);
@@ -2029,7 +2300,7 @@ var customerTools = [
2029
2300
  }
2030
2301
  },
2031
2302
  onError(error) {
2032
- reject(new Error(`Job error: ${error}`));
2303
+ rejectWithProviderError(reject, error);
2033
2304
  },
2034
2305
  onTimeout(timeoutMs) {
2035
2306
  reject(new JobWaitTimeoutError(timeoutMs));
@@ -2051,9 +2322,87 @@ var customerTools = [
2051
2322
  }
2052
2323
  return errorResult(`Failed to fetch result for event_id="${input.job_event_id}": ${msg}`);
2053
2324
  }
2325
+ if (providerPubkey === void 0) {
2326
+ return textResult(`${UNVERIFIED_PROVIDER_NOTICE}
2327
+
2328
+ ${result}`);
2329
+ }
2054
2330
  return textResult(result);
2055
2331
  }
2056
2332
  }),
2333
+ defineTool({
2334
+ name: "fetch_job_file",
2335
+ description: "Download a job result that was delivered as a FILE (transferred P2P via iroh) to a local path. Use this after submit_and_pay_job or get_job_result reports a file result. Resumable and bounded by a max file size; the bytes are written to disk, never returned to you inline.",
2336
+ schema: FetchJobFileSchema,
2337
+ async handler(ctx, input) {
2338
+ checkLen("job_event_id", input.job_event_id, MAX_EVENT_ID_LEN);
2339
+ let outputPath;
2340
+ try {
2341
+ outputPath = await resolveOutputPath(input.output_path, {
2342
+ allowOutsideCwd: input.allow_outside_cwd
2343
+ });
2344
+ } catch (error) {
2345
+ return errorResult(error instanceof Error ? error.message : String(error));
2346
+ }
2347
+ const agent = ctx.active();
2348
+ let attachment;
2349
+ if (agent.agentDir !== void 0) {
2350
+ const entry = await findCustomerJob(agent.agentDir, input.job_event_id);
2351
+ if (entry?.attachmentJson !== void 0) {
2352
+ try {
2353
+ attachment = JSON.parse(entry.attachmentJson);
2354
+ } catch {
2355
+ }
2356
+ }
2357
+ }
2358
+ if (attachment === void 0) {
2359
+ try {
2360
+ const results = await agent.client.marketplace.queryJobResults(
2361
+ agent.identity,
2362
+ [input.job_event_id],
2363
+ [input.kind_offset]
2364
+ );
2365
+ const resultEntry = results.get(input.job_event_id);
2366
+ if (resultEntry !== void 0 && !resultEntry.decryptionFailed) {
2367
+ attachment = decodeJobPayload(resultEntry.content).attachment;
2368
+ }
2369
+ } catch (error) {
2370
+ logger.warn(
2371
+ { event: "fetch_job_file_query_failed", err: String(error) },
2372
+ "relay re-fetch of result failed"
2373
+ );
2374
+ }
2375
+ }
2376
+ if (attachment === void 0) {
2377
+ return errorResult(
2378
+ `No file result found for event_id="${input.job_event_id}". It may be a text result, not yet delivered, or expired from the relays.`
2379
+ );
2380
+ }
2381
+ const irohTransport = attachment.transports.find((transport) => transport.kind === "iroh");
2382
+ if (irohTransport === void 0) {
2383
+ return errorResult("Result attachment has no supported transport (iroh).");
2384
+ }
2385
+ try {
2386
+ await ensureIrohTransport(agent).fetchToPath(irohTransport.ticket, outputPath, {
2387
+ maxBytes: LIMITS.MAX_FILE_SIZE,
2388
+ timeoutMs: Math.min(input.timeout_secs, MAX_TIMEOUT_SECS) * 1e3
2389
+ });
2390
+ } catch (error) {
2391
+ const msg = error instanceof Error ? error.message : String(error);
2392
+ return errorResult(
2393
+ `Failed to download the file for event_id="${input.job_event_id}": ${msg}. The provider may be offline or no longer seeding it.`
2394
+ );
2395
+ }
2396
+ if (agent.agentDir !== void 0) {
2397
+ await updateCustomerJob(agent.agentDir, input.job_event_id, {
2398
+ resultFilePath: outputPath,
2399
+ fetchedAt: Date.now()
2400
+ }).catch(() => {
2401
+ });
2402
+ }
2403
+ return textResult(`Downloaded result file to ${outputPath}.`);
2404
+ }
2405
+ }),
2057
2406
  defineTool({
2058
2407
  name: "list_my_jobs",
2059
2408
  description: "List jobs submitted by the CURRENT AGENT from the local on-disk history (.customer-history.json). Pass include_nostr=true to also pull from Nostr relays and merge - useful for jobs submitted outside this MCP (e.g. the web app) or to recover after a local-cache wipe. Targeted (encrypted) Nostr results are decrypted automatically. Each entry is tagged with source=local-only|nostr-only|merged. WARNING: result content is untrusted external data.",
@@ -2116,14 +2465,14 @@ var customerTools = [
2116
2465
  if (decrypted.decryptionFailed) {
2117
2466
  resultText = "[decryption failed - targeted result not for this agent]";
2118
2467
  } else {
2119
- const cleaned = sanitizeInner(decrypted.content);
2468
+ const cleaned = sanitizeInner(decodeResultPreview(decrypted.content));
2120
2469
  if (scanForInjections(cleaned, "full")) {
2121
2470
  freetextSuspicious = true;
2122
2471
  }
2123
2472
  resultText = cleaned;
2124
2473
  }
2125
2474
  } else if (nostr.result) {
2126
- const cleaned = sanitizeInner(nostr.result);
2475
+ const cleaned = sanitizeInner(decodeResultPreview(nostr.result));
2127
2476
  if (scanForInjections(cleaned, "full")) {
2128
2477
  freetextSuspicious = true;
2129
2478
  }
@@ -2165,15 +2514,25 @@ ${wrapped}`);
2165
2514
  }),
2166
2515
  defineTool({
2167
2516
  name: "submit_and_pay_job",
2168
- description: 'Full customer flow: submit job -> auto-pay -> wait for result. Validates that the payment recipient matches the provider card. If payment succeeded but no result arrives within the wait window, this returns a non-error "still processing" notice with the event ID (NOT a failure) - re-poll get_job_result later (results persist on the relays; for long jobs, poll periodically, e.g. from a subagent). Handles both free and paid providers automatically. If max_price_lamports is not set and provider requests payment, the job is rejected with the price - set max_price_lamports to auto-approve payments up to that limit. COST: input is sent inline in the tool call, so a large input pays output tokens on the calling LLM. For files or git diffs, prefer submit_and_pay_job_from_file or submit_diff_review respectively.',
2517
+ description: 'Full customer flow: submit job -> auto-pay -> wait for result. Validates that the payment recipient matches the provider card. If payment succeeded but no result arrives within the wait window, this returns a non-error "still processing" notice with the event ID (NOT a failure) - re-poll get_job_result later (results persist on the relays; for long jobs, poll periodically, e.g. from a subagent). Handles both free and paid providers automatically. If max_price_lamports is not set and the capability is paid, this returns the advertised price for confirmation WITHOUT submitting a job - re-call with max_price_lamports set to approve payments up to that limit (this is a confirmation, not an error). COST: input is sent inline in the tool call, so a large input pays output tokens on the calling LLM. For files or git diffs, prefer submit_and_pay_job_from_file or submit_diff_review respectively.',
2169
2518
  schema: SubmitAndPayJobSchema,
2170
2519
  async handler(ctx, input) {
2171
2520
  ctx.toolRateLimiter.check();
2172
- checkLen("input", input.input, MAX_INPUT_LEN);
2173
2521
  checkLen("provider_npub", input.provider_npub, MAX_NPUB_LEN);
2522
+ const inputBytes = utf8ByteLength(input.input);
2523
+ if (inputBytes > LIMITS.MAX_REINLINE_TEXT_BYTES) {
2524
+ return errorResult(
2525
+ `Input is ${inputBytes} bytes (max ${LIMITS.MAX_REINLINE_TEXT_BYTES} for an inline job). Send a large file with submit_and_pay_job_from_file.`
2526
+ );
2527
+ }
2174
2528
  const agent = ctx.active();
2529
+ const prepared = await prepareTextInput(agent, input.input);
2530
+ if ("error" in prepared) {
2531
+ return errorResult(prepared.error);
2532
+ }
2175
2533
  return executeSubmitAndPay(ctx, agent, {
2176
- input: input.input,
2534
+ input: prepared.input,
2535
+ attachment: prepared.attachment,
2177
2536
  providerNpub: input.provider_npub,
2178
2537
  providerPubkey: decodeNpub(input.provider_npub),
2179
2538
  capability: input.capability,
@@ -2184,35 +2543,61 @@ ${wrapped}`);
2184
2543
  dTag: toDTag(input.capability),
2185
2544
  kindOffset: input.kind_offset,
2186
2545
  timeoutMs: Math.min(input.timeout_secs, MAX_TIMEOUT_SECS) * 1e3,
2187
- maxPriceLamports: input.max_price_lamports
2546
+ maxPriceLamports: input.max_price_lamports,
2547
+ toolName: "submit_and_pay_job"
2188
2548
  });
2189
2549
  }
2190
2550
  }),
2191
2551
  defineTool({
2192
2552
  name: "submit_and_pay_job_from_file",
2193
- description: "Same as submit_and_pay_job, but the job input is read from a file on disk by the MCP server instead of being passed inline by the LLM. Use this when the input is large (logs, generated content, captured output) and the LLM only needs to forward it - the file content never enters the model's output tokens. input_path may be absolute or relative to the MCP server's working directory. Max file size matches the inline limit.",
2553
+ description: "Same as submit_and_pay_job, but the job input is read from a file on disk by the MCP server instead of being passed inline by the LLM. Use this when the input is large or binary (images, logs, captured output) and the LLM only needs to forward it - the file content never enters the model's output tokens. input_path may be absolute or relative to the MCP server's working directory. The file is ALWAYS transferred peer-to-peer via iroh, so this needs: a persistent agent, a PAID provider skill (free skills reject file inputs), and the iroh addon. Text files reach the skill on stdin; binary files via ELISYM_INPUT_FILE.",
2194
2554
  schema: SubmitAndPayJobFromFileSchema,
2195
2555
  async handler(ctx, input) {
2196
2556
  ctx.toolRateLimiter.check();
2197
2557
  checkLen("provider_npub", input.provider_npub, MAX_NPUB_LEN);
2198
- let payload;
2558
+ let prepared;
2199
2559
  try {
2200
- payload = await readJobInputFile(input.input_path, {
2560
+ prepared = await prepareFileInput(input.input_path, {
2201
2561
  allowOutsideCwd: input.allow_outside_cwd
2202
2562
  });
2203
2563
  } catch (e) {
2204
2564
  return errorResult(e instanceof Error ? e.message : String(e));
2205
2565
  }
2206
2566
  const agent = ctx.active();
2567
+ if (agent.agentDir === void 0) {
2568
+ return errorResult(
2569
+ `Sending a file requires a persistent agent (this is an ephemeral session). Files are always transferred P2P via iroh, never inline.`
2570
+ );
2571
+ }
2572
+ let attachment;
2573
+ try {
2574
+ const seeded = await ensureIrohTransport(agent).seedPath(prepared.absPath);
2575
+ attachment = {
2576
+ name: prepared.name,
2577
+ size: seeded.size,
2578
+ mime: prepared.mime,
2579
+ transports: [{ kind: "iroh", ticket: seeded.ticket }]
2580
+ };
2581
+ } catch (e) {
2582
+ const msg = e instanceof Error ? e.message : String(e);
2583
+ if (/@number0\/iroh|iroh file transfer is unavailable/i.test(msg)) {
2584
+ return errorResult(
2585
+ `File transfer is unavailable: the optional @number0/iroh addon is not installed. Install it (e.g. \`bun add @number0/iroh\`) to send files.`
2586
+ );
2587
+ }
2588
+ return errorResult(`Failed to seed file for transfer: ${msg}`);
2589
+ }
2207
2590
  return executeSubmitAndPay(ctx, agent, {
2208
- input: payload,
2591
+ input: "",
2592
+ attachment,
2209
2593
  providerNpub: input.provider_npub,
2210
2594
  providerPubkey: decodeNpub(input.provider_npub),
2211
2595
  capability: input.capability,
2212
2596
  dTag: toDTag(input.capability),
2213
2597
  kindOffset: input.kind_offset,
2214
2598
  timeoutMs: Math.min(input.timeout_secs, MAX_TIMEOUT_SECS) * 1e3,
2215
- maxPriceLamports: input.max_price_lamports
2599
+ maxPriceLamports: input.max_price_lamports,
2600
+ toolName: "submit_and_pay_job_from_file"
2216
2601
  });
2217
2602
  }
2218
2603
  }),
@@ -2225,7 +2610,9 @@ ${wrapped}`);
2225
2610
  checkLen("provider_npub", input.provider_npub, MAX_NPUB_LEN);
2226
2611
  let diffResult;
2227
2612
  try {
2228
- diffResult = await computeGitDiff(input.repo_path, input.base);
2613
+ diffResult = await computeGitDiff(input.repo_path, input.base, {
2614
+ allowOutsideCwd: input.allow_outside_cwd
2615
+ });
2229
2616
  } catch (e) {
2230
2617
  return errorResult(e instanceof Error ? e.message : String(e));
2231
2618
  }
@@ -2234,21 +2621,28 @@ ${wrapped}`);
2234
2621
  ` : "";
2235
2622
  const payload = `${promptBlock}--- git diff (${diffResult.describedRange}) ---
2236
2623
  ${diffResult.diff}`;
2237
- if (payload.length > MAX_INPUT_LEN) {
2624
+ const payloadBytes = utf8ByteLength(payload);
2625
+ if (payloadBytes > LIMITS.MAX_REINLINE_TEXT_BYTES) {
2238
2626
  return errorResult(
2239
- `Combined prompt + diff is ${payload.length} chars (max ${MAX_INPUT_LEN}). Shorten the prompt or pass a narrower base.`
2627
+ `Combined prompt + diff is ${payloadBytes} bytes (max ${LIMITS.MAX_REINLINE_TEXT_BYTES}). Pass a narrower "base" or shorten the prompt.`
2240
2628
  );
2241
2629
  }
2242
2630
  const agent = ctx.active();
2631
+ const prepared = await prepareTextInput(agent, payload);
2632
+ if ("error" in prepared) {
2633
+ return errorResult(prepared.error);
2634
+ }
2243
2635
  return executeSubmitAndPay(ctx, agent, {
2244
- input: payload,
2636
+ input: prepared.input,
2637
+ attachment: prepared.attachment,
2245
2638
  providerNpub: input.provider_npub,
2246
2639
  providerPubkey: decodeNpub(input.provider_npub),
2247
2640
  capability: input.capability,
2248
2641
  dTag: toDTag(input.capability),
2249
2642
  kindOffset: input.kind_offset,
2250
2643
  timeoutMs: Math.min(input.timeout_secs, MAX_TIMEOUT_SECS) * 1e3,
2251
- maxPriceLamports: input.max_price_lamports
2644
+ maxPriceLamports: input.max_price_lamports,
2645
+ toolName: "submit_diff_review"
2252
2646
  });
2253
2647
  }
2254
2648
  }),
@@ -2290,30 +2684,17 @@ ${diffResult.diff}`;
2290
2684
  );
2291
2685
  return errorResult(text);
2292
2686
  }
2293
- const price = card.payment?.job_price ?? 0;
2294
- const cardAsset = assetFromCardPayment(card.payment);
2295
- if (input.max_price_lamports !== void 0 && price > input.max_price_lamports) {
2296
- return errorResult(
2297
- `Price ${formatAssetAmount(cardAsset, BigInt(price))} exceeds max ${formatAssetAmount(cardAsset, BigInt(input.max_price_lamports))}`
2298
- );
2299
- }
2300
- if (price > 0 && input.max_price_lamports === void 0) {
2301
- const gasLine = await gasHintForCardAsset(agent, cardAsset);
2302
- const safeProviderName = sanitizeField(provider.name || input.provider_npub, 64);
2303
- const { text } = sanitizeUntrusted(
2304
- `Capability "${input.capability}" from "${safeProviderName}" costs ${formatAssetAmount(cardAsset, BigInt(price))}.${gasLine}
2305
-
2306
- To confirm, call buy_capability again with max_price_lamports set (e.g. ${price} or higher).`,
2307
- "text"
2308
- );
2309
- return {
2310
- content: [
2311
- {
2312
- type: "text",
2313
- text
2314
- }
2315
- ]
2316
- };
2687
+ const priceGate = await confirmPriceGate({
2688
+ agent,
2689
+ providerLabel: sanitizeField(provider.name || input.provider_npub, 64),
2690
+ capability: input.capability,
2691
+ price: card.payment?.job_price ?? 0,
2692
+ asset: assetFromCardPayment(card.payment),
2693
+ maxPriceLamports: input.max_price_lamports,
2694
+ toolName: "buy_capability"
2695
+ });
2696
+ if (priceGate) {
2697
+ return priceGate;
2317
2698
  }
2318
2699
  const expectedRecipient = card.payment?.chain === "solana" ? card.payment.address : void 0;
2319
2700
  if (agent.solanaKeypair && !expectedRecipient) {
@@ -2367,7 +2748,11 @@ To confirm, call buy_capability again with max_price_lamports set (e.g. ${price}
2367
2748
  providerPubkey,
2368
2749
  customerPublicKey: agent.identity.publicKey,
2369
2750
  callbacks: {
2370
- onResult(content) {
2751
+ onResult(content, _eventId, attachment) {
2752
+ if (attachment) {
2753
+ payHandler.onResultReceived(formatFileResultMetadata(jobId, attachment));
2754
+ return;
2755
+ }
2371
2756
  const kind = isLikelyBase64(content) ? "binary" : "text";
2372
2757
  const sanitized = sanitizeUntrusted(content, kind);
2373
2758
  payHandler.onResultReceived(
@@ -2378,7 +2763,7 @@ ${sanitized.text}`
2378
2763
  },
2379
2764
  onFeedback: payHandler.onFeedback,
2380
2765
  onError(error) {
2381
- reject(new Error(`Job error: ${error}`));
2766
+ rejectWithProviderError(reject, error);
2382
2767
  },
2383
2768
  onTimeout(timeoutMs) {
2384
2769
  reject(new JobWaitTimeoutError(timeoutMs));
@@ -2892,7 +3277,13 @@ var discoveryTools = [
2892
3277
  asset_mint: asset.mint,
2893
3278
  chain: card.payment?.chain,
2894
3279
  network: card.payment?.network,
2895
- network_fee_estimate_sol: gasEstimate
3280
+ network_fee_estimate_sol: gasEstimate,
3281
+ // File-exchange hints (dynamic-script). Informational: the MCP/CLI
3282
+ // CAN send files via submit_and_pay_job_from_file, so this does not
3283
+ // gate anything - it just tells the caller a file input is expected.
3284
+ // Already length-bounded by parseCapabilityEvent.
3285
+ ...card.inputMime ? { input_mime: card.inputMime } : {},
3286
+ ...card.outputMime ? { output_mime: card.outputMime } : {}
2896
3287
  };
2897
3288
  }),
2898
3289
  supported_kinds: a.supportedKinds,
@@ -3701,12 +4092,20 @@ if (toolMap.size !== allTools.length) {
3701
4092
  );
3702
4093
  }
3703
4094
  function redactSecrets(text) {
3704
- return text.replace(/\bnsec1[02-9ac-hj-np-z]{20,}\b/gi, "[REDACTED]").replace(/\bsk-(?:ant-)?[A-Za-z0-9_-]{16,}\b/g, "[REDACTED]").replace(/\b[0-9a-fA-F]{64}\b/g, "[REDACTED]").replace(/\b[1-9A-HJ-NP-Za-km-z]{80,}\b/g, "[REDACTED]");
4095
+ return text.replace(/\bnsec1[02-9ac-hj-np-z]{20,}\b/gi, "[REDACTED]").replace(/\bsk-(?:ant-)?[A-Za-z0-9_-]{16,}\b/g, "[REDACTED]").replace(/\b[0-9a-fA-F]{64}\b/g, "[REDACTED]").replace(/\b[1-9A-HJ-NP-Za-km-z]{80,}\b/g, "[REDACTED]").replace(/\[\s*(?:\d{1,3}\s*,\s*){31,}\d{1,3}\s*\]/g, "[REDACTED]");
3705
4096
  }
3706
4097
  function safeError(context, e) {
3707
4098
  const message = e instanceof Error ? e.message : String(e);
3708
4099
  const stack = e instanceof Error ? e.stack : void 0;
3709
- logger.error({ event: "tool_error", context, err: message, stack }, "tool call failed");
4100
+ logger.error(
4101
+ {
4102
+ event: "tool_error",
4103
+ context,
4104
+ err: redactSecrets(message),
4105
+ stack: stack !== void 0 ? redactSecrets(stack) : void 0
4106
+ },
4107
+ "tool call failed"
4108
+ );
3710
4109
  let msg;
3711
4110
  if (e instanceof ZodError) {
3712
4111
  const parts = e.issues.map((i) => {
@@ -3715,9 +4114,9 @@ function safeError(context, e) {
3715
4114
  });
3716
4115
  msg = `Invalid arguments: ${parts.join("; ")}`;
3717
4116
  } else if (e instanceof Error) {
3718
- msg = e.message.split("\n")[0].slice(0, 300);
4117
+ msg = redactSecrets(e.message).split("\n")[0].slice(0, 300);
3719
4118
  } else {
3720
- msg = String(e).slice(0, 300);
4119
+ msg = redactSecrets(String(e)).split("\n")[0].slice(0, 300);
3721
4120
  }
3722
4121
  return {
3723
4122
  // pino redact does not cover error-message string contents, so scrub key/secret
@@ -3847,6 +4246,7 @@ async function startServer(ctx) {
3847
4246
  shuttingDown = true;
3848
4247
  logger.info({ event: "shutdown", reason }, "shutting down");
3849
4248
  for (const agent of ctx.registry.values()) {
4249
+ await shutdownIrohTransport(agent);
3850
4250
  try {
3851
4251
  agent.client.close();
3852
4252
  } catch (e) {