@absol-labs/agent 0.10.0 → 0.11.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.
Files changed (141) hide show
  1. package/README.md +107 -23
  2. package/dist/capability/invocation-capability.d.ts +18 -6
  3. package/dist/capability/invocation-capability.d.ts.map +1 -1
  4. package/dist/capability/invocation-capability.js.map +1 -1
  5. package/dist/cli/commands/claim.d.ts.map +1 -1
  6. package/dist/cli/commands/claim.js +20 -4
  7. package/dist/cli/commands/claim.js.map +1 -1
  8. package/dist/cli/commands/close.d.ts.map +1 -1
  9. package/dist/cli/commands/close.js +16 -4
  10. package/dist/cli/commands/close.js.map +1 -1
  11. package/dist/cli/commands/invoke.d.ts.map +1 -1
  12. package/dist/cli/commands/invoke.js +1 -0
  13. package/dist/cli/commands/invoke.js.map +1 -1
  14. package/dist/cli/commands/open.d.ts.map +1 -1
  15. package/dist/cli/commands/open.js +63 -11
  16. package/dist/cli/commands/open.js.map +1 -1
  17. package/dist/cli/commands/reclaim-unverified.d.ts.map +1 -1
  18. package/dist/cli/commands/reclaim-unverified.js +12 -4
  19. package/dist/cli/commands/reclaim-unverified.js.map +1 -1
  20. package/dist/cli/commands/reclaim.d.ts.map +1 -1
  21. package/dist/cli/commands/reclaim.js +16 -5
  22. package/dist/cli/commands/reclaim.js.map +1 -1
  23. package/dist/cli/commands/streams.js +1 -1
  24. package/dist/cli/commands/streams.js.map +1 -1
  25. package/dist/cli/deps.d.ts +53 -7
  26. package/dist/cli/deps.d.ts.map +1 -1
  27. package/dist/cli/deps.js +65 -15
  28. package/dist/cli/deps.js.map +1 -1
  29. package/dist/cli/privy.d.ts +83 -0
  30. package/dist/cli/privy.d.ts.map +1 -0
  31. package/dist/cli/privy.js +230 -0
  32. package/dist/cli/privy.js.map +1 -0
  33. package/dist/cli/spend-policy.d.ts +60 -0
  34. package/dist/cli/spend-policy.d.ts.map +1 -0
  35. package/dist/cli/spend-policy.js +65 -0
  36. package/dist/cli/spend-policy.js.map +1 -0
  37. package/dist/cli/wallet.d.ts +108 -39
  38. package/dist/cli/wallet.d.ts.map +1 -1
  39. package/dist/cli/wallet.js +108 -43
  40. package/dist/cli/wallet.js.map +1 -1
  41. package/dist/gateway/caller-auth-gateway.d.ts +38 -3
  42. package/dist/gateway/caller-auth-gateway.d.ts.map +1 -1
  43. package/dist/gateway/caller-auth-gateway.js +25 -1
  44. package/dist/gateway/caller-auth-gateway.js.map +1 -1
  45. package/dist/gateway/http-server.d.ts +53 -0
  46. package/dist/gateway/http-server.d.ts.map +1 -1
  47. package/dist/gateway/http-server.js +189 -4
  48. package/dist/gateway/http-server.js.map +1 -1
  49. package/dist/gateway/x402-mode.d.ts +123 -0
  50. package/dist/gateway/x402-mode.d.ts.map +1 -0
  51. package/dist/gateway/x402-mode.js +212 -0
  52. package/dist/gateway/x402-mode.js.map +1 -0
  53. package/dist/index.d.ts +6 -4
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +8 -3
  56. package/dist/index.js.map +1 -1
  57. package/dist/mandates/env.d.ts +3 -2
  58. package/dist/mandates/env.d.ts.map +1 -1
  59. package/dist/mandates/env.js +3 -2
  60. package/dist/mandates/env.js.map +1 -1
  61. package/dist/mcp/server.d.ts +49 -0
  62. package/dist/mcp/server.d.ts.map +1 -1
  63. package/dist/mcp/server.js +513 -24
  64. package/dist/mcp/server.js.map +1 -1
  65. package/dist/mcp/service-invoker.d.ts +97 -0
  66. package/dist/mcp/service-invoker.d.ts.map +1 -0
  67. package/dist/mcp/service-invoker.js +167 -0
  68. package/dist/mcp/service-invoker.js.map +1 -0
  69. package/dist/sdk/invoke.d.ts +19 -4
  70. package/dist/sdk/invoke.d.ts.map +1 -1
  71. package/dist/sdk/invoke.js +69 -5
  72. package/dist/sdk/invoke.js.map +1 -1
  73. package/dist/x402/delivery-oracle.d.ts +108 -0
  74. package/dist/x402/delivery-oracle.d.ts.map +1 -0
  75. package/dist/x402/delivery-oracle.js +174 -0
  76. package/dist/x402/delivery-oracle.js.map +1 -0
  77. package/dist/x402/facilitator-server-entry.d.ts +3 -0
  78. package/dist/x402/facilitator-server-entry.d.ts.map +1 -0
  79. package/dist/x402/facilitator-server-entry.js +27 -0
  80. package/dist/x402/facilitator-server-entry.js.map +1 -0
  81. package/dist/x402/facilitator-server.d.ts +73 -0
  82. package/dist/x402/facilitator-server.d.ts.map +1 -0
  83. package/dist/x402/facilitator-server.js +305 -0
  84. package/dist/x402/facilitator-server.js.map +1 -0
  85. package/dist/x402/facilitator.d.ts +69 -17
  86. package/dist/x402/facilitator.d.ts.map +1 -1
  87. package/dist/x402/facilitator.js +76 -33
  88. package/dist/x402/facilitator.js.map +1 -1
  89. package/dist/x402/payment-requirements.d.ts +106 -0
  90. package/dist/x402/payment-requirements.d.ts.map +1 -0
  91. package/dist/x402/payment-requirements.js +138 -0
  92. package/dist/x402/payment-requirements.js.map +1 -0
  93. package/dist/x402/relayer.d.ts +41 -0
  94. package/dist/x402/relayer.d.ts.map +1 -0
  95. package/dist/x402/relayer.js +64 -0
  96. package/dist/x402/relayer.js.map +1 -0
  97. package/dist/x402/resource-index.d.ts +75 -0
  98. package/dist/x402/resource-index.d.ts.map +1 -0
  99. package/dist/x402/resource-index.js +126 -0
  100. package/dist/x402/resource-index.js.map +1 -0
  101. package/dist/x402/verified-delivery-facilitator.d.ts +285 -0
  102. package/dist/x402/verified-delivery-facilitator.d.ts.map +1 -0
  103. package/dist/x402/verified-delivery-facilitator.js +423 -0
  104. package/dist/x402/verified-delivery-facilitator.js.map +1 -0
  105. package/dist/zktls/t2-delivery-proof.d.ts +6 -6
  106. package/docs/agent-layer.md +12 -8
  107. package/docs/cli.md +158 -22
  108. package/docs/mcp-hosted.md +4 -3
  109. package/docs/mcp-reference.md +175 -0
  110. package/docs/quickstart.md +18 -4
  111. package/docs/threat-model.md +4 -3
  112. package/docs/x402.md +648 -0
  113. package/package.json +18 -7
  114. package/src/capability/invocation-capability.ts +17 -1
  115. package/src/cli/commands/claim.ts +30 -4
  116. package/src/cli/commands/close.ts +20 -4
  117. package/src/cli/commands/invoke.ts +1 -0
  118. package/src/cli/commands/open.ts +76 -10
  119. package/src/cli/commands/reclaim-unverified.ts +16 -4
  120. package/src/cli/commands/reclaim.ts +22 -5
  121. package/src/cli/commands/streams.ts +1 -1
  122. package/src/cli/deps.ts +115 -24
  123. package/src/cli/privy.ts +396 -0
  124. package/src/cli/spend-policy.ts +139 -0
  125. package/src/cli/wallet.ts +188 -57
  126. package/src/gateway/caller-auth-gateway.ts +77 -6
  127. package/src/gateway/http-server.ts +280 -6
  128. package/src/gateway/x402-mode.ts +350 -0
  129. package/src/index.ts +40 -5
  130. package/src/mandates/env.ts +3 -2
  131. package/src/mcp/server.ts +681 -29
  132. package/src/mcp/service-invoker.ts +285 -0
  133. package/src/sdk/invoke.ts +90 -12
  134. package/src/x402/delivery-oracle.ts +285 -0
  135. package/src/x402/facilitator-server-entry.ts +35 -0
  136. package/src/x402/facilitator-server.ts +445 -0
  137. package/src/x402/facilitator.ts +125 -46
  138. package/src/x402/payment-requirements.ts +231 -0
  139. package/src/x402/relayer.ts +122 -0
  140. package/src/x402/resource-index.ts +228 -0
  141. package/src/x402/verified-delivery-facilitator.ts +744 -0
