@elisym/cli 0.31.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,20 +1,20 @@
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, jobRequestKind, DEFAULT_KIND_OFFSET, toDTag, createBlossomTransport, generateSolanaWallet, verifyAgentIdentities, getProtocolConfig, getProtocolProgramId, calculateProtocolFee, LIMITS, makeCensor, DEFAULT_REDACT_PATHS, POLICY_T_TAG, SESSION_ID_REGEX, createSlidingWindowLimiter, utf8ByteLength, readAcceptedTransports, resolveDelegationAsset, MAX_PROOF_TTL_SECS, PROOF_CLOCK_SKEW_SECS, verifyDelegationAuthProof, deriveOwnerDelegationAta, getDelegation, buildDelegatedTransfer, buildSignedPull, sendConfirmToTerminal, parseDelegatedPayment, confirmPullToTerminal, isDefinitelyUnpaid, decodeJobPayload, BoundedSet, KIND_JOB_FEEDBACK, encodeSecretKeyBase58, GITHUB_USERNAME_REGEX, X_USERNAME_REGEX, normalizeNip05Identifier, splitNip05Identifier, 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, readFile, mkdtemp, rm, rename, stat } from 'node:fs/promises';
12
- import { parseSkillMd, validateSkillFrontmatter, 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';
17
- import { LlmHealthMonitor, startLlmRecovery, createFreeLlmLimiterSet, ScriptBillingExhaustedError, ScriptExecutionError, FREE_LLM_GLOBAL_KEY, freeLlmCustomerKey, LlmHealthError } from '@elisym/sdk/llm-health';
16
+ import { createHash, randomBytes } from 'node:crypto';
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';
20
20
  import { Socket } from 'node:net';
@@ -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) {
@@ -3804,9 +4007,6 @@ function resolveProviderApiKey(input) {
3804
4007
  error: `Provider "${provider}" needs an API key (required by skill(s): ${skillList}). Set secrets.llm_api_keys.${provider} via 'npx @elisym/cli profile <agent>' or export ${descriptor.envVar}.`
3805
4008
  };
3806
4009
  }
3807
- function sanitizeForTerminal(value) {
3808
- return value.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, "");
3809
- }
3810
4010
  function resolveLevel(options) {
3811
4011
  if (options.level) {
3812
4012
  return options.level;
@@ -3845,7 +4045,7 @@ function createLogger(options = {}) {
3845
4045
  logger = pino(baseOptions, pino.destination(2));
3846
4046
  }
3847
4047
  function logWithIndent(line) {
3848
- process.stdout.write(` ${sanitizeForTerminal(line)}
4048
+ process.stdout.write(` ${deleteControlCharacters(line)}
3849
4049
  `);
3850
4050
  }
3851
4051
  return {
@@ -3866,6 +4066,43 @@ var MIME_BY_EXT = {
3866
4066
  function mimeFromPath(path) {
3867
4067
  return MIME_BY_EXT[extname(path).toLowerCase()] ?? "application/octet-stream";
3868
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
+ }
3869
4106
  var REFERENCE_SCAN_WINDOW = DEFAULTS.VERIFY_SIGNATURE_LIMIT;
3870
4107
  var REFERENCE_SCAN_LIST_ATTEMPTS = 3;
3871
4108
  var REFERENCE_SCAN_LIST_RETRY_DELAY_MS = 400;
@@ -4250,7 +4487,7 @@ var PaymentRecovery = class {
4250
4487
  * An error is never "the customer did not pay" - the caller keeps the job
4251
4488
  * recoverable.
4252
4489
  */
4253
- async listReferenceCandidates(reference, rpc, deadline) {
4490
+ async listReferenceCandidates(reference, rpc, pastDeadline) {
4254
4491
  let lastMessage = "unknown error";
4255
4492
  let attempts = 0;
4256
4493
  for (let attempt = 0; attempt < REFERENCE_SCAN_LIST_ATTEMPTS; attempt++) {
@@ -4266,7 +4503,7 @@ var PaymentRecovery = class {
4266
4503
  };
4267
4504
  } catch (e) {
4268
4505
  lastMessage = e?.message ?? "unknown error";
4269
- if (Date.now() >= deadline) {
4506
+ if (pastDeadline()) {
4270
4507
  break;
4271
4508
  }
4272
4509
  if (attempt < REFERENCE_SCAN_LIST_ATTEMPTS - 1) {
@@ -4306,7 +4543,9 @@ var PaymentRecovery = class {
4306
4543
  * the SDK default of 10 retries x 3s x a full window - over ten minutes for
4307
4544
  * one deferred entry.
4308
4545
  *
4309
- * 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"):
4310
4549
  * 1. the listing SUCCEEDED and came back SHORT of the window, so this is the
4311
4550
  * reference's whole history rather than a truncated page. A flood that
4312
4551
  * fills the window could be hiding the payment behind it, which is
@@ -4318,26 +4557,40 @@ var PaymentRecovery = class {
4318
4557
  * evidence of non-payment;
4319
4558
  * 3. no candidate was SKIPPED for belonging to another job. A skip means
4320
4559
  * something did touch this reference and we chose not to look at it, so
4321
- * the scan saw less than the whole truth.
4322
- * The caller then requires the payment request's own expiry to have passed and
4323
- * a second consecutive sighting. Anything else is inconclusive: a false
4324
- * 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.
4325
4570
  *
4326
4571
  * What "none" therefore means is narrow and honest: after the failed-on-chain
4327
4572
  * transactions are dropped, the reference's entire history is EMPTY - there
4328
4573
  * was nothing at all to look at.
4329
4574
  */
4330
- async verifyByReferenceScan(reference, rpc, jobId, deadline, verifySignature) {
4331
- 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);
4332
4580
  if ("error" in listed) {
4333
4581
  return { outcome: "inconclusive", error: listed.error };
4334
4582
  }
4335
4583
  let skippedConsumed = false;
4584
+ let skippedUnusable = false;
4336
4585
  let unverifiableCandidate;
4337
4586
  for (const candidate of listed.signatures) {
4338
- if (Date.now() > deadline) {
4587
+ if (pastDeadline()) {
4339
4588
  return { outcome: "inconclusive", error: "the reference scan ran out of time" };
4340
4589
  }
4590
+ if (!isUsableSignature(candidate)) {
4591
+ skippedUnusable = true;
4592
+ continue;
4593
+ }
4341
4594
  const owner = this.ledger.paymentSignatureOwner(candidate);
4342
4595
  if (owner !== void 0 && owner !== jobId) {
4343
4596
  skippedConsumed = true;
@@ -4349,6 +4602,9 @@ var PaymentRecovery = class {
4349
4602
  }
4350
4603
  unverifiableCandidate ??= verified.error ?? "no reason given";
4351
4604
  }
4605
+ if (pastDeadline()) {
4606
+ return { outcome: "inconclusive", error: "the reference scan ran out of time" };
4607
+ }
4352
4608
  if (listed.windowFull) {
4353
4609
  return {
4354
4610
  outcome: "inconclusive",
@@ -4361,6 +4617,12 @@ var PaymentRecovery = class {
4361
4617
  error: "the reference carries a transaction another job already settled, so this scan did not see the whole picture"
4362
4618
  };
4363
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
+ }
4364
4626
  if (unverifiableCandidate !== void 0) {
4365
4627
  return {
4366
4628
  outcome: "inconclusive",
@@ -4408,10 +4670,25 @@ var PaymentRecovery = class {
4408
4670
  );
4409
4671
  return "corrupt-state";
4410
4672
  }
4411
- const deadline = Date.now() + REFERENCE_SCAN_DEADLINE_MS;
4673
+ let deadline;
4412
4674
  try {
4413
4675
  const rpc = createSolanaRpc(getRpcUrl(this.network));
4414
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
+ }
4415
4692
  const verify = async (txSignature2, retries) => {
4416
4693
  const verification = this.strategy.verifyPayment(rpc, request, protocolConfig, {
4417
4694
  txSignature: txSignature2,
@@ -4443,7 +4720,7 @@ var PaymentRecovery = class {
4443
4720
  };
4444
4721
  const ownSignature = entry.payment_signature;
4445
4722
  let txSignature;
4446
- if (ownSignature !== void 0) {
4723
+ if (isUsableSignature(ownSignature) && !pastDeadline()) {
4447
4724
  const own = await verify(ownSignature, OWN_SETTLEMENT_VERIFY_RETRIES);
4448
4725
  if (own.verified) {
4449
4726
  txSignature = ownSignature;
@@ -4458,11 +4735,11 @@ var PaymentRecovery = class {
4458
4735
  reference,
4459
4736
  rpc,
4460
4737
  entry.job_id,
4461
- deadline,
4738
+ pastDeadline,
4462
4739
  verify
4463
4740
  );
4464
4741
  if (scan.outcome === "none") {
4465
- if (ownSignature !== void 0) {
4742
+ if (isUsableSignature(ownSignature)) {
4466
4743
  log(
4467
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.`
4468
4745
  );
@@ -4803,14 +5080,24 @@ var SessionStore = class {
4803
5080
  }
4804
5081
  const payload = lines.map((line) => JSON.stringify(line)).join("\n") + "\n";
4805
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
+ }
4806
5087
  try {
4807
- appendFileSync(path, payload, { mode: FILE_MODE });
4808
- } catch (error) {
4809
- if (error.code !== "ENOENT") {
4810
- 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 });
4811
5096
  }
4812
- mkdirSync(join(this.root, customerId), { recursive: true, mode: DIR_MODE });
4813
- 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;
4814
5101
  }
