Setting the file. One moment.
X402 Fetch · Agents Pay · aws/agent-toolkit-for-aws · Skills Docs
Repo No. 14 · Agents Pay
↖ Back to the coverEnd User Computing Skills
Messaging And Streaming Skills
Migration And Modernization Skills
Networking And Content Delivery Skills
Security And Identity Skills
Web And Mobile Development
132 skills · 818 min
ContentsBack to the top of the page 70
Creating Amazon Aurora Db Cluster With Instances
93
Routing Traffic With Route53 And CloudFront
Resilience Program Design
Creating API Gateway Stage
def _purge_expired
— line 405
This file
Number 21.11
Position 11 of 14
Type Python
Size 34 KB
Lines 740 scripts/ x402_fetch.py
Python · 740 lines · 34 KB
15
Configuration
16 -------------
17 Resource identifiers and the payment policy come from one operator-owned file,
18 ~/.agents-pay/config.json. The runtime binds this path to the operating-system
19 account and ignores HOME, AGENTS_PAY_CONFIG, and X402_POLICY_FILE. Administrative
20 commands may use --path or AGENTS_PAY_CONFIG explicitly. The file holds no provider
21 credentials, only ARNs and IDs plus the limits. See x402_policy for its shape.
22
23 The config file takes precedence over the environment for every identifier. That
24 ordering is deliberate: the session ID names the budget being drawn down, so if a
25 variable could override the 0600 file, anything able to set that variable could
26 redirect spending to a larger session. The environment remains a fallback for
27 container and Lambda deployments with no writable home — a weaker mode, because
28 whatever sets the environment there chooses the session.
29
30 Region/resource-resolution fixes (see agents_pay_admin.py for the admin-side
31 half of this batch: resolve_region() and resolve_manager_arn() there fix the
32 same pattern for the admin CLI's config.json lookups)
33 -------------------------------------------------------------------------------
34 Three call sites in this file used to build PaymentManager with
35 `region_name=pol.resolve_resource(policy, "region") or "us-west-2"`. That
36 hardcoded fallback only fires when nothing configures a region anywhere — a
37 normal state for a deployment that relies on its AWS profile/IMDS region rather
38 than setting one explicitly — and PaymentManager already falls back to
39 boto3.Session().region_name internally before its own "us-west-2" default. So
40 forcing "us-west-2" here could send a real payment against the wrong AWS region
41 and fail with a confusing manager-not-found error. Fixed by passing
42 pol.resolve_resource(policy, "region") (or None) and letting boto3/PaymentManager
43 resolve it themselves. See payment_session_status(), prepare_browser_payment(),
44 and x402_fetch() below.
45
46 A related fix in x402_policy.derive_client_token() closes a second instance of
47 the same pattern: it read PAYMENT_SESSION_ID from the environment directly
48 whenever a caller omitted session_id, bypassing resolve_resource()'s documented
49 config-file-first precedence for a spending credential. See its docstring.
50
51 Tunables (behaviour only, never identifiers):
52
53 X402_MAX_BODY_BYTES response cap (default 262144, clamped 1 KiB - 64 MiB)
54 X402_TIMEOUT_SECONDS per-request timeout (default 20, clamped 1 - 120)
55
56 This module deliberately does NOT provide session creation or infrastructure
57 setup. Those are separate trusted paths with separate credentials, so a
58 compromised agent cannot mint budget or create payment resources.
59 """
60
61 from __future__ import annotations
62
63 import hashlib
64 import json
65 import os
66 import secrets
67 import socket
68 import ssl
69 import time
70 from typing import Any
71 from urllib.parse import urlparse
72
73 import httpx
74
75 import x402_policy as pol
76
77 AGENT_NAME = "openclaw-aws-agents-pay"
78
79
80 def _bounded_env (name: str , default: float , minimum: float , maximum: float ) -> float :
81 """Read a numeric tunable, ignoring values that are invalid or out of range.
82
83 Parsed defensively at import: a malformed value raising here would escape
84 every handler and break the contract that x402_fetch never raises into the
85 agent loop. A zero or negative limit would also silently disable the control,
86 so values are clamped rather than trusted.
87 """
88 raw = os.getenv(name)
89 if raw is None :
90 return default
91 try :
92 value = float (raw)
93 except ( TypeError , ValueError ):
94 return default
95 if value < minimum or value > maximum:
96 return default
97 return value
98
99
100 # Cap between 1 KiB and 64 MiB; a 0/negative/garbage value falls back to default.
101 MAX_BODY_BYTES = int (_bounded_env( "X402_MAX_BODY_BYTES" , 256 * 1024 , 1024 , 64 * 1024 * 1024 ))
102 TIMEOUT_SECONDS = _bounded_env( "X402_TIMEOUT_SECONDS" , 20.0 , 1.0 , 120.0 )
103 # Transient on-chain settlement can leave a paid retry at 402; replay the SAME
104 # authorization up to this many times. Bounded so a bad value cannot loop forever.
105 MAX_PAYMENT_ATTEMPTS = int (_bounded_env( "X402_MAX_PAYMENT_ATTEMPTS" , 5 , 1 , 10 ))
106
107 class PaymentBlocked ( Exception ):
108 """Raised when trusted code refuses to pay or to fetch."""
109
110
111 def _require_resource (policy: dict , key: str ) -> str :
112 """Resolve a required resource identifier, config file first then environment.
113
114 Config wins over the environment on purpose: the session ID names the budget
115 being spent, so if a variable could override the 0600 file, anything able to
116 set that variable could redirect spending to a larger session.
117 """
118 value = pol.resolve_resource(policy, key)
119 if not value:
120 env = pol. RESOURCE_ENV .get(key, key.upper())
121 raise PaymentBlocked(
122 f "Missing payment resource ' { key } '. Add it to the config "
123 f "(agents_pay_admin.py init-config) or set { env } . Provision resources "
124 "with the trusted setup path first."
125 )
126 return value
127
128
129 def _ssl_context () -> ssl.SSLContext:
130 """Strict TLS context: hostname checking and chain verification always on.
131
132 Prefers certifi's CA bundle when present, because some hosts (including
133 mise-managed Pythons) have no system CA file and would otherwise fail
134 verification. Verification is never disabled — if no bundle is available the
135 request fails closed rather than proceeding unverified.
136 """
137 try :
138 import certifi
139
140 ctx = ssl.create_default_context( cafile = certifi.where())
141 except ImportError :
142 ctx = ssl.create_default_context()
143 ctx.check_hostname = True
144 ctx.verify_mode = ssl. CERT_REQUIRED
145 return ctx
146
147
148 class _PinnedResolverTransport ( httpx . HTTPTransport ):
149 """HTTP transport that only ever connects to a pre-vetted IP address.
150
151 Closes the DNS-rebinding window. Validating a hostname and then letting the
152 HTTP client resolve it again independently leaves a TOCTOU gap: the second
153 lookup can return an internal address. Here resolution happens once, every
154 answer is vetted, and the socket is opened directly to a vetted address.
155
156 Implemented by overriding the connection pool's socket creation rather than
157 by patching `socket.getaddrinfo`. Patching that global is not thread-safe —
158 two concurrent fetches to different hosts can restore or observe each
159 other's state, and a request can end up resolving *unpinned*, which silently
160 reopens the very window this class exists to close.
161
162 The URL keeps its real hostname, so SNI and certificate verification are
163 unaffected; only the dialed address is fixed. (Rewriting the URL to a bare
164 IP would break certificate validation.)
165 """
166
167 def __init__ (self, pinned_ip: str , ** kwargs):
168 super (). __init__ ( ** kwargs)
169 self ._pinned_ip = pinned_ip
170 pool = self ._pool # httpcore connection pool
171 original_factory = pool._network_backend
172
173 class _PinnedBackend :
174 """Delegates to the real backend, forcing the destination address."""
175
176 def __init__ (self, backend, pinned: str ):
177 self ._backend = backend
178 self ._pinned = pinned
179
180 def connect_tcp (self, host, port, timeout = None , local_address = None , socket_options = None ):
181 # Re-vet at connect time; the pin was vetted at resolve time too.
182 pol.assert_public_ip( self ._pinned)
183 return self ._backend.connect_tcp(
184 self ._pinned, port, timeout = timeout,
185 local_address = local_address, socket_options = socket_options,
186 )
187
188 def __getattr__ (self, name):
189 return getattr ( self ._backend, name)
190
191 pool._network_backend = _PinnedBackend(original_factory, pinned_ip)
192
193
194 def _resolve_and_vet (hostname: str , port: int ) -> str :
195 """Resolve once, reject if ANY answer is internal, return the address to pin."""
196 try :
197 infos = socket.getaddrinfo(hostname, port, proto = socket. IPPROTO_TCP )
198 except socket.gaierror:
199 raise PaymentBlocked( "Payment URL hostname does not resolve." ) from None
200 if not infos:
201 raise PaymentBlocked( "Payment URL hostname does not resolve." )
202 for info in infos:
203 pol.assert_public_ip(info[ 4 ][ 0 ])
204 return infos[ 0 ][ 4 ][ 0 ]
205
206
207 #: Only side-effect-free verbs. A body-bearing request (POST/PUT/PATCH) would let the
208 #: agent push agent-chosen data to an arbitrary origin, which widens the very
209 #: exfiltration surface, and the policy gate validates the URL, not a request body.
210 #: Paid *retrieval* is what this skill is for.
211 _ALLOWED_METHODS = ( "GET" , "HEAD" )
212
213
214 def _get (url: str , headers: dict[ str , str ] | None = None , method: str = "GET" ) -> httpx.Response:
215 """One hardened HTTPS request: no redirects, pinned address, bounded body."""
216 method = method.upper()
217 if method not in _ALLOWED_METHODS :
218 raise PaymentBlocked(
219 f "Method { method } is not allowed; this skill fetches paid content with "
220 f " { ' or ' .join( _ALLOWED_METHODS ) } . A request body would let the agent send "
221 "data to an arbitrary origin, which the policy gate does not validate."
222 )
223 parsed = urlparse(url)
224 host, port = parsed.hostname or "" , parsed.port or 443
225 pinned_ip = _resolve_and_vet(host, port)
226
227 transport = _PinnedResolverTransport(pinned_ip, verify = _ssl_context(), retries = 0 )
228
229 request_headers = {
230 "Accept" : "application/json, text/plain" ,
231 # Refuse compressed responses. httpx advertises gzip/deflate by default
232 # and iter_bytes() yields DECOMPRESSED bytes, so a small compressed body
233 # can expand by ~1000x and blow past the size cap before it is checked
234 # (a decompression bomb against a payment-capable process).
235 "Accept-Encoding" : "identity" ,
236 }
237 if headers:
238 request_headers.update(headers)
239
240 with httpx.Client(
241 transport = transport,
242 timeout = httpx.Timeout( TIMEOUT_SECONDS , connect = min ( 5.0 , TIMEOUT_SECONDS )),
243 follow_redirects = False , # a 30x is surfaced, never auto-followed
244 max_redirects = 0 ,
245 ) as client:
246 with client.stream(method, url, headers = request_headers) as response:
247 # Reject an oversized body before reading any of it, when declared.
248 declared = response.headers.get( "content-length" )
249 if declared and declared.isdigit() and int (declared) > MAX_BODY_BYTES :
250 raise PaymentBlocked(
251 f "Response declares { declared } bytes, over the { MAX_BODY_BYTES } limit."
252 )
253
254 body = bytearray ()
255 # Check BEFORE extending, so the buffer can never exceed the cap even
256 # if a single chunk arrives larger than expected (e.g. a decompressing
257 # transport). Small chunks also keep peak memory close to the cap.
258 for chunk in response.iter_bytes( chunk_size = 8192 ):
259 if len (body) + len (chunk) > MAX_BODY_BYTES :
260 raise PaymentBlocked(
261 f "Response exceeded { MAX_BODY_BYTES } bytes; refusing to buffer more."
262 )
263 body.extend(chunk)
264 response.read_body = bytes (body) # type: ignore[attr-defined]
265 return response
266
267
268 def _body_text (response: httpx.Response) -> str :
269 raw: bytes = getattr (response, "read_body" , b "" )
270 return raw.decode(response.encoding or "utf-8" , errors = "replace" )
271
272
273 def _safe_content (response: httpx.Response, policy: dict | None = None ) -> dict[ str , Any]:
274 """Return metadata about paid content; optionally return the body.
275
276 By default, paid publisher content is withheld from model context as a
277 security control (it may contain prompt injection). Operators can opt in
278 to body return by setting `return_body: true` in the policy section of
279 the config file (~/.agents-pay/config.json). The config file is bound to
280 the OS account (0600) and cannot be set by the model at runtime.
281 """
282 content_type = (response.headers.get( "content-type" ) or "" ).split( ";" )[ 0 ].strip().lower()
283 raw: bytes = getattr (response, "read_body" , b "" )
284 result: dict[ str , Any] = {
285 "content_type" : content_type or "unknown" ,
286 "body_sha256" : hashlib.sha256(raw).hexdigest(),
287 "bytes" : len (raw),
288 }
289 return_body = (policy or {}).get( "return_body" ) is True
290 if return_body:
291 result[ "body_returned" ] = True
292 result[ "body" ] = raw.decode( "utf-8" , errors = "replace" )[: 10240 ]
293 result[ "truncated" ] = len (raw) > 10240
294 result[ "untrusted" ] = True
295 else :
296 result[ "body_returned" ] = False
297 result[ "note" ] = (
298 "Body withheld: paid publisher content is untrusted and must not enter "
299 "the payment-capable model context. Use a separate no-payment/no-network "
300 "analysis context if summarisation is required. To opt in, set "
301 "'return_body: true' in the policy section of the config file."
302 )
303 return result
304
305
306 def payment_session_status () -> str :
307 """Report whether the configured payment session can currently be spent.
308
309 Read-only: it cannot create, extend, or fund anything. Exposing this to the
310 model is safe and useful — it lets an agent say "the budget is exhausted, ask
311 your operator" instead of discovering it as a failed payment mid-task.
312
313 Returns JSON with a `usable` boolean. When false, `next_step` says what a
314 HUMAN must do; the agent has no way to remedy it itself.
315 """
316 try :
317 try :
318 policy = pol.load_config()
319 except pol.PolicyError:
320 policy = { "_resources" : {}} # no config: fall back to the environment
321 session_id = pol.resolve_resource(policy, "payment_session_id" )
322 manager_arn = pol.resolve_resource(policy, "payment_manager_arn" )
323 user_id = pol.resolve_resource(policy, "user_id" )
324 if not session_id or not manager_arn:
325 return json.dumps(
326 {
327 "usable" : False ,
328 "reason" : "No payment session is configured." ,
329 "next_step" : (
330 "An operator must run: agents_pay_admin.py new-session "
331 "(requires a human at a terminal)."
332 ),
333 }
334 )
335
336 try :
337 from bedrock_agentcore.payments import PaymentManager
338 except ImportError :
339 return json.dumps(
340 {
341 "usable" : False ,
342 "reason" : "bedrock-agentcore payments support is not installed." ,
343 "next_step" : "Install bedrock-agentcore>=1.19.0." ,
344 }
345 )
346
347 manager = PaymentManager(
348 payment_manager_arn = manager_arn,
349 # Region resolution here must match agents_pay_admin.py's resolve_region():
350 # config.json's resources.region, else AWS_REGION, else let boto3/PaymentManager
351 # resolve it (profile, IMDS, etc.) themselves. A hardcoded "us-west-2" fallback
352 # would silently override a correctly-resolved region whenever the operator's
353 # deployment lives elsewhere and neither config nor env sets one explicitly —
354 # PaymentManager itself already falls back to boto3.Session().region_name before
355 # its own "us-west-2" default, so passing None here is safe and correct.
356 region_name = pol.resolve_resource(policy, "region" ),
357 agent_name = AGENT_NAME ,
358 )
359 session = manager.get_payment_session( payment_session_id = session_id, user_id = user_id)
360
361 # Field names vary across SDK revisions; read defensively and report only
362 # what we can positively confirm rather than guessing a usable=True.
363 status = str (session.get( "status" ) or session.get( "sessionStatus" ) or "" ).upper()
364 limits = session.get( "limits" ) or {}
365 spend_cap = (limits.get( "maxSpendAmount" ) or {}).get( "value" )
366 spent = (session.get( "spentAmount" ) or {}).get( "value" )
367
368 usable = status in ( "ACTIVE" , "READY" )
369 result: dict[ str , Any] = { "usable" : usable, "status" : status or "unknown" }
370 if spend_cap is not None :
371 result[ "budget_usd" ] = str (spend_cap)
372 if spent is not None :
373 result[ "spent_usd" ] = str (spent)
374 if not usable:
375 result[ "next_step" ] = (
376 "An operator must run: agents_pay_admin.py new-session "
377 "(requires a human at a terminal). The agent cannot mint budget."
378 )
379 return json.dumps(result)
380
381 except Exception as e: # noqa: BLE001 - never raise into the agent loop
382 return json.dumps(
383 {
384 "usable" : False ,
385 "reason" : f " { type (e). __name__ } while reading session status." ,
386 "next_step" : "An operator must check PAYMENT_SESSION_ID and AWS credentials." ,
387 }
388 )
389
390
391 # --- Browser path: opaque handles instead of raw proofs -----------------------
392 #
393 # Some paid resources must render in a real browser, so the payment proof has to
394 # be attached to a navigation the agent controls. Handing the proof to the model
395 # must not expose the proof to the model, so it never leaves this process. It is held
396 # here and referenced by a single-use handle bound to one origin and resource.
397 #
398 # The handle is useless to an attacker who reads the transcript — it is not a
399 # credential, cannot be replayed elsewhere, and expires.
400
401 _PROOF_VAULT : dict[ str , dict[ str , Any]] = {}
402 _HANDLE_TTL_SECONDS = 90.0
403
404
405 def _purge_expired (now: float ) -> None :
406 for handle in [h for h, e in _PROOF_VAULT .items() if e[ "expires_at" ] <= now]:
407 entry = _PROOF_VAULT .pop(handle, None )
408 if entry:
409 entry[ "header" ].clear()
410
411
412 def prepare_browser_payment (url: str , purchase_id: str | None = None ) -> str :
413 """Pay for a URL and return an OPAQUE HANDLE for a browser to replay.
414
415 Same trusted pipeline as `x402_fetch` — policy gate, SSRF checks, strict
416 challenge parsing, derived idempotency — but instead of fetching the content
417 it retains the signed proof in-process and returns a handle.
418
419 The model receives the handle and a redacted receipt. It never sees the proof.
420 Pass the handle to `attach_browser_payment()` at navigation time.
421 """
422 try :
423 policy = pol.load_config()
424 origin = pol.assert_public_https_url(url)
425 # Origin pinning is optional, so an empty allowed_origins list permits public
426 # HTTPS origins. The mandatory SSRF controls above and below still apply.
427 allowed = [o.lower().rstrip( "/" ) for o in (policy.get( "allowed_origins" ) or [])]
428 if allowed and origin.lower() not in allowed:
429 raise PaymentBlocked( f "Origin { origin } is not in the configured allowed_origins." )
430
431 response = _get(url)
432 if response.status_code != 402 :
433 return json.dumps(
434 {
435 "paid" : False ,
436 "refused" : True ,
437 "reason" : f "URL returned { response.status_code } , not 402. No payment needed." ,
438 }
439 )
440
441 challenge = _extract_challenge(response)
442 decision = pol.authorize_payment(url, challenge, policy, purchase_id)
443
444 manager_arn = _require_resource(policy, "payment_manager_arn" )
445 instrument_id = _require_resource(policy, "payment_instrument_id" )
446 session_id = _require_resource(policy, "payment_session_id" )
447 user_id = _require_resource(policy, "user_id" )
448
449 try :
450 from bedrock_agentcore.payments import PaymentManager
451 except ImportError :
452 raise PaymentBlocked(
453 "bedrock-agentcore with payments support is not installed."
454 ) from None
455
456 manager = PaymentManager(
457 payment_manager_arn = manager_arn,
458 # See the resolve_region()-equivalent rationale in payment_session_status():
459 # config.json/env first, else let boto3 resolve region itself rather than
460 # forcing "us-west-2" and risking a manager-not-found against an ARN that
461 # actually lives in the operator's real deployment region.
462 region_name = pol.resolve_resource(policy, "region" ),
463 agent_name = AGENT_NAME ,
464 )
465
466 # Only the vetted entry reaches the signer — resource is included only
467 # after URL-binding validation in the policy gate (see _validated_resource).
468 vetted = { "x402Version" : decision[ "x402_version" ], "accepts" : [decision[ "accept" ]]}
469 if decision.get( "resource" ):
470 vetted[ "resource" ] = decision[ "resource" ]
471 vetted_challenge = json.dumps(vetted)
472 payment_header = manager.generate_payment_header(
473 payment_instrument_id = instrument_id,
474 payment_session_id = session_id,
475 user_id = user_id,
476 client_token = decision[ "client_token" ],
477 payment_required_request = {
478 "statusCode" : 402 ,
479 "headers" : { "content-type" : "application/json" },
480 "body" : vetted_challenge,
481 },
482 )
483 if not isinstance (payment_header, dict ):
484 raise PaymentBlocked( "Payment header generation returned an unexpected shape." )
485
486 now = time.monotonic()
487 _purge_expired(now)
488 handle = "x402h_" + secrets.token_urlsafe( 24 )
489 _PROOF_VAULT [handle] = {
490 "header" : payment_header, # stays here; never returned
491 "origin" : decision[ "origin" ],
492 "path" : urlparse(url).path or "/" ,
493 "expires_at" : now + _HANDLE_TTL_SECONDS ,
494 }
495
496 return json.dumps(
497 {
498 "paid" : True ,
499 "handle" : handle,
500 "expires_in_seconds" : int ( _HANDLE_TTL_SECONDS ),
501 "receipt" : {
502 "amount_usd" : decision[ "amount_usd" ],
503 "network" : decision[ "accept" ][ "network" ],
504 "resource" : f " { decision[ 'origin' ] }{ urlparse(url).path } " ,
505 },
506 "next_step" : (
507 "Call attach_browser_payment(handle, url) to get the header to set on "
508 "the browser, then navigate. The handle is single-use and expires."
509 ),
510 }
511 )
512
513 except (pol.PolicyError, PaymentBlocked) as e:
514 return json.dumps({ "paid" : False , "refused" : True , "reason" : str (e)})
515 except Exception as e: # noqa: BLE001
516 return json.dumps(
517 { "paid" : False , "refused" : True , "reason" : f " { type (e). __name__ } during payment flow." }
518 )
519
520
521 def attach_browser_payment (handle: str , url: str ) -> dict[ str , str ]:
522 """Redeem a handle for the header to set on a browser, for ONE navigation.
523
524 Returns the actual `{header: value}` mapping, so this is the one point where
525 the proof becomes visible to the caller. Give it to browser-driving code, not
526 to the model: a framework should register `prepare_browser_payment` as the
527 model-facing tool and call this from trusted glue at navigation time.
528
529 Single-use and origin+path bound: a handle stolen from a transcript cannot be
530 redeemed for a different resource, and cannot be redeemed twice.
531 """
532 now = time.monotonic()
533 _purge_expired(now)
534
535 entry = _PROOF_VAULT .get(handle)
536 if entry is None :
537 raise PaymentBlocked( "Unknown, already-used, or expired payment handle." )
538
539 # Bind redemption to the exact resource the payment was authorized for.
540 if pol._canonical_origin(url).lower() != entry[ "origin" ].lower():
541 raise PaymentBlocked( "Handle does not match this origin." )
542 if (urlparse(url).path or "/" ) != entry[ "path" ]:
543 raise PaymentBlocked( "Handle does not match this resource path." )
544
545 _PROOF_VAULT .pop(handle, None ) # single use: consumed on redemption
546 header = entry[ "header" ]
547 return dict (header)
548
549
550 def _extract_challenge (response: httpx.Response) -> dict :
551 """Parse an x402 challenge from the `payment-required` header or the body.
552
553 Strict: a challenge must be a JSON object carrying `accepts`, with no default
554 version fallback or tolerance for merely plausible shapes.
555 """
556 import base64
557
558 header = response.headers.get( "payment-required" ) or response.headers.get( "x-payment-required" )
559 if header:
560 for decode in ( lambda h: base64.b64decode(h, validate = True ).decode( "utf-8" ), lambda h: h):
561 try :
562 parsed = json.loads(decode(header))
563 if isinstance (parsed, dict ) and parsed.get( "accepts" ):
564 return parsed
565 except Exception : # noqa: BLE001 - try the next decoding strategy
566 continue
567
568 try :
569 parsed = json.loads(_body_text(response))
570 except json.JSONDecodeError:
571 raise PaymentBlocked( "402 response contains no parseable x402 challenge." ) from None
572
573 if isinstance (parsed, dict ):
574 if parsed.get( "accepts" ):
575 return parsed
576 inner = parsed.get( "challenge" )
577 if isinstance (inner, dict ) and inner.get( "accepts" ):
578 return inner
579 raise PaymentBlocked( "402 challenge is missing the required 'accepts' array." )
580
581
582 def x402_fetch (url: str , purchase_id: str | None = None , method: str = "GET" ) -> str :
583 """Fetch an x402-protected URL, paying only if trusted policy allows it.
584
585 Returns a JSON string. On success it contains status, response metadata,
586 and a redacted payment receipt (amount, network, resource) — never the
587 signed proof, credential, or transaction signature. The paid body is
588 returned only when `return_body: true` is set in the operator's config
589 file; otherwise only content type, byte count, and SHA-256 hash are
590 included.
591 """
592 try :
593 # Pre-flight: load policy and clear the origin BEFORE any network I/O, so
594 # an unapproved host is never contacted at all. Checking only after the
595 # probe would still leave the probe itself as an SSRF primitive.
596 policy = pol.load_config()
597 origin = pol.assert_public_https_url(url)
598 # Origin pinning is optional, so an empty allowed_origins list permits public
599 # HTTPS origins. The mandatory SSRF controls above and below still apply.
600 allowed = [o.lower().rstrip( "/" ) for o in (policy.get( "allowed_origins" ) or [])]
601 if allowed and origin.lower() not in allowed:
602 raise PaymentBlocked( f "Origin { origin } is not in the configured allowed_origins." )
603
604 response = _get(url, method = method)
605
606 if response.status_code != 402 :
607 return json.dumps(
608 {
609 "status_code" : response.status_code,
610 "paid" : False ,
611 "untrusted" : True ,
612 ** _safe_content(response, policy),
613 }
614 )
615
616 # --- 402 path: every decision below is made in trusted code. ---
617 challenge = _extract_challenge(response)
618 # Re-runs the destination and origin checks, then validates the challenge
619 # (scheme, network, asset, recipient, amount) against the same policy.
620 decision = pol.authorize_payment(url, challenge, policy, purchase_id) # raises to refuse
621
622 manager_arn = _require_resource(policy, "payment_manager_arn" )
623 instrument_id = _require_resource(policy, "payment_instrument_id" )
624 session_id = _require_resource(policy, "payment_session_id" )
625 user_id = _require_resource(policy, "user_id" )
626
627 try :
628 from bedrock_agentcore.payments import PaymentManager
629 except ImportError :
630 raise PaymentBlocked(
631 "bedrock-agentcore with payments support is not installed "
632 "(needs a version providing bedrock_agentcore.payments)."
633 ) from None
634
635 manager = PaymentManager(
636 payment_manager_arn = manager_arn,
637 # Same fix as the two call sites above and agents_pay_admin.py's
638 # resolve_region(): never force "us-west-2" over a region that config.json,
639 # the environment, or boto3's own session/profile resolution already has
640 # right — doing so on this signing path risked a real payment attempt
641 # failing with a confusing manager-not-found instead of succeeding.
642 region_name = pol.resolve_resource(policy, "region" ),
643 agent_name = AGENT_NAME ,
644 )
645
646 # CRITICAL: hand the signer ONLY the entry the policy approved.
647 #
648 # Forwarding the publisher's raw 402 (its headers and body) would mean
649 # validating one document and signing another: the challenge can carry
650 # several accepts entries, or a compliant header alongside a hostile
651 # body, so the SDK could settle terms the gate never saw. The vetted
652 # entry is reserialized into a single-entry challenge; the resource
653 # object is included only after URL-binding validation in the policy
654 # gate (see _validated_resource).
655 vetted = { "x402Version" : decision[ "x402_version" ], "accepts" : [decision[ "accept" ]]}
656 if decision.get( "resource" ):
657 vetted[ "resource" ] = decision[ "resource" ]
658 vetted_challenge = json.dumps(vetted)
659
660 # Settle and replay, retrying a TRANSIENT post-payment 402.
661 #
662 # On Base Sepolia the proof is often valid while on-chain settlement lags, so
663 # the paid retry still returns 402. Without a retry that surfaces as a failed
664 # fetch for a payment the user already made. The SDK only builds the header —
665 # it does not make the merchant call — so the retry has to live here.
666 #
667 # Safe because the SAME derived client_token is reused for every attempt:
668 # ProcessPayment is idempotent on it, so each attempt replays one
669 # authorization/nonce. A retry either settles the not-yet-settled payment or,
670 # if it had already settled, reverts on-chain. It cannot charge twice.
671 paid_response = None
672 for attempt in range ( 1 , MAX_PAYMENT_ATTEMPTS + 1 ):
673 # The proof lives only in this local variable, for the length of one
674 # request. It is never returned to the caller and never logged. The
675 # `finally` guarantees it is dropped even if the paid request raises —
676 # a bare `del` after the call would be skipped on the exception path.
677 payment_header = None
678 try :
679 payment_header = manager.generate_payment_header(
680 payment_instrument_id = instrument_id,
681 payment_session_id = session_id,
682 user_id = user_id,
683 client_token = decision[ "client_token" ], # stable => retry-safe
684 payment_required_request = {
685 "statusCode" : 402 ,
686 "headers" : { "content-type" : "application/json" },
687 "body" : vetted_challenge,
688 },
689 )
690 if not isinstance (payment_header, dict ):
691 # Defensive: the SDK contract is a {header: value} mapping. Bail
692 # out rather than passing an unknown shape to the HTTP layer.
693 raise PaymentBlocked( "Payment header generation returned an unexpected shape." )
694 paid_response = _get(url, headers = payment_header, method = method)
695 finally :
696 if isinstance (payment_header, dict ):
697 payment_header.clear() # overwrite the mapping, then drop it
698 del payment_header
699
700 if paid_response.status_code != 402 :
701 break # 2xx, or a non-transient error worth surfacing as-is
702
703 if paid_response is not None and paid_response.status_code == 402 :
704 return json.dumps(
705 {
706 "paid" : False ,
707 "refused" : False ,
708 "status_code" : 402 ,
709 "attempts" : MAX_PAYMENT_ATTEMPTS ,
710 "reason" : (
711 f "Paid and replayed { MAX_PAYMENT_ATTEMPTS } times but the merchant "
712 "still returns 402 — usually transient on-chain settlement. The same "
713 "authorization was replayed each time, so there is no double charge. "
714 "Retry shortly, or raise X402_MAX_PAYMENT_ATTEMPTS."
715 ),
716 }
717 )
718
719 return json.dumps(
720 {
721 "status_code" : paid_response.status_code,
722 "paid" : 200 <= paid_response.status_code < 300 ,
723 "untrusted" : True ,
724 "receipt" : { # redacted by construction
725 "amount_usd" : decision[ "amount_usd" ],
726 "network" : decision[ "accept" ][ "network" ],
727 "resource" : f " { decision[ 'origin' ] }{ urlparse(url).path } " ,
728 },
729 ** _safe_content(paid_response, policy),
730 }
731 )
732
733 except (pol.PolicyError, PaymentBlocked) as e:
734 # Refusals are safe to surface: they carry no challenge values or secrets.
735 return json.dumps({ "paid" : False , "refused" : True , "reason" : str (e)})
736 except Exception as e: # noqa: BLE001
737 # Never let a raw exception escape — SDK errors can embed request detail.
738 return json.dumps(
739 { "paid" : False , "refused" : True , "reason" : f " { type (e). __name__ } during payment flow." }
740 )