@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.
- package/README.md +107 -23
- package/dist/capability/invocation-capability.d.ts +18 -6
- package/dist/capability/invocation-capability.d.ts.map +1 -1
- package/dist/capability/invocation-capability.js.map +1 -1
- package/dist/cli/commands/claim.d.ts.map +1 -1
- package/dist/cli/commands/claim.js +20 -4
- package/dist/cli/commands/claim.js.map +1 -1
- package/dist/cli/commands/close.d.ts.map +1 -1
- package/dist/cli/commands/close.js +16 -4
- package/dist/cli/commands/close.js.map +1 -1
- package/dist/cli/commands/invoke.d.ts.map +1 -1
- package/dist/cli/commands/invoke.js +1 -0
- package/dist/cli/commands/invoke.js.map +1 -1
- package/dist/cli/commands/open.d.ts.map +1 -1
- package/dist/cli/commands/open.js +63 -11
- package/dist/cli/commands/open.js.map +1 -1
- package/dist/cli/commands/reclaim-unverified.d.ts.map +1 -1
- package/dist/cli/commands/reclaim-unverified.js +12 -4
- package/dist/cli/commands/reclaim-unverified.js.map +1 -1
- package/dist/cli/commands/reclaim.d.ts.map +1 -1
- package/dist/cli/commands/reclaim.js +16 -5
- package/dist/cli/commands/reclaim.js.map +1 -1
- package/dist/cli/commands/streams.js +1 -1
- package/dist/cli/commands/streams.js.map +1 -1
- package/dist/cli/deps.d.ts +53 -7
- package/dist/cli/deps.d.ts.map +1 -1
- package/dist/cli/deps.js +65 -15
- package/dist/cli/deps.js.map +1 -1
- package/dist/cli/privy.d.ts +83 -0
- package/dist/cli/privy.d.ts.map +1 -0
- package/dist/cli/privy.js +230 -0
- package/dist/cli/privy.js.map +1 -0
- package/dist/cli/spend-policy.d.ts +60 -0
- package/dist/cli/spend-policy.d.ts.map +1 -0
- package/dist/cli/spend-policy.js +65 -0
- package/dist/cli/spend-policy.js.map +1 -0
- package/dist/cli/wallet.d.ts +108 -39
- package/dist/cli/wallet.d.ts.map +1 -1
- package/dist/cli/wallet.js +108 -43
- package/dist/cli/wallet.js.map +1 -1
- package/dist/gateway/caller-auth-gateway.d.ts +38 -3
- package/dist/gateway/caller-auth-gateway.d.ts.map +1 -1
- package/dist/gateway/caller-auth-gateway.js +25 -1
- package/dist/gateway/caller-auth-gateway.js.map +1 -1
- package/dist/gateway/http-server.d.ts +53 -0
- package/dist/gateway/http-server.d.ts.map +1 -1
- package/dist/gateway/http-server.js +189 -4
- package/dist/gateway/http-server.js.map +1 -1
- package/dist/gateway/x402-mode.d.ts +123 -0
- package/dist/gateway/x402-mode.d.ts.map +1 -0
- package/dist/gateway/x402-mode.js +212 -0
- package/dist/gateway/x402-mode.js.map +1 -0
- package/dist/index.d.ts +6 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -3
- package/dist/index.js.map +1 -1
- package/dist/mandates/env.d.ts +3 -2
- package/dist/mandates/env.d.ts.map +1 -1
- package/dist/mandates/env.js +3 -2
- package/dist/mandates/env.js.map +1 -1
- package/dist/mcp/server.d.ts +49 -0
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +513 -24
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/service-invoker.d.ts +97 -0
- package/dist/mcp/service-invoker.d.ts.map +1 -0
- package/dist/mcp/service-invoker.js +167 -0
- package/dist/mcp/service-invoker.js.map +1 -0
- package/dist/sdk/invoke.d.ts +19 -4
- package/dist/sdk/invoke.d.ts.map +1 -1
- package/dist/sdk/invoke.js +69 -5
- package/dist/sdk/invoke.js.map +1 -1
- package/dist/x402/delivery-oracle.d.ts +108 -0
- package/dist/x402/delivery-oracle.d.ts.map +1 -0
- package/dist/x402/delivery-oracle.js +174 -0
- package/dist/x402/delivery-oracle.js.map +1 -0
- package/dist/x402/facilitator-server-entry.d.ts +3 -0
- package/dist/x402/facilitator-server-entry.d.ts.map +1 -0
- package/dist/x402/facilitator-server-entry.js +27 -0
- package/dist/x402/facilitator-server-entry.js.map +1 -0
- package/dist/x402/facilitator-server.d.ts +73 -0
- package/dist/x402/facilitator-server.d.ts.map +1 -0
- package/dist/x402/facilitator-server.js +305 -0
- package/dist/x402/facilitator-server.js.map +1 -0
- package/dist/x402/facilitator.d.ts +69 -17
- package/dist/x402/facilitator.d.ts.map +1 -1
- package/dist/x402/facilitator.js +76 -33
- package/dist/x402/facilitator.js.map +1 -1
- package/dist/x402/payment-requirements.d.ts +106 -0
- package/dist/x402/payment-requirements.d.ts.map +1 -0
- package/dist/x402/payment-requirements.js +138 -0
- package/dist/x402/payment-requirements.js.map +1 -0
- package/dist/x402/relayer.d.ts +41 -0
- package/dist/x402/relayer.d.ts.map +1 -0
- package/dist/x402/relayer.js +64 -0
- package/dist/x402/relayer.js.map +1 -0
- package/dist/x402/resource-index.d.ts +75 -0
- package/dist/x402/resource-index.d.ts.map +1 -0
- package/dist/x402/resource-index.js +126 -0
- package/dist/x402/resource-index.js.map +1 -0
- package/dist/x402/verified-delivery-facilitator.d.ts +285 -0
- package/dist/x402/verified-delivery-facilitator.d.ts.map +1 -0
- package/dist/x402/verified-delivery-facilitator.js +423 -0
- package/dist/x402/verified-delivery-facilitator.js.map +1 -0
- package/dist/zktls/t2-delivery-proof.d.ts +6 -6
- package/docs/agent-layer.md +12 -8
- package/docs/cli.md +158 -22
- package/docs/mcp-hosted.md +4 -3
- package/docs/mcp-reference.md +175 -0
- package/docs/quickstart.md +18 -4
- package/docs/threat-model.md +4 -3
- package/docs/x402.md +648 -0
- package/package.json +18 -7
- package/src/capability/invocation-capability.ts +17 -1
- package/src/cli/commands/claim.ts +30 -4
- package/src/cli/commands/close.ts +20 -4
- package/src/cli/commands/invoke.ts +1 -0
- package/src/cli/commands/open.ts +76 -10
- package/src/cli/commands/reclaim-unverified.ts +16 -4
- package/src/cli/commands/reclaim.ts +22 -5
- package/src/cli/commands/streams.ts +1 -1
- package/src/cli/deps.ts +115 -24
- package/src/cli/privy.ts +396 -0
- package/src/cli/spend-policy.ts +139 -0
- package/src/cli/wallet.ts +188 -57
- package/src/gateway/caller-auth-gateway.ts +77 -6
- package/src/gateway/http-server.ts +280 -6
- package/src/gateway/x402-mode.ts +350 -0
- package/src/index.ts +40 -5
- package/src/mandates/env.ts +3 -2
- package/src/mcp/server.ts +681 -29
- package/src/mcp/service-invoker.ts +285 -0
- package/src/sdk/invoke.ts +90 -12
- package/src/x402/delivery-oracle.ts +285 -0
- package/src/x402/facilitator-server-entry.ts +35 -0
- package/src/x402/facilitator-server.ts +445 -0
- package/src/x402/facilitator.ts +125 -46
- package/src/x402/payment-requirements.ts +231 -0
- package/src/x402/relayer.ts +122 -0
- package/src/x402/resource-index.ts +228 -0
- 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(
|
|
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:
|
|
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:
|
|
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("//")
|
|
489
|
-
throw new InvokePathError(
|
|
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 {
|