4815
5102
  this.globalBytes += Buffer.byteLength(payload);
4816
5103
  }
@@ -4882,6 +5169,9 @@ var SessionStore = class {
4882
5169
  let raw;
4883
5170
  let size;
4884
5171
  try {
5172
+ if (isBlockingNodeSync(path)) {
5173
+ return null;
5174
+ }
4885
5175
  size = statSync(path).size;
4886
5176
  raw = readFileSync(path, "utf-8");
4887
5177
  } catch {
@@ -4913,9 +5203,17 @@ var SessionStore = class {
4913
5203
  }
4914
5204
  if (torn) {
4915
5205
  const payload = lines.map((line) => JSON.stringify(line)).join("\n") + (lines.length > 0 ? "\n" : "");
4916
- const tempPath = `${path}.tmp`;
4917
- writeFileSync(tempPath, payload, { mode: FILE_MODE });
4918
- 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
+ }
4919
5217
  this.globalBytes = Math.max(0, this.globalBytes - size + Buffer.byteLength(payload));
4920
5218
  }
4921
5219
  return lines;
@@ -4984,10 +5282,18 @@ var SessionStore = class {
4984
5282
  const payload = rewritten.map((line) => JSON.stringify(line)).join("\n") + "\n";
4985
5283
  const path = this.sessionPath(customerId, sessionId);
4986
5284
  const oldSize = existsSync(path) ? statSync(path).size : 0;
4987
- const tempPath = `${path}.tmp`;
5285
+ const tempPath = `${path}.tmp.${randomBytes(6).toString("hex")}`;
4988
5286
  mkdirSync(join(this.root, customerId), { recursive: true, mode: DIR_MODE });
4989
- writeFileSync(tempPath, payload, { mode: FILE_MODE });
4990
- 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
+ }
4991
5297
  this.globalBytes = Math.max(0, this.globalBytes - oldSize + Buffer.byteLength(payload));
4992
5298
  return assembleReplay(rewritten, void 0);
4993
5299
  }
@@ -5124,7 +5430,7 @@ var SessionStore = class {
5124
5430
  continue;
5125
5431
  }
