@elisym/cli 0.32.0 → 0.33.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,19 +1,19 @@
1
1
  #!/usr/bin/env -S node --no-deprecation
2
2
  import { ReadableStream } from 'node:stream/web';
3
- import { readFileSync, existsSync, readdirSync, statSync, renameSync, chmodSync, mkdirSync, writeFileSync, appendFileSync, unlinkSync, rmdirSync, realpathSync } from 'node:fs';
3
+ import { readFileSync, existsSync, readdirSync, statSync, realpathSync, renameSync, chmodSync, mkdirSync, writeFileSync, unlinkSync, appendFileSync, rmdirSync } from 'node:fs';
4
4
  import { dirname, join, resolve, basename, extname, relative, sep } from 'node:path';
5
- import { DEFAULTS, SolanaPaymentStrategy, validateAgentName, RELAYS, ElisymIdentity, splAssetsForNetwork, formatSol, formatAssetAmount, signerFromSecretKeyBase58, resolveUsdcAsset, ElisymClient, KIND_EXTERNAL_IDENTITIES, POLICY_D_TAG_PREFIX, KIND_LONG_FORM_ARTICLE, deleteControlCharacters, jobRequestKind, DEFAULT_KIND_OFFSET, toDTag, createBlossomTransport, generateSolanaWallet, verifyAgentIdentities, getProtocolConfig, getProtocolProgramId, calculateProtocolFee, LIMITS, makeCensor, DEFAULT_REDACT_PATHS, flattenForComparison, POLICY_T_TAG, SESSION_ID_REGEX, createSlidingWindowLimiter, excerptUntrusted, isScriptBillingExhaustedError, excerptUntrustedTail, scriptOutput, isScriptExecutionError, utf8ByteLength, readAcceptedTransports, resolveDelegationAsset, MAX_PROOF_TTL_SECS, PROOF_CLOCK_SKEW_SECS, verifyDelegationAuthProof, deriveOwnerDelegationAta, getDelegation, buildDelegatedTransfer, buildSignedPull, sendConfirmToTerminal, parseDelegatedPayment, confirmPullToTerminal, isDefinitelyUnpaid, decodeJobPayload, isLlmHealthError, BoundedSet, KIND_JOB_FEEDBACK, encodeSecretKeyBase58, GITHUB_USERNAME_REGEX, X_USERNAME_REGEX, normalizeNip05Identifier, splitNip05Identifier, clipToCodeUnits, PROVIDER_REFUSED_PREFIX, PROVIDER_FAILED_MESSAGE, NATIVE_SOL, GIST_ID_REGEX, TWEET_ID_REGEX } from '@elisym/sdk';
6
- import { ElisymYamlSchema, resolveInHome, resolveInProject, createAgentDir, writeYamlInitial, writeExampleSkillTemplate, writeSecrets, listAgents, loadAgent, writeYaml, agentPaths, readMediaCache, loadPoliciesFromDir, ensureGitignoreHasIrohEntry, ensureGitignoreHasX402Entries, ensureGitignoreHasSessionsEntry, ensureGitignoreHasDelegationNoncesEntry, readAgentPublic, lookupCachedUrl, newCacheEntry, writeMediaCache } from '@elisym/sdk/agent-store';
5
+ import { DEFAULTS, SolanaPaymentStrategy, validateAgentName, RELAYS, ElisymIdentity, splAssetsForNetwork, formatSol, formatAssetAmount, deleteControlCharacters, signerFromSecretKeyBase58, resolveUsdcAsset, ElisymClient, KIND_EXTERNAL_IDENTITIES, POLICY_D_TAG_PREFIX, KIND_LONG_FORM_ARTICLE, jobRequestKind, DEFAULT_KIND_OFFSET, toDTag, createBlossomTransport, generateSolanaWallet, verifyAgentIdentities, getProtocolConfig, getProtocolProgramId, calculateProtocolFee, LIMITS, makeCensor, DEFAULT_REDACT_PATHS, flattenForComparison, POLICY_T_TAG, SESSION_ID_REGEX, createSlidingWindowLimiter, excerptUntrusted, isScriptBillingExhaustedError, excerptUntrustedTail, scriptOutput, isScriptExecutionError, utf8ByteLength, readAcceptedTransports, resolveDelegationAsset, MAX_PROOF_TTL_SECS, PROOF_CLOCK_SKEW_SECS, verifyDelegationAuthProof, deriveOwnerDelegationAta, getDelegation, buildDelegatedTransfer, buildSignedPull, sendConfirmToTerminal, parseDelegatedPayment, confirmPullToTerminal, isDefinitelyUnpaid, decodeJobPayload, isLlmHealthError, BoundedSet, KIND_JOB_FEEDBACK, encodeSecretKeyBase58, GITHUB_USERNAME_REGEX, X_USERNAME_REGEX, normalizeNip05Identifier, splitNip05Identifier, clipToCodeUnits, degenerateReference, PROVIDER_REFUSED_PREFIX, PROVIDER_FAILED_MESSAGE, NATIVE_SOL, GIST_ID_REGEX, TWEET_ID_REGEX } from '@elisym/sdk';
6
+ import { ElisymYamlSchema, resolveInHome, resolveInProject, createAgentDir, writeYamlInitial, writeExampleSkillTemplate, writeSecrets, listAgents, loadAgent, writeYaml, agentPaths, ensureGitignoreHasX402Entries, readMediaCache, ensureGitignoreHasPrivateStateEntries, loadPoliciesFromDir, ensureGitignoreHasDelegationNoncesEntry, ensureGitignoreHasIrohEntry, ensureGitignoreHasSessionsEntry, readAgentPublic, isBlockingNodeSync, AgentNameSchema, lookupCachedUrl, newCacheEntry, isBlockingNode, writeMediaCache } from '@elisym/sdk/agent-store';
7
7
  import { isAddress, createSolanaRpc, address, signature } from '@solana/kit';
8
8
  import { generateSecretKey, getPublicKey, nip19, verifyEvent } from 'nostr-tools';
9
9
  import YAML2 from 'yaml';
10
10
  import { Command } from 'commander';
11
- import { mkdir, writeFile, mkdtemp, rm, readFile, rename, stat } from 'node:fs/promises';
12
- import { parseSkillMd, validateSkillFrontmatter, isHostScratchError, isScriptRefusalError, startsWithRefusalHint, HostScratchError, DEFAULT_X402_MAX_INPUT_BYTES, resolveInsidePathReal, DEFAULT_SCRIPT_TIMEOUT_MS, OnchainCallSkill, DynamicScriptSkill as DynamicScriptSkill$1, StaticScriptSkill as StaticScriptSkill$1, StaticFileSkill as StaticFileSkill$1, ScriptSkill as ScriptSkill$1 } from '@elisym/sdk/skills';
11
+ import { parseSkillMd, validateSkillFrontmatter, isHostScratchError, isScriptRefusalError, startsWithRefusalHint, HostScratchError, DEFAULT_X402_MAX_INPUT_BYTES, resolveInsidePathReal, DEFAULT_SCRIPT_TIMEOUT_MS, loadSkillsFromDir, OnchainCallSkill, DynamicScriptSkill as DynamicScriptSkill$1, StaticScriptSkill as StaticScriptSkill$1, StaticFileSkill as StaticFileSkill$1, ScriptSkill as ScriptSkill$1 } from '@elisym/sdk/skills';
12
+ import { mkdtemp, writeFile, rm, readFile, mkdir, chmod, rename, readdir, stat } from 'node:fs/promises';
13
13
  import chalk from 'chalk';
14
14
  import Decimal from 'decimal.js-light';
15
15
  import { decodePaymentRequiredHeader } from '@x402/core/http';
16
- import { createHash } from 'node:crypto';
16
+ import { createHash, randomBytes } from 'node:crypto';
17
17
  import { LlmHealthMonitor, startLlmRecovery, createFreeLlmLimiterSet, FREE_LLM_GLOBAL_KEY, freeLlmCustomerKey } from '@elisym/sdk/llm-health';
18
18
  import { createIrohTransport } from '@elisym/sdk/node';
19
19
  import { lookup } from 'node:dns/promises';
