agent402-client 0.8.13 → 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.13";
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
@@ -66,7 +68,9 @@ export class Agent402 {
66
68
  * @param {string} [opts.baseUrl="https://agent402.tools"]
67
69
  * @param {typeof fetch} [opts.fetch] an x402-wrapped fetch for wallet-only tools (optional)
68
70
  * @param {boolean} [opts.cache=true] cache results in memory (deterministic tools)
69
- * @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
70
74
  * @param {number} [opts.maxPerCallUsd] hard ceiling on a single paid call (USD); over → SpendingLimitError before paying
71
75
  * @param {number} [opts.dailyLimitUsd] hard ceiling on rolling-24h paid spend (USD)
72
76
  * @param {number} [opts.maxPerHostUsd] hard ceiling on rolling-24h paid spend to one seller host (USD)
@@ -78,10 +82,14 @@ export class Agent402 {
78
82
  * Optional buyer-owned result validator. `id` namespaces cache entries;
79
83
  * `validate` may return false or throw to reject delivery.
80
84
  */
81
- 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,
82
86
  maxPerCallUsd = null, dailyLimitUsd = null, maxPerHostUsd = null, creditsKey = null,
83
87
  maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES, outputValidator = null } = {}) {
84
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
+ }
85
93
  if (typeof fetchImpl !== "function") throw new Error("No fetch available - pass { fetchImpl } on Node < 18");
86
94
  this.baseUrl = String(baseUrl).replace(/\/$/, "");
87
95
  this.payFetch = payFetch || null;
@@ -308,10 +316,10 @@ export class Agent402 {
308
316
  *
309
317
  * @param {string} walletAddress the caller's wallet address (e.g. "0x...")
310
318
  * @param {object} [opts]
311
- * @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)
312
320
  * @returns {Promise<{builderCode:string, walletAddress:string}>}
313
321
  */
314
- static async registerBuilderCode(walletAddress, { fetchImpl = globalThis.fetch } = {}) {
322
+ static async registerBuilderCode(walletAddress, { fetchImpl = distrustFetch() } = {}) {
315
323
  if (!walletAddress || typeof walletAddress !== "string") throw new Error("walletAddress is required");
316
324
  const r = await fetchImpl("https://api.base.dev/v1/agents/builder-codes", {
317
325
  method: "POST",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent402-client",
3
- "version": "0.8.13",
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
+ }