@@ -0,0 +1,285 @@
1
+ import {
2
+ RegistryUnavailableError,
3
+ type DiscoverServicesOptions,
4
+ type ServiceListing,
5
+ } from "../discovery/registry.js";
6
+ import {
7
+ invoke as defaultInvoke,
8
+ type InvokeStreamReader,
9
+ } from "../sdk/invoke.js";
10
+ import type {
11
+ HttpMethod,
12
+ InvocationCapabilityDomainInput,
13
+ InvocationCapabilitySigner,
14
+ } from "../capability/invocation-capability.js";
15
+
16
+ /**
17
+ * The "use" half of the MCP tool surface.
18
+ *
19
+ * The MCP server could open a stream and settle it but could not CALL the
20
+ * service the stream paid for — an agent that hired an endpoint through this
21
+ * server had no way to reach it, which made every other tool a payment rail
22
+ * with no product on the other end. This module is the same path
23
+ * `metrik invoke` takes (`../cli/commands/invoke.ts`): resolve the stream's
24
+ * VERIFIED listing, sign a single-use `InvocationCapability` as the stream's
25
+ * buyer, and call the operator's gateway-fronted origin.
26
+ *
27
+ * It moves no funds and signs no transaction. Billing is a function of elapsed
28
+ * verified time, not of calls (see {@link METERING_MODEL} in `./server.ts`), so
29
+ * an invocation is authorized by the stream alone and is deliberately NOT
30
+ * gated by the spend mandate — there is no spend to bound.
31
+ */
32
+
33
+ /**
34
+ * Cap on the response body handed back to the MCP caller.
35
+ *
36
+ * An MCP tool result is pasted into a model's context. A seller can return a
37
+ * body of any size, and an unbounded one would blow the context (or the host's
38
+ * message limit) on a single call. Truncation is reported explicitly rather
39
+ * than silently, so an agent can tell a short answer from a clipped one.
40
+ */
41
+ export const MAX_INVOCATION_RESPONSE_BYTES = 64 * 1024;
42
+
43
+ /** Raised when no verified listing in the registry matches the stream's `serviceRef`. */
44
+ export class ServiceListingNotFoundError extends Error {
45
+ readonly serviceRef: `0x${string}`;
46
+
47
+ constructor(serviceRef: `0x${string}`) {
48
+ super(`no verified listing matches serviceRef ${serviceRef}`);
49
+ this.name = "ServiceListingNotFoundError";
50
+ this.serviceRef = serviceRef;
51
+ }
52
+ }
53
+
54
+ export interface ServiceInvocationRequest {
55
+ readonly streamId: `0x${string}`;
56
+ readonly method: HttpMethod;
57
+ /** Origin-relative path, e.g. `/price?symbol=BTC`. */
58
+ readonly path: string;
59
+ readonly headers?: Readonly<Record<string, string>>;
60
+ readonly body?: string;
61
+ readonly ttlSeconds?: number;
62
+ }
63
+
64
+ export interface ServiceInvocationResult {
65
+ readonly streamId: `0x${string}`;
66
+ readonly method: HttpMethod;
67
+ readonly path: string;
68
+ readonly status: number;
69
+ readonly ok: boolean;
70
+ readonly headers: Record<string, string>;
71
+ readonly body: string;
72
+ readonly bodyTruncated: boolean;
73
+ /** The single-use capability this call was authorized by. */
74
+ readonly capability: {
75
+ readonly nonce: `0x${string}`;
76
+ readonly expiry: number;
77
+ };
78
+ readonly routing: {
79
+ readonly serviceRef: `0x${string}`;
80
+ readonly operator: `0x${string}`;
81
+ readonly access: "public" | "gated";
82
+ /** The signed origin the call went to — never an unsigned caller-supplied URL. */
83
+ readonly origin: string;
84
+ };
85
+ /**
86
+ * The caller-auth gateway's machine-readable rejection reason when it refused
87
+ * the capability, or `null`. Distinguishes "the seller's app returned 403"
88
+ * from "the gateway would not let the call through, and here is why".
89
+ */
90
+ readonly gatewayDenialReason: string | null;
91
+ }
92
+
93
+ export interface ServiceInvoker {
94
+ invokeService(
95
+ request: ServiceInvocationRequest,
96
+ ): Promise<ServiceInvocationResult>;
97
+ }
98
+
99
+ /** Discovery driver — same shape as the server's, kept local to avoid an import cycle. */
100
+ type DiscoverServicesFn = (
101
+ opts?: DiscoverServicesOptions,
102
+ ) => Promise<ServiceListing[]>;
103
+
104
+ export interface CreateServiceInvokerOptions {
105
+ /** Reads the stream's buyer/serviceRef/status/expiry. */
106
+ readonly streamReader: InvokeStreamReader;
107
+ /** The server's own wallet. Must BE the stream's buyer or the call fails closed. */
108
+ readonly buyer: InvocationCapabilitySigner;
109
+ /** `{chainId, verifyingContract}` of the escrow the stream settles on. */
110
+ readonly domain: InvocationCapabilityDomainInput;
111
+ readonly discoverServices: DiscoverServicesFn;
112
+ /** Registry read options (URL/apikey). Passed through to discovery. */
113
+ readonly discoverOptions?: DiscoverServicesOptions;
114
+ /**
115
+ * Clock for the capability's expiry and the stream's liveness check.
116
+ * Defaults to the wall clock; injectable so a test can pin it.
117
+ */
118
+ readonly now?: () => number;
119
+ /** Injectable for tests. Defaults to the real `invoke()`. */
120
+ readonly invokeImpl?: typeof defaultInvoke;
121
+ readonly maxResponseBytes?: number;
122
+ }
123
+
124
+ export function createServiceInvoker(
125
+ options: CreateServiceInvokerOptions,
126
+ ): ServiceInvoker {
127
+ const invokeImpl = options.invokeImpl ?? defaultInvoke;
128
+ const maxBytes = options.maxResponseBytes ?? MAX_INVOCATION_RESPONSE_BYTES;
129
+
130
+ return {
131
+ async invokeService(request) {
132
+ // ONE read, used twice. The listing lookup needs the stream's serviceRef
133
+ // and `invoke()` re-reads the stream to authorize the call; memoizing
134
+ // makes both decisions come from the SAME snapshot (no window where the
135
+ // listing is resolved from one state and the capability signed against
136
+ // another) and costs one RPC round trip instead of two.
137
+ const reader = memoizeStreamReader(options.streamReader);
138
+ const stream = await reader.getStreamV2(request.streamId);
139
+ const listing = await resolveVerifiedListing(
140
+ stream.serviceRef as `0x${string}`,
141
+ options,
142
+ );
143
+
144
+ const result = await invokeImpl(
145
+ request.streamId,
146
+ {
147
+ method: request.method,
148
+ path: request.path,
149
+ ...(request.headers === undefined
150
+ ? {}
151
+ : { headers: request.headers }),
152
+ ...(request.body === undefined ? {} : { body: request.body }),
153
+ },
154
+ {
155
+ streamReader: reader,
156
+ buyer: options.buyer,
157
+ domain: options.domain,
158
+ listing,
159
+ ...(request.ttlSeconds === undefined
160
+ ? {}
161
+ : { ttlSeconds: request.ttlSeconds }),
162
+ ...(options.now === undefined ? {} : { nowSeconds: options.now() }),
163
+ },
164
+ );
165
+
166
+ const raw = await result.response.text();
167
+ const truncated = Buffer.byteLength(raw, "utf8") > maxBytes;
168
+ const body = truncated
169
+ ? Buffer.from(raw, "utf8").subarray(0, maxBytes).toString("utf8")
170
+ : raw;
171
+
172
+ return {
173
+ streamId: request.streamId,
174
+ method: request.method,
175
+ path: request.path,
176
+ status: result.response.status,
177
+ ok: result.response.ok,
178
+ headers: Object.fromEntries(result.response.headers.entries()),
179
+ body,
180
+ bodyTruncated: truncated,
181
+ capability: {
182
+ nonce: result.capability.nonce as `0x${string}`,
183
+ expiry: result.capability.expiry,
184
+ },
185
+ routing: {
186
+ serviceRef: listing.serviceRef,
187
+ operator: listing.operator,
188
+ access: listing.access,
189
+ origin: safeOrigin(listing.accessUrl),
190
+ },
191
+ gatewayDenialReason: readGatewayDenialReason(
192
+ result.response.status,
193
+ raw,
194
+ ),
195
+ };
196
+ },
197
+ };
198
+ }
199
+
200
+ /**
201
+ * Finds the operator-signed listing for a `serviceRef`.
202
+ *
203
+ * The registry is an untrusted transport: `discoverServices` re-derives every
204
+ * `serviceRef` from the signed record and recovers the operator's signature, so
205
+ * a compromised registry can neither invent an operator nor redirect an
206
+ * invocation to an origin its operator never signed. That verification is why
207
+ * this tool routes through discovery instead of accepting a caller-supplied URL
208
+ * — an LLM-chosen base URL is exactly the injection this design refuses.
209
+ */
210
+ async function resolveVerifiedListing(
211
+ serviceRef: `0x${string}`,
212
+ options: CreateServiceInvokerOptions,
213
+ ): Promise<ServiceListing> {
214
+ const listings = await options.discoverServices(options.discoverOptions);
215
+ const wanted = serviceRef.toLowerCase();
216
+ const match = listings.find(
217
+ (listing) => listing.serviceRef.toLowerCase() === wanted,
218
+ );
219
+ if (match === undefined) {
220
+ throw new ServiceListingNotFoundError(serviceRef);
221
+ }
222
+ return match;
223
+ }
224
+
225
+ /** Wraps a reader so repeated reads of the SAME stream id reuse one result. */
226
+ function memoizeStreamReader(reader: InvokeStreamReader): InvokeStreamReader {
227
+ const cache = new Map<
228
+ string,
229
+ ReturnType<InvokeStreamReader["getStreamV2"]>
230
+ >();
231
+ return {
232
+ getStreamV2(streamId) {
233
+ const key = streamId.toLowerCase();
234
+ const cached = cache.get(key);
235
+ if (cached !== undefined) {
236
+ return cached;
237
+ }
238
+ const pending = reader.getStreamV2(streamId);
239
+ cache.set(key, pending);
240
+ return pending;
241
+ },
242
+ };
243
+ }
244
+
245
+ /**
246
+ * Extracts the caller-auth gateway's `reason` from a 402/403 body.
247
+ *
248
+ * The gateway answers a refused capability with
249
+ * `{"authorized":false,"reason":"<closed-set reason>"}`
250
+ * (`../gateway/http-server.ts`). Surfacing that verbatim turns "403" into
251
+ * something an agent can act on — `stream-expired` and `nonce-replayed` call
252
+ * for opposite responses.
253
+ */
254
+ function readGatewayDenialReason(status: number, body: string): string | null {
255
+ if (status !== 402 && status !== 403) {
256
+ return null;
257
+ }
258
+ try {
259
+ const parsed = JSON.parse(body) as {
260
+ readonly authorized?: unknown;
261
+ readonly reason?: unknown;
262
+ } | null;
263
+ if (
264
+ typeof parsed === "object" &&
265
+ parsed !== null &&
266
+ parsed.authorized === false &&
267
+ typeof parsed.reason === "string"
268
+ ) {
269
+ return parsed.reason;
270
+ }
271
+ } catch {
272
+ // Not the gateway's JSON shape — a plain upstream 403. Nothing to add.
273
+ }
274
+ return null;
275
+ }
276
+
277
+ function safeOrigin(url: string): string {
278
+ try {
279
+ return new URL(url).origin;
280
+ } catch {
281
+ return url;
282
+ }
283
+ }
284
+
285
+ export { RegistryUnavailableError };
package/src/sdk/invoke.ts CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  hashInvocationPath,
25
25
  signInvocationCapability,
