@elisym/mcp 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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, formatNetworkBaseline, getProtocolProgramId, getProtocolConfig, assetKey, assetByKey, 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;
1174
+ }
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.");
1080
1187
  }
1081
- const content = await readFile(absPath, "utf-8");
1082
- if (content.length > MAX_INPUT_LEN) {
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 {
@@ -1151,18 +1258,18 @@ async function computeGitDiff(repoPath, base) {
1151
1258
  `Invalid "base": ${base}. Use a branch/tag/commit ref (letters, digits, ". _ / @ ~ ^ -", no leading "-", no "..").`
1152
1259
  );
1153
1260
  }
1154
- args = ["diff", "--no-ext-diff", "--end-of-options", `${base}...HEAD`];
1261
+ args = ["diff", "--no-ext-diff", "--no-textconv", "--end-of-options", `${base}...HEAD`];
1155
1262
  describedRange = `${base}...HEAD`;
1156
1263
  } else if (await isDirty(absRepo)) {
1157
- args = ["diff", "--no-ext-diff", "HEAD"];
1264
+ args = ["diff", "--no-ext-diff", "--no-textconv", "HEAD"];
1158
1265
  describedRange = "HEAD (working tree, uncommitted changes)";
1159
1266
  } else {
1160
1267
  const detected = await detectDefaultBase(absRepo);
1161
1268
  if (detected) {
1162
- args = ["diff", "--no-ext-diff", "--end-of-options", `${detected}...HEAD`];
1269
+ args = ["diff", "--no-ext-diff", "--no-textconv", "--end-of-options", `${detected}...HEAD`];
1163
1270
  describedRange = `${detected}...HEAD`;
1164
1271
  } else {
1165
- args = ["diff", "--no-ext-diff", "HEAD"];
1272
+ args = ["diff", "--no-ext-diff", "--no-textconv", "HEAD"];
1166
1273
  describedRange = "HEAD (no main/master detected)";
1167
1274
  }
1168
1275
  }
@@ -1172,9 +1279,10 @@ async function computeGitDiff(repoPath, base) {
1172
1279
  `No changes in range ${describedRange}. Nothing to review - commit work, pass an explicit "base", or check the repo path.`
1173
1280
  );
1174
1281
  }
