agent402-client 0.8.12 → 0.8.14

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/README.md CHANGED
@@ -351,6 +351,25 @@ const payFetch = wrapFetchWithPayment(fetch, client);
351
351
  Pairs with `maxPerCallUsd` / `dailyLimitUsd` (how much) and
352
352
  `withNetworkPreference` (which chain).
353
353
 
354
+ ## Distrusted certificate authorities (`distrustFetch`)
355
+
356
+ Since 0.8.14 the SDK's own requests refuse a TLS chain that passes through one
357
+ of the intermediate CAs listed in `DISTRUSTED_SPKI_SHA256` (keys reported
358
+ recoverable in Mozilla bug 2083449, which still validate against Node's bundled
359
+ store). The check is keyed on each certificate's SubjectPublicKeyInfo hash and
360
+ runs on a dedicated dispatcher, so nothing changes for the rest of your process.
361
+ Give your payment wrapper the same base fetch so the 402 and the paid retry go
362
+ through it too:
363
+
364
+ ```js
365
+ import { distrustFetch } from "agent402-client";
366
+ const payFetch = wrapFetchWithPayment(distrustFetch(), client);
367
+ ```
368
+
369
+ It applies on Node with the default dispatcher; with a proxy or custom
370
+ dispatcher installed, requests go out through yours unchanged. Pass
371
+ `{ tlsDistrust: false }` to send the default fetch as is.
372
+
354
373
  ## Legal
355
374
 