26
26
  type HttpMethod,
27
+ type InvocationCapabilitySigner,
27
28
  type InvocationCapability,
28
29
  type InvocationCapabilityDomainInput,
29
30
  type SignedInvocationCapability,
@@ -115,16 +116,14 @@ export class InvokeAccessUrlError extends Error {
115
116
  }
116
117
 
117
118
  export class InvokePathError extends Error {
118
- constructor(path: string) {
119
- super(
120
- `invocation path must be origin-relative and start with one slash: ${path}`,
121
- );
119
+ constructor(message: string) {
120
+ super(message);
122
121
  this.name = "InvokePathError";
123
122
  }
124
123
  }
125
124
 
126
125
  export interface CapabilityForOptions {
127
- readonly buyer: LocalAccount;
126
+ readonly buyer: InvocationCapabilitySigner;
128
127
  readonly domain: InvocationCapabilityDomainInput;
129
128
  readonly serviceRef: Hex;
130
129
  /** Default {@link DEFAULT_CAPABILITY_TTL_SECONDS}. */
@@ -173,7 +172,7 @@ export interface InvokeOptions {
173
172
  /** Reads the stream's current serviceRef/operator/status/expiry. Real default: a `MetrikClient` instance. */
174
173
  readonly streamReader: InvokeStreamReader;
175
174
  /** The buyer's own wallet - signs the capability, never broadcasts a transaction. */
176
- readonly buyer: LocalAccount;
175
+ readonly buyer: InvocationCapabilitySigner;
177
176
  /** `{chainId, verifyingContract}` - the `StreamEscrowV2` the stream settles on. */
178
177
  readonly domain: InvocationCapabilityDomainInput;
179
178
  /** Preferred: a cryptographically verified result from discoverServices(). */
@@ -226,6 +225,10 @@ export async function invoke(
226
225
  stream.serviceRef,
227
226
  options,
228
227
  );
228
+ // Resolve BEFORE signing. A path that escapes the signed base must never
229
+ // cause a buyer-signed capability to exist at all, let alone be sent
230
+ // somewhere the operator did not sign.
231
+ const url = resolveInvocationUrl(serviceBaseUrl, request.path);
229
232
 
230
233
  const capability = await capabilityFor(streamId, request, {
231
234
  buyer: options.buyer,
@@ -239,10 +242,6 @@ export async function invoke(
239
242
  });
240
243
 
241
244
  const header = encodeCapabilityHeader(capability);
242
- const url = new URL(
243
- request.path,
244
- ensureTrailingSlash(serviceBaseUrl),
245
- ).toString();
246
245
  const fetchImpl = options.fetchImpl ?? fetch;
247
246
  const response = await fetchImpl(url, {
248
247
  method: request.method,
@@ -484,10 +483,89 @@ function requireHttpUrl(value: string, label: string): string {
484
483
  return parsed.toString();
485
484
  }
486
485
 
486
+ /**
487
+ * Characters that must never appear in an invocation path.
488
+ *
489
+ * The backslash is the important one. The WHATWG URL parser treats `\` as `/`
490
+ * for special schemes, so `/\evil.com/x` resolved against `https://good/`
491
+ * yields `https://evil.com/x` — a prefix check for `//` does not see it. C0
492
+ * controls and DEL are rejected alongside it because a header- or log-splitting
493
+ * character has no business in a path either.
494
+ */
495
+ const FORBIDDEN_PATH_CHARACTERS = /[\\\u0000-\u001f\u007f]/;
496
+
487
497
  function assertOriginRelativePath(path: string): void {
488
- if (!path.startsWith("/") || path.startsWith("//") || path.includes("#")) {
489
- throw new InvokePathError(path);
498
+ if (!path.startsWith("/") || path.startsWith("//")) {
499
+ throw new InvokePathError(
500
+ `invocation path must be origin-relative and start with one slash: ${JSON.stringify(path)}`,
501
+ );
502
+ }
503
+ if (path.includes("#")) {
504
+ throw new InvokePathError(
505
+ `invocation path must not contain a fragment: ${JSON.stringify(path)}`,
506
+ );
507
+ }
508
+ if (FORBIDDEN_PATH_CHARACTERS.test(path)) {
509
+ throw new InvokePathError(
510
+ `invocation path must not contain a backslash or a control character: ${JSON.stringify(path)}`,
511
+ );
512
+ }
513
+ }
514
+
515
+ /**
516
+ * Resolves `path` against the operator-signed base URL and PROVES the result
517
+ * did not leave it.
518
+ *
519
+ * Prefix checks on the raw path are not sufficient and never were: the URL
520
+ * parser decides where a string points, so the only trustworthy check is to
521
+ * resolve first and then compare what came out. `/\evil.com/x` and
522
+ * `/a/../../b` both look origin-relative and both leave the signed base.
523
+ *
524
+ * What escaping would cost, concretely: the buyer's signed EIP-712 capability
525
+ * is handed to an attacker-controlled host, the attacker's response is
526
+ * returned to the caller as if it were the paid service's answer, and the
527
+ * MCP host's own network becomes reachable from a tool argument.
528
+ */
529
+ export function resolveInvocationUrl(
530
+ serviceBaseUrl: string,
531
+ path: string,
532
+ ): string {
533
+ assertOriginRelativePath(path);
534
+ const base = new URL(ensureTrailingSlash(serviceBaseUrl));
535
+ let resolved: URL;
536
+ try {
537
+ resolved = new URL(path, base);
538
+ } catch {
539
+ throw new InvokePathError(
540
+ `invocation path does not resolve against the signed service URL: ${JSON.stringify(path)}`,
541
+ );
542
+ }
543
+ if (resolved.origin !== base.origin || resolved.origin === "null") {
544
+ throw new InvokePathError(
545
+ `invocation path resolves to ${resolved.origin}, which is not the operator-signed origin ${base.origin}`,
546
+ );
547
+ }
548
+ if (resolved.username !== "" || resolved.password !== "") {
549
+ throw new InvokePathError(
550
+ "invocation path must not carry credentials in the resolved URL",
551
+ );
552
+ }
553
+ // `base.pathname` always ends in `/` (ensureTrailingSlash), so a prefix match
554
+ // on the RESOLVED pathname is a true containment check — `..` segments have
555
+ // already been collapsed by the parser.
556
+ //
557
+ // An absolute-path reference replaces the whole path, so this ALSO rejects a
558
+ // signed base URL carrying a sub-path (`https://host/base/`). That is
559
+ // deliberate: the previous behaviour silently discarded the operator's signed
560
+ // `/base/` and called the origin root instead — an origin path the operator
561
+ // never signed, and one the gateway's own pathHash check could not match.
562
+ // Refusing says so instead of quietly calling the wrong place.
563
+ if (!resolved.pathname.startsWith(base.pathname)) {
564
+ throw new InvokePathError(
565
+ `invocation path ${JSON.stringify(path)} resolves to ${resolved.pathname}, which is outside the operator-signed base path ${base.pathname}. An origin-relative path replaces the whole path, so a signed base URL with a sub-path cannot be addressed this way.`,
566
+ );
490
567
  }
568
+ return resolved.toString();
491
569
  }
492
570
 
493
571
  function ensureTrailingSlash(value: string): string {