1175
- if (diff.length > MAX_INPUT_LEN) {
1282
+ const diffBytes = utf8ByteLength(diff);
1283
+ if (diffBytes > LIMITS.MAX_REINLINE_TEXT_BYTES) {
1176
1284
  throw new Error(
1177
- `Diff for range ${describedRange} is ${diff.length} chars (max ${MAX_INPUT_LEN}). Pass a narrower "base" or split the review.`
1285
+ `Diff for range ${describedRange} is ${diffBytes} bytes (max ${LIMITS.MAX_REINLINE_TEXT_BYTES}). Pass a narrower "base" or split the review.`
1178
1286
  );
1179
1287
  }
1180
1288
  return { diff, describedRange };
@@ -1416,7 +1524,13 @@ var CustomerJobEntrySchema = z.object({
1416
1524
  completedAt: z.number().int().nonnegative(),
1417
1525
  resultPreview: z.string().max(RESULT_PREVIEW_MAX_LEN).optional(),
1418
1526
  paymentSig: z.string().max(128).optional(),
1419
- customerFeedback: FeedbackSchema.optional()
1527
+ customerFeedback: FeedbackSchema.optional(),
1528
+ /** JSON-serialized FileAttachment when the result is a file (fetched via fetch_job_file). */
1529
+ attachmentJson: z.string().max(8192).optional(),
1530
+ /** Local path the result file was downloaded to (set by fetch_job_file). */
1531
+ resultFilePath: z.string().max(4096).optional(),
1532
+ /** Unix ms when the result file was downloaded. */
1533
+ fetchedAt: z.number().int().nonnegative().optional()
1420
1534
  }).strict();
1421
1535
  var CustomerHistorySchema = z.object({
1422
1536
  version: z.literal(1),
@@ -1515,6 +1629,16 @@ var GetJobResultSchema = z.object({
1515
1629
  timeout_secs: z.number().int().min(1).max(600).default(60),
1516
1630
  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
1631
  });
1632
+ var FetchJobFileSchema = z.object({
1633
+ job_event_id: z.string(),
1634
+ output_path: z.string().min(1).max(4096).describe("Local path to write the downloaded result file to."),
1635
+ allow_outside_cwd: z.boolean().default(false).describe(
1636
+ "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."
1637
+ ),
1638
+ provider_npub: z.string().optional(),
1639
+ kind_offset: z.number().int().min(0).max(999).default(DEFAULT_KIND_OFFSET),
1640
+ timeout_secs: z.number().int().min(1).max(600).default(300)
1641
+ });
1518
1642
  var ListMyJobsSchema = z.object({
1519
1643
  limit: z.number().int().min(1).max(50).default(20),
1520
1644
  kind_offset: z.number().int().min(0).max(999).default(DEFAULT_KIND_OFFSET),
@@ -1749,7 +1873,7 @@ function makePaymentFeedbackHandler(opts) {
1749
1873
  opts.resolveNoWallet(
1750
1874
  `Payment required but no Solana wallet configured.
1751
1875
  Amount: ${signedAmount !== void 0 ? formatAssetAmount(asset, BigInt(signedAmount)) : "unknown"}
1752
- Payment request: ${paymentRequest}`
1876
+ Payment request: ${sanitizeUntrusted(paymentRequest, "structured").text}`
1753
1877
  );
1754
1878
  return;
1755
1879
  }
@@ -1788,6 +1912,57 @@ Payment request: ${paymentRequest}`
1788
1912
  };
1789
1913
  return { onFeedback, onResultReceived };
1790
1914
  }
1915
+ function formatFileResultMetadata(jobId, attachment) {
1916
+ const name = sanitizeField(attachment.name, 200);
1917
+ const mime = sanitizeField(attachment.mime, 100);
1918
+ const details = sanitizeUntrusted(
1919
+ `name: ${name}
1920
+ size: ${attachment.size} bytes
1921
+ type: ${mime}`,
1922
+ "text"
1923
+ ).text;
1924
+ return `Job completed. The result is a FILE (not inlined here):
1925
+ ${details}
1926
+ Download it with fetch_job_file(job_event_id="${jobId}", output_path="<local path>").`;
1927
+ }
1928
+ function decodeResultPreview(rawContent) {
1929
+ try {
1930
+ const decoded = decodeJobPayload(rawContent);
1931
+ if (decoded.attachment) {
1932
+ return `[file result: ${decoded.attachment.name} (${decoded.attachment.size} bytes). Download with fetch_job_file.]`;
1933
+ }
1934
+ return decoded.text ?? rawContent;
1935
+ } catch {
1936
+ return rawContent;
1937
+ }
1938
+ }
1939
+ async function prepareTextInput(agent, text) {
1940
+ if (utf8ByteLength(text) <= LIMITS.MAX_ENCRYPTED_INLINE_BYTES) {
1941
+ return { input: text };
1942
+ }
1943
+ const byteLength = utf8ByteLength(text);
1944
+ if (agent.agentDir === void 0) {
1945
+ return {
1946
+ 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).`
1947
+ };
1948
+ }
1949
+ try {
1950
+ const seeded = await ensureIrohTransport(agent).seedBytes(Buffer.from(text, "utf8"));
1951
+ return {
1952
+ input: "",
1953
+ attachment: {
1954
+ name: "input.txt",
1955
+ size: seeded.size,
1956
+ mime: "text/plain",
1957
+ transports: [{ kind: "iroh", ticket: seeded.ticket }]
1958
+ }
1959
+ };
1960
+ } catch (e) {
1961
+ return {
1962
+ error: `Failed to seed input for transfer: ${e instanceof Error ? e.message : String(e)}`
1963
+ };
1964
+ }
1965
+ }
1791
1966
  async function executeSubmitAndPay(ctx, agent, params) {
1792
1967
  const ping = await agent.client.ping.pingAgent(params.providerPubkey, PRE_PING_TIMEOUT_MS);
1793
1968
  if (!ping.online) {
@@ -1819,12 +1994,14 @@ async function executeSubmitAndPay(ctx, agent, params) {
1819
1994
  input: params.input,
1820
1995
  capability: params.dTag,
1821
1996
  providerPubkey: params.providerPubkey,
1822
- kindOffset: params.kindOffset
1997
+ kindOffset: params.kindOffset,
1998
+ attachment: params.attachment
1823
1999
  });
1824
2000
  let paymentSig;
1825
2001
  let paidAmountSubunits;
1826
2002
  let paidAssetKey;
1827
2003
  let paymentWarnings = [];
2004
+ let resultAttachment;
1828
2005
  try {
1829
2006
  const result = await awaitJobResult(
1830
2007
  agent,
@@ -1855,7 +2032,12 @@ async function executeSubmitAndPay(ctx, agent, params) {
1855
2032
  providerPubkey: params.providerPubkey,
1856
2033
  customerPublicKey: agent.identity.publicKey,
1857
2034
  callbacks: {
1858
- onResult(content) {
2035
+ onResult(content, _eventId, attachment) {
2036
+ if (attachment) {
2037
+ resultAttachment = attachment;
2038
+ payHandler.onResultReceived(formatFileResultMetadata(jobId, attachment));
2039
+ return;
2040
+ }
1859
2041
  const kind = isLikelyBase64(content) ? "binary" : "text";
1860
2042
  const sanitized = sanitizeUntrusted(content, kind);
1861
2043
  payHandler.onResultReceived(`Job completed.
@@ -1864,7 +2046,7 @@ ${sanitized.text}`);
1864
2046
  },
1865
2047
  onFeedback: payHandler.onFeedback,
1866
2048
  onError(error) {
1867
- reject(new Error(`Job error: ${error}`));
2049
+ reject(new Error(`Job error: ${sanitizeUntrusted(error, "text").text}`));
1868
2050
  },
1869
2051
  onTimeout(timeoutMs) {
1870
2052
  reject(new JobWaitTimeoutError(timeoutMs));
@@ -1887,7 +2069,8 @@ ${sanitized.text}`);
1887
2069
  submittedAt,
1888
2070
  completedAt: Date.now(),
1889
2071
  resultPreview: result.slice(0, RESULT_PREVIEW_MAX_LEN),
1890
- paymentSig
2072
+ paymentSig,
2073
+ attachmentJson: resultAttachment ? JSON.stringify(resultAttachment) : void 0
1891
2074
  });
1892
2075
  const warningBlock = paymentWarnings.length > 0 ? `${paymentWarnings.join("\n")}
1893
2076
  ` : "";
@@ -2018,7 +2201,11 @@ var customerTools = [
2018
2201
  providerPubkey,
2019
2202
  customerPublicKey: agent.identity.publicKey,
2020
2203
  callbacks: {
2021
- onResult(content, _eventId) {
2204
+ onResult(content, _eventId, attachment) {
2205
+ if (attachment) {
2206
+ resolve(formatFileResultMetadata(input.job_event_id, attachment));
2207
+ return;
2208
+ }
2022
2209
  const kind = isLikelyBase64(content) ? "binary" : "text";
2023
2210
  const sanitized = sanitizeUntrusted(content, kind);
2024
2211
  resolve(sanitized.text);
@@ -2054,6 +2241,79 @@ var customerTools = [
2054
2241
  return textResult(result);
2055
2242
  }
2056
2243
  }),
2244
+ defineTool({
2245
+ name: "fetch_job_file",
2246
+ 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.",
2247
+ schema: FetchJobFileSchema,
2248
+ async handler(ctx, input) {
2249
+ checkLen("job_event_id", input.job_event_id, MAX_EVENT_ID_LEN);
2250
+ let outputPath;
2251
+ try {
2252
+ outputPath = await resolveOutputPath(input.output_path, {
2253
+ allowOutsideCwd: input.allow_outside_cwd
2254
+ });
2255
+ } catch (error) {
2256
+ return errorResult(error instanceof Error ? error.message : String(error));
2257
+ }
2258
+ const agent = ctx.active();
2259
+ let attachment;
2260
+ if (agent.agentDir !== void 0) {
2261
+ const entry = await findCustomerJob(agent.agentDir, input.job_event_id);
2262
+ if (entry?.attachmentJson !== void 0) {
2263
+ try {
2264
+ attachment = JSON.parse(entry.attachmentJson);
2265
+ } catch {
2266
+ }
2267
+ }
2268
+ }
2269
+ if (attachment === void 0) {
2270
+ try {
2271
+ const results = await agent.client.marketplace.queryJobResults(
2272
+ agent.identity,
2273
+ [input.job_event_id],
2274
+ [input.kind_offset]
2275
+ );
2276
+ const resultEntry = results.get(input.job_event_id);
2277
+ if (resultEntry !== void 0 && !resultEntry.decryptionFailed) {
2278
+ attachment = decodeJobPayload(resultEntry.content).attachment;
2279
+ }
2280
+ } catch (error) {
2281
+ logger.warn(
2282
+ { event: "fetch_job_file_query_failed", err: String(error) },
2283
+ "relay re-fetch of result failed"
2284
+ );
2285
+ }
2286
+ }
2287
+ if (attachment === void 0) {
2288
+ return errorResult(
2289
+ `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.`
2290
+ );
2291
+ }
2292
+ const irohTransport = attachment.transports.find((transport) => transport.kind === "iroh");
2293
+ if (irohTransport === void 0) {
2294
+ return errorResult("Result attachment has no supported transport (iroh).");
2295
+ }
2296
+ try {
2297
+ await ensureIrohTransport(agent).fetchToPath(irohTransport.ticket, outputPath, {
2298
+ maxBytes: LIMITS.MAX_FILE_SIZE,
2299
+ timeoutMs: Math.min(input.timeout_secs, MAX_TIMEOUT_SECS) * 1e3
2300
+ });
2301
+ } catch (error) {
2302
+ const msg = error instanceof Error ? error.message : String(error);
2303
+ return errorResult(
2304
+ `Failed to download the file for event_id="${input.job_event_id}": ${msg}. The provider may be offline or no longer seeding it.`
2305
+ );
2306
+ }
2307
+ if (agent.agentDir !== void 0) {
2308
+ await updateCustomerJob(agent.agentDir, input.job_event_id, {
2309
+ resultFilePath: outputPath,
2310
+ fetchedAt: Date.now()
2311
+ }).catch(() => {
2312
+ });
2313
+ }
2314
+ return textResult(`Downloaded result file to ${outputPath}.`);
2315
+ }
2316
+ }),
2057
2317
  defineTool({
2058
2318
  name: "list_my_jobs",
2059
2319
  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 +2376,14 @@ var customerTools = [
2116
2376
  if (decrypted.decryptionFailed) {
2117
2377
  resultText = "[decryption failed - targeted result not for this agent]";
2118
2378
  } else {
2119
- const cleaned = sanitizeInner(decrypted.content);
2379
+ const cleaned = sanitizeInner(decodeResultPreview(decrypted.content));
2120
2380
  if (scanForInjections(cleaned, "full")) {
2121
2381
  freetextSuspicious = true;
2122
2382
  }
2123
2383
  resultText = cleaned;
2124
2384
  }
2125
2385
  } else if (nostr.result) {
2126
- const cleaned = sanitizeInner(nostr.result);
2386
+ const cleaned = sanitizeInner(decodeResultPreview(nostr.result));
2127
2387
  if (scanForInjections(cleaned, "full")) {
2128
2388
  freetextSuspicious = true;
2129
2389
  }
@@ -2169,11 +2429,21 @@ ${wrapped}`);
2169
2429
  schema: SubmitAndPayJobSchema,
2170
2430
  async handler(ctx, input) {
2171
2431
  ctx.toolRateLimiter.check();
2172
- checkLen("input", input.input, MAX_INPUT_LEN);
2173
2432
  checkLen("provider_npub", input.provider_npub, MAX_NPUB_LEN);
2433
+ const inputBytes = utf8ByteLength(input.input);
2434
+ if (inputBytes > LIMITS.MAX_REINLINE_TEXT_BYTES) {
2435
+ return errorResult(
2436
+ `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.`
2437
+ );
2438
+ }
2174
2439
  const agent = ctx.active();
2440
+ const prepared = await prepareTextInput(agent, input.input);
2441
+ if ("error" in prepared) {
2442
+ return errorResult(prepared.error);
2443
+ }
2175
2444
  return executeSubmitAndPay(ctx, agent, {
2176
- input: input.input,
2445
+ input: prepared.input,
2446
+ attachment: prepared.attachment,
2177
2447
  providerNpub: input.provider_npub,
2178
2448
  providerPubkey: decodeNpub(input.provider_npub),
2179
2449
  capability: input.capability,
@@ -2190,22 +2460,46 @@ ${wrapped}`);
2190
2460
  }),
2191
2461
  defineTool({
2192
2462
  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.",
2463
+ 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
2464
  schema: SubmitAndPayJobFromFileSchema,
2195
2465
  async handler(ctx, input) {
2196
2466
  ctx.toolRateLimiter.check();
2197
2467
  checkLen("provider_npub", input.provider_npub, MAX_NPUB_LEN);
2198
- let payload;
2468
+ let prepared;
2199
2469
  try {
2200
- payload = await readJobInputFile(input.input_path, {
2470
+ prepared = await prepareFileInput(input.input_path, {
2201
2471
  allowOutsideCwd: input.allow_outside_cwd
2202
2472
  });
2203
2473
  } catch (e) {
2204
2474
  return errorResult(e instanceof Error ? e.message : String(e));
2205
2475
  }
2206
2476
  const agent = ctx.active();
2477
+ if (agent.agentDir === void 0) {
2478
+ return errorResult(
2479
+ `Sending a file requires a persistent agent (this is an ephemeral session). Files are always transferred P2P via iroh, never inline.`
2480
+ );
2481
+ }
2482
+ let attachment;
2483
+ try {
2484
+ const seeded = await ensureIrohTransport(agent).seedPath(prepared.absPath);
2485
+ attachment = {
2486
+ name: prepared.name,
2487
+ size: seeded.size,
2488
+ mime: prepared.mime,
2489
+ transports: [{ kind: "iroh", ticket: seeded.ticket }]
2490
+ };
2491
+ } catch (e) {
2492
+ const msg = e instanceof Error ? e.message : String(e);
2493
+ if (/@number0\/iroh|iroh file transfer is unavailable/i.test(msg)) {
2494
+ return errorResult(
2495
+ `File transfer is unavailable: the optional @number0/iroh addon is not installed. Install it (e.g. \`bun add @number0/iroh\`) to send files.`
2496
+ );
2497
+ }
2498
+ return errorResult(`Failed to seed file for transfer: ${msg}`);
2499
+ }
2207
2500
  return executeSubmitAndPay(ctx, agent, {
2208
- input: payload,
2501
+ input: "",
2502
+ attachment,
2209
2503
  providerNpub: input.provider_npub,
2210
2504
  providerPubkey: decodeNpub(input.provider_npub),
2211
2505
  capability: input.capability,
@@ -2234,14 +2528,20 @@ ${wrapped}`);
2234
2528
  ` : "";
2235
2529
  const payload = `${promptBlock}--- git diff (${diffResult.describedRange}) ---
2236
2530
  ${diffResult.diff}`;
2237
- if (payload.length > MAX_INPUT_LEN) {
2531
+ const payloadBytes = utf8ByteLength(payload);
2532
+ if (payloadBytes > LIMITS.MAX_REINLINE_TEXT_BYTES) {
2238
2533
  return errorResult(
2239
- `Combined prompt + diff is ${payload.length} chars (max ${MAX_INPUT_LEN}). Shorten the prompt or pass a narrower base.`
2534
+ `Combined prompt + diff is ${payloadBytes} bytes (max ${LIMITS.MAX_REINLINE_TEXT_BYTES}). Pass a narrower "base" or shorten the prompt.`
2240
2535
  );
2241
2536
  }
2242
2537
  const agent = ctx.active();
2538
+ const prepared = await prepareTextInput(agent, payload);
2539
+ if ("error" in prepared) {
2540
+ return errorResult(prepared.error);
2541
+ }
2243
2542
  return executeSubmitAndPay(ctx, agent, {
2244
- input: payload,
2543
+ input: prepared.input,
2544
+ attachment: prepared.attachment,
2245
2545
  providerNpub: input.provider_npub,
2246
2546
  providerPubkey: decodeNpub(input.provider_npub),
2247
2547
  capability: input.capability,
@@ -2367,7 +2667,11 @@ To confirm, call buy_capability again with max_price_lamports set (e.g. ${price}
2367
2667
  providerPubkey,
2368
2668
  customerPublicKey: agent.identity.publicKey,
2369
2669
  callbacks: {
2370
- onResult(content) {
2670
+ onResult(content, _eventId, attachment) {
2671
+ if (attachment) {
2672
+ payHandler.onResultReceived(formatFileResultMetadata(jobId, attachment));
2673
+ return;
2674
+ }
2371
2675
  const kind = isLikelyBase64(content) ? "binary" : "text";
2372
2676
  const sanitized = sanitizeUntrusted(content, kind);
2373
2677
  payHandler.onResultReceived(
@@ -2892,7 +3196,13 @@ var discoveryTools = [
2892
3196
  asset_mint: asset.mint,
2893
3197
  chain: card.payment?.chain,
2894
3198
  network: card.payment?.network,
2895
- network_fee_estimate_sol: gasEstimate
3199
+ network_fee_estimate_sol: gasEstimate,
3200
+ // File-exchange hints (dynamic-script). Informational: the MCP/CLI
3201
+ // CAN send files via submit_and_pay_job_from_file, so this does not
3202
+ // gate anything - it just tells the caller a file input is expected.
3203
+ // Already length-bounded by parseCapabilityEvent.
3204
+ ...card.inputMime ? { input_mime: card.inputMime } : {},
3205
+ ...card.outputMime ? { output_mime: card.outputMime } : {}
2896
3206
  };
2897
3207
  }),
2898
3208
  supported_kinds: a.supportedKinds,
@@ -3701,12 +4011,20 @@ if (toolMap.size !== allTools.length) {
3701
4011
  );
3702
4012
  }
3703
4013
  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]");
4014
+ 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
4015
  }
3706
4016
  function safeError(context, e) {
3707
4017
  const message = e instanceof Error ? e.message : String(e);
3708
4018
  const stack = e instanceof Error ? e.stack : void 0;
3709
- logger.error({ event: "tool_error", context, err: message, stack }, "tool call failed");
4019
+ logger.error(
4020
+ {
4021
+ event: "tool_error",
4022
+ context,
4023
+ err: redactSecrets(message),
4024
+ stack: stack !== void 0 ? redactSecrets(stack) : void 0
4025
+ },
4026
+ "tool call failed"
4027
+ );
3710
4028
  let msg;
3711
4029
  if (e instanceof ZodError) {
3712
4030
  const parts = e.issues.map((i) => {
@@ -3715,9 +4033,9 @@ function safeError(context, e) {
3715
4033
  });
3716
4034
  msg = `Invalid arguments: ${parts.join("; ")}`;
3717
4035
  } else if (e instanceof Error) {
3718
- msg = e.message.split("\n")[0].slice(0, 300);
4036
+ msg = redactSecrets(e.message).split("\n")[0].slice(0, 300);
3719
4037
  } else {
3720
- msg = String(e).slice(0, 300);
4038
+ msg = redactSecrets(String(e)).split("\n")[0].slice(0, 300);
3721
4039
  }
3722
4040
  return {
3723
4041
  // pino redact does not cover error-message string contents, so scrub key/secret
@@ -3847,6 +4165,7 @@ async function startServer(ctx) {
3847
4165
  shuttingDown = true;
3848
4166
  logger.info({ event: "shutdown", reason }, "shutting down");
3849
4167
  for (const agent of ctx.registry.values()) {
4168
+ await shutdownIrohTransport(agent);
3850
4169
  try {
3851
4170
  agent.client.close();
3852
4171
  } catch (e) {