5126
5432
  for (const name of names) {
5127
- if (!name.includes(".corrupt.")) {
5433
+ if (!name.includes(".corrupt.") && !name.includes(".tmp")) {
5128
5434
  continue;
5129
5435
  }
5130
5436
  const path = join(dir, name);
@@ -5290,6 +5596,15 @@ var X402PermanentError = class extends Error {
5290
5596
  };
5291
5597
 
5292
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
+ }
5293
5608
  var payment = new SolanaPaymentStrategy();
5294
5609
  var LEDGER_GC_INTERVAL_MS = 60 * 60 * 1e3;
5295
5610
  var SIG_PATH_TIMEOUT_MS = 60 * 1e3;
@@ -5324,24 +5639,106 @@ function resolveJobAsset(tags, skills) {
5324
5639
  const skill = skills.route(tags);
5325
5640
  return skill?.asset ?? NATIVE_SOL;
5326
5641
  }
5327
- var BILLING_BODY_MARKERS3 = ["credit balance", "billing", "insufficient", "insufficient_quota"];
5328
5642
  var SCRIPT_BILLING_INVALID_MARKERS = [
5329
- "credit balance",
5330
- "billing",
5331
- "insufficient",
5332
- "insufficient_quota",
5333
- "x-api-key",
5334
- "invalid api key",
5335
- "invalid_api_key",
5336
- "authentication_error",
5337
- "unauthorized",
5338
- "unauthenticated"
5643
+ // Three questions, one row each, so an edit cannot land half-applied: does
5644
+ // this phrase gate THIS pair at all, does it also take every model on the
5645
+ // operator's key offline (`cascades`), and is it about money rather than
5646
+ // credentials (`billing`, which picks the reason the operator is shown and
5647
+ // the recovery probe reads).
5648
+ //
5649
+ // `cascades` is the expensive one. Gating ONE pair on a false positive costs
5650
+ // the operator a capability until the recovery probe clears it; a cascade
5651
+ // costs them every capability on that key. So it needs a phrase an unrelated
5652
+ // failure does not produce: `insufficient` alone is a Solana builder saying
5653
+ // "insufficient funds for rent" and `billing` alone is a form field, while
5654
+ // nothing prints `invalid x-api-key` or `credit balance` except the provider
5655
+ // whose key it is. The bare status words carry `cascades: false` for the same
5656
+ // reason - `unauthorized` is what any proxy says when it did not like a
5657
+ // request. The LLM path next door demands an HTTP 401/402 before cascading;
5658
+ // this is the script path's version of that bar.
5659
+ { phrase: "credit balance", cascades: true, billing: true },
5660
+ { phrase: "billing", cascades: false, billing: true },
5661
+ { phrase: "insufficient", cascades: false, billing: true },
5662
+ { phrase: "insufficient_quota", cascades: true, billing: true },
5663
+ // Gates this pair, never cascades: `x-api-key` is the name of a REQUEST
5664
+ // HEADER, which `curl -v` and any client that prints its own headers emit on a
5665
+ // perfectly ordinary failure. Taking every model on the key offline for that
5666
+ // is the expensive direction; `invalid x-api-key` below is the provider
5667
+ // actually rejecting it.
5668
+ { phrase: "x-api-key", cascades: false, billing: false },
5669
+ // The provider REJECTING the key, which no request header says.
5670
+ { phrase: "invalid x-api-key", cascades: true, billing: false },
5671
+ { phrase: "invalid api key", cascades: true, billing: false },
5672
+ { phrase: "invalid_api_key", cascades: true, billing: false },
5673
+ { phrase: "authentication_error", cascades: true, billing: false },
5674
+ { phrase: "unauthorized", cascades: false, billing: false },
5675
+ { phrase: "unauthenticated", cascades: false, billing: false }
5339
5676
  ];
5340
- function scriptMessageLooksLikeBillingOrInvalid(message) {
5341
- const lower = message.toLowerCase();
5342
- return SCRIPT_BILLING_INVALID_MARKERS.some((marker) => lower.includes(marker));
5677
+ var BILLING_BODY_MARKERS3 = SCRIPT_BILLING_INVALID_MARKERS.filter((marker) => marker.billing).map(
5678
+ (marker) => marker.phrase
5679
+ );
5680
+ function classifyScriptSignal(message) {
5681
+ let looksBillingOrInvalid = false;
5682
+ let billing = false;
5683
+ let cascade = false;
5684
+ let signalAt = -1;
5685
+ for (const match of message.matchAll(ALL_MARKERS_RE)) {
5686
+ const marker = MARKER_BY_PHRASE.get(match[0].toLowerCase());
5687
+ looksBillingOrInvalid = true;
5688
+ billing = billing || marker?.billing === true;
5689
+ cascade = cascade || marker?.cascades === true;
5690
+ if (signalAt === -1) {
5691
+ signalAt = match.index;
5692
+ }
5693
+ }
5694
+ return { looksBillingOrInvalid, reason: billing ? "billing" : "invalid", cascade, signalAt };
5695
+ }
5696
+ var HOST_DISK_CODES = /* @__PURE__ */ new Set(["ENOSPC", "EROFS", "EDQUOT", "EMFILE", "ENFILE", "EIO"]);
5697
+ function asHostScratchFailure(error) {
5698
+ const code = error?.code;
5699
+ if (code === void 0 || !HOST_DISK_CODES.has(code)) {
5700
+ return void 0;
5701
+ }
5702
+ return new HostScratchError(error instanceof Error ? error.message : String(error));
5703
+ }
5704
+ var HEALTH_REASON_CHARS = 500;
5705
+ var SIGNAL_LEAD_UNITS = 80;
5706
+ function markerPattern(phrases, flags) {
5707
+ const longestFirst = [...phrases].sort((left, right) => right.length - left.length);
5708
+ return new RegExp(
5709
+ longestFirst.map((phrase) => phrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|"),
5710
+ flags
5711
+ );
5712
+ }
5713
+ var ALL_MARKERS_RE = markerPattern(
5714
+ SCRIPT_BILLING_INVALID_MARKERS.map((marker) => marker.phrase),
5715
+ "gi"
5716
+ );
5717
+ var MARKER_BY_PHRASE = new Map(
5718
+ SCRIPT_BILLING_INVALID_MARKERS.map((marker) => [marker.phrase, marker])
5719
+ );
5720
+ function signalIndex(text) {
5721
+ ALL_MARKERS_RE.lastIndex = 0;
5722
+ const found = ALL_MARKERS_RE.exec(text)?.index ?? -1;
5723
+ ALL_MARKERS_RE.lastIndex = 0;
5724
+ return found;
5725
+ }
5726
+ function operatorReason(diagnostic, known, budget = HEALTH_REASON_CHARS) {
5727
+ if (startsWithRefusalHint(diagnostic)) {
5728
+ return excerptUntrusted(diagnostic, budget);
5729
+ }
5730
+ const signalAt = known ?? signalIndex(diagnostic);
5731
+ if (signalAt === -1) {
5732
+ return excerptUntrustedTail(diagnostic, budget);
5733
+ }
5734
+ const from = Math.max(0, signalAt - SIGNAL_LEAD_UNITS);
5735
+ if (from === 0) {
5736
+ return excerptUntrusted(diagnostic, budget);
5737
+ }
5738
+ return `\u2026${excerptUntrusted(diagnostic.slice(from, from + budget * 8), budget - 1)}`;
5343
5739
  }
5344
5740
  var AGENT_UNAVAILABLE_MESSAGE = "Agent temporarily unavailable";
5741
+ var OPERATOR_EXCERPT_CHARS = 1500;
5345
5742
  var AgentUnavailableError = class extends Error {
5346
5743
  constructor() {
5347
5744
  super(AGENT_UNAVAILABLE_MESSAGE);
@@ -5395,16 +5792,49 @@ function customerSafeMessage(error) {
5395
5792
  if (error instanceof AgentUnavailableError || error instanceof ExecutionBudgetExceededError || error instanceof SeedFailedError) {
5396
5793
  return error.message;
5397
5794
  }
5398
- if (error instanceof ScriptExecutionError) {
5399
- return error.message;
5795
+ if (isScriptRefusalError(error)) {
5796
+ return `${PROVIDER_REFUSED_PREFIX}${error.message}`;
5400
5797
  }
5401
- if (error instanceof ScriptBillingExhaustedError) {
5798
+ if (isScriptExecutionError(error)) {
5799
+ return PROVIDER_FAILED_MESSAGE;
5800
+ }
5801
+ if (isScriptBillingExhaustedError(error) || isHostScratchError(error) || error instanceof X402TransientError) {
5402
5802
  return AGENT_UNAVAILABLE_MESSAGE;
5403
5803
  }
5404
5804
  if (error instanceof Error && CUSTOMER_SAFE_MESSAGE_PREFIXES.some((prefix) => error.message.startsWith(prefix))) {
5405
5805
  return error.message;
5406
5806
  }
5407
- return "Internal processing error";
5807
+ return PROVIDER_FAILED_MESSAGE;
5808
+ }
5809
+ function needsScratchSpace(mode, hasAttachment) {
5810
+ if (hasAttachment) {
5811
+ return true;
5812
+ }
5813
+ return mode !== void 0 && mode !== "llm" && mode !== "static-file" && mode !== "static-script";
5814
+ }
5815
+ function describeForOperator(error) {
5816
+ if (isHostScratchError(error)) {
5817
+ return `${error.message}: ${excerptUntrusted(error.detail, OPERATOR_EXCERPT_CHARS)}`;
5818
+ }
5819
+ if (isScriptBillingExhaustedError(error)) {
5820
+ return `script signalled billing exhausted: ${excerptUntrustedTail(scriptOutput(error), OPERATOR_EXCERPT_CHARS)}`;
5821
+ }
5822
+ if (isScriptRefusalError(error)) {
5823
+ return error.stderr === "" ? `refused: ${error.message}` : `refused: ${error.message} (stderr: ${error.stderr})`;
5824
+ }
5825
+ if (isScriptExecutionError(error)) {
5826
+ return `${error.message}: ${excerptUntrustedTail(error.detail, OPERATOR_EXCERPT_CHARS)}`;
5827
+ }
5828
+ if (typeof error === "string") {
5829
+ return excerptUntrusted(error, OPERATOR_EXCERPT_CHARS);
5830
+ }
5831
+ if (typeof error === "object" && error !== null && "message" in error) {
5832
+ const message = error.message;
5833
+ if (typeof message === "string" && message !== "") {
5834
+ return excerptUntrusted(message, OPERATOR_EXCERPT_CHARS);
5835
+ }
5836
+ }
5837
+ return "Unknown error";
5408
5838
  }
5409
5839
  function parseRawEventDelegated(rawEventJson) {
5410
5840
  if (rawEventJson === void 0) {
@@ -5691,12 +6121,51 @@ var AgentRuntime = class {
5691
6121
  }
5692
6122
  return ` (cascading to ${siblings} other model(s) for ${provider} sharing the same API key)`;
5693
6123
  }
6124
+ /**
6125
+ * Whether this agent can still make itself a scratch directory.
6126
+ *
6127
+ * A `HostScratchError` says the last job could not run because the temp
6128
+ * directory refused it. That is not the operator's API key, so it must not
6129
+ * gate the health pair - but SOMETHING has to stop the next customer paying
6130
+ * into an agent that will fail them the same way, which is what the health
6131
+ * gate used to do by accident. Probed before payment, and only once such a
6132
+ * failure has been seen - a working host pays nothing for this, and a broken
6133
+ * one is asked again on every job rather than after a timer: an agent whose
6134
+ * jobs arrive minutes apart would otherwise let every customer through.
6135
+ */
6136
+ scratchFailed = false;
6137
+ async scratchSpaceUsable() {
6138
+ if (!this.scratchFailed) {
6139
+ return true;
6140
+ }
6141
+ const probe = await mkdtemp(join(tmpdir(), "elisym-probe-")).catch(() => null);
6142
+ if (probe === null) {
6143
+ return false;
6144
+ }
6145
+ const wrote = await writeFile(join(probe, "probe"), "x").then(() => true).catch(() => false);
6146
+ await rm(probe, { recursive: true, force: true }).catch(() => {
6147
+ });
6148
+ if (!wrote) {
6149
+ return false;
6150
+ }
6151
+ this.scratchFailed = false;
6152
+ return true;
6153
+ }
5694
6154
  markHealthFromExecuteError(skill, err, log, jobId) {
5695
6155
  if (!this.healthMonitor) {
5696
6156
  return false;
5697
6157
  }
5698
6158
  const tag = `[${jobId.slice(0, 8)}]`;
5699
- if (err instanceof ScriptBillingExhaustedError) {
6159
+ if (isHostScratchError(err)) {
6160
+ log(
6161
+ `${tag} Skill "${skill.name}" could not be given scratch space by THIS AGENT: ${excerptUntrusted(err.detail, 300)} Health state unchanged; the job stays paid for recovery.`
6162
+ );
6163
+ return false;
6164
+ }
6165
+ if (isScriptRefusalError(err)) {
6166
+ return false;
6167
+ }
6168
+ if (isScriptBillingExhaustedError(err)) {
5700
6169
  const provider = skill.llmOverride?.provider;
5701
6170
  const model = skill.llmOverride?.model;
5702
6171
  if (!provider || !model) {
@@ -5708,7 +6177,12 @@ var AgentRuntime = class {
5708
6177
  log(
5709
6178
  `${tag} Script signaled billing-exhausted (exit ${err.exitCode}). Marking ${provider}/${model} unhealthy${this.cascadeSuffix(provider, model)}; future jobs against this pair will be refused until recovery probe succeeds.`
5710
6179
  );
5711
- this.healthMonitor.markUnhealthyFromJob(provider, model, "billing", err.message);
6180
+ this.healthMonitor.markUnhealthyFromJob(
6181
+ provider,
6182
+ model,
6183
+ "billing",
6184
+ excerptUntrustedTail(scriptOutput(err), 200)
6185
+ );
5712
6186
  return true;
5713
6187
  }
5714
6188
  if (skill.mode === "llm" && skill.resolvedTriple) {
@@ -5718,7 +6192,7 @@ var AgentRuntime = class {
5718
6192
  return false;
5719
6193
  }
5720
6194
  const status = Number(match[1]);
5721
- const body = (match[2] ?? "").slice(0, 200);
6195
+ const body = excerptUntrusted(match[2] ?? "", 200);
5722
6196
  const isBillingStatus = status === 402;
5723
6197
  const isAuthStatus = status === 401 || status === 403;
5724
6198
  const isBilling400 = status === 400 && bodyLooksLikeBilling3(body);
@@ -5737,33 +6211,43 @@ var AgentRuntime = class {
5737
6211
  }
5738
6212
  if (skill.mode !== "llm") {
5739
6213
  let message;
5740
- if (err instanceof ScriptExecutionError) {
5741
- message = err.detail;
6214
+ let scanned;
6215
+ let diagnostic;
6216
+ if (isScriptExecutionError(err)) {
6217
+ message = err.stderr ?? err.detail;
6218
+ diagnostic = startsWithRefusalHint(err.detail) || message.trim() === "" ? err.detail : message;
6219
+ scanned = err.stderr === void 0 ? "the skill error" : "stderr";
5742
6220
  } else if (err instanceof Error) {
5743
6221
  message = err.message;
6222
+ diagnostic = message;
6223
+ scanned = "the skill error";
5744
6224
  } else {
5745
6225
  message = String(err);
6226
+ diagnostic = message;
6227
+ scanned = "the skill error";
5746
6228
  }
5747
6229
  const provider = skill.llmOverride?.provider;
5748
6230
  const model = skill.llmOverride?.model;
5749
6231
  if (!provider || !model) {
5750
6232
  log(
5751
- `${tag} Script "${skill.name}" failed ("${message.slice(0, 120)}") but did not declare provider/model in SKILL.md - cannot gate future jobs.`
6233
+ `${tag} Script "${skill.name}" failed ("${operatorReason(diagnostic)}") but did not declare provider/model in SKILL.md - cannot gate future jobs.`
5752
6234
  );
5753
6235
  return false;
5754
6236
  }
5755
- const lower = message.toLowerCase();
5756
- const looksBillingOrInvalid = scriptMessageLooksLikeBillingOrInvalid(message);
5757
- const reason = looksBillingOrInvalid && (lower.includes("credit balance") || lower.includes("billing") || lower.includes("insufficient")) ? "billing" : "invalid";
5758
- const cascade = looksBillingOrInvalid;
6237
+ const { looksBillingOrInvalid, reason, cascade, signalAt } = classifyScriptSignal(message);
5759
6238
  const cascadeNote = cascade ? this.cascadeSuffix(provider, model) : " (no cascade)";
5760
- const signalNote = looksBillingOrInvalid ? `${reason} signal in stderr` : `generic exit (no billing/invalid markers, classified as ${reason}, skill-local)`;
6239
+ const signalNote = looksBillingOrInvalid ? `${reason} signal in ${scanned}` : `generic exit (no billing/invalid markers, classified as ${reason}, skill-local)`;
5761
6240
  log(
5762
6241
  `${tag} Script failure (${signalNote}). Marking ${provider}/${model} unhealthy${cascadeNote}; future jobs against this pair will be refused until recovery probe succeeds.`
5763
6242
  );
5764
- this.healthMonitor.markUnhealthyFromJob(provider, model, reason, message.slice(0, 200), {
5765
- cascade
5766
- });
6243
+ this.healthMonitor.markUnhealthyFromJob(
6244
+ provider,
6245
+ model,
6246
+ reason,
6247
+ // The index only when the quote is of the same string the scan read.
6248
+ operatorReason(diagnostic, diagnostic === message ? signalAt : void 0),
6249
+ { cascade }
6250
+ );
5767
6251
  return true;
5768
6252
  }
5769
6253
  return false;
@@ -5777,8 +6261,31 @@ var AgentRuntime = class {
5777
6261
  });
5778
6262
  return { feeBps: config.feeBps, treasury: config.treasury };
5779
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
+ }
5780
6287
  async run() {
5781
- const log = this.callbacks.onLog ?? console.log;
6288
+ const log = this.operatorLog;
5782
6289
  this.ledger.pruneOldEntries(LEDGER_RETENTION_MS);
5783
6290
  this.sessionStore?.init();
5784
6291
  this.reconcileUsedNonces();
@@ -5878,8 +6385,8 @@ var AgentRuntime = class {
5878
6385
  if (jobSession) {
5879
6386
  jobSession.store.admitLive(jobSession.customerId, jobSession.sessionId);
5880
6387
  }
5881
- this.limit(() => this.processJob(job)).catch((e) => {
5882
- this.callbacks.onJobError?.(job.jobId, e.message);
6388
+ this.limit(() => this.processJob(job)).catch((err) => {
6389
+ this.callbacks.onJobError?.(job.jobId, describeForOperator(err));
5883
6390
  }).finally(() => {
5884
6391
  this.inFlight.delete(job.jobId);
5885
6392
  this.pending--;
@@ -5928,7 +6435,7 @@ var AgentRuntime = class {
5928
6435
  this.callbacks.onStop?.();
5929
6436
  } catch (error) {
5930
6437
  const msg = error instanceof Error ? error.message : String(error);
5931
- (this.callbacks.onLog ?? console.log)(`onStop error: ${msg}`);
6438
+ this.operatorLog(`onStop error: ${msg}`);
5932
6439
  }
5933
6440
  this.abortController.abort();
5934
6441
  for (const controller of this.jobAbortControllers) {
@@ -5964,18 +6471,31 @@ var AgentRuntime = class {
5964
6471
  this.jobAbortControllers.add(jobAbort);
5965
6472
  try {
5966
6473
  await this.executeJob(job, jobAbort.signal);
5967
- } catch (e) {
5968
- const log = this.callbacks.onLog ?? console.log;
5969
- log(`[${job.jobId.slice(0, 8)}] Error: ${e.message}`);
6474
+ } catch (raw) {
6475
+ const e = asHostScratchFailure(raw) ?? raw;
6476
+ const log = this.operatorLog;
6477
+ const operatorMessage = describeForOperator(e);
6478
+ log(`[${job.jobId.slice(0, 8)}] Error: ${operatorMessage}`);
6479
+ if (isHostScratchError(e)) {
6480
+ this.scratchFailed = true;
6481
+ }
5970
6482
  const currentStatus = this.ledger.getStatus(job.jobId);
5971
- const keepPaidForRecovery = (e instanceof AgentUnavailableError || e instanceof SeedFailedError || e instanceof PaymentTimeoutError || e instanceof X402TransientError || e instanceof ExecutionBudgetExceededError && this.skills.route(job.tags)?.mode === "x402") && currentStatus === "paid";
6483
+ const keepPaidForRecovery = (e instanceof AgentUnavailableError || // The exit-42 contract: the key is out of credits, not the job out of
6484
+ // sense, so the job waits for the operator to top up. Reached when the
6485
+ // gate did NOT flip - an agent with no health monitor, or a skill that
6486
+ // declares no pair - where the customer is told "temporarily
6487
+ // unavailable" and that has to stay true of their money.
6488
+ isScriptBillingExhaustedError(e) || // The agent's own disk, not the job: a tmpdir that is full or
6489
+ // read-only now may not be in five minutes, and the customer has
6490
+ // already paid. Terminating here would keep their money for a failure
6491
+ // that was never about their request.
6492
+ isHostScratchError(e) || e instanceof SeedFailedError || e instanceof PaymentTimeoutError || e instanceof X402TransientError || e instanceof ExecutionBudgetExceededError && this.skills.route(job.tags)?.mode === "x402") && currentStatus === "paid";
5972
6493
  if (currentStatus !== "executed" && !keepPaidForRecovery) {
5973
6494
  this.ledger.markFailed(job.jobId);
5974
6495
  }
5975
6496
  if (keepPaidForRecovery) {
5976
6497
  log(`[${job.jobId.slice(0, 8)}] Keeping status=paid; recovery will retry (24h cutoff).`);
5977
6498
  }
5978
- const operatorMessage = e instanceof ScriptExecutionError ? `${e.message}: ${e.detail}` : e.message ?? "Unknown error";
5979
6499
  this.callbacks.onJobError?.(job.jobId, operatorMessage);
5980
6500
  const safeMessage = customerSafeMessage(e);
5981
6501
  await this.transport.sendFeedback(job, { type: "error", message: safeMessage }).catch(() => {
@@ -5986,7 +6506,7 @@ var AgentRuntime = class {
5986
6506
  }
5987
6507
  /** Core job processing logic - payment, skill execution, result delivery. */
5988
6508
  async executeJob(job, signal) {
5989
- const log = this.callbacks.onLog ?? console.log;
6509
+ const log = this.operatorLog;
5990
6510
  const inputBytes = utf8ByteLength(job.input);
5991
6511
  if (inputBytes > LIMITS.MAX_INPUT_LENGTH) {
5992
6512
  throw new Error(`Input too long: ${inputBytes} bytes (max ${LIMITS.MAX_INPUT_LENGTH})`);
@@ -6007,13 +6527,25 @@ var AgentRuntime = class {
6007
6527
  return;
6008
6528
  }
6009
6529
  }
6530
+ if (needsScratchSpace(matched?.mode, job.attachment !== void 0) && !await this.scratchSpaceUsable()) {
6531
+ log(
6532
+ `[${job.jobId.slice(0, 8)}] Refusing job before payment: this agent cannot create scratch space (check the temp directory).`
6533
+ );
6534
+ await this.transport.sendFeedback(job, { type: "error", message: AGENT_UNAVAILABLE_MESSAGE }).catch(() => {
6535
+ });
6536
+ return;
6537
+ }
6010
6538
  if (matched?.mode === "x402" && job.attachment !== void 0) {
6011
6539
  const method = matched.x402?.method ?? "POST";
6012
6540
  const maxInputBytes = matched.x402?.maxInputBytes ?? 0;
6013
6541
  const attachmentOk = method === "POST" && job.attachment.mime.startsWith("text/") && job.attachment.size <= maxInputBytes;
6014
6542
  if (!attachmentOk) {
6015
6543
  log(
6016
- `[${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})`
6017
6549
  );
6018
6550
  await this.transport.sendFeedback(job, {
6019
6551
  type: "error",
@@ -6066,7 +6598,7 @@ var AgentRuntime = class {
6066
6598
  if (delegatedPull === void 0) {
6067
6599
  return;
6068
6600
  }
6069
- netAmount = Number(delegatedPull.priceSubunits);
6601
+ netAmount = ledgerAmount(delegatedPull.priceSubunits);
6070
6602
  } else if (jobPrice > 0) {
6071
6603
  const result = await this.collectPayment(job, jobPrice, jobAsset, signal);
6072
6604
  netAmount = result.netAmount;
@@ -6190,7 +6722,7 @@ var AgentRuntime = class {
6190
6722
  log
6191
6723
  );
6192
6724
  const outcome = await this.executeDelegatedPull(job, delegatedPull, log);
6193
- netAmount = Number(delegatedPull.chargedSubunits ?? delegatedPull.priceSubunits);
6725
+ netAmount = ledgerAmount(delegatedPull.chargedSubunits ?? delegatedPull.priceSubunits);
6194
6726
  if (outcome === "dead") {
6195
6727
  this.ledger.markFailed(job.jobId);
6196
6728
  await this.transport.sendFeedback(job, {
@@ -6447,7 +6979,7 @@ var AgentRuntime = class {
6447
6979
  job.jobId,
6448
6980
  pull.signature,
6449
6981
  Number(pull.lastValidBlockHeight),
6450
- Number(amount)
6982
+ ledgerAmount(amount)
6451
6983
  );
6452
6984
  ctx.pullSignature = pull.signature;
6453
6985
  const outcome = await sendConfirmToTerminal(ctx.rpc, pull, {});
@@ -6653,7 +7185,7 @@ var AgentRuntime = class {
6653
7185
  * rather than losing the customer's paid job.
6654
7186
  */
6655
7187
  async seedGuarded(jobId, kind, run) {
6656
- const log = this.callbacks.onLog ?? console.log;
7188
+ const log = this.operatorLog;
6657
7189
  const started = Date.now();
6658
7190
  try {
6659
7191
  const seeded = await run();
@@ -6800,6 +7332,9 @@ var AgentRuntime = class {
6800
7332
  return void 0;
6801
7333
  }
6802
7334
  try {
7335
+ if (await isBlockingNode(filePath)) {
7336
+ return void 0;
7337
+ }
6803
7338
  const bytes = await readFile(filePath);
6804
7339
  return await this.seedBlossomMember(bytes, recipientPubkey);
6805
7340
  } catch {
@@ -6893,7 +7428,9 @@ var AgentRuntime = class {
6893
7428
  });
6894
7429
  return this.materializeBytesInput(bytes, attachment.mime);
6895
7430
  }
6896
- const dir = await mkdtemp(join(tmpdir(), "elisym-job-"));
7431
+ const dir = await mkdtemp(join(tmpdir(), "elisym-job-")).catch((err) => {
7432
+ throw new HostScratchError(err instanceof Error ? err.message : String(err));
7433
+ });
6897
7434
  const filePath = join(dir, "input");
6898
7435
  try {
6899
7436
  await this.irohTransport.fetchToPath(irohMember.ticket, filePath, {
@@ -6945,14 +7482,16 @@ var AgentRuntime = class {
6945
7482
  return { inlineText: Buffer.from(bytes).toString("utf8"), cleanup: async () => {
6946
7483
  } };
6947
7484
  }
6948
- const dir = await mkdtemp(join(tmpdir(), "elisym-job-"));
7485
+ const dir = await mkdtemp(join(tmpdir(), "elisym-job-")).catch((err) => {
7486
+ throw new HostScratchError(err instanceof Error ? err.message : String(err));
7487
+ });
6949
7488
  const filePath = join(dir, "input");
6950
7489
  try {
6951
7490
  await writeFile(filePath, bytes);
6952
7491
  } catch (error) {
6953
7492
  await rm(dir, { recursive: true, force: true }).catch(() => {
6954
7493
  });
6955
- throw error;
7494
+ throw asHostScratchFailure(error) ?? error;
6956
7495
  }
6957
7496
  return {
6958
7497
  filePath,
@@ -6966,7 +7505,7 @@ var AgentRuntime = class {
6966
7505
  * polls for on-chain confirmation. Aborts if signal fires.
6967
7506
  */
6968
7507
  async collectPayment(job, jobPrice, jobAsset, signal) {
6969
- const log = this.callbacks.onLog ?? console.log;
7508
+ const log = this.operatorLog;
6970
7509
  if (!this.config.solanaAddress) {
6971
7510
  throw new Error("Solana address not configured");
6972
7511
  }
@@ -7038,7 +7577,7 @@ var AgentRuntime = class {
7038
7577
  return;
7039
7578
  }
7040
7579
  const txSignature = askedSignature ?? verified.txSignature;
7041
- if (txSignature === void 0) {
7580
+ if (!isUsableSignature(txSignature)) {
7042
7581
  paymentRefusal = { consumedByOther: false, sentence: UNNAMED_SETTLEMENT_SENTENCE };
7043
7582
  lose({ verified: false }, `${pathLabel}: ${UNNAMED_SETTLEMENT_SENTENCE}`);
7044
7583
  return;
@@ -7207,7 +7746,7 @@ var AgentRuntime = class {
7207
7746
  (jobId) => this.ledger.getStatus(jobId) === "paid"
7208
7747
  );
7209
7748
  }
7210
- const log = this.callbacks.onLog ?? console.log;
7749
+ const log = this.operatorLog;
7211
7750
  this.logDeferralSummary(log);
7212
7751
  if (pending.length === 0) {
7213
7752
  return;
@@ -7287,12 +7826,13 @@ var AgentRuntime = class {
7287
7826
  this.limit(async () => {
7288
7827
  try {
7289
7828
  await this.recoverSingleJob(entry, log);
7290
- } catch (e) {
7291
- log(`[${entry.job_id.slice(0, 8)}] Recovery: failed: ${e.message}`);
7829
+ } catch (err) {
7830
+ log(`[${entry.job_id.slice(0, 8)}] Recovery: failed: ${describeForOperator(err)}`);
7292
7831
  } finally {
7293
7832
  this.inFlight.delete(entry.job_id);
7294
7833
  this.pending--;
7295
7834
  }
7835
+ }).catch(() => {
7296
7836
  });
7297
7837
  }
7298
7838
  }
@@ -7398,7 +7938,7 @@ var AgentRuntime = class {
7398
7938
  try {
7399
7939
  await this.healthMonitor.assertReady(healthPair.provider, healthPair.model);
7400
7940
  } catch (err) {
7401
- if (err instanceof LlmHealthError) {
7941
+ if (isLlmHealthError(err)) {
7402
7942
  log(
7403
7943
  `[${entry.job_id.slice(0, 8)}] Recovery: pair ${healthPair.provider}/${healthPair.model} still unhealthy (${err.reason}); waiting for recovery probe.`
7404
7944
  );
@@ -7442,7 +7982,15 @@ var AgentRuntime = class {
7442
7982
  entry.customer_id,
7443
7983
  recoveryAbort.signal
7444
7984
  );
7445
- } catch {
7985
+ } catch (raw) {
7986
+ const err = asHostScratchFailure(raw) ?? raw;
7987
+ if (isHostScratchError(err)) {
7988
+ this.scratchFailed = true;
7989
+ log(
7990
+ `[${entry.job_id.slice(0, 8)}] Recovery: no scratch space for the input file; leaving the job paid.`
7991
+ );
7992
+ return;
7993
+ }
7446
7994
  log(`[${entry.job_id.slice(0, 8)}] Recovery: input file unavailable, marking failed`);
7447
7995
  await this.failRecoveredJob(entry, fakeJob, RECOVERY_INPUT_UNAVAILABLE_CUSTOMER_MESSAGE);
7448
7996
  return;
@@ -7453,6 +8001,7 @@ var AgentRuntime = class {
7453
8001
  skill
7454
8002
  );
7455
8003
  const runRecoveryExecution = async (history) => {
8004
+ let budgetExceeded = false;
7456
8005
  let budgetTimer;
7457
8006
  try {
7458
8007
  const execPromise = skill.execute(
@@ -7474,6 +8023,7 @@ var AgentRuntime = class {
7474
8023
  execPromise,
7475
8024
  new Promise((_resolve, reject) => {
7476
8025
  budgetTimer = setTimeout(() => {
8026
+ budgetExceeded = true;
7477
8027
  recoveryAbort.abort();
7478
8028
  reject(new ExecutionBudgetExceededError(recoveryBudgetMs));
7479
8029
  }, recoveryBudgetMs);
@@ -7482,6 +8032,12 @@ var AgentRuntime = class {
7482
8032
  }
7483
8033
  return await execPromise;
7484
8034
  } catch (err) {
8035
+ if (isHostScratchError(err)) {
8036
+ this.scratchFailed = true;
8037
+ }
8038
+ if (budgetExceeded) {
8039
+ throw new ExecutionBudgetExceededError(recoveryBudgetMs);
8040
+ }
7485
8041
  this.markHealthFromExecuteError(skill, err, log, entry.job_id);
7486
8042
  throw err;
7487
8043
  } finally {
@@ -7494,18 +8050,28 @@ var AgentRuntime = class {
7494
8050
  }
7495
8051
  }
7496
8052
  };
7497
- const output = recoveryJobSession === null ? await runRecoveryExecution() : await this.runSessionExchange({
7498
- session: recoveryJobSession,
7499
- jobId: entry.job_id,
7500
- userRecord: buildUserRecord(
7501
- recoveryInputFile?.inlineText ?? entry.input,
7502
- recoveryInputFile?.filePath !== void 0 ? "attachment" : void 0
7503
- ),
7504
- signal: recoveryAbort.signal,
7505
- log,
7506
- execute: runRecoveryExecution,
7507
- excludeOwnTurns: true
7508
- });
8053
+ let output;
8054
+ try {
8055
+ output = recoveryJobSession === null ? await runRecoveryExecution() : await this.runSessionExchange({
8056
+ session: recoveryJobSession,
8057
+ jobId: entry.job_id,
8058
+ userRecord: buildUserRecord(
8059
+ recoveryInputFile?.inlineText ?? entry.input,
8060
+ recoveryInputFile?.filePath !== void 0 ? "attachment" : void 0
8061
+ ),
8062
+ signal: recoveryAbort.signal,
8063
+ log,
8064
+ execute: runRecoveryExecution,
8065
+ excludeOwnTurns: true
8066
+ });
8067
+ } catch (error) {
8068
+ if (!isScriptRefusalError(error)) {
8069
+ throw error;
8070
+ }
8071
+ log(`[${entry.job_id.slice(0, 8)}] Recovery: ${describeForOperator(error)}`);
8072
+ await this.failRecoveredJob(entry, fakeJob, customerSafeMessage(error));
8073
+ return;
8074
+ }
7509
8075
  const { attachments: resultAttachments, deliveredContent } = await this.buildResultAttachment(
7510
8076
  entry.job_id,
7511
8077
  output,
@@ -8028,7 +8594,7 @@ function buildCliSkill(parsed, entryPath, scriptEnv, network) {
8028
8594
  }
8029
8595
  return skill;
8030
8596
  }
8031
- function loadSkillsFromDir(skillsDir, options) {
8597
+ function loadSkillsFromDir2(skillsDir, options) {
8032
8598
  const skills = [];
8033
8599
  let entries;
8034
8600
  try {
@@ -8047,6 +8613,9 @@ function loadSkillsFromDir(skillsDir, options) {
8047
8613
  }
8048
8614
  const skillMdPath = join(entryPath, "SKILL.md");
8049
8615
  try {
8616
+ if (isBlockingNodeSync(skillMdPath)) {
8617
+ throw new Error("it is a pipe, socket or device, not a file");
8618
+ }
8050
8619
  const content = readFileSync(skillMdPath, "utf-8");
8051
8620
  const { frontmatter, systemPrompt } = parseSkillMd(content);
8052
8621
  const parsed = validateSkillFrontmatter(frontmatter, systemPrompt, {
@@ -8394,6 +8963,9 @@ function startWatchdog(deps) {
8394
8963
  }
8395
8964
  };
8396
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;
8397
8969
  var X402_JOBS_FILE = ".x402-jobs.json";
8398
8970
  var X402_RESULTS_DIR = ".x402-results";
8399
8971
  function sanitizeJobId(jobId) {
@@ -8419,6 +8991,11 @@ var X402JobStore = class {
8419
8991
  return scheduled;
8420
8992
  }
8421
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
+ }
8422
8999
  let raw;
8423
9000
  try {
8424
9001
  raw = await readFile(this.jobsPath, "utf-8");
@@ -8432,16 +9009,73 @@ var X402JobStore = class {
8432
9009
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
8433
9010
  throw new Error(`x402 store ${this.jobsPath} is not a JSON object`);
8434
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
+ }
8435
9026
  return parsed;
8436
9027
  }
8437
9028
  async save(file) {
8438
- const tempPath = `${this.jobsPath}.tmp`;
8439
- await writeFile(tempPath, JSON.stringify(file, null, 2), "utf-8");
8440
- 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
+ }
8441
9044
  }
8442
9045
  resultFilePath(jobId) {
8443
9046
  return join(this.resultsDir, sanitizeJobId(jobId));
8444
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
+ }
8445
9079
  /**
8446
9080
  * Atomically claim one paid attempt: increment iff still under BOTH caps
8447
9081
  * (durable attempts and the monotonic signature count), and FLUSH before
@@ -8522,8 +9156,20 @@ var X402JobStore = class {
8522
9156
  /** Binary result: bytes hit disk BEFORE the record flush (crash-safe ordering). */
8523
9157
  async saveFileResult(jobId, mime, bytes) {
8524
9158
  const filePath = this.resultFilePath(jobId);
8525
- await mkdir(this.resultsDir, { recursive: true });
8526
- 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
+ }
8527
9173
  await this.runExclusive(async () => {
8528
9174
  const file = await this.load();
8529
9175
  const now = Date.now();
@@ -8559,9 +9205,32 @@ var X402JobStore = class {
8559
9205
  }
8560
9206
  return { data: "", outputMime: result.mime, filePath };
8561
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
+ }
8562
9229
  /** Drop records (and their result files) older than the cache TTL. */
8563
9230
  async sweepExpired(now = Date.now()) {
8564
9231
  await this.runExclusive(async () => {
9232
+ await this.sweepStrandedIndexTemporaries();
9233
+ await chmod(this.jobsPath, INDEX_FILE_MODE).catch(() => void 0);
8565
9234
  const file = await this.load();
8566
9235
  let changed = false;
8567
9236
  for (const [jobId, record] of Object.entries(file)) {
@@ -8572,6 +9241,7 @@ var X402JobStore = class {
8572
9241
  changed = true;
8573
9242
  await rm(this.resultFilePath(jobId), { force: true }).catch(() => {
8574
9243
  });
9244
+ await this.sweepStrandedTemporaries(jobId);
8575
9245
  }
8576
9246
  if (changed) {
8577
9247
  await this.save(file);
@@ -8731,21 +9401,17 @@ function withPaymentIdentifier(headerValue, declaration, jobId) {
8731
9401
  return headerValue;
8732
9402
  }
8733
9403
  }
8734
- var UNICODE_FORMAT_MARKS = /\p{Cf}/gu;
8735
- function flattenForOperator(text) {
8736
- return sanitizeForTerminal(text).replace(UNICODE_FORMAT_MARKS, "").replace(/\s+/g, " ").trim();
8737
- }
8738
9404
  function printedPrefix(flattened) {
8739
- return flattened.length <= X402_ERROR_EXCERPT_CHARS ? flattened : flattened.slice(0, X402_ERROR_EXCERPT_CHARS).replace(/[\ud800-\udbff]$/, "");
9405
+ return clipToCodeUnits(flattened, X402_ERROR_EXCERPT_CHARS);
8740
9406
  }
8741
9407
  function clipForOperator(flattened) {
8742
9408
  return flattened.length <= X402_ERROR_EXCERPT_CHARS ? flattened : `${printedPrefix(flattened)}...`;
8743
9409
  }
8744
9410
  function quoteUpstream(text) {
8745
- return clipForOperator(flattenForOperator(text));
9411
+ return clipForOperator(flattenForComparison(text));
8746
9412
  }
8747
9413
  function quoteUpstreamWithInput(text, sentInput) {
8748
- return clipForOperator(maskCustomerInput(flattenForOperator(text), inputForms(sentInput)));
9414
+ return clipForOperator(maskCustomerInput(flattenForComparison(text), inputForms(sentInput)));
8749
9415
  }
8750
9416
  function wireForm(sentInput) {
8751
9417
  return new TextDecoder().decode(new TextEncoder().encode(sentInput));
@@ -8767,7 +9433,7 @@ function endsInsideInput(text, forms) {
8767
9433
  }
8768
9434
  function inputForms(sentInput) {
8769
9435
  return [wireForm(sentInput), jsonEscapedForm(sentInput), ...urlEncodedForms(sentInput)].map(
8770
- flattenForOperator
9436
+ flattenForComparison
8771
9437
  );
8772
9438
  }
8773
9439
  function maskCustomerInput(text, forms) {
@@ -8811,7 +9477,7 @@ var X402Driver = class {
8811
9477
  signerPromise = null;
8812
9478
  repriceHintLogged = /* @__PURE__ */ new Set();
8813
9479
  log(message) {
8814
- (this.options.log ?? console.log)(`[x402] ${flattenForOperator(message)}`);
9480
+ (this.options.log ?? console.log)(`[x402] ${flattenForComparison(message)}`);
8815
9481
  }
8816
9482
  getSigner() {
8817
9483
  const secret = this.options.solanaSecretKeyBase58;
@@ -9015,7 +9681,7 @@ var X402Driver = class {
9015
9681
  this.options.errorBodyReadMs ?? X402_ERROR_BODY_READ_MS
9016
9682
  );
9017
9683
  const forms = inputForms(sentInput);
9018
- const quoted = maskCustomerInput(flattenForOperator(body.text), forms);
9684
+ const quoted = maskCustomerInput(flattenForComparison(body.text), forms);
9019
9685
  if (quoted.length === 0) {
9020
9686
  return "";
9021
9687
  }
@@ -9242,7 +9908,7 @@ async function cmdStart(nameArg, options = {}) {
9242
9908
  const scriptEnv = buildScriptEnv(loaded.secrets);
9243
9909
  const paths = agentPaths(loaded.dir);
9244
9910
  const skillsDir = paths.skills;
9245
- const allSkills = loadSkillsFromDir(skillsDir, { network: walletNetwork, scriptEnv });
9911
+ const allSkills = loadSkillsFromDir2(skillsDir, { network: walletNetwork, scriptEnv });
9246
9912
  if (allSkills.length === 0) {
9247
9913
  console.error(` ! No skills found in ${skillsDir}
9248
9914
  `);
@@ -9265,6 +9931,31 @@ async function cmdStart(nameArg, options = {}) {
9265
9931
  );
9266
9932
  process.exit(1);
9267
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
+ }
9268
9959
  const llmSkills = allSkills.filter((skill) => skill.mode === "llm");
9269
9960
  const scriptDepSkills = allSkills.filter(
9270
9961
  (skill) => skill.mode !== "llm" && skill.llmOverride?.provider && skill.llmOverride?.model
@@ -9449,6 +10140,7 @@ async function cmdStart(nameArg, options = {}) {
9449
10140
  const x402Skills = allSkills.filter((skill) => skill.mode === "x402");
9450
10141
  let x402InvariantBroken;
9451
10142
  if (x402Skills.length > 0) {
10143
+ await ensureGitignoreHasX402Entries(dirname(loaded.dir));
9452
10144
  const solanaSecretKey = loaded.secrets.solana_secret_key;
9453
10145
  if (solanaSecretKey === void 0 || solanaSecretKey.length === 0) {
9454
10146
  x402InvariantBroken = "no solana_secret_key in .secrets.json";
@@ -9555,8 +10247,11 @@ async function cmdStart(nameArg, options = {}) {
9555
10247
  }
9556
10248
  }
9557
10249
  if (mediaCacheDirty) {
10250
+ await ensureGitignoreHasPrivateStateEntries(dirname(loaded.dir));
9558
10251
  await writeMediaCache(loaded.dir, mediaCache);
9559
10252
  }
10253
+ await ensureGitignoreHasPrivateStateEntries(dirname(loaded.dir));
10254
+ const ledger = new JobLedger(paths.jobs);
9560
10255
  let profilePublished = false;
9561
10256
  try {
9562
10257
  await client.discovery.publishProfile(
@@ -9667,7 +10362,7 @@ async function cmdStart(nameArg, options = {}) {
9667
10362
  }
9668
10363
  try {
9669
10364
  await client.policies.deletePolicy(identity2, type);
9670
- console.log(` Removed stale policy: ${sanitizeForTerminal(type)}`);
10365
+ console.log(` Removed stale policy: ${deleteControlCharacters(type)}`);
9671
10366
  } catch {
9672
10367
  }
9673
10368
  }
@@ -9708,6 +10403,16 @@ async function cmdStart(nameArg, options = {}) {
9708
10403
  console.warn(` ! Run \`npx @elisym/cli delegate-key ${loaded.name}\` to enable it.`);
9709
10404
  }
9710
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
+ }
9711
10416
  const buildCard = (skill) => buildCapabilityCard(skill, { walletNetwork, solanaAddress, delegatePubkey });
9712
10417
  function retiredModelKeyForSkill(skill) {
9713
10418
  if (skill.resolvedTriple) {
@@ -9778,7 +10483,7 @@ async function cmdStart(nameArg, options = {}) {
9778
10483
  const card = JSON.parse(ev.content);
9779
10484
  if (card.name && toDTag(card.name) === dTag) {
9780
10485
  await client.discovery.deleteCapability(identity2, card.name);
9781
- console.log(` Removed stale capability: ${sanitizeForTerminal(card.name)}`);
10486
+ console.log(` Removed stale capability: ${deleteControlCharacters(card.name)}`);
9782
10487
  }
9783
10488
  } catch {
9784
10489
  }
@@ -9797,16 +10502,7 @@ async function cmdStart(nameArg, options = {}) {
9797
10502
  });
9798
10503
  };
9799
10504
  const transport = new NostrTransport(client, identity2, [DEFAULT_KIND_OFFSET]);
9800
- const ledger = new JobLedger(paths.jobs);
9801
10505
  const irohTransport = createIrohTransport({ storePath: join(loaded.dir, ".iroh") });
9802
- await ensureGitignoreHasIrohEntry(dirname(loaded.dir));
9803
- if (x402Skills.length > 0) {
9804
- await ensureGitignoreHasX402Entries(dirname(loaded.dir));
9805
- }
9806
- const hasContextSkills = registry.all().some((skill) => skill.context === true);
9807
- if (hasContextSkills) {
9808
- await ensureGitignoreHasSessionsEntry(dirname(loaded.dir));
9809
- }
9810
10506
  const runtimeConfig = {
9811
10507
  paymentTimeoutSecs: DEFAULTS.PAYMENT_EXPIRY_SECS,
9812
10508
  maxConcurrentJobs: MAX_CONCURRENT_JOBS,
@@ -9821,10 +10517,6 @@ async function cmdStart(nameArg, options = {}) {
9821
10517
  // the payment key) to execute post-work pulls from customer delegations.
9822
10518
  delegateSigner
9823
10519
  };
9824
- const nonceStore = delegateSigner !== void 0 ? new UsedNonceStore(join(loaded.dir, ".delegation-nonces.json")) : void 0;
9825
- if (nonceStore !== void 0) {
9826
- await ensureGitignoreHasDelegationNoncesEntry(dirname(loaded.dir));
9827
- }
9828
10520
  const rpcUrlForLog = stripRpcSecrets(process.env.SOLANA_RPC_URL ?? getRpcUrl(walletNetwork));
9829
10521
  logger.debug(
9830
10522
  {
@@ -9871,7 +10563,7 @@ async function cmdStart(nameArg, options = {}) {
9871
10563
  ledger,
9872
10564
  {
9873
10565
  onJobReceived: (job) => {
9874
- const cap = sanitizeForTerminal(job.tags.find((t) => t !== "elisym") ?? "unknown");
10566
+ const cap = deleteControlCharacters(job.tags.find((t) => t !== "elisym") ?? "unknown");
9875
10567
  process.stdout.write(` [job] ${job.jobId.slice(0, 16)} | cap=${cap}
9876
10568
  `);
9877
10569
  logger.info({ event: "job_received", jobId: job.jobId, capability: cap });
@@ -9882,7 +10574,7 @@ async function cmdStart(nameArg, options = {}) {
9882
10574
  logger.info({ event: "job_delivered", jobId });
9883
10575
  },
9884
10576
  onJobError: (jobId, error) => {
9885
- const safeError = sanitizeForTerminal(error);
10577
+ const safeError = deleteControlCharacters(error);
9886
10578
  process.stderr.write(` [job] ${jobId.slice(0, 16)} | error: ${safeError}
9887
10579
  `);
9888
10580
  logger.error({ event: "job_error", jobId, error: safeError });
@@ -9974,37 +10666,6 @@ function buildScriptEnv(secrets) {
9974
10666
  }
9975
10667
  return scriptEnv;
9976
10668
  }
9977
- var PUBLIC_SOLANA_RPC_HOSTS = /* @__PURE__ */ new Set([
9978
- "api.devnet.solana.com",
9979
- "api.mainnet-beta.solana.com",
9980
- "api.testnet.solana.com"
9981
- ]);
9982
- function stripRpcSecrets(raw) {
9983
- try {
9984
- const parsed = new URL(raw);
9985
- parsed.username = "";
9986
- parsed.password = "";
9987
- if (!PUBLIC_SOLANA_RPC_HOSTS.has(parsed.hostname)) {
9988
- return `${parsed.protocol}//${parsed.host}/***`;
9989
- }
9990
- const marker = parsed.search.length > 0 ? "?***" : "";
9991
- parsed.search = "";
9992
- return `${parsed.toString()}${marker}`;
9993
- } catch {
9994
- return "[unparseable RPC URL]";
9995
- }
9996
- }
9997
- function isPublicSolanaRpcUrl(raw) {
9998
- try {
9999
- const parsed = new URL(raw);
10000
- return PUBLIC_SOLANA_RPC_HOSTS.has(parsed.hostname) && !parsed.username && !parsed.password && !parsed.search && (parsed.pathname === "" || parsed.pathname === "/");
10001
- } catch {
10002
- return false;
10003
- }
10004
- }
10005
- function redactRpcUrlsInText(text) {
10006
- return text.replace(/https?:\/\/[^\s)'"]+/g, (url) => stripRpcSecrets(url));
10007
- }
10008
10669
  async function resolveMediaField(value, agentDir, cache, blossom, identity2, onCacheUpdate) {
10009
10670
  if (!value) {
10010
10671
  return void 0;
@@ -10057,11 +10718,23 @@ async function uploadOrReuse(cacheKey, absPath, containRoot, cache, blossom, ide
10057
10718
  );
10058
10719
  return void 0;
10059
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
+ }
10060
10727
  try {
10061
10728
  const cached = await lookupCachedUrl(cache, cacheKey, realPath);
10062
10729
  if (cached) {
10063
10730
  return cached;
10064
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
+ }
10065
10738
  console.log(` Uploading ${basename(realPath)}...`);
10066
10739
  const data = readFileSync(realPath);
10067
10740
  const sha256 = createHash("sha256").update(data).digest("hex");