356
375
  Use of the hosted instance at agent402.tools is subject to its [Terms of Service](https://agent402.tools/terms) (acceptable-use policy included) and [Privacy Policy](https://agent402.tools/privacy). This package is MIT-licensed; the hosted server is AGPL-3.0. Both are provided as-is without warranty, and self-hosted deployments are their operator's responsibility.
package/index.js CHANGED
@@ -18,12 +18,14 @@
18
18
  // // paid tools: pass an x402-wrapped fetch (your wallet signs)
19
19
  // const a = new Agent402({ fetch: payFetch });
20
20
  import { createHash, randomBytes } from "node:crypto";
21
+ import { distrustFetch } from "./tls-distrust.js";
22
+ export { distrustFetch, distrustDispatcher, distrustCheckServerIdentity, DISTRUSTED_SPKI_SHA256 } from "./tls-distrust.js";
21
23
 
22
24
  // Keep in lockstep with package.json. Every request the SDK issues carries
23
25
  // `User-Agent: agent402-client/<version>` - a standard header, no extra
24
26
  // network calls - so a seller can attribute traffic (and settled payments)
25
27
  // to this SDK. Product token only; nothing about the caller rides along.
26
- const VERSION = "0.8.12";
28
+ const VERSION = "0.8.14";
27
29
  const USER_AGENT = `agent402-client/${VERSION}`;
28
30
  // 32MB: about a hundred times any realistic response from this catalog (the
29
31
  // largest is a base64 image at a few MB), so it cannot break a legitimate
@@ -33,13 +35,42 @@ const DEFAULT_MAX_RESPONSE_BYTES = 32 * 1024 * 1024;
33
35
 
34
36
  const leadingZeroBits = (buf) => { let n = 0; for (const b of buf) { if (b === 0) { n += 8; continue; } n += Math.clz32(b) - 24; break; } return n; };
35
37
 
38
+ // Discovery reads (catalog, find, route, skill packs, leaderboard, a
39
+ // proof-of-work challenge) settle nothing, so a 503 the server sheds under
40
+ // load is safe to repeat. It is retried only when it names its own delay
41
+ // (a Retry-After header or a retryAfterSeconds body field), at most
42
+ // DISCOVERY_ATTEMPTS tries in all, each wait capped at DISCOVERY_MAX_WAIT_MS.
43
+ // A paid or priced call never goes through here: a repeat of one could settle
44
+ // twice.
45
+ const DISCOVERY_ATTEMPTS = 3;
46
+ const DISCOVERY_MAX_WAIT_MS = 5000;
47
+
48
+ /** The wait (ms) a shed 503 asks for, or null when `r` is not one. */
49
+ async function shedRetryWaitMs(r, maxWaitMs = DISCOVERY_MAX_WAIT_MS) {
50
+ if (!r || r.status !== 503) return null;
51
+ const header = r.headers && typeof r.headers.get === "function" ? r.headers.get("retry-after") : null;
52
+ let seconds = header != null && String(header).trim() !== "" ? Number(header) : NaN;
53
+ if (!Number.isFinite(seconds)) {
54
+ try {
55
+ const body = await (typeof r.clone === "function" ? r.clone() : r).json();
56
+ seconds = Number(body && body.retryAfterSeconds);
57
+ } catch { seconds = NaN; }
58
+ }
59
+ if (!Number.isFinite(seconds) || seconds < 0) return null;
60
+ return Math.min(seconds * 1000, maxWaitMs);
61
+ }
62
+
63
+ const sleepMs = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
64
+
36
65
  export class Agent402 {
37
66
  /**
38
67
  * @param {object} [opts]
39
68
  * @param {string} [opts.baseUrl="https://agent402.tools"]
40
69
  * @param {typeof fetch} [opts.fetch] an x402-wrapped fetch for wallet-only tools (optional)
41
70
  * @param {boolean} [opts.cache=true] cache results in memory (deterministic tools)
42
- * @param {typeof fetch} [opts.fetchImpl] plain fetch (defaults to global fetch)
71
+ * @param {typeof fetch} [opts.fetchImpl] plain fetch (defaults to global fetch through distrustFetch)
72
+ * @param {boolean} [opts.tlsDistrust=true] send the default fetch through distrustFetch (refuses a TLS
73
+ * chain through a distrusted intermediate CA); false sends it as is
43
74
  * @param {number} [opts.maxPerCallUsd] hard ceiling on a single paid call (USD); over → SpendingLimitError before paying
44
75
  * @param {number} [opts.dailyLimitUsd] hard ceiling on rolling-24h paid spend (USD)
45
76
  * @param {number} [opts.maxPerHostUsd] hard ceiling on rolling-24h paid spend to one seller host (USD)
@@ -51,10 +82,14 @@ export class Agent402 {
51
82
  * Optional buyer-owned result validator. `id` namespaces cache entries;
52
83
  * `validate` may return false or throw to reject delivery.
53
84
  */
54
- constructor({ baseUrl = "https://agent402.tools", fetch: payFetch, cache = true, fetchImpl = globalThis.fetch,
85
+ constructor({ baseUrl = "https://agent402.tools", fetch: payFetch, cache = true, fetchImpl = undefined, tlsDistrust = true,
55
86
  maxPerCallUsd = null, dailyLimitUsd = null, maxPerHostUsd = null, creditsKey = null,
56
87
  maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES, outputValidator = null } = {}) {
57
88
  this.creditsKey = typeof creditsKey === "string" && /^a402_[A-Za-z0-9_-]{32,64}$/.test(creditsKey) ? creditsKey : null;
89
+ if (fetchImpl === undefined) {
90
+ fetchImpl = globalThis.fetch;
91
+ if (typeof fetchImpl === "function" && tlsDistrust !== false) fetchImpl = distrustFetch(fetchImpl);
92
+ }
58
93
  if (typeof fetchImpl !== "function") throw new Error("No fetch available - pass { fetchImpl } on Node < 18");
59
94
  this.baseUrl = String(baseUrl).replace(/\/$/, "");
60
95
  this.payFetch = payFetch || null;
@@ -62,6 +97,8 @@ export class Agent402 {
62
97
  // above). Caller-supplied init.headers still win on a key collision.
63
98
  this.f = (url, init = {}) => fetchImpl(url, { ...init, headers: { "User-Agent": USER_AGENT, ...(init.headers || {}) } });
64
99
  this._catalog = null;
100
+ // The wait between shed-503 retries of a discovery read (tests replace it).
101
+ this._sleep = sleepMs;
65
102
  this._cache = cache ? new Map() : null;
66
103
  // Salt for the default Idempotency-Key: one per client instance, so the key
67
104
  // for (slug, params) is stable across call() invocations on THIS client
@@ -147,10 +184,35 @@ export class Agent402 {
147
184
  return JSON.parse(Buffer.concat(chunks).toString("utf8"));
148
185
  }
149
186
 
187
+ /**
188
+ * GET a discovery surface, retrying a shed 503 (see DISCOVERY_ATTEMPTS).
189
+ * Returns the response once it is 2xx; otherwise throws an Error naming the
190
+ * operation, the surface and the last status, with `.status`, `.surface`
191
+ * and `.attempts` set.
192
+ */
193
+ async _discoveryGet(label, path) {
194
+ let r;
195
+ let attempts = 0;
196
+ for (;;) {
197
+ attempts++;
198
+ r = await this.f(`${this.baseUrl}${path}`);
199
+ if (r.ok) return r;
200
+ if (attempts >= DISCOVERY_ATTEMPTS) break;
201
+ const wait = await shedRetryWaitMs(r);
202
+ if (wait == null) break;
203
+ await this._sleep(wait);
204
+ }
205
+ const surface = path.split("?")[0];
206
+ const err = new Error(`${label} failed: HTTP ${r.status} from ${surface}${attempts > 1 ? ` after ${attempts} attempts` : ""}`);
207
+ err.status = r.status;
208
+ err.surface = surface;
209
+ err.attempts = attempts;
210
+ throw err;
211
+ }
212
+
150
213
  async _loadCatalog() {
151
214
  if (this._catalog) return this._catalog;
152
- const r = await this.f(`${this.baseUrl}/api/pricing`);
153
- if (!r.ok) throw new Error(`could not load catalog: HTTP ${r.status}`);
215
+ const r = await this._discoveryGet("could not load catalog", "/api/pricing");
154
216
  const j = await r.json();
155
217
  const m = new Map();
156
218
  for (const e of j.endpoints || []) m.set(e.slug, { method: e.method, path: e.path, computePayable: e.computePayable, price: e.price });
@@ -160,8 +222,7 @@ export class Agent402 {
160
222
 
161
223
  /** Resolve a plain-language task to the best-matching tools (route, price, schema, example). */
162
224
  async find(task, { k = 5 } = {}) {
163
- const r = await this.f(`${this.baseUrl}/api/find?q=${encodeURIComponent(task)}&k=${k}`);
164
- if (!r.ok) throw new Error(`find failed: HTTP ${r.status}`);
225
+ const r = await this._discoveryGet("find", `/api/find?q=${encodeURIComponent(task)}&k=${k}`);
165
226
  return (await r.json()).results || [];
166
227
  }
167
228
 
@@ -190,8 +251,7 @@ export class Agent402 {
190
251
  const includeParam = include === "external" || include === "local" ? include : "all";
191
252
  const qs = new URLSearchParams({ q: String(task ?? ""), k: String(top), include: includeParam });
192
253
  if (network) qs.set("network", String(network));
193
- const r = await this.f(`${this.baseUrl}/api/route?${qs}`);
194
- if (!r.ok) throw new Error(`route failed: HTTP ${r.status}`);
254
+ const r = await this._discoveryGet("route", `/api/route?${qs}`);
195
255
  return r.json();
196
256
  }
197
257
 
@@ -204,8 +264,7 @@ export class Agent402 {
204
264
  * to fetch the rendered prompt messages, or hand the slug to an MCP client.
205
265
  */
206
266
  async findWorkflows(task, { k = 2 } = {}) {
207
- const r = await this.f(`${this.baseUrl}/api/find?q=${encodeURIComponent(task)}&k=${k}`);
208
- if (!r.ok) throw new Error(`findWorkflows failed: HTTP ${r.status}`);
267
+ const r = await this._discoveryGet("findWorkflows", `/api/find?q=${encodeURIComponent(task)}&k=${k}`);
209
268
  return (await r.json()).packs || [];
210
269
  }
211
270
 
@@ -216,8 +275,7 @@ export class Agent402 {
216
275
  */
217
276
  async getWorkflowPrompt(slug, args = {}) {
218
277
  const qs = new URLSearchParams(Object.entries(args).map(([k, v]) => [k, String(v)])).toString();
219
- const r = await this.f(`${this.baseUrl}/api/skill-packs/${encodeURIComponent(slug)}/prompt${qs ? `?${qs}` : ""}`);
220
- if (!r.ok) throw new Error(`getWorkflowPrompt("${slug}") failed: HTTP ${r.status}`);
278
+ const r = await this._discoveryGet(`getWorkflowPrompt("${slug}")`, `/api/skill-packs/${encodeURIComponent(slug)}/prompt${qs ? `?${qs}` : ""}`);
221
279
  return r.json();
222
280
  }
223
281
 
@@ -238,9 +296,7 @@ export class Agent402 {
238
296
  const top = Math.min(Math.max(parseInt(limit, 10) || 10, 1), 50);
239
297
  const sortParam = sort === "calls" ? "calls" : "usd";
240
298
  const includeParam = include === "all" ? "all" : "external";
241
- const url = `${this.baseUrl}/api/leaderboard?top=${top}&sort=${sortParam}&include=${includeParam}`;
242
- const r = await this.f(url);
243
- if (!r.ok) throw new Error(`topSellers failed: HTTP ${r.status}`);
299
+ const r = await this._discoveryGet("topSellers", `/api/leaderboard?top=${top}&sort=${sortParam}&include=${includeParam}`);
244
300
  const snap = await r.json();
245
301
  return {
246
302
  window: snap.windowLabel || snap.windowServed || "24h",
@@ -260,10 +316,10 @@ export class Agent402 {
260
316
  *
261
317
  * @param {string} walletAddress the caller's wallet address (e.g. "0x...")
262
318
  * @param {object} [opts]
263
- * @param {typeof fetch} [opts.fetchImpl] plain fetch (defaults to global fetch)
319
+ * @param {typeof fetch} [opts.fetchImpl] plain fetch (defaults to global fetch through distrustFetch)
264
320
  * @returns {Promise<{builderCode:string, walletAddress:string}>}
265
321
  */
266
- static async registerBuilderCode(walletAddress, { fetchImpl = globalThis.fetch } = {}) {
322
+ static async registerBuilderCode(walletAddress, { fetchImpl = distrustFetch() } = {}) {
267
323
  if (!walletAddress || typeof walletAddress !== "string") throw new Error("walletAddress is required");
268
324
  const r = await fetchImpl("https://api.base.dev/v1/agents/builder-codes", {
269
325
  method: "POST",
@@ -436,8 +492,7 @@ export class Agent402 {
436
492
  }
437
493
 
438
494
  async _powChallenge(slug) {
439
- const r = await this.f(`${this.baseUrl}/api/pow/challenge?slug=${encodeURIComponent(slug)}`);
440
- if (!r.ok) throw new Error(`proof-of-work challenge for "${slug}" failed: HTTP ${r.status}`);
495
+ const r = await this._discoveryGet(`proof-of-work challenge for "${slug}"`, `/api/pow/challenge?slug=${encodeURIComponent(slug)}`);
441
496
  return r.json();
442
497
  }
443
498
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent402-client",
3
- "version": "0.8.12",
3
+ "version": "0.8.14",
4
4
  "description": "Agent402 buyer SDK: resolve a task to a tool across 500+ pay-per-call endpoints and call it with payment handled - free via proof-of-work, or over x402 or MPP (a stock mppx fetch works) with a wallet you keep; the buy side of Agentic Finance. Caching, idempotent retries (Idempotency-Key), non-custodial, zero dependencies.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -9,6 +9,7 @@
9
9
  },
10
10
  "files": [
11
11
  "index.js",
12
+ "tls-distrust.js",
12
13
  "README.md"
13
14
  ],
14
15
  "engines": {
@@ -0,0 +1,110 @@
1
+ // Refuse TLS chains through a distrusted intermediate CA, for this SDK's own
2
+ // requests only (nothing global changes in the caller's process).
3
+ //
4
+ // The private keys of the intermediate CAs below are recoverable (Mozilla bug
5
+ // 2083449). A cross-signed path makes them validate against "emSign ECC Root
6
+ // CA - G3" in Node's bundled store, and Node checks no revocation by default,
7
+ // so a certificate minted with one of these keys for a seller's host would be
8
+ // accepted, and a forged 402 could name a different payTo. The check is keyed
9
+ // on the SubjectPublicKeyInfo sha256 of every certificate in the presented
10
+ // chain. It can be removed once the Node releases this SDK supports ship a CA
11
+ // store that no longer builds the path.
12
+ //
13
+ // distrustFetch(baseFetch) returns a fetch that sends each request through a
14
+ // dedicated undici Agent whose connect.checkServerIdentity refuses such a
15
+ // chain, then runs Node's own hostname check. It applies only while the
16
+ // process-wide dispatcher is still Node's own default; once the caller has
17
+ // installed one (setGlobalDispatcher: a proxy, a custom CA, timeouts), the
18
+ // request goes out through theirs unchanged and their TLS choices win.
19
+ import tls from "node:tls";
20
+ import { createHash, X509Certificate } from "node:crypto";
21
+
22
+ export const DISTRUSTED_SPKI_SHA256 = Object.freeze({
23
+ "ebed6475c2c2d80fcb199f3fe8dd6e6b3ce60b77eb15d4e42c3dec0c6f5b5105": "EM DV TLS CA - G3B",
24
+ "43bffa19d6ef651451f6841e019931e96f6f2eaeff28ab4b6f1ce4576de2717b": "EM OV TLS CA - G3B",
25
+ "e0254c7fcdc265e2f0f8a428dabb75ab4ae039b4872d62f5ee57f0877d5a8a41": "EM EV TLS CA - G3B",
26
+ "49dd353dc2b919a3770645117d0e60c16115e7d1bc259eefe734b00ea434fe5f": "EM DV TLS CA - G3C",
27
+ "63ecefee431d49523bc1136c4e72d0206a477086caf606cd5cf025686c109cf8": "EM OV TLS CA - G3C",
28
+ "5c0660a2b164910d8ff589c18e14552e9e0ec237dcefe2cb736d41a4811f81a0": "EM EV TLS CA - G3C",
29
+ "ec4ffa1ad0ac88081314b7aaffef6a91f97cd57291bb5ba686c5415f5eb4f6b6": "EM OV CS CA - G3B",
30
+ "92c063d67e8f14db71d288e11266ffdaf69cbf115f7bab14f3bc2f68d25e0e30": "EM OV CS CA - G3C",
31
+ });
32
+
33
+ /** checkServerIdentity that refuses a chain through a listed key (plus any
34
+ * `extraSpki` hex hashes), else defers to Node's hostname check. */
35
+ export function distrustCheckServerIdentity(extraSpki = []) {
36
+ const extra = new Set([...extraSpki].map((h) => String(h).toLowerCase()));
37
+ const nodeCheck = tls.checkServerIdentity;
38
+ return (host, cert) => {
39
+ const seen = [];
40
+ for (let c = cert; c && typeof c === "object" && !seen.includes(c) && seen.length < 16; c = c.issuerCertificate === c ? null : c.issuerCertificate) {
41
+ seen.push(c);
42
+ if (!c.raw) continue;
43
+ let spki;
44
+ try {
45
+ spki = createHash("sha256").update(new X509Certificate(c.raw).publicKey.export({ type: "spki", format: "der" })).digest("hex");
46
+ } catch {
47
+ return Object.assign(new Error(`Refusing the TLS certificate for ${host}: distrusted intermediate check could not read the certificate chain`), { code: "ERR_TLS_DISTRUSTED_INTERMEDIATE" });
48
+ }
49
+ const name = DISTRUSTED_SPKI_SHA256[spki] || (extra.has(spki) ? "listed key" : null);
50
+ if (name) return Object.assign(new Error(`Refusing the TLS certificate for ${host}: distrusted intermediate (${name}) in the certificate chain`), { code: "ERR_TLS_DISTRUSTED_INTERMEDIATE" });
51
+ }
52
+ return nodeCheck(host, cert);
53
+ };
54
+ }
55
+
56
+ const GLOBAL_DISPATCHER = Symbol.for("undici.globalDispatcher.1");
57
+
58
+ // Node's own default dispatcher, observed when this module loads. Node fills
59
+ // the slot when fetch is first called (synchronously, before any connection),
60
+ // so when the slot is still empty here a data: URL fetch fills it with Node's
61
+ // instance and nothing else can have put it there. When the slot is already
62
+ // filled at load, whoever filled it is unknown and it is treated as the
63
+ // caller's (no dedicated dispatcher), unless another copy of this code (the
64
+ // client and the MCP server share it) observed that same instance as Node's.
65
+ const OBSERVED = Symbol.for("agent402.tlsDistrust.nodeDefaultDispatcher");
66
+ function observeNodeDefault() {
67
+ if (typeof process !== "object" || !process?.versions?.node || process.versions.bun) return null;
68
+ const current = globalThis[GLOBAL_DISPATCHER];
69
+ if (current) return globalThis[OBSERVED] === current ? current : null;
70
+ if (typeof globalThis.fetch !== "function") return null;
71
+ try { globalThis.fetch("data:,").catch(() => {}); } catch { return null; }
72
+ const observed = globalThis[GLOBAL_DISPATCHER] || null;
73
+ if (observed) Object.defineProperty(globalThis, OBSERVED, { value: observed, configurable: false, enumerable: false, writable: false });
74
+ return observed;
75
+ }
76
+ const NODE_DEFAULT_DISPATCHER = observeNodeDefault();
77
+
78
+ /** True when the process-wide dispatcher is not Node's own default: the
79
+ * caller installed one, or its origin is unknown. */
80
+ export function callerDispatcherInstalled() {
81
+ return !NODE_DEFAULT_DISPATCHER || globalThis[GLOBAL_DISPATCHER] !== NODE_DEFAULT_DISPATCHER;
82
+ }
83
+
84
+ /** A dedicated undici Agent carrying the refusal, or null when this platform
85
+ * cannot take one or the caller installed their own global dispatcher (see
86
+ * the header). */
87
+ export async function distrustDispatcher({ extraSpki = [] } = {}) {
88
+ if (callerDispatcherInstalled()) return null;
89
+ const AgentClass = NODE_DEFAULT_DISPATCHER.constructor;
90
+ if (typeof AgentClass !== "function") return null;
91
+ return new AgentClass({ connect: { checkServerIdentity: distrustCheckServerIdentity(extraSpki) } });
92
+ }
93
+
94
+ /** Wrap a fetch so every request it makes goes through distrustDispatcher().
95
+ * With no baseFetch it calls globalThis.fetch as it is at request time. A
96
+ * caller's own init.dispatcher, or a global dispatcher the caller installed
97
+ * (checked on every request), is left alone. Pass the result as the base
98
+ * fetch of a payment wrapper (wrapFetchWithPayment(distrustFetch(), client))
99
+ * so the 402 and the paid retry both go through it. */
100
+ export function distrustFetch(baseFetch, opts = {}) {
101
+ if (baseFetch !== undefined && typeof baseFetch !== "function") throw new Error("distrustFetch needs a fetch function");
102
+ let pending = null;
103
+ return async (input, init = {}) => {
104
+ const f = baseFetch || globalThis.fetch;
105
+ if (callerDispatcherInstalled() || init?.dispatcher !== undefined) return f(input, init);
106
+ pending ||= distrustDispatcher(opts).catch(() => null);
107
+ const dispatcher = await pending;
108
+ return f(input, dispatcher ? { ...init, dispatcher } : init);
109
+ };
110
+ }