From 2a953343a856d51042e6c83f6ebc4b11af29366c Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 22 May 2026 02:40:20 +0000 Subject: [PATCH 1/5] fix(payments): drop unsupported paymentConnectorId from ProcessPayment call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProcessPayment's public API contract (bedrock-agentcore Data Plane) does not accept a paymentConnectorId field — the connector is resolved server side from the payment instrument. The SDK was forwarding the param, causing every real ProcessPayment call to fail at the boto3 layer with: Parameter validation failed: Unknown parameter in input: "paymentConnectorId", must be one of: userId, agentName, paymentManagerArn, paymentSessionId, paymentInstrumentId, paymentType, paymentInput, clientToken The bug surfaced end-to-end when AgentCorePaymentsPlugin auto-paid a 402 response: get_payment_instrument succeeded, then generate_payment_header forwarded payment_connector_id into process_payment, which appended it to the API call and was rejected. Unit tests passed because they mock the boto3 client and don't validate the param schema. Fix: stop including paymentConnectorId in the ProcessPayment request body, and stop forwarding it from generate_payment_header. The kwarg is kept on both function signatures for backward compatibility, with docstrings updated to note that it is accepted but no longer forwarded. Adds a regression test asserting paymentConnectorId is absent from the ProcessPayment kwargs even when callers pass it explicitly. --- src/bedrock_agentcore/payments/manager.py | 39 +++++++++++-------- .../payments/test_payment_manager.py | 32 +++++++++++++++ 2 files changed, 55 insertions(+), 16 deletions(-) diff --git a/src/bedrock_agentcore/payments/manager.py b/src/bedrock_agentcore/payments/manager.py index 0b968b55..47b79702 100644 --- a/src/bedrock_agentcore/payments/manager.py +++ b/src/bedrock_agentcore/payments/manager.py @@ -844,7 +844,10 @@ def process_payment( payment_input: Payment input details specific to the payment type user_id: Unique identifier for the user (optional, omitted for bearer auth) client_token: Optional idempotency token for request uniqueness - payment_connector_id: Optional payment connector ID to route the payment + payment_connector_id: Accepted for backward compatibility but no longer + forwarded to the service. ProcessPayment derives the connector from + the payment instrument; sending paymentConnectorId on this call was + rejected by the API as an unknown parameter. Returns: Dictionary containing processPaymentId and transaction details @@ -873,8 +876,9 @@ def process_payment( "paymentInput": payment_input, "clientToken": client_token, } - if payment_connector_id is not None: - params["paymentConnectorId"] = payment_connector_id + # paymentConnectorId is intentionally NOT included — the ProcessPayment + # API does not accept it and rejects requests that contain it. The + # connector is resolved server-side from the payment instrument. result = self._payment_client.process_payment(**params) logger.info("Successfully processed payment for user %s", user_id) @@ -935,7 +939,10 @@ def generate_payment_header( network_preferences: Optional list of network identifiers in order of preference. If not provided, defaults to NETWORK_PREFERENCES from constants. client_token: Optional unique token for idempotency. If not provided, a new one is generated. - payment_connector_id: Optional payment connector ID to pass to process_payment. + payment_connector_id: Accepted for backward compatibility but no longer + forwarded to process_payment. ProcessPayment derives the connector + from the payment instrument; sending paymentConnectorId on that call + was rejected by the API as an unknown parameter. Returns: Dictionary with header name and value (e.g., {"X-PAYMENT": "base64..."} or @@ -1045,18 +1052,18 @@ def generate_payment_header( } } - process_payment_params = { - "user_id": user_id, - "payment_session_id": payment_session_id, - "payment_instrument_id": payment_instrument_id, - "payment_type": "CRYPTO_X402", - "payment_input": payment_input, - "client_token": client_token, - } - if payment_connector_id is not None: - process_payment_params["payment_connector_id"] = payment_connector_id - - payment_result = self.process_payment(**process_payment_params) + # ProcessPayment does not accept paymentConnectorId — the connector is + # resolved server-side from the payment instrument. The argument is + # intentionally not forwarded, even when callers (e.g. plugins) supply + # it via plugin config. + payment_result = self.process_payment( + user_id=user_id, + payment_session_id=payment_session_id, + payment_instrument_id=payment_instrument_id, + payment_type="CRYPTO_X402", + payment_input=payment_input, + client_token=client_token, + ) logger.debug("Payment processed successfully") # Extract cryptoX402 proof from payment result diff --git a/tests/bedrock_agentcore/payments/test_payment_manager.py b/tests/bedrock_agentcore/payments/test_payment_manager.py index db3be636..e58d0d7f 100644 --- a/tests/bedrock_agentcore/payments/test_payment_manager.py +++ b/tests/bedrock_agentcore/payments/test_payment_manager.py @@ -943,6 +943,38 @@ def test_payment_manager_arn_injected_in_payment(self, mock_session_class): call_kwargs = mock_client.process_payment.call_args[1] assert call_kwargs["paymentManagerArn"] == arn + @patch("bedrock_agentcore.payments.manager.boto3.Session") + def test_process_payment_does_not_forward_connector_id(self, mock_session_class): + """ProcessPayment must NOT include paymentConnectorId — the live API rejects it. + + Regression: an earlier change forwarded payment_connector_id into the API + call, which botocore rejected with "Unknown parameter in input: + paymentConnectorId". The connector is resolved server-side from the + payment instrument, so the SDK now drops the kwarg before calling boto3. + """ + arn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:payment-manager/pm-123" + + mock_session = MagicMock() + mock_session.region_name = "us-east-1" + mock_client = MagicMock() + mock_session.client.return_value = mock_client + mock_session_class.return_value = mock_session + + mock_client.process_payment.return_value = {"processPaymentId": "payment-123"} + + manager = PaymentManager(payment_manager_arn=arn, region_name="us-east-1") + manager.process_payment( + user_id="user-123", + payment_session_id="session-123", + payment_instrument_id="instrument-123", + payment_type="CRYPTO_X402", + payment_input={"cryptoX402": {"version": "2", "payload": {}}}, + payment_connector_id="connector-456", + ) + + call_kwargs = mock_client.process_payment.call_args[1] + assert "paymentConnectorId" not in call_kwargs + @patch("bedrock_agentcore.payments.manager.boto3.Session") def test_process_payment_insufficient_budget(self, mock_session_class): """Test InsufficientBudget error during payment processing.""" From 4c771a22487255c097f8bfd7888ed43510f3ac71 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 22 May 2026 02:47:07 +0000 Subject: [PATCH 2/5] feat(payments): add http_request tool to AgentCorePaymentsPlugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ships a Strands @tool method on AgentCorePaymentsPlugin that performs HTTP requests and emits Strands ToolResult content blocks the SDK's payment handlers can parse. When the endpoint returns 402 Payment Required, the tool prefixes the content block with the spec-compliant PAYMENT_REQUIRED: marker, so the plugin's existing after_tool_call hook intercepts the result, generates an x402 payment header via PaymentManager, and Strands re-invokes the tool with the payment header injected. Why on the plugin: aligns with how get_payment_instrument / list_payment_instruments / get_payment_session are already exposed — adding the plugin to a Strands agent now produces a turnkey paid-HTTP experience without requiring strands-agents-tools or hand-written httpx code in customer agents. httpx is already a hard SDK dep; no new dependencies are introduced. The 402 envelope (statusCode + headers + body) matches what GenericPaymentHandler / HttpRequestPaymentHandler already parse, so the existing handler dispatch lights up automatically. Tests cover: - 200 envelope shape (Strands ToolResult dict, not a raw list) - 402 wrapped with PAYMENT_REQUIRED: marker - End-to-end contract: tool output -> get_payment_handler -> 402 - GET ignores body, POST sends dict as JSON, str body sent verbatim - Caller header dict isolated (mutations don't bleed back) - Non-JSON body falls back to {"text": ...} - httpx.RequestError returned as status='error' (not raised) - Method casing normalization - Default headers when caller omits All 490 payments tests pass. --- .../payments/integrations/strands/plugin.py | 85 ++++++- .../integrations/strands/test_tools.py | 234 ++++++++++++++++++ 2 files changed, 317 insertions(+), 2 deletions(-) diff --git a/src/bedrock_agentcore/payments/integrations/strands/plugin.py b/src/bedrock_agentcore/payments/integrations/strands/plugin.py index 041b53ed..db22ecde 100644 --- a/src/bedrock_agentcore/payments/integrations/strands/plugin.py +++ b/src/bedrock_agentcore/payments/integrations/strands/plugin.py @@ -1,9 +1,11 @@ """AgentCorePaymentsPlugin for Strands Agents framework.""" +import json import logging import uuid -from typing import Any, Dict, Optional +from typing import Any, Dict, Optional, Union +import httpx from strands.hooks import AfterToolCallEvent, BeforeToolCallEvent from strands.plugins import Plugin, hook from strands.tools import tool @@ -25,7 +27,8 @@ class AgentCorePaymentsPlugin(Plugin): """Plugin for handling X402 payment requirements and providing payment tools in Strands Agents. - This plugin provides three tools for querying payment information: + This plugin provides tools for querying payment information and making paid HTTP calls: + - http_request: Call a (paid) HTTP endpoint; 402 responses are settled automatically - getPaymentInstrument: Retrieve details about a specific payment instrument - listPaymentInstruments: List all payment instruments for a user - getPaymentSession: Retrieve details about a specific payment session @@ -726,3 +729,81 @@ def get_payment_session( str(e), ) raise + + @tool + def http_request( + self, + url: str, + method: str = "GET", + headers: Optional[Dict[str, str]] = None, + body: Optional[Union[Dict[str, Any], str]] = None, + ) -> Dict[str, Any]: + """Call an HTTP endpoint. 402 Payment Required responses are settled automatically. + + When the endpoint responds with HTTP 402, this plugin's after_tool_call hook + intercepts the result, generates an x402 payment header via the configured + PaymentManager, mutates ``headers`` with the X-PAYMENT (v1) or + PAYMENT-SIGNATURE (v2) header, and Strands re-invokes this tool — yielding + the final 200 response and (when applicable) a settle hash in the + PAYMENT-RESPONSE header. + + Returns a Strands ToolResult dict: ``status`` is always ``success`` (HTTP + errors are returned in the body, not raised), and ``content`` is a single + text block. On 402 the text is prefixed with ``PAYMENT_REQUIRED:`` so the + SDK's payment handlers can extract the x402 payload. + + Args: + url: The full URL to request. + method: HTTP method. Defaults to ``GET``. + headers: Optional request headers. The plugin mutates this dict to add + the payment header on retry. + body: Optional request body. ``dict`` is sent as JSON; ``str`` is sent + as-is. Ignored for ``GET``/``HEAD``. + + Returns: + Strands ToolResult dict with ``status`` and ``content``. + """ + request_headers = dict(headers) if headers else {} + method_upper = method.upper() + + try: + with httpx.Client(timeout=30.0, follow_redirects=True) as client: + if body is None or method_upper in ("GET", "HEAD"): + resp = client.request(method_upper, url, headers=request_headers) + elif isinstance(body, str): + resp = client.request(method_upper, url, headers=request_headers, content=body) + else: + resp = client.request(method_upper, url, headers=request_headers, json=body) + except httpx.RequestError as exc: + logger.error("http_request failed for %s: %s", url, exc) + return { + "status": "error", + "content": [{"text": json.dumps({ + "statusCode": 0, + "error": f"Request failed: {exc}", + "url": url, + })}], + } + + response_headers = dict(resp.headers) + try: + response_body: Any = resp.json() + except Exception: + response_body = {"text": resp.text} + + payload = { + "statusCode": resp.status_code, + "headers": response_headers, + "body": response_body, + } + + if resp.status_code == 402: + return { + "status": "success", + "content": [{"text": f"PAYMENT_REQUIRED: {json.dumps(payload)}"}], + } + + return { + "status": "success", + "content": [{"text": json.dumps(payload)}], + } diff --git a/tests/bedrock_agentcore/payments/integrations/strands/test_tools.py b/tests/bedrock_agentcore/payments/integrations/strands/test_tools.py index 9de2fc57..c57a61ec 100644 --- a/tests/bedrock_agentcore/payments/integrations/strands/test_tools.py +++ b/tests/bedrock_agentcore/payments/integrations/strands/test_tools.py @@ -1681,3 +1681,237 @@ def test_bearer_auth_mode_user_id_none_passes_none_to_manager(self): payment_instrument_id="instr-123", payment_connector_id=None, ) + + +class TestHttpRequestTool: + """Tests for the http_request tool method on AgentCorePaymentsPlugin. + + The tool returns a Strands ToolResult dict so the AgentCorePaymentsPlugin's + after_tool_call hook can inspect the result and trigger payment retries on 402. + """ + + def _make_plugin(self): + from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + ) + return AgentCorePaymentsPlugin(config) + + @staticmethod + def _mock_response(status_code: int, headers: dict | None = None, json_body=None, text: str | None = None): + from unittest.mock import MagicMock as _MagicMock + + resp = _MagicMock() + resp.status_code = status_code + resp.headers = headers or {} + if json_body is not None: + resp.json.return_value = json_body + resp.text = json.dumps(json_body) + else: + resp.json.side_effect = ValueError("not json") + resp.text = text or "" + return resp + + @patch("bedrock_agentcore.payments.integrations.strands.plugin.httpx.Client") + def test_returns_tool_result_envelope_on_200(self, mock_client_cls): + """200 OK responses are wrapped in a Strands ToolResult dict.""" + plugin = self._make_plugin() + mock_client = MagicMock() + mock_client_cls.return_value.__enter__.return_value = mock_client + mock_client.request.return_value = self._mock_response( + 200, headers={"content-type": "application/json"}, json_body={"weather": "sunny", "temp": 70} + ) + + result = plugin.http_request(url="https://example.com/weather") + + assert result["status"] == "success" + assert isinstance(result["content"], list) and len(result["content"]) == 1 + text = result["content"][0]["text"] + assert not text.startswith("PAYMENT_REQUIRED:") + body = json.loads(text) + assert body["statusCode"] == 200 + assert body["body"] == {"weather": "sunny", "temp": 70} + + @patch("bedrock_agentcore.payments.integrations.strands.plugin.httpx.Client") + def test_402_uses_payment_required_marker(self, mock_client_cls): + """402 responses are tagged with PAYMENT_REQUIRED: so the SDK handler can parse them.""" + plugin = self._make_plugin() + mock_client = MagicMock() + mock_client_cls.return_value.__enter__.return_value = mock_client + + x402_body = { + "x402Version": 2, + "error": "Payment required", + "accepts": [{ + "scheme": "exact", + "network": "eip155:84532", + "amount": "1000", + "asset": "0x036C", + "payTo": "0xabc", + "maxTimeoutSeconds": 300, + "extra": {"name": "USDC", "version": "2"}, + }], + } + mock_client.request.return_value = self._mock_response( + 402, headers={"PAYMENT-REQUIRED": "base64..."}, json_body=x402_body + ) + + result = plugin.http_request(url="https://example.com/paid") + + assert result["status"] == "success" + text = result["content"][0]["text"] + assert text.startswith("PAYMENT_REQUIRED: ") + payload = json.loads(text[len("PAYMENT_REQUIRED: "):]) + assert payload["statusCode"] == 402 + assert payload["body"]["x402Version"] == 2 + assert payload["body"]["accepts"][0]["network"] == "eip155:84532" + + @patch("bedrock_agentcore.payments.integrations.strands.plugin.httpx.Client") + def test_402_marker_is_parsed_by_sdk_handler(self, mock_client_cls): + """The 402 envelope this tool emits is recognized by the SDK's handler dispatcher. + + End-to-end contract test: tool output -> get_payment_handler -> 402 status code. + """ + from bedrock_agentcore.payments.integrations.handlers import get_payment_handler + + plugin = self._make_plugin() + mock_client = MagicMock() + mock_client_cls.return_value.__enter__.return_value = mock_client + mock_client.request.return_value = self._mock_response( + 402, + headers={"x-foo": "bar"}, + json_body={"x402Version": 1, "accepts": [{"scheme": "exact", "network": "eip155:84532"}]}, + ) + + tool_result = plugin.http_request(url="https://example.com/paid") + # event.result for AfterToolCallEvent is the content list + handler = get_payment_handler("http_request", {"url": "https://example.com/paid", "headers": {}}) + assert handler.extract_status_code(tool_result["content"]) == 402 + body = handler.extract_body(tool_result["content"]) + assert body["x402Version"] == 1 + + @patch("bedrock_agentcore.payments.integrations.strands.plugin.httpx.Client") + def test_get_request_does_not_send_body(self, mock_client_cls): + """GET requests must never send a body, even if the agent supplies one.""" + plugin = self._make_plugin() + mock_client = MagicMock() + mock_client_cls.return_value.__enter__.return_value = mock_client + mock_client.request.return_value = self._mock_response(200, json_body={"ok": True}) + + plugin.http_request(url="https://example.com", method="GET", body={"ignored": "value"}) + + call_kwargs = mock_client.request.call_args.kwargs + assert "json" not in call_kwargs + assert "content" not in call_kwargs + + @patch("bedrock_agentcore.payments.integrations.strands.plugin.httpx.Client") + def test_post_with_dict_body_serializes_as_json(self, mock_client_cls): + """dict body is sent via httpx's json= kwarg (sets Content-Type).""" + plugin = self._make_plugin() + mock_client = MagicMock() + mock_client_cls.return_value.__enter__.return_value = mock_client + mock_client.request.return_value = self._mock_response(200, json_body={"ok": True}) + + plugin.http_request(url="https://example.com/api", method="POST", body={"name": "test"}) + + call_kwargs = mock_client.request.call_args.kwargs + assert call_kwargs["json"] == {"name": "test"} + assert "content" not in call_kwargs + + @patch("bedrock_agentcore.payments.integrations.strands.plugin.httpx.Client") + def test_post_with_str_body_sent_as_raw_content(self, mock_client_cls): + """str body is sent verbatim via content=, not re-serialized.""" + plugin = self._make_plugin() + mock_client = MagicMock() + mock_client_cls.return_value.__enter__.return_value = mock_client + mock_client.request.return_value = self._mock_response(200, json_body={"ok": True}) + + plugin.http_request(url="https://example.com/api", method="POST", body="raw=payload&x=1") + + call_kwargs = mock_client.request.call_args.kwargs + assert call_kwargs["content"] == "raw=payload&x=1" + assert "json" not in call_kwargs + + @patch("bedrock_agentcore.payments.integrations.strands.plugin.httpx.Client") + def test_caller_headers_are_forwarded(self, mock_client_cls): + """User-supplied headers are passed through to the request (and copied, not aliased).""" + plugin = self._make_plugin() + mock_client = MagicMock() + mock_client_cls.return_value.__enter__.return_value = mock_client + mock_client.request.return_value = self._mock_response(200, json_body={"ok": True}) + + caller_headers = {"X-Custom": "v1", "Authorization": "Bearer xyz"} + plugin.http_request(url="https://example.com", headers=caller_headers) + + sent = mock_client.request.call_args.kwargs["headers"] + assert sent["X-Custom"] == "v1" + assert sent["Authorization"] == "Bearer xyz" + # Should be a new dict — mutating the request copy must not bleed back. + sent["X-Plugin-Mutation"] = "ok" + assert "X-Plugin-Mutation" not in caller_headers + + @patch("bedrock_agentcore.payments.integrations.strands.plugin.httpx.Client") + def test_non_json_response_body_falls_back_to_text(self, mock_client_cls): + """Endpoints that return non-JSON bodies are wrapped in {"text": ...}.""" + plugin = self._make_plugin() + mock_client = MagicMock() + mock_client_cls.return_value.__enter__.return_value = mock_client + mock_client.request.return_value = self._mock_response( + 200, headers={"content-type": "text/plain"}, text="hello world" + ) + + result = plugin.http_request(url="https://example.com") + + body = json.loads(result["content"][0]["text"]) + assert body["statusCode"] == 200 + assert body["body"] == {"text": "hello world"} + + @patch("bedrock_agentcore.payments.integrations.strands.plugin.httpx.Client") + def test_request_error_returned_as_error_status(self, mock_client_cls): + """Network errors are returned as ToolResult status='error' rather than raised.""" + import httpx as _httpx + + plugin = self._make_plugin() + mock_client = MagicMock() + mock_client_cls.return_value.__enter__.return_value = mock_client + mock_client.request.side_effect = _httpx.ConnectError("dns failure") + + result = plugin.http_request(url="https://example.com") + + assert result["status"] == "error" + body = json.loads(result["content"][0]["text"]) + assert body["statusCode"] == 0 + assert "dns failure" in body["error"] + assert body["url"] == "https://example.com" + + @patch("bedrock_agentcore.payments.integrations.strands.plugin.httpx.Client") + def test_method_is_uppercased(self, mock_client_cls): + """HTTP method is normalized to upper case so callers can pass 'get' or 'GET'.""" + plugin = self._make_plugin() + mock_client = MagicMock() + mock_client_cls.return_value.__enter__.return_value = mock_client + mock_client.request.return_value = self._mock_response(200, json_body={}) + + plugin.http_request(url="https://example.com", method="post", body={"a": 1}) + + # First positional arg to client.request is the method + assert mock_client.request.call_args.args[0] == "POST" + + @patch("bedrock_agentcore.payments.integrations.strands.plugin.httpx.Client") + def test_default_headers_dict_when_caller_omits(self, mock_client_cls): + """When the caller does not pass headers, the request still gets a (mutable) dict.""" + plugin = self._make_plugin() + mock_client = MagicMock() + mock_client_cls.return_value.__enter__.return_value = mock_client + mock_client.request.return_value = self._mock_response(200, json_body={}) + + plugin.http_request(url="https://example.com") + + sent = mock_client.request.call_args.kwargs["headers"] + assert isinstance(sent, dict) + assert sent == {} From de290ab4bf469f021046602682438374ac1b9467 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 22 May 2026 03:27:54 +0000 Subject: [PATCH 3/5] style(payments): apply ruff format to plugin + tests --- .../payments/integrations/strands/plugin.py | 16 +++++++++----- .../integrations/strands/test_tools.py | 22 ++++++++++--------- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/src/bedrock_agentcore/payments/integrations/strands/plugin.py b/src/bedrock_agentcore/payments/integrations/strands/plugin.py index db22ecde..80f016f1 100644 --- a/src/bedrock_agentcore/payments/integrations/strands/plugin.py +++ b/src/bedrock_agentcore/payments/integrations/strands/plugin.py @@ -778,11 +778,17 @@ def http_request( logger.error("http_request failed for %s: %s", url, exc) return { "status": "error", - "content": [{"text": json.dumps({ - "statusCode": 0, - "error": f"Request failed: {exc}", - "url": url, - })}], + "content": [ + { + "text": json.dumps( + { + "statusCode": 0, + "error": f"Request failed: {exc}", + "url": url, + } + ) + } + ], } response_headers = dict(resp.headers) diff --git a/tests/bedrock_agentcore/payments/integrations/strands/test_tools.py b/tests/bedrock_agentcore/payments/integrations/strands/test_tools.py index c57a61ec..9f4b07a5 100644 --- a/tests/bedrock_agentcore/payments/integrations/strands/test_tools.py +++ b/tests/bedrock_agentcore/payments/integrations/strands/test_tools.py @@ -1747,15 +1747,17 @@ def test_402_uses_payment_required_marker(self, mock_client_cls): x402_body = { "x402Version": 2, "error": "Payment required", - "accepts": [{ - "scheme": "exact", - "network": "eip155:84532", - "amount": "1000", - "asset": "0x036C", - "payTo": "0xabc", - "maxTimeoutSeconds": 300, - "extra": {"name": "USDC", "version": "2"}, - }], + "accepts": [ + { + "scheme": "exact", + "network": "eip155:84532", + "amount": "1000", + "asset": "0x036C", + "payTo": "0xabc", + "maxTimeoutSeconds": 300, + "extra": {"name": "USDC", "version": "2"}, + } + ], } mock_client.request.return_value = self._mock_response( 402, headers={"PAYMENT-REQUIRED": "base64..."}, json_body=x402_body @@ -1766,7 +1768,7 @@ def test_402_uses_payment_required_marker(self, mock_client_cls): assert result["status"] == "success" text = result["content"][0]["text"] assert text.startswith("PAYMENT_REQUIRED: ") - payload = json.loads(text[len("PAYMENT_REQUIRED: "):]) + payload = json.loads(text[len("PAYMENT_REQUIRED: ") :]) assert payload["statusCode"] == 402 assert payload["body"]["x402Version"] == 2 assert payload["body"]["accepts"][0]["network"] == "eip155:84532" From b130f9ab1e6e86b9bba0473c93cac8ee41620bf5 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 22 May 2026 03:37:51 +0000 Subject: [PATCH 4/5] feat(payments): provide_http_request opt-out flag for AgentCorePaymentsPlugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strands' tool registry raises ValueError on duplicate tool names, so a caller who wants to ship their own http_request currently can't — the plugin's auto-discovered http_request crashes Agent construction first. Add provide_http_request: bool = True to AgentCorePaymentsPluginConfig. When True (default), behavior is unchanged: the plugin's http_request @tool is auto-registered. When False, the plugin filters its own http_request out of self._tools right after super().__init__() so the caller can pass their own without a name collision. Auto-payment of 402 responses still works against any tool whose output emits the PAYMENT_REQUIRED: content marker — the after_tool_call hook is independent of the tool registration, so disabling the flag does not disable interception. Adds 5 tests: - default registers http_request - opt-out drops only http_request (other plugin tools intact) - opt-out preserves the after_tool_call/before_tool_call hooks - config validator rejects non-bool values for the flag - init_agent still works with the flag off All 495 payments tests pass. --- .../payments/integrations/config.py | 12 +++ .../payments/integrations/strands/plugin.py | 12 +++ .../integrations/strands/test_tools.py | 90 +++++++++++++++++++ 3 files changed, 114 insertions(+) diff --git a/src/bedrock_agentcore/payments/integrations/config.py b/src/bedrock_agentcore/payments/integrations/config.py index 86e05898..9c408d45 100644 --- a/src/bedrock_agentcore/payments/integrations/config.py +++ b/src/bedrock_agentcore/payments/integrations/config.py @@ -39,6 +39,14 @@ class AgentCorePaymentsPluginConfig: eligible (preserving existing behavior). When set, only tool calls whose name appears in this list will trigger payment processing; all others are skipped. + provide_http_request: Whether the plugin should register its built-in + ``http_request`` ``@tool`` on the agent. Defaults to True so adding the + plugin gives a turnkey paid-HTTP experience. Set to False if you want + to ship your own ``http_request`` tool — Strands raises a ValueError + on duplicate tool names, so you must opt out of the plugin's version + before passing your own. Auto-payment of 402 responses still works + against any tool whose output carries the ``PAYMENT_REQUIRED:`` + content marker, so disabling this flag does not disable interception. """ payment_manager_arn: str @@ -54,6 +62,7 @@ class AgentCorePaymentsPluginConfig: bearer_token: Optional[str] = None token_provider: Optional[Callable[[], str]] = None payment_tool_allowlist: Optional[List[str]] = None + provide_http_request: bool = True def __post_init__(self) -> None: """Validate configuration after initialization.""" @@ -87,6 +96,9 @@ def __post_init__(self) -> None: if not all(isinstance(t, str) for t in self.payment_tool_allowlist): raise ValueError("All entries in payment_tool_allowlist must be strings") + if not isinstance(self.provide_http_request, bool): + raise ValueError(f"provide_http_request must be a boolean, got {type(self.provide_http_request).__name__}") + def update_payment_session_id(self, payment_session_id: str) -> None: """Update the payment session ID. diff --git a/src/bedrock_agentcore/payments/integrations/strands/plugin.py b/src/bedrock_agentcore/payments/integrations/strands/plugin.py index 80f016f1..539e87bb 100644 --- a/src/bedrock_agentcore/payments/integrations/strands/plugin.py +++ b/src/bedrock_agentcore/payments/integrations/strands/plugin.py @@ -58,6 +58,18 @@ def __init__(self, config: AgentCorePaymentsPluginConfig): super().__init__() self.config = config self.payment_manager: Optional[PaymentManager] = None + + # Honor the provide_http_request opt-out: Strands' Plugin base auto-discovers + # every @tool method into self._tools at super().__init__(). If the caller + # wants to ship their own http_request, drop ours so Strands' tool registry + # doesn't raise ValueError on duplicate tool name. + if not self.config.provide_http_request: + self._tools = [t for t in self._tools if t.tool_name != "http_request"] + logger.info( + "provide_http_request=False — plugin's http_request tool will not be registered. " + "Auto-payment still triggers on any tool emitting a PAYMENT_REQUIRED: marker." + ) + logger.info("Initialized AgentCorePaymentsPlugin") def init_agent(self, agent) -> None: diff --git a/tests/bedrock_agentcore/payments/integrations/strands/test_tools.py b/tests/bedrock_agentcore/payments/integrations/strands/test_tools.py index 9f4b07a5..06d02d11 100644 --- a/tests/bedrock_agentcore/payments/integrations/strands/test_tools.py +++ b/tests/bedrock_agentcore/payments/integrations/strands/test_tools.py @@ -1917,3 +1917,93 @@ def test_default_headers_dict_when_caller_omits(self, mock_client_cls): sent = mock_client.request.call_args.kwargs["headers"] assert isinstance(sent, dict) assert sent == {} + + +class TestProvideHttpRequestOptOut: + """Tests for the provide_http_request config flag. + + When True (default), AgentCorePaymentsPlugin registers its built-in + http_request @tool. When False, the tool is dropped from the plugin's + discovered tools so callers can ship their own http_request without + Strands raising ValueError on duplicate tool names. + """ + + @staticmethod + def _make_config(**overrides): + from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + + kwargs = dict( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="test-instrument", + payment_session_id="test-session", + ) + kwargs.update(overrides) + return AgentCorePaymentsPluginConfig(**kwargs) + + def test_default_provides_http_request(self): + """By default the plugin registers http_request alongside its other tools.""" + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + + plugin = AgentCorePaymentsPlugin(self._make_config()) + tool_names = {t.tool_name for t in plugin.tools} + assert "http_request" in tool_names + # Sanity: the rest of the plugin's tools are still there. + assert "get_payment_instrument" in tool_names + assert "get_payment_session" in tool_names + + def test_opt_out_drops_only_http_request(self): + """provide_http_request=False removes ONLY http_request from the registered tools.""" + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + + plugin = AgentCorePaymentsPlugin(self._make_config(provide_http_request=False)) + tool_names = {t.tool_name for t in plugin.tools} + assert "http_request" not in tool_names + # Other plugin tools must still be present so payment-info queries still work. + assert "get_payment_instrument" in tool_names + assert "list_payment_instruments" in tool_names + assert "get_payment_instrument_balance" in tool_names + assert "get_payment_session" in tool_names + + def test_opt_out_does_not_disable_payment_hook(self): + """Disabling the tool must not disable the after_tool_call interceptor. + + A caller's own http_request that emits PAYMENT_REQUIRED-marked content + should still trigger the plugin's auto-pay path. + """ + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + + plugin = AgentCorePaymentsPlugin(self._make_config(provide_http_request=False)) + hook_names = [getattr(h, "__name__", repr(h)) for h in plugin.hooks] + assert "after_tool_call" in hook_names + assert "before_tool_call" in hook_names + + def test_provide_http_request_validation_rejects_non_bool(self): + """Config validator rejects non-boolean values for the new flag.""" + import pytest + + from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + + with pytest.raises(ValueError, match="provide_http_request must be a boolean"): + AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:payment:us-east-1:123456789012:payment-manager/test", + user_id="test-user", + provide_http_request="yes", # type: ignore[arg-type] + ) + + def test_opt_out_preserves_payment_manager_init(self): + """The init flow (PaymentManager initialization) is unaffected by the flag.""" + from unittest.mock import MagicMock + + from bedrock_agentcore.payments.integrations.strands.plugin import AgentCorePaymentsPlugin + + plugin = AgentCorePaymentsPlugin(self._make_config(provide_http_request=False)) + # init_agent should be callable and should not blow up just because we opted out. + try: + plugin.init_agent(agent=MagicMock()) + except RuntimeError: + # Real PaymentManager call against a fake ARN — tolerate the network/IAM + # failure; what we care about is that init_agent doesn't trip on the flag. + pass + # Plugin object itself must still be usable + assert plugin.config.provide_http_request is False From bbac3d8dd2c589b10ed80e7e8739deee8c21fa8b Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 22 May 2026 03:58:14 +0000 Subject: [PATCH 5/5] feat(payments): add post_payment_retry_delay_seconds to fix EIP-3009 timing race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The x402 EIP-3009 transferWithAuthorization contract requires block.timestamp > validAfter — a strict greater-than check, not greater-or-equal. When the signing service mints a signature with validAfter set to ~current Unix time, fast merchant facilitators submit the transaction within the same second the signature was minted, hitting the contract's strict check before block.timestamp advances. The seller returns a misleading 402 with reason "invalid_payload" or "invalid_exact_evm_transaction_simulation_failed" — but the signature is structurally valid; it's a race between minting and submission. Verified empirically: same signed payload reverts via eth_call within the validAfter second, then succeeds 5+ seconds later. Once the chain advances one block past validAfter, the seller's facilitator submits and the on-chain transferWithAuthorization settles. Real Base Sepolia transaction proving the round-trip: 0x0e542e2d5c3c97521bd47231d299c6d56841fe0865f63ebfaa92d6f47e28b6b6 Add post_payment_retry_delay_seconds: float = 3.0 to AgentCorePaymentsPluginConfig. Sleeps that many seconds in after_tool_call right before setting event.retry = True, so by the time Strands re-invokes the tool with the PAYMENT-SIGNATURE header, the chain has advanced one block past validAfter. 3.0s default chosen as ~one Base Sepolia block (~2s) plus margin. Set to 0 to disable for tests or chains with sub-second blocks. Adds 7 tests: - default delay is 3.0 - custom positive delay is honored - zero delay skips the sleep entirely (event.retry still set) - no sleep when signing fails (we never reach the retry path) - validator rejects negative values - validator rejects non-numeric values (str) - validator rejects bool (which would otherwise sneak through isinstance(int)) All 502 payments tests pass. --- .../payments/integrations/config.py | 23 +++ .../payments/integrations/strands/plugin.py | 15 ++ .../integrations/strands/test_plugin.py | 165 ++++++++++++++++++ 3 files changed, 203 insertions(+) diff --git a/src/bedrock_agentcore/payments/integrations/config.py b/src/bedrock_agentcore/payments/integrations/config.py index 9c408d45..5456a1e7 100644 --- a/src/bedrock_agentcore/payments/integrations/config.py +++ b/src/bedrock_agentcore/payments/integrations/config.py @@ -47,6 +47,16 @@ class AgentCorePaymentsPluginConfig: before passing your own. Auto-payment of 402 responses still works against any tool whose output carries the ``PAYMENT_REQUIRED:`` content marker, so disabling this flag does not disable interception. + post_payment_retry_delay_seconds: Seconds to wait after generating a + payment header before allowing the tool to be retried. The x402 + EIP-3009 ``transferWithAuthorization`` contract requires + ``block.timestamp > validAfter`` (strict greater-than). Some signing + services set ``validAfter`` close to the current time, which can + cause the merchant facilitator to submit before ``validAfter`` + elapses, producing a misleading "invalid_payload" response. A small + delay between signing and retry lets the chain advance one block so + the authorization is valid by the time the seller submits. Defaults + to 3.0 seconds (about one Base Sepolia block). Set to 0 to disable. """ payment_manager_arn: str @@ -63,6 +73,7 @@ class AgentCorePaymentsPluginConfig: token_provider: Optional[Callable[[], str]] = None payment_tool_allowlist: Optional[List[str]] = None provide_http_request: bool = True + post_payment_retry_delay_seconds: float = 3.0 def __post_init__(self) -> None: """Validate configuration after initialization.""" @@ -99,6 +110,18 @@ def __post_init__(self) -> None: if not isinstance(self.provide_http_request, bool): raise ValueError(f"provide_http_request must be a boolean, got {type(self.provide_http_request).__name__}") + if not isinstance(self.post_payment_retry_delay_seconds, (int, float)) or isinstance( + self.post_payment_retry_delay_seconds, bool + ): + raise ValueError( + "post_payment_retry_delay_seconds must be a number, got " + f"{type(self.post_payment_retry_delay_seconds).__name__}" + ) + if self.post_payment_retry_delay_seconds < 0: + raise ValueError( + f"post_payment_retry_delay_seconds must be >= 0, got {self.post_payment_retry_delay_seconds}" + ) + def update_payment_session_id(self, payment_session_id: str) -> None: """Update the payment session ID. diff --git a/src/bedrock_agentcore/payments/integrations/strands/plugin.py b/src/bedrock_agentcore/payments/integrations/strands/plugin.py index 539e87bb..aada8cc0 100644 --- a/src/bedrock_agentcore/payments/integrations/strands/plugin.py +++ b/src/bedrock_agentcore/payments/integrations/strands/plugin.py @@ -2,6 +2,7 @@ import json import logging +import time import uuid from typing import Any, Dict, Optional, Union @@ -251,6 +252,20 @@ def after_tool_call(self, event: AfterToolCallEvent) -> None: # after this retry, we know it's a server-side rejection, not a signing failure. self._mark_successful_signing(event) + # Wait one chain-block before letting the tool retry, so the merchant's + # facilitator has time to see block.timestamp > validAfter when it submits + # transferWithAuthorization to USDC. Without this delay, fast facilitators + # can submit in the same second the signature was minted, hitting the + # contract's strict ``block.timestamp > validAfter`` check and producing + # a misleading "invalid_payload" 402 from the seller. + delay = self.config.post_payment_retry_delay_seconds + if delay > 0: + logger.info( + "Waiting %.1fs before retry to allow chain to advance past validAfter", + delay, + ) + time.sleep(delay) + # Set retry flag to re-execute the tool with payment credentials. event.retry = True self._reset_interrupt_retry_count(event) diff --git a/tests/bedrock_agentcore/payments/integrations/strands/test_plugin.py b/tests/bedrock_agentcore/payments/integrations/strands/test_plugin.py index f110bc7b..c77c7209 100644 --- a/tests/bedrock_agentcore/payments/integrations/strands/test_plugin.py +++ b/tests/bedrock_agentcore/payments/integrations/strands/test_plugin.py @@ -1800,3 +1800,168 @@ def test_allowlist_none_allows_all_tools(self): mock_handler.extract_status_code.assert_called_once() mock_pm_instance.generate_payment_header.assert_called_once() + + +class TestPostPaymentRetryDelay: + """Tests for post_payment_retry_delay_seconds backoff before tool retry. + + The x402 EIP-3009 transferWithAuthorization contract requires + block.timestamp > validAfter (strict greater-than). When the signing + service sets validAfter near the current time, fast facilitators submit + in the same second the signature was minted and hit a deterministic + revert. The plugin sleeps post_payment_retry_delay_seconds between + signing and re-invoking the tool to let the chain advance. + """ + + @patch("bedrock_agentcore.payments.integrations.strands.plugin.time.sleep") + @patch("bedrock_agentcore.payments.integrations.strands.plugin.PaymentManager") + def test_default_delay_is_three_seconds(self, mock_payment_manager_class, mock_sleep): + """Default post_payment_retry_delay_seconds is 3.0 — sleep is called with 3.0.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="payment-instrument-123", + payment_session_id="payment-session-456", + ) + assert config.post_payment_retry_delay_seconds == 3.0 + + mock_pm_instance = MagicMock() + mock_pm_instance.generate_payment_header.return_value = {"X-PAYMENT": "base64"} + plugin = AgentCorePaymentsPlugin(config=config) + plugin.payment_manager = mock_pm_instance + + event, agent = _create_event_with_agent( + { + "result": [{"text": f"PAYMENT_REQUIRED: {json.dumps({'statusCode': 402, 'headers': {}, 'body': {}})}"}], + "tool_use": {"name": "http_request", "toolUseId": "tool-123", "input": {"headers": {}}}, + "invocation_state": {}, + "retry": False, + } + ) + plugin.after_tool_call(event) + + assert event.retry is True + mock_sleep.assert_called_once_with(3.0) + + @patch("bedrock_agentcore.payments.integrations.strands.plugin.time.sleep") + @patch("bedrock_agentcore.payments.integrations.strands.plugin.PaymentManager") + def test_custom_delay_value(self, mock_payment_manager_class, mock_sleep): + """A custom positive delay is honored verbatim.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="payment-instrument-123", + payment_session_id="payment-session-456", + post_payment_retry_delay_seconds=1.5, + ) + + mock_pm_instance = MagicMock() + mock_pm_instance.generate_payment_header.return_value = {"X-PAYMENT": "base64"} + plugin = AgentCorePaymentsPlugin(config=config) + plugin.payment_manager = mock_pm_instance + + event, agent = _create_event_with_agent( + { + "result": [{"text": f"PAYMENT_REQUIRED: {json.dumps({'statusCode': 402, 'headers': {}, 'body': {}})}"}], + "tool_use": {"name": "http_request", "toolUseId": "tool-123", "input": {"headers": {}}}, + "invocation_state": {}, + "retry": False, + } + ) + plugin.after_tool_call(event) + + mock_sleep.assert_called_once_with(1.5) + + @patch("bedrock_agentcore.payments.integrations.strands.plugin.time.sleep") + @patch("bedrock_agentcore.payments.integrations.strands.plugin.PaymentManager") + def test_zero_delay_skips_sleep(self, mock_payment_manager_class, mock_sleep): + """post_payment_retry_delay_seconds=0 skips the sleep entirely.""" + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="payment-instrument-123", + payment_session_id="payment-session-456", + post_payment_retry_delay_seconds=0, + ) + + mock_pm_instance = MagicMock() + mock_pm_instance.generate_payment_header.return_value = {"X-PAYMENT": "base64"} + plugin = AgentCorePaymentsPlugin(config=config) + plugin.payment_manager = mock_pm_instance + + event, agent = _create_event_with_agent( + { + "result": [{"text": f"PAYMENT_REQUIRED: {json.dumps({'statusCode': 402, 'headers': {}, 'body': {}})}"}], + "tool_use": {"name": "http_request", "toolUseId": "tool-123", "input": {"headers": {}}}, + "invocation_state": {}, + "retry": False, + } + ) + plugin.after_tool_call(event) + + # Tool should still be set to retry — sleep is the only thing skipped. + assert event.retry is True + mock_sleep.assert_not_called() + + @patch("bedrock_agentcore.payments.integrations.strands.plugin.time.sleep") + @patch("bedrock_agentcore.payments.integrations.strands.plugin.PaymentManager") + def test_sleep_does_not_fire_when_payment_fails(self, mock_payment_manager_class, mock_sleep): + """If signing fails, no sleep should run — we never reach the retry path.""" + from bedrock_agentcore.payments.manager import PaymentInstrumentNotFound + + config = AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + payment_instrument_id="payment-instrument-123", + payment_session_id="payment-session-456", + ) + + mock_pm_instance = MagicMock() + mock_pm_instance.generate_payment_header.side_effect = PaymentInstrumentNotFound("missing") + plugin = AgentCorePaymentsPlugin(config=config) + plugin.payment_manager = mock_pm_instance + + event, agent = _create_event_with_agent( + { + "result": [{"text": f"PAYMENT_REQUIRED: {json.dumps({'statusCode': 402, 'headers': {}, 'body': {}})}"}], + "tool_use": {"name": "http_request", "toolUseId": "tool-123", "input": {"headers": {}}}, + "invocation_state": {}, + "retry": False, + } + ) + plugin.after_tool_call(event) + + mock_sleep.assert_not_called() + + def test_validator_rejects_negative_delay(self): + """post_payment_retry_delay_seconds cannot be negative.""" + import pytest + + with pytest.raises(ValueError, match="post_payment_retry_delay_seconds must be >= 0"): + AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + post_payment_retry_delay_seconds=-1.0, + ) + + def test_validator_rejects_non_numeric_delay(self): + """post_payment_retry_delay_seconds must be int/float, not bool/str.""" + import pytest + + with pytest.raises(ValueError, match="post_payment_retry_delay_seconds must be a number"): + AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + post_payment_retry_delay_seconds="3", # type: ignore[arg-type] + ) + + def test_validator_rejects_bool_delay(self): + """bool sneaks through isinstance(int) — explicitly reject.""" + import pytest + + with pytest.raises(ValueError, match="post_payment_retry_delay_seconds must be a number"): + AgentCorePaymentsPluginConfig( + payment_manager_arn="arn:aws:bedrock-agentcore:us-west-2:123456789012:payment-manager/test", + user_id="test-user", + post_payment_retry_delay_seconds=True, # type: ignore[arg-type] + )