@@ -1553,6 +1553,129 @@ function formatSplBalanceValue(asset, balance) {
1553
1553
  async function fetchUsdcBalance(rpc, owner, network) {
1554
1554
  return fetchSplBalance(rpc, owner, resolveUsdcAsset(network));
1555
1555
  }
1556
+ async function findSharedPayoutNeighbors(cwd, ownDir, network, payoutAddress) {
1557
+ let ownReal;
1558
+ try {
1559
+ ownReal = realpathSync(ownDir);
1560
+ } catch {
1561
+ return [];
1562
+ }
1563
+ const listed = await listAgents(cwd);
1564
+ const candidates = [];
1565
+ for (const agent of listed) {
1566
+ candidates.push({ name: agent.name, dir: agent.dir, source: agent.source });
1567
+ if (agent.shadowsGlobal) {
1568
+ const homeDir = resolveInHome(agent.name);
1569
+ if (homeDir !== null) {
1570
+ candidates.push({ name: agent.name, dir: homeDir, source: "home" });
1571
+ }
1572
+ }
1573
+ }
1574
+ const neighbors = [];
1575
+ const seen = /* @__PURE__ */ new Set();
1576
+ for (const candidate of candidates) {
1577
+ if (!AgentNameSchema.safeParse(candidate.name).success) {
1578
+ continue;
1579
+ }
1580
+ let candidateReal;
1581
+ try {
1582
+ candidateReal = realpathSync(candidate.dir);
1583
+ } catch {
1584
+ continue;
1585
+ }
1586
+ if (candidateReal === ownReal || seen.has(candidateReal)) {
1587
+ continue;
1588
+ }
1589
+ seen.add(candidateReal);
1590
+ let neighborAddress;
1591
+ let neighborNetwork;
1592
+ try {
1593
+ const { yaml } = await readAgentPublic({
1594
+ name: candidate.name,
1595
+ dir: candidate.dir,
1596
+ source: candidate.source,
1597
+ shadowsGlobal: false
1598
+ });
1599
+ const solana = yaml.payments.find((entry) => entry.chain === "solana");
1600
+ neighborAddress = solana?.address;
1601
+ neighborNetwork = solana?.network;
1602
+ } catch {
1603
+ continue;
1604
+ }
1605
+ if (neighborAddress !== payoutAddress || neighborNetwork !== network) {
1606
+ continue;
1607
+ }
1608
+ const paid = await neighborPaidState(candidate.dir, network);
1609
+ if (paid === false) {
1610
+ continue;
1611
+ }
1612
+ neighbors.push({
1613
+ name: candidate.name,
1614
+ // Sanitized, and newlines collapsed SEPARATELY: the sanitizer deletes
1615
+ // every C0/C1 control except tab and newline, and a newline in a POSIX
1616
+ // path is legal - left alone it would break the banner across lines. Two
1617
+ // different defenses, and neither substitutes for the other: the name is
1618
+ // checked by a pattern that admits nothing the sanitizer touches, while
1619
+ // the path carries an operator's project root that nobody validates.
1620
+ // `\s+` would be wrong here - it would eat the legitimate double spaces
1621
+ // and tabs a real path can carry, and the printed path would stop
1622
+ // matching the one on disk.
1623
+ dir: deleteControlCharacters(candidate.dir).replace(/\n+/g, " "),
1624
+ paid
1625
+ });
1626
+ }
1627
+ return neighbors;
1628
+ }
1629
+ async function neighborPaidState(neighborDir, network) {
1630
+ const skillsDir = agentPaths(neighborDir).skills;
1631
+ let entries;
1632
+ try {
1633
+ entries = readdirSync(skillsDir);
1634
+ } catch (error) {
1635
+ return error.code === "ENOENT" ? false : "unknown";
1636
+ }
1637
+ let denominator = 0;
1638
+ let sawLess = false;
1639
+ for (const entry of entries) {
1640
+ const entryPath = join(skillsDir, entry);
1641
+ try {
1642
+ if (!statSync(entryPath).isDirectory()) {
1643
+ continue;
1644
+ }
1645
+ } catch {
1646
+ sawLess = true;
1647
+ continue;
1648
+ }
1649
+ const skillMd = join(entryPath, "SKILL.md");
1650
+ try {
1651
+ if (isBlockingNodeSync(skillMd)) {
1652
+ return "unknown";
1653
+ }
1654
+ readFileSync(skillMd, "utf-8");
1655
+ denominator += 1;
1656
+ } catch (error) {
1657
+ if (error.code !== "ENOENT") {
1658
+ sawLess = true;
1659
+ }
1660
+ }
1661
+ }
1662
+ let skills;
1663
+ try {
1664
+ skills = loadSkillsFromDir(skillsDir, {
1665
+ network,
1666
+ allowFreeSkills: true,
1667
+ allowX402Skills: true,
1668
+ logger: { warn: () => {
1669
+ } }
1670
+ });
1671
+ } catch {
1672
+ return "unknown";
1673
+ }
1674
+ if (skills.some((skill) => skill.priceSubunits > 0n)) {
1675
+ return true;
1676
+ }
1677
+ return sawLess || skills.length < denominator ? "unknown" : false;
1678
+ }
1556
1679
 
1557
1680
  // src/x402/constants.ts
1558
1681
  var X402_SOLANA_DEVNET_CAIP2 = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1";
@@ -1787,13 +1910,10 @@ function fail(message) {
1787
1910
  `);
1788
1911
  process.exit(1);
1789
1912
  }
1913
+ var CONTROL_CHARACTERS = /\p{Cc}/gu;
1914
+ var UNSEEN_CHARACTERS = /[\p{Cf}\p{Default_Ignorable_Code_Point}\uFFFD]/gu;
1790
1915
  function sanitizeUpstreamText(raw, maxLength) {
1791
- let withoutControls = "";
1792
- for (const char of raw) {
1793
- const code = char.codePointAt(0) ?? 0;
1794
- withoutControls += code < 32 || code === 127 ? " " : char;
1795
- }
1796
- return withoutControls.replace(/—/g, "-").replace(/\s+/g, " ").trim().slice(0, maxLength).trim();
1916
+ return raw.normalize("NFKC").replace(CONTROL_CHARACTERS, " ").replace(UNSEEN_CHARACTERS, "").replace(/—/g, "-").replace(/\s+/g, " ").trim().slice(0, maxLength).trim();
1797
1917
  }
1798
1918
  function slugFromUrl(url) {
1799
1919
  const pathPart = url.pathname.replace(/\/+$/, "").split("/").filter(Boolean).pop() ?? "";
@@ -1892,6 +2012,12 @@ function scanExistingSkills(skillsDir, candidateDTag, url) {
1892
2012
  if (!statSync(join(skillsDir, entry)).isDirectory()) {
1893
2013
  continue;
1894
2014
  }
2015
+ if (isBlockingNodeSync(skillMdPath)) {
2016
+ console.warn(
2017
+ ` ! Could not check ${skillMdPath} for a name collision: it is a pipe, socket or device, not a file`
2018
+ );
2019
+ continue;
2020
+ }
1895
2021
  const { frontmatter } = parseSkillMd(readFileSync(skillMdPath, "utf-8"));
1896
2022
  if (typeof frontmatter.name !== "string") {
1897
2023
  continue;
@@ -1928,6 +2054,16 @@ function describeUnacceptableAccepts(probe, agentNetwork) {
1928
2054
  }
1929
2055
  return `no exact-scheme ${agentNetwork}-USDC requirement found (service accepts: ${networks.join(", ") || "nothing parseable"})`;
1930
2056
  }
2057
+ async function writeSkillMdRefusingBlockingNode(targetDir, content) {
2058
+ await mkdir(targetDir, { recursive: true, mode: 448 });
2059
+ await chmod(targetDir, 448).catch(() => void 0);
2060
+ const skillMdPath = join(targetDir, "SKILL.md");
2061
+ if (isBlockingNodeSync(skillMdPath)) {
2062
+ throw new Error(`Refusing to write ${skillMdPath}: it is a pipe, socket or device, not a file`);
2063
+ }
2064
+ await writeFile(skillMdPath, content, "utf-8");
2065
+ return skillMdPath;
2066
+ }
1931
2067
  async function cmdX402Add(url, agentName, options) {
1932
2068
  const cwd = process.cwd();
1933
2069
  const { default: inquirer } = await import('inquirer');
@@ -2242,8 +2378,7 @@ async function cmdX402Add(url, agentName, options) {
2242
2378
  allowFreeSkills: false,
2243
2379
  allowX402Skills: true
2244
2380
  });
2245
- await mkdir(targetDir, { recursive: true });
2246
- await writeFile(join(targetDir, "SKILL.md"), content, "utf-8");
2381
+ await writeSkillMdRefusingBlockingNode(targetDir, content);
2247
2382
  await ensureGitignoreHasX402Entries(dirname(loaded.dir));
2248
2383
  console.log(`
2249
2384
  Wrote ${join(targetDir, "SKILL.md")}`);
@@ -3279,6 +3414,9 @@ async function probeRelays(relays, logger, timeoutMs = TCP_PROBE_TIMEOUT_MS) {
3279
3414
  }
3280
3415
  var LEDGER_DIR_MODE = 448;
3281
3416
  var LEDGER_FILE_MODE = 384;
3417
+ function isUsableSignature(value) {
3418
+ return typeof value === "string" && value.length > 0;
3419
+ }
3282
3420
  var VALID_TRANSITIONS = {
3283
3421
  paid: ["executed", "failed"],
3284
3422
  executed: ["delivered", "failed"],
@@ -3286,6 +3424,30 @@ var VALID_TRANSITIONS = {
3286
3424
  failed: []
3287
3425
  };
3288
3426
  var MAX_DOUBLE_SETTLE_WARNINGS = 20;
3427
+ var STRANDED_TEMP_MIN_AGE_MS = 60 * 60 * 1e3;
3428
+ function sweepStrandedTemporaries(path) {
3429
+ const legacyName = `${basename(path)}.tmp`;
3430
+ const prefix = `${legacyName}.`;
3431
+ const cutoff = Date.now() - STRANDED_TEMP_MIN_AGE_MS;
3432
+ let names;
3433
+ try {
3434
+ names = readdirSync(dirname(path));
3435
+ } catch {
3436
+ return;
3437
+ }
3438
+ for (const name of names) {
3439
+ if (name !== legacyName && !name.startsWith(prefix)) {
3440
+ continue;
3441
+ }
3442
+ const candidate = join(dirname(path), name);
3443
+ try {
3444
+ if (statSync(candidate).mtimeMs < cutoff) {
3445
+ unlinkSync(candidate);
3446
+ }
3447
+ } catch {
3448
+ }
3449
+ }
3450
+ }
3289
3451
  var JobLedger = class {
3290
3452
  entries = /* @__PURE__ */ new Map();
3291
3453
  /**
@@ -3303,8 +3465,14 @@ var JobLedger = class {
3303
3465
  constructor(ledgerPath) {
3304
3466
  this.path = ledgerPath;
3305
3467
  this.load();
3468
+ sweepStrandedTemporaries(this.path);
3306
3469
  }
3307
3470
  load() {
3471
+ if (isBlockingNodeSync(this.path)) {
3472
+ throw new Error(
3473
+ `Refusing to read the job ledger at ${this.path}: it is a pipe, socket or device, not a file. An empty ledger would drop the record of which transaction paid for which job.`
3474
+ );
3475
+ }
3308
3476
  try {
3309
3477
  const raw = readFileSync(this.path, "utf-8");
3310
3478
  const data = JSON.parse(raw);
@@ -3327,6 +3495,11 @@ var JobLedger = class {
3327
3495
  );
3328
3496
  }
3329
3497
  } catch (e) {
3498
+ if (typeof e?.code === "string" && e.code !== "ENOENT") {
3499
+ throw new Error(
3500
+ `Refusing to start on a job ledger that cannot be read (${e.code}) at ${this.path}. An empty ledger would drop the record of which transaction paid for which job. Check the file's owner and mode (a ledger written under sudo needs a chown).`
3501
+ );
3502
+ }
3330
3503
  if (e?.code !== "ENOENT") {
3331
3504
  console.warn(` ! Ledger load warning: ${e?.message ?? "unknown error"}`);
3332
3505
  try {
@@ -3343,10 +3516,18 @@ var JobLedger = class {
3343
3516
  const dir = dirname(this.path);
3344
3517
  mkdirSync(dir, { recursive: true, mode: LEDGER_DIR_MODE });
3345
3518
  const obj = Object.fromEntries(this.entries);
3346
- const tmp = this.path + ".tmp";
3347
- writeFileSync(tmp, JSON.stringify(obj, null, 2), { mode: LEDGER_FILE_MODE });
3348
- chmodSync(tmp, LEDGER_FILE_MODE);
3349
- renameSync(tmp, this.path);
3519
+ const tmp = `${this.path}.tmp.${randomBytes(6).toString("hex")}`;
3520
+ try {
3521
+ writeFileSync(tmp, JSON.stringify(obj, null, 2), { mode: LEDGER_FILE_MODE });
3522
+ chmodSync(tmp, LEDGER_FILE_MODE);
3523
+ renameSync(tmp, this.path);
3524
+ } catch (error) {
3525
+ try {
3526
+ unlinkSync(tmp);
3527
+ } catch {
3528
+ }
3529
+ throw error;
3530
+ }
3350
3531
  }
3351
3532
  recordPaid(entry) {
3352
3533
  if (this.entries.has(entry.job_id)) {
@@ -3414,7 +3595,7 @@ var JobLedger = class {
3414
3595
  let suppressed = 0;
3415
3596
  for (const entry of this.entries.values()) {
3416
3597
  const paymentSignature = entry.payment_signature;
3417
- if (typeof paymentSignature !== "string" || paymentSignature.length === 0) {
3598
+ if (!isUsableSignature(paymentSignature)) {
3418
3599
  continue;
3419
3600
  }
3420
3601
  const owner = this.paymentSignatureOwners.get(paymentSignature);
@@ -3484,6 +3665,9 @@ var JobLedger = class {
3484
3665
  * this job - refusing the owner its own settlement until the next restart.
3485
3666
  */
3486
3667
  claimPaymentSignature(paymentSignature, jobId) {
3668
+ if (!isUsableSignature(paymentSignature)) {
3669
+ return "unknown-job";
3670
+ }
3487
3671
  const entry = this.entries.get(jobId);
3488
3672
  if (entry === void 0) {
3489
3673
  return "unknown-job";
@@ -3603,7 +3787,7 @@ var JobLedger = class {
3603
3787
  for (const [id, entry] of this.entries) {
3604
3788
  if ((entry.status === "delivered" || entry.status === "failed") && entry.created_at < cutoff) {
3605
3789
  this.entries.delete(id);
3606
- if (typeof entry.payment_signature === "string" && entry.payment_signature.length > 0) {
3790
+ if (isUsableSignature(entry.payment_signature)) {
3607
3791
  prunedSignatures.add(entry.payment_signature);
3608
3792
  }
3609
3793
  deleted += 1;
@@ -3651,8 +3835,14 @@ var UsedNonceStore = class {
3651
3835
  this.path = noncePath;
3652
3836
  this.maxEntries = maxEntries;
3653
3837
  this.load();
3838
+ sweepStrandedTemporaries(this.path);
3654
3839
  }
3655
3840
  load() {
3841
+ if (isBlockingNodeSync(this.path)) {
3842
+ throw new Error(
3843
+ `Refusing to read the nonce store at ${this.path}: it is a pipe, socket or device, not a file. An empty store would let a delegated pull be replayed.`
3844
+ );
3845
+ }
3656
3846
  try {
3657
3847
  const raw = readFileSync(this.path, "utf-8");
3658
3848
  const data = JSON.parse(raw);
@@ -3662,6 +3852,11 @@ var UsedNonceStore = class {
3662
3852
  }
3663
3853
  }
3664
3854
  } catch (e) {
3855
+ if (typeof e?.code === "string" && e.code !== "ENOENT") {
3856
+ throw new Error(
3857
+ `Refusing to start on a nonce store that cannot be read (${e.code}) at ${this.path}. An empty store would let a delegated pull be replayed. Check the file's owner and mode (a store written under sudo needs a chown).`
3858
+ );
3859
+ }
3665
3860
  if (e?.code !== "ENOENT") {
3666
3861
  console.warn(` ! Nonce store load warning: ${e?.message ?? "unknown error"}`);
3667
3862
  try {
@@ -3677,9 +3872,17 @@ var UsedNonceStore = class {
3677
3872
  const dir = dirname(this.path);
3678
3873
  mkdirSync(dir, { recursive: true, mode: LEDGER_DIR_MODE });
3679
3874
  const obj = Object.fromEntries(this.entries);
3680
- const tmp = this.path + ".tmp";
3681
- writeFileSync(tmp, JSON.stringify(obj), { mode: LEDGER_FILE_MODE });
3682
- renameSync(tmp, this.path);
3875
+ const tmp = `${this.path}.tmp.${randomBytes(6).toString("hex")}`;
3876
+ try {
3877
+ writeFileSync(tmp, JSON.stringify(obj), { mode: LEDGER_FILE_MODE });
3878
+ renameSync(tmp, this.path);
3879
+ } catch (error) {
3880
+ try {
3881
+ unlinkSync(tmp);
3882
+ } catch {
3883
+ }
3884
+ throw error;
3885
+ }
3683
3886
  chmodSync(this.path, LEDGER_FILE_MODE);
3684
3887
  }
3685
3888
  has(key) {
@@ -3863,6 +4066,43 @@ var MIME_BY_EXT = {
3863
4066
  function mimeFromPath(path) {
3864
4067
  return MIME_BY_EXT[extname(path).toLowerCase()] ?? "application/octet-stream";
3865
4068
  }
4069
+ var IMAGE_EXTENSIONS = Object.keys(MIME_BY_EXT);
4070
+ function isImagePath(path) {
4071
+ return Object.hasOwn(MIME_BY_EXT, extname(path).toLowerCase());
4072
+ }
4073
+
4074
+ // src/rpc-redact.ts
4075
+ var PUBLIC_SOLANA_RPC_HOSTS = /* @__PURE__ */ new Set([
4076
+ "api.devnet.solana.com",
4077
+ "api.mainnet-beta.solana.com",
4078
+ "api.testnet.solana.com"
4079
+ ]);
4080
+ function stripRpcSecrets(raw) {
4081
+ try {
4082
+ const parsed = new URL(raw);
4083
+ parsed.username = "";
4084
+ parsed.password = "";
4085
+ if (!PUBLIC_SOLANA_RPC_HOSTS.has(parsed.hostname)) {
4086
+ return `${parsed.protocol}//${parsed.host}/***`;
4087
+ }
4088
+ const marker = parsed.search.length > 0 ? "?***" : "";
4089
+ parsed.search = "";
4090
+ return `${parsed.toString()}${marker}`;
4091
+ } catch {
4092
+ return "[unparseable RPC URL]";
4093
+ }
4094
+ }
4095
+ function isPublicSolanaRpcUrl(raw) {
4096
+ try {
4097
+ const parsed = new URL(raw);
4098
+ return PUBLIC_SOLANA_RPC_HOSTS.has(parsed.hostname) && !parsed.username && !parsed.password && !parsed.search && (parsed.pathname === "" || parsed.pathname === "/");
4099
+ } catch {
4100
+ return false;
4101
+ }
4102
+ }
4103
+ function redactRpcUrlsInText(text) {
4104
+ return text.replace(/https?:\/\/[^\s)'"]+/g, (url) => stripRpcSecrets(url));
4105
+ }
3866
4106
  var REFERENCE_SCAN_WINDOW = DEFAULTS.VERIFY_SIGNATURE_LIMIT;
3867
4107
  var REFERENCE_SCAN_LIST_ATTEMPTS = 3;
3868
4108
  var REFERENCE_SCAN_LIST_RETRY_DELAY_MS = 400;
@@ -4247,7 +4487,7 @@ var PaymentRecovery = class {
4247
4487
  * An error is never "the customer did not pay" - the caller keeps the job
4248
4488
  * recoverable.
4249
4489
  */
4250
- async listReferenceCandidates(reference, rpc, deadline) {
4490
+ async listReferenceCandidates(reference, rpc, pastDeadline) {
4251
4491
  let lastMessage = "unknown error";
4252
4492
  let attempts = 0;
4253
4493
  for (let attempt = 0; attempt < REFERENCE_SCAN_LIST_ATTEMPTS; attempt++) {
@@ -4263,7 +4503,7 @@ var PaymentRecovery = class {
4263
4503
  };
4264
4504
  } catch (e) {
4265
4505
  lastMessage = e?.message ?? "unknown error";
4266
- if (Date.now() >= deadline) {
4506
+ if (pastDeadline()) {
4267
4507
  break;
4268
4508
  }
4269
4509
  if (attempt < REFERENCE_SCAN_LIST_ATTEMPTS - 1) {
@@ -4303,7 +4543,9 @@ var PaymentRecovery = class {
4303
4543
  * the SDK default of 10 retries x 3s x a full window - over ten minutes for
4304
4544
  * one deferred entry.
4305
4545
  *
4306
- * TERMINAL ("none") REQUIRES ALL THREE, and the caller adds two more:
4546
+ * TERMINAL ("none") REQUIRES ALL FOUR OF THESE, and the caller adds four more
4547
+ * on top (they are counted as six in the docs, where 2-4 below are read as one
4548
+ * condition - "nothing was skipped and nothing was left unverified"):
4307
4549
  * 1. the listing SUCCEEDED and came back SHORT of the window, so this is the
4308
4550
  * reference's whole history rather than a truncated page. A flood that
4309
4551
  * fills the window could be hiding the payment behind it, which is
@@ -4315,26 +4557,40 @@ var PaymentRecovery = class {
4315
4557
  * evidence of non-payment;
4316
4558
  * 3. no candidate was SKIPPED for belonging to another job. A skip means
4317
4559
  * something did touch this reference and we chose not to look at it, so
4318
- * the scan saw less than the whole truth.
4319
- * The caller then requires the payment request's own expiry to have passed and
4320
- * a second consecutive sighting. Anything else is inconclusive: a false
4321
- * deferral costs latency, a false "unpaid" destroys the customer's money.
4560
+ * the scan saw less than the whole truth;
4561
+ * 4. no candidate was SKIPPED for an unusable signature. Same reasoning as
4562
+ * 3, with the node rather than the ledger as the reason we did not look:
4563
+ * a blanked signature is still a transaction on this reference.
4564
+ * The caller then requires, in this order: that the job owns no settlement of
4565
+ * its own, that the payment request's own expiry has passed, that the endpoint
4566
+ * proved its cluster and that it serves address history, and - one level up,
4567
+ * in `runtime.ts` - a second consecutive sighting. Anything else is
4568
+ * inconclusive: a false deferral costs latency, a false "unpaid" destroys the
4569
+ * customer's money.
4322
4570
  *
4323
4571
  * What "none" therefore means is narrow and honest: after the failed-on-chain
4324
4572
  * transactions are dropped, the reference's entire history is EMPTY - there
4325
4573
  * was nothing at all to look at.
4326
4574
  */
4327
- async verifyByReferenceScan(reference, rpc, jobId, deadline, verifySignature) {
4328
- const listed = await this.listReferenceCandidates(reference, rpc, deadline);
4575
+ async verifyByReferenceScan(reference, rpc, jobId, pastDeadline, verifySignature) {
4576
+ if (pastDeadline()) {
4577
+ return { outcome: "inconclusive", error: "the reference scan ran out of time" };
4578
+ }
4579
+ const listed = await this.listReferenceCandidates(reference, rpc, pastDeadline);
4329
4580
  if ("error" in listed) {
4330
4581
  return { outcome: "inconclusive", error: listed.error };
4331
4582
  }
4332
4583
  let skippedConsumed = false;
4584
+ let skippedUnusable = false;
4333
4585
  let unverifiableCandidate;
4334
4586
  for (const candidate of listed.signatures) {
4335
- if (Date.now() > deadline) {
4587
+ if (pastDeadline()) {
4336
4588
  return { outcome: "inconclusive", error: "the reference scan ran out of time" };
4337
4589
  }
4590
+ if (!isUsableSignature(candidate)) {
4591
+ skippedUnusable = true;
4592
+ continue;
4593
+ }
4338
4594
  const owner = this.ledger.paymentSignatureOwner(candidate);
4339
4595
  if (owner !== void 0 && owner !== jobId) {
4340
4596
  skippedConsumed = true;
@@ -4346,6 +4602,9 @@ var PaymentRecovery = class {
4346
4602
  }
4347
4603
  unverifiableCandidate ??= verified.error ?? "no reason given";
4348
4604
  }
4605
+ if (pastDeadline()) {
4606
+ return { outcome: "inconclusive", error: "the reference scan ran out of time" };
4607
+ }
4349
4608
  if (listed.windowFull) {
4350
4609
  return {
4351
4610
  outcome: "inconclusive",
@@ -4358,6 +4617,12 @@ var PaymentRecovery = class {
4358
4617
  error: "the reference carries a transaction another job already settled, so this scan did not see the whole picture"
4359
4618
  };
4360
4619
  }
4620
+ if (skippedUnusable) {
4621
+ return {
4622
+ outcome: "inconclusive",
4623
+ error: "the reference carries a transaction whose signature the node reported unusably, so this scan did not see the whole picture"
4624
+ };
4625
+ }
4361
4626
  if (unverifiableCandidate !== void 0) {
4362
4627
  return {
4363
4628
  outcome: "inconclusive",
@@ -4405,10 +4670,25 @@ var PaymentRecovery = class {
4405
4670
  );
4406
4671
  return "corrupt-state";
4407
4672
  }
4408
- const deadline = Date.now() + REFERENCE_SCAN_DEADLINE_MS;
4673
+ let deadline;
4409
4674
  try {
4410
4675
  const rpc = createSolanaRpc(getRpcUrl(this.network));
4411
4676
  const protocolConfig = await this.fetchProtocolConfig();
4677
+ deadline = Date.now() + REFERENCE_SCAN_DEADLINE_MS;
4678
+ const pastDeadline = () => signal?.aborted === true || Date.now() >= deadline;
4679
+ const degenerate = await degenerateReference(request, this.network, protocolConfig.treasury);
4680
+ if (degenerate !== void 0) {
4681
+ if (!isUsableSignature(entry.payment_signature)) {
4682
+ log(
4683
+ `[${shortId}] Recovery: the payment request's reference (${request.reference}) is an address the payment itself is computed from, so the transfer cannot be singled out by listing it. This is provider-side state, not a chain or customer problem.`
4684
+ );
4685
+ return "corrupt-state";
4686
+ }
4687
+ log(
4688
+ `[${shortId}] Recovery: the reference (${request.reference}) is an address the payment is computed from, but this job already owns settlement ${entry.payment_signature} - deferring rather than failing it, in case this build's list grew past what the settlement was accepted under. Not listing the reference: it cannot single out a payment.`
4689
+ );
4690
+ return "deferred";
4691
+ }
4412
4692
  const verify = async (txSignature2, retries) => {
4413
4693
  const verification = this.strategy.verifyPayment(rpc, request, protocolConfig, {
4414
4694
  txSignature: txSignature2,
@@ -4440,7 +4720,7 @@ var PaymentRecovery = class {
4440
4720
  };
4441
4721
  const ownSignature = entry.payment_signature;
4442
4722
  let txSignature;
4443
- if (ownSignature !== void 0) {
4723
+ if (isUsableSignature(ownSignature) && !pastDeadline()) {
4444
4724
  const own = await verify(ownSignature, OWN_SETTLEMENT_VERIFY_RETRIES);
4445
4725
  if (own.verified) {
4446
4726
  txSignature = ownSignature;
@@ -4455,11 +4735,11 @@ var PaymentRecovery = class {
4455
4735
  reference,
4456
4736
  rpc,
4457
4737
  entry.job_id,
4458
- deadline,
4738
+ pastDeadline,
4459
4739
  verify
4460
4740
  );
4461
4741
  if (scan.outcome === "none") {
4462
- if (ownSignature !== void 0) {
4742
+ if (isUsableSignature(ownSignature)) {
4463
4743
  log(
4464
4744
  `[${shortId}] Recovery: the reference scan is empty, but this job already owns settlement ${ownSignature} - treating the empty scan as an RPC history gap, not as non-payment. Deferring.`
4465
4745
  );
@@ -4800,14 +5080,24 @@ var SessionStore = class {
4800
5080
  }
4801
5081
  const payload = lines.map((line) => JSON.stringify(line)).join("\n") + "\n";
4802
5082
  const path = this.sessionPath(customerId, sessionId);
5083
+ if (isBlockingNodeSync(path)) {
5084
+ this.log(`[sessions] session file is not a regular file; not recording this exchange`);
5085
+ return;
5086
+ }
4803
5087
  try {
4804
- appendFileSync(path, payload, { mode: FILE_MODE });
4805
- } catch (error) {
4806
- if (error.code !== "ENOENT") {
4807
- throw error;
5088
+ try {
5089
+ appendFileSync(path, payload, { mode: FILE_MODE });
5090
+ } catch (error) {
5091
+ if (error.code !== "ENOENT") {
5092
+ throw error;
5093
+ }
5094
+ mkdirSync(join(this.root, customerId), { recursive: true, mode: DIR_MODE });
5095
+ appendFileSync(path, payload, { mode: FILE_MODE });
4808
5096
  }
4809
- mkdirSync(join(this.root, customerId), { recursive: true, mode: DIR_MODE });
4810
- appendFileSync(path, payload, { mode: FILE_MODE });
5097
+ } catch (error) {
5098
+ const reason = error instanceof Error ? error.message : String(error);
5099
+ this.log(`[sessions] could not record this exchange (${reason}); delivering without it`);
5100
+ return;
4811
5101
  }
4812
5102
  this.globalBytes += Buffer.byteLength(payload);
4813
5103
  }
@@ -4879,6 +5169,9 @@ var SessionStore = class {
4879
5169
  let raw;
4880
5170
  let size;
4881
5171
  try {
5172
+ if (isBlockingNodeSync(path)) {
5173
+ return null;
5174
+ }
4882
5175
  size = statSync(path).size;
4883
5176
  raw = readFileSync(path, "utf-8");
4884
5177
  } catch {
@@ -4910,9 +5203,17 @@ var SessionStore = class {
4910
5203
  }
4911
5204
  if (torn) {
4912
5205
  const payload = lines.map((line) => JSON.stringify(line)).join("\n") + (lines.length > 0 ? "\n" : "");
4913
- const tempPath = `${path}.tmp`;
4914
- writeFileSync(tempPath, payload, { mode: FILE_MODE });
4915
- renameSync(tempPath, path);
5206
+ const tempPath = `${path}.tmp.${randomBytes(6).toString("hex")}`;
5207
+ try {
5208
+ writeFileSync(tempPath, payload, { mode: FILE_MODE });
5209
+ renameSync(tempPath, path);
5210
+ } catch (error) {
5211
+ try {
5212
+ unlinkSync(tempPath);
5213
+ } catch {
5214
+ }
5215
+ throw error;
5216
+ }
4916
5217
  this.globalBytes = Math.max(0, this.globalBytes - size + Buffer.byteLength(payload));
4917
5218
  }
4918
5219
  return lines;
@@ -4981,10 +5282,18 @@ var SessionStore = class {
4981
5282
  const payload = rewritten.map((line) => JSON.stringify(line)).join("\n") + "\n";
4982
5283
  const path = this.sessionPath(customerId, sessionId);
4983
5284
  const oldSize = existsSync(path) ? statSync(path).size : 0;
4984
- const tempPath = `${path}.tmp`;
5285
+ const tempPath = `${path}.tmp.${randomBytes(6).toString("hex")}`;
4985
5286
  mkdirSync(join(this.root, customerId), { recursive: true, mode: DIR_MODE });
4986
- writeFileSync(tempPath, payload, { mode: FILE_MODE });
4987
- renameSync(tempPath, path);
5287
+ try {
5288
+ writeFileSync(tempPath, payload, { mode: FILE_MODE });
5289
+ renameSync(tempPath, path);
5290
+ } catch (error) {
5291
+ try {
5292
+ unlinkSync(tempPath);
5293
+ } catch {
5294
+ }
5295
+ throw error;
5296
+ }
4988
5297
  this.globalBytes = Math.max(0, this.globalBytes - oldSize + Buffer.byteLength(payload));
4989
5298
  return assembleReplay(rewritten, void 0);
4990
5299
  }
@@ -5121,7 +5430,7 @@ var SessionStore = class {
5121
5430
  continue;
5122
5431
  }
5123
5432
  for (const name of names) {
5124
- if (!name.includes(".corrupt.")) {
5433
+ if (!name.includes(".corrupt.") && !name.includes(".tmp")) {
5125
5434
  continue;
5126
5435
  }
5127
5436
  const path = join(dir, name);
@@ -5287,6 +5596,15 @@ var X402PermanentError = class extends Error {
5287
5596
  };
5288
5597
 
5289
5598
  // src/runtime.ts
5599
+ var MAX_LOGGED_MIME_CHARS = 128;
5600
+ function ledgerAmount(subunits) {
5601
+ if (subunits < 0n || subunits > BigInt(Number.MAX_SAFE_INTEGER)) {
5602
+ throw new Error(
5603
+ `Amount ${subunits.toString()} subunits cannot be recorded exactly: the ledger holds amounts as numbers, and this one is outside the range a number represents without rounding.`
5604
+ );
5605
+ }
5606
+ return Number(subunits);
5607
+ }
5290
5608
  var payment = new SolanaPaymentStrategy();
5291
5609
  var LEDGER_GC_INTERVAL_MS = 60 * 60 * 1e3;
5292
5610
  var SIG_PATH_TIMEOUT_MS = 60 * 1e3;
@@ -5943,8 +6261,31 @@ var AgentRuntime = class {
5943
6261
  });
5944
6262
  return { feeBps: config.feeBps, treasury: config.treasury };
5945
6263
  }
6264
+ /**
6265
+ * The ONE way a line leaves this runtime for the operator.
6266
+ *
6267
+ * Every line is scrubbed of RPC credentials here, once, rather than at the
6268
+ * call sites that happen to quote an error. Third-party RPC providers carry
6269
+ * the API key in the URL, and an error reaching this file can quote that URL:
6270
+ * `@solana/kit` does not put it in its own transport errors - measured across
6271
+ * refused connections, DNS failures, 429, 401, a JSON-RPC error and a body
6272
+ * that is not JSON - but for a JSON-RPC error it does not recognize it passes
6273
+ * the SERVER's message through, and a proxy is free to write the request URL
6274
+ * into that. `start.ts` already scrubbed the wallet error for this reason and
6275
+ * nothing here did.
6276
+ *
6277
+ * One place because the call sites are many and grow: seven bound a logger
6278
+ * of their own, and the payment path alone quotes an error in four. Nothing
6279
+ * this runtime logs carries a URL of its own, so the scrub costs no
6280
+ * diagnostic. The sink is `onLog`, which `start` fans out to the terminal and
6281
+ * to the structured log - so both are covered by the one pass.
6282
+ */
6283
+ get operatorLog() {
6284
+ const sink = this.callbacks.onLog ?? console.log;
6285
+ return (line) => sink(redactRpcUrlsInText(line));
6286
+ }
5946
6287
  async run() {
5947
- const log = this.callbacks.onLog ?? console.log;
6288
+ const log = this.operatorLog;
5948
6289
  this.ledger.pruneOldEntries(LEDGER_RETENTION_MS);
5949
6290
  this.sessionStore?.init();
5950
6291
  this.reconcileUsedNonces();
@@ -6094,7 +6435,7 @@ var AgentRuntime = class {
6094
6435
  this.callbacks.onStop?.();
6095
6436
  } catch (error) {
6096
6437
  const msg = error instanceof Error ? error.message : String(error);
6097
- (this.callbacks.onLog ?? console.log)(`onStop error: ${msg}`);
6438
+ this.operatorLog(`onStop error: ${msg}`);
6098
6439
  }
6099
6440
  this.abortController.abort();
6100
6441
  for (const controller of this.jobAbortControllers) {
@@ -6132,7 +6473,7 @@ var AgentRuntime = class {
6132
6473
  await this.executeJob(job, jobAbort.signal);
6133
6474
  } catch (raw) {
6134
6475
  const e = asHostScratchFailure(raw) ?? raw;
6135
- const log = this.callbacks.onLog ?? console.log;
6476
+ const log = this.operatorLog;
6136
6477
  const operatorMessage = describeForOperator(e);
6137
6478
  log(`[${job.jobId.slice(0, 8)}] Error: ${operatorMessage}`);
6138
6479
  if (isHostScratchError(e)) {
@@ -6165,7 +6506,7 @@ var AgentRuntime = class {
6165
6506
  }
6166
6507
  /** Core job processing logic - payment, skill execution, result delivery. */
6167
6508
  async executeJob(job, signal) {
6168
- const log = this.callbacks.onLog ?? console.log;
6509
+ const log = this.operatorLog;
6169
6510
  const inputBytes = utf8ByteLength(job.input);
6170
6511
  if (inputBytes > LIMITS.MAX_INPUT_LENGTH) {
6171
6512
  throw new Error(`Input too long: ${inputBytes} bytes (max ${LIMITS.MAX_INPUT_LENGTH})`);
@@ -6200,7 +6541,11 @@ var AgentRuntime = class {
6200
6541
  const attachmentOk = method === "POST" && job.attachment.mime.startsWith("text/") && job.attachment.size <= maxInputBytes;
6201
6542
  if (!attachmentOk) {
6202
6543
  log(
6203
- `[${job.jobId.slice(0, 8)}] Rejecting attachment on x402 skill (mime=${job.attachment.mime}, size=${job.attachment.size})`
6544
+ // `mime` is the customer's string and its schema bounds only the
6545
+ // length, so a newline in it forged whole log lines. Every other
6546
+ // piece of untrusted text in this file already goes through the
6547
+ // excerpt; this was the one that did not.
6548
+ `[${job.jobId.slice(0, 8)}] Rejecting attachment on x402 skill (mime=${excerptUntrusted(job.attachment.mime, MAX_LOGGED_MIME_CHARS)}, size=${job.attachment.size})`
6204
6549
  );
6205
6550
  await this.transport.sendFeedback(job, {
6206
6551
  type: "error",
@@ -6253,7 +6598,7 @@ var AgentRuntime = class {
6253
6598
  if (delegatedPull === void 0) {
6254
6599
  return;
6255
6600
  }
6256
- netAmount = Number(delegatedPull.priceSubunits);
6601
+ netAmount = ledgerAmount(delegatedPull.priceSubunits);
6257
6602
  } else if (jobPrice > 0) {
6258
6603
  const result = await this.collectPayment(job, jobPrice, jobAsset, signal);
6259
6604
  netAmount = result.netAmount;
@@ -6377,7 +6722,7 @@ var AgentRuntime = class {
6377
6722
  log
6378
6723
  );
6379
6724
  const outcome = await this.executeDelegatedPull(job, delegatedPull, log);
6380
- netAmount = Number(delegatedPull.chargedSubunits ?? delegatedPull.priceSubunits);
6725
+ netAmount = ledgerAmount(delegatedPull.chargedSubunits ?? delegatedPull.priceSubunits);
6381
6726
  if (outcome === "dead") {
6382
6727
  this.ledger.markFailed(job.jobId);
6383
6728
  await this.transport.sendFeedback(job, {
@@ -6634,7 +6979,7 @@ var AgentRuntime = class {
6634
6979
  job.jobId,
6635
6980
  pull.signature,
6636
6981
  Number(pull.lastValidBlockHeight),
6637
- Number(amount)
6982
+ ledgerAmount(amount)
6638
6983
  );
6639
6984
  ctx.pullSignature = pull.signature;
6640
6985
  const outcome = await sendConfirmToTerminal(ctx.rpc, pull, {});
@@ -6840,7 +7185,7 @@ var AgentRuntime = class {
6840
7185
  * rather than losing the customer's paid job.
6841
7186
  */
6842
7187
  async seedGuarded(jobId, kind, run) {
6843
- const log = this.callbacks.onLog ?? console.log;
7188
+ const log = this.operatorLog;
6844
7189
  const started = Date.now();
6845
7190
  try {
6846
7191
  const seeded = await run();
@@ -6987,6 +7332,9 @@ var AgentRuntime = class {
6987
7332
  return void 0;
6988
7333
  }
6989
7334
  try {
7335
+ if (await isBlockingNode(filePath)) {
7336
+ return void 0;
7337
+ }
6990
7338
  const bytes = await readFile(filePath);
6991
7339
  return await this.seedBlossomMember(bytes, recipientPubkey);
6992
7340
  } catch {
@@ -7157,7 +7505,7 @@ var AgentRuntime = class {
7157
7505
  * polls for on-chain confirmation. Aborts if signal fires.
7158
7506
  */
7159
7507
  async collectPayment(job, jobPrice, jobAsset, signal) {
7160
- const log = this.callbacks.onLog ?? console.log;
7508
+ const log = this.operatorLog;
7161
7509
  if (!this.config.solanaAddress) {
7162
7510
  throw new Error("Solana address not configured");
7163
7511
  }
@@ -7229,7 +7577,7 @@ var AgentRuntime = class {
7229
7577
  return;
7230
7578
  }
7231
7579
  const txSignature = askedSignature ?? verified.txSignature;
7232
- if (txSignature === void 0) {
7580
+ if (!isUsableSignature(txSignature)) {
7233
7581
  paymentRefusal = { consumedByOther: false, sentence: UNNAMED_SETTLEMENT_SENTENCE };
7234
7582
  lose({ verified: false }, `${pathLabel}: ${UNNAMED_SETTLEMENT_SENTENCE}`);
7235
7583
  return;
@@ -7398,7 +7746,7 @@ var AgentRuntime = class {
7398
7746
  (jobId) => this.ledger.getStatus(jobId) === "paid"
7399
7747
  );
7400
7748
  }
7401
- const log = this.callbacks.onLog ?? console.log;
7749
+ const log = this.operatorLog;
7402
7750
  this.logDeferralSummary(log);
7403
7751
  if (pending.length === 0) {
7404
7752
  return;
@@ -8246,7 +8594,7 @@ function buildCliSkill(parsed, entryPath, scriptEnv, network) {
8246
8594
  }
8247
8595
  return skill;
8248
8596
  }
8249
- function loadSkillsFromDir(skillsDir, options) {
8597
+ function loadSkillsFromDir2(skillsDir, options) {
8250
8598
  const skills = [];
8251
8599
  let entries;
8252
8600
  try {
@@ -8265,6 +8613,9 @@ function loadSkillsFromDir(skillsDir, options) {
8265
8613
  }
8266
8614
  const skillMdPath = join(entryPath, "SKILL.md");
8267
8615
  try {
8616
+ if (isBlockingNodeSync(skillMdPath)) {
8617
+ throw new Error("it is a pipe, socket or device, not a file");
8618
+ }
8268
8619
  const content = readFileSync(skillMdPath, "utf-8");
8269
8620
  const { frontmatter, systemPrompt } = parseSkillMd(content);
8270
8621
  const parsed = validateSkillFrontmatter(frontmatter, systemPrompt, {
@@ -8612,6 +8963,9 @@ function startWatchdog(deps) {
8612
8963
  }
8613
8964
  };
8614
8965
  }
8966
+ var INDEX_FILE_MODE = 384;
8967
+ var REPAIR_HINT = "every bridged job is refused until that entry is corrected or removed by hand; removing it forgets what was already paid for that job";
8968
+ var STRANDED_TEMP_MIN_AGE_MS2 = 60 * 60 * 1e3;
8615
8969
  var X402_JOBS_FILE = ".x402-jobs.json";
8616
8970
  var X402_RESULTS_DIR = ".x402-results";
8617
8971
  function sanitizeJobId(jobId) {
@@ -8637,6 +8991,11 @@ var X402JobStore = class {
8637
8991
  return scheduled;
8638
8992
  }
8639
8993
  async load() {
8994
+ if (await isBlockingNode(this.jobsPath)) {
8995
+ throw new Error(
8996
+ `Refusing to read ${this.jobsPath}: it is a pipe, socket or device, not a file`
8997
+ );
8998
+ }
8640
8999
  let raw;
8641
9000
  try {
8642
9001
  raw = await readFile(this.jobsPath, "utf-8");
@@ -8650,16 +9009,73 @@ var X402JobStore = class {
8650
9009
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
8651
9010
  throw new Error(`x402 store ${this.jobsPath} is not a JSON object`);
8652
9011
  }
9012
+ const isCount = (value) => typeof value === "number" && Number.isInteger(value) && value >= 0;
9013
+ for (const [jobId, record] of Object.entries(parsed)) {
9014
+ if (record === null || typeof record !== "object" || Array.isArray(record)) {
9015
+ throw new Error(
9016
+ `x402 store ${this.jobsPath} holds a non-record at ${jobId} - ${REPAIR_HINT}`
9017
+ );
9018
+ }
9019
+ const slot = record;
9020
+ if (!isCount(slot.attempts) || !isCount(slot.created_at) || !isCount(slot.updated_at) || slot.signatures !== void 0 && !isCount(slot.signatures)) {
9021
+ throw new Error(
9022
+ `x402 store ${this.jobsPath} holds an unusable record at ${jobId} - ${REPAIR_HINT}`
9023
+ );
9024
+ }
9025
+ }
8653
9026
  return parsed;
8654
9027
  }
8655
9028
  async save(file) {
8656
- const tempPath = `${this.jobsPath}.tmp`;
8657
- await writeFile(tempPath, JSON.stringify(file, null, 2), "utf-8");
8658
- await rename(tempPath, this.jobsPath);
9029
+ const tempPath = `${this.jobsPath}.tmp.${randomBytes(6).toString("hex")}`;
9030
+ try {
9031
+ await writeFile(tempPath, JSON.stringify(file, null, 2), {
9032
+ encoding: "utf-8",
9033
+ mode: INDEX_FILE_MODE
9034
+ });
9035
+ await chmod(tempPath, INDEX_FILE_MODE);
9036
+ await rename(tempPath, this.jobsPath);
9037
+ } catch (error) {
9038
+ try {
9039
+ await rm(tempPath, { force: true });
9040
+ } catch {
9041
+ }
9042
+ throw error;
9043
+ }
8659
9044
  }
8660
9045
  resultFilePath(jobId) {
8661
9046
  return join(this.resultsDir, sanitizeJobId(jobId));
8662
9047
  }
9048
+ /** Remove `<result>.tmp.<hex>` leftovers for one job. Best effort. */
9049
+ async sweepStrandedTemporaries(jobId) {
9050
+ const prefix = `${sanitizeJobId(jobId)}.tmp.`;
9051
+ try {
9052
+ const entries = await readdir(this.resultsDir);
9053
+ await Promise.all(
9054
+ entries.filter((entry) => entry.startsWith(prefix)).map((entry) => this.removeIfStale(join(this.resultsDir, entry)))
9055
+ );
9056
+ } catch {
9057
+ }
9058
+ }
9059
+ /**
9060
+ * Remove a temporary only once it is too old to be a live writer's.
9061
+ *
9062
+ * The queue serializes writers inside ONE process, and there is meant to be
9063
+ * one store per agent directory - but `sweepExpired` runs from the driver's
9064
+ * constructor, so a second `elisym start` on the same directory sweeps while
9065
+ * the first is mid-write. Same argument as the ledger's guard: two agents on
9066
+ * one directory is unsupported, and a sweep is no place to make it worse.
9067
+ */
9068
+ async removeIfStale(path) {
9069
+ try {
9070
+ const info = await stat(path);
9071
+ if (Date.now() - info.mtimeMs < STRANDED_TEMP_MIN_AGE_MS2) {
9072
+ return;
9073
+ }
9074
+ } catch {
9075
+ return;
9076
+ }
9077
+ await rm(path, { force: true }).catch(() => void 0);
9078
+ }
8663
9079
  /**
8664
9080
  * Atomically claim one paid attempt: increment iff still under BOTH caps
8665
9081
  * (durable attempts and the monotonic signature count), and FLUSH before
@@ -8740,8 +9156,20 @@ var X402JobStore = class {
8740
9156
  /** Binary result: bytes hit disk BEFORE the record flush (crash-safe ordering). */
8741
9157
  async saveFileResult(jobId, mime, bytes) {
8742
9158
  const filePath = this.resultFilePath(jobId);
8743
- await mkdir(this.resultsDir, { recursive: true });
8744
- await writeFile(filePath, bytes);
9159
+ await mkdir(this.resultsDir, { recursive: true, mode: 448 });
9160
+ await chmod(this.resultsDir, 448).catch(() => {
9161
+ });
9162
+ const tempPath = `${filePath}.tmp.${randomBytes(6).toString("hex")}`;
9163
+ try {
9164
+ await writeFile(tempPath, bytes, { mode: 384 });
9165
+ await rename(tempPath, filePath);
9166
+ } catch (error) {
9167
+ try {
9168
+ await rm(tempPath, { force: true });
9169
+ } catch {
9170
+ }
9171
+ throw error;
9172
+ }
8745
9173
  await this.runExclusive(async () => {
8746
9174
  const file = await this.load();
8747
9175
  const now = Date.now();
@@ -8777,9 +9205,32 @@ var X402JobStore = class {
8777
9205
  }
8778
9206
  return { data: "", outputMime: result.mime, filePath };
8779
9207
  }
9208
+ /**
9209
+ * Remove fragments of the INDEX left by a crash between write and rename.
9210
+ *
9211
+ * Keyed by record id like the result sweep cannot be: the index has no job to
9212
+ * expire with, so nothing would ever visit its fragments - the suffix is
9213
+ * random, the name is not `.x402-jobs.json`, and `sweepStrandedTemporaries`
9214
+ * walks the results directory only. What a fragment holds is which upstream
9215
+ * calls this bridge has already paid for.
9216
+ */
9217
+ async sweepStrandedIndexTemporaries() {
9218
+ const dir = dirname(this.jobsPath);
9219
+ const legacyName = `${basename(this.jobsPath)}.tmp`;
9220
+ const prefix = `${legacyName}.`;
9221
+ try {
9222
+ const entries = await readdir(dir);
9223
+ await Promise.all(
9224
+ entries.filter((entry) => entry === legacyName || entry.startsWith(prefix)).map((entry) => this.removeIfStale(join(dir, entry)))
9225
+ );
9226
+ } catch {
9227
+ }
9228
+ }
8780
9229
  /** Drop records (and their result files) older than the cache TTL. */
8781
9230
  async sweepExpired(now = Date.now()) {
8782
9231
  await this.runExclusive(async () => {
9232
+ await this.sweepStrandedIndexTemporaries();
9233
+ await chmod(this.jobsPath, INDEX_FILE_MODE).catch(() => void 0);
8783
9234
  const file = await this.load();
8784
9235
  let changed = false;
8785
9236
  for (const [jobId, record] of Object.entries(file)) {
@@ -8790,6 +9241,7 @@ var X402JobStore = class {
8790
9241
  changed = true;
8791
9242
  await rm(this.resultFilePath(jobId), { force: true }).catch(() => {
8792
9243
  });
9244
+ await this.sweepStrandedTemporaries(jobId);
8793
9245
  }
8794
9246
  if (changed) {
8795
9247
  await this.save(file);
@@ -9456,7 +9908,7 @@ async function cmdStart(nameArg, options = {}) {
9456
9908
  const scriptEnv = buildScriptEnv(loaded.secrets);
9457
9909
  const paths = agentPaths(loaded.dir);
9458
9910
  const skillsDir = paths.skills;
9459
- const allSkills = loadSkillsFromDir(skillsDir, { network: walletNetwork, scriptEnv });
9911
+ const allSkills = loadSkillsFromDir2(skillsDir, { network: walletNetwork, scriptEnv });
9460
9912
  if (allSkills.length === 0) {
9461
9913
  console.error(` ! No skills found in ${skillsDir}
9462
9914
  `);
@@ -9479,6 +9931,31 @@ async function cmdStart(nameArg, options = {}) {
9479
9931
  );
9480
9932
  process.exit(1);
9481
9933
  }
9934
+ if (hasPaid && solanaAddress) {
9935
+ try {
9936
+ const neighbors = await findSharedPayoutNeighbors(
9937
+ cwd,
9938
+ loaded.dir,
9939
+ walletNetwork,
9940
+ solanaAddress
9941
+ );
9942
+ for (const neighbor of neighbors) {
9943
+ console.log(
9944
+ ` ! WARNING: agent "${neighbor.name}" (${neighbor.dir}) ` + (neighbor.paid === "unknown" ? `shares this payout address on ${walletNetwork}, and its skills could not be read to tell whether any of them are paid.` : `is paid at the same address on ${walletNetwork}.`) + ` One transaction can then be counted for jobs of both agents, so a customer could be served twice for one transfer. See https://docs.elisym.network/protocol/payments for what the rail does and does not promise.`
9945
+ );
9946
+ }
9947
+ if (neighbors.length > 0) {
9948
+ console.log();
9949
+ }
9950
+ } catch (error) {
9951
+ console.log(
9952
+ ` ! Could not check whether another agent is paid at this address (${deleteControlCharacters(
9953
+ error instanceof Error ? error.message : String(error)
9954
+ )}). Starting anyway.`
9955
+ );
9956
+ console.log();
9957
+ }
9958
+ }
9482
9959
  const llmSkills = allSkills.filter((skill) => skill.mode === "llm");
9483
9960
  const scriptDepSkills = allSkills.filter(
9484
9961
  (skill) => skill.mode !== "llm" && skill.llmOverride?.provider && skill.llmOverride?.model
@@ -9663,6 +10140,7 @@ async function cmdStart(nameArg, options = {}) {
9663
10140
  const x402Skills = allSkills.filter((skill) => skill.mode === "x402");
9664
10141
  let x402InvariantBroken;
9665
10142
  if (x402Skills.length > 0) {
10143
+ await ensureGitignoreHasX402Entries(dirname(loaded.dir));
9666
10144
  const solanaSecretKey = loaded.secrets.solana_secret_key;
9667
10145
  if (solanaSecretKey === void 0 || solanaSecretKey.length === 0) {
9668
10146
  x402InvariantBroken = "no solana_secret_key in .secrets.json";
@@ -9769,8 +10247,11 @@ async function cmdStart(nameArg, options = {}) {
9769
10247
  }
9770
10248
  }
9771
10249
  if (mediaCacheDirty) {
10250
+ await ensureGitignoreHasPrivateStateEntries(dirname(loaded.dir));
9772
10251
  await writeMediaCache(loaded.dir, mediaCache);
9773
10252
  }
10253
+ await ensureGitignoreHasPrivateStateEntries(dirname(loaded.dir));
10254
+ const ledger = new JobLedger(paths.jobs);
9774
10255
  let profilePublished = false;
9775
10256
  try {
9776
10257
  await client.discovery.publishProfile(
@@ -9922,6 +10403,16 @@ async function cmdStart(nameArg, options = {}) {
9922
10403
  console.warn(` ! Run \`npx @elisym/cli delegate-key ${loaded.name}\` to enable it.`);
9923
10404
  }
9924
10405
  }
10406
+ if (delegateSigner !== void 0) {
10407
+ await ensureGitignoreHasDelegationNoncesEntry(dirname(loaded.dir));
10408
+ }
10409
+ const nonceStore = delegateSigner !== void 0 ? new UsedNonceStore(join(loaded.dir, ".delegation-nonces.json")) : void 0;
10410
+ await ensureGitignoreHasIrohEntry(dirname(loaded.dir));
10411
+ await ensureGitignoreHasPrivateStateEntries(dirname(loaded.dir));
10412
+ const hasContextSkills = registry.all().some((skill) => skill.context === true);
10413
+ if (hasContextSkills) {
10414
+ await ensureGitignoreHasSessionsEntry(dirname(loaded.dir));
10415
+ }
9925
10416
  const buildCard = (skill) => buildCapabilityCard(skill, { walletNetwork, solanaAddress, delegatePubkey });
9926
10417
  function retiredModelKeyForSkill(skill) {
9927
10418
  if (skill.resolvedTriple) {
@@ -10011,16 +10502,7 @@ async function cmdStart(nameArg, options = {}) {
10011
10502
  });
10012
10503
  };
10013
10504
  const transport = new NostrTransport(client, identity2, [DEFAULT_KIND_OFFSET]);
10014
- const ledger = new JobLedger(paths.jobs);
10015
10505
  const irohTransport = createIrohTransport({ storePath: join(loaded.dir, ".iroh") });
10016
- await ensureGitignoreHasIrohEntry(dirname(loaded.dir));
10017
- if (x402Skills.length > 0) {
10018
- await ensureGitignoreHasX402Entries(dirname(loaded.dir));
10019
- }
10020
- const hasContextSkills = registry.all().some((skill) => skill.context === true);
10021
- if (hasContextSkills) {
10022
- await ensureGitignoreHasSessionsEntry(dirname(loaded.dir));
10023
- }
10024
10506
  const runtimeConfig = {
10025
10507
  paymentTimeoutSecs: DEFAULTS.PAYMENT_EXPIRY_SECS,
10026
10508
  maxConcurrentJobs: MAX_CONCURRENT_JOBS,
@@ -10035,10 +10517,6 @@ async function cmdStart(nameArg, options = {}) {
10035
10517
  // the payment key) to execute post-work pulls from customer delegations.
10036
10518
  delegateSigner
10037
10519
  };
10038
- const nonceStore = delegateSigner !== void 0 ? new UsedNonceStore(join(loaded.dir, ".delegation-nonces.json")) : void 0;
10039
- if (nonceStore !== void 0) {
10040
- await ensureGitignoreHasDelegationNoncesEntry(dirname(loaded.dir));
10041
- }
10042
10520
  const rpcUrlForLog = stripRpcSecrets(process.env.SOLANA_RPC_URL ?? getRpcUrl(walletNetwork));
10043
10521
  logger.debug(
10044
10522
  {
@@ -10188,37 +10666,6 @@ function buildScriptEnv(secrets) {
10188
10666
  }
10189
10667
  return scriptEnv;
10190
10668
  }
10191
- var PUBLIC_SOLANA_RPC_HOSTS = /* @__PURE__ */ new Set([
10192
- "api.devnet.solana.com",
10193
- "api.mainnet-beta.solana.com",
10194
- "api.testnet.solana.com"
10195
- ]);
10196
- function stripRpcSecrets(raw) {
10197
- try {
10198
- const parsed = new URL(raw);
10199
- parsed.username = "";
10200
- parsed.password = "";
10201
- if (!PUBLIC_SOLANA_RPC_HOSTS.has(parsed.hostname)) {
10202
- return `${parsed.protocol}//${parsed.host}/***`;
10203
- }
10204
- const marker = parsed.search.length > 0 ? "?***" : "";
10205
- parsed.search = "";
10206
- return `${parsed.toString()}${marker}`;
10207
- } catch {
10208
- return "[unparseable RPC URL]";
10209
- }
10210
- }
10211
- function isPublicSolanaRpcUrl(raw) {
10212
- try {
10213
- const parsed = new URL(raw);
10214
- return PUBLIC_SOLANA_RPC_HOSTS.has(parsed.hostname) && !parsed.username && !parsed.password && !parsed.search && (parsed.pathname === "" || parsed.pathname === "/");
10215
- } catch {
10216
- return false;
10217
- }
10218
- }
10219
- function redactRpcUrlsInText(text) {
10220
- return text.replace(/https?:\/\/[^\s)'"]+/g, (url) => stripRpcSecrets(url));
10221
- }
10222
10669
  async function resolveMediaField(value, agentDir, cache, blossom, identity2, onCacheUpdate) {
10223
10670
  if (!value) {
10224
10671
  return void 0;
@@ -10271,11 +10718,23 @@ async function uploadOrReuse(cacheKey, absPath, containRoot, cache, blossom, ide
10271
10718
  );
10272
10719
  return void 0;
10273
10720
  }
10721
+ if (!isImagePath(realPath)) {
10722
+ console.warn(
10723
+ ` ! Skipping upload of ${basename(absPath)}: not an image (${IMAGE_EXTENSIONS.join(", ")}). Nothing else is published from an agent directory.`
10724
+ );
10725
+ return void 0;
10726
+ }
10274
10727
  try {
10275
10728
  const cached = await lookupCachedUrl(cache, cacheKey, realPath);
10276
10729
  if (cached) {
10277
10730
  return cached;
10278
10731
  }
10732
+ if (isBlockingNodeSync(realPath)) {
10733
+ console.warn(
10734
+ ` ! Skipping ${basename(realPath)}: it is a pipe, socket or device, not a file`
10735
+ );
10736
+ return void 0;
10737
+ }
10279
10738
  console.log(` Uploading ${basename(realPath)}...`);
10280
10739
  const data = readFileSync(realPath);
10281
10740
  const sha256 = createHash("sha256").update(data).digest("hex");