From 122e7c32e7db6e6ffd009cabd5fdd43311cadd8d Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 2 Jun 2026 19:22:15 +0000 Subject: [PATCH 01/16] feat(payments): add AgentCore Payments as first-class CLI resource Adds AgentCore Payments as a first-class resource type in the CLI: - `agentcore add/remove payment-manager` and `payment-connector` (CoinbaseCDP + StripePrivy), CLI + TUI wizard - Cascading delete of connectors + credentials + .env.local cleanup - CDK-backed deploy via AgentCorePaymentManager / AgentCorePaymentConnector L3 constructs, including runtime-role payment data-plane IAM grants - Payment credential provider setup (imperative, AgentCore Identity vault) - CFN output parsing into deployed-state - Invoke flags: --payment-instrument-id, --payment-session-id, --auto-session - Strands template wires AgentCorePaymentsPlugin; PAYMENT_SYSTEM_PROMPT references the plugin-provided http_request tool Schema: - payments[] in agentcore.json, .optional() (non-breaking: absent configs are not rewritten with payments: []) - payment manager name regex matches CreatePaymentManager API (no underscore); connector names allow underscores per CreatePaymentConnector - getOrCreatePaymentSession unwraps the CreatePaymentSession `paymentSession` response so --auto-session forwards a real session id Verified end-to-end on Base Sepolia: real on-chain USDC settle via the SDK plugin (bedrock-agentcore >= 1.12.0 / PR #493). --- AGENTS.md | 6 +- docs/commands.md | 149 ++- docs/configuration.md | 99 +- docs/payments.md | 343 +++++++ e2e-tests/payment-strands-bedrock.test.ts | 212 ++++ e2e-tests/payment-validation.test.ts | 301 ++++++ integ-tests/add-remove-payment.test.ts | 511 ++++++++++ .../assets.snapshot.test.ts.snap | 409 +++++++- src/assets/cdk/bin/cdk.ts | 35 +- src/assets/cdk/lib/cdk-stack.ts | 163 +++- src/assets/cdk/test/cdk.test.ts | 2 + .../langchain_langgraph/base/pyproject.toml | 2 +- .../python/agui/googleadk/base/pyproject.toml | 2 +- .../langchain_langgraph/base/pyproject.toml | 2 +- .../python/http/autogen/base/pyproject.toml | 2 +- .../langchain_langgraph/base/pyproject.toml | 2 +- .../http/openaiagents/base/pyproject.toml | 2 +- src/assets/python/http/strands/base/main.py | 41 +- .../strands/capabilities/payments/__init__.py | 4 + .../strands/capabilities/payments/payments.py | 142 +++ src/cli/aws/agentcore-payments.ts | 491 ++++++++++ src/cli/aws/agentcore.ts | 27 +- src/cli/aws/index.ts | 8 + src/cli/cdk/toolkit-lib/types.ts | 6 + src/cli/cdk/toolkit-lib/wrapper.ts | 9 +- .../__tests__/parse-payment-outputs.test.ts | 350 +++++++ src/cli/cloudformation/outputs.ts | 75 ++ src/cli/commands/deploy/actions.ts | 108 ++- src/cli/commands/dev/browser-mode.ts | 5 + src/cli/commands/dev/command.tsx | 3 +- src/cli/commands/invoke/action.ts | 58 ++ src/cli/commands/invoke/command.tsx | 16 +- src/cli/commands/invoke/types.ts | 6 + src/cli/commands/invoke/validate.ts | 12 + .../commands/logs/__tests__/action.test.ts | 4 + src/cli/commands/remove/command.tsx | 58 +- src/cli/commands/remove/types.ts | 4 +- src/cli/commands/status/action.ts | 53 +- src/cli/commands/status/command.tsx | 11 + .../validate/__tests__/action.test.ts | 251 +++++ src/cli/commands/validate/action.ts | 129 ++- src/cli/commands/validate/command.tsx | 11 +- src/cli/errors.ts | 28 + .../__tests__/checks-extended.test.ts | 10 + src/cli/logging/remove-logger.ts | 4 +- .../agent/generate/schema-mapper.ts | 8 + .../agent/generate/write-agent-to-project.ts | 1 + .../deploy/__tests__/assert-env-file.test.ts | 156 +++ .../__tests__/post-deploy-ab-tests.test.ts | 1 + .../post-deploy-config-bundles.test.ts | 1 + .../post-deploy-http-gateways.test.ts | 1 + .../__tests__/pre-deploy-payments.test.ts | 440 +++++++++ src/cli/operations/deploy/index.ts | 11 + .../operations/deploy/pre-deploy-identity.ts | 258 ++++- src/cli/operations/deploy/preflight.ts | 7 +- src/cli/operations/deploy/teardown.ts | 1 + .../operations/dev/__tests__/config.test.ts | 21 + .../dev/__tests__/payment-env.test.ts | 188 ++++ src/cli/operations/dev/load-dev-env.ts | 14 +- src/cli/operations/dev/payment-env.ts | 67 ++ .../primitives/PaymentConnectorPrimitive.ts | 602 ++++++++++++ src/cli/primitives/PaymentManagerPrimitive.ts | 718 ++++++++++++++ .../__tests__/GatewayPrimitive.test.ts | 1 + .../PaymentConnectorPrimitive.test.ts | 480 +++++++++ .../__tests__/PaymentManagerPrimitive.test.ts | 392 ++++++++ .../primitives/__tests__/auth-utils.test.ts | 1 + .../__tests__/credential-utils.test.ts | 60 ++ .../__tests__/payment-validation.test.ts | 134 +++ .../__tests__/wirePaymentCapability.test.ts | 918 ++++++++++++++++++ src/cli/primitives/credential-utils.ts | 36 + src/cli/primitives/payment-eligible.ts | 37 + src/cli/primitives/registry.ts | 6 + src/cli/project.ts | 1 + src/cli/telemetry/schemas/command-run.ts | 6 + src/cli/templates/BaseRenderer.ts | 18 +- src/cli/templates/render.ts | 2 +- src/cli/templates/types.ts | 1 + src/cli/tui/components/ResourceGraph.tsx | 1 + src/cli/tui/hooks/useCdkPreflight.ts | 133 ++- src/cli/tui/hooks/useDevServer.ts | 3 +- src/cli/tui/hooks/useRemove.ts | 12 + src/cli/tui/screens/add/AddFlow.tsx | 20 + src/cli/tui/screens/add/AddScreen.tsx | 4 +- src/cli/tui/screens/deploy/useDeployFlow.ts | 47 +- .../payment/AddPaymentConnectorScreen.tsx | 305 ++++++ .../tui/screens/payment/AddPaymentFlow.tsx | 471 +++++++++ .../payment/AddPaymentManagerScreen.tsx | 336 +++++++ src/cli/tui/screens/payment/index.ts | 15 + src/cli/tui/screens/payment/types.ts | 123 +++ .../screens/payment/useAddPaymentWizard.ts | 348 +++++++ .../tui/screens/payment/useCreatePayment.ts | 151 +++ src/cli/tui/screens/remove/RemoveFlow.tsx | 110 ++- src/cli/tui/screens/remove/RemoveScreen.tsx | 12 + .../remove/__tests__/RemoveScreen.test.tsx | 4 + src/cli/tui/screens/remove/useRemoveFlow.ts | 7 + src/lib/packaging/__tests__/helpers.test.ts | 26 + src/lib/packaging/helpers.ts | 39 +- src/lib/utils/env.ts | 22 + src/lib/utils/index.ts | 2 +- .../__tests__/agentcore-project.test.ts | 32 + src/schema/schemas/__tests__/payment.test.ts | 114 +++ src/schema/schemas/agentcore-project.ts | 78 +- src/schema/schemas/deployed-state.ts | 34 +- src/schema/schemas/primitives/index.ts | 17 + src/schema/schemas/primitives/payment.ts | 102 ++ 105 files changed, 11196 insertions(+), 126 deletions(-) create mode 100644 docs/payments.md create mode 100644 e2e-tests/payment-strands-bedrock.test.ts create mode 100644 e2e-tests/payment-validation.test.ts create mode 100644 integ-tests/add-remove-payment.test.ts create mode 100644 src/assets/python/http/strands/capabilities/payments/__init__.py create mode 100644 src/assets/python/http/strands/capabilities/payments/payments.py create mode 100644 src/cli/aws/agentcore-payments.ts create mode 100644 src/cli/cloudformation/__tests__/parse-payment-outputs.test.ts create mode 100644 src/cli/operations/deploy/__tests__/assert-env-file.test.ts create mode 100644 src/cli/operations/deploy/__tests__/pre-deploy-payments.test.ts create mode 100644 src/cli/operations/dev/__tests__/payment-env.test.ts create mode 100644 src/cli/operations/dev/payment-env.ts create mode 100644 src/cli/primitives/PaymentConnectorPrimitive.ts create mode 100644 src/cli/primitives/PaymentManagerPrimitive.ts create mode 100644 src/cli/primitives/__tests__/PaymentConnectorPrimitive.test.ts create mode 100644 src/cli/primitives/__tests__/PaymentManagerPrimitive.test.ts create mode 100644 src/cli/primitives/__tests__/credential-utils.test.ts create mode 100644 src/cli/primitives/__tests__/payment-validation.test.ts create mode 100644 src/cli/primitives/__tests__/wirePaymentCapability.test.ts create mode 100644 src/cli/primitives/payment-eligible.ts create mode 100644 src/cli/tui/screens/payment/AddPaymentConnectorScreen.tsx create mode 100644 src/cli/tui/screens/payment/AddPaymentFlow.tsx create mode 100644 src/cli/tui/screens/payment/AddPaymentManagerScreen.tsx create mode 100644 src/cli/tui/screens/payment/index.ts create mode 100644 src/cli/tui/screens/payment/types.ts create mode 100644 src/cli/tui/screens/payment/useAddPaymentWizard.ts create mode 100644 src/cli/tui/screens/payment/useCreatePayment.ts create mode 100644 src/schema/schemas/__tests__/payment.test.ts create mode 100644 src/schema/schemas/primitives/payment.ts diff --git a/AGENTS.md b/AGENTS.md index 92afc67cb..4d4627610 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,9 +32,9 @@ These options are available on all commands: - `create` - Create new AgentCore project - `add` - Add resources (agent, memory, credential, evaluator, online-eval, gateway, gateway-target, policy-engine, - policy) + policy, payment-manager, payment-connector) - `remove` - Remove resources (agent, memory, credential, evaluator, online-eval, gateway, gateway-target, - policy-engine, policy, all) + policy-engine, policy, payment-manager, payment-connector, all) - `deploy` - Deploy infrastructure to AWS - `status` - Check deployment status - `dev` - Local development server (CodeZip: uvicorn with hot-reload; Container: Docker build + run with volume mount) @@ -88,6 +88,8 @@ Current primitives: - `GatewayTargetPrimitive` — gateway target creation/removal with code generation - `PolicyEnginePrimitive` — Cedar policy engine creation/removal - `PolicyPrimitive` — Cedar policy creation/removal within policy engines +- `PaymentManagerPrimitive` — payment manager creation/removal with agent code wiring +- `PaymentConnectorPrimitive` — payment connector creation/removal with credential management Singletons are created in `registry.ts` and wired into CLI commands via `cli.ts`. See `src/cli/AGENTS.md` for details on adding new primitives. diff --git a/docs/commands.md b/docs/commands.md index ac4d33aca..3105706ba 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -140,14 +140,14 @@ agentcore status --runtime-id abc123 agentcore status --json ``` -| Flag | Description | -| ------------------- | -------------------------------------------------------------------------------------------------------------------------- | -| `--runtime-id ` | Look up a specific runtime by ID | -| `--target ` | Select deployment target | -| `--type ` | Filter by resource type: `agent`, `memory`, `credential`, `gateway`, `evaluator`, `online-eval`, `policy-engine`, `policy` | -| `--state ` | Filter by deployment state: `deployed`, `local-only`, `pending-removal` | -| `--runtime ` | Filter to a specific runtime | -| `--json` | JSON output | +| Flag | Description | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| `--runtime-id ` | Look up a specific runtime by ID | +| `--target ` | Select deployment target | +| `--type ` | Filter by resource type: `agent`, `memory`, `credential`, `gateway`, `evaluator`, `online-eval`, `payment`, `policy-engine`, `policy` | +| `--state ` | Filter by deployment state: `deployed`, `local-only`, `pending-removal` | +| `--runtime ` | Filter to a specific runtime | +| `--json` | JSON output | ### validate @@ -473,6 +473,85 @@ agentcore add gateway-target \ > `open-api-schema` requires `--outbound-auth` (`oauth` or `api-key`). `api-gateway` supports `api-key` or `none`. > `mcp-server` supports `oauth` or `none`. +### add payment-manager + +Add a payment manager to the project. See [Payments](payments.md) for full usage guide. + +```bash +# Minimal (defaults: AWS_IAM, interceptor, auto-payment enabled) +agentcore add payment-manager --name MyManager + +# With CUSTOM_JWT authorization +agentcore add payment-manager \ + --name MyManager \ + --authorizer-type CUSTOM_JWT \ + --discovery-url https://cognito-idp.us-east-1.amazonaws.com/us-east-1_XXXXX/.well-known/openid-configuration \ + --allowed-clients "client-id-1,client-id-2" + +# With advanced options +agentcore add payment-manager \ + --name MyManager \ + --auto-payment true \ + --default-spend-limit 25.00 \ + --tool-allowlist "web_search,fetch_url" \ + --network-preferences "eip155:84532" +``` + +| Flag | Description | +| ---------------------------------- | ----------------------------------------------------- | +| `--name ` | Manager name (required in non-interactive mode) | +| `--authorizer-type ` | `AWS_IAM` (default) or `CUSTOM_JWT` | +| `--discovery-url ` | OIDC discovery URL (required for CUSTOM_JWT) | +| `--allowed-clients ` | Comma-separated client IDs (CUSTOM_JWT only) | +| `--allowed-audience ` | Comma-separated allowed audiences (CUSTOM_JWT only) | +| `--allowed-scopes ` | Comma-separated allowed scopes (CUSTOM_JWT only) | +| `--pattern ` | `interceptor` (default) or `tool-based` | +| `--auto-payment [value]` | Enable automatic payment: `true` (default) or `false` | +| `--default-spend-limit ` | Default session spend limit in USD (default: `10.00`) | +| `--tool-allowlist ` | Comma-separated tool names eligible for payment | +| `--network-preferences ` | Comma-separated network IDs (e.g., `eip155:84532`) | +| `--description ` | Human-readable description | +| `--json` | JSON output | + +### add payment-connector + +Add a payment connector to an existing payment manager. See [Payments](payments.md) for credential details. + +```bash +# CoinbaseCDP provider +agentcore add payment-connector \ + --manager MyManager \ + --name MyCDPConnector \ + --provider CoinbaseCDP \ + --api-key-id your-api-key-id \ + --api-key-secret your-api-key-secret \ + --wallet-secret your-wallet-secret + +# StripePrivy provider +agentcore add payment-connector \ + --manager MyManager \ + --name MyStripeConnector \ + --provider StripePrivy \ + --app-id your-app-id \ + --app-secret your-app-secret \ + --authorization-private-key your-private-key \ + --authorization-id your-auth-id +``` + +| Flag | Description | +| ----------------------------------- | ------------------------------------------ | +| `--manager ` | Parent payment manager (required) | +| `--name ` | Connector name (required) | +| `--provider ` | `CoinbaseCDP` (default) or `StripePrivy` | +| `--api-key-id ` | Coinbase CDP API Key ID | +| `--api-key-secret ` | Coinbase CDP API Key Secret | +| `--wallet-secret ` | Coinbase CDP Wallet Secret | +| `--app-id ` | Privy App ID (StripePrivy) | +| `--app-secret ` | Privy App Secret (StripePrivy) | +| `--authorization-private-key ` | ECDSA P-256 private key (StripePrivy) | +| `--authorization-id ` | Authorization key identifier (StripePrivy) | +| `--json` | JSON output | + ### add credential Add a credential to the project. Supports API key and OAuth credential types. @@ -739,19 +818,22 @@ agentcore remove runtime-endpoint --name prod agentcore remove dataset --name MyDataset agentcore remove config-bundle --name MyBundle agentcore remove ab-test --name PromptComparison +agentcore remove payment-manager --name MyManager -y +agentcore remove payment-connector --name MyCDPConnector --manager MyManager -y # Reset everything agentcore remove all -y agentcore remove all --dry-run # Preview ``` -| Flag | Description | -| ------------------- | ------------------------------------------------- | -| `--name ` | Resource name | -| `--engine ` | Policy engine name (required for `remove policy`) | -| `-y, --yes` | Skip confirmation | -| `--dry-run` | Preview (`remove all` only) | -| `--json` | JSON output | +| Flag | Description | +| ------------------- | --------------------------------------------------------- | +| `--name ` | Resource name | +| `--engine ` | Policy engine name (required for `remove policy`) | +| `--manager ` | Parent payment manager (required for `payment-connector`) | +| `-y, --yes` | Skip confirmation | +| `--dry-run` | Preview (`remove all` only) | +| `--json` | JSON output | --- @@ -815,23 +897,26 @@ agentcore invoke --exec "cat /etc/os-release" --json The prompt can come from four sources, resolved in this precedence order: `--prompt` > positional > `--prompt-file` > piped stdin. `--prompt-file` combined with piped stdin content returns a collision error — pick one. -| Flag | Description | -| ---------------------- | ---------------------------------------------------------------- | -| `[prompt]` | Prompt text (positional argument) | -| `--prompt ` | Prompt text (flag, takes precedence over positional) | -| `--prompt-file ` | Read the prompt from a file (useful for long / structured input) | -| `--runtime ` | Specific runtime | -| `--target ` | Deployment target | -| `--session-id ` | Continue a specific session | -| `--user-id ` | User ID for runtime invocation (default: `default-user`) | -| `--stream` | Stream response in real-time | -| `--tool ` | MCP tool name (use with `call-tool` prompt) | -| `--input ` | MCP tool arguments as JSON (use with `--tool`) | -| `-H, --header ` | Custom header (`"Name: Value"`, repeatable) | -| `--bearer-token ` | Bearer token for CUSTOM_JWT auth | -| `--exec` | Execute a shell command in the runtime container | -| `--timeout ` | Timeout in seconds for `--exec` commands | -| `--json` | JSON output | +| Flag | Description | +| ------------------------------ | ---------------------------------------------------------------- | +| `[prompt]` | Prompt text (positional argument) | +| `--prompt ` | Prompt text (flag, takes precedence over positional) | +| `--prompt-file ` | Read the prompt from a file (useful for long / structured input) | +| `--runtime ` | Specific runtime | +| `--target ` | Deployment target | +| `--session-id ` | Continue a specific session | +| `--user-id ` | User ID for runtime invocation (default: `default-user`) | +| `--stream` | Stream response in real-time | +| `--tool ` | MCP tool name (use with `call-tool` prompt) | +| `--input ` | MCP tool arguments as JSON (use with `--tool`) | +| `-H, --header ` | Custom header (`"Name: Value"`, repeatable) | +| `--bearer-token ` | Bearer token for CUSTOM_JWT auth | +| `--payment-instrument-id ` | Payment instrument ID for x402 payments | +| `--payment-session-id ` | Payment session ID for budget tracking | +| `--auto-session` | Auto-create/reuse a payment session for testing | +| `--exec` | Execute a shell command in the runtime container | +| `--timeout ` | Timeout in seconds for `--exec` commands | +| `--json` | JSON output | Piped stdin is auto-detected: when no prompt is supplied and stdin is not a TTY, the prompt is read from stdin. diff --git a/docs/configuration.md b/docs/configuration.md index 05f580107..ef1c41e14 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -38,6 +38,7 @@ Main project configuration using a **flat resource model**. Agents, memories, an ], "memories": [], "credentials": [], + "payments": [], "evaluators": [], "onlineEvalConfigs": [], "agentCoreGateways": [], @@ -57,6 +58,7 @@ Main project configuration using a **flat resource model**. Agents, memories, an | `credentials` | Yes | Array of credential providers (API key or OAuth) | | `evaluators` | Yes | Array of custom evaluator definitions | | `onlineEvalConfigs` | Yes | Array of online eval configurations | +| `payments` | No | Array of payment manager configurations | | `policyEngines` | No | Array of policy engine configurations | | `agentCoreGateways` | No | Array of gateway definitions | | `mcpRuntimeTools` | No | Array of MCP runtime tool definitions | @@ -482,6 +484,88 @@ implementations. --- +## Payment Manager Resource + +Payment managers define how agents handle x402 microtransactions. Each manager has one or more connectors that provide +wallet credentials. See [Payments](payments.md) for the full usage guide. + +```json +{ + "payments": [ + { + "name": "MyManager", + "authorizerType": "AWS_IAM", + "pattern": "interceptor", + "autoPayment": true, + "defaultSpendLimit": "10.00", + "paymentToolAllowlist": ["web_search", "fetch_url"], + "networkPreferences": ["eip155:84532"], + "description": "Production payment manager", + "connectors": [ + { + "name": "MyCDPConnector", + "provider": "CoinbaseCDP", + "credentialName": "my-cdp-creds" + } + ] + } + ] +} +``` + +### Payment Manager Fields + +| Field | Required | Description | +| ------------------------- | -------- | -------------------------------------------------------------------- | +| `name` | Yes | Manager name (alphanumeric + underscore, max 48, starts with letter) | +| `authorizerType` | No | `"AWS_IAM"` (default) or `"CUSTOM_JWT"` | +| `authorizerConfiguration` | Cond. | Required when `authorizerType` is `"CUSTOM_JWT"` (see below) | +| `pattern` | No | `"interceptor"` (default) or `"tool-based"` | +| `connectors` | Yes | Array of payment connector objects | +| `autoPayment` | No | Enable automatic payment (default: `true`) | +| `defaultSpendLimit` | No | Default session budget in USD (e.g., `"10.00"`) | +| `paymentToolAllowlist` | No | Array of tool names eligible for payment | +| `networkPreferences` | No | Array of network identifiers (e.g., `"eip155:84532"`) | +| `description` | No | Human-readable description | + +### Authorizer Configuration (CUSTOM_JWT) + +```json +{ + "authorizerConfiguration": { + "customJWTAuthorizer": { + "discoveryUrl": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_XXXXX/.well-known/openid-configuration", + "allowedClients": ["client-id-1"], + "allowedAudience": ["https://api.example.com"], + "allowedScopes": ["payments:read", "payments:write"] + } + } +} +``` + +| Field | Required | Description | +| ----------------- | -------- | --------------------------- | +| `discoveryUrl` | Yes | OIDC discovery URL | +| `allowedClients` | No | Array of allowed client IDs | +| `allowedAudience` | No | Array of allowed audiences | +| `allowedScopes` | No | Array of allowed scopes | + +### Payment Connector + +| Field | Required | Description | +| ---------------- | -------- | -------------------------------------------------- | +| `name` | Yes | Connector name (alphanumeric + underscore, max 48) | +| `provider` | No | `"CoinbaseCDP"` (default) or `"StripePrivy"` | +| `credentialName` | Yes | Name of the credential (maps to `.env.local` vars) | + +### Payment Credential Provider + +Payment connectors use a `PaymentCredentialProvider` credential type, distinct from `ApiKeyCredentialProvider` and +`OAuthCredentialProvider`. The credential is automatically created during `agentcore deploy` from values in +`.env.local`. You do not need to add it to the `credentials` array manually. + +--- + ## aws-targets.json Deployment target @@ -524,6 +608,19 @@ AGENTCORE_CREDENTIAL_{projectName}GEMINI=... # OAuth credentials AGENTCORE_CREDENTIAL_{projectName}{credentialName}_CLIENT_ID=my-client-id AGENTCORE_CREDENTIAL_{projectName}{credentialName}_CLIENT_SECRET=my-client-secret + +# Payment credentials - CoinbaseCDP (3 variables per connector) +AGENTCORE_CREDENTIAL_{CREDENTIAL_NAME}_API_KEY_ID=your-api-key-id +AGENTCORE_CREDENTIAL_{CREDENTIAL_NAME}_API_KEY_SECRET=your-api-key-secret +AGENTCORE_CREDENTIAL_{CREDENTIAL_NAME}_WALLET_SECRET=your-wallet-secret + +# Payment credentials - StripePrivy (4 variables per connector) +AGENTCORE_CREDENTIAL_{CREDENTIAL_NAME}_APP_ID=your-app-id +AGENTCORE_CREDENTIAL_{CREDENTIAL_NAME}_APP_SECRET=your-app-secret +AGENTCORE_CREDENTIAL_{CREDENTIAL_NAME}_AUTHORIZATION_PRIVATE_KEY=your-private-key +AGENTCORE_CREDENTIAL_{CREDENTIAL_NAME}_AUTHORIZATION_ID=your-auth-id ``` -Environment variable names should match the credential names in your configuration. +Environment variable names should match the credential names in your configuration. For payment credentials, +`{CREDENTIAL_NAME}` is the connector's `credentialName` uppercased with hyphens replaced by underscores (e.g., +`my-cdp-creds` becomes `MY_CDP_CREDS`). See [Payments](payments.md#credential-storage) for details. diff --git a/docs/payments.md b/docs/payments.md new file mode 100644 index 000000000..9239d5eb0 --- /dev/null +++ b/docs/payments.md @@ -0,0 +1,343 @@ +# Payments + +Payments enable agents to process microtransactions using the [x402 protocol](https://www.x402.org/). When an agent's +HTTP tool call receives a `402 Payment Required` response, the payments system automatically signs and submits payment, +then retries the original request. This lets agents access paid APIs and services without manual intervention. + +For a full overview of the payment architecture, see +[AgentCore Payments](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments.html) in the AWS developer +guide. + +## Quick Start + +```bash +# 1. Create a project with payments capability +agentcore create --name MyProject --defaults +cd MyProject + +# 2. Add a payment manager +agentcore add payment-manager --name MyManager --pattern interceptor + +# 3. Add a payment connector with CoinbaseCDP credentials +agentcore add payment-connector \ + --manager MyManager \ + --name MyCDPConnector \ + --provider CoinbaseCDP \ + --api-key-id your-api-key-id \ + --api-key-secret your-api-key-secret \ + --wallet-secret your-wallet-secret + +# 4. Deploy (creates payment infrastructure on AWS) +agentcore deploy -y + +# 5. Invoke with auto-session (creates a test payment session) +agentcore invoke --auto-session --prompt "Use a paid tool" +``` + +> **Note**: `--auto-session` requires a successful deploy first because it reads from deployed state to locate the +> payment manager ARN and create a session. + +## How It Works + +When an agent makes an HTTP request to a paid endpoint, the server returns a `402 Payment Required` response containing +payment requirements (amount, recipient, network). The AgentCore payments plugin intercepts this response, calls +`ProcessPayment` to sign a USDC transaction, and retries the original request with payment proof headers attached. + +For the full runtime flow, see +[How AgentCore payments works](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments-how-it-works.html). + +### Payment Patterns + +| Pattern | Behavior | +| ----------- | ------------------------------------------------------------ | +| interceptor | Automatically handles 402 responses (transparent to agent) | +| tool-based | Exposes payment as an agent tool (agent decides when to pay) | + +## Adding a Payment Manager + +A payment manager is the top-level resource that orchestrates payment operations. It defines authorization, spending +patterns, and budget defaults. + +### CLI Command + +```bash +# Minimal (defaults: AWS_IAM auth, interceptor pattern, auto-payment enabled) +agentcore add payment-manager --name MyManager + +# With all advanced options +agentcore add payment-manager \ + --name MyManager \ + --authorizer-type AWS_IAM \ + --pattern interceptor \ + --auto-payment true \ + --default-spend-limit 25.00 \ + --tool-allowlist "web_search,fetch_url" \ + --network-preferences "eip155:84532,eip155:8453" \ + --description "Production payment manager" +``` + +| Flag | Description | +| ---------------------------------- | ------------------------------------------------------------------- | +| `--name ` | Manager name (required in non-interactive mode) | +| `--authorizer-type ` | `AWS_IAM` (default) or `CUSTOM_JWT` | +| `--discovery-url ` | OIDC discovery URL (required for CUSTOM_JWT) | +| `--allowed-clients ` | Comma-separated client IDs (CUSTOM_JWT only) | +| `--allowed-audience ` | Comma-separated allowed audiences (CUSTOM_JWT only) | +| `--allowed-scopes ` | Comma-separated allowed scopes (CUSTOM_JWT only) | +| `--pattern ` | `interceptor` (default) or `tool-based` | +| `--auto-payment [value]` | Enable automatic payment: `true` (default) or `false` | +| `--default-spend-limit ` | Default session spend limit in USD (default: `10.00`) | +| `--tool-allowlist ` | Comma-separated tool names eligible for payment | +| `--network-preferences ` | Comma-separated network IDs (e.g., `eip155:84532` for Base Sepolia) | +| `--description ` | Human-readable description | +| `--json` | Output result as JSON | + +Name constraints: must start with a letter, contain only alphanumeric characters and underscores, max 48 characters. + +When you add a payment manager, the CLI automatically patches your agent code to include the payments plugin. The +generated code is at `capabilities/payments/payments.py` in each agent's directory. + +### Authorization Types + +**AWS_IAM** (default): Uses AWS IAM SigV4 signing for payment authorization. No additional configuration needed. + +```bash +agentcore add payment-manager --name MyManager --authorizer-type AWS_IAM +``` + +**CUSTOM_JWT**: Uses a custom JWT authorizer via OIDC discovery. Useful when end users authenticate via an external +identity provider (e.g., Cognito). + +```bash +agentcore add payment-manager \ + --name MyManager \ + --authorizer-type CUSTOM_JWT \ + --discovery-url https://cognito-idp.us-east-1.amazonaws.com/us-east-1_XXXXX/.well-known/openid-configuration \ + --allowed-clients "client-id-1,client-id-2" \ + --allowed-audience "https://api.example.com" \ + --allowed-scopes "payments:read,payments:write" +``` + +For details on IAM role separation (ManagementRole vs ProcessPaymentRole), see +[IAM roles for AgentCore payments](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments-iam-roles.html). + +## Adding a Payment Connector + +A payment connector links a credential provider (wallet credentials) to a payment manager. Each manager needs at least +one connector before it can process payments. + +### CoinbaseCDP Provider + +```bash +agentcore add payment-connector \ + --manager MyManager \ + --name MyCDPConnector \ + --provider CoinbaseCDP \ + --api-key-id your-api-key-id \ + --api-key-secret your-api-key-secret \ + --wallet-secret your-wallet-secret +``` + +| Flag | Description | +| --------------------------- | ---------------------------------------- | +| `--manager ` | Parent payment manager (required) | +| `--name ` | Connector name (required) | +| `--provider ` | `CoinbaseCDP` (default) or `StripePrivy` | +| `--api-key-id ` | Coinbase CDP API Key ID | +| `--api-key-secret ` | Coinbase CDP API Key Secret | +| `--wallet-secret ` | Coinbase CDP Wallet Secret (ECDSA P-256) | +| `--json` | Output result as JSON | + +### StripePrivy Provider + +```bash +agentcore add payment-connector \ + --manager MyManager \ + --name MyStripeConnector \ + --provider StripePrivy \ + --app-id your-privy-app-id \ + --app-secret your-privy-app-secret \ + --authorization-private-key your-ecdsa-private-key \ + --authorization-id your-authorization-key-id +``` + +| Flag | Description | +| ----------------------------------- | ----------------------------------- | +| `--manager ` | Parent payment manager (required) | +| `--name ` | Connector name (required) | +| `--provider ` | Must be `StripePrivy` | +| `--app-id ` | Privy App ID | +| `--app-secret ` | Privy App Secret | +| `--authorization-private-key ` | ECDSA P-256 private key for signing | +| `--authorization-id ` | Authorization key identifier | +| `--json` | Output result as JSON | + +### Credential Storage + +Connector credentials are stored in `agentcore/.env.local` and never committed to source control. The env var naming +convention is: + +**CoinbaseCDP** (3 variables): + +``` +AGENTCORE_CREDENTIAL_{CREDENTIAL_NAME}_API_KEY_ID=... +AGENTCORE_CREDENTIAL_{CREDENTIAL_NAME}_API_KEY_SECRET=... +AGENTCORE_CREDENTIAL_{CREDENTIAL_NAME}_WALLET_SECRET=... +``` + +**StripePrivy** (4 variables): + +``` +AGENTCORE_CREDENTIAL_{CREDENTIAL_NAME}_APP_ID=... +AGENTCORE_CREDENTIAL_{CREDENTIAL_NAME}_APP_SECRET=... +AGENTCORE_CREDENTIAL_{CREDENTIAL_NAME}_AUTHORIZATION_PRIVATE_KEY=... +AGENTCORE_CREDENTIAL_{CREDENTIAL_NAME}_AUTHORIZATION_ID=... +``` + +`{CREDENTIAL_NAME}` is the connector's credential name uppercased with hyphens replaced by underscores. For example, a +credential named `my-cdp-creds` becomes `AGENTCORE_CREDENTIAL_MY_CDP_CREDS_API_KEY_ID`. + +### Credential Rotation + +To rotate credentials: + +1. Update the values in `agentcore/.env.local` +2. Run `agentcore deploy -y` + +Deploy automatically updates the PaymentCredentialProvider on AWS with the new secret values. + +## Deploying with Payments + +When you run `agentcore deploy`, the CLI creates payment infrastructure via direct API calls (not CloudFormation). The +deploy sequence for each payment manager: + +1. Reads credentials from `.env.local` +2. Creates or updates a **PaymentCredentialProvider** with the connector secrets +3. Creates **IAM roles** (ProcessPaymentRole and ResourceRetrievalRole) if they don't exist +4. Creates the **PaymentManager** (skipped if it already exists) +5. Creates or updates the **PaymentConnector** linking credentials to the manager + +### Prerequisites + +- `agentcore/.env.local` must exist with all required credential variables +- Each manager must have at least one connector configured +- AWS credentials with sufficient permissions (see + [IAM roles](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments-iam-roles.html)) + +> **Note**: First-time deployment takes extra time for IAM role creation and propagation. Subsequent deploys are faster. + +## Invoking with Payment Context + +After deploying, use `agentcore invoke` to test agents with payment capabilities. + +### Payment Flags + +| Flag | Description | +| ------------------------------ | --------------------------------------------------------- | +| `--payment-instrument-id ` | Payment instrument ID (a funded wallet) for x402 payments | +| `--payment-session-id ` | Payment session ID for budget tracking | +| `--auto-session` | Auto-create or reuse a payment session for testing | + +### Auto-Session Mode + +`--auto-session` creates a temporary payment session with the default spend limit, or reuses an existing one from the +current testing context. This is the simplest way to test payment flows without manually creating instruments and +sessions via the AWS API. + +```bash +agentcore invoke --auto-session --prompt "Search for paid research papers" +``` + +### Explicit Payment Context + +For production testing with specific instruments and sessions: + +```bash +agentcore invoke \ + --payment-instrument-id payment-instrument-abc123 \ + --payment-session-id payment-session-xyz789 \ + --prompt "Process a payment for the weather API" +``` + +For details on creating instruments and sessions, see +[Create a payment instrument](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments-create-instrument.html). + +## Status and Removal + +### Checking Status + +```bash +agentcore status --type payment +``` + +Shows each payment manager's deployment state, connector count, and live health from the AWS API. The status command +queries the deployed payment manager to verify it's reachable. + +### Removing a Connector + +```bash +agentcore remove payment-connector --name MyCDPConnector --manager MyManager -y +``` + +The `--manager` flag is required when a connector name exists under multiple managers. + +### Removing a Manager + +```bash +agentcore remove payment-manager --name MyManager -y +``` + +Removing a payment manager cascades: it deletes all associated connectors and credential providers from the local +configuration. + +## Validation + +`agentcore validate` checks payment configuration for common issues: + +- Credential cross-references: verifies each connector's `credentialName` maps to a valid credential entry +- `.env.local` existence: confirms the secrets file exists when payment connectors are configured +- Missing environment variables: checks that all required `AGENTCORE_CREDENTIAL_*` variables are present + +```bash +agentcore validate +``` + +## Troubleshooting + +| Error | Cause | Fix | +| ------------------------------------- | ---------------------------------------- | -------------------------------------------------------------- | +| `.env.local not found` | No secrets file in project | Create `agentcore/.env.local` with credential vars | +| `Missing credentials for connector` | Env vars not set for a connector | Add the required `AGENTCORE_CREDENTIAL_*` vars to `.env.local` | +| `ServiceQuotaExceededException` | Account limit on payment managers | Request a quota increase via AWS Support | +| `No connectors for payment manager` | Manager has zero connectors | Add at least one connector before deploying | +| `PaymentCredentialProvider not found` | Orphaned reference after manual deletion | Re-run `agentcore deploy` to recreate | +| `Request timeout` | Network or service availability | Retry deploy; check internet connectivity | +| `Invalid authorizer type` | Typo in `--authorizer-type` flag | Use `AWS_IAM` or `CUSTOM_JWT` (case-sensitive) | + +For additional troubleshooting, see +[Troubleshooting AgentCore payments](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments-troubleshooting.html). + +## Further Reading + +**AWS Documentation:** + +- [AgentCore Payments overview](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments.html) +- [Core concepts](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments-concepts.html) +- [How it works](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments-how-it-works.html) +- [Getting started](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments-getting-started.html) +- [Prerequisites](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments-prerequisites.html) +- [IAM roles](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments-iam-roles.html) +- [Create manager and connector](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments-create-manager.html) +- [Create instrument](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments-create-instrument.html) +- [Process a payment](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments-process-payment.html) +- [Coinbase Bazaar via Gateway](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments-connect-bazaar.html) +- [Observability](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments-observability.html) +- [Troubleshooting](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments-troubleshooting.html) + +**Blog:** + +- [Agents that transact: Introducing Amazon Bedrock AgentCore Payments](https://aws.amazon.com/blogs/machine-learning/agents-that-transact-introducing-amazon-bedrock-agentcore-payments-built-with-coinbase-and-stripe/) + +**Samples:** + +- [x402 Payments with CloudFront](https://github.com/aws-samples/sample-agentcore-cloudfront-x402-payments) diff --git a/e2e-tests/payment-strands-bedrock.test.ts b/e2e-tests/payment-strands-bedrock.test.ts new file mode 100644 index 000000000..33c1eb63c --- /dev/null +++ b/e2e-tests/payment-strands-bedrock.test.ts @@ -0,0 +1,212 @@ +/** + * E2E test: Payment manager + connector → deploy → status + * + * Creates a Strands/Bedrock project with a payment manager and connector, + * deploys it to AWS, and verifies payment infrastructure is created correctly. + * + * Required env vars: + * - AWS credentials (via profile or env vars) + * - CDP_API_KEY_ID, CDP_API_KEY_SECRET, CDP_WALLET_SECRET (for connector creation) + * - CDK_TARBALL (optional — path to payment-aware CDK constructs tgz) + */ +import { hasAwsCredentials, parseJsonOutput, prereqs, retry } from '../src/test-utils/index.js'; +import { installCdkTarball, runAgentCoreCLI, teardownE2EProject, writeAwsTargets } from './e2e-helper.js'; +import { randomUUID } from 'node:crypto'; +import { mkdir, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const hasAws: boolean = hasAwsCredentials(); +const hasCdpCreds = !!(process.env.CDP_API_KEY_ID && process.env.CDP_API_KEY_SECRET && process.env.CDP_WALLET_SECRET); +const canRun = prereqs.npm && prereqs.git && prereqs.uv && hasAws && hasCdpCreds; + +describe.sequential('e2e: payments — create → add payment → deploy → status', () => { + let testDir: string; + let projectPath: string; + let agentName: string; + const managerName = 'E2ePayMgr'; + const connectorName = 'E2ePayConn'; + + beforeAll(async () => { + if (!canRun) return; + + testDir = join(tmpdir(), `agentcore-e2e-pay-${randomUUID()}`); + await mkdir(testDir, { recursive: true }); + + agentName = `E2ePay${String(Date.now()).slice(-8)}`; + + // Create project + const createResult = await runAgentCoreCLI( + [ + 'create', + '--name', + agentName, + '--language', + 'Python', + '--framework', + 'Strands', + '--model-provider', + 'Bedrock', + '--memory', + 'none', + '--json', + ], + testDir + ); + + expect(createResult.exitCode, `Create failed: ${createResult.stderr}`).toBe(0); + const createJson = parseJsonOutput(createResult.stdout) as { projectPath: string }; + projectPath = createJson.projectPath; + + // Add payment manager + const mgrResult = await runAgentCoreCLI( + ['add', 'payment-manager', '--name', managerName, '--pattern', 'interceptor', '--json'], + projectPath + ); + expect(mgrResult.exitCode, `Add manager failed: ${mgrResult.stderr}`).toBe(0); + + // Add payment connector with CDP credentials + const connResult = await runAgentCoreCLI( + [ + 'add', + 'payment-connector', + '--manager', + managerName, + '--name', + connectorName, + '--provider', + 'CoinbaseCDP', + '--api-key-id', + process.env.CDP_API_KEY_ID!, + '--api-key-secret', + process.env.CDP_API_KEY_SECRET!, + '--wallet-secret', + process.env.CDP_WALLET_SECRET!, + '--json', + ], + projectPath + ); + expect(connResult.exitCode, `Add connector failed: ${connResult.stderr}`).toBe(0); + + // Write AWS targets + install CDK tarball + await writeAwsTargets(projectPath); + installCdkTarball(projectPath); + }, 300000); + + afterAll(async () => { + if (projectPath && hasAws) { + await teardownE2EProject(projectPath, agentName, 'Bedrock'); + } + if (testDir) await rm(testDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 1000 }); + }, 600000); + + it.skipIf(!canRun)('has correct agentcore.json structure', async () => { + const configPath = join(projectPath, 'agentcore', 'agentcore.json'); + const config = JSON.parse(await readFile(configPath, 'utf-8')); + + // Manager exists with correct fields + const manager = config.payments?.find((p: Record) => p.name === managerName); + expect(manager).toBeTruthy(); + expect(manager.authorizerType).toBe('AWS_IAM'); + expect(manager.pattern).toBe('interceptor'); + + // Connector nested inside manager + const connector = manager.connectors?.find((c: Record) => c.name === connectorName); + expect(connector).toBeTruthy(); + expect(connector.provider).toBe('CoinbaseCDP'); + + // Credential exists + const cred = config.credentials?.find( + (c: Record) => c.authorizerType === 'PaymentCredentialProvider' + ); + expect(cred).toBeTruthy(); + }); + + it.skipIf(!canRun)('has payment capability code in agent', async () => { + const config = JSON.parse(await readFile(join(projectPath, 'agentcore', 'agentcore.json'), 'utf-8')); + const runtimeName = config.runtimes?.[0]?.name; + expect(runtimeName).toBeTruthy(); + + // payments.py exists with per-invocation factory + const paymentsCode = await readFile( + join(projectPath, 'app', runtimeName, 'capabilities', 'payments', 'payments.py'), + 'utf-8' + ); + expect(paymentsCode).toContain('create_payments_plugin'); + expect(paymentsCode).toContain('user_id'); + expect(paymentsCode).toContain('instrument_id'); + expect(paymentsCode).toContain('session_id'); + }); + + it.skipIf(!canRun)( + 'deploys to AWS successfully', + async () => { + expect(projectPath).toBeTruthy(); + + await retry( + async () => { + const result = await runAgentCoreCLI(['deploy', '--yes', '--json'], projectPath); + + if (result.exitCode !== 0) { + console.log('Deploy stdout:', result.stdout); + console.log('Deploy stderr:', result.stderr); + } + + expect(result.exitCode, `Deploy failed: ${result.stderr}`).toBe(0); + + const json = parseJsonOutput(result.stdout) as { success: boolean }; + expect(json.success).toBe(true); + }, + 1, + 30000 + ); + }, + 600000 + ); + + it.skipIf(!canRun)('status shows payment manager', async () => { + expect(projectPath).toBeTruthy(); + + const result = await runAgentCoreCLI(['status', '--json'], projectPath); + expect(result.exitCode).toBe(0); + + const json = parseJsonOutput(result.stdout) as { + success: boolean; + resources: { resourceType: string; name: string; deploymentState: string }[]; + }; + expect(json.success).toBe(true); + + // Find payment resource + const paymentResource = json.resources?.find(r => r.resourceType === 'payment' && r.name === managerName); + expect(paymentResource, 'Payment manager should appear in status').toBeTruthy(); + expect(paymentResource!.deploymentState).toBe('deployed'); + }); + + it.skipIf(!canRun)('deployed-state.json has payment manager and connector info', async () => { + // Read deployed state from the CLI's internal state + const statePath = join(projectPath, 'agentcore', '.cli', 'deployed-state.json'); + const state = JSON.parse(await readFile(statePath, 'utf-8')); + + const targetState = Object.values(state.targets)[0] as Record; + const resources = targetState?.resources as Record; + const payments = resources?.payments as Record; + + expect(payments).toBeTruthy(); + const managerState = payments[managerName] as Record; + expect(managerState).toBeTruthy(); + expect(managerState.managerId).toBeTruthy(); + expect(managerState.managerArn).toBeTruthy(); + expect(managerState.processPaymentRoleArn).toBeTruthy(); + expect(managerState.resourceRetrievalRoleArn).toBeTruthy(); + expect(managerState.roleCreatedByCli).toBe(true); + + // Connector info + const connectors = managerState.connectors as Record>; + expect(connectors).toBeTruthy(); + const connState = connectors[connectorName]; + expect(connState).toBeTruthy(); + expect(connState!.connectorId).toBeTruthy(); + expect(connState!.credentialProviderArn).toBeTruthy(); + }); +}); diff --git a/e2e-tests/payment-validation.test.ts b/e2e-tests/payment-validation.test.ts new file mode 100644 index 000000000..a60bc7ab4 --- /dev/null +++ b/e2e-tests/payment-validation.test.ts @@ -0,0 +1,301 @@ +/** + * E2E test: Payment validation, config fields, and remove lifecycle + * + * Tests payment-specific validation (whitespace creds, StripePrivy key format), + * config fields (autoPayment, defaultSpendLimit, paymentToolAllowlist, networkPreferences), + * and remove cascading behavior. No AWS deploy needed — all local. + */ +import { parseJsonOutput, prereqs } from '../src/test-utils/index.js'; +import { runAgentCoreCLI } from './e2e-helper.js'; +import { randomUUID } from 'node:crypto'; +import { mkdir, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const canRun = prereqs.npm && prereqs.git && prereqs.uv; + +describe.sequential('e2e: payments — validation, config, and remove lifecycle', () => { + let testDir: string; + let projectPath: string; + + beforeAll(async () => { + if (!canRun) return; + + testDir = join(tmpdir(), `agentcore-e2e-payval-${randomUUID()}`); + await mkdir(testDir, { recursive: true }); + + const createResult = await runAgentCoreCLI( + [ + 'create', + '--name', + 'PayVal', + '--language', + 'Python', + '--framework', + 'Strands', + '--model-provider', + 'Bedrock', + '--memory', + 'none', + '--json', + ], + testDir + ); + expect(createResult.exitCode, `Create failed: stdout=${createResult.stdout} stderr=${createResult.stderr}`).toBe(0); + const createJson = parseJsonOutput(createResult.stdout) as { projectPath: string }; + projectPath = createJson.projectPath; + }, 120000); + + afterAll(async () => { + if (testDir) await rm(testDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 1000 }); + }); + + // ── Config fields ───────────────────────────────────────────────────────── + + it.skipIf(!canRun)('add payment-manager with --auto-payment and --default-spend-limit', async () => { + const result = await runAgentCoreCLI( + [ + 'add', + 'payment-manager', + '--name', + 'cfgMgr', + '--pattern', + 'interceptor', + '--auto-payment', + 'false', + '--default-spend-limit', + '7.50', + '--json', + ], + projectPath + ); + expect(result.exitCode).toBe(0); + + const config = JSON.parse(await readFile(join(projectPath, 'agentcore', 'agentcore.json'), 'utf-8')); + const mgr = config.payments.find((p: Record) => p.name === 'cfgMgr'); + expect(mgr.autoPayment).toBe(false); + expect(mgr.defaultSpendLimit).toBe('7.50'); + }); + + it.skipIf(!canRun)('agentcore.json accepts paymentToolAllowlist and networkPreferences', async () => { + const configPath = join(projectPath, 'agentcore', 'agentcore.json'); + const config = JSON.parse(await readFile(configPath, 'utf-8')); + const mgr = config.payments.find((p: Record) => p.name === 'cfgMgr'); + mgr.paymentToolAllowlist = ['http_request', 'fetch_url']; + mgr.networkPreferences = ['eip155:84532']; + const { writeFile: wf } = await import('node:fs/promises'); + await wf(configPath, JSON.stringify(config, null, 2)); + + const valResult = await runAgentCoreCLI(['validate'], projectPath); + expect(valResult.exitCode).toBe(0); + }); + + // ── Validation: whitespace credentials ──────────────────────────────────── + + it.skipIf(!canRun)('rejects whitespace-only CoinbaseCDP credentials', async () => { + const result = await runAgentCoreCLI( + [ + 'add', + 'payment-connector', + '--manager', + 'cfgMgr', + '--name', + 'wsConn', + '--provider', + 'CoinbaseCDP', + '--api-key-id', + ' ', + '--api-key-secret', + ' ', + '--wallet-secret', + ' ', + '--json', + ], + projectPath + ); + expect(result.exitCode).toBe(1); + const json = parseJsonOutput(result.stdout) as { success: boolean; error: string }; + expect(json.success).toBe(false); + expect(json.error).toContain('Missing required options'); + }); + + // ── Validation: StripePrivy key format ──────────────────────────────────── + + it.skipIf(!canRun)('rejects non-base64 StripePrivy authorizationPrivateKey', async () => { + const result = await runAgentCoreCLI( + [ + 'add', + 'payment-connector', + '--manager', + 'cfgMgr', + '--name', + 'badKey', + '--provider', + 'StripePrivy', + '--app-id', + 'test', + '--app-secret', + 'test', + '--authorization-private-key', + 'not-base64!', + '--authorization-id', + 'test', + '--json', + ], + projectPath + ); + expect(result.exitCode).toBe(1); + const json = parseJsonOutput(result.stdout) as { success: boolean; error: string }; + expect(json.success).toBe(false); + expect(json.error).toContain('base64'); + }); + + it.skipIf(!canRun)('rejects too-short StripePrivy authorizationPrivateKey', async () => { + const result = await runAgentCoreCLI( + [ + 'add', + 'payment-connector', + '--manager', + 'cfgMgr', + '--name', + 'shortKey', + '--provider', + 'StripePrivy', + '--app-id', + 'test', + '--app-secret', + 'test', + '--authorization-private-key', + 'dGVzdA==', + '--authorization-id', + 'test', + '--json', + ], + projectPath + ); + expect(result.exitCode).toBe(1); + const json = parseJsonOutput(result.stdout) as { success: boolean; error: string }; + expect(json.success).toBe(false); + expect(json.error).toContain('EC P-256'); + }); + + it.skipIf(!canRun)('accepts valid StripePrivy credentials (PKCS#8 P-256 key)', async () => { + const validKey = + 'MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgX172itZu99Ae6bmVpS+6bwKyFmbuy9vkHAIEXwi1IduhRANCAAS160HztG9NZvTv05zfg76koloQ5G+NJwN8lVR5rRKmCLqe+pyc0znwF9Q+LsENdGqi7zTWVVJhhEq3Xa5Tm4F4'; + const result = await runAgentCoreCLI( + [ + 'add', + 'payment-connector', + '--manager', + 'cfgMgr', + '--name', + 'spConn', + '--provider', + 'StripePrivy', + '--app-id', + 'privy-app', + '--app-secret', + 'privy-secret', + '--authorization-private-key', + validKey, + '--authorization-id', + 'auth-123', + '--json', + ], + projectPath + ); + expect(result.exitCode).toBe(0); + const json = parseJsonOutput(result.stdout) as { success: boolean }; + expect(json.success).toBe(true); + }); + + // ── Validation: duplicate names ─────────────────────────────────────────── + + it.skipIf(!canRun)('rejects duplicate manager name', async () => { + const result = await runAgentCoreCLI( + ['add', 'payment-manager', '--name', 'cfgMgr', '--pattern', 'interceptor', '--json'], + projectPath + ); + expect(result.exitCode).toBe(1); + const json = parseJsonOutput(result.stdout) as { success: boolean; error: string }; + expect(json.error).toContain('already exists'); + }); + + it.skipIf(!canRun)('rejects connector on non-existent manager', async () => { + const result = await runAgentCoreCLI( + [ + 'add', + 'payment-connector', + '--manager', + 'ghostMgr', + '--name', + 'x', + '--provider', + 'CoinbaseCDP', + '--api-key-id', + 'a', + '--api-key-secret', + 'b', + '--wallet-secret', + 'c', + '--json', + ], + projectPath + ); + expect(result.exitCode).toBe(1); + const json = parseJsonOutput(result.stdout) as { success: boolean; error: string }; + expect(json.error).toContain('not found'); + }); + + // ── Remove lifecycle ────────────────────────────────────────────────────── + + it.skipIf(!canRun)('add CDP connector for remove testing', async () => { + const result = await runAgentCoreCLI( + [ + 'add', + 'payment-connector', + '--manager', + 'cfgMgr', + '--name', + 'cdpConn', + '--provider', + 'CoinbaseCDP', + '--api-key-id', + 'key-id', + '--api-key-secret', + 'key-secret', + '--wallet-secret', + 'wallet-secret', + '--json', + ], + projectPath + ); + expect(result.exitCode).toBe(0); + }); + + it.skipIf(!canRun)('remove connector cleans env vars', async () => { + const result = await runAgentCoreCLI( + ['remove', 'payment-connector', '--manager', 'cfgMgr', '--name', 'cdpConn', '--yes', '--json'], + projectPath + ); + expect(result.exitCode).toBe(0); + + const envContent = await readFile(join(projectPath, 'agentcore', '.env.local'), 'utf-8'); + expect(envContent).not.toContain('CDPCONN_CDP_API_KEY_ID'); + }); + + it.skipIf(!canRun)('remove manager cascades (removes remaining connectors + env vars)', async () => { + const result = await runAgentCoreCLI( + ['remove', 'payment-manager', '--name', 'cfgMgr', '--yes', '--json'], + projectPath + ); + expect(result.exitCode).toBe(0); + + const config = JSON.parse(await readFile(join(projectPath, 'agentcore', 'agentcore.json'), 'utf-8')); + expect(config.payments).toEqual([]); + + const envContent = await readFile(join(projectPath, 'agentcore', '.env.local'), 'utf-8'); + expect(envContent.trim()).toBe(''); + }); +}); diff --git a/integ-tests/add-remove-payment.test.ts b/integ-tests/add-remove-payment.test.ts new file mode 100644 index 000000000..68d28c013 --- /dev/null +++ b/integ-tests/add-remove-payment.test.ts @@ -0,0 +1,511 @@ +import { createTestProject, readProjectConfig, runCLI } from '../src/test-utils/index.js'; +import type { TestProject } from '../src/test-utils/index.js'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +describe('integration: add and remove payment managers and connectors', () => { + let project: TestProject; + + beforeAll(async () => { + project = await createTestProject({ + language: 'Python', + framework: 'Strands', + modelProvider: 'Bedrock', + memory: 'none', + }); + }); + + afterAll(async () => { + await project.cleanup(); + }); + + describe('payment manager lifecycle', () => { + const managerName = `IntegMgr${Date.now().toString().slice(-6)}`; + + it('adds an AWS_IAM payment manager', async () => { + const result = await runCLI( + ['add', 'payment-manager', '--name', managerName, '--pattern', 'interceptor', '--json'], + project.projectPath + ); + + expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(true); + expect(json.managerName).toBe(managerName); + + const config = await readProjectConfig(project.projectPath); + const manager = config.payments?.find((p: Record) => p.name === managerName); + expect(manager, `Payment manager "${managerName}" should be in config`).toBeTruthy(); + expect(manager!.authorizerType).toBe('AWS_IAM'); + expect(manager!.pattern).toBe('interceptor'); + expect(manager!.connectors).toEqual([]); + }); + + it('rejects duplicate payment manager name', async () => { + const result = await runCLI(['add', 'payment-manager', '--name', managerName, '--json'], project.projectPath); + + expect(result.exitCode).toBe(1); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(false); + expect(json.error).toContain('already exists'); + }); + + it('generates payment capability code for agents', async () => { + const config = await readProjectConfig(project.projectPath); + const agentName = config.runtimes?.[0]?.name; + expect(agentName).toBeTruthy(); + + const paymentsPath = join(project.projectPath, 'app', agentName!, 'capabilities', 'payments', 'payments.py'); + const paymentsCode = await readFile(paymentsPath, 'utf-8'); + expect(paymentsCode).toContain('create_payments_plugin'); + expect(paymentsCode).toContain('instrument_id'); + expect(paymentsCode).toContain('session_id'); + }); + + it('removes the payment manager', async () => { + const result = await runCLI( + ['remove', 'payment-manager', '--name', managerName, '--yes', '--json'], + project.projectPath + ); + + expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(true); + + const config = await readProjectConfig(project.projectPath); + const found = config.payments?.some((p: Record) => p.name === managerName); + expect(found, `Payment manager "${managerName}" should be removed`).toBeFalsy(); + }); + }); + + describe('CUSTOM_JWT payment manager', () => { + const jwtManagerName = `IntegJwt${Date.now().toString().slice(-6)}`; + + it('adds a CUSTOM_JWT payment manager with OIDC config', async () => { + const result = await runCLI( + [ + 'add', + 'payment-manager', + '--name', + jwtManagerName, + '--authorizer-type', + 'CUSTOM_JWT', + '--discovery-url', + 'https://cognito-idp.us-east-1.amazonaws.com/us-east-1_test/.well-known/openid-configuration', + '--allowed-clients', + 'client-1,client-2', + '--pattern', + 'interceptor', + '--json', + ], + project.projectPath + ); + + expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(true); + + const config = await readProjectConfig(project.projectPath); + const manager = config.payments?.find((p: Record) => p.name === jwtManagerName); + expect(manager).toBeTruthy(); + expect(manager!.authorizerType).toBe('CUSTOM_JWT'); + expect((manager as any).authorizerConfiguration?.customJWTAuthorizer?.discoveryUrl).toContain( + 'openid-configuration' + ); + expect((manager as any).authorizerConfiguration?.customJWTAuthorizer?.allowedClients).toEqual([ + 'client-1', + 'client-2', + ]); + }); + + it('rejects CUSTOM_JWT without discovery-url', async () => { + const result = await runCLI( + ['add', 'payment-manager', '--name', 'noUrl', '--authorizer-type', 'CUSTOM_JWT', '--json'], + project.projectPath + ); + + expect(result.exitCode).toBe(1); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(false); + expect(json.error).toContain('--discovery-url is required'); + }); + + afterAll(async () => { + await runCLI(['remove', 'payment-manager', '--name', jwtManagerName, '--yes'], project.projectPath); + }); + }); + + describe('payment connector lifecycle', () => { + const managerName = `IntegConnMgr${Date.now().toString().slice(-6)}`; + const connectorName1 = `IntegConn1${Date.now().toString().slice(-6)}`; + const connectorName2 = `IntegConn2${Date.now().toString().slice(-6)}`; + + beforeAll(async () => { + await runCLI(['add', 'payment-manager', '--name', managerName, '--pattern', 'interceptor'], project.projectPath); + }); + + it('adds a payment connector to the manager', async () => { + const result = await runCLI( + [ + 'add', + 'payment-connector', + '--manager', + managerName, + '--name', + connectorName1, + '--provider', + 'CoinbaseCDP', + '--api-key-id', + 'test-key-id', + '--api-key-secret', + 'test-key-secret', + '--wallet-secret', + 'test-wallet-secret', + '--json', + ], + project.projectPath + ); + + expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(true); + + const config = await readProjectConfig(project.projectPath); + const manager = config.payments?.find((p: Record) => p.name === managerName); + const connector = (manager?.connectors as Record[])?.find( + (c: Record) => c.name === connectorName1 + ); + expect(connector, `Connector "${connectorName1}" should be in manager's connectors`).toBeTruthy(); + + // Verify credential was created + const cred = config.credentials?.find( + (c: Record) => c.authorizerType === 'PaymentCredentialProvider' + ); + expect(cred, 'PaymentCredentialProvider credential should exist').toBeTruthy(); + }); + + it('stores CDP secrets in .env.local', async () => { + const envPath = join(project.projectPath, 'agentcore', '.env.local'); + const envContent = await readFile(envPath, 'utf-8'); + expect(envContent).toContain('API_KEY_ID'); + expect(envContent).toContain('API_KEY_SECRET'); + expect(envContent).toContain('WALLET_SECRET'); + }); + + it('adds a second connector to the same manager', async () => { + const result = await runCLI( + [ + 'add', + 'payment-connector', + '--manager', + managerName, + '--name', + connectorName2, + '--provider', + 'CoinbaseCDP', + '--api-key-id', + 'test-key-id-2', + '--api-key-secret', + 'test-key-secret-2', + '--wallet-secret', + 'test-wallet-secret-2', + '--json', + ], + project.projectPath + ); + + expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); + + const config = await readProjectConfig(project.projectPath); + const manager = config.payments?.find((p: Record) => p.name === managerName); + const connectors = manager?.connectors as Record[]; + expect(connectors?.length).toBe(2); + }); + + it('rejects duplicate connector name', async () => { + const result = await runCLI( + [ + 'add', + 'payment-connector', + '--manager', + managerName, + '--name', + connectorName1, + '--provider', + 'CoinbaseCDP', + '--api-key-id', + 'x', + '--api-key-secret', + 'y', + '--wallet-secret', + 'z', + '--json', + ], + project.projectPath + ); + + expect(result.exitCode).toBe(1); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(false); + expect(json.error).toContain('already exists'); + }); + + it('rejects connector for non-existent manager', async () => { + const result = await runCLI( + [ + 'add', + 'payment-connector', + '--manager', + 'noSuchManager', + '--name', + 'x', + '--provider', + 'CoinbaseCDP', + '--api-key-id', + 'x', + '--api-key-secret', + 'y', + '--wallet-secret', + 'z', + '--json', + ], + project.projectPath + ); + + expect(result.exitCode).toBe(1); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(false); + expect(json.error).toContain('not found'); + }); + + it('removes a single connector', async () => { + const result = await runCLI( + ['remove', 'payment-connector', '--manager', managerName, '--name', connectorName1, '--yes', '--json'], + project.projectPath + ); + + expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(true); + + const config = await readProjectConfig(project.projectPath); + const manager = config.payments?.find((p: Record) => p.name === managerName); + const connectors = manager?.connectors as Record[]; + expect(connectors?.length).toBe(1); + expect(connectors[0]?.name).toBe(connectorName2); + }); + + it('removes the manager with remaining connector', async () => { + const result = await runCLI( + ['remove', 'payment-manager', '--name', managerName, '--yes', '--json'], + project.projectPath + ); + + expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); + + const config = await readProjectConfig(project.projectPath); + const found = config.payments?.some((p: Record) => p.name === managerName); + expect(found).toBeFalsy(); + }); + }); + + describe('StripePrivy connector lifecycle', () => { + const managerName = `IntegSpMgr${Date.now().toString().slice(-6)}`; + const connectorName = `IntegSpConn${Date.now().toString().slice(-6)}`; + + beforeAll(async () => { + await runCLI(['add', 'payment-manager', '--name', managerName, '--pattern', 'interceptor'], project.projectPath); + }); + + it('adds a StripePrivy connector to the manager', async () => { + const result = await runCLI( + [ + 'add', + 'payment-connector', + '--manager', + managerName, + '--name', + connectorName, + '--provider', + 'StripePrivy', + '--app-id', + 'test-app-id', + '--app-secret', + 'test-app-secret', + '--authorization-private-key', + 'RkFLRV9TVFJJUEVfUFJJVllfVEVTVF9LRVlfQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQQ==', + '--authorization-id', + 'test-auth-id', + '--json', + ], + project.projectPath + ); + + expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(true); + + const config = await readProjectConfig(project.projectPath); + const manager = config.payments?.find((p: Record) => p.name === managerName); + const connector = (manager?.connectors as Record[])?.find( + (c: Record) => c.name === connectorName + ); + expect(connector, `Connector "${connectorName}" should be in manager's connectors`).toBeTruthy(); + expect(connector!.provider).toBe('StripePrivy'); + + const cred = config.credentials?.find( + (c: Record) => c.authorizerType === 'PaymentCredentialProvider' && c.provider === 'StripePrivy' + ); + expect(cred, 'StripePrivy PaymentCredentialProvider credential should exist').toBeTruthy(); + }); + + it('stores StripePrivy secrets in .env.local', async () => { + const envPath = join(project.projectPath, 'agentcore', '.env.local'); + const envContent = await readFile(envPath, 'utf-8'); + expect(envContent).toContain('APP_ID'); + expect(envContent).toContain('APP_SECRET'); + expect(envContent).toContain('AUTHORIZATION_PRIVATE_KEY'); + expect(envContent).toContain('AUTHORIZATION_ID'); + }); + + it('rejects duplicate StripePrivy connector name', async () => { + const result = await runCLI( + [ + 'add', + 'payment-connector', + '--manager', + managerName, + '--name', + connectorName, + '--provider', + 'StripePrivy', + '--app-id', + 'x', + '--app-secret', + 'y', + '--authorization-private-key', + 'RkFLRV9TVFJJUEVfUFJJVllfVEVTVF9LRVlfQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQQ==', + '--authorization-id', + 'w', + '--json', + ], + project.projectPath + ); + + expect(result.exitCode).toBe(1); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(false); + expect(json.error).toContain('already exists'); + }); + + it('rejects StripePrivy connector missing required credentials', async () => { + const result = await runCLI( + [ + 'add', + 'payment-connector', + '--manager', + managerName, + '--name', + 'incomplete', + '--provider', + 'StripePrivy', + '--app-id', + 'x', + '--json', + ], + project.projectPath + ); + + expect(result.exitCode).toBe(1); + }); + + it('removes the StripePrivy connector', async () => { + const result = await runCLI( + ['remove', 'payment-connector', '--manager', managerName, '--name', connectorName, '--yes', '--json'], + project.projectPath + ); + + expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(true); + + const config = await readProjectConfig(project.projectPath); + const manager = config.payments?.find((p: Record) => p.name === managerName); + const connectors = manager?.connectors as Record[]; + expect(connectors?.length).toBe(0); + }); + + afterAll(async () => { + await runCLI(['remove', 'payment-manager', '--name', managerName, '--yes'], project.projectPath); + }); + }); + + describe('validation', () => { + it('passes agentcore validate after add/remove lifecycle', async () => { + const result = await runCLI(['validate'], project.projectPath); + expect(result.exitCode).toBe(0); + }); + + it('rejects invalid authorizer type', async () => { + const result = await runCLI( + ['add', 'payment-manager', '--name', 'x', '--authorizer-type', 'INVALID', '--json'], + project.projectPath + ); + expect(result.exitCode).toBe(1); + }); + + it('rejects invalid pattern', async () => { + const result = await runCLI( + ['add', 'payment-manager', '--name', 'x', '--pattern', 'invalid', '--json'], + project.projectPath + ); + expect(result.exitCode).toBe(1); + }); + + it('rejects invalid provider', async () => { + const result = await runCLI( + [ + 'add', + 'payment-connector', + '--manager', + 'x', + '--name', + 'y', + '--provider', + 'INVALID', + '--api-key-id', + 'x', + '--api-key-secret', + 'y', + '--wallet-secret', + 'z', + '--json', + ], + project.projectPath + ); + expect(result.exitCode).toBe(1); + }); + + it('requires --manager for payment-connector', async () => { + const result = await runCLI( + [ + 'add', + 'payment-connector', + '--name', + 'x', + '--provider', + 'CoinbaseCDP', + '--api-key-id', + 'x', + '--api-key-secret', + 'y', + '--wallet-secret', + 'z', + ], + project.projectPath + ); + expect(result.exitCode).toBe(1); + }); + }); +}); diff --git a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap index 992e8d19f..b20c02b00 100644 --- a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap +++ b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap @@ -75,15 +75,11 @@ async function main() { // Extract MCP configuration from project spec. // Gateway fields are stored in agentcore.json but may not yet be on the - // AgentCoreProjectSpec type from @aws/agentcore-cdk, so we read them - // dynamically and cast the resulting object. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const specAny = spec as any; - const mcpSpec = specAny.agentCoreGateways?.length + const mcpSpec = spec.agentCoreGateways?.length ? { - agentCoreGateways: specAny.agentCoreGateways, - mcpRuntimeTools: specAny.mcpRuntimeTools, - unassignedTargets: specAny.unassignedTargets, + agentCoreGateways: spec.agentCoreGateways, + mcpRuntimeTools: spec.mcpRuntimeTools, + unassignedTargets: spec.unassignedTargets, } : undefined; @@ -154,11 +150,32 @@ async function main() { | Record | undefined; + // Payment credential provider ARNs live in the same credentials map as identity credentials + const paymentCredentials = credentials; + + const paymentSpec = spec.payments?.length + ? spec.payments.map(p => ({ + name: p.name, + description: p.description, + authorizerType: p.authorizerType, + authorizerConfiguration: p.authorizerConfiguration, + autoPayment: p.autoPayment, + paymentToolAllowlist: p.paymentToolAllowlist, + networkPreferences: p.networkPreferences, + connectors: p.connectors.map(c => ({ + name: c.name, + provider: c.provider, + credentialProviderArn: paymentCredentials?.[c.credentialName]?.credentialProviderArn ?? '', + })), + })) + : undefined; + new AgentCoreStack(app, stackName, { spec, mcpSpec, credentials, harnesses: harnessConfigs.length > 0 ? harnessConfigs : undefined, + paymentSpec, env, description: \`AgentCore stack for \${spec.name} deployed to \${target.name} (\${target.region})\`, tags: { @@ -173,7 +190,7 @@ async function main() { main().catch((error: unknown) => { console.error('AgentCore CDK synthesis failed:', error instanceof Error ? error.message : error); - process.exitCode = 1; + process.exit(1); }); " `; @@ -300,10 +317,14 @@ exports[`Assets Directory Snapshots > CDK assets > cdk/cdk/lib/cdk-stack.ts shou "import { AgentCoreApplication, AgentCoreMcp, + AgentCorePaymentManager, + AgentCorePaymentConnector, type AgentCoreProjectSpec, type AgentCoreMcpSpec, + type CustomJWTAuthorizerConfig, } from '@aws/agentcore-cdk'; import { CfnOutput, Stack, type StackProps } from 'aws-cdk-lib'; +import * as iam from 'aws-cdk-lib/aws-iam'; import { Construct } from 'constructs'; export interface HarnessConfig { @@ -320,6 +341,23 @@ export interface HarnessConfig { s3AccessPoints?: { accessPointArn: string; mountPath: string }[]; } +export interface PaymentConnectorSpec { + name: string; + provider: 'CoinbaseCDP' | 'StripePrivy'; + credentialProviderArn: string; +} + +export interface PaymentSpec { + name: string; + description?: string; + authorizerType: 'AWS_IAM' | 'CUSTOM_JWT'; + authorizerConfiguration?: { customJWTAuthorizer: CustomJWTAuthorizerConfig }; + autoPayment?: boolean; + paymentToolAllowlist?: string[]; + networkPreferences?: string[]; + connectors: PaymentConnectorSpec[]; +} + export interface AgentCoreStackProps extends StackProps { /** * The AgentCore project specification containing agents, memories, and credentials. @@ -337,6 +375,30 @@ export interface AgentCoreStackProps extends StackProps { * Harness role configurations. */ harnesses?: HarnessConfig[]; + /** + * Payment specifications with resolved credential provider ARNs. + */ + paymentSpec?: PaymentSpec[]; +} + +function toCdkId(name: string): string { + return name.replace(/_/g, ''); +} + +/** + * Decide whether a deployed runtime should receive payment env vars + IAM grants. + * Payments today only ships a runtime shim for Python HTTP runtimes; injecting + * AGENTCORE_PAYMENT_* env vars into TypeScript / MCP / A2A / AGUI runtimes + * would surface env vars they cannot consume and would dilute least-privilege + * IAM grants for runtimes that never call ProcessPayment. + */ +function isPaymentEligibleAgent(agent: { entrypoint?: string; protocol?: string }): boolean { + if (agent.protocol && agent.protocol !== 'HTTP') { + return false; + } + const entrypoint = typeof agent.entrypoint === 'string' ? agent.entrypoint : ''; + const entrypointFile = entrypoint.split(':')[0] ?? ''; + return entrypointFile.endsWith('.py'); } /** @@ -352,7 +414,7 @@ export class AgentCoreStack extends Stack { constructor(scope: Construct, id: string, props: AgentCoreStackProps) { super(scope, id, props); - const { spec, mcpSpec, credentials, harnesses } = props; + const { spec, mcpSpec, credentials, harnesses, paymentSpec } = props; // Create AgentCoreApplication with all agents and harness roles // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -373,6 +435,122 @@ export class AgentCoreStack extends Stack { }); } + // Create payment infrastructure via CFN constructs + if (paymentSpec && paymentSpec.length > 0) { + for (const payment of paymentSpec) { + const mgrId = toCdkId(payment.name); + const manager = new AgentCorePaymentManager(this, \`Payment\${mgrId}\`, { + projectName: spec.name, + name: payment.name, + authorizerType: payment.authorizerType, + description: payment.description, + authorizerConfiguration: payment.authorizerConfiguration, + tags: spec.tags, + }); + + const prefix = \`AGENTCORE_PAYMENT_\${payment.name.toUpperCase().replace(/-/g, '_')}\`; + + // Wire env vars from construct output tokens into eligible agent environments only. + // See isPaymentEligibleAgent — non-Python or non-HTTP runtimes have no shim that + // can consume these env vars, and giving them sts:AssumeRole on the + // ProcessPaymentRole would broaden the privilege surface unnecessarily. + for (const env of this.application.environments.values()) { + if (!isPaymentEligibleAgent(env.agent)) { + continue; + } + env.runtime.addEnvironmentVariable(\`\${prefix}_MANAGER_ARN\`, manager.paymentManagerArn); + env.runtime.addEnvironmentVariable(\`\${prefix}_PROCESS_PAYMENT_ROLE_ARN\`, manager.processPaymentRoleArn); + + // Grant runtime execution role permission to assume the ProcessPaymentRole. + // The ProcessPaymentRole's trust policy allows AccountRootPrincipal, but the + // caller still needs sts:AssumeRole on its own role to perform the assumption. + env.runtime.role.addToPrincipalPolicy( + new iam.PolicyStatement({ + actions: ['sts:AssumeRole'], + resources: [manager.processPaymentRoleArn], + }) + ); + + // Grant payment data-plane actions directly to the runtime role. + // + // NOTE: This deviates from the canonical role model in the AgentCore Payments + // beta guide, which assigns Get/List/Create instrument+session actions to a + // separate ManagementRole and limits the agent's role to ProcessPayment only. + // The current SDK plugin (AgentCorePaymentsPlugin.generate_payment_header) + // calls GetPaymentInstrument internally during the 402 auto-pay path, so the + // runtime role needs read access. CreatePaymentSession is included so + // \`agentcore invoke --auto-session\` works without a separate ManagementRole + // call. Tighten this if the SDK is updated to accept pre-fetched instrument + // details and split create-session into a backend-only flow. + env.runtime.role.addToPrincipalPolicy( + new iam.PolicyStatement({ + actions: [ + 'bedrock-agentcore:GetPaymentInstrument', + 'bedrock-agentcore:ListPaymentInstruments', + 'bedrock-agentcore:GetPaymentInstrumentBalance', + 'bedrock-agentcore:GetPaymentSession', + 'bedrock-agentcore:ListPaymentSessions', + 'bedrock-agentcore:CreatePaymentSession', + 'bedrock-agentcore:ProcessPayment', + ], + resources: [manager.paymentManagerArn, \`\${manager.paymentManagerArn}/*\`], + }) + ); + + if (payment.autoPayment !== undefined) { + env.runtime.addEnvironmentVariable(\`\${prefix}_AUTO_PAYMENT\`, String(payment.autoPayment)); + } + if (payment.paymentToolAllowlist) { + env.runtime.addEnvironmentVariable(\`\${prefix}_TOOL_ALLOWLIST\`, payment.paymentToolAllowlist.join(',')); + } + if (payment.networkPreferences) { + env.runtime.addEnvironmentVariable(\`\${prefix}_NETWORK_PREFERENCES\`, payment.networkPreferences.join(',')); + } + if (payment.authorizerType === 'CUSTOM_JWT') { + env.runtime.addEnvironmentVariable(\`\${prefix}_AUTH_MODE\`, 'bearer'); + } + } + + // Create connectors for this manager + for (const connector of payment.connectors) { + const connId = toCdkId(connector.name); + const conn = new AgentCorePaymentConnector(this, \`Payment\${mgrId}\${connId}\`, { + projectName: spec.name, + paymentManager: manager, + connectorName: connector.name, + connectorType: connector.provider, + credentialProviderArn: connector.credentialProviderArn, + }); + + // Wire first connector's ID as env var (eligible agents only) + if (connector === payment.connectors[0]) { + for (const env of this.application.environments.values()) { + if (!isPaymentEligibleAgent(env.agent)) continue; + env.runtime.addEnvironmentVariable(\`\${prefix}_CONNECTOR_ID\`, conn.paymentConnectorId); + } + } + + new CfnOutput(this, \`Payment\${mgrId}\${connId}ConnectorId\`, { + value: conn.paymentConnectorId, + }); + } + + // CFN Outputs for post-deploy state parsing + new CfnOutput(this, \`Payment\${mgrId}ManagerArn\`, { + value: manager.paymentManagerArn, + }); + new CfnOutput(this, \`Payment\${mgrId}ManagerId\`, { + value: manager.paymentManagerId, + }); + new CfnOutput(this, \`Payment\${mgrId}ProcessPaymentRoleArn\`, { + value: manager.processPaymentRoleArn, + }); + new CfnOutput(this, \`Payment\${mgrId}ResourceRetrievalRoleArn\`, { + value: manager.resourceRetrievalRoleArn, + }); + } + } + // Stack-level output new CfnOutput(this, 'StackNameOutput', { description: 'Name of the CloudFormation Stack', @@ -445,6 +623,8 @@ test('AgentCoreStack synthesizes with empty spec', () => { evaluators: [], onlineEvalConfigs: [], policyEngines: [], + payments: [], + configBundles: [], agentCoreGateways: [], mcpRuntimeTools: [], unassignedTargets: [], @@ -605,6 +785,8 @@ exports[`Assets Directory Snapshots > File listing > should match the expected f "python/http/strands/base/pyproject.toml", "python/http/strands/capabilities/memory/__init__.py", "python/http/strands/capabilities/memory/session.py", + "python/http/strands/capabilities/payments/__init__.py", + "python/http/strands/capabilities/payments/payments.py", "python/mcp/standalone/base/README.md", "python/mcp/standalone/base/gitignore.template", "python/mcp/standalone/base/main.py", @@ -1629,7 +1811,7 @@ dependencies = [ {{/if}}{{#if (eq modelProvider "OpenAI")}}"langchain-openai >= 0.2.0", {{/if}}"aws-opentelemetry-distro", "opentelemetry-instrumentation-langchain >= 0.59.0", - "bedrock-agentcore[a2a] >= 1.0.3", + "bedrock-agentcore[a2a] >= 1.8.0", "botocore[crt] >= 1.35.0", "langgraph >= 0.2.0", ] @@ -2276,7 +2458,7 @@ requires-python = ">=3.10" dependencies = [ "ag-ui-adk >= 0.6.0", "ag-ui-protocol >= 0.1.10", - "bedrock-agentcore >= 1.0.3", + "bedrock-agentcore >= 1.8.0", "fastapi >= 0.115.12", "google-adk >= 1.16.0, < 2.0.0", "google-genai >= 1.0.0, < 2.0.0", @@ -2648,7 +2830,7 @@ dependencies = [ {{/if}}{{#if (eq modelProvider "OpenAI")}}"langchain-openai >= 0.2.0", {{/if}}"aws-opentelemetry-distro", "opentelemetry-instrumentation-langchain >= 0.59.0", - "bedrock-agentcore >= 1.0.3", + "bedrock-agentcore >= 1.8.0", "botocore[crt] >= 1.35.0", "langgraph >= 0.3.25", "langchain >= 0.3.0", @@ -3467,7 +3649,7 @@ dependencies = [ "autogen-ext[mcp] >= 0.7.5", "opentelemetry-distro", "opentelemetry-exporter-otlp", - "bedrock-agentcore >= 1.0.3", + "bedrock-agentcore >= 1.8.0", "botocore[crt] >= 1.35.0", "tiktoken", {{#if (eq modelProvider "Bedrock")}} @@ -4422,7 +4604,7 @@ dependencies = [ "mcp >= 1.19.0", "langchain-mcp-adapters >= 0.1.11", "langchain >= 1.0.3", - "bedrock-agentcore >= 1.0.3", + "bedrock-agentcore >= 1.8.0", "botocore[crt] >= 1.35.0", {{#if (eq modelProvider "Bedrock")}} "langchain-aws >= 1.0.0", @@ -4858,7 +5040,7 @@ requires-python = ">=3.10" dependencies = [ "aws-opentelemetry-distro", "openai-agents >= 0.4.2", - "bedrock-agentcore >= 1.0.3", + "bedrock-agentcore >= 1.8.0", "botocore[crt] >= 1.35.0", {{#if hasGateway}}{{#if (includes gatewayAuthTypes "AWS_IAM")}}"mcp-proxy-for-aws >= 1.1.0", {{/if}}{{/if}} @@ -4977,6 +5159,9 @@ from memory.session import get_memory_session_manager {{#if needsOs}} import os {{/if}} +{{#if hasPayment}} +from capabilities.payments.payments import create_payments_plugin, PAYMENT_SYSTEM_PROMPT +{{/if}} app = BedrockAgentCoreApp() log = app.logger @@ -5112,12 +5297,12 @@ class ConfigBundleHook(HookProvider): {{/if}} {{#if hasMemory}} +{{#unless hasPayment}} def agent_factory(): cache = {} def get_or_create_agent(session_id, user_id): key = f"{session_id}/{user_id}" if key not in cache: - # Create an agent for the given session_id and user_id cache[key] = Agent( model=load_model(), session_manager=get_memory_session_manager(session_id, user_id), @@ -5128,6 +5313,7 @@ def agent_factory(): return cache[key] return get_or_create_agent get_or_create_agent = agent_factory() +{{/unless}} {{else}} {{#if hasConfigBundle}} def create_agent(): @@ -5138,6 +5324,7 @@ def create_agent(): hooks=[ConfigBundleHook()], ) {{else}} +{{#unless hasPayment}} _agent = None def get_or_create_agent(): @@ -5146,9 +5333,10 @@ def get_or_create_agent(): _agent = Agent( model=load_model(), system_prompt=DEFAULT_SYSTEM_PROMPT, - tools=tools + tools=tools, ) return _agent +{{/unless}} {{/if}} {{/if}} @@ -5157,16 +5345,47 @@ def get_or_create_agent(): async def invoke(payload, context): log.info("Invoking Agent.....") +{{#if hasPayment}} + user_id = payload.get("user_id") or getattr(context, "user_id", "default-user") + instrument_id = payload.get("payment_instrument_id") + session_id = payload.get("payment_session_id") + payments_plugin = create_payments_plugin(user_id, instrument_id, session_id) + plugins = [payments_plugin] if payments_plugin else [] +{{/if}} + {{#if hasMemory}} +{{#if hasPayment}} + mem_session_id = getattr(context, 'session_id', 'default-session') + mem_user_id = getattr(context, 'user_id', 'default-user') + agent = Agent( + model=load_model(), + session_manager=get_memory_session_manager(mem_session_id, mem_user_id), + system_prompt=DEFAULT_SYSTEM_PROMPT + PAYMENT_SYSTEM_PROMPT, + tools=tools, + plugins=plugins,{{#if hasConfigBundle}} + hooks=[ConfigBundleHook()],{{/if}} + ) +{{else}} session_id = getattr(context, 'session_id', 'default-session') user_id = getattr(context, 'user_id', 'default-user') agent = get_or_create_agent(session_id, user_id) +{{/if}} +{{else}} +{{#if hasPayment}} + agent = Agent( + model=load_model(), + system_prompt=DEFAULT_SYSTEM_PROMPT + PAYMENT_SYSTEM_PROMPT, + tools=tools, + plugins=plugins,{{#if hasConfigBundle}} + hooks=[ConfigBundleHook()],{{/if}} + ) {{else}} {{#if hasConfigBundle}} agent = create_agent() {{else}} agent = get_or_create_agent() {{/if}} +{{/if}} {{/if}} # Execute and format response @@ -5479,6 +5698,160 @@ def get_memory_session_manager(session_id: Optional[str], actor_id: str) -> Opti " `; +exports[`Assets Directory Snapshots > Python framework assets > python/python/http/strands/capabilities/payments/__init__.py should match snapshot 1`] = ` +""""Payment capabilities for Strands agents.""" +from .payments import create_payments_plugin + +__all__ = ["create_payments_plugin"] +" +`; + +exports[`Assets Directory Snapshots > Python framework assets > python/python/http/strands/capabilities/payments/payments.py should match snapshot 1`] = ` +""""Payment capability -- auto-generated by agentcore CLI. + +Configures AgentCorePaymentsPlugin for Strands agents. +Manager config is auto-discovered from AGENTCORE_PAYMENT_* +environment variables set at deploy time. + +Uses a per-invocation factory pattern so each request gets its own +plugin instance with the correct user_id, instrument_id, and session_id. +This prevents concurrency bugs where one user's payment context +could leak to another user's request. + +Uses the official SDK plugin which handles: +- x402 v1 (body-based) and v2 (header-based) payment detection +- Automatic 402 response interception and payment processing +- Retry limiting (max 3 payment retries per tool use) +- Error management and logging +""" +import os +import logging + +import boto3 +from bedrock_agentcore.payments.integrations.strands import AgentCorePaymentsPlugin +from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + +logger = logging.getLogger(__name__) + +PAYMENT_SYSTEM_PROMPT = """ +You have payment capabilities via the x402 protocol: +- Use http_request to call HTTP endpoints. 402 Payment Required responses are settled automatically by the plugin and the call is retried. +- Use get_payment_session to check your remaining budget before expensive operations +- Use get_payment_instrument_balance to check wallet USDC balance +- Use list_payment_instruments to see available payment instruments +- If budget is low, inform the user before proceeding with paid requests +""" + +_manager_arn = None +_connector_id = None +_process_payment_role_arn = None +_name_segment = None +_region = None +_auth_mode = None +_manager_count = 0 +for key, value in os.environ.items(): + if key.startswith("AGENTCORE_PAYMENT_") and key.endswith("_MANAGER_ARN"): + if _manager_arn is None: + _manager_arn = value + _name_segment = key[len("AGENTCORE_PAYMENT_"):-len("_MANAGER_ARN")] + _manager_count += 1 +if _manager_count > 1: + logger.warning( + "Multiple payment managers detected in environment. Using the first one found. " + "Remove extra AGENTCORE_PAYMENT_*_MANAGER_ARN env vars to eliminate ambiguity." + ) +_region = os.getenv("AWS_REGION") + +_prefix = f"AGENTCORE_PAYMENT_{_name_segment}_" if _name_segment else "AGENTCORE_PAYMENT_" +_auth_mode = os.getenv(f"{_prefix}AUTH_MODE", "sigv4") +_connector_id = os.getenv(f"{_prefix}CONNECTOR_ID") +_process_payment_role_arn = os.getenv(f"{_prefix}PROCESS_PAYMENT_ROLE_ARN") +_auto_payment = os.getenv(f"{_prefix}AUTO_PAYMENT", "true").lower() == "true" +_allowlist_raw = os.getenv(f"{_prefix}TOOL_ALLOWLIST") +_allowlist = _allowlist_raw.split(",") if _allowlist_raw else None +_network_prefs_raw = os.getenv(f"{_prefix}NETWORK_PREFERENCES") +_network_prefs = _network_prefs_raw.split(",") if _network_prefs_raw else None + +if not _manager_arn: + logger.warning("No payment manager config found in environment") +if not _connector_id: + logger.warning("No payment connector config found in environment") + + +def _assume_role_session(role_arn): + """Assume an IAM role and return a boto3 session with temporary credentials.""" + sts = boto3.client("sts", region_name=_region) + creds = sts.assume_role( + RoleArn=role_arn, + RoleSessionName="agentcore-payment-plugin", + )["Credentials"] + return boto3.Session( + aws_access_key_id=creds["AccessKeyId"], + aws_secret_access_key=creds["SecretAccessKey"], + aws_session_token=creds["SessionToken"], + region_name=_region, + ) + + +def create_payments_plugin(user_id, instrument_id=None, session_id=None): + """Create a fresh plugin instance per invocation. + + Args: + user_id: From invocation context (required for SigV4, derived from JWT for bearer) + instrument_id: From invocation payload (created by app backend per user) + session_id: From invocation payload (created by app backend per conversation) + + Returns: + AgentCorePaymentsPlugin instance, or None if no manager is configured. + """ + if not _manager_arn: + return None + + config_kwargs = { + "payment_manager_arn": _manager_arn, + "region": _region, + "payment_instrument_id": instrument_id, + "payment_session_id": session_id, + "payment_connector_id": _connector_id, + } + + config_kwargs["auto_payment"] = _auto_payment + if _allowlist: + config_kwargs["payment_tool_allowlist"] = _allowlist + if _network_prefs: + config_kwargs["network_preferences_config"] = _network_prefs + + if _process_payment_role_arn: + # Only pass boto3_session if SDK supports it (added in bedrock-agentcore >= 1.11). + # Older SDKs use the runtime role's default credentials and can still call ProcessPayment + # if the runtime role has been granted permission directly. + import inspect + if "boto3_session" in inspect.signature(AgentCorePaymentsPluginConfig).parameters: + config_kwargs["boto3_session"] = _assume_role_session(_process_payment_role_arn) + else: + logger.warning( + "PROCESS_PAYMENT_ROLE_ARN set but bedrock-agentcore SDK does not support boto3_session. " + "Upgrade to bedrock-agentcore>=1.11 to enable cross-role payment processing." + ) + + if _auth_mode == "bearer": + bearer_token = os.getenv("AGENTCORE_BEARER_TOKEN") + if bearer_token: + config_kwargs["bearer_token"] = bearer_token + else: + logger.warning( + "Bearer auth mode configured but AGENTCORE_BEARER_TOKEN not set. " + "Falling back to SigV4. Set AGENTCORE_BEARER_TOKEN or pass bearer_token in invoke context." + ) + config_kwargs["user_id"] = user_id or "default-user" + else: + config_kwargs["user_id"] = user_id or "default-user" + + config = AgentCorePaymentsPluginConfig(**config_kwargs) + return AgentCorePaymentsPlugin(config=config) +" +`; + exports[`Assets Directory Snapshots > Python framework assets > python/python/mcp/standalone/base/README.md should match snapshot 1`] = ` "# {{ name }} diff --git a/src/assets/cdk/bin/cdk.ts b/src/assets/cdk/bin/cdk.ts index f2518baf3..41207ca96 100644 --- a/src/assets/cdk/bin/cdk.ts +++ b/src/assets/cdk/bin/cdk.ts @@ -30,15 +30,11 @@ async function main() { // Extract MCP configuration from project spec. // Gateway fields are stored in agentcore.json but may not yet be on the - // AgentCoreProjectSpec type from @aws/agentcore-cdk, so we read them - // dynamically and cast the resulting object. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const specAny = spec as any; - const mcpSpec = specAny.agentCoreGateways?.length + const mcpSpec = spec.agentCoreGateways?.length ? { - agentCoreGateways: specAny.agentCoreGateways, - mcpRuntimeTools: specAny.mcpRuntimeTools, - unassignedTargets: specAny.unassignedTargets, + agentCoreGateways: spec.agentCoreGateways, + mcpRuntimeTools: spec.mcpRuntimeTools, + unassignedTargets: spec.unassignedTargets, } : undefined; @@ -109,11 +105,32 @@ async function main() { | Record | undefined; + // Payment credential provider ARNs live in the same credentials map as identity credentials + const paymentCredentials = credentials; + + const paymentSpec = spec.payments?.length + ? spec.payments.map(p => ({ + name: p.name, + description: p.description, + authorizerType: p.authorizerType, + authorizerConfiguration: p.authorizerConfiguration, + autoPayment: p.autoPayment, + paymentToolAllowlist: p.paymentToolAllowlist, + networkPreferences: p.networkPreferences, + connectors: p.connectors.map(c => ({ + name: c.name, + provider: c.provider, + credentialProviderArn: paymentCredentials?.[c.credentialName]?.credentialProviderArn ?? '', + })), + })) + : undefined; + new AgentCoreStack(app, stackName, { spec, mcpSpec, credentials, harnesses: harnessConfigs.length > 0 ? harnessConfigs : undefined, + paymentSpec, env, description: `AgentCore stack for ${spec.name} deployed to ${target.name} (${target.region})`, tags: { @@ -128,5 +145,5 @@ async function main() { main().catch((error: unknown) => { console.error('AgentCore CDK synthesis failed:', error instanceof Error ? error.message : error); - process.exitCode = 1; + process.exit(1); }); diff --git a/src/assets/cdk/lib/cdk-stack.ts b/src/assets/cdk/lib/cdk-stack.ts index 91f4a6a91..f16f84555 100644 --- a/src/assets/cdk/lib/cdk-stack.ts +++ b/src/assets/cdk/lib/cdk-stack.ts @@ -1,10 +1,14 @@ import { AgentCoreApplication, AgentCoreMcp, + AgentCorePaymentManager, + AgentCorePaymentConnector, type AgentCoreProjectSpec, type AgentCoreMcpSpec, + type CustomJWTAuthorizerConfig, } from '@aws/agentcore-cdk'; import { CfnOutput, Stack, type StackProps } from 'aws-cdk-lib'; +import * as iam from 'aws-cdk-lib/aws-iam'; import { Construct } from 'constructs'; export interface HarnessConfig { @@ -21,6 +25,23 @@ export interface HarnessConfig { s3AccessPoints?: { accessPointArn: string; mountPath: string }[]; } +export interface PaymentConnectorSpec { + name: string; + provider: 'CoinbaseCDP' | 'StripePrivy'; + credentialProviderArn: string; +} + +export interface PaymentSpec { + name: string; + description?: string; + authorizerType: 'AWS_IAM' | 'CUSTOM_JWT'; + authorizerConfiguration?: { customJWTAuthorizer: CustomJWTAuthorizerConfig }; + autoPayment?: boolean; + paymentToolAllowlist?: string[]; + networkPreferences?: string[]; + connectors: PaymentConnectorSpec[]; +} + export interface AgentCoreStackProps extends StackProps { /** * The AgentCore project specification containing agents, memories, and credentials. @@ -38,6 +59,30 @@ export interface AgentCoreStackProps extends StackProps { * Harness role configurations. */ harnesses?: HarnessConfig[]; + /** + * Payment specifications with resolved credential provider ARNs. + */ + paymentSpec?: PaymentSpec[]; +} + +function toCdkId(name: string): string { + return name.replace(/_/g, ''); +} + +/** + * Decide whether a deployed runtime should receive payment env vars + IAM grants. + * Payments today only ships a runtime shim for Python HTTP runtimes; injecting + * AGENTCORE_PAYMENT_* env vars into TypeScript / MCP / A2A / AGUI runtimes + * would surface env vars they cannot consume and would dilute least-privilege + * IAM grants for runtimes that never call ProcessPayment. + */ +function isPaymentEligibleAgent(agent: { entrypoint?: string; protocol?: string }): boolean { + if (agent.protocol && agent.protocol !== 'HTTP') { + return false; + } + const entrypoint = typeof agent.entrypoint === 'string' ? agent.entrypoint : ''; + const entrypointFile = entrypoint.split(':')[0] ?? ''; + return entrypointFile.endsWith('.py'); } /** @@ -53,7 +98,7 @@ export class AgentCoreStack extends Stack { constructor(scope: Construct, id: string, props: AgentCoreStackProps) { super(scope, id, props); - const { spec, mcpSpec, credentials, harnesses } = props; + const { spec, mcpSpec, credentials, harnesses, paymentSpec } = props; // Create AgentCoreApplication with all agents and harness roles // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -74,6 +119,122 @@ export class AgentCoreStack extends Stack { }); } + // Create payment infrastructure via CFN constructs + if (paymentSpec && paymentSpec.length > 0) { + for (const payment of paymentSpec) { + const mgrId = toCdkId(payment.name); + const manager = new AgentCorePaymentManager(this, `Payment${mgrId}`, { + projectName: spec.name, + name: payment.name, + authorizerType: payment.authorizerType, + description: payment.description, + authorizerConfiguration: payment.authorizerConfiguration, + tags: spec.tags, + }); + + const prefix = `AGENTCORE_PAYMENT_${payment.name.toUpperCase().replace(/-/g, '_')}`; + + // Wire env vars from construct output tokens into eligible agent environments only. + // See isPaymentEligibleAgent — non-Python or non-HTTP runtimes have no shim that + // can consume these env vars, and giving them sts:AssumeRole on the + // ProcessPaymentRole would broaden the privilege surface unnecessarily. + for (const env of this.application.environments.values()) { + if (!isPaymentEligibleAgent(env.agent)) { + continue; + } + env.runtime.addEnvironmentVariable(`${prefix}_MANAGER_ARN`, manager.paymentManagerArn); + env.runtime.addEnvironmentVariable(`${prefix}_PROCESS_PAYMENT_ROLE_ARN`, manager.processPaymentRoleArn); + + // Grant runtime execution role permission to assume the ProcessPaymentRole. + // The ProcessPaymentRole's trust policy allows AccountRootPrincipal, but the + // caller still needs sts:AssumeRole on its own role to perform the assumption. + env.runtime.role.addToPrincipalPolicy( + new iam.PolicyStatement({ + actions: ['sts:AssumeRole'], + resources: [manager.processPaymentRoleArn], + }) + ); + + // Grant payment data-plane actions directly to the runtime role. + // + // NOTE: This deviates from the canonical role model in the AgentCore Payments + // beta guide, which assigns Get/List/Create instrument+session actions to a + // separate ManagementRole and limits the agent's role to ProcessPayment only. + // The current SDK plugin (AgentCorePaymentsPlugin.generate_payment_header) + // calls GetPaymentInstrument internally during the 402 auto-pay path, so the + // runtime role needs read access. CreatePaymentSession is included so + // `agentcore invoke --auto-session` works without a separate ManagementRole + // call. Tighten this if the SDK is updated to accept pre-fetched instrument + // details and split create-session into a backend-only flow. + env.runtime.role.addToPrincipalPolicy( + new iam.PolicyStatement({ + actions: [ + 'bedrock-agentcore:GetPaymentInstrument', + 'bedrock-agentcore:ListPaymentInstruments', + 'bedrock-agentcore:GetPaymentInstrumentBalance', + 'bedrock-agentcore:GetPaymentSession', + 'bedrock-agentcore:ListPaymentSessions', + 'bedrock-agentcore:CreatePaymentSession', + 'bedrock-agentcore:ProcessPayment', + ], + resources: [manager.paymentManagerArn, `${manager.paymentManagerArn}/*`], + }) + ); + + if (payment.autoPayment !== undefined) { + env.runtime.addEnvironmentVariable(`${prefix}_AUTO_PAYMENT`, String(payment.autoPayment)); + } + if (payment.paymentToolAllowlist) { + env.runtime.addEnvironmentVariable(`${prefix}_TOOL_ALLOWLIST`, payment.paymentToolAllowlist.join(',')); + } + if (payment.networkPreferences) { + env.runtime.addEnvironmentVariable(`${prefix}_NETWORK_PREFERENCES`, payment.networkPreferences.join(',')); + } + if (payment.authorizerType === 'CUSTOM_JWT') { + env.runtime.addEnvironmentVariable(`${prefix}_AUTH_MODE`, 'bearer'); + } + } + + // Create connectors for this manager + for (const connector of payment.connectors) { + const connId = toCdkId(connector.name); + const conn = new AgentCorePaymentConnector(this, `Payment${mgrId}${connId}`, { + projectName: spec.name, + paymentManager: manager, + connectorName: connector.name, + connectorType: connector.provider, + credentialProviderArn: connector.credentialProviderArn, + }); + + // Wire first connector's ID as env var (eligible agents only) + if (connector === payment.connectors[0]) { + for (const env of this.application.environments.values()) { + if (!isPaymentEligibleAgent(env.agent)) continue; + env.runtime.addEnvironmentVariable(`${prefix}_CONNECTOR_ID`, conn.paymentConnectorId); + } + } + + new CfnOutput(this, `Payment${mgrId}${connId}ConnectorId`, { + value: conn.paymentConnectorId, + }); + } + + // CFN Outputs for post-deploy state parsing + new CfnOutput(this, `Payment${mgrId}ManagerArn`, { + value: manager.paymentManagerArn, + }); + new CfnOutput(this, `Payment${mgrId}ManagerId`, { + value: manager.paymentManagerId, + }); + new CfnOutput(this, `Payment${mgrId}ProcessPaymentRoleArn`, { + value: manager.processPaymentRoleArn, + }); + new CfnOutput(this, `Payment${mgrId}ResourceRetrievalRoleArn`, { + value: manager.resourceRetrievalRoleArn, + }); + } + } + // Stack-level output new CfnOutput(this, 'StackNameOutput', { description: 'Name of the CloudFormation Stack', diff --git a/src/assets/cdk/test/cdk.test.ts b/src/assets/cdk/test/cdk.test.ts index c540efbe7..170a0fd90 100644 --- a/src/assets/cdk/test/cdk.test.ts +++ b/src/assets/cdk/test/cdk.test.ts @@ -15,6 +15,8 @@ test('AgentCoreStack synthesizes with empty spec', () => { evaluators: [], onlineEvalConfigs: [], policyEngines: [], + payments: [], + configBundles: [], agentCoreGateways: [], mcpRuntimeTools: [], unassignedTargets: [], diff --git a/src/assets/python/a2a/langchain_langgraph/base/pyproject.toml b/src/assets/python/a2a/langchain_langgraph/base/pyproject.toml index aa1057439..e67af7505 100644 --- a/src/assets/python/a2a/langchain_langgraph/base/pyproject.toml +++ b/src/assets/python/a2a/langchain_langgraph/base/pyproject.toml @@ -17,7 +17,7 @@ dependencies = [ {{/if}}{{#if (eq modelProvider "OpenAI")}}"langchain-openai >= 0.2.0", {{/if}}"aws-opentelemetry-distro", "opentelemetry-instrumentation-langchain >= 0.59.0", - "bedrock-agentcore[a2a] >= 1.0.3", + "bedrock-agentcore[a2a] >= 1.8.0", "botocore[crt] >= 1.35.0", "langgraph >= 0.2.0", ] diff --git a/src/assets/python/agui/googleadk/base/pyproject.toml b/src/assets/python/agui/googleadk/base/pyproject.toml index 42f0f9d92..7195670e1 100644 --- a/src/assets/python/agui/googleadk/base/pyproject.toml +++ b/src/assets/python/agui/googleadk/base/pyproject.toml @@ -11,7 +11,7 @@ requires-python = ">=3.10" dependencies = [ "ag-ui-adk >= 0.6.0", "ag-ui-protocol >= 0.1.10", - "bedrock-agentcore >= 1.0.3", + "bedrock-agentcore >= 1.8.0", "fastapi >= 0.115.12", "google-adk >= 1.16.0, < 2.0.0", "google-genai >= 1.0.0, < 2.0.0", diff --git a/src/assets/python/agui/langchain_langgraph/base/pyproject.toml b/src/assets/python/agui/langchain_langgraph/base/pyproject.toml index 71d94cdde..767603101 100644 --- a/src/assets/python/agui/langchain_langgraph/base/pyproject.toml +++ b/src/assets/python/agui/langchain_langgraph/base/pyproject.toml @@ -18,7 +18,7 @@ dependencies = [ {{/if}}{{#if (eq modelProvider "OpenAI")}}"langchain-openai >= 0.2.0", {{/if}}"aws-opentelemetry-distro", "opentelemetry-instrumentation-langchain >= 0.59.0", - "bedrock-agentcore >= 1.0.3", + "bedrock-agentcore >= 1.8.0", "botocore[crt] >= 1.35.0", "langgraph >= 0.3.25", "langchain >= 0.3.0", diff --git a/src/assets/python/http/autogen/base/pyproject.toml b/src/assets/python/http/autogen/base/pyproject.toml index b6ba46d8c..743706144 100644 --- a/src/assets/python/http/autogen/base/pyproject.toml +++ b/src/assets/python/http/autogen/base/pyproject.toml @@ -13,7 +13,7 @@ dependencies = [ "autogen-ext[mcp] >= 0.7.5", "opentelemetry-distro", "opentelemetry-exporter-otlp", - "bedrock-agentcore >= 1.0.3", + "bedrock-agentcore >= 1.8.0", "botocore[crt] >= 1.35.0", "tiktoken", {{#if (eq modelProvider "Bedrock")}} diff --git a/src/assets/python/http/langchain_langgraph/base/pyproject.toml b/src/assets/python/http/langchain_langgraph/base/pyproject.toml index ddc367b11..c1cfaa79f 100644 --- a/src/assets/python/http/langchain_langgraph/base/pyproject.toml +++ b/src/assets/python/http/langchain_langgraph/base/pyproject.toml @@ -15,7 +15,7 @@ dependencies = [ "mcp >= 1.19.0", "langchain-mcp-adapters >= 0.1.11", "langchain >= 1.0.3", - "bedrock-agentcore >= 1.0.3", + "bedrock-agentcore >= 1.8.0", "botocore[crt] >= 1.35.0", {{#if (eq modelProvider "Bedrock")}} "langchain-aws >= 1.0.0", diff --git a/src/assets/python/http/openaiagents/base/pyproject.toml b/src/assets/python/http/openaiagents/base/pyproject.toml index 61944b9a5..a344eccc7 100644 --- a/src/assets/python/http/openaiagents/base/pyproject.toml +++ b/src/assets/python/http/openaiagents/base/pyproject.toml @@ -11,7 +11,7 @@ requires-python = ">=3.10" dependencies = [ "aws-opentelemetry-distro", "openai-agents >= 0.4.2", - "bedrock-agentcore >= 1.0.3", + "bedrock-agentcore >= 1.8.0", "botocore[crt] >= 1.35.0", {{#if hasGateway}}{{#if (includes gatewayAuthTypes "AWS_IAM")}}"mcp-proxy-for-aws >= 1.1.0", {{/if}}{{/if}} diff --git a/src/assets/python/http/strands/base/main.py b/src/assets/python/http/strands/base/main.py index e839618b0..254550134 100644 --- a/src/assets/python/http/strands/base/main.py +++ b/src/assets/python/http/strands/base/main.py @@ -18,6 +18,9 @@ {{#if needsOs}} import os {{/if}} +{{#if hasPayment}} +from capabilities.payments.payments import create_payments_plugin, PAYMENT_SYSTEM_PROMPT +{{/if}} app = BedrockAgentCoreApp() log = app.logger @@ -153,12 +156,12 @@ def _override_tool_desc(self, event: BeforeToolCallEvent) -> None: {{/if}} {{#if hasMemory}} +{{#unless hasPayment}} def agent_factory(): cache = {} def get_or_create_agent(session_id, user_id): key = f"{session_id}/{user_id}" if key not in cache: - # Create an agent for the given session_id and user_id cache[key] = Agent( model=load_model(), session_manager=get_memory_session_manager(session_id, user_id), @@ -169,6 +172,7 @@ def get_or_create_agent(session_id, user_id): return cache[key] return get_or_create_agent get_or_create_agent = agent_factory() +{{/unless}} {{else}} {{#if hasConfigBundle}} def create_agent(): @@ -179,6 +183,7 @@ def create_agent(): hooks=[ConfigBundleHook()], ) {{else}} +{{#unless hasPayment}} _agent = None def get_or_create_agent(): @@ -187,9 +192,10 @@ def get_or_create_agent(): _agent = Agent( model=load_model(), system_prompt=DEFAULT_SYSTEM_PROMPT, - tools=tools + tools=tools, ) return _agent +{{/unless}} {{/if}} {{/if}} @@ -198,16 +204,47 @@ def get_or_create_agent(): async def invoke(payload, context): log.info("Invoking Agent.....") +{{#if hasPayment}} + user_id = payload.get("user_id") or getattr(context, "user_id", "default-user") + instrument_id = payload.get("payment_instrument_id") + session_id = payload.get("payment_session_id") + payments_plugin = create_payments_plugin(user_id, instrument_id, session_id) + plugins = [payments_plugin] if payments_plugin else [] +{{/if}} + {{#if hasMemory}} +{{#if hasPayment}} + mem_session_id = getattr(context, 'session_id', 'default-session') + mem_user_id = getattr(context, 'user_id', 'default-user') + agent = Agent( + model=load_model(), + session_manager=get_memory_session_manager(mem_session_id, mem_user_id), + system_prompt=DEFAULT_SYSTEM_PROMPT + PAYMENT_SYSTEM_PROMPT, + tools=tools, + plugins=plugins,{{#if hasConfigBundle}} + hooks=[ConfigBundleHook()],{{/if}} + ) +{{else}} session_id = getattr(context, 'session_id', 'default-session') user_id = getattr(context, 'user_id', 'default-user') agent = get_or_create_agent(session_id, user_id) +{{/if}} +{{else}} +{{#if hasPayment}} + agent = Agent( + model=load_model(), + system_prompt=DEFAULT_SYSTEM_PROMPT + PAYMENT_SYSTEM_PROMPT, + tools=tools, + plugins=plugins,{{#if hasConfigBundle}} + hooks=[ConfigBundleHook()],{{/if}} + ) {{else}} {{#if hasConfigBundle}} agent = create_agent() {{else}} agent = get_or_create_agent() {{/if}} +{{/if}} {{/if}} # Execute and format response diff --git a/src/assets/python/http/strands/capabilities/payments/__init__.py b/src/assets/python/http/strands/capabilities/payments/__init__.py new file mode 100644 index 000000000..74c5b37a2 --- /dev/null +++ b/src/assets/python/http/strands/capabilities/payments/__init__.py @@ -0,0 +1,4 @@ +"""Payment capabilities for Strands agents.""" +from .payments import create_payments_plugin + +__all__ = ["create_payments_plugin"] diff --git a/src/assets/python/http/strands/capabilities/payments/payments.py b/src/assets/python/http/strands/capabilities/payments/payments.py new file mode 100644 index 000000000..6b84770e9 --- /dev/null +++ b/src/assets/python/http/strands/capabilities/payments/payments.py @@ -0,0 +1,142 @@ +"""Payment capability -- auto-generated by agentcore CLI. + +Configures AgentCorePaymentsPlugin for Strands agents. +Manager config is auto-discovered from AGENTCORE_PAYMENT_* +environment variables set at deploy time. + +Uses a per-invocation factory pattern so each request gets its own +plugin instance with the correct user_id, instrument_id, and session_id. +This prevents concurrency bugs where one user's payment context +could leak to another user's request. + +Uses the official SDK plugin which handles: +- x402 v1 (body-based) and v2 (header-based) payment detection +- Automatic 402 response interception and payment processing +- Retry limiting (max 3 payment retries per tool use) +- Error management and logging +""" +import os +import logging + +import boto3 +from bedrock_agentcore.payments.integrations.strands import AgentCorePaymentsPlugin +from bedrock_agentcore.payments.integrations.config import AgentCorePaymentsPluginConfig + +logger = logging.getLogger(__name__) + +PAYMENT_SYSTEM_PROMPT = """ +You have payment capabilities via the x402 protocol: +- Use http_request to call HTTP endpoints. 402 Payment Required responses are settled automatically by the plugin and the call is retried. +- Use get_payment_session to check your remaining budget before expensive operations +- Use get_payment_instrument_balance to check wallet USDC balance +- Use list_payment_instruments to see available payment instruments +- If budget is low, inform the user before proceeding with paid requests +""" + +_manager_arn = None +_connector_id = None +_process_payment_role_arn = None +_name_segment = None +_region = None +_auth_mode = None +_manager_count = 0 +for key, value in os.environ.items(): + if key.startswith("AGENTCORE_PAYMENT_") and key.endswith("_MANAGER_ARN"): + if _manager_arn is None: + _manager_arn = value + _name_segment = key[len("AGENTCORE_PAYMENT_"):-len("_MANAGER_ARN")] + _manager_count += 1 +if _manager_count > 1: + logger.warning( + "Multiple payment managers detected in environment. Using the first one found. " + "Remove extra AGENTCORE_PAYMENT_*_MANAGER_ARN env vars to eliminate ambiguity." + ) +_region = os.getenv("AWS_REGION") + +_prefix = f"AGENTCORE_PAYMENT_{_name_segment}_" if _name_segment else "AGENTCORE_PAYMENT_" +_auth_mode = os.getenv(f"{_prefix}AUTH_MODE", "sigv4") +_connector_id = os.getenv(f"{_prefix}CONNECTOR_ID") +_process_payment_role_arn = os.getenv(f"{_prefix}PROCESS_PAYMENT_ROLE_ARN") +_auto_payment = os.getenv(f"{_prefix}AUTO_PAYMENT", "true").lower() == "true" +_allowlist_raw = os.getenv(f"{_prefix}TOOL_ALLOWLIST") +_allowlist = _allowlist_raw.split(",") if _allowlist_raw else None +_network_prefs_raw = os.getenv(f"{_prefix}NETWORK_PREFERENCES") +_network_prefs = _network_prefs_raw.split(",") if _network_prefs_raw else None + +if not _manager_arn: + logger.warning("No payment manager config found in environment") +if not _connector_id: + logger.warning("No payment connector config found in environment") + + +def _assume_role_session(role_arn): + """Assume an IAM role and return a boto3 session with temporary credentials.""" + sts = boto3.client("sts", region_name=_region) + creds = sts.assume_role( + RoleArn=role_arn, + RoleSessionName="agentcore-payment-plugin", + )["Credentials"] + return boto3.Session( + aws_access_key_id=creds["AccessKeyId"], + aws_secret_access_key=creds["SecretAccessKey"], + aws_session_token=creds["SessionToken"], + region_name=_region, + ) + + +def create_payments_plugin(user_id, instrument_id=None, session_id=None): + """Create a fresh plugin instance per invocation. + + Args: + user_id: From invocation context (required for SigV4, derived from JWT for bearer) + instrument_id: From invocation payload (created by app backend per user) + session_id: From invocation payload (created by app backend per conversation) + + Returns: + AgentCorePaymentsPlugin instance, or None if no manager is configured. + """ + if not _manager_arn: + return None + + config_kwargs = { + "payment_manager_arn": _manager_arn, + "region": _region, + "payment_instrument_id": instrument_id, + "payment_session_id": session_id, + "payment_connector_id": _connector_id, + } + + config_kwargs["auto_payment"] = _auto_payment + if _allowlist: + config_kwargs["payment_tool_allowlist"] = _allowlist + if _network_prefs: + config_kwargs["network_preferences_config"] = _network_prefs + + if _process_payment_role_arn: + # Only pass boto3_session if SDK supports it (added in bedrock-agentcore >= 1.11). + # Older SDKs use the runtime role's default credentials and can still call ProcessPayment + # if the runtime role has been granted permission directly. + import inspect + if "boto3_session" in inspect.signature(AgentCorePaymentsPluginConfig).parameters: + config_kwargs["boto3_session"] = _assume_role_session(_process_payment_role_arn) + else: + logger.warning( + "PROCESS_PAYMENT_ROLE_ARN set but bedrock-agentcore SDK does not support boto3_session. " + "Upgrade to bedrock-agentcore>=1.11 to enable cross-role payment processing." + ) + + if _auth_mode == "bearer": + bearer_token = os.getenv("AGENTCORE_BEARER_TOKEN") + if bearer_token: + config_kwargs["bearer_token"] = bearer_token + else: + logger.warning( + "Bearer auth mode configured but AGENTCORE_BEARER_TOKEN not set. " + "Falling back to SigV4. Set AGENTCORE_BEARER_TOKEN or pass bearer_token in invoke context." + ) + config_kwargs["user_id"] = user_id or "default-user" + else: + config_kwargs["user_id"] = user_id or "default-user" + + config = AgentCorePaymentsPluginConfig(**config_kwargs) + return AgentCorePaymentsPlugin(config=config) diff --git a/src/cli/aws/agentcore-payments.ts b/src/cli/aws/agentcore-payments.ts new file mode 100644 index 000000000..42a07c343 --- /dev/null +++ b/src/cli/aws/agentcore-payments.ts @@ -0,0 +1,491 @@ +/** + * AWS client wrappers for Payment control plane operations. + * + * Uses direct HTTP requests with SigV4 signing against the control plane + * because the Payment APIs are not yet in the SDK client. + */ +import { getCredentialProvider } from './account'; +import { serviceEndpoint } from './partition'; +import { Sha256 } from '@aws-crypto/sha256-js'; +import { defaultProvider } from '@aws-sdk/credential-provider-node'; +import { HttpRequest } from '@smithy/protocol-http'; +import { SignatureV4 } from '@smithy/signature-v4'; + +// ============================================================================ +// Types +// ============================================================================ + +// ── Create Payment Credential Provider ───────────────────────────────────── + +interface CreateCoinbaseCdpCredentialProviderOptions { + region: string; + name: string; + vendor: 'CoinbaseCDP'; + apiKeyId: string; + apiKeySecret: string; + walletSecret: string; +} + +interface CreateStripePrivyCredentialProviderOptions { + region: string; + name: string; + vendor: 'StripePrivy'; + appId: string; + appSecret: string; + authorizationPrivateKey: string; + authorizationId: string; +} + +type CreatePaymentCredentialProviderOptions = + | CreateCoinbaseCdpCredentialProviderOptions + | CreateStripePrivyCredentialProviderOptions; + +interface PaymentCredentialProviderApiResult { + credentialProviderArn: string; + status: string; +} + +// ── Update Payment Credential Provider ───────────────────────────────────── + +type UpdatePaymentCredentialProviderOptions = CreatePaymentCredentialProviderOptions; + +// ── Get Payment Credential Provider ──────────────────────────────────────── + +interface GetPaymentCredentialProviderOptions { + region: string; + name: string; +} + +interface PaymentCredentialProviderDetail { + credentialProviderArn: string; + name: string; + status: string; +} + +// ── Get Payment Manager ─────────────────────────────────────────────────── + +interface GetPaymentManagerOptions { + region: string; + paymentManagerId: string; +} + +interface PaymentManagerDetail { + paymentManagerId: string; + paymentManagerArn: string; + name: string; + status: string; + description?: string; + roleArn?: string; +} + +// ============================================================================ +// HTTP signing helper +// ============================================================================ + +function getControlPlaneEndpoint(region: string): string { + const stage = process.env.AGENTCORE_STAGE?.toLowerCase(); + if (stage === 'beta') return `https://beta.${region}.elcapcp.genesis-primitives.aws.dev`; + if (stage === 'gamma') return `https://gamma.${region}.elcapcp.genesis-primitives.aws.dev`; + return `https://${serviceEndpoint('bedrock-agentcore-control', region)}`; +} + +async function signedRequest(options: { + region: string; + method: string; + path: string; + body?: string; +}): Promise { + const { region, method, path, body } = options; + const endpoint = getControlPlaneEndpoint(region); + const url = new URL(path, endpoint); + + const query: Record = {}; + url.searchParams.forEach((value, key) => { + query[key] = value; + }); + + const request = new HttpRequest({ + method, + protocol: 'https:', + hostname: url.hostname, + path: url.pathname, + ...(Object.keys(query).length > 0 && { query }), + headers: { + 'Content-Type': 'application/json', + host: url.hostname, + }, + ...(body && { body }), + }); + + const credentials = getCredentialProvider() ?? defaultProvider(); + const service = 'bedrock-agentcore'; + const signer = new SignatureV4({ + service, + region, + credentials, + sha256: Sha256, + }); + + const signedReq = await signer.sign(request); + + let response: Response; + try { + response = await fetch(`${endpoint}${path}`, { + method, + headers: signedReq.headers as Record, + ...(body && { body }), + signal: AbortSignal.timeout(8000), + }); + } catch (err) { + if (err instanceof Error && err.name === 'TimeoutError') { + throw new Error( + `Payment API request timed out (>8s) for ${method} ${path}. Check network connectivity and region.` + ); + } + throw err; + } + + if (!response.ok) { + const errorBody = await response.text(); + // Sanitize error body -- API validation errors may echo request fields containing secrets + const sanitized = errorBody + .replace( + /("apiKeySecret"|"walletSecret"|"apiKeyId"|"appId"|"appSecret"|"authorizationPrivateKey"|"authorizationId")\s*:\s*"[^"]*"/g, + '$1:"[REDACTED]"' + ) + .slice(0, 500); + + const error = new Error(`Payment API error (${response.status}): ${sanitized}`) as Error & { code?: string }; + try { + const parsed = JSON.parse(errorBody) as Record; + const code = parsed.code ?? parsed.__type; + if (typeof code === 'string') error.code = code; + } catch (_err) { + /* ignore parse failures */ + } + throw error; + } + + if (response.status === 204) return {}; + return response.json(); +} + +// ============================================================================ +// Payment Credential Provider Operations +// ============================================================================ + +function buildProviderConfigPayload(options: CreatePaymentCredentialProviderOptions): { + credentialProviderVendor: string; + providerConfigurationInput: Record; +} { + if (options.vendor === 'StripePrivy') { + return { + credentialProviderVendor: 'StripePrivy', + providerConfigurationInput: { + stripePrivyConfiguration: { + appId: options.appId, + appSecret: options.appSecret, + authorizationPrivateKey: options.authorizationPrivateKey, + authorizationId: options.authorizationId, + }, + }, + }; + } + return { + credentialProviderVendor: 'CoinbaseCDP', + providerConfigurationInput: { + coinbaseCdpConfiguration: { + apiKeyId: options.apiKeyId, + apiKeySecret: options.apiKeySecret, + walletSecret: options.walletSecret, + }, + }, + }; +} + +export async function createPaymentCredentialProvider( + options: CreatePaymentCredentialProviderOptions +): Promise { + const { credentialProviderVendor, providerConfigurationInput } = buildProviderConfigPayload(options); + const body = JSON.stringify({ + name: options.name, + credentialProviderVendor, + providerConfigurationInput, + }); + + try { + const data = (await signedRequest({ + region: options.region, + method: 'POST', + path: '/identities/CreatePaymentCredentialProvider', + body, + })) as PaymentCredentialProviderApiResult; + + return { + credentialProviderArn: data.credentialProviderArn, + status: data.status, + }; + } catch (err) { + throw new Error( + `Failed to create payment credential provider "${options.name}": ${err instanceof Error ? err.message : String(err)}` + ); + } +} + +export async function updatePaymentCredentialProvider( + options: UpdatePaymentCredentialProviderOptions +): Promise { + const { credentialProviderVendor, providerConfigurationInput } = buildProviderConfigPayload(options); + const body = JSON.stringify({ + name: options.name, + credentialProviderVendor, + providerConfigurationInput, + }); + + try { + const data = (await signedRequest({ + region: options.region, + method: 'POST', + path: '/identities/UpdatePaymentCredentialProvider', + body, + })) as PaymentCredentialProviderApiResult; + + return { + credentialProviderArn: data.credentialProviderArn, + status: data.status, + }; + } catch (err) { + throw new Error( + `Failed to update payment credential provider "${options.name}": ${err instanceof Error ? err.message : String(err)}` + ); + } +} + +export async function getPaymentCredentialProvider( + options: GetPaymentCredentialProviderOptions +): Promise { + try { + const data = (await signedRequest({ + region: options.region, + method: 'POST', + path: '/identities/GetPaymentCredentialProvider', + body: JSON.stringify({ name: options.name }), + })) as PaymentCredentialProviderDetail; + + return data; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes('(404)') || msg.includes('ResourceNotFoundException')) return null; + throw new Error(`Failed to get payment credential provider "${options.name}": ${msg}`); + } +} + +export async function deletePaymentCredentialProvider(options: { region: string; name: string }): Promise { + try { + await signedRequest({ + region: options.region, + method: 'POST', + path: '/identities/DeletePaymentCredentialProvider', + body: JSON.stringify({ name: options.name }), + }); + } catch (err) { + throw new Error( + `Failed to delete payment credential provider "${options.name}": ${err instanceof Error ? err.message : String(err)}` + ); + } +} + +// ============================================================================ +// Payment Manager Operations +// ============================================================================ + +export async function getPaymentManager(options: GetPaymentManagerOptions): Promise { + try { + return (await signedRequest({ + region: options.region, + method: 'GET', + path: `/payments/managers/${encodeURIComponent(options.paymentManagerId)}`, + })) as PaymentManagerDetail; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes('(404)') || msg.includes('ResourceNotFoundException')) return null; + throw new Error(`Failed to get payment manager "${options.paymentManagerId}": ${msg}`); + } +} + +// ============================================================================ +// Data Plane Operations (Payment Sessions) +// ============================================================================ + +function getDataPlaneEndpoint(region: string): string { + const stage = process.env.AGENTCORE_STAGE?.toLowerCase(); + if (stage === 'beta') return `https://beta.${region}.elcapdp.genesis-primitives.aws.dev`; + if (stage === 'gamma') return `https://gamma.${region}.elcapdp.genesis-primitives.aws.dev`; + return `https://${serviceEndpoint('bedrock-agentcore', region)}`; +} + +async function signedDataPlaneRequest(options: { + region: string; + method: string; + path: string; + body?: string; + extraHeaders?: Record; +}): Promise { + const { region, method, path, body, extraHeaders } = options; + const endpoint = getDataPlaneEndpoint(region); + const url = new URL(path, endpoint); + + const query: Record = {}; + url.searchParams.forEach((value, key) => { + query[key] = value; + }); + + const request = new HttpRequest({ + method, + protocol: 'https:', + hostname: url.hostname, + path: url.pathname, + ...(Object.keys(query).length > 0 && { query }), + headers: { + 'Content-Type': 'application/json', + host: url.hostname, + ...extraHeaders, + }, + ...(body && { body }), + }); + + const credentials = getCredentialProvider() ?? defaultProvider(); + const service = 'bedrock-agentcore'; + const signer = new SignatureV4({ + service, + region, + credentials, + sha256: Sha256, + }); + + const signedReq = await signer.sign(request); + + let response: Response; + try { + response = await fetch(`${endpoint}${path}`, { + method, + headers: signedReq.headers as Record, + ...(body && { body }), + signal: AbortSignal.timeout(8000), + }); + } catch (err) { + if (err instanceof Error && err.name === 'TimeoutError') { + throw new Error( + `Payment data plane API request timed out (>8s) for ${method} ${path}. Check network connectivity and region.` + ); + } + throw err; + } + + if (!response.ok) { + const errorBody = await response.text().catch(() => ''); + const sanitized = errorBody + .replace( + /("apiKeySecret"|"walletSecret"|"apiKeyId"|"appId"|"appSecret"|"authorizationPrivateKey"|"authorizationId")\s*:\s*"[^"]*"/g, + '$1:"[REDACTED]"' + ) + .slice(0, 500); + const error = new Error(`Payment data plane API error (${response.status}): ${sanitized}`) as Error & { + code?: string; + }; + try { + const parsed = JSON.parse(errorBody) as Record; + const code = parsed.code ?? parsed.__type; + if (typeof code === 'string') error.code = code; + } catch (_err) { + /* ignore parse failures */ + } + throw error; + } + + if (response.status === 204) return {}; + return response.json(); +} + +// ── Payment Session Types ───────────────────────────────────────────────── + +interface GetOrCreatePaymentSessionOptions { + region: string; + managerArn: string; + userId: string; + defaultSpendLimit?: string; + defaultExpiryMinutes?: number; +} + +interface PaymentSessionSummary { + paymentSessionId: string; + status: string; + expiryTime?: string; +} + +interface ListPaymentSessionsResult { + paymentSessions: PaymentSessionSummary[]; + nextToken?: string; +} + +interface CreatePaymentSessionResult { + // CreatePaymentSession wraps the session in `paymentSession`, unlike + // ListPaymentSessions which returns `paymentSessions[]` at the top level. + paymentSession: { + paymentSessionId: string; + paymentManagerArn?: string; + userId?: string; + expiryTimeInMinutes?: number; + }; +} + +/** + * Get an existing active payment session or create a new one with default budget. + * Uses the developer's credentials (ManagementRole). + */ +export async function getOrCreatePaymentSession(options: GetOrCreatePaymentSessionOptions): Promise { + const { region, managerArn, userId, defaultSpendLimit = '10.00', defaultExpiryMinutes = 60 } = options; + const userIdHeader = { 'X-Amzn-Bedrock-AgentCore-Payments-User-Id': userId }; + + // Try to find an existing active session + try { + const listResult = (await signedDataPlaneRequest({ + region, + method: 'POST', + path: '/payments/listPaymentSessions', + body: JSON.stringify({ + userId, + paymentManagerArn: managerArn, + }), + extraHeaders: userIdHeader, + })) as ListPaymentSessionsResult; + + const activeSessions = (listResult.paymentSessions ?? []).filter(s => s.status === 'ACTIVE'); + if (activeSessions.length > 0) { + return activeSessions[0]!.paymentSessionId; + } + } catch (_err) { + // If list fails, fall through to create + } + + // No active session found — create one with configured budget + const createResult = (await signedDataPlaneRequest({ + region, + method: 'POST', + path: '/payments/createPaymentSession', + body: JSON.stringify({ + userId, + paymentManagerArn: managerArn, + expiryTimeInMinutes: defaultExpiryMinutes, + limits: { + maxSpendAmount: { + value: defaultSpendLimit, + currency: 'USD', + }, + }, + }), + extraHeaders: userIdHeader, + })) as CreatePaymentSessionResult; + + return createResult.paymentSession.paymentSessionId; +} diff --git a/src/cli/aws/agentcore.ts b/src/cli/aws/agentcore.ts index 43a1094f0..d5fcf8f9a 100644 --- a/src/cli/aws/agentcore.ts +++ b/src/cli/aws/agentcore.ts @@ -72,6 +72,10 @@ export interface InvokeAgentRuntimeOptions { baggage?: string; /** Runtime endpoint qualifier (e.g. DEFAULT, PROMPT_V1). Defaults to DEFAULT. */ endpoint?: string; + /** Payment instrument ID for x402 payments */ + paymentInstrumentId?: string; + /** Payment session ID for budget tracking */ + paymentSessionId?: string; } export interface InvokeAgentRuntimeResult { @@ -151,6 +155,21 @@ export function extractResult(text: string): string { } } +/** + * Build the JSON payload body for an invoke request. + * Includes payment context fields only when provided. + */ +function buildInvokePayload(options: InvokeAgentRuntimeOptions): string { + const body: Record = { prompt: options.payload }; + if (options.paymentInstrumentId) { + body.payment_instrument_id = options.paymentInstrumentId; + } + if (options.paymentSessionId) { + body.payment_session_id = options.paymentSessionId; + } + return JSON.stringify(body); +} + // --------------------------------------------------------------------------- // Bearer token (CUSTOM_JWT) thin HTTP client // --------------------------------------------------------------------------- @@ -203,7 +222,7 @@ async function invokeWithBearerTokenStreaming(options: InvokeAgentRuntimeOptions const res = await fetch(url, { method: 'POST', headers, - body: JSON.stringify({ prompt: options.payload }), + body: buildInvokePayload(options), }); if (!res.ok) { @@ -289,7 +308,7 @@ async function invokeWithBearerToken(options: InvokeAgentRuntimeOptions): Promis const res = await fetch(url, { method: 'POST', headers, - body: JSON.stringify({ prompt: options.payload }), + body: buildInvokePayload(options), }); if (!res.ok) { @@ -321,7 +340,7 @@ export async function invokeAgentRuntimeStreaming(options: InvokeAgentRuntimeOpt const command = new InvokeAgentRuntimeCommand({ agentRuntimeArn: options.runtimeArn, - payload: new TextEncoder().encode(JSON.stringify({ prompt: options.payload })), + payload: new TextEncoder().encode(buildInvokePayload(options)), contentType: 'application/json', accept: 'application/json, text/event-stream', runtimeSessionId: options.sessionId, @@ -417,7 +436,7 @@ export async function invokeAgentRuntime(options: InvokeAgentRuntimeOptions): Pr const command = new InvokeAgentRuntimeCommand({ agentRuntimeArn: options.runtimeArn, - payload: new TextEncoder().encode(JSON.stringify({ prompt: options.payload })), + payload: new TextEncoder().encode(buildInvokePayload(options)), contentType: 'application/json', accept: 'application/json, text/event-stream', runtimeSessionId: options.sessionId, diff --git a/src/cli/aws/index.ts b/src/cli/aws/index.ts index 80d879044..2a143628d 100644 --- a/src/cli/aws/index.ts +++ b/src/cli/aws/index.ts @@ -54,6 +54,14 @@ export { type ListHarnessesResult, type InvokeHarnessOptions, } from './agentcore-harness'; +export { + createPaymentCredentialProvider, + updatePaymentCredentialProvider, + getPaymentCredentialProvider, + deletePaymentCredentialProvider, + getPaymentManager, + getOrCreatePaymentSession, +} from './agentcore-payments'; export { DEFAULT_RUNTIME_USER_ID, executeBashCommand, diff --git a/src/cli/cdk/toolkit-lib/types.ts b/src/cli/cdk/toolkit-lib/types.ts index 61f2039b4..261e01712 100644 --- a/src/cli/cdk/toolkit-lib/types.ts +++ b/src/cli/cdk/toolkit-lib/types.ts @@ -172,6 +172,12 @@ export interface CdkToolkitWrapperOptions { * Optional AWS profile to use. */ profile?: string; + + /** + * Default AWS region for CDK operations. + * Without this, the toolkit falls back to AWS_REGION env var or us-east-1. + */ + region?: string; } export interface StackSelectionOptions { diff --git a/src/cli/cdk/toolkit-lib/wrapper.ts b/src/cli/cdk/toolkit-lib/wrapper.ts index 1fff8f724..50a05a307 100644 --- a/src/cli/cdk/toolkit-lib/wrapper.ts +++ b/src/cli/cdk/toolkit-lib/wrapper.ts @@ -94,9 +94,13 @@ export class CdkToolkitWrapper { */ async initialize(): Promise { return withErrorContext(`initialize (project: ${this.projectDir})`, async () => { - // Use explicit profile, fall back to AWS_PROFILE env var per AWS SDK precedence + // Use explicit profile and region, fall back to env vars per AWS SDK precedence const profile = this.options.profile ?? process.env.AWS_PROFILE; - const sdkConfig = profile ? { baseCredentials: BaseCredentials.awsCliCompatible({ profile }) } : undefined; + const region = this.options.region ?? process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION; + const sdkConfig = + profile || region + ? { baseCredentials: BaseCredentials.awsCliCompatible({ profile, defaultRegion: region }) } + : undefined; this.toolkit = new Toolkit({ ioHost: this.options.ioHost, @@ -105,6 +109,7 @@ export class CdkToolkitWrapper { this.cloudAssemblySource = await this.toolkit.fromCdkApp(this.getCdkAppCommand(), { workingDirectory: this.projectDir, + ...(region && { env: { AWS_REGION: region, AWS_DEFAULT_REGION: region } }), }); }); } diff --git a/src/cli/cloudformation/__tests__/parse-payment-outputs.test.ts b/src/cli/cloudformation/__tests__/parse-payment-outputs.test.ts new file mode 100644 index 000000000..3a49ebe0d --- /dev/null +++ b/src/cli/cloudformation/__tests__/parse-payment-outputs.test.ts @@ -0,0 +1,350 @@ +import { parsePaymentOutputs } from '../outputs.js'; +import type { StackOutputs } from '../outputs.js'; +import { describe, expect, it } from 'vitest'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeOutputs(name: string, overrides: Record = {}): StackOutputs { + return { + [`Payment${name}ManagerArn`]: `arn:aws:bedrock:us-east-1:123456789012:payment-manager/${name}`, + [`Payment${name}ManagerId`]: `pm-${name.toLowerCase()}-001`, + [`Payment${name}ProcessPaymentRoleArn`]: `arn:aws:iam::123456789012:role/${name}ProcessPaymentRole`, + [`Payment${name}ResourceRetrievalRoleArn`]: `arn:aws:iam::123456789012:role/${name}ResourceRetrievalRole`, + ...overrides, + }; +} + +const COINBASE_CREDENTIAL_ARN = 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/coinbase'; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('parsePaymentOutputs', () => { + describe('happy path', () => { + it('returns a complete PaymentDeployedState when all outputs are present', () => { + const outputs: StackOutputs = { + ...makeOutputs('MyManager'), + PaymentMyManagerCoinbaseConnectorId: 'conn-coinbase-001', + }; + + const specs = [ + { + name: 'MyManager', + connectors: [ + { + name: 'Coinbase', + credentialProviderArn: COINBASE_CREDENTIAL_ARN, + credentialProviderName: 'coinbase-cdp', + }, + ], + }, + ]; + + const result = parsePaymentOutputs(outputs, specs); + + expect(result.MyManager).toBeDefined(); + expect(result.MyManager!.managerId).toBe('pm-mymanager-001'); + expect(result.MyManager!.managerArn).toBe('arn:aws:bedrock:us-east-1:123456789012:payment-manager/MyManager'); + expect(result.MyManager!.processPaymentRoleArn).toBe( + 'arn:aws:iam::123456789012:role/MyManagerProcessPaymentRole' + ); + expect(result.MyManager!.resourceRetrievalRoleArn).toBe( + 'arn:aws:iam::123456789012:role/MyManagerResourceRetrievalRole' + ); + expect(result.MyManager!.connectors.Coinbase).toEqual({ + connectorId: 'conn-coinbase-001', + credentialProviderArn: COINBASE_CREDENTIAL_ARN, + credentialProviderName: 'coinbase-cdp', + }); + }); + }); + + describe('missing required manager fields', () => { + it('skips a payment when managerArn is absent', () => { + const outputs: StackOutputs = { + PaymentMyManagerManagerId: 'pm-001', + PaymentMyManagerProcessPaymentRoleArn: 'arn:aws:iam::123:role/ProcessPaymentRole', + PaymentMyManagerResourceRetrievalRoleArn: 'arn:aws:iam::123:role/ResourceRetrievalRole', + // managerArn intentionally omitted + }; + + const result = parsePaymentOutputs(outputs, [{ name: 'MyManager', connectors: [] }]); + + expect(result.MyManager).toBeUndefined(); + expect(Object.keys(result)).toHaveLength(0); + }); + + it('skips a payment when managerId is absent', () => { + const outputs: StackOutputs = { + PaymentMyManagerManagerArn: 'arn:aws:bedrock:us-east-1:123:payment-manager/MyManager', + PaymentMyManagerProcessPaymentRoleArn: 'arn:aws:iam::123:role/ProcessPaymentRole', + PaymentMyManagerResourceRetrievalRoleArn: 'arn:aws:iam::123:role/ResourceRetrievalRole', + // managerId intentionally omitted + }; + + const result = parsePaymentOutputs(outputs, [{ name: 'MyManager', connectors: [] }]); + + expect(result.MyManager).toBeUndefined(); + }); + + it('skips a payment when processPaymentRoleArn is absent', () => { + const outputs: StackOutputs = { + ...makeOutputs('MyManager'), + }; + delete outputs.PaymentMyManagerProcessPaymentRoleArn; + + const result = parsePaymentOutputs(outputs, [{ name: 'MyManager', connectors: [] }]); + + expect(result.MyManager).toBeUndefined(); + }); + + it('skips a payment when resourceRetrievalRoleArn is absent', () => { + const outputs: StackOutputs = { + ...makeOutputs('MyManager'), + }; + delete outputs.PaymentMyManagerResourceRetrievalRoleArn; + + const result = parsePaymentOutputs(outputs, [{ name: 'MyManager', connectors: [] }]); + + expect(result.MyManager).toBeUndefined(); + }); + }); + + describe('missing connector output', () => { + it('includes the manager with an empty connectors map when connector output is absent', () => { + const outputs: StackOutputs = makeOutputs('MyManager'); + // No connector output key present + + const specs = [ + { + name: 'MyManager', + connectors: [ + { + name: 'Coinbase', + credentialProviderArn: COINBASE_CREDENTIAL_ARN, + }, + ], + }, + ]; + + const result = parsePaymentOutputs(outputs, specs); + + expect(result.MyManager).toBeDefined(); + expect(result.MyManager!.connectors).toEqual({}); + }); + + it('includes a manager that has no connectors configured at all', () => { + const outputs: StackOutputs = makeOutputs('MyManager'); + + const result = parsePaymentOutputs(outputs, [{ name: 'MyManager', connectors: [] }]); + + expect(result.MyManager).toBeDefined(); + expect(result.MyManager!.connectors).toEqual({}); + }); + }); + + describe('multiple managers', () => { + it('parses both managers independently', () => { + const outputs: StackOutputs = { + ...makeOutputs('Alpha'), + ...makeOutputs('Beta'), + PaymentAlphaCoinbaseConnectorId: 'conn-alpha-coinbase', + PaymentBetaStripeConnectorId: 'conn-beta-stripe', + }; + + const specs = [ + { + name: 'Alpha', + connectors: [{ name: 'Coinbase', credentialProviderArn: 'arn:cred:alpha' }], + }, + { + name: 'Beta', + connectors: [{ name: 'Stripe', credentialProviderArn: 'arn:cred:beta' }], + }, + ]; + + const result = parsePaymentOutputs(outputs, specs); + + expect(Object.keys(result)).toHaveLength(2); + + expect(result.Alpha!.managerId).toBe('pm-alpha-001'); + expect(result.Alpha!.connectors.Coinbase).toEqual({ + connectorId: 'conn-alpha-coinbase', + credentialProviderArn: 'arn:cred:alpha', + credentialProviderName: undefined, + }); + + expect(result.Beta!.managerId).toBe('pm-beta-001'); + expect(result.Beta!.connectors.Stripe).toEqual({ + connectorId: 'conn-beta-stripe', + credentialProviderArn: 'arn:cred:beta', + credentialProviderName: undefined, + }); + }); + + it('skips only the invalid manager when one of two is missing a required field', () => { + const outputs: StackOutputs = { + ...makeOutputs('Good'), + // Bad is missing resourceRetrievalRoleArn + PaymentBadManagerArn: 'arn:aws:bedrock:us-east-1:123:payment-manager/Bad', + PaymentBadManagerId: 'pm-bad-001', + PaymentBadProcessPaymentRoleArn: 'arn:aws:iam::123:role/BadProcessPaymentRole', + }; + + const result = parsePaymentOutputs(outputs, [ + { name: 'Good', connectors: [] }, + { name: 'Bad', connectors: [] }, + ]); + + expect(result.Good).toBeDefined(); + expect(result.Bad).toBeUndefined(); + }); + }); + + describe('authorizerType pass-through', () => { + it('includes authorizerType AWS_IAM when set in spec', () => { + const outputs: StackOutputs = makeOutputs('MyManager'); + + const result = parsePaymentOutputs(outputs, [{ name: 'MyManager', authorizerType: 'AWS_IAM', connectors: [] }]); + + expect(result.MyManager!.authorizerType).toBe('AWS_IAM'); + }); + + it('includes authorizerType CUSTOM_JWT when set in spec', () => { + const outputs: StackOutputs = makeOutputs('MyManager'); + + const result = parsePaymentOutputs(outputs, [ + { name: 'MyManager', authorizerType: 'CUSTOM_JWT', connectors: [] }, + ]); + + expect(result.MyManager!.authorizerType).toBe('CUSTOM_JWT'); + }); + + it('omits authorizerType when not set in spec', () => { + const outputs: StackOutputs = makeOutputs('MyManager'); + + const result = parsePaymentOutputs(outputs, [{ name: 'MyManager', connectors: [] }]); + + expect(result.MyManager!.authorizerType).toBeUndefined(); + }); + }); + + describe('autoPayment / toolAllowlist / networkPreferences pass-through', () => { + it('includes autoPayment: true when set in spec', () => { + const outputs: StackOutputs = makeOutputs('MyManager'); + + const result = parsePaymentOutputs(outputs, [{ name: 'MyManager', autoPayment: true, connectors: [] }]); + + expect(result.MyManager!.autoPayment).toBe(true); + }); + + it('includes autoPayment: false when explicitly set to false', () => { + const outputs: StackOutputs = makeOutputs('MyManager'); + + const result = parsePaymentOutputs(outputs, [{ name: 'MyManager', autoPayment: false, connectors: [] }]); + + expect(result.MyManager!.autoPayment).toBe(false); + }); + + it('omits autoPayment when not set in spec', () => { + const outputs: StackOutputs = makeOutputs('MyManager'); + + const result = parsePaymentOutputs(outputs, [{ name: 'MyManager', connectors: [] }]); + + expect(result.MyManager!.autoPayment).toBeUndefined(); + }); + + it('includes paymentToolAllowlist when set in spec', () => { + const outputs: StackOutputs = makeOutputs('MyManager'); + const allowlist = ['x402_pay', 'x402_check_balance']; + + const result = parsePaymentOutputs(outputs, [ + { name: 'MyManager', paymentToolAllowlist: allowlist, connectors: [] }, + ]); + + expect(result.MyManager!.paymentToolAllowlist).toEqual(allowlist); + }); + + it('omits paymentToolAllowlist when not set in spec', () => { + const outputs: StackOutputs = makeOutputs('MyManager'); + + const result = parsePaymentOutputs(outputs, [{ name: 'MyManager', connectors: [] }]); + + expect(result.MyManager!.paymentToolAllowlist).toBeUndefined(); + }); + + it('includes networkPreferences when set in spec', () => { + const outputs: StackOutputs = makeOutputs('MyManager'); + const networks = ['eip155:84532', 'eip155:8453']; + + const result = parsePaymentOutputs(outputs, [ + { name: 'MyManager', networkPreferences: networks, connectors: [] }, + ]); + + expect(result.MyManager!.networkPreferences).toEqual(networks); + }); + + it('omits networkPreferences when not set in spec', () => { + const outputs: StackOutputs = makeOutputs('MyManager'); + + const result = parsePaymentOutputs(outputs, [{ name: 'MyManager', connectors: [] }]); + + expect(result.MyManager!.networkPreferences).toBeUndefined(); + }); + + it('passes all optional spec fields through together', () => { + const outputs: StackOutputs = { + ...makeOutputs('MyManager'), + PaymentMyManagerCoinbaseConnectorId: 'conn-001', + }; + + const result = parsePaymentOutputs(outputs, [ + { + name: 'MyManager', + authorizerType: 'AWS_IAM', + autoPayment: true, + paymentToolAllowlist: ['x402_pay'], + networkPreferences: ['eip155:84532'], + connectors: [{ name: 'Coinbase', credentialProviderArn: COINBASE_CREDENTIAL_ARN }], + }, + ]); + + expect(result.MyManager!.authorizerType).toBe('AWS_IAM'); + expect(result.MyManager!.autoPayment).toBe(true); + expect(result.MyManager!.paymentToolAllowlist).toEqual(['x402_pay']); + expect(result.MyManager!.networkPreferences).toEqual(['eip155:84532']); + }); + }); + + describe('edge cases', () => { + it('returns empty object when specs array is empty', () => { + const outputs: StackOutputs = makeOutputs('MyManager'); + + const result = parsePaymentOutputs(outputs, []); + + expect(result).toEqual({}); + }); + + it('returns empty object when outputs is empty', () => { + const result = parsePaymentOutputs({}, [{ name: 'MyManager', connectors: [] }]); + + expect(result).toEqual({}); + }); + + it('ignores unrelated stack outputs', () => { + const outputs: StackOutputs = { + ...makeOutputs('MyManager'), + SomeOtherOutputABC: 'unrelated-value', + ApplicationAgentSomethingRuntimeIdOutput: 'rt-999', + }; + + const result = parsePaymentOutputs(outputs, [{ name: 'MyManager', connectors: [] }]); + + expect(Object.keys(result)).toHaveLength(1); + expect(result.MyManager).toBeDefined(); + }); + }); +}); diff --git a/src/cli/cloudformation/outputs.ts b/src/cli/cloudformation/outputs.ts index f5b1173bb..132aa7d0b 100644 --- a/src/cli/cloudformation/outputs.ts +++ b/src/cli/cloudformation/outputs.ts @@ -5,6 +5,7 @@ import type { EvaluatorDeployedState, MemoryDeployedState, OnlineEvalDeployedState, + PaymentDeployedState, PolicyDeployedState, PolicyEngineDeployedState, RuntimeEndpointDeployedState, @@ -407,6 +408,73 @@ export function parseDatasetOutputs( return datasets; } +/** + * Strip underscores from a name to produce a valid CDK logical ID segment. + * Must match the toCdkId() function in the vended cdk-stack.ts. + */ +function toPaymentCdkId(name: string): string { + return name.replace(/_/g, ''); +} + +/** + * Parse payment-related CfnOutputs from a deployed stack. + * Output keys follow the pattern: Payment{name}ManagerArn, Payment{name}ManagerId, etc. + * Names have underscores stripped to produce valid CDK logical IDs. + */ +export function parsePaymentOutputs( + outputs: StackOutputs, + paymentSpecs: { + name: string; + authorizerType?: 'AWS_IAM' | 'CUSTOM_JWT'; + autoPayment?: boolean; + paymentToolAllowlist?: string[]; + networkPreferences?: string[]; + connectors: { name: string; credentialProviderArn: string; credentialProviderName?: string }[]; + }[] +): Record { + const payments: Record = {}; + + for (const spec of paymentSpecs) { + const mgrId = toPaymentCdkId(spec.name); + const managerArn = outputs[`Payment${mgrId}ManagerArn`]; + const managerId = outputs[`Payment${mgrId}ManagerId`]; + const processPaymentRoleArn = outputs[`Payment${mgrId}ProcessPaymentRoleArn`]; + const resourceRetrievalRoleArn = outputs[`Payment${mgrId}ResourceRetrievalRoleArn`]; + + if (!managerArn || !managerId || !processPaymentRoleArn || !resourceRetrievalRoleArn) continue; + + const connectors: Record< + string, + { connectorId: string; credentialProviderArn: string; credentialProviderName?: string } + > = {}; + for (const conn of spec.connectors) { + const connId = toPaymentCdkId(conn.name); + const connectorId = outputs[`Payment${mgrId}${connId}ConnectorId`]; + if (connectorId) { + connectors[conn.name] = { + connectorId, + credentialProviderArn: conn.credentialProviderArn, + credentialProviderName: conn.credentialProviderName, + }; + } + } + + payments[spec.name] = { + managerId, + managerArn, + connectors, + processPaymentRoleArn, + resourceRetrievalRoleArn, + ...(spec.authorizerType && { authorizerType: spec.authorizerType }), + ...(spec.autoPayment !== undefined && { autoPayment: spec.autoPayment }), + ...(spec.paymentToolAllowlist && { paymentToolAllowlist: spec.paymentToolAllowlist }), + ...(spec.networkPreferences && { networkPreferences: spec.networkPreferences }), + }; + } + + return payments; +} + export interface BuildDeployedStateOptions { targetName: string; stackName: string; @@ -434,6 +502,7 @@ export interface BuildDeployedStateOptions { } >; datasets?: Record; + payments?: Record; } /** @@ -456,6 +525,7 @@ export function buildDeployedState(opts: BuildDeployedStateOptions): DeployedSta runtimeEndpoints, harnesses, datasets, + payments, } = opts; const targetState: TargetDeployedState = { resources: { @@ -522,6 +592,11 @@ export function buildDeployedState(opts: BuildDeployedStateOptions): DeployedSta targetState.resources!.harnesses = harnesses; } + // Add payment state from CFN outputs (or preserve credential provider state) + if (payments && Object.keys(payments).length > 0) { + targetState.resources!.payments = payments; + } + return { targets: { ...existingState?.targets, diff --git a/src/cli/commands/deploy/actions.ts b/src/cli/commands/deploy/actions.ts index 9856b64b4..404291381 100644 --- a/src/cli/commands/deploy/actions.ts +++ b/src/cli/commands/deploy/actions.ts @@ -13,6 +13,7 @@ import { parseGatewayOutputs, parseMemoryOutputs, parseOnlineEvalOutputs, + parsePaymentOutputs, parsePolicyEngineOutputs, parsePolicyOutputs, parseRuntimeEndpointOutputs, @@ -21,6 +22,7 @@ import { getErrorMessage } from '../../errors'; import { isPreviewEnabled } from '../../feature-flags'; import { ExecLogger } from '../../logging'; import { + assertEnvFileExists, bootstrapEnvironment, buildCdkProject, checkBootstrapNeeded, @@ -47,6 +49,11 @@ import { import { syncDatasets } from '../../operations/deploy/post-deploy-datasets'; import { setupHttpGateways } from '../../operations/deploy/post-deploy-http-gateways'; import { enableOnlineEvalConfigs } from '../../operations/deploy/post-deploy-online-evals'; +import { + cleanupPaymentCredentialProviders, + hasPaymentCredentialProviders, + setupPaymentCredentialProviders, +} from '../../operations/deploy/pre-deploy-identity'; import { toStackName } from '../import/import-utils'; import type { DeployResult } from './types'; import { StackSelectionStrategy } from '@aws-cdk/toolkit-lib'; @@ -183,6 +190,14 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise = {}; @@ -265,6 +280,40 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise 0) { const existingPreSynthState = await configIO.readDeployedState().catch(() => ({ targets: {} }) as DeployedState); @@ -281,10 +330,10 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise undefined); + const existingPayments = existingDeployedState?.targets?.[target.name]?.resources?.payments; + if (existingPayments && Object.keys(existingPayments).length > 0) { + startStep('Clean up payment credentials'); + try { + await cleanupPaymentCredentialProviders({ region: target.region, payments: existingPayments }); + endStep('success'); + } catch (cleanupErr) { + endStep('error', `Payment cleanup: ${getErrorMessage(cleanupErr)}`); + // Continue with teardown -- payment cleanup is best-effort + } + } + + // After deploying the empty spec, destroy the stack entirely startStep('Tear down stack'); const teardown = await performStackTeardown(target.name); if (!teardown.success) { @@ -510,6 +574,21 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise d.name); const datasets = parseDatasetOutputs(outputs, datasetNames); + // Parse payment outputs from CFN stack + const paymentSpecs = (context.projectSpec.payments ?? []).map(p => ({ + name: p.name, + authorizerType: p.authorizerType, + autoPayment: p.autoPayment, + paymentToolAllowlist: p.paymentToolAllowlist, + networkPreferences: p.networkPreferences, + connectors: p.connectors.map(c => ({ + name: c.name, + credentialProviderArn: deployedCredentials[c.credentialName]?.credentialProviderArn ?? '', + credentialProviderName: c.credentialName, + })), + })); + const payments = paymentSpecs.length > 0 ? parsePaymentOutputs(outputs, paymentSpecs) : undefined; + endStep('success'); // Post-CDK: deploy imperative resources (harness) — preview mode only @@ -578,6 +657,7 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise 0) { diff --git a/src/cli/commands/dev/browser-mode.ts b/src/cli/commands/dev/browser-mode.ts index a0a45444f..010c5bec8 100644 --- a/src/cli/commands/dev/browser-mode.ts +++ b/src/cli/commands/dev/browser-mode.ts @@ -154,6 +154,11 @@ export async function runBrowserMode(opts: BrowserModeOptions): Promise { const { workingDir, project, agentName, harnessName, otelEnvVars = {}, collector } = opts; const configRoot = findConfigRoot(workingDir); + // Browser mode serves multiple agents; we don't know which agent will be + // launched until the user picks one in the web UI. Pass no runtime so + // payment env vars are emitted for any deployed manager and the spawned + // agent decides whether to consume them. Single-agent CLI dev correctly + // narrows by runtime via loadDevEnv(workingDir, runtime) elsewhere. const { envVars } = await loadDevEnv(workingDir); const supportedAgents = getDevSupportedAgents(project); diff --git a/src/cli/commands/dev/command.tsx b/src/cli/commands/dev/command.tsx index 13062d65e..7f01a8dfc 100644 --- a/src/cli/commands/dev/command.tsx +++ b/src/cli/commands/dev/command.tsx @@ -384,7 +384,8 @@ export const registerDev = (program: Command) => { } const agentName = opts.runtime ?? project.runtimes[0]?.name; - const { envVars } = await loadDevEnv(workingDir); + const selectedRuntime = project.runtimes.find(r => r.name === agentName); + const { envVars } = await loadDevEnv(workingDir, selectedRuntime); const mergedEnvVars = { ...envVars, ...otelEnvVars }; const config = getDevConfig(workingDir, project, configRoot ?? undefined, agentName); diff --git a/src/cli/commands/invoke/action.ts b/src/cli/commands/invoke/action.ts index a320dbadb..1bebf2a50 100644 --- a/src/cli/commands/invoke/action.ts +++ b/src/cli/commands/invoke/action.ts @@ -1,8 +1,10 @@ import { ConfigIO, ResourceNotFoundError, ValidationError } from '../../../lib'; import type { AgentCoreProjectSpec, AwsDeploymentTargets, DeployedState, HarnessModel } from '../../../schema'; import { + DEFAULT_RUNTIME_USER_ID, buildAguiRunInput, executeBashCommand, + getOrCreatePaymentSession, invokeA2ARuntime, invokeAgentRuntime, invokeAgentRuntimeStreaming, @@ -119,6 +121,58 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption ); } + // Payment flags are only supported for HTTP protocol + if ( + (options.paymentInstrumentId || options.paymentSessionId || options.autoSession) && + agentSpec.protocol && + agentSpec.protocol !== 'HTTP' + ) { + return { + success: false, + error: new Error( + `Payment flags are only supported for HTTP protocol agents. Agent '${agentSpec.name}' uses '${agentSpec.protocol}'.` + ), + }; + } + + // Conflict: --auto-session and --payment-session-id are mutually exclusive + if (options.autoSession && options.paymentSessionId) { + return { + success: false, + error: new Error('--auto-session and --payment-session-id are mutually exclusive. Use one or the other.'), + }; + } + + // Auto-session: get or create a payment session when --auto-session is set + if (options.autoSession && !options.paymentSessionId) { + const targetState = deployedState.targets[selectedTargetName]; + const payments = targetState?.resources?.payments; + const firstManager = payments ? Object.values(payments)[0] : undefined; + if (!firstManager?.managerArn) { + return { + success: false, + error: new Error('--auto-session requires a deployed payment manager. Run `agentcore deploy` first.'), + }; + } + try { + const paymentSpec = project.payments?.find(p => p.name === Object.keys(payments!)[0]); + const sessionId = await getOrCreatePaymentSession({ + region: targetConfig.region, + managerArn: firstManager.managerArn, + userId: options.userId ?? DEFAULT_RUNTIME_USER_ID, + defaultSpendLimit: paymentSpec?.defaultSpendLimit, + }); + options = { ...options, paymentSessionId: sessionId }; + } catch (err) { + return { + success: false, + error: new Error( + `--auto-session failed to create payment session: ${err instanceof Error ? err.message : String(err)}` + ), + }; + } + } + // Exec mode: run shell command in runtime container if (options.exec) { const logger = new InvokeLogger({ @@ -446,6 +500,8 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption headers: options.headers, bearerToken: options.bearerToken, baggage, + paymentInstrumentId: options.paymentInstrumentId, + paymentSessionId: options.paymentSessionId, }); for await (const chunk of result.stream) { @@ -480,6 +536,8 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption headers: options.headers, bearerToken: options.bearerToken, baggage, + paymentInstrumentId: options.paymentInstrumentId, + paymentSessionId: options.paymentSessionId, }); logger.logResponse(response.content); diff --git a/src/cli/commands/invoke/command.tsx b/src/cli/commands/invoke/command.tsx index dfc2f3298..fd634734a 100644 --- a/src/cli/commands/invoke/command.tsx +++ b/src/cli/commands/invoke/command.tsx @@ -157,7 +157,10 @@ export const registerInvoke = (program: Command) => { (val: string, prev: string[]) => [...prev, val], [] as string[] ) - .option('--bearer-token ', 'Bearer token for CUSTOM_JWT auth (bypasses SigV4) [non-interactive]'); + .option('--bearer-token ', 'Bearer token for CUSTOM_JWT auth (bypasses SigV4) [non-interactive]') + .option('--payment-instrument-id ', 'Payment instrument ID for x402 payments [non-interactive]') + .option('--payment-session-id ', 'Payment session ID for budget tracking [non-interactive]') + .option('--auto-session', 'Auto-create/reuse a payment session for testing [non-interactive]'); if (isPreviewEnabled()) { invokeCmd @@ -227,6 +230,9 @@ export const registerInvoke = (program: Command) => { systemPrompt?: string; allowedTools?: string; actorId?: string; + paymentInstrumentId?: string; + paymentSessionId?: string; + autoSession?: boolean; } ) => { try { @@ -270,7 +276,10 @@ export const registerInvoke = (program: Command) => { cliOptions.bearerToken || cliOptions.harness || cliOptions.harnessArn || - cliOptions.verbose + cliOptions.verbose || + cliOptions.paymentInstrumentId || + cliOptions.paymentSessionId || + cliOptions.autoSession ) { const result = await withCommandRunTelemetry( 'invoke', @@ -325,6 +334,9 @@ export const registerInvoke = (program: Command) => { systemPrompt: cliOptions.systemPrompt, allowedTools: cliOptions.allowedTools, actorId: cliOptions.actorId, + paymentInstrumentId: cliOptions.paymentInstrumentId, + paymentSessionId: cliOptions.paymentSessionId, + autoSession: cliOptions.autoSession, }; return handleInvokeCLI(options, invokeContext); diff --git a/src/cli/commands/invoke/types.ts b/src/cli/commands/invoke/types.ts index 22faa2392..dca5ca540 100644 --- a/src/cli/commands/invoke/types.ts +++ b/src/cli/commands/invoke/types.ts @@ -51,6 +51,12 @@ export interface InvokeOptions { allowedTools?: string; /** Override memory actor ID (harness only) */ actorId?: string; + /** Payment instrument ID for x402 payments */ + paymentInstrumentId?: string; + /** Payment session ID for budget tracking */ + paymentSessionId?: string; + /** Auto-create/reuse a payment session for testing (runs with developer ManagementRole credentials) */ + autoSession?: boolean; } export type InvokeResult = Result & { diff --git a/src/cli/commands/invoke/validate.ts b/src/cli/commands/invoke/validate.ts index dd97241b8..db1badc4e 100644 --- a/src/cli/commands/invoke/validate.ts +++ b/src/cli/commands/invoke/validate.ts @@ -21,5 +21,17 @@ export function validateInvokeOptions(options: InvokeOptions): ValidationResult if (options.stream && !options.prompt) { return { valid: false, error: 'Prompt is required for streaming' }; } + if (options.autoSession && options.paymentSessionId) { + return { + valid: false, + error: '--auto-session and --payment-session-id are mutually exclusive. Use one or the other.', + }; + } + if (options.paymentInstrumentId?.trim() === '') { + return { valid: false, error: '--payment-instrument-id cannot be empty' }; + } + if (options.paymentSessionId?.trim() === '') { + return { valid: false, error: '--payment-session-id cannot be empty' }; + } return { valid: true }; } diff --git a/src/cli/commands/logs/__tests__/action.test.ts b/src/cli/commands/logs/__tests__/action.test.ts index f7fcabc3c..06de367e1 100644 --- a/src/cli/commands/logs/__tests__/action.test.ts +++ b/src/cli/commands/logs/__tests__/action.test.ts @@ -65,6 +65,7 @@ describe('resolveAgentContext', () => { httpGateways: [], harnesses: [], datasets: [], + payments: [], }, deployedState: { targets: { @@ -131,6 +132,7 @@ describe('resolveAgentContext', () => { httpGateways: [], harnesses: [], datasets: [], + payments: [], }, }); const result = resolveAgentContext(context, {}); @@ -177,6 +179,7 @@ describe('resolveAgentContext', () => { httpGateways: [], harnesses: [], datasets: [], + payments: [], }, deployedState: { targets: { @@ -233,6 +236,7 @@ describe('resolveAgentContext', () => { httpGateways: [], harnesses: [], datasets: [], + payments: [], }, }); const result = resolveAgentContext(context, {}); diff --git a/src/cli/commands/remove/command.tsx b/src/cli/commands/remove/command.tsx index 8c2b07f4a..496669ef2 100644 --- a/src/cli/commands/remove/command.tsx +++ b/src/cli/commands/remove/command.tsx @@ -1,6 +1,10 @@ -import { ConfigIO, serializeResult, toError } from '../../../lib'; +import { ConfigIO, removeEnvVars, serializeResult, toError } from '../../../lib'; import { COMMAND_DESCRIPTIONS } from '../../constants'; import { getErrorMessage } from '../../errors'; +import { + computePaymentCredentialEnvVarNames, + computeStripePrivyCredentialEnvVarNames, +} from '../../primitives/credential-utils'; import { runCliCommand } from '../../telemetry/cli-command-run.js'; import { renderTUI } from '../../tui'; import { requireProject, requireTTY } from '../../tui/guards'; @@ -9,10 +13,26 @@ import { validateRemoveAllOptions } from './validate'; import type { Command } from '@commander-js/extra-typings'; import { Text, render } from 'ink'; -async function handleRemoveAll(_options: RemoveAllOptions): Promise { +async function handleRemoveAll(options: RemoveAllOptions): Promise { try { const configIO = new ConfigIO(); + if (options.dryRun) { + const current = await configIO.readProjectSpec(); + const items: string[] = []; + for (const r of current.runtimes ?? []) items.push(`runtime: ${r.name}`); + for (const m of current.memories ?? []) items.push(`memory: ${m.name}`); + for (const c of current.credentials ?? []) items.push(`credential: ${c.name}`); + for (const p of current.payments ?? []) items.push(`payment-manager: ${p.name}`); + for (const e of current.evaluators ?? []) items.push(`evaluator: ${e.name}`); + for (const g of current.agentCoreGateways ?? []) items.push(`gateway: ${g.name}`); + for (const pe of current.policyEngines ?? []) items.push(`policy-engine: ${pe.name}`); + return { + success: true, + message: items.length > 0 ? `Would remove: ${items.join(', ')}` : 'Nothing to remove', + }; + } + // Get current project name to preserve it let projectName = 'Project'; try { @@ -22,11 +42,35 @@ async function handleRemoveAll(_options: RemoveAllOptions): Promise { await runCliCommand('remove.all', !!options.json, async () => { const result = await handleRemoveAll(options); if (!result.success) throw result.error; - console.log(JSON.stringify(serializeResult(result))); + if (options.json) { + console.log(JSON.stringify(serializeResult(result))); + } else { + console.log(result.message ?? 'All schemas reset to empty state'); + if (result.note) console.log(result.note); + } return {}; }); } diff --git a/src/cli/commands/remove/types.ts b/src/cli/commands/remove/types.ts index afd6de173..b829199d1 100644 --- a/src/cli/commands/remove/types.ts +++ b/src/cli/commands/remove/types.ts @@ -14,7 +14,9 @@ export type ResourceType = | 'policy' | 'config-bundle' | 'ab-test' - | 'dataset'; + | 'dataset' + | 'payment-manager' + | 'payment-connector'; export interface RemoveOptions { resourceType: ResourceType; diff --git a/src/cli/commands/status/action.ts b/src/cli/commands/status/action.ts index 1432e2d1a..0d68af2c5 100644 --- a/src/cli/commands/status/action.ts +++ b/src/cli/commands/status/action.ts @@ -3,6 +3,7 @@ import type { Result } from '../../../lib/result'; import type { AgentCoreProjectSpec, AwsDeploymentTargets, DeployedResourceState, DeployedState } from '../../../schema'; import { getAgentRuntimeStatus } from '../../aws'; import { getEvaluator, getOnlineEvaluationConfig } from '../../aws/agentcore-control'; +import { getPaymentManager } from '../../aws/agentcore-payments'; import { dnsSuffix } from '../../aws/partition'; import { getErrorMessage } from '../../errors'; import { isPreviewEnabled } from '../../feature-flags'; @@ -26,7 +27,8 @@ export interface ResourceStatusEntry { | 'ab-test' | 'dataset' | 'harness' - | 'runtime-endpoint'; + | 'runtime-endpoint' + | 'payment'; name: string; deploymentState: ResourceDeploymentState; identifier?: string; @@ -308,6 +310,14 @@ export function computeResourceStatuses( }) : []; + const payments = diffResourceSet({ + resourceType: 'payment', + localItems: project.payments ?? [], + deployedRecord: resources?.payments ?? {}, + getIdentifier: deployed => deployed.managerArn, + getLocalDetail: item => `${item.authorizerType} — ${item.pattern} (${item.connectors.length} connector(s))`, + }); + return [ ...agents, ...runtimeEndpoints, @@ -322,6 +332,7 @@ export function computeResourceStatuses( ...configBundles, ...abTests, ...harnesses, + ...payments, ]; } @@ -495,6 +506,46 @@ export async function handleProjectStatus( const hasOnlineEvalErrors = resources.some(r => r.resourceType === 'online-eval' && r.error); logger.endStep(hasOnlineEvalErrors ? 'error' : 'success'); } + + // Enrich deployed payment managers with live status + const paymentStates = targetResources?.payments ?? {}; + const deployedPayments = resources.filter( + e => e.resourceType === 'payment' && e.deploymentState === 'deployed' && paymentStates[e.name] + ); + + if (deployedPayments.length > 0) { + logger.startStep( + `Fetch payment status (${deployedPayments.length} manager${deployedPayments.length !== 1 ? 's' : ''})` + ); + + await Promise.all( + resources.map(async (entry, i) => { + if (entry.resourceType !== 'payment' || entry.deploymentState !== 'deployed') return; + + const paymentState = paymentStates[entry.name]; + if (!paymentState) return; + + const connectorCount = Object.keys(paymentState.connectors ?? {}).length; + + try { + const managerDetail = await getPaymentManager({ + region: targetConfig.region, + paymentManagerId: paymentState.managerId, + }); + const status = managerDetail?.status ?? 'unknown'; + resources[i] = { ...entry, detail: `${status} — ${connectorCount} connector(s)` }; + logger.log(` ${entry.name}: ${status} (${paymentState.managerId})`); + } catch (error) { + const errorMsg = getErrorMessage(error); + resources[i] = { ...entry, detail: `unknown — ${connectorCount} connector(s)`, error: errorMsg }; + logger.log(` ${entry.name}: unknown (fetch failed) - ${errorMsg}`, 'error'); + } + }) + ); + + const hasPaymentErrors = resources.some(r => r.resourceType === 'payment' && r.error); + logger.endStep(hasPaymentErrors ? 'error' : 'success'); + } } logger.finalize(true); diff --git a/src/cli/commands/status/command.tsx b/src/cli/commands/status/command.tsx index e1ed074e0..b60276219 100644 --- a/src/cli/commands/status/command.tsx +++ b/src/cli/commands/status/command.tsx @@ -21,6 +21,7 @@ const VALID_RESOURCE_TYPES = [ 'gateway', 'evaluator', 'online-eval', + 'payment', 'policy-engine', 'policy', 'config-bundle', @@ -170,6 +171,7 @@ export const registerStatus = (program: Command) => { const abTests = filtered.filter(r => r.resourceType === 'ab-test'); const datasets = filtered.filter(r => r.resourceType === 'dataset'); const harnesses = filtered.filter(r => r.resourceType === 'harness'); + const payments = filtered.filter(r => r.resourceType === 'payment'); // TODO: Add http-gateway resource type when diffResourceSet for HTTP gateways is added to action.ts // Fetch enriched dataset info when --type dataset is specified @@ -379,6 +381,15 @@ export const registerStatus = (program: Command) => { )} + {payments.length > 0 && ( + + Payments + {payments.map(entry => ( + + ))} + + )} + {/* TODO: Add HTTP Gateways render section when diffResourceSet is added to action.ts */} {harnesses.length > 0 && ( diff --git a/src/cli/commands/validate/__tests__/action.test.ts b/src/cli/commands/validate/__tests__/action.test.ts index 4e8714014..08e967cdb 100644 --- a/src/cli/commands/validate/__tests__/action.test.ts +++ b/src/cli/commands/validate/__tests__/action.test.ts @@ -8,12 +8,22 @@ const { mockReadDeployedState, mockConfigExists, mockFindConfigRoot, + mockExistsSync, + mockReadEnvFile, + mockSecureCredentialsGet, } = vi.hoisted(() => ({ mockReadProjectSpec: vi.fn(), mockReadAWSDeploymentTargets: vi.fn(), mockReadDeployedState: vi.fn(), mockConfigExists: vi.fn(), mockFindConfigRoot: vi.fn(), + mockExistsSync: vi.fn(), + mockReadEnvFile: vi.fn(), + mockSecureCredentialsGet: vi.fn(), +})); + +vi.mock('fs', () => ({ + existsSync: mockExistsSync, })); vi.mock('../../../../lib/index.js', () => { @@ -50,6 +60,15 @@ vi.mock('../../../../lib/index.js', () => { } } + class SecureCredentials { + static fromEnvVars(_vars: Record) { + return new SecureCredentials(); + } + get(key: string) { + return mockSecureCredentialsGet(key); + } + } + return { ConfigIO: class { readProjectSpec = mockReadProjectSpec; @@ -63,6 +82,8 @@ vi.mock('../../../../lib/index.js', () => { ConfigNotFoundError, NoProjectError, findConfigRoot: mockFindConfigRoot, + readEnvFile: mockReadEnvFile, + SecureCredentials, }; }); @@ -215,3 +236,233 @@ describe('handleValidate', () => { expect(result.error.message).toBe('string error'); }); }); + +describe('payment validation', () => { + const CONFIG_ROOT = '/project/agentcore'; + + const baseSpec = { + name: 'Test', + version: 1, + managedBy: 'CDK' as const, + runtimes: [], + }; + + const coinbaseCredential = { + name: 'my-cred', + authorizerType: 'PaymentCredentialProvider', + provider: 'CoinbaseCDP', + }; + + const validPaymentSpec = { + ...baseSpec, + credentials: [coinbaseCredential], + payments: [ + { + name: 'my-manager', + connectors: [{ name: 'my-connector', credentialName: 'my-cred', provider: 'CoinbaseCDP' }], + }, + ], + }; + + afterEach(() => vi.clearAllMocks()); + + it('passes with valid config and .env.local present', async () => { + mockFindConfigRoot.mockReturnValue(CONFIG_ROOT); + mockReadProjectSpec.mockResolvedValue(validPaymentSpec); + mockReadAWSDeploymentTargets.mockResolvedValue([]); + mockConfigExists.mockReturnValue(false); + mockExistsSync.mockReturnValue(true); + mockReadEnvFile.mockResolvedValue({}); + // All three CoinbaseCDP vars present with real values + mockSecureCredentialsGet.mockImplementation((key: string) => { + const map: Record = { + AGENTCORE_CREDENTIAL_MY_CRED_API_KEY_ID: 'key-id', + AGENTCORE_CREDENTIAL_MY_CRED_API_KEY_SECRET: 'key-secret', + AGENTCORE_CREDENTIAL_MY_CRED_WALLET_SECRET: 'wallet-secret', + }; + return map[key]; + }); + + const result = await handleValidate({}); + + expect(result.success).toBe(true); + }); + + it('fails when payment manager has zero connectors', async () => { + mockFindConfigRoot.mockReturnValue(CONFIG_ROOT); + mockReadProjectSpec.mockResolvedValue({ + ...baseSpec, + payments: [{ name: 'empty-manager', connectors: [] }], + }); + mockReadAWSDeploymentTargets.mockResolvedValue([]); + mockConfigExists.mockReturnValue(false); + + const result = await handleValidate({}); + + expect(result.success).toBe(false); + assert(!result.success); + expect(result.error.message).toContain('"empty-manager" has no connectors'); + expect(result.error.message).toContain('--manager empty-manager'); + }); + + it('fails when connector references a credential that does not exist', async () => { + mockFindConfigRoot.mockReturnValue(CONFIG_ROOT); + mockReadProjectSpec.mockResolvedValue({ + ...baseSpec, + credentials: [], + payments: [ + { + name: 'my-manager', + connectors: [{ name: 'my-connector', credentialName: 'ghost-cred' }], + }, + ], + }); + mockReadAWSDeploymentTargets.mockResolvedValue([]); + mockConfigExists.mockReturnValue(false); + + const result = await handleValidate({}); + + expect(result.success).toBe(false); + assert(!result.success); + expect(result.error.message).toContain('"ghost-cred" which does not exist'); + expect(result.error.message).toContain('"my-connector"'); + }); + + it('fails when referenced credential has wrong authorizerType', async () => { + mockFindConfigRoot.mockReturnValue(CONFIG_ROOT); + mockReadProjectSpec.mockResolvedValue({ + ...baseSpec, + credentials: [{ name: 'bad-cred', authorizerType: 'OAuth2' }], + payments: [ + { + name: 'my-manager', + connectors: [{ name: 'my-connector', credentialName: 'bad-cred' }], + }, + ], + }); + mockReadAWSDeploymentTargets.mockResolvedValue([]); + mockConfigExists.mockReturnValue(false); + + const result = await handleValidate({}); + + expect(result.success).toBe(false); + assert(!result.success); + expect(result.error.message).toContain('"OAuth2"'); + expect(result.error.message).toContain('"PaymentCredentialProvider"'); + expect(result.error.message).toContain('"my-connector"'); + }); + + it('fails when connector provider does not match credential provider', async () => { + mockFindConfigRoot.mockReturnValue(CONFIG_ROOT); + mockReadProjectSpec.mockResolvedValue({ + ...baseSpec, + credentials: [{ name: 'my-cred', authorizerType: 'PaymentCredentialProvider', provider: 'StripePrivy' }], + payments: [ + { + name: 'my-manager', + connectors: [{ name: 'my-connector', credentialName: 'my-cred', provider: 'CoinbaseCDP' }], + }, + ], + }); + mockReadAWSDeploymentTargets.mockResolvedValue([]); + mockConfigExists.mockReturnValue(false); + + const result = await handleValidate({}); + + expect(result.success).toBe(false); + assert(!result.success); + expect(result.error.message).toContain('"CoinbaseCDP"'); + expect(result.error.message).toContain('"StripePrivy"'); + expect(result.error.message).toContain('"my-connector"'); + }); + + it('fails with variable list when .env.local is missing and connectors exist', async () => { + mockFindConfigRoot.mockReturnValue(CONFIG_ROOT); + mockReadProjectSpec.mockResolvedValue(validPaymentSpec); + mockReadAWSDeploymentTargets.mockResolvedValue([]); + mockConfigExists.mockReturnValue(false); + mockExistsSync.mockReturnValue(false); + + const result = await handleValidate({}); + + expect(result.success).toBe(false); + assert(!result.success); + expect(result.error.message).toContain('.env.local not found'); + expect(result.error.message).toContain('AGENTCORE_CREDENTIAL_MY_CRED_API_KEY_ID'); + expect(result.error.message).toContain('AGENTCORE_CREDENTIAL_MY_CRED_API_KEY_SECRET'); + expect(result.error.message).toContain('AGENTCORE_CREDENTIAL_MY_CRED_WALLET_SECRET'); + }); + + it('fails naming missing CoinbaseCDP vars when .env.local exists but vars are absent', async () => { + mockFindConfigRoot.mockReturnValue(CONFIG_ROOT); + mockReadProjectSpec.mockResolvedValue(validPaymentSpec); + mockReadAWSDeploymentTargets.mockResolvedValue([]); + mockConfigExists.mockReturnValue(false); + mockExistsSync.mockReturnValue(true); + mockReadEnvFile.mockResolvedValue({}); + // Only api key id is set; secret and wallet secret are missing + mockSecureCredentialsGet.mockImplementation((key: string) => { + if (key === 'AGENTCORE_CREDENTIAL_MY_CRED_API_KEY_ID') return 'key-id'; + return undefined; + }); + + const result = await handleValidate({}); + + expect(result.success).toBe(false); + assert(!result.success); + expect(result.error.message).toContain('Missing CoinbaseCDP credentials'); + expect(result.error.message).toContain('AGENTCORE_CREDENTIAL_MY_CRED_API_KEY_SECRET'); + expect(result.error.message).toContain('AGENTCORE_CREDENTIAL_MY_CRED_WALLET_SECRET'); + expect(result.error.message).not.toContain('AGENTCORE_CREDENTIAL_MY_CRED_API_KEY_ID'); + }); + + it('fails naming missing StripePrivy vars when .env.local exists but vars are absent', async () => { + mockFindConfigRoot.mockReturnValue(CONFIG_ROOT); + mockReadProjectSpec.mockResolvedValue({ + ...baseSpec, + credentials: [{ name: 'stripe-cred', authorizerType: 'PaymentCredentialProvider', provider: 'StripePrivy' }], + payments: [ + { + name: 'stripe-manager', + connectors: [{ name: 'stripe-connector', credentialName: 'stripe-cred', provider: 'StripePrivy' }], + }, + ], + }); + mockReadAWSDeploymentTargets.mockResolvedValue([]); + mockConfigExists.mockReturnValue(false); + mockExistsSync.mockReturnValue(true); + mockReadEnvFile.mockResolvedValue({}); + // Only app id is present; the other three are missing + mockSecureCredentialsGet.mockImplementation((key: string) => { + if (key === 'AGENTCORE_CREDENTIAL_STRIPE_CRED_APP_ID') return 'app-id'; + return undefined; + }); + + const result = await handleValidate({}); + + expect(result.success).toBe(false); + assert(!result.success); + expect(result.error.message).toContain('Missing StripePrivy credentials'); + expect(result.error.message).toContain('AGENTCORE_CREDENTIAL_STRIPE_CRED_APP_SECRET'); + expect(result.error.message).toContain('AGENTCORE_CREDENTIAL_STRIPE_CRED_AUTHORIZATION_PRIVATE_KEY'); + expect(result.error.message).toContain('AGENTCORE_CREDENTIAL_STRIPE_CRED_AUTHORIZATION_ID'); + expect(result.error.message).not.toContain('AGENTCORE_CREDENTIAL_STRIPE_CRED_APP_ID'); + }); + + it('fails when credential values in .env.local are whitespace-only', async () => { + mockFindConfigRoot.mockReturnValue(CONFIG_ROOT); + mockReadProjectSpec.mockResolvedValue(validPaymentSpec); + mockReadAWSDeploymentTargets.mockResolvedValue([]); + mockConfigExists.mockReturnValue(false); + mockExistsSync.mockReturnValue(true); + mockReadEnvFile.mockResolvedValue({}); + // All three vars exist but contain only whitespace + mockSecureCredentialsGet.mockReturnValue(' '); + + const result = await handleValidate({}); + + expect(result.success).toBe(false); + assert(!result.success); + expect(result.error.message).toContain('Missing CoinbaseCDP credentials'); + }); +}); diff --git a/src/cli/commands/validate/action.ts b/src/cli/commands/validate/action.ts index b6d12566b..bcb0988d7 100644 --- a/src/cli/commands/validate/action.ts +++ b/src/cli/commands/validate/action.ts @@ -5,9 +5,17 @@ import { ConfigReadError, ConfigValidationError, NoProjectError, + SecureCredentials, findConfigRoot, + readEnvFile, } from '../../../lib'; import type { Result } from '../../../lib/result'; +import { + computePaymentCredentialEnvVarNames, + computeStripePrivyCredentialEnvVarNames, +} from '../../primitives/credential-utils'; +import { existsSync } from 'fs'; +import { join } from 'path'; export interface ValidateOptions { directory?: string; @@ -32,12 +40,131 @@ export async function handleValidate(options: ValidateOptions): Promise const configIO = new ConfigIO({ baseDir: configRoot }); // Validate project spec (agentcore.json) + let projectSpec; try { - await configIO.readProjectSpec(); + projectSpec = await configIO.readProjectSpec(); } catch (err) { return { success: false, error: new Error(formatError(err, 'agentcore.json'), { cause: err }) }; } + // Validate payment credential completeness (local only, no network calls) + if (projectSpec.payments && projectSpec.payments.length > 0) { + for (const payment of projectSpec.payments) { + if (payment.connectors.length === 0) { + return { + success: false, + error: new Error( + `Payment manager "${payment.name}" has no connectors. Add a connector with \`agentcore add payment-connector --manager ${payment.name}\`` + ), + }; + } + for (const connector of payment.connectors) { + const credential = projectSpec.credentials?.find(c => c.name === connector.credentialName); + if (!credential) { + return { + success: false, + error: new Error( + `Payment connector "${connector.name}" (manager "${payment.name}") references credential "${connector.credentialName}" which does not exist.` + ), + }; + } + if (credential.authorizerType !== 'PaymentCredentialProvider') { + return { + success: false, + error: new Error( + `Payment connector "${connector.name}" references credential "${connector.credentialName}" with type "${credential.authorizerType}" — expected "PaymentCredentialProvider".` + ), + }; + } + const connectorProvider = connector.provider ?? 'CoinbaseCDP'; + const credentialProvider = 'provider' in credential ? (credential as { provider: string }).provider : undefined; + if (credentialProvider && credentialProvider !== connectorProvider) { + return { + success: false, + error: new Error( + `Payment connector "${connector.name}" uses provider "${connectorProvider}" but credential "${connector.credentialName}" is configured for "${credentialProvider}".` + ), + }; + } + } + } + + // Check .env.local has required variables + const hasConnectors = projectSpec.payments.some(p => p.connectors.length > 0); + const envFilePath = join(configRoot, '.env.local'); + if (hasConnectors && !existsSync(envFilePath)) { + const expectedVars: string[] = []; + for (const payment of projectSpec.payments) { + for (const connector of payment.connectors) { + const provider = connector.provider ?? 'CoinbaseCDP'; + if (provider === 'StripePrivy') { + const vars = computeStripePrivyCredentialEnvVarNames(connector.credentialName); + expectedVars.push(vars.appId, vars.appSecret, vars.authorizationPrivateKey, vars.authorizationId); + } else { + const vars = computePaymentCredentialEnvVarNames(connector.credentialName); + expectedVars.push(vars.apiKeyId, vars.apiKeySecret, vars.walletSecret); + } + } + } + return { + success: false, + error: new Error( + `agentcore/.env.local not found. Payment credentials required:\n${expectedVars.map(v => ` ${v}`).join('\n')}\n\nRun 'agentcore add payment-connector --manager ' to set credentials interactively.` + ), + }; + } + if (existsSync(envFilePath)) { + try { + const envVars = await readEnvFile(configRoot); + const credentials = SecureCredentials.fromEnvVars(envVars); + for (const payment of projectSpec.payments) { + for (const connector of payment.connectors) { + const provider = connector.provider ?? 'CoinbaseCDP'; + if (provider === 'StripePrivy') { + const vars = computeStripePrivyCredentialEnvVarNames(connector.credentialName); + const missing = [ + !credentials.get(vars.appId)?.trim() && vars.appId, + !credentials.get(vars.appSecret)?.trim() && vars.appSecret, + !credentials.get(vars.authorizationPrivateKey)?.trim() && vars.authorizationPrivateKey, + !credentials.get(vars.authorizationId)?.trim() && vars.authorizationId, + ].filter(Boolean); + if (missing.length > 0) { + return { + success: false, + error: new Error( + `Missing StripePrivy credentials for connector "${connector.name}" in .env.local: ${missing.join(', ')}` + ), + }; + } + } else { + const vars = computePaymentCredentialEnvVarNames(connector.credentialName); + const missing = [ + !credentials.get(vars.apiKeyId)?.trim() && vars.apiKeyId, + !credentials.get(vars.apiKeySecret)?.trim() && vars.apiKeySecret, + !credentials.get(vars.walletSecret)?.trim() && vars.walletSecret, + ].filter(Boolean); + if (missing.length > 0) { + return { + success: false, + error: new Error( + `Missing CoinbaseCDP credentials for connector "${connector.name}" in .env.local: ${missing.join(', ')}` + ), + }; + } + } + } + } + } catch (error) { + return { + success: false, + error: new Error( + `Failed to read .env.local: ${error instanceof Error ? error.message : String(error)}. Fix the file or re-run 'agentcore add payment-connector' to set credentials.` + ), + }; + } + } + } + // Validate AWS targets (aws-targets.json) try { await configIO.readAWSDeploymentTargets(); diff --git a/src/cli/commands/validate/command.tsx b/src/cli/commands/validate/command.tsx index 7842ec24a..3072083ab 100644 --- a/src/cli/commands/validate/command.tsx +++ b/src/cli/commands/validate/command.tsx @@ -8,10 +8,19 @@ export const registerValidate = (program: Command) => { program .command('validate') .option('-d, --directory ', 'Project directory containing agentcore config') + .option('--json', 'Output as JSON [non-interactive]') .description(COMMAND_DESCRIPTIONS.validate) .action(async options => { const result = await withCommandRunTelemetry('validate', {}, async () => handleValidate(options)); - if (result.success) { + + if (options.json) { + if (result.success) { + console.log(JSON.stringify({ success: true })); + } else { + console.log(JSON.stringify({ success: false, error: result.error.message })); + } + process.exit(result.success ? 0 : 1); + } else if (result.success) { render(Valid); process.exit(0); } else { diff --git a/src/cli/errors.ts b/src/cli/errors.ts index 99c59c051..ccca6fa16 100644 --- a/src/cli/errors.ts +++ b/src/cli/errors.ts @@ -107,6 +107,34 @@ export function isExpiredTokenError(err: unknown): boolean { return false; } +/** + * Checks if an error indicates a service quota or resource limit has been exceeded. + */ +export function isQuotaExceededError(err: unknown): boolean { + if (!err || typeof err !== 'object') { + return false; + } + + const error = err as Record; + + if ( + error.code === 'ServiceQuotaExceededException' || + error.code === 'LimitExceededException' || + error.code === 'TooManyRequestsException' + ) { + return true; + } + + const message = getErrorMessage(err).toLowerCase(); + return ( + message.includes('quota exceeded') || + message.includes('limit exceeded') || + message.includes('too many') || + message.includes('maximum number') || + message.includes('maxmanagers') + ); +} + /** * Checks if an error indicates the CloudFormation stack is in a transitional state. * These errors occur when trying to deploy to a stack that is currently being updated. diff --git a/src/cli/external-requirements/__tests__/checks-extended.test.ts b/src/cli/external-requirements/__tests__/checks-extended.test.ts index 9a8caa5d9..130e9c43c 100644 --- a/src/cli/external-requirements/__tests__/checks-extended.test.ts +++ b/src/cli/external-requirements/__tests__/checks-extended.test.ts @@ -58,6 +58,7 @@ describe('requiresUv', () => { httpGateways: [], harnesses: [], datasets: [], + payments: [], }; expect(requiresUv(project)).toBe(true); }); @@ -88,6 +89,7 @@ describe('requiresUv', () => { httpGateways: [], harnesses: [], datasets: [], + payments: [], }; expect(requiresUv(project)).toBe(false); }); @@ -109,6 +111,7 @@ describe('requiresUv', () => { httpGateways: [], harnesses: [], datasets: [], + payments: [], }; expect(requiresUv(project)).toBe(false); }); @@ -141,6 +144,7 @@ describe('requiresContainerRuntime', () => { httpGateways: [], harnesses: [], datasets: [], + payments: [], }; expect(requiresContainerRuntime(project)).toBe(true); }); @@ -171,6 +175,7 @@ describe('requiresContainerRuntime', () => { httpGateways: [], harnesses: [], datasets: [], + payments: [], }; expect(requiresContainerRuntime(project)).toBe(false); }); @@ -192,6 +197,7 @@ describe('requiresContainerRuntime', () => { httpGateways: [], harnesses: [], datasets: [], + payments: [], }; expect(requiresContainerRuntime(project)).toBe(false); }); @@ -230,6 +236,7 @@ describe('requiresContainerRuntime', () => { httpGateways: [], harnesses: [], datasets: [], + payments: [], }; expect(requiresContainerRuntime(project)).toBe(true); }); @@ -302,6 +309,7 @@ describe('checkDependencyVersions', () => { httpGateways: [], harnesses: [], datasets: [], + payments: [], }; const result = await checkDependencyVersions(project); @@ -327,6 +335,7 @@ describe('checkDependencyVersions', () => { httpGateways: [], harnesses: [], datasets: [], + payments: [], }; const result = await checkDependencyVersions(project); @@ -360,6 +369,7 @@ describe('checkDependencyVersions', () => { httpGateways: [], harnesses: [], datasets: [], + payments: [], }; const result = await checkDependencyVersions(project); diff --git a/src/cli/logging/remove-logger.ts b/src/cli/logging/remove-logger.ts index dc3119d2e..b58e86593 100644 --- a/src/cli/logging/remove-logger.ts +++ b/src/cli/logging/remove-logger.ts @@ -21,7 +21,9 @@ export interface RemoveLoggerOptions { | 'policy' | 'config-bundle' | 'ab-test' - | 'dataset'; + | 'dataset' + | 'payment-manager' + | 'payment-connector'; /** Name of the resource being removed */ resourceName: string; } diff --git a/src/cli/operations/agent/generate/schema-mapper.ts b/src/cli/operations/agent/generate/schema-mapper.ts index eadfa3bcd..e9584e248 100644 --- a/src/cli/operations/agent/generate/schema-mapper.ts +++ b/src/cli/operations/agent/generate/schema-mapper.ts @@ -279,6 +279,14 @@ export async function mapGenerateConfigToRenderConfig( hasMemory: isMcp || config.language === 'TypeScript' ? false : config.memory !== 'none', hasIdentity: isMcp ? false : identityProviders.length > 0, hasGateway: gatewayProviders.length > 0, + hasPayment: await (async () => { + try { + const spec = await new ConfigIO().readProjectSpec(); + return (spec.payments ?? []).length > 0; + } catch { + return false; + } + })(), isVpc: config.networkMode === 'VPC', buildType: config.buildType, memoryProviders: diff --git a/src/cli/operations/agent/generate/write-agent-to-project.ts b/src/cli/operations/agent/generate/write-agent-to-project.ts index 8fef363c0..55056c4b2 100644 --- a/src/cli/operations/agent/generate/write-agent-to-project.ts +++ b/src/cli/operations/agent/generate/write-agent-to-project.ts @@ -76,6 +76,7 @@ export async function writeAgentToProject(config: GenerateConfig, options?: Writ httpGateways: [], harnesses: [], datasets: [], + payments: [], }; await configIO.writeProjectSpec(project); diff --git a/src/cli/operations/deploy/__tests__/assert-env-file.test.ts b/src/cli/operations/deploy/__tests__/assert-env-file.test.ts new file mode 100644 index 000000000..208342def --- /dev/null +++ b/src/cli/operations/deploy/__tests__/assert-env-file.test.ts @@ -0,0 +1,156 @@ +import type { AgentCoreProjectSpec } from '../../../../schema'; +import { assertEnvFileExists, getAllCredentials } from '../pre-deploy-identity'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockExistsSync } = vi.hoisted(() => ({ mockExistsSync: vi.fn() })); +vi.mock('fs', async () => { + const actual = await vi.importActual('fs'); + return { ...actual, existsSync: mockExistsSync }; +}); + +const BASE_DIR = '/fake/project/agentcore'; + +function makeSpec(overrides: Partial = {}): AgentCoreProjectSpec { + return { + name: 'TestProject', + version: 1, + managedBy: 'CDK', + runtimes: [], + memories: [], + credentials: [], + evaluators: [], + onlineEvalConfigs: [], + agentCoreGateways: [], + policyEngines: [], + payments: [], + ...overrides, + } as AgentCoreProjectSpec; +} + +describe('assertEnvFileExists', () => { + beforeEach(() => { + mockExistsSync.mockReset(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('returns null when no credentials exist (file missing is fine)', () => { + mockExistsSync.mockReturnValue(false); + const result = assertEnvFileExists(makeSpec(), BASE_DIR); + expect(result).toBeNull(); + }); + + it('returns null when file exists', () => { + mockExistsSync.mockReturnValue(true); + const spec = makeSpec({ + credentials: [{ name: 'mykey', authorizerType: 'ApiKeyCredentialProvider' } as any], + }); + const result = assertEnvFileExists(spec, BASE_DIR); + expect(result).toBeNull(); + }); + + it('lists ApiKey env vars when file is missing', () => { + mockExistsSync.mockReturnValue(false); + const spec = makeSpec({ + credentials: [{ name: 'openai', authorizerType: 'ApiKeyCredentialProvider' } as any], + }); + const result = assertEnvFileExists(spec, BASE_DIR); + expect(result).toContain('agentcore/.env.local not found'); + expect(result).toContain('AGENTCORE_CREDENTIAL_OPENAI'); + }); + + it('lists OAuth2 env vars when file is missing', () => { + mockExistsSync.mockReturnValue(false); + const spec = makeSpec({ + credentials: [{ name: 'google-oauth', authorizerType: 'OAuthCredentialProvider' } as any], + }); + const result = assertEnvFileExists(spec, BASE_DIR); + expect(result).toContain('AGENTCORE_CREDENTIAL_GOOGLE_OAUTH_CLIENT_ID'); + expect(result).toContain('AGENTCORE_CREDENTIAL_GOOGLE_OAUTH_CLIENT_SECRET'); + }); + + it('lists CoinbaseCDP payment env vars when file is missing', () => { + mockExistsSync.mockReturnValue(false); + const spec = makeSpec({ + payments: [ + { + name: 'PayMgr', + authorizerType: 'AWS_IAM', + pattern: 'interceptor', + connectors: [{ name: 'cdpconn', provider: 'CoinbaseCDP', credentialName: 'PayMgr-cdpconn-cdp' }], + } as any, + ], + }); + const result = assertEnvFileExists(spec, BASE_DIR); + expect(result).toContain('AGENTCORE_CREDENTIAL_PAYMGR_CDPCONN_CDP_API_KEY_ID'); + expect(result).toContain('AGENTCORE_CREDENTIAL_PAYMGR_CDPCONN_CDP_API_KEY_SECRET'); + expect(result).toContain('AGENTCORE_CREDENTIAL_PAYMGR_CDPCONN_CDP_WALLET_SECRET'); + }); + + it('lists StripePrivy payment env vars when file is missing', () => { + mockExistsSync.mockReturnValue(false); + const spec = makeSpec({ + payments: [ + { + name: 'PayMgr', + authorizerType: 'AWS_IAM', + pattern: 'interceptor', + connectors: [ + { name: 'stripeconn', provider: 'StripePrivy', credentialName: 'PayMgr-stripeconn-stripe-privy' }, + ], + } as any, + ], + }); + const result = assertEnvFileExists(spec, BASE_DIR); + expect(result).toContain('APP_ID'); + expect(result).toContain('APP_SECRET'); + expect(result).toContain('AUTHORIZATION_PRIVATE_KEY'); + expect(result).toContain('AUTHORIZATION_ID'); + }); + + it('combines all credential types in a single error', () => { + mockExistsSync.mockReturnValue(false); + const spec = makeSpec({ + credentials: [ + { name: 'openai', authorizerType: 'ApiKeyCredentialProvider' } as any, + { name: 'google', authorizerType: 'OAuthCredentialProvider' } as any, + ], + payments: [ + { + name: 'PayMgr', + authorizerType: 'AWS_IAM', + pattern: 'interceptor', + connectors: [{ name: 'cdpconn', provider: 'CoinbaseCDP', credentialName: 'PayMgr-cdpconn-cdp' }], + } as any, + ], + }); + const result = assertEnvFileExists(spec, BASE_DIR); + expect(result).toContain('AGENTCORE_CREDENTIAL_OPENAI'); + expect(result).toContain('AGENTCORE_CREDENTIAL_GOOGLE_CLIENT_ID'); + expect(result).toContain('AGENTCORE_CREDENTIAL_PAYMGR_CDPCONN_CDP_API_KEY_ID'); + }); +}); + +describe('getAllCredentials', () => { + it('returns empty when no credentials configured', () => { + expect(getAllCredentials(makeSpec())).toEqual([]); + }); + + it('includes payment connector env vars', () => { + const spec = makeSpec({ + payments: [ + { + name: 'PayMgr', + authorizerType: 'AWS_IAM', + pattern: 'interceptor', + connectors: [{ name: 'cdpconn', provider: 'CoinbaseCDP', credentialName: 'PayMgr-cdpconn-cdp' }], + } as any, + ], + }); + const result = getAllCredentials(spec); + expect(result.length).toBe(3); + expect(result.every(c => c.providerName === 'PayMgr-cdpconn-cdp')).toBe(true); + }); +}); diff --git a/src/cli/operations/deploy/__tests__/post-deploy-ab-tests.test.ts b/src/cli/operations/deploy/__tests__/post-deploy-ab-tests.test.ts index c5e28c6e8..a206fc23b 100644 --- a/src/cli/operations/deploy/__tests__/post-deploy-ab-tests.test.ts +++ b/src/cli/operations/deploy/__tests__/post-deploy-ab-tests.test.ts @@ -71,6 +71,7 @@ function makeProjectSpec(abTests: AgentCoreProjectSpec['abTests'] = []): AgentCo datasets: [], abTests, harnesses: [], + payments: [], }; } diff --git a/src/cli/operations/deploy/__tests__/post-deploy-config-bundles.test.ts b/src/cli/operations/deploy/__tests__/post-deploy-config-bundles.test.ts index 2a3a5a55d..cd2dc82a8 100644 --- a/src/cli/operations/deploy/__tests__/post-deploy-config-bundles.test.ts +++ b/src/cli/operations/deploy/__tests__/post-deploy-config-bundles.test.ts @@ -509,6 +509,7 @@ describe('resolveConfigBundleComponentKeys', () => { datasets: [], abTests: [], harnesses: [], + payments: [], }; } diff --git a/src/cli/operations/deploy/__tests__/post-deploy-http-gateways.test.ts b/src/cli/operations/deploy/__tests__/post-deploy-http-gateways.test.ts index 232eee0a0..17321f72d 100644 --- a/src/cli/operations/deploy/__tests__/post-deploy-http-gateways.test.ts +++ b/src/cli/operations/deploy/__tests__/post-deploy-http-gateways.test.ts @@ -83,6 +83,7 @@ function makeProjectSpec(httpGateways: AgentCoreProjectSpec['httpGateways'] = [] httpGateways, harnesses: [], datasets: [], + payments: [], }; } diff --git a/src/cli/operations/deploy/__tests__/pre-deploy-payments.test.ts b/src/cli/operations/deploy/__tests__/pre-deploy-payments.test.ts new file mode 100644 index 000000000..1d333a231 --- /dev/null +++ b/src/cli/operations/deploy/__tests__/pre-deploy-payments.test.ts @@ -0,0 +1,440 @@ +import { cleanupPaymentCredentialProviders, setupPaymentCredentialProviders } from '../pre-deploy-identity.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +// ============================================================================ +// Hoisted mocks +// ============================================================================ + +const { + mockCreatePaymentCredentialProvider, + mockUpdatePaymentCredentialProvider, + mockGetPaymentCredentialProvider, + mockDeletePaymentCredentialProvider, + mockReadEnvFile, + mockExistsSync, +} = vi.hoisted(() => ({ + mockCreatePaymentCredentialProvider: vi.fn(), + mockUpdatePaymentCredentialProvider: vi.fn(), + mockGetPaymentCredentialProvider: vi.fn(), + mockDeletePaymentCredentialProvider: vi.fn(), + mockReadEnvFile: vi.fn(), + mockExistsSync: vi.fn(), +})); + +vi.mock('../../../aws/agentcore-payments', () => ({ + createPaymentCredentialProvider: mockCreatePaymentCredentialProvider, + updatePaymentCredentialProvider: mockUpdatePaymentCredentialProvider, + getPaymentCredentialProvider: mockGetPaymentCredentialProvider, + deletePaymentCredentialProvider: mockDeletePaymentCredentialProvider, +})); + +vi.mock('../../../../lib', () => ({ + SecureCredentials: class { + constructor(private envVars: Record) {} + static fromEnvVars(envVars: Record) { + return new this(envVars); + } + merge(_other: unknown) { + return this; + } + get(key: string) { + return this.envVars[key]; + } + }, + readEnvFile: mockReadEnvFile, +})); + +vi.mock('fs', () => ({ + existsSync: mockExistsSync, +})); + +vi.mock('../../../errors', () => ({ + isNoCredentialsError: () => false, + isQuotaExceededError: () => false, +})); + +vi.mock('../../../external-requirements/checks', () => ({ + getAwsLoginGuidance: vi.fn().mockResolvedValue('Run: aws sso login'), +})); + +// ============================================================================ +// Shared fixtures +// ============================================================================ + +const BASE_DIR = '/project/agentcore'; +const REGION = 'us-east-1'; + +function makeCoinbaseSpec(credentialName = 'my-cdp-cred') { + return { + name: 'test-project', + payments: [ + { + name: 'my-payment-manager', + connectors: [ + { + name: 'my-connector', + provider: 'CoinbaseCDP' as const, + credentialName, + }, + ], + }, + ], + credentials: [ + { + name: credentialName, + authorizerType: 'PaymentCredentialProvider' as const, + }, + ], + runtimes: [], + }; +} + +function makeStripePrivySpec(credentialName = 'my-stripe-cred') { + return { + name: 'test-project', + payments: [ + { + name: 'my-payment-manager', + connectors: [ + { + name: 'my-connector', + provider: 'StripePrivy' as const, + credentialName, + }, + ], + }, + ], + credentials: [ + { + name: credentialName, + authorizerType: 'PaymentCredentialProvider' as const, + }, + ], + runtimes: [], + }; +} + +// ============================================================================ +// setupPaymentCredentialProviders +// ============================================================================ + +describe('setupPaymentCredentialProviders', () => { + afterEach(() => vi.clearAllMocks()); + + it('returns empty credentialProviders when payments array is empty', async () => { + const projectSpec = { + name: 'test-project', + payments: [], + credentials: [], + runtimes: [], + }; + + const result = await setupPaymentCredentialProviders({ + projectSpec: projectSpec as any, + configBaseDir: BASE_DIR, + region: REGION, + }); + + expect(result.hasErrors).toBe(false); + expect(result.errors).toHaveLength(0); + expect(result.credentialProviders).toEqual({}); + expect(mockGetPaymentCredentialProvider).not.toHaveBeenCalled(); + expect(mockCreatePaymentCredentialProvider).not.toHaveBeenCalled(); + }); + + it('creates a new credential provider when none exists', async () => { + mockExistsSync.mockReturnValue(true); + mockReadEnvFile.mockResolvedValue({ + AGENTCORE_CREDENTIAL_MY_CDP_CRED_API_KEY_ID: 'key-id-123', + AGENTCORE_CREDENTIAL_MY_CDP_CRED_API_KEY_SECRET: 'key-secret-abc', + AGENTCORE_CREDENTIAL_MY_CDP_CRED_WALLET_SECRET: 'wallet-secret-xyz', + }); + mockGetPaymentCredentialProvider.mockResolvedValue(null); + mockCreatePaymentCredentialProvider.mockResolvedValue({ + credentialProviderArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789:payment-credential-provider/my-cdp-cred', + status: 'ACTIVE', + }); + + const result = await setupPaymentCredentialProviders({ + projectSpec: makeCoinbaseSpec() as any, + configBaseDir: BASE_DIR, + region: REGION, + }); + + expect(result.hasErrors).toBe(false); + expect(result.errors).toHaveLength(0); + expect(mockCreatePaymentCredentialProvider).toHaveBeenCalledOnce(); + expect(mockUpdatePaymentCredentialProvider).not.toHaveBeenCalled(); + expect(result.credentialProviders['my-cdp-cred']).toEqual({ + credentialProviderArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789:payment-credential-provider/my-cdp-cred', + credentialProviderName: 'my-cdp-cred', + }); + }); + + it('updates an existing credential provider when one already exists', async () => { + mockExistsSync.mockReturnValue(true); + mockReadEnvFile.mockResolvedValue({ + AGENTCORE_CREDENTIAL_MY_CDP_CRED_API_KEY_ID: 'key-id-123', + AGENTCORE_CREDENTIAL_MY_CDP_CRED_API_KEY_SECRET: 'key-secret-abc', + AGENTCORE_CREDENTIAL_MY_CDP_CRED_WALLET_SECRET: 'wallet-secret-xyz', + }); + mockGetPaymentCredentialProvider.mockResolvedValue({ + credentialProviderArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789:payment-credential-provider/my-cdp-cred', + name: 'my-cdp-cred', + status: 'ACTIVE', + }); + mockUpdatePaymentCredentialProvider.mockResolvedValue({ + credentialProviderArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789:payment-credential-provider/my-cdp-cred', + status: 'ACTIVE', + }); + + const result = await setupPaymentCredentialProviders({ + projectSpec: makeCoinbaseSpec() as any, + configBaseDir: BASE_DIR, + region: REGION, + }); + + expect(result.hasErrors).toBe(false); + expect(mockUpdatePaymentCredentialProvider).toHaveBeenCalledOnce(); + expect(mockCreatePaymentCredentialProvider).not.toHaveBeenCalled(); + expect(result.credentialProviders['my-cdp-cred']?.credentialProviderArn).toBe( + 'arn:aws:bedrock-agentcore:us-east-1:123456789:payment-credential-provider/my-cdp-cred' + ); + }); + + it('returns error when specific CoinbaseCDP env vars are missing from .env.local', async () => { + mockExistsSync.mockReturnValue(true); + // Only provide apiKeyId — leave secret and walletSecret absent + mockReadEnvFile.mockResolvedValue({ + AGENTCORE_CREDENTIAL_MY_CDP_CRED_API_KEY_ID: 'key-id-123', + }); + mockGetPaymentCredentialProvider.mockResolvedValue(null); + + const result = await setupPaymentCredentialProviders({ + projectSpec: makeCoinbaseSpec() as any, + configBaseDir: BASE_DIR, + region: REGION, + }); + + expect(result.hasErrors).toBe(true); + expect(result.errors).toHaveLength(1); + expect(result.errors[0]).toContain('Missing CDP credentials'); + expect(result.errors[0]).toContain('AGENTCORE_CREDENTIAL_MY_CDP_CRED_API_KEY_SECRET'); + expect(result.errors[0]).toContain('AGENTCORE_CREDENTIAL_MY_CDP_CRED_WALLET_SECRET'); + expect(mockCreatePaymentCredentialProvider).not.toHaveBeenCalled(); + }); + + it('returns error when specific StripePrivy env vars are missing from .env.local', async () => { + mockExistsSync.mockReturnValue(true); + // Provide only appId — leave others absent + mockReadEnvFile.mockResolvedValue({ + AGENTCORE_CREDENTIAL_MY_STRIPE_CRED_APP_ID: 'app-id-123', + }); + mockGetPaymentCredentialProvider.mockResolvedValue(null); + + const result = await setupPaymentCredentialProviders({ + projectSpec: makeStripePrivySpec() as any, + configBaseDir: BASE_DIR, + region: REGION, + }); + + expect(result.hasErrors).toBe(true); + expect(result.errors).toHaveLength(1); + expect(result.errors[0]).toContain('Missing StripePrivy credentials'); + expect(result.errors[0]).toContain('AGENTCORE_CREDENTIAL_MY_STRIPE_CRED_APP_SECRET'); + expect(result.errors[0]).toContain('AGENTCORE_CREDENTIAL_MY_STRIPE_CRED_AUTHORIZATION_PRIVATE_KEY'); + expect(result.errors[0]).toContain('AGENTCORE_CREDENTIAL_MY_STRIPE_CRED_AUTHORIZATION_ID'); + expect(mockCreatePaymentCredentialProvider).not.toHaveBeenCalled(); + }); + + it('resolves all 3 CoinbaseCDP env vars and passes them to create', async () => { + mockExistsSync.mockReturnValue(true); + mockReadEnvFile.mockResolvedValue({ + AGENTCORE_CREDENTIAL_MY_CDP_CRED_API_KEY_ID: 'key-id-123', + AGENTCORE_CREDENTIAL_MY_CDP_CRED_API_KEY_SECRET: 'key-secret-abc', + AGENTCORE_CREDENTIAL_MY_CDP_CRED_WALLET_SECRET: 'wallet-secret-xyz', + }); + mockGetPaymentCredentialProvider.mockResolvedValue(null); + mockCreatePaymentCredentialProvider.mockResolvedValue({ + credentialProviderArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789:payment-credential-provider/my-cdp-cred', + status: 'ACTIVE', + }); + + await setupPaymentCredentialProviders({ + projectSpec: makeCoinbaseSpec() as any, + configBaseDir: BASE_DIR, + region: REGION, + }); + + expect(mockCreatePaymentCredentialProvider).toHaveBeenCalledWith( + expect.objectContaining({ + vendor: 'CoinbaseCDP', + name: 'my-cdp-cred', + apiKeyId: 'key-id-123', + apiKeySecret: 'key-secret-abc', + walletSecret: 'wallet-secret-xyz', + region: REGION, + }) + ); + }); + + it('resolves all 4 StripePrivy env vars and passes them to create', async () => { + mockExistsSync.mockReturnValue(true); + mockReadEnvFile.mockResolvedValue({ + AGENTCORE_CREDENTIAL_MY_STRIPE_CRED_APP_ID: 'app-id-123', + AGENTCORE_CREDENTIAL_MY_STRIPE_CRED_APP_SECRET: 'app-secret-abc', + AGENTCORE_CREDENTIAL_MY_STRIPE_CRED_AUTHORIZATION_PRIVATE_KEY: 'priv-key-xyz', + AGENTCORE_CREDENTIAL_MY_STRIPE_CRED_AUTHORIZATION_ID: 'auth-id-456', + }); + mockGetPaymentCredentialProvider.mockResolvedValue(null); + mockCreatePaymentCredentialProvider.mockResolvedValue({ + credentialProviderArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789:payment-credential-provider/my-stripe-cred', + status: 'ACTIVE', + }); + + await setupPaymentCredentialProviders({ + projectSpec: makeStripePrivySpec() as any, + configBaseDir: BASE_DIR, + region: REGION, + }); + + expect(mockCreatePaymentCredentialProvider).toHaveBeenCalledWith( + expect.objectContaining({ + vendor: 'StripePrivy', + name: 'my-stripe-cred', + appId: 'app-id-123', + appSecret: 'app-secret-abc', + authorizationPrivateKey: 'priv-key-xyz', + authorizationId: 'auth-id-456', + region: REGION, + }) + ); + }); +}); + +// ============================================================================ +// cleanupPaymentCredentialProviders +// ============================================================================ + +describe('cleanupPaymentCredentialProviders', () => { + afterEach(() => vi.clearAllMocks()); + + it('deletes credential providers by name extracted from ARN', async () => { + mockDeletePaymentCredentialProvider.mockResolvedValue(undefined); + + await cleanupPaymentCredentialProviders({ + region: REGION, + payments: { + 'my-payment-manager': { + connectors: { + 'my-connector': { + credentialProviderArn: + 'arn:aws:bedrock-agentcore:us-east-1:123456789:payment-credential-provider/my-cdp-cred', + }, + }, + }, + }, + }); + + expect(mockDeletePaymentCredentialProvider).toHaveBeenCalledOnce(); + expect(mockDeletePaymentCredentialProvider).toHaveBeenCalledWith({ + region: REGION, + name: 'my-cdp-cred', + }); + }); + + it('deletes multiple credential providers across managers and connectors', async () => { + mockDeletePaymentCredentialProvider.mockResolvedValue(undefined); + + await cleanupPaymentCredentialProviders({ + region: REGION, + payments: { + 'manager-a': { + connectors: { + 'connector-1': { + credentialProviderArn: + 'arn:aws:bedrock-agentcore:us-east-1:123456789:payment-credential-provider/cred-one', + }, + 'connector-2': { + credentialProviderArn: + 'arn:aws:bedrock-agentcore:us-east-1:123456789:payment-credential-provider/cred-two', + }, + }, + }, + 'manager-b': { + connectors: { + 'connector-3': { + credentialProviderArn: + 'arn:aws:bedrock-agentcore:us-east-1:123456789:payment-credential-provider/cred-three', + }, + }, + }, + }, + }); + + expect(mockDeletePaymentCredentialProvider).toHaveBeenCalledTimes(3); + expect(mockDeletePaymentCredentialProvider).toHaveBeenCalledWith({ region: REGION, name: 'cred-one' }); + expect(mockDeletePaymentCredentialProvider).toHaveBeenCalledWith({ region: REGION, name: 'cred-two' }); + expect(mockDeletePaymentCredentialProvider).toHaveBeenCalledWith({ region: REGION, name: 'cred-three' }); + }); + + it('ignores 404 errors gracefully without throwing', async () => { + mockDeletePaymentCredentialProvider.mockRejectedValue(new Error('Payment API error (404): resource not found')); + + await expect( + cleanupPaymentCredentialProviders({ + region: REGION, + payments: { + 'my-payment-manager': { + connectors: { + 'my-connector': { + credentialProviderArn: + 'arn:aws:bedrock-agentcore:us-east-1:123456789:payment-credential-provider/my-cdp-cred', + }, + }, + }, + }, + }) + ).resolves.toBeUndefined(); + }); + + it('ignores NotFound errors gracefully without throwing', async () => { + mockDeletePaymentCredentialProvider.mockRejectedValue(new Error('ResourceNotFoundException: not found')); + + await expect( + cleanupPaymentCredentialProviders({ + region: REGION, + payments: { + 'my-payment-manager': { + connectors: { + 'my-connector': { + credentialProviderArn: + 'arn:aws:bedrock-agentcore:us-east-1:123456789:payment-credential-provider/my-cdp-cred', + }, + }, + }, + }, + }) + ).resolves.toBeUndefined(); + }); + + it('makes no API calls when payments object is empty', async () => { + await cleanupPaymentCredentialProviders({ + region: REGION, + payments: {}, + }); + + expect(mockDeletePaymentCredentialProvider).not.toHaveBeenCalled(); + }); + + it('makes no API calls when a manager has no connectors', async () => { + await cleanupPaymentCredentialProviders({ + region: REGION, + payments: { + 'my-payment-manager': {}, + }, + }); + + expect(mockDeletePaymentCredentialProvider).not.toHaveBeenCalled(); + }); +}); diff --git a/src/cli/operations/deploy/index.ts b/src/cli/operations/deploy/index.ts index db6d841eb..ed1c6fc1d 100644 --- a/src/cli/operations/deploy/index.ts +++ b/src/cli/operations/deploy/index.ts @@ -21,6 +21,7 @@ export { hasIdentityOAuthProviders, getMissingCredentials, getAllCredentials, + assertEnvFileExists, type SetupApiKeyProvidersOptions, type SetupOAuth2ProvidersOptions, type PreDeployIdentityResult, @@ -41,6 +42,16 @@ export { type DestroyTargetOptions, } from './teardown'; +// Pre-deploy payment credential setup +export { + setupPaymentCredentialProviders, + hasPaymentCredentialProviders, + cleanupPaymentCredentialProviders, + type SetupPaymentCredentialProvidersOptions, + type PaymentCredentialProvidersResult, + type PaymentCredentialProviderResult, +} from './pre-deploy-identity'; + // Post-deploy observability setup export { setupTransactionSearch } from './post-deploy-observability'; diff --git a/src/cli/operations/deploy/pre-deploy-identity.ts b/src/cli/operations/deploy/pre-deploy-identity.ts index 8484f16aa..84cb9186a 100644 --- a/src/cli/operations/deploy/pre-deploy-identity.ts +++ b/src/cli/operations/deploy/pre-deploy-identity.ts @@ -1,9 +1,19 @@ import { SecureCredentials, readEnvFile } from '../../../lib'; import type { AgentCoreProjectSpec, Credential } from '../../../schema'; import { getCredentialProvider } from '../../aws'; -import { isNoCredentialsError } from '../../errors'; +import { + createPaymentCredentialProvider, + deletePaymentCredentialProvider, + getPaymentCredentialProvider, + updatePaymentCredentialProvider, +} from '../../aws/agentcore-payments'; +import { isNoCredentialsError, isQuotaExceededError } from '../../errors'; import { getAwsLoginGuidance } from '../../external-requirements/checks'; -import { computeDefaultCredentialEnvVarName } from '../../primitives/credential-utils'; +import { + computeDefaultCredentialEnvVarName, + computePaymentCredentialEnvVarNames, + computeStripePrivyCredentialEnvVarNames, +} from '../../primitives/credential-utils'; import { apiKeyProviderExists, createApiKeyProvider, @@ -15,6 +25,8 @@ import { } from '../identity'; import { BedrockAgentCoreControlClient, GetTokenVaultCommand } from '@aws-sdk/client-bedrock-agentcore-control'; import { CreateKeyCommand, KMSClient } from '@aws-sdk/client-kms'; +import { existsSync } from 'fs'; +import { join } from 'path'; // ───────────────────────────────────────────────────────────────────────────── // Types @@ -85,6 +97,9 @@ export async function setupApiKeyProviders(options: SetupApiKeyProvidersOptions) // Set up each credential in the project for (const credential of projectSpec.credentials) { + // Skip payment credentials — handled by setupPaymentCredentialProviders below + if (credential.authorizerType === 'PaymentCredentialProvider') continue; + if (credential.authorizerType === 'ApiKeyCredentialProvider') { const result = await setupApiKeyCredentialProvider(client, credential, allCredentials); results.push(result); @@ -235,6 +250,7 @@ export async function getMissingCredentials( /** * Get list of all credentials in the project that need env vars (for manual entry prompt and runtime credential reading). + * Covers ApiKey, OAuth2, and Payment connectors. */ export function getAllCredentials(projectSpec: AgentCoreProjectSpec): MissingCredential[] { const credentials: MissingCredential[] = []; @@ -254,9 +270,49 @@ export function getAllCredentials(projectSpec: AgentCoreProjectSpec): MissingCre } } + for (const payment of projectSpec.payments ?? []) { + for (const connector of payment.connectors) { + if (connector.provider === 'StripePrivy') { + const vars = computeStripePrivyCredentialEnvVarNames(connector.credentialName); + credentials.push( + { providerName: connector.credentialName, envVarName: vars.appId }, + { providerName: connector.credentialName, envVarName: vars.appSecret }, + { providerName: connector.credentialName, envVarName: vars.authorizationPrivateKey }, + { providerName: connector.credentialName, envVarName: vars.authorizationId } + ); + } else { + const vars = computePaymentCredentialEnvVarNames(connector.credentialName); + credentials.push( + { providerName: connector.credentialName, envVarName: vars.apiKeyId }, + { providerName: connector.credentialName, envVarName: vars.apiKeySecret }, + { providerName: connector.credentialName, envVarName: vars.walletSecret } + ); + } + } + } + return credentials; } +/** + * Assert that .env.local exists if any credentials require it. + * Returns null if file exists or no credentials need it; an error message otherwise. + * + * The error lists every required env var across ApiKey, OAuth2, and Payment connectors + * so the user can populate the file in one shot rather than discovering missing vars + * one at a time across separate setup steps. + */ +export function assertEnvFileExists(projectSpec: AgentCoreProjectSpec, configBaseDir: string): string | null { + const allCredentials = getAllCredentials(projectSpec); + if (allCredentials.length === 0) return null; + + const envFilePath = join(configBaseDir, '.env.local'); + if (existsSync(envFilePath)) return null; + + const varList = allCredentials.map(c => ` ${c.envVarName}`).join('\n'); + return `agentcore/.env.local not found. Credentials require environment variables.\n\nRequired variables:\n${varList}\n\nTo fix: create agentcore/.env.local with the variables above, or re-run the relevant 'agentcore add' command to enter credentials interactively.`; +} + // ───────────────────────────────────────────────────────────────────────────── // OAuth2 Credential Provider Setup // ───────────────────────────────────────────────────────────────────────────── @@ -393,3 +449,201 @@ async function setupSingleOAuth2Provider( return { providerName: credential.name, status: 'error', error: errorMessage }; } } + +// ───────────────────────────────────────────────────────────────────────────── +// Payment Credential Providers +// ───────────────────────────────────────────────────────────────────────────── + +export interface PaymentCredentialProviderResult { + credentialProviderArn: string; + credentialProviderName: string; +} + +export interface PaymentCredentialProvidersResult { + credentialProviders: Record; + hasErrors: boolean; + errors: string[]; +} + +export interface SetupPaymentCredentialProvidersOptions { + projectSpec: AgentCoreProjectSpec; + configBaseDir: string; + region: string; + runtimeCredentials?: SecureCredentials; +} + +export function hasPaymentCredentialProviders(projectSpec: AgentCoreProjectSpec): boolean { + return (projectSpec.payments ?? []).length > 0; +} + +export async function setupPaymentCredentialProviders( + options: SetupPaymentCredentialProvidersOptions +): Promise { + const { projectSpec, configBaseDir, region, runtimeCredentials } = options; + + const result: PaymentCredentialProvidersResult = { + credentialProviders: {}, + hasErrors: false, + errors: [], + }; + + if ((projectSpec.payments ?? []).length === 0) { + return result; + } + + // The unified .env.local check runs at the top of the deploy flow (assertEnvFileExists). + // By the time we get here, the file exists; per-var validation below catches empty values. + + const envVars = await readEnvFile(configBaseDir); + const envCredentials = SecureCredentials.fromEnvVars(envVars); + const allCredentials = runtimeCredentials ? envCredentials.merge(runtimeCredentials) : envCredentials; + + for (const payment of projectSpec.payments ?? []) { + for (const connector of payment.connectors) { + try { + const credentialName = connector.credentialName; + const credential = projectSpec.credentials.find( + c => c.name === credentialName && c.authorizerType === 'PaymentCredentialProvider' + ); + if (!credential) { + result.hasErrors = true; + result.errors.push( + `Payment manager "${payment.name}" connector "${connector.name}" references credential "${credentialName}" which is not a PaymentCredentialProvider` + ); + continue; + } + + const credentialProviderArn = await createOrUpdatePaymentCredentialProvider({ + connector, + credential, + region, + credentials: allCredentials, + }); + + result.credentialProviders[credentialName] = { + credentialProviderArn, + credentialProviderName: credentialName, + }; + } catch (error) { + let errorMessage: string; + if (isNoCredentialsError(error)) { + errorMessage = `AWS credentials not found. ${await getAwsLoginGuidance()}`; + } else if (isQuotaExceededError(error)) { + errorMessage = `Service quota exceeded. Delete unused credential providers, or request a limit increase via the AWS Service Quotas console.`; + } else { + errorMessage = error instanceof Error ? error.message : String(error); + } + result.hasErrors = true; + result.errors.push(`Credential provider for "${connector.name}": ${errorMessage}`); + } + } + } + + return result; +} + +export async function cleanupPaymentCredentialProviders(options: { + region: string; + payments: Record }>; +}): Promise { + const { region, payments } = options; + + for (const [name, state] of Object.entries(payments)) { + for (const [connName, conn] of Object.entries(state.connectors ?? {})) { + const credName = conn.credentialProviderArn.split('/').pop() ?? ''; + if (credName) { + try { + await deletePaymentCredentialProvider({ region, name: credName }); + } catch (credErr) { + const msg = credErr instanceof Error ? credErr.message : String(credErr); + if (!msg.includes('404') && !msg.includes('NotFound')) { + console.warn( + `Failed to delete credential provider for connector '${connName}' (payment '${name}'): ${msg}` + ); + } + } + } + } + } +} + +// ── Payment Credential Provider Helper ──────────────────────────────────── + +interface CreateOrUpdatePaymentCredentialProviderOptions { + connector: NonNullable[number]['connectors'][number]; + credential: AgentCoreProjectSpec['credentials'][number]; + region: string; + credentials: SecureCredentials; +} + +async function createOrUpdatePaymentCredentialProvider( + options: CreateOrUpdatePaymentCredentialProviderOptions +): Promise { + const { connector, credential, region, credentials } = options; + const vendor = connector.provider ?? 'CoinbaseCDP'; + + let credProviderOptions: Parameters[0]; + + if (vendor === 'StripePrivy') { + const envVarNames = computeStripePrivyCredentialEnvVarNames(credential.name); + const appId = credentials.get(envVarNames.appId); + const appSecret = credentials.get(envVarNames.appSecret); + const authorizationPrivateKey = credentials.get(envVarNames.authorizationPrivateKey); + const authorizationId = credentials.get(envVarNames.authorizationId); + + if (!appId || !appSecret || !authorizationPrivateKey || !authorizationId) { + const missing = [ + !appId && envVarNames.appId, + !appSecret && envVarNames.appSecret, + !authorizationPrivateKey && envVarNames.authorizationPrivateKey, + !authorizationId && envVarNames.authorizationId, + ].filter(Boolean); + throw new Error( + `Missing StripePrivy credentials for connector "${connector.name}" in agentcore/.env.local: ${missing.join(', ')}` + ); + } + + credProviderOptions = { + region, + name: credential.name, + vendor: 'StripePrivy', + appId, + appSecret, + authorizationPrivateKey, + authorizationId, + }; + } else { + const envVarNames = computePaymentCredentialEnvVarNames(credential.name); + const apiKeyId = credentials.get(envVarNames.apiKeyId); + const apiKeySecret = credentials.get(envVarNames.apiKeySecret); + const walletSecret = credentials.get(envVarNames.walletSecret); + + if (!apiKeyId || !apiKeySecret || !walletSecret) { + const missing = [ + !apiKeyId && envVarNames.apiKeyId, + !apiKeySecret && envVarNames.apiKeySecret, + !walletSecret && envVarNames.walletSecret, + ].filter(Boolean); + throw new Error( + `Missing CDP credentials for connector "${connector.name}" in agentcore/.env.local: ${missing.join(', ')}` + ); + } + + credProviderOptions = { + region, + name: credential.name, + vendor: 'CoinbaseCDP', + apiKeyId, + apiKeySecret, + walletSecret, + }; + } + + const existingProvider = await getPaymentCredentialProvider({ region, name: credential.name }); + if (existingProvider) { + const updateResult = await updatePaymentCredentialProvider(credProviderOptions); + return updateResult.credentialProviderArn; + } + const createResult = await createPaymentCredentialProvider(credProviderOptions); + return createResult.credentialProviderArn; +} diff --git a/src/cli/operations/deploy/preflight.ts b/src/cli/operations/deploy/preflight.ts index a60ec4e1d..044fa1e56 100644 --- a/src/cli/operations/deploy/preflight.ts +++ b/src/cli/operations/deploy/preflight.ts @@ -92,6 +92,7 @@ export async function validateProject(): Promise { // Check for gateways in agentcore.json const hasGateways = projectSpec.agentCoreGateways && projectSpec.agentCoreGateways.length > 0; + const hasPayments = projectSpec.payments && projectSpec.payments.length > 0; if ( !hasAgents && @@ -100,7 +101,8 @@ export async function validateProject(): Promise { !hasEvaluators && !hasPolicyEngines && !hasHarnesses && - !hasDatasets + !hasDatasets && + !hasPayments ) { let hasExistingStack = false; try { @@ -242,6 +244,8 @@ export interface SynthOptions { ioHost?: IIoHost; /** Previous toolkit wrapper to dispose before synthesis. */ previousWrapper?: CdkToolkitWrapper | null; + /** Target region for CDK operations. Without this, toolkit may default to us-east-1. */ + region?: string; } /** @@ -262,6 +266,7 @@ export async function synthesizeCdk(cdkProject: LocalCdkProject, options?: Synth const toolkitWrapper = await createCdkToolkitWrapper({ projectDir: cdkProject.projectDir, ioHost: options?.ioHost ?? silentIoHost, + region: options?.region, }); // synth() produces the assembly internally and stores the directory for later use diff --git a/src/cli/operations/deploy/teardown.ts b/src/cli/operations/deploy/teardown.ts index b7c39c49b..73b4777a6 100644 --- a/src/cli/operations/deploy/teardown.ts +++ b/src/cli/operations/deploy/teardown.ts @@ -63,6 +63,7 @@ export async function destroyTarget(options: DestroyTargetOptions): Promise { httpGateways: [], harnesses: [], datasets: [], + payments: [], }; const config = getDevConfig(workingDir, project); @@ -59,6 +60,7 @@ describe('getDevConfig', () => { httpGateways: [], harnesses: [], datasets: [], + payments: [], }; const config = getDevConfig(workingDir, project); @@ -91,6 +93,7 @@ describe('getDevConfig', () => { httpGateways: [], harnesses: [], datasets: [], + payments: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -129,6 +132,7 @@ describe('getDevConfig', () => { httpGateways: [], harnesses: [], datasets: [], + payments: [], }; expect(() => getDevConfig(workingDir, project, undefined, 'NonExistentAgent')).toThrow( @@ -162,6 +166,7 @@ describe('getDevConfig', () => { httpGateways: [], harnesses: [], datasets: [], + payments: [], }; const config = getDevConfig(workingDir, project, undefined, 'TsAgent'); @@ -196,6 +201,7 @@ describe('getDevConfig', () => { httpGateways: [], harnesses: [], datasets: [], + payments: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -230,6 +236,7 @@ describe('getDevConfig', () => { httpGateways: [], harnesses: [], datasets: [], + payments: [], }; // No configRoot provided @@ -264,6 +271,7 @@ describe('getDevConfig', () => { httpGateways: [], harnesses: [], datasets: [], + payments: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -298,6 +306,7 @@ describe('getDevConfig', () => { httpGateways: [], harnesses: [], datasets: [], + payments: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -331,6 +340,7 @@ describe('getDevConfig', () => { httpGateways: [], harnesses: [], datasets: [], + payments: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -364,6 +374,7 @@ describe('getDevConfig', () => { httpGateways: [], harnesses: [], datasets: [], + payments: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -397,6 +408,7 @@ describe('getDevConfig', () => { httpGateways: [], harnesses: [], datasets: [], + payments: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -430,6 +442,7 @@ describe('getDevConfig', () => { httpGateways: [], harnesses: [], datasets: [], + payments: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -464,6 +477,7 @@ describe('getDevConfig', () => { httpGateways: [], harnesses: [], datasets: [], + payments: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -511,6 +525,7 @@ describe('getAgentPort', () => { httpGateways: [], harnesses: [], datasets: [], + payments: [], }; expect(getAgentPort(project, 'Agent1', 8080)).toBe(8080); @@ -534,6 +549,7 @@ describe('getAgentPort', () => { httpGateways: [], harnesses: [], datasets: [], + payments: [], }; expect(getAgentPort(project, 'NonExistent', 9000)).toBe(9000); @@ -562,6 +578,7 @@ describe('getDevSupportedAgents', () => { httpGateways: [], harnesses: [], datasets: [], + payments: [], }; expect(getDevSupportedAgents(project)).toEqual([]); @@ -593,6 +610,7 @@ describe('getDevSupportedAgents', () => { httpGateways: [], harnesses: [], datasets: [], + payments: [], }; const supported = getDevSupportedAgents(project); @@ -633,6 +651,7 @@ describe('getDevSupportedAgents', () => { abTests: [], httpGateways: [], harnesses: [], + payments: [], }; const supported = getDevSupportedAgents(project); @@ -665,6 +684,7 @@ describe('getDevSupportedAgents', () => { httpGateways: [], harnesses: [], datasets: [], + payments: [], }; const supported = getDevSupportedAgents(project); @@ -706,6 +726,7 @@ describe('getDevSupportedAgents', () => { httpGateways: [], harnesses: [], datasets: [], + payments: [], }; const supported = getDevSupportedAgents(project); diff --git a/src/cli/operations/dev/__tests__/payment-env.test.ts b/src/cli/operations/dev/__tests__/payment-env.test.ts new file mode 100644 index 000000000..a84d251de --- /dev/null +++ b/src/cli/operations/dev/__tests__/payment-env.test.ts @@ -0,0 +1,188 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const { mockReadDeployedState } = vi.hoisted(() => ({ + mockReadDeployedState: vi.fn(), +})); + +vi.mock('../../../../lib/index.js', () => ({ + ConfigIO: class { + readDeployedState = mockReadDeployedState; + }, +})); + +const { getPaymentEnvVars } = await import('../payment-env.js'); + +describe('getPaymentEnvVars', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns empty object when readDeployedState throws', async () => { + mockReadDeployedState.mockRejectedValue(new Error('not found')); + const result = await getPaymentEnvVars(); + expect(result).toEqual({}); + }); + + it('generates MANAGER_ARN, PROCESS_PAYMENT_ROLE_ARN, and CONNECTOR_ID for a single manager', async () => { + mockReadDeployedState.mockResolvedValue({ + targets: { + default: { + resources: { + payments: { + 'my-payment': { + managerArn: 'arn:aws:bedrock:us-east-1:123:payment-manager/my-payment', + processPaymentRoleArn: 'arn:aws:iam::123:role/ProcessPaymentRole', + connectors: { + 'coinbase-connector': { + connectorId: 'conn-abc123', + credentialProviderName: 'my-cdp-cred', + }, + }, + }, + }, + }, + }, + }, + }); + const result = await getPaymentEnvVars(); + + expect(result).toEqual({ + AGENTCORE_PAYMENT_MY_PAYMENT_MANAGER_ARN: 'arn:aws:bedrock:us-east-1:123:payment-manager/my-payment', + AGENTCORE_PAYMENT_MY_PAYMENT_PROCESS_PAYMENT_ROLE_ARN: 'arn:aws:iam::123:role/ProcessPaymentRole', + AGENTCORE_PAYMENT_MY_PAYMENT_CONNECTOR_ID: 'conn-abc123', + }); + }); + + it('injects AUTH_MODE=bearer when authorizerType is CUSTOM_JWT', async () => { + mockReadDeployedState.mockResolvedValue({ + targets: { + default: { + resources: { + payments: { + 'jwt-payment': { + managerArn: 'arn:aws:bedrock:us-east-1:123:payment-manager/jwt-payment', + authorizerType: 'CUSTOM_JWT', + connectors: { + 'my-conn': { connectorId: 'conn-jwt' }, + }, + }, + }, + }, + }, + }, + }); + + const result = await getPaymentEnvVars(); + + expect(result.AGENTCORE_PAYMENT_JWT_PAYMENT_AUTH_MODE).toBe('bearer'); + }); + + it('does NOT inject AUTH_MODE when authorizerType is AWS_IAM', async () => { + mockReadDeployedState.mockResolvedValue({ + targets: { + default: { + resources: { + payments: { + 'iam-payment': { + managerArn: 'arn:aws:bedrock:us-east-1:123:payment-manager/iam-payment', + authorizerType: 'AWS_IAM', + connectors: { + 'my-conn': { connectorId: 'conn-iam' }, + }, + }, + }, + }, + }, + }, + }); + + const result = await getPaymentEnvVars(); + + expect(result).not.toHaveProperty('AGENTCORE_PAYMENT_IAM_PAYMENT_AUTH_MODE'); + }); + + it('exposes first connector ID at manager level for multiple connectors', async () => { + mockReadDeployedState.mockResolvedValue({ + targets: { + default: { + resources: { + payments: { + 'multi-pay': { + managerArn: 'arn:aws:bedrock:us-east-1:123:payment-manager/multi-pay', + connectors: { + 'connector-one': { + connectorId: 'conn-001', + }, + 'connector-two': { + connectorId: 'conn-002', + }, + }, + }, + }, + }, + }, + }, + }); + + const result = await getPaymentEnvVars(); + + expect(result.AGENTCORE_PAYMENT_MULTI_PAY_CONNECTOR_ID).toBe('conn-001'); + expect(result).not.toHaveProperty('AGENTCORE_PAYMENT_MULTI_PAY_CONNECTOR_ONE_CONNECTOR_ID'); + expect(result).not.toHaveProperty('AGENTCORE_PAYMENT_MULTI_PAY_CONNECTOR_TWO_CONNECTOR_ID'); + }); + + it('does not inject PROCESS_PAYMENT_ROLE_ARN when it is missing', async () => { + mockReadDeployedState.mockResolvedValue({ + targets: { + default: { + resources: { + payments: { + 'no-role-pay': { + managerArn: 'arn:aws:bedrock:us-east-1:123:payment-manager/no-role-pay', + connectors: { + 'my-conn': { connectorId: 'conn-norole' }, + }, + }, + }, + }, + }, + }, + }); + + const result = await getPaymentEnvVars(); + + expect(result).not.toHaveProperty('AGENTCORE_PAYMENT_NO_ROLE_PAY_PROCESS_PAYMENT_ROLE_ARN'); + // Ensure no "undefined" string values leaked in + for (const value of Object.values(result)) { + expect(value).not.toBe('undefined'); + } + }); + + it('injects autoPayment, paymentToolAllowlist, and networkPreferences when set', async () => { + mockReadDeployedState.mockResolvedValue({ + targets: { + default: { + resources: { + payments: { + 'config-pay': { + managerArn: 'arn:aws:bedrock:us-east-1:123:payment-manager/config-pay', + autoPayment: true, + paymentToolAllowlist: ['pay_tool_a', 'pay_tool_b'], + networkPreferences: ['eip155:84532', 'eip155:1'], + connectors: { + 'my-conn': { connectorId: 'conn-cfg' }, + }, + }, + }, + }, + }, + }, + }); + + const result = await getPaymentEnvVars(); + + expect(result.AGENTCORE_PAYMENT_CONFIG_PAY_AUTO_PAYMENT).toBe('true'); + expect(result.AGENTCORE_PAYMENT_CONFIG_PAY_TOOL_ALLOWLIST).toBe('pay_tool_a,pay_tool_b'); + expect(result.AGENTCORE_PAYMENT_CONFIG_PAY_NETWORK_PREFERENCES).toBe('eip155:84532,eip155:1'); + }); +}); diff --git a/src/cli/operations/dev/load-dev-env.ts b/src/cli/operations/dev/load-dev-env.ts index 3139c4a99..d68204231 100644 --- a/src/cli/operations/dev/load-dev-env.ts +++ b/src/cli/operations/dev/load-dev-env.ts @@ -1,26 +1,32 @@ import { findConfigRoot, readEnvFile } from '../../../lib'; +import type { AgentEnvSpec } from '../../../schema'; import { getGatewayEnvVars } from './gateway-env.js'; import { getMemoryEnvVars } from './memory-env.js'; +import { getPaymentEnvVars } from './payment-env.js'; export interface DevEnv { - /** Merged env vars: deployed-state (gateway + memory) first, then .env overrides */ + /** Merged env vars: deployed-state (gateway + memory + payment) first, then .env overrides */ envVars: Record; /** Number of deployed memories (based on env vars resolved from deployed state) */ deployedMemoryCount: number; } /** - * Load all dev-mode environment variables: deployed-state gateway/memory env vars + * Load all dev-mode environment variables: deployed-state gateway/memory/payment env vars * merged with the user's .env file. Deployed-state vars go first so .env can override. + * + * @param runtime The runtime being launched. When provided, payment env vars + * are only injected for runtimes that can consume them (Python HTTP today). */ -export async function loadDevEnv(workingDir: string): Promise { +export async function loadDevEnv(workingDir: string, runtime?: AgentEnvSpec): Promise { const configRoot = findConfigRoot(workingDir); const dotEnvVars = configRoot ? await readEnvFile(configRoot) : {}; const gatewayEnvVars = await getGatewayEnvVars(); const memoryEnvVars = await getMemoryEnvVars(); + const paymentEnvVars = await getPaymentEnvVars(runtime); return { - envVars: { ...gatewayEnvVars, ...memoryEnvVars, ...dotEnvVars }, + envVars: { ...gatewayEnvVars, ...memoryEnvVars, ...paymentEnvVars, ...dotEnvVars }, deployedMemoryCount: Object.keys(memoryEnvVars).length, }; } diff --git a/src/cli/operations/dev/payment-env.ts b/src/cli/operations/dev/payment-env.ts new file mode 100644 index 000000000..16cdeb151 --- /dev/null +++ b/src/cli/operations/dev/payment-env.ts @@ -0,0 +1,67 @@ +import { ConfigIO } from '../../../lib/index.js'; +import type { AgentEnvSpec } from '../../../schema'; +import { isPaymentEligibleRuntime } from '../../primitives/payment-eligible.js'; + +/** + * Build payment env vars for a dev runtime. Mirrors the CDK stack's deploy-time + * injection but reads from `deployed-state.json` (so payments only "activate" + * locally once the project has been deployed and state is populated). + * + * @param runtime The agent runtime spec being launched. When provided and the + * runtime is not eligible for payments (non-Python, non-HTTP), an empty map + * is returned — matches the CDK behaviour of skipping ineligible runtimes. + */ +export async function getPaymentEnvVars(runtime?: AgentEnvSpec): Promise> { + if (runtime && !isPaymentEligibleRuntime(runtime)) { + return {}; + } + + const configIO = new ConfigIO(); + const envVars: Record = {}; + + try { + const deployedState = await configIO.readDeployedState(); + + // Iterate all targets (not just 'default') + for (const target of Object.values(deployedState?.targets ?? {})) { + const payments = target?.resources?.payments ?? {}; + + for (const [name, payment] of Object.entries(payments)) { + if (!payment.managerArn) continue; + const sanitized = name.toUpperCase().replace(/-/g, '_'); + envVars[`AGENTCORE_PAYMENT_${sanitized}_MANAGER_ARN`] = payment.managerArn; + if (payment.processPaymentRoleArn) { + envVars[`AGENTCORE_PAYMENT_${sanitized}_PROCESS_PAYMENT_ROLE_ARN`] = payment.processPaymentRoleArn; + } + + const connectorEntries = Object.entries(payment.connectors ?? {}); + + // Expose first connector's ID at manager level (matches CDK injection) + const firstConnector = connectorEntries[0]?.[1]; + if (firstConnector) { + envVars[`AGENTCORE_PAYMENT_${sanitized}_CONNECTOR_ID`] = firstConnector.connectorId; + } + + // Payment config env vars (parity with CDK stack injection) + if (payment.autoPayment !== undefined) { + envVars[`AGENTCORE_PAYMENT_${sanitized}_AUTO_PAYMENT`] = String(payment.autoPayment); + } + if (payment.paymentToolAllowlist && payment.paymentToolAllowlist.length > 0) { + envVars[`AGENTCORE_PAYMENT_${sanitized}_TOOL_ALLOWLIST`] = payment.paymentToolAllowlist.join(','); + } + if (payment.networkPreferences && payment.networkPreferences.length > 0) { + envVars[`AGENTCORE_PAYMENT_${sanitized}_NETWORK_PREFERENCES`] = payment.networkPreferences.join(','); + } + + // Auth mode from deployed state (mirrors CDK injection) + if (payment.authorizerType === 'CUSTOM_JWT') { + envVars[`AGENTCORE_PAYMENT_${sanitized}_AUTH_MODE`] = 'bearer'; + } + } + } + } catch { + // No deployed state or project spec issue — skip payment env vars + } + + return envVars; +} diff --git a/src/cli/primitives/PaymentConnectorPrimitive.ts b/src/cli/primitives/PaymentConnectorPrimitive.ts new file mode 100644 index 000000000..559e3da80 --- /dev/null +++ b/src/cli/primitives/PaymentConnectorPrimitive.ts @@ -0,0 +1,602 @@ +import { findConfigRoot, removeEnvVars, setEnvVar, toError } from '../../lib'; +import type { AgentCoreProjectSpec, PaymentProvider } from '../../schema'; +import { PaymentConnectorNameSchema, PaymentConnectorSchema, PaymentProviderSchema } from '../../schema'; +import type { RemoveResult } from '../commands/remove/types'; +import { getErrorMessage } from '../errors'; +import type { RemovalPreview, SchemaChange } from '../operations/remove/types'; +import { requireTTY } from '../tui/guards/tty'; +import { BasePrimitive } from './BasePrimitive'; +import { SOURCE_CODE_NOTE } from './constants'; +import { computePaymentCredentialEnvVarNames, computeStripePrivyCredentialEnvVarNames } from './credential-utils'; +import type { AddResult, AddScreenComponent, RemovableResource } from './types'; +import type { Command } from '@commander-js/extra-typings'; + +/** + * Options for adding a CoinbaseCDP payment connector. + */ +export interface AddCoinbaseCdpConnectorOptions { + manager: string; + name: string; + provider: 'CoinbaseCDP'; + apiKeyId: string; + apiKeySecret: string; + walletSecret: string; +} + +/** + * Options for adding a StripePrivy payment connector. + */ +export interface AddStripePrivyConnectorOptions { + manager: string; + name: string; + provider: 'StripePrivy'; + appId: string; + appSecret: string; + authorizationPrivateKey: string; + authorizationId: string; +} + +export type AddPaymentConnectorOptions = AddCoinbaseCdpConnectorOptions | AddStripePrivyConnectorOptions; + +/** + * Removable connector resource with parent manager context. + */ +export interface RemovableConnectorResource extends RemovableResource { + managerName: string; +} + +/** + * PaymentConnectorPrimitive handles payment connector add/remove operations. + * Connectors are child resources of a PaymentManager, using composite keys + * (managerName/connectorName) for removal — following the PolicyPrimitive pattern. + */ +export class PaymentConnectorPrimitive extends BasePrimitive { + readonly kind = 'payment-connector' as const; + readonly label = 'Payment Connector'; + readonly primitiveSchema = PaymentConnectorSchema; + + async add( + options: AddPaymentConnectorOptions + ): Promise> { + try { + const project = await this.readProjectSpec(); + // payments is optional in the schema; a connector can only attach to an + // existing manager, so an absent array simply means "manager not found". + project.payments ??= []; + + const manager = project.payments.find(m => m.name === options.manager); + if (!manager) { + return { success: false, error: new Error(`Payment manager "${options.manager}" not found.`) }; + } + + // Check for duplicate connector name within the manager + if (manager.connectors.some(c => c.name === options.name)) { + return { + success: false, + error: new Error(`Payment connector "${options.name}" already exists in manager "${options.manager}".`), + }; + } + + // Build a credential name from the connector name (suffix indicates provider) + const credentialSuffix = options.provider === 'StripePrivy' ? 'stripe-privy' : 'cdp'; + const credentialName = `${options.manager}-${options.name}-${credentialSuffix}`; + + // Check for duplicate credential name + this.checkDuplicate(project.credentials, credentialName, 'Credential'); + + // Create a PaymentCredentialProvider credential entry + project.credentials.push({ + authorizerType: 'PaymentCredentialProvider', + name: credentialName, + provider: options.provider, + }); + + // Write secrets to .env.local BEFORE spec (if this fails, spec is untouched) + if (options.provider === 'StripePrivy') { + const envVarNames = computeStripePrivyCredentialEnvVarNames(credentialName); + await setEnvVar(envVarNames.appId, options.appId); + await setEnvVar(envVarNames.appSecret, options.appSecret); + await setEnvVar(envVarNames.authorizationPrivateKey, options.authorizationPrivateKey); + await setEnvVar(envVarNames.authorizationId, options.authorizationId); + } else { + const envVarNames = computePaymentCredentialEnvVarNames(credentialName); + await setEnvVar(envVarNames.apiKeyId, options.apiKeyId); + await setEnvVar(envVarNames.apiKeySecret, options.apiKeySecret); + await setEnvVar(envVarNames.walletSecret, options.walletSecret); + } + + // Push connector into the manager's connectors array + manager.connectors.push({ + name: options.name, + provider: options.provider, + credentialName, + }); + + await this.writeProjectSpec(project); + + return { + success: true, + connectorName: options.name, + managerName: options.manager, + credentialName, + }; + } catch (err) { + return { success: false, error: toError(err) }; + } + } + + /** + * Remove a connector by composite key "managerName/connectorName" or by separate arguments. + * The composite key format is used by getRemovable() and the generic TUI remove flow. + */ + async remove(nameOrCompositeKey: string, managerName?: string): Promise { + try { + const project = await this.readProjectSpec(); + project.payments ??= []; + + let resolvedManager: string | undefined = managerName; + let resolvedConnector: string = nameOrCompositeKey; + + if (!resolvedManager && nameOrCompositeKey.includes('/')) { + const slashIndex = nameOrCompositeKey.indexOf('/'); + resolvedManager = nameOrCompositeKey.slice(0, slashIndex); + resolvedConnector = nameOrCompositeKey.slice(slashIndex + 1); + } + + if (!resolvedManager) { + // Find which manager contains this connector + const matchingManagers = project.payments.filter(m => m.connectors.some(c => c.name === resolvedConnector)); + if (matchingManagers.length > 1) { + return { + success: false, + error: new Error( + `Connector "${resolvedConnector}" exists in multiple managers: ${matchingManagers.map(m => m.name).join(', ')}. Use --manager to specify which one.` + ), + }; + } + if (matchingManagers.length === 1) { + resolvedManager = matchingManagers[0]!.name; + } + } + + for (const manager of project.payments) { + if (resolvedManager && manager.name !== resolvedManager) continue; + + const connIndex = manager.connectors.findIndex(c => c.name === resolvedConnector); + if (connIndex !== -1) { + const connector = manager.connectors[connIndex]!; + const credentialName = connector.credentialName; + + // Remove connector + manager.connectors.splice(connIndex, 1); + + // Remove associated credential if no longer referenced + const stillReferenced = project.payments.some(m => + m.connectors.some(c => c.credentialName === credentialName) + ); + if (!stillReferenced) { + const credIndex = project.credentials.findIndex(c => c.name === credentialName); + if (credIndex !== -1) { + project.credentials.splice(credIndex, 1); + } + } + + await this.writeProjectSpec(project); + + // Clean up .env.local secrets (provider-specific) + if (!stillReferenced) { + try { + if (connector.provider === 'StripePrivy') { + const envVarNames = computeStripePrivyCredentialEnvVarNames(credentialName); + await removeEnvVars([ + envVarNames.appId, + envVarNames.appSecret, + envVarNames.authorizationPrivateKey, + envVarNames.authorizationId, + ]); + } else { + const envVarNames = computePaymentCredentialEnvVarNames(credentialName); + await removeEnvVars([envVarNames.apiKeyId, envVarNames.apiKeySecret, envVarNames.walletSecret]); + } + } catch { + // Best-effort cleanup + } + } + + return { success: true }; + } + } + + return { + success: false, + error: new Error( + `Payment connector "${resolvedConnector}" not found${resolvedManager ? ` in manager "${resolvedManager}"` : ''}.` + ), + }; + } catch (err) { + return { success: false, error: toError(err) }; + } + } + + async previewRemove(nameOrCompositeKey: string): Promise { + const project = await this.readProjectSpec(); + project.payments ??= []; + + let targetManager: string | undefined; + let targetConnector: string = nameOrCompositeKey; + + if (nameOrCompositeKey.includes('/')) { + const slashIndex = nameOrCompositeKey.indexOf('/'); + targetManager = nameOrCompositeKey.slice(0, slashIndex); + targetConnector = nameOrCompositeKey.slice(slashIndex + 1); + } + + if (!targetManager) { + const matchingManagers = project.payments.filter(m => m.connectors.some(c => c.name === targetConnector)); + if (matchingManagers.length > 1) { + throw new Error( + `Connector "${targetConnector}" exists in multiple managers: ${matchingManagers.map(m => m.name).join(', ')}. Use --manager to specify which one.` + ); + } + if (matchingManagers.length === 1) { + targetManager = matchingManagers[0]!.name; + } + } + + for (const manager of project.payments) { + if (targetManager && manager.name !== targetManager) continue; + + const connector = manager.connectors.find(c => c.name === targetConnector); + if (connector) { + const summary = [`Removing payment connector: ${targetConnector} (from manager ${manager.name})`]; + + const stillReferenced = project.payments.some(m => + m.connectors + .filter(c => !(m.name === manager.name && c.name === targetConnector)) + .some(c => c.credentialName === connector.credentialName) + ); + if (!stillReferenced) { + summary.push(`Associated credential "${connector.credentialName}" will also be removed`); + } else { + summary.push(`Credential "${connector.credentialName}" is shared and will be kept`); + } + + const schemaChanges: SchemaChange[] = []; + const afterSpec: AgentCoreProjectSpec = { + ...project, + payments: project.payments.map(m => { + if (m.name !== manager.name) return m; + return { + ...m, + connectors: m.connectors.filter(c => c.name !== targetConnector), + }; + }), + }; + schemaChanges.push({ + file: 'agentcore/agentcore.json', + before: project, + after: afterSpec, + }); + + return { summary, directoriesToDelete: [], schemaChanges }; + } + } + + throw new Error( + `Payment connector "${targetConnector}" not found${targetManager ? ` in manager "${targetManager}"` : ''}.` + ); + } + + /** + * Get all removable connectors across all managers. + * Returns composite keys "managerName/connectorName" following PolicyPrimitive pattern. + */ + async getRemovable(): Promise { + try { + const project = await this.readProjectSpec(); + const resources: RemovableConnectorResource[] = []; + + for (const manager of project.payments ?? []) { + for (const connector of manager.connectors) { + resources.push({ + name: `${manager.name}/${connector.name}`, + managerName: manager.name, + }); + } + } + + return resources; + } catch { + return []; + } + } + + registerCommands(addCmd: Command, removeCmd: Command): void { + addCmd + .command('payment-connector') + .description('Add a payment connector to a payment manager') + .option('--manager ', 'Payment manager name [non-interactive]') + .option('--name ', 'Payment connector name [non-interactive]') + .option('--provider ', 'Payment provider: CoinbaseCDP, StripePrivy [non-interactive]') + .option('--api-key-id ', 'CDP API Key ID (CoinbaseCDP) [non-interactive]') + .option('--api-key-secret ', 'CDP API Key Secret (CoinbaseCDP) [non-interactive]') + .option('--wallet-secret ', 'CDP Wallet Secret (CoinbaseCDP) [non-interactive]') + .option('--app-id ', 'Privy App ID (StripePrivy) [non-interactive]') + .option('--app-secret ', 'Privy App Secret (StripePrivy) [non-interactive]') + .option('--authorization-private-key ', 'ECDSA P-256 private key (StripePrivy) [non-interactive]') + .option('--authorization-id ', 'Authorization key identifier (StripePrivy) [non-interactive]') + .option('--json', 'Output as JSON [non-interactive]') + .action( + async (cliOptions: { + manager?: string; + name?: string; + provider?: string; + apiKeyId?: string; + apiKeySecret?: string; + walletSecret?: string; + appId?: string; + appSecret?: string; + authorizationPrivateKey?: string; + authorizationId?: string; + json?: boolean; + }) => { + try { + if (!findConfigRoot()) { + console.error('No agentcore project found. Run `agentcore create` first.'); + process.exit(1); + } + + const hasAnyOption = + cliOptions.manager ?? + cliOptions.name ?? + cliOptions.provider ?? + cliOptions.apiKeyId ?? + cliOptions.apiKeySecret ?? + cliOptions.walletSecret ?? + cliOptions.appId ?? + cliOptions.appSecret ?? + cliOptions.authorizationPrivateKey ?? + cliOptions.authorizationId ?? + cliOptions.json; + + if (hasAnyOption) { + if (!cliOptions.provider) { + const error = '--provider is required. Valid: CoinbaseCDP, StripePrivy'; + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error })); + } else { + console.error(error); + } + process.exit(1); + } + let provider: PaymentProvider; + try { + provider = PaymentProviderSchema.parse(cliOptions.provider); + } catch { + const error = `Invalid provider "${cliOptions.provider}". Valid: CoinbaseCDP, StripePrivy`; + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error })); + } else { + console.error(error); + } + process.exit(1); + } + + const missing: string[] = []; + if (!cliOptions.manager) missing.push('--manager'); + if (!cliOptions.name) missing.push('--name'); + + if (provider === 'StripePrivy') { + if (!cliOptions.appId?.trim()) missing.push('--app-id'); + if (!cliOptions.appSecret?.trim()) missing.push('--app-secret'); + if (!cliOptions.authorizationPrivateKey?.trim()) missing.push('--authorization-private-key'); + if (!cliOptions.authorizationId?.trim()) missing.push('--authorization-id'); + } else { + if (!cliOptions.apiKeyId?.trim()) missing.push('--api-key-id'); + if (!cliOptions.apiKeySecret?.trim()) missing.push('--api-key-secret'); + if (!cliOptions.walletSecret?.trim()) missing.push('--wallet-secret'); + } + + if (missing.length > 0) { + const error = `Missing required options for ${provider}: ${missing.join(', ')}`; + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error })); + } else { + console.error(error); + } + process.exit(1); + } + + const nameResult = PaymentConnectorNameSchema.safeParse(cliOptions.name); + if (!nameResult.success) { + const error = `Invalid connector name: ${nameResult.error.issues[0]?.message}`; + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error })); + } else { + console.error(error); + } + process.exit(1); + } + + // Validate StripePrivy authorizationPrivateKey format (base64-encoded EC P-256 key) + if (provider === 'StripePrivy') { + // AWS docs ship the key with a `wallet-auth:` prefix — strip it transparently. + let trimmedKey = cliOptions.authorizationPrivateKey!.trim(); + if (trimmedKey.startsWith('wallet-auth:')) { + trimmedKey = trimmedKey.slice('wallet-auth:'.length); + cliOptions.authorizationPrivateKey = trimmedKey; + } + const BASE64_REGEX = /^[A-Za-z0-9+/]+=*$/; + if (!BASE64_REGEX.test(trimmedKey)) { + const error = 'authorizationPrivateKey must be base64-encoded'; + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error })); + } else { + console.error(error); + } + process.exit(1); + } + const decoded = Buffer.from(trimmedKey, 'base64'); + if (decoded.length < 100 || decoded.length > 200) { + const error = + 'authorizationPrivateKey must be a base64-encoded EC P-256 private key (unexpected length)'; + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error })); + } else { + console.error(error); + } + process.exit(1); + } + } + + let result: Awaited>; + if (provider === 'StripePrivy') { + result = await this.add({ + manager: cliOptions.manager!, + name: cliOptions.name!, + provider, + appId: cliOptions.appId!.trim(), + appSecret: cliOptions.appSecret!.trim(), + authorizationPrivateKey: cliOptions.authorizationPrivateKey!.trim(), + authorizationId: cliOptions.authorizationId!.trim(), + }); + } else { + result = await this.add({ + manager: cliOptions.manager!, + name: cliOptions.name!, + provider, + apiKeyId: cliOptions.apiKeyId!.trim(), + apiKeySecret: cliOptions.apiKeySecret!.trim(), + walletSecret: cliOptions.walletSecret!.trim(), + }); + } + + if (cliOptions.json) { + console.log( + JSON.stringify( + result.success + ? result + : { + success: false, + error: result.error instanceof Error ? result.error.message : String(result.error), + } + ) + ); + } else if (result.success) { + console.log(`Added payment connector '${result.connectorName}' to manager '${result.managerName}'`); + console.log(`Credential '${result.credentialName}' created and secrets stored in .env.local`); + console.log(`Run \`agentcore deploy\` to create payment infrastructure on AWS.`); + } else { + console.error(result.error.message); + } + process.exit(result.success ? 0 : 1); + } else { + requireTTY(); + const [{ render }, { default: React }, { AddFlow }] = await Promise.all([ + import('ink'), + import('react'), + import('../tui/screens/add/AddFlow'), + ]); + const { clear, unmount } = render( + React.createElement(AddFlow, { + isInteractive: false, + onExit: () => { + clear(); + unmount(); + process.exit(0); + }, + }) + ); + } + } catch (error) { + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error: getErrorMessage(error) })); + } else { + console.error(`Error: ${getErrorMessage(error)}`); + } + process.exit(1); + } + } + ); + + removeCmd + .command('payment-connector') + .description('Remove a payment connector from a payment manager') + .option('--name ', 'Name of connector to remove [non-interactive]') + .option('--manager ', 'Payment manager name [non-interactive]') + .option('-y, --yes', 'Skip confirmation prompt [non-interactive]') + .option('--json', 'Output as JSON [non-interactive]') + .action(async (cliOptions: { name?: string; manager?: string; yes?: boolean; json?: boolean }) => { + try { + if (!findConfigRoot()) { + console.error('No agentcore project found. Run `agentcore create` first.'); + process.exit(1); + } + + if (cliOptions.name || cliOptions.yes || cliOptions.json) { + if (!cliOptions.name) { + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error: '--name is required' })); + } else { + console.error('--name is required'); + } + process.exit(1); + } + + // Build composite key when --manager is provided + const removeKey = cliOptions.manager ? `${cliOptions.manager}/${cliOptions.name}` : cliOptions.name; + const result = await this.remove(removeKey); + + if (cliOptions.json) { + console.log( + JSON.stringify({ + success: result.success, + resourceType: this.kind, + resourceName: cliOptions.name, + message: result.success ? `Removed payment connector '${cliOptions.name}'` : undefined, + note: result.success ? SOURCE_CODE_NOTE : undefined, + error: !result.success ? result.error.message : undefined, + }) + ); + } else if (result.success) { + console.log(`Removed payment connector '${cliOptions.name}'`); + } else { + console.error(result.error.message); + } + process.exit(result.success ? 0 : 1); + } else { + requireTTY(); + const [{ render }, { default: React }, { RemoveFlow }] = await Promise.all([ + import('ink'), + import('react'), + import('../tui/screens/remove'), + ]); + const { clear, unmount } = render( + React.createElement(RemoveFlow, { + isInteractive: false, + force: cliOptions.yes, + initialResourceType: this.kind, + initialResourceName: cliOptions.name, + onExit: () => { + clear(); + unmount(); + process.exit(0); + }, + }) + ); + } + } catch (error) { + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error: getErrorMessage(error) })); + } else { + console.error(`Error: ${getErrorMessage(error)}`); + } + process.exit(1); + } + }); + } + + addScreen(): AddScreenComponent { + return null; + } +} diff --git a/src/cli/primitives/PaymentManagerPrimitive.ts b/src/cli/primitives/PaymentManagerPrimitive.ts new file mode 100644 index 000000000..8a59d369d --- /dev/null +++ b/src/cli/primitives/PaymentManagerPrimitive.ts @@ -0,0 +1,718 @@ +import { findConfigRoot, removeEnvVars, serializeResult, toError } from '../../lib'; +import type { AgentCoreProjectSpec, PaymentAuthorizerType, PaymentPattern } from '../../schema'; +import { + PaymentAuthorizerTypeSchema, + PaymentManagerNameSchema, + PaymentManagerSchema, + PaymentPatternSchema, +} from '../../schema'; +import type { RemoveResult } from '../commands/remove/types'; +import { getErrorMessage } from '../errors'; +import type { RemovalPreview, SchemaChange } from '../operations/remove/types'; +import { getTemplatePath } from '../templates/templateRoot'; +import { requireTTY } from '../tui/guards/tty'; +import { BasePrimitive } from './BasePrimitive'; +import { SOURCE_CODE_NOTE } from './constants'; +import { computePaymentCredentialEnvVarNames, computeStripePrivyCredentialEnvVarNames } from './credential-utils'; +import { isPaymentEligibleRuntime } from './payment-eligible'; +import type { AddResult, AddScreenComponent, RemovableResource } from './types'; +import type { Command } from '@commander-js/extra-typings'; +import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; + +/** + * Find a safe character offset for inserting a top-level Python import. + * + * Python requires `from __future__ import ...` to appear before any other + * imports. A module-level docstring (if present) must appear before any + * import. This helper returns the offset just AFTER: + * - any leading shebang / encoding cookie, + * - an optional module docstring (`""" ... """` or `''' ... '''`), + * - all `from __future__ import ...` lines (single- or multi-line). + * + * Inserting at this offset is safe regardless of how the user has formatted + * the rest of their imports (parenthesised multi-line, conditional imports, + * etc.) — we never splice into the middle of an existing import statement. + */ +export function computeImportInsertionPoint(source: string): number { + let pos = 0; + const len = source.length; + + // Skip BOM, shebang, leading blank/comment-only lines, and a module docstring. + // We walk line-by-line; any non-blank, non-shebang, non-comment, non-docstring + // line ends the prelude. + while (pos < len) { + // Skip blank lines. + if (source[pos] === '\n') { + pos++; + continue; + } + // Read one line. + const lineEnd = source.indexOf('\n', pos); + const lineEndPos = lineEnd === -1 ? len : lineEnd; + const line = source.slice(pos, lineEndPos); + const trimmed = line.trim(); + + // Shebang or encoding cookie or comment — skip the line. + if (trimmed.startsWith('#')) { + pos = lineEndPos + 1; + continue; + } + + // Module docstring? Match a triple-quoted string at the start of the line. + if (/^("""|''')/.test(trimmed)) { + const quote = trimmed.startsWith('"""') ? '"""' : "'''"; + // Single-line docstring. + const restOfLine = trimmed.slice(3); + if (restOfLine.endsWith(quote) && restOfLine.length >= 3) { + pos = lineEndPos + 1; + continue; + } + // Multi-line docstring — find the closing quote. + const closeIdx = source.indexOf(quote, pos + 3); + if (closeIdx === -1) break; + const afterClose = source.indexOf('\n', closeIdx + 3); + pos = afterClose === -1 ? len : afterClose + 1; + continue; + } + + // `from __future__ import ...` — skip past the entire (possibly multi-line) statement. + if (/^from __future__ import\b/.test(trimmed)) { + // Multi-line parenthesised form: keep advancing until parens balance. + const openParen = line.indexOf('('); + if (openParen !== -1 && !line.includes(')', openParen)) { + const closeParen = source.indexOf(')', pos); + if (closeParen === -1) break; + const afterClose = source.indexOf('\n', closeParen); + pos = afterClose === -1 ? len : afterClose + 1; + } else { + pos = lineEndPos + 1; + } + continue; + } + + // Anything else — we're past the prelude. Insert here. + break; + } + + return pos; +} + +/** + * Options for adding a payment manager resource. + */ +export interface AddPaymentManagerOptions { + name: string; + authorizerType: PaymentAuthorizerType; + discoveryUrl?: string; + allowedClients?: string[]; + allowedAudience?: string[]; + allowedScopes?: string[]; + pattern: PaymentPattern; + description?: string; + autoPayment?: boolean; + defaultSpendLimit?: string; + paymentToolAllowlist?: string[]; + networkPreferences?: string[]; +} + +/** + * PaymentManagerPrimitive handles payment manager add/remove operations. + * Manages the top-level payment manager entry in agentcore.json. + * Connectors (child resources) are managed by PaymentConnectorPrimitive. + */ +export class PaymentManagerPrimitive extends BasePrimitive { + readonly kind = 'payment-manager' as const; + readonly label = 'Payment Manager'; + readonly primitiveSchema = PaymentManagerSchema; + + async add( + options: AddPaymentManagerOptions + ): Promise> { + try { + const project = await this.readProjectSpec(); + // payments is optional in the schema (absent on projects with no payment + // managers); normalize to an array so the mutating logic below is safe. + project.payments ??= []; + + this.checkDuplicate(project.payments, options.name, 'Payment manager'); + + if (options.authorizerType === 'CUSTOM_JWT' && !options.discoveryUrl) { + return { success: false, error: new Error('--discovery-url is required when --authorizer-type is CUSTOM_JWT') }; + } + + const authorizerConfiguration = + options.authorizerType === 'CUSTOM_JWT' + ? { + customJWTAuthorizer: { + discoveryUrl: options.discoveryUrl!, + ...(options.allowedClients && { allowedClients: options.allowedClients }), + ...(options.allowedAudience && { allowedAudience: options.allowedAudience }), + ...(options.allowedScopes && { allowedScopes: options.allowedScopes }), + }, + } + : undefined; + + project.payments.push({ + name: options.name, + authorizerType: options.authorizerType, + ...(authorizerConfiguration && { authorizerConfiguration }), + pattern: options.pattern, + connectors: [], + ...(options.description && { description: options.description }), + ...(options.autoPayment !== undefined && { autoPayment: options.autoPayment }), + ...(options.defaultSpendLimit && { defaultSpendLimit: options.defaultSpendLimit }), + ...(options.paymentToolAllowlist?.length && { paymentToolAllowlist: options.paymentToolAllowlist }), + ...(options.networkPreferences?.length && { networkPreferences: options.networkPreferences }), + }); + + await this.writeProjectSpec(project); + + // Wire payment capability into all agents. + // Payments today only ships a runtime shim for Python Strands HTTP agents. + // Skip everything else (TypeScript runtimes, MCP/A2A/AGUI protocols, + // non-Strands Python frameworks) — those would either no-op or have + // their main.py corrupted by the Strands-shaped template. The runtime + // name is collected so the CLI can warn the user that payments must be + // wired manually for those runtimes. + const configRoot = findConfigRoot(); + const skippedRuntimes: string[] = []; + if (configRoot) { + for (const runtime of project.runtimes) { + if (!isPaymentEligibleRuntime(runtime)) { + skippedRuntimes.push(runtime.name); + continue; + } + const wired = this.wirePaymentCapability(configRoot, runtime.codeLocation); + if (!wired) { + skippedRuntimes.push(runtime.name); + } + } + } + + return { success: true, managerName: options.name, skippedRuntimes }; + } catch (err) { + return { success: false, error: toError(err) }; + } + } + + async remove(name: string): Promise { + try { + const project = await this.readProjectSpec(); + project.payments ??= []; + + const index = project.payments.findIndex(p => p.name === name); + if (index === -1) { + return { success: false, error: new Error(`Payment manager "${name}" not found.`) }; + } + + const manager = project.payments[index]!; + + // Collect connector info before removal for cleanup + const connectorInfo = manager.connectors.map(c => ({ + credentialName: c.credentialName, + provider: c.provider, + })); + + // Remove the manager (which removes all its nested connectors) + project.payments.splice(index, 1); + + // Remove associated credentials that are no longer referenced by any connector + for (const { credentialName } of connectorInfo) { + const stillReferenced = project.payments.some(m => m.connectors.some(c => c.credentialName === credentialName)); + if (!stillReferenced) { + const credIndex = project.credentials.findIndex(c => c.name === credentialName); + if (credIndex !== -1) { + project.credentials.splice(credIndex, 1); + } + } + } + + await this.writeProjectSpec(project); + + // Clean up .env.local secrets for removed credentials (provider-specific) + for (const { credentialName, provider } of connectorInfo) { + const stillReferenced = project.payments.some(m => m.connectors.some(c => c.credentialName === credentialName)); + if (!stillReferenced) { + try { + if (provider === 'StripePrivy') { + const envVarNames = computeStripePrivyCredentialEnvVarNames(credentialName); + await removeEnvVars([ + envVarNames.appId, + envVarNames.appSecret, + envVarNames.authorizationPrivateKey, + envVarNames.authorizationId, + ]); + } else { + const envVarNames = computePaymentCredentialEnvVarNames(credentialName); + await removeEnvVars([envVarNames.apiKeyId, envVarNames.apiKeySecret, envVarNames.walletSecret]); + } + } catch { + // Best-effort cleanup + } + } + } + + return { success: true }; + } catch (err) { + return { success: false, error: toError(err) }; + } + } + + async previewRemove(name: string): Promise { + const project = await this.readProjectSpec(); + project.payments ??= []; + + const manager = project.payments.find(p => p.name === name); + if (!manager) { + throw new Error(`Payment manager "${name}" not found.`); + } + + const summary: string[] = [`Removing payment manager: ${name}`]; + if (manager.connectors.length > 0) { + summary.push(`Note: ${manager.connectors.length} connector(s) within this manager will also be removed`); + for (const conn of manager.connectors) { + summary.push(` - Connector: ${conn.name} (credential: ${conn.credentialName})`); + } + } + + const credentialNames = manager.connectors.map(c => c.credentialName); + for (const credName of credentialNames) { + const otherReferences = project.payments.some( + m => m.name !== name && m.connectors.some(c => c.credentialName === credName) + ); + if (!otherReferences) { + summary.push(`Associated credential "${credName}" will also be removed`); + } else { + summary.push(`Credential "${credName}" is shared by other managers and will be kept`); + } + } + + const schemaChanges: SchemaChange[] = []; + const afterSpec: AgentCoreProjectSpec = { + ...project, + payments: project.payments.filter(p => p.name !== name), + }; + schemaChanges.push({ + file: 'agentcore/agentcore.json', + before: project, + after: afterSpec, + }); + + return { summary, directoriesToDelete: [], schemaChanges }; + } + + async getRemovable(): Promise { + try { + const project = await this.readProjectSpec(); + return (project.payments ?? []).map(p => ({ name: p.name })); + } catch { + return []; + } + } + + async getExistingManagers(): Promise { + try { + const project = await this.readProjectSpec(); + return (project.payments ?? []).map(p => p.name); + } catch { + return []; + } + } + + registerCommands(addCmd: Command, removeCmd: Command): void { + addCmd + .command('payment-manager') + .description('Add a payment manager to the project') + .option('--name ', 'Payment manager name [non-interactive]') + .option('--authorizer-type ', 'Authorizer type: AWS_IAM or CUSTOM_JWT (default: AWS_IAM) [non-interactive]') + .option('--discovery-url ', 'OIDC discovery URL (required for CUSTOM_JWT) [non-interactive]') + .option('--allowed-clients ', 'Comma-separated allowed client IDs (for CUSTOM_JWT) [non-interactive]') + .option('--allowed-audience ', 'Comma-separated allowed audiences (for CUSTOM_JWT) [non-interactive]') + .option('--allowed-scopes ', 'Comma-separated allowed scopes (for CUSTOM_JWT) [non-interactive]') + .option('--pattern ', 'Payment pattern: interceptor or tool-based [non-interactive]') + .option('--auto-payment [value]', 'Enable auto payment: true or false (default: true) [non-interactive]') + .option('--default-spend-limit ', 'Default spend limit in USD (default: 10.00) [non-interactive]') + .option('--tool-allowlist ', 'Comma-separated tool names eligible for payment [non-interactive]') + .option( + '--network-preferences ', + 'Comma-separated network identifiers e.g. eip155:84532 [non-interactive]' + ) + .option('--description ', 'Payment manager description [non-interactive]') + .option('--json', 'Output as JSON [non-interactive]') + .action( + async (cliOptions: { + name?: string; + authorizerType?: string; + discoveryUrl?: string; + allowedClients?: string; + allowedAudience?: string; + allowedScopes?: string; + pattern?: string; + autoPayment?: string | boolean; + defaultSpendLimit?: string; + toolAllowlist?: string; + networkPreferences?: string; + description?: string; + json?: boolean; + }) => { + try { + if (!findConfigRoot()) { + console.error('No agentcore project found. Run `agentcore create` first.'); + process.exit(1); + } + + if (cliOptions.name !== undefined || cliOptions.authorizerType || cliOptions.pattern || cliOptions.json) { + if (!cliOptions.name) { + const error = '--name is required'; + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error })); + } else { + console.error(error); + } + process.exit(1); + } + + const nameResult = PaymentManagerNameSchema.safeParse(cliOptions.name); + if (!nameResult.success) { + const error = `Invalid name: ${nameResult.error.issues[0]?.message}`; + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error })); + } else { + console.error(error); + } + process.exit(1); + } + + let authorizerType: PaymentAuthorizerType; + try { + authorizerType = PaymentAuthorizerTypeSchema.parse(cliOptions.authorizerType ?? 'AWS_IAM'); + } catch { + const error = `Invalid authorizer type "${cliOptions.authorizerType}". Valid: AWS_IAM, CUSTOM_JWT`; + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error })); + } else { + console.error(error); + } + process.exit(1); + } + + let pattern: PaymentPattern; + try { + pattern = PaymentPatternSchema.parse(cliOptions.pattern ?? 'interceptor'); + } catch { + const error = `Invalid pattern "${cliOptions.pattern}". Valid: interceptor, tool-based`; + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error })); + } else { + console.error(error); + } + process.exit(1); + } + + if (cliOptions.defaultSpendLimit !== undefined) { + const num = Number(cliOptions.defaultSpendLimit); + if (Number.isNaN(num) || num < 0) { + const error = 'Invalid --default-spend-limit: must be a valid non-negative number (e.g., "10.00")'; + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error })); + } else { + console.error(error); + } + process.exit(1); + } + } + + const parseList = (val?: string): string[] | undefined => + val + ? val + .split(',') + .map(s => s.trim()) + .filter(Boolean) + : undefined; + + const result = await this.add({ + name: cliOptions.name, + authorizerType, + discoveryUrl: cliOptions.discoveryUrl, + allowedClients: parseList(cliOptions.allowedClients), + allowedAudience: parseList(cliOptions.allowedAudience), + allowedScopes: parseList(cliOptions.allowedScopes), + pattern, + autoPayment: + cliOptions.autoPayment !== undefined + ? (() => { + const val = String(cliOptions.autoPayment).toLowerCase(); + if (['true', 'false', 'yes', 'no', '1', '0', 'on', 'off'].includes(val)) { + return !['false', 'no', '0', 'off'].includes(val); + } + throw new Error(`Invalid --auto-payment value "${cliOptions.autoPayment}". Use true or false.`); + })() + : undefined, + defaultSpendLimit: cliOptions.defaultSpendLimit, + paymentToolAllowlist: parseList(cliOptions.toolAllowlist), + networkPreferences: parseList(cliOptions.networkPreferences), + description: cliOptions.description, + }); + + if (cliOptions.json) { + console.log(JSON.stringify(serializeResult(result))); + } else if (result.success) { + console.log(`Added payment manager '${result.managerName}'`); + if (result.skippedRuntimes && result.skippedRuntimes.length > 0) { + console.warn( + `\nWarning: payment capability auto-wiring skipped for non-Strands runtime(s): ${result.skippedRuntimes.join(', ')}.` + ); + console.warn( + `Payments are only auto-wired into Strands agents today. You will need to wire payment plugins manually for these runtimes.` + ); + } else { + console.log(`\nPayment capability code has been added to your agent(s).`); + } + console.log( + `Add a payment connector with \`agentcore add payment-connector --manager ${result.managerName}\`` + ); + } else { + console.error(result.error.message); + } + process.exit(result.success ? 0 : 1); + } else { + requireTTY(); + const [{ render }, { default: React }, { AddFlow }] = await Promise.all([ + import('ink'), + import('react'), + import('../tui/screens/add/AddFlow'), + ]); + const { clear, unmount } = render( + React.createElement(AddFlow, { + isInteractive: false, + onExit: () => { + clear(); + unmount(); + process.exit(0); + }, + }) + ); + } + } catch (error) { + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error: getErrorMessage(error) })); + } else { + console.error(`Error: ${getErrorMessage(error)}`); + } + process.exit(1); + } + } + ); + + removeCmd + .command('payment-manager') + .description('Remove a payment manager from the project') + .option('--name ', 'Name of resource to remove [non-interactive]') + .option('-y, --yes', 'Skip confirmation prompt [non-interactive]') + .option('--json', 'Output as JSON [non-interactive]') + .action(async (cliOptions: { name?: string; yes?: boolean; json?: boolean }) => { + try { + if (!findConfigRoot()) { + console.error('No agentcore project found. Run `agentcore create` first.'); + process.exit(1); + } + + if (cliOptions.name || cliOptions.yes || cliOptions.json) { + if (!cliOptions.name) { + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error: '--name is required' })); + } else { + console.error('--name is required'); + } + process.exit(1); + } + + const result = await this.remove(cliOptions.name); + if (cliOptions.json) { + console.log( + JSON.stringify({ + success: result.success, + resourceType: this.kind, + resourceName: cliOptions.name, + message: result.success ? `Removed payment manager '${cliOptions.name}'` : undefined, + note: result.success ? SOURCE_CODE_NOTE : undefined, + error: !result.success ? result.error.message : undefined, + }) + ); + } else if (result.success) { + console.log(`Removed payment manager '${cliOptions.name}'`); + } else { + console.error(result.error.message); + } + process.exit(result.success ? 0 : 1); + } else { + requireTTY(); + const [{ render }, { default: React }, { RemoveFlow }] = await Promise.all([ + import('ink'), + import('react'), + import('../tui/screens/remove'), + ]); + const { clear, unmount } = render( + React.createElement(RemoveFlow, { + isInteractive: false, + force: cliOptions.yes, + initialResourceType: this.kind, + initialResourceName: cliOptions.name, + onExit: () => { + clear(); + unmount(); + process.exit(0); + }, + }) + ); + } + } catch (error) { + if (cliOptions.json) { + console.log(JSON.stringify({ success: false, error: getErrorMessage(error) })); + } else { + console.error(`Error: ${getErrorMessage(error)}`); + } + process.exit(1); + } + }); + } + + addScreen(): AddScreenComponent { + return null; + } + + /** + * Wire payment capability template into an agent's code directory. + * Copies payments.py and patches main.py to add the import line. + * + * Note: The per-invocation plugin setup (extracting user_id, instrument_id, + * session_id from payload and creating the plugin inside the entrypoint) is + * handled by the Handlebars template for new agents. For existing agents, + * the user must manually update their entrypoint to use the factory pattern. + */ + private wirePaymentCapability(configRoot: string, codeLocation: string): boolean { + const projectRoot = dirname(configRoot); + const agentDir = resolve(projectRoot, codeLocation); + const capDir = join(agentDir, 'capabilities', 'payments'); + + const mainPath = join(agentDir, 'main.py'); + if (!existsSync(mainPath)) return false; + + const main = readFileSync(mainPath, 'utf-8'); + + // Only Strands templates have a payments capability shim today. The + // shim's plugin pattern (Agent(plugins=[...])) is Strands-specific and + // would not work for LangChain/LangGraph, GoogleADK, OpenAIAgents, etc. + // Detect by import signature — the unrendered Handlebars template still + // contains "from strands import" so this works pre- and post-render. + const isStrandsAgent = /^from strands(\.|\s)/m.test(main) || main.includes('from strands import'); + if (!isStrandsAgent) { + return false; + } + + const templateDir = getTemplatePath('python', 'http', 'strands', 'capabilities', 'payments'); + if (!existsSync(templateDir)) return false; + + // Drop the capability files into the agent. Idempotent: skipped if + // payments.py already exists (e.g. user is re-adding after remove). + if (!existsSync(join(capDir, 'payments.py'))) { + mkdirSync(capDir, { recursive: true }); + copyFileSync(join(templateDir, 'payments.py'), join(capDir, 'payments.py')); + } + const initPath = join(capDir, '__init__.py'); + if (!existsSync(initPath)) writeFileSync(initPath, ''); + const parentInit = join(agentDir, 'capabilities', '__init__.py'); + if (!existsSync(parentInit)) writeFileSync(parentInit, ''); + + // Idempotency check: if main.py already imports the plugin factory, the + // file has been patched in a prior add — leave it alone. + if (main.includes('create_payments_plugin')) return true; + + const importLine = 'from capabilities.payments.payments import create_payments_plugin, PAYMENT_SYSTEM_PROMPT'; + + let patched = main; + + // 1. Insert the payment import near the top of the file (after any module + // docstring and `from __future__` imports — Python requires those to + // come first). This avoids the brittle "find the last import" approach, + // which could splice the new import into the middle of a parenthesised + // multi-line import block and produce a SyntaxError. + const insertPos = computeImportInsertionPoint(patched); + patched = patched.slice(0, insertPos) + importLine + '\n' + patched.slice(insertPos); + + // 2. Replace "agent = get_or_create_agent()" with per-invocation agent + plugin creation + // The cached agent pattern can't work with per-invocation plugins because + // plugins are scoped to a request (different user_id/instrument_id/session_id). + // Allow optional trailing comments (e.g. `# type: ignore`). + const agentCallPattern = /^([^\S\n]*)agent = get_or_create_agent\(\)[ \t]*(#[^\n]*)?$/m; + const agentCallMatch = agentCallPattern.exec(patched); + if (agentCallMatch) { + const indent = agentCallMatch[1]; + // Preserve config-bundle wiring: if the file already imports + // ConfigBundleHook, the existing Agent() must have used it; emit + // hooks=[...] alongside the new plugins=[...] so we don't silently + // regress that feature when the user adds payments. + const usesConfigBundle = /\bConfigBundleHook\b/.test(patched); + const replacement = [ + `${indent}# Payment plugin (per-invocation — different user/instrument/session per request)`, + `${indent}user_id = payload.get("user_id") or getattr(context, "user_id", "default-user")`, + `${indent}instrument_id = payload.get("payment_instrument_id")`, + `${indent}session_id = payload.get("payment_session_id")`, + `${indent}payments_plugin = create_payments_plugin(user_id, instrument_id, session_id)`, + `${indent}plugins = [payments_plugin] if payments_plugin else []`, + ``, + `${indent}agent = Agent(`, + `${indent} model=load_model(),`, + `${indent} system_prompt=DEFAULT_SYSTEM_PROMPT + PAYMENT_SYSTEM_PROMPT,`, + `${indent} tools=tools,`, + `${indent} plugins=plugins,`, + ...(usesConfigBundle ? [`${indent} hooks=[ConfigBundleHook()],`] : []), + `${indent})`, + ].join('\n'); + patched = + patched.slice(0, agentCallMatch.index) + + replacement + + patched.slice(agentCallMatch.index + agentCallMatch[0].length); + + // Remove the now-dead cached agent singleton (replaced by per-invocation Agent above). + // Allow typed annotation form (`_agent: Agent | None = None`) and one-or-more blank + // lines before `def get_or_create_agent():` (PEP-8 permits two blank lines). + const singletonPattern = + /^_agent(?:\s*:[^=\n]+)?\s*=\s*None\n\n+def get_or_create_agent\(\):[\s\S]+?return _agent\n/m; + const before = patched; + patched = patched.replace(singletonPattern, ''); + if (patched === before) { + // Call site replaced but singleton not — abort with a clean error + // rather than ship a corrupted main.py with a dead `_agent = None` + // and an orphaned `get_or_create_agent` definition. + throw new Error( + `Could not safely auto-wire payments into ${mainPath}: the agent= call was replaced ` + + `but the cached \`_agent\` / \`get_or_create_agent\` definition has an unrecognised shape. ` + + `Edit main.py manually — see docs/payments.md for the expected pattern.` + ); + } + } else { + const byoAgentPattern = /^(\s*)(agent = Agent\()/m; + const byoMatch = byoAgentPattern.exec(patched); + if (byoMatch) { + const indent = byoMatch[1]; + const pluginSetup = [ + `${indent}# Payment plugin (per-invocation — different user/instrument/session per request)`, + `${indent}user_id = payload.get("user_id") or getattr(context, "user_id", "default-user")`, + `${indent}instrument_id = payload.get("payment_instrument_id")`, + `${indent}session_id = payload.get("payment_session_id")`, + `${indent}payments_plugin = create_payments_plugin(user_id, instrument_id, session_id)`, + `${indent}plugins = [payments_plugin] if payments_plugin else []`, + ``, + `${indent}# TODO: Add plugins=plugins to your Agent() constructor below`, + `${indent}${byoMatch[2]}`, + ].join('\n'); + patched = patched.slice(0, byoMatch.index) + pluginSetup + patched.slice(byoMatch.index + byoMatch[0].length); + } + } + + writeFileSync(mainPath, patched); + return true; + } +} diff --git a/src/cli/primitives/__tests__/GatewayPrimitive.test.ts b/src/cli/primitives/__tests__/GatewayPrimitive.test.ts index df77151e7..863bc780c 100644 --- a/src/cli/primitives/__tests__/GatewayPrimitive.test.ts +++ b/src/cli/primitives/__tests__/GatewayPrimitive.test.ts @@ -18,6 +18,7 @@ const defaultProject: AgentCoreProjectSpec = { httpGateways: [], harnesses: [], datasets: [], + payments: [], }; const { mockConfigExists, mockReadProjectSpec, mockWriteProjectSpec } = vi.hoisted(() => ({ diff --git a/src/cli/primitives/__tests__/PaymentConnectorPrimitive.test.ts b/src/cli/primitives/__tests__/PaymentConnectorPrimitive.test.ts new file mode 100644 index 000000000..507872b85 --- /dev/null +++ b/src/cli/primitives/__tests__/PaymentConnectorPrimitive.test.ts @@ -0,0 +1,480 @@ +import type { AgentCoreProjectSpec } from '../../../schema'; +import { PaymentConnectorPrimitive } from '../PaymentConnectorPrimitive'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// ── Hoisted mocks ──────────────────────────────────────────────────────────── + +const { mockSetEnvVar, mockRemoveEnvVars, mockReadProjectSpec, mockWriteProjectSpec } = vi.hoisted(() => ({ + mockSetEnvVar: vi.fn().mockResolvedValue(undefined), + mockRemoveEnvVars: vi.fn().mockResolvedValue(undefined), + mockReadProjectSpec: vi.fn(), + mockWriteProjectSpec: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../../../lib', () => { + const MockConfigIO = vi.fn(function (this: Record) { + this.readProjectSpec = mockReadProjectSpec; + this.writeProjectSpec = mockWriteProjectSpec; + }); + return { + ConfigIO: MockConfigIO, + findConfigRoot: vi.fn().mockReturnValue('/fake/root'), + setEnvVar: mockSetEnvVar, + removeEnvVars: mockRemoveEnvVars, + toError: (err: unknown) => (err instanceof Error ? err : new Error(String(err))), + serializeResult: (r: unknown) => r, + ResourceNotFoundError: class extends Error { + constructor(m: string) { + super(m); + this.name = 'ResourceNotFoundError'; + } + }, + }; +}); + +// ── Fixtures ───────────────────────────────────────────────────────────────── + +function makeProject(overrides: Partial = {}): AgentCoreProjectSpec { + return { + name: 'test-project', + version: 1, + managedBy: 'CDK' as const, + runtimes: [], + memories: [], + credentials: [], + evaluators: [], + onlineEvalConfigs: [], + agentCoreGateways: [], + policyEngines: [], + configBundles: [], + abTests: [], + httpGateways: [], + harnesses: [], + payments: [], + ...overrides, + }; +} + +function makeManager( + name: string, + connectors: { name: string; provider: 'CoinbaseCDP' | 'StripePrivy'; credentialName: string }[] = [] +) { + return { + name, + authorizerType: 'AWS_IAM' as const, + pattern: 'interceptor' as const, + connectors, + }; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('PaymentConnectorPrimitive', () => { + let primitive: PaymentConnectorPrimitive; + + beforeEach(() => { + vi.clearAllMocks(); + primitive = new PaymentConnectorPrimitive(); + }); + + // ── add() ────────────────────────────────────────────────────────────────── + + describe('add()', () => { + describe('CoinbaseCDP happy path', () => { + it('returns success with correct names', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject({ payments: [makeManager('mgr1')] })); + + const result = await primitive.add({ + manager: 'mgr1', + name: 'conn1', + provider: 'CoinbaseCDP', + apiKeyId: 'key-id', + apiKeySecret: 'key-secret', + walletSecret: 'wallet-secret', + }); + + expect(result.success).toBe(true); + if (!result.success) throw new Error('expected success'); + expect(result.connectorName).toBe('conn1'); + expect(result.managerName).toBe('mgr1'); + expect(result.credentialName).toBe('mgr1-conn1-cdp'); + }); + + it('writes all 3 CoinbaseCDP env vars', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject({ payments: [makeManager('mgr1')] })); + + await primitive.add({ + manager: 'mgr1', + name: 'conn1', + provider: 'CoinbaseCDP', + apiKeyId: 'key-id', + apiKeySecret: 'key-secret', + walletSecret: 'wallet-secret', + }); + + expect(mockSetEnvVar).toHaveBeenCalledTimes(3); + expect(mockSetEnvVar).toHaveBeenCalledWith('AGENTCORE_CREDENTIAL_MGR1_CONN1_CDP_API_KEY_ID', 'key-id'); + expect(mockSetEnvVar).toHaveBeenCalledWith('AGENTCORE_CREDENTIAL_MGR1_CONN1_CDP_API_KEY_SECRET', 'key-secret'); + expect(mockSetEnvVar).toHaveBeenCalledWith( + 'AGENTCORE_CREDENTIAL_MGR1_CONN1_CDP_WALLET_SECRET', + 'wallet-secret' + ); + }); + + it('writes env vars BEFORE writeProjectSpec', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject({ payments: [makeManager('mgr1')] })); + + const callOrder: string[] = []; + mockSetEnvVar.mockImplementation(() => { + callOrder.push('setEnvVar'); + return Promise.resolve(); + }); + mockWriteProjectSpec.mockImplementation(() => { + callOrder.push('writeProjectSpec'); + return Promise.resolve(); + }); + + await primitive.add({ + manager: 'mgr1', + name: 'conn1', + provider: 'CoinbaseCDP', + apiKeyId: 'key-id', + apiKeySecret: 'key-secret', + walletSecret: 'wallet-secret', + }); + + const firstWrite = callOrder.indexOf('writeProjectSpec'); + const lastEnvVar = callOrder.lastIndexOf('setEnvVar'); + expect(lastEnvVar).toBeLessThan(firstWrite); + }); + + it('writes connector into manager and credential into spec', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject({ payments: [makeManager('mgr1')] })); + + await primitive.add({ + manager: 'mgr1', + name: 'conn1', + provider: 'CoinbaseCDP', + apiKeyId: 'key-id', + apiKeySecret: 'key-secret', + walletSecret: 'wallet-secret', + }); + + expect(mockWriteProjectSpec).toHaveBeenCalledTimes(1); + const writtenSpec = mockWriteProjectSpec.mock.calls[0]![0] as AgentCoreProjectSpec; + const manager = writtenSpec.payments!.find(m => m.name === 'mgr1'); + expect(manager?.connectors).toHaveLength(1); + expect(manager?.connectors[0]!.name).toBe('conn1'); + expect(manager?.connectors[0]!.provider).toBe('CoinbaseCDP'); + expect(manager?.connectors[0]!.credentialName).toBe('mgr1-conn1-cdp'); + expect(writtenSpec.credentials).toHaveLength(1); + expect(writtenSpec.credentials[0]!.name).toBe('mgr1-conn1-cdp'); + }); + }); + + describe('StripePrivy happy path', () => { + it('writes 4 env vars for StripePrivy', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject({ payments: [makeManager('mgr1')] })); + + const result = await primitive.add({ + manager: 'mgr1', + name: 'sp-conn', + provider: 'StripePrivy', + appId: 'app-123', + appSecret: 'app-secret-456', + authorizationPrivateKey: 'priv-key-789', + authorizationId: 'auth-id-abc', + }); + + expect(result.success).toBe(true); + expect(mockSetEnvVar).toHaveBeenCalledTimes(4); + expect(mockSetEnvVar).toHaveBeenCalledWith('AGENTCORE_CREDENTIAL_MGR1_SP_CONN_STRIPE_PRIVY_APP_ID', 'app-123'); + expect(mockSetEnvVar).toHaveBeenCalledWith( + 'AGENTCORE_CREDENTIAL_MGR1_SP_CONN_STRIPE_PRIVY_APP_SECRET', + 'app-secret-456' + ); + expect(mockSetEnvVar).toHaveBeenCalledWith( + 'AGENTCORE_CREDENTIAL_MGR1_SP_CONN_STRIPE_PRIVY_AUTHORIZATION_PRIVATE_KEY', + 'priv-key-789' + ); + expect(mockSetEnvVar).toHaveBeenCalledWith( + 'AGENTCORE_CREDENTIAL_MGR1_SP_CONN_STRIPE_PRIVY_AUTHORIZATION_ID', + 'auth-id-abc' + ); + }); + + it('uses "stripe-privy" suffix for credentialName', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject({ payments: [makeManager('mgr1')] })); + + const result = await primitive.add({ + manager: 'mgr1', + name: 'sp-conn', + provider: 'StripePrivy', + appId: 'app-123', + appSecret: 'app-secret-456', + authorizationPrivateKey: 'priv-key-789', + authorizationId: 'auth-id-abc', + }); + + if (!result.success) throw new Error('expected success'); + expect(result.credentialName).toBe('mgr1-sp-conn-stripe-privy'); + }); + }); + + describe('error cases', () => { + it('returns error when manager does not exist', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject({ payments: [] })); + + const result = await primitive.add({ + manager: 'non-existent', + name: 'conn1', + provider: 'CoinbaseCDP', + apiKeyId: 'k', + apiKeySecret: 's', + walletSecret: 'w', + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain('"non-existent"'); + expect(result.error.message).toContain('not found'); + } + expect(mockSetEnvVar).not.toHaveBeenCalled(); + expect(mockWriteProjectSpec).not.toHaveBeenCalled(); + }); + + it('returns error for duplicate connector name within same manager', async () => { + mockReadProjectSpec.mockResolvedValue( + makeProject({ + payments: [ + makeManager('mgr1', [{ name: 'conn1', provider: 'CoinbaseCDP', credentialName: 'mgr1-conn1-cdp' }]), + ], + }) + ); + + const result = await primitive.add({ + manager: 'mgr1', + name: 'conn1', + provider: 'CoinbaseCDP', + apiKeyId: 'k', + apiKeySecret: 's', + walletSecret: 'w', + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain('"conn1"'); + expect(result.error.message).toContain('already exists'); + expect(result.error.message).toContain('"mgr1"'); + } + expect(mockSetEnvVar).not.toHaveBeenCalled(); + expect(mockWriteProjectSpec).not.toHaveBeenCalled(); + }); + }); + }); + + // ── remove() ────────────────────────────────────────────────────────────── + + describe('remove()', () => { + it('auto-resolves manager when connector exists in exactly one manager', async () => { + mockReadProjectSpec.mockResolvedValue( + makeProject({ + payments: [ + makeManager('mgr1', [{ name: 'conn1', provider: 'CoinbaseCDP', credentialName: 'mgr1-conn1-cdp' }]), + ], + credentials: [ + { authorizerType: 'PaymentCredentialProvider', name: 'mgr1-conn1-cdp', provider: 'CoinbaseCDP' }, + ], + }) + ); + + const result = await primitive.remove('conn1'); + + expect(result.success).toBe(true); + expect(mockWriteProjectSpec).toHaveBeenCalledTimes(1); + const written = mockWriteProjectSpec.mock.calls[0]![0] as AgentCoreProjectSpec; + expect(written.payments![0]!.connectors).toHaveLength(0); + }); + + it('returns error when connector exists in multiple managers (ambiguous)', async () => { + mockReadProjectSpec.mockResolvedValue( + makeProject({ + payments: [ + makeManager('mgr1', [{ name: 'conn1', provider: 'CoinbaseCDP', credentialName: 'mgr1-conn1-cdp' }]), + makeManager('mgr2', [{ name: 'conn1', provider: 'CoinbaseCDP', credentialName: 'mgr2-conn1-cdp' }]), + ], + credentials: [ + { authorizerType: 'PaymentCredentialProvider', name: 'mgr1-conn1-cdp', provider: 'CoinbaseCDP' }, + { authorizerType: 'PaymentCredentialProvider', name: 'mgr2-conn1-cdp', provider: 'CoinbaseCDP' }, + ], + }) + ); + + const result = await primitive.remove('conn1'); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain('exists in multiple managers'); + expect(result.error.message).toContain('mgr1'); + expect(result.error.message).toContain('mgr2'); + expect(result.error.message).toContain('--manager'); + } + expect(mockWriteProjectSpec).not.toHaveBeenCalled(); + }); + + it('removes orphaned credential from spec and cleans up env vars', async () => { + mockReadProjectSpec.mockResolvedValue( + makeProject({ + payments: [ + makeManager('mgr1', [{ name: 'conn1', provider: 'CoinbaseCDP', credentialName: 'mgr1-conn1-cdp' }]), + ], + credentials: [ + { authorizerType: 'PaymentCredentialProvider', name: 'mgr1-conn1-cdp', provider: 'CoinbaseCDP' }, + ], + }) + ); + + const result = await primitive.remove('conn1'); + + expect(result.success).toBe(true); + const written = mockWriteProjectSpec.mock.calls[0]![0] as AgentCoreProjectSpec; + // Credential removed from spec + expect(written.credentials).toHaveLength(0); + // Env vars cleaned up + expect(mockRemoveEnvVars).toHaveBeenCalledTimes(1); + expect(mockRemoveEnvVars).toHaveBeenCalledWith( + expect.arrayContaining([ + 'AGENTCORE_CREDENTIAL_MGR1_CONN1_CDP_API_KEY_ID', + 'AGENTCORE_CREDENTIAL_MGR1_CONN1_CDP_API_KEY_SECRET', + 'AGENTCORE_CREDENTIAL_MGR1_CONN1_CDP_WALLET_SECRET', + ]) + ); + }); + + it('keeps shared credential in spec when still referenced by another connector', async () => { + // Both connectors in different managers share the same credentialName + const sharedCred = 'shared-cred'; + mockReadProjectSpec.mockResolvedValue( + makeProject({ + payments: [ + makeManager('mgr1', [{ name: 'conn1', provider: 'CoinbaseCDP', credentialName: sharedCred }]), + makeManager('mgr2', [{ name: 'conn2', provider: 'CoinbaseCDP', credentialName: sharedCred }]), + ], + credentials: [{ authorizerType: 'PaymentCredentialProvider', name: sharedCred, provider: 'CoinbaseCDP' }], + }) + ); + + // Remove conn1 from mgr1 using composite key + const result = await primitive.remove('mgr1/conn1'); + + expect(result.success).toBe(true); + const written = mockWriteProjectSpec.mock.calls[0]![0] as AgentCoreProjectSpec; + // Credential kept because mgr2/conn2 still references it + expect(written.credentials).toHaveLength(1); + expect(written.credentials[0]!.name).toBe(sharedCred); + // No env var cleanup + expect(mockRemoveEnvVars).not.toHaveBeenCalled(); + }); + }); + + // ── previewRemove() ──────────────────────────────────────────────────────── + + describe('previewRemove()', () => { + it('correctly excludes the target connector when computing stillReferenced', async () => { + // Only one connector references this credential — previewRemove should + // report it as orphaned (not shared), even though the connector is still + // in the spec during the preview pass. + mockReadProjectSpec.mockResolvedValue( + makeProject({ + payments: [ + makeManager('mgr1', [{ name: 'conn1', provider: 'CoinbaseCDP', credentialName: 'mgr1-conn1-cdp' }]), + ], + credentials: [ + { authorizerType: 'PaymentCredentialProvider', name: 'mgr1-conn1-cdp', provider: 'CoinbaseCDP' }, + ], + }) + ); + + const preview = await primitive.previewRemove('mgr1/conn1'); + + const credRemovalMsg = preview.summary.find(s => s.includes('will also be removed')); + expect(credRemovalMsg).toBeDefined(); + expect(credRemovalMsg).toContain('mgr1-conn1-cdp'); + }); + + it('reports credential as shared when another connector still references it', async () => { + const sharedCred = 'shared-cred'; + mockReadProjectSpec.mockResolvedValue( + makeProject({ + payments: [ + makeManager('mgr1', [{ name: 'conn1', provider: 'CoinbaseCDP', credentialName: sharedCred }]), + makeManager('mgr2', [{ name: 'conn2', provider: 'CoinbaseCDP', credentialName: sharedCred }]), + ], + credentials: [{ authorizerType: 'PaymentCredentialProvider', name: sharedCred, provider: 'CoinbaseCDP' }], + }) + ); + + const preview = await primitive.previewRemove('mgr1/conn1'); + + const sharedMsg = preview.summary.find(s => s.includes('shared') && s.includes('kept')); + expect(sharedMsg).toBeDefined(); + }); + + it('includes the target connector in the summary', async () => { + mockReadProjectSpec.mockResolvedValue( + makeProject({ + payments: [ + makeManager('mgr1', [{ name: 'conn1', provider: 'CoinbaseCDP', credentialName: 'mgr1-conn1-cdp' }]), + ], + credentials: [ + { authorizerType: 'PaymentCredentialProvider', name: 'mgr1-conn1-cdp', provider: 'CoinbaseCDP' }, + ], + }) + ); + + const preview = await primitive.previewRemove('conn1'); + + expect(preview.summary[0]).toContain('conn1'); + expect(preview.summary[0]).toContain('mgr1'); + }); + + it('includes a schema change entry for agentcore.json', async () => { + mockReadProjectSpec.mockResolvedValue( + makeProject({ + payments: [ + makeManager('mgr1', [{ name: 'conn1', provider: 'CoinbaseCDP', credentialName: 'mgr1-conn1-cdp' }]), + ], + credentials: [ + { authorizerType: 'PaymentCredentialProvider', name: 'mgr1-conn1-cdp', provider: 'CoinbaseCDP' }, + ], + }) + ); + + const preview = await primitive.previewRemove('conn1'); + + expect(preview.schemaChanges).toHaveLength(1); + expect(preview.schemaChanges[0]!.file).toBe('agentcore/agentcore.json'); + const after = preview.schemaChanges[0]!.after as AgentCoreProjectSpec; + expect(after.payments![0]!.connectors).toHaveLength(0); + }); + + it('throws when connector is not found', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject({ payments: [makeManager('mgr1')] })); + + await expect(primitive.previewRemove('does-not-exist')).rejects.toThrow('not found'); + }); + + it('throws when connector exists in multiple managers without a composite key', async () => { + mockReadProjectSpec.mockResolvedValue( + makeProject({ + payments: [ + makeManager('mgr1', [{ name: 'conn1', provider: 'CoinbaseCDP', credentialName: 'mgr1-conn1-cdp' }]), + makeManager('mgr2', [{ name: 'conn1', provider: 'CoinbaseCDP', credentialName: 'mgr2-conn1-cdp' }]), + ], + }) + ); + + await expect(primitive.previewRemove('conn1')).rejects.toThrow('exists in multiple managers'); + }); + }); +}); diff --git a/src/cli/primitives/__tests__/PaymentManagerPrimitive.test.ts b/src/cli/primitives/__tests__/PaymentManagerPrimitive.test.ts new file mode 100644 index 000000000..0911d8ac1 --- /dev/null +++ b/src/cli/primitives/__tests__/PaymentManagerPrimitive.test.ts @@ -0,0 +1,392 @@ +import type { AgentCoreProjectSpec } from '../../../schema'; +import { PaymentManagerPrimitive } from '../PaymentManagerPrimitive'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const mockReadProjectSpec = vi.fn(); +const mockWriteProjectSpec = vi.fn(); + +vi.mock('../../../lib', () => ({ + ConfigIO: class { + readProjectSpec = mockReadProjectSpec; + writeProjectSpec = mockWriteProjectSpec; + }, + findConfigRoot: vi.fn().mockReturnValue(null), + removeEnvVars: vi.fn().mockResolvedValue(undefined), + toError: (err: unknown) => (err instanceof Error ? err : new Error(String(err))), + serializeResult: (r: unknown) => r, +})); + +vi.mock('../templates/templateRoot', () => ({ + getTemplatePath: vi.fn().mockReturnValue('/nonexistent/template/path'), +})); + +function makeProject(overrides: Partial = {}): AgentCoreProjectSpec { + return { + name: 'TestProject', + version: 1, + managedBy: 'CDK' as const, + runtimes: [], + memories: [], + credentials: [], + evaluators: [], + onlineEvalConfigs: [], + agentCoreGateways: [], + policyEngines: [], + configBundles: [], + abTests: [], + httpGateways: [], + harnesses: [], + payments: [], + ...overrides, + }; +} + +function makePaymentManager( + name: string, + connectors: { name: string; credentialName: string; provider?: 'CoinbaseCDP' | 'StripePrivy' }[] = [] +) { + return { + name, + authorizerType: 'AWS_IAM' as const, + pattern: 'interceptor' as const, + connectors: connectors.map(c => ({ + name: c.name, + credentialName: c.credentialName, + provider: c.provider ?? ('CoinbaseCDP' as const), + })), + }; +} + +function makePaymentCredential(name: string) { + return { + authorizerType: 'PaymentCredentialProvider' as const, + name, + provider: 'CoinbaseCDP' as const, + }; +} + +const primitive = new PaymentManagerPrimitive(); + +describe('PaymentManagerPrimitive', () => { + afterEach(() => vi.clearAllMocks()); + + describe('add()', () => { + it('happy path with AWS_IAM — adds manager to spec and returns success', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject()); + mockWriteProjectSpec.mockResolvedValue(undefined); + + const result = await primitive.add({ + name: 'myManager', + authorizerType: 'AWS_IAM', + pattern: 'interceptor', + }); + + expect(result.success).toBe(true); + expect(result).toHaveProperty('managerName', 'myManager'); + + const written = mockWriteProjectSpec.mock.calls[0]![0] as AgentCoreProjectSpec; + expect(written.payments).toHaveLength(1); + const manager = written.payments![0]!; + expect(manager.name).toBe('myManager'); + expect(manager.authorizerType).toBe('AWS_IAM'); + expect(manager.pattern).toBe('interceptor'); + expect(manager.connectors).toEqual([]); + expect(manager.authorizerConfiguration).toBeUndefined(); + }); + + it('happy path writes optional fields when provided', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject()); + mockWriteProjectSpec.mockResolvedValue(undefined); + + await primitive.add({ + name: 'richManager', + authorizerType: 'AWS_IAM', + pattern: 'tool-based', + description: 'My payment manager', + autoPayment: true, + defaultSpendLimit: '50.00', + paymentToolAllowlist: ['buy_item', 'refund'], + networkPreferences: ['eip155:84532'], + }); + + const written = mockWriteProjectSpec.mock.calls[0]![0] as AgentCoreProjectSpec; + const manager = written.payments![0]!; + expect(manager.description).toBe('My payment manager'); + expect(manager.autoPayment).toBe(true); + expect(manager.defaultSpendLimit).toBe('50.00'); + expect(manager.paymentToolAllowlist).toEqual(['buy_item', 'refund']); + expect(manager.networkPreferences).toEqual(['eip155:84532']); + }); + + it('happy path with CUSTOM_JWT and discovery URL — builds authorizerConfiguration', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject()); + mockWriteProjectSpec.mockResolvedValue(undefined); + + const result = await primitive.add({ + name: 'jwtManager', + authorizerType: 'CUSTOM_JWT', + discoveryUrl: 'https://example.com/.well-known/openid-configuration', + allowedClients: ['client1', 'client2'], + allowedAudience: ['aud1'], + allowedScopes: ['scope1'], + pattern: 'interceptor', + }); + + expect(result.success).toBe(true); + + const written = mockWriteProjectSpec.mock.calls[0]![0] as AgentCoreProjectSpec; + const manager = written.payments![0]!; + expect(manager.authorizerType).toBe('CUSTOM_JWT'); + expect(manager.authorizerConfiguration?.customJWTAuthorizer?.discoveryUrl).toBe( + 'https://example.com/.well-known/openid-configuration' + ); + expect(manager.authorizerConfiguration?.customJWTAuthorizer?.allowedClients).toEqual(['client1', 'client2']); + expect(manager.authorizerConfiguration?.customJWTAuthorizer?.allowedAudience).toEqual(['aud1']); + expect(manager.authorizerConfiguration?.customJWTAuthorizer?.allowedScopes).toEqual(['scope1']); + }); + + it('duplicate name — returns error without writing', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject({ payments: [makePaymentManager('existingManager')] })); + + const result = await primitive.add({ + name: 'existingManager', + authorizerType: 'AWS_IAM', + pattern: 'interceptor', + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain('existingManager'); + expect(result.error.message).toContain('already exists'); + } + expect(mockWriteProjectSpec).not.toHaveBeenCalled(); + }); + + it('CUSTOM_JWT without discovery URL — returns error without writing', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject()); + + const result = await primitive.add({ + name: 'jwtManager', + authorizerType: 'CUSTOM_JWT', + pattern: 'interceptor', + // no discoveryUrl + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain('--discovery-url'); + expect(result.error.message).toContain('CUSTOM_JWT'); + } + expect(mockWriteProjectSpec).not.toHaveBeenCalled(); + }); + + it('readProjectSpec failure — returns error', async () => { + mockReadProjectSpec.mockRejectedValue(new Error('disk read failure')); + + const result = await primitive.add({ + name: 'anyManager', + authorizerType: 'AWS_IAM', + pattern: 'interceptor', + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toBe('disk read failure'); + } + }); + }); + + describe('remove()', () => { + it('cascading delete — removes manager and its connectors from spec', async () => { + const project = makeProject({ + payments: [ + makePaymentManager('managerA', [{ name: 'connA', credentialName: 'cred1' }]), + makePaymentManager('managerB'), + ], + credentials: [makePaymentCredential('cred1')], + }); + mockReadProjectSpec.mockResolvedValue(project); + mockWriteProjectSpec.mockResolvedValue(undefined); + + const result = await primitive.remove('managerA'); + + expect(result.success).toBe(true); + + const written = mockWriteProjectSpec.mock.calls[0]![0] as AgentCoreProjectSpec; + expect(written.payments).toHaveLength(1); + expect(written.payments![0]!.name).toBe('managerB'); + // credential no longer referenced — should be removed + expect(written.credentials).toHaveLength(0); + }); + + it('cascading delete — removes multiple connectors and their credentials', async () => { + const project = makeProject({ + payments: [ + makePaymentManager('bigManager', [ + { name: 'connA', credentialName: 'credA' }, + { name: 'connB', credentialName: 'credB' }, + ]), + ], + credentials: [makePaymentCredential('credA'), makePaymentCredential('credB')], + }); + mockReadProjectSpec.mockResolvedValue(project); + mockWriteProjectSpec.mockResolvedValue(undefined); + + const result = await primitive.remove('bigManager'); + + expect(result.success).toBe(true); + const written = mockWriteProjectSpec.mock.calls[0]![0] as AgentCoreProjectSpec; + expect(written.payments).toHaveLength(0); + expect(written.credentials).toHaveLength(0); + }); + + it('non-existent name — returns error without writing', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject()); + + const result = await primitive.remove('doesNotExist'); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain('doesNotExist'); + expect(result.error.message).toContain('not found'); + } + expect(mockWriteProjectSpec).not.toHaveBeenCalled(); + }); + + it('credential shared across managers — credential kept after removing one manager', async () => { + const project = makeProject({ + payments: [ + makePaymentManager('managerA', [{ name: 'connA', credentialName: 'sharedCred' }]), + makePaymentManager('managerB', [{ name: 'connB', credentialName: 'sharedCred' }]), + ], + credentials: [makePaymentCredential('sharedCred')], + }); + mockReadProjectSpec.mockResolvedValue(project); + mockWriteProjectSpec.mockResolvedValue(undefined); + + const result = await primitive.remove('managerA'); + + expect(result.success).toBe(true); + const written = mockWriteProjectSpec.mock.calls[0]![0] as AgentCoreProjectSpec; + expect(written.payments).toHaveLength(1); + expect(written.payments![0]!.name).toBe('managerB'); + // sharedCred still referenced by managerB — must be kept + expect(written.credentials).toHaveLength(1); + expect(written.credentials[0]!.name).toBe('sharedCred'); + }); + + it('manager with no connectors — removes cleanly without touching credentials', async () => { + const project = makeProject({ + payments: [makePaymentManager('emptyManager'), makePaymentManager('otherManager')], + credentials: [makePaymentCredential('unrelatedCred')], + }); + mockReadProjectSpec.mockResolvedValue(project); + mockWriteProjectSpec.mockResolvedValue(undefined); + + const result = await primitive.remove('emptyManager'); + + expect(result.success).toBe(true); + const written = mockWriteProjectSpec.mock.calls[0]![0] as AgentCoreProjectSpec; + expect(written.payments).toHaveLength(1); + expect(written.payments![0]!.name).toBe('otherManager'); + expect(written.credentials).toHaveLength(1); + }); + + it('readProjectSpec failure — returns error', async () => { + mockReadProjectSpec.mockRejectedValue(new Error('io error')); + + const result = await primitive.remove('anyManager'); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toBe('io error'); + } + }); + }); + + describe('getRemovable()', () => { + it('returns manager names from spec', async () => { + mockReadProjectSpec.mockResolvedValue( + makeProject({ + payments: [makePaymentManager('alpha'), makePaymentManager('beta')], + }) + ); + + const result = await primitive.getRemovable(); + + expect(result).toEqual([{ name: 'alpha' }, { name: 'beta' }]); + }); + + it('returns empty array when no managers exist', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject()); + + expect(await primitive.getRemovable()).toEqual([]); + }); + + it('returns empty array on readProjectSpec error', async () => { + mockReadProjectSpec.mockRejectedValue(new Error('fail')); + + expect(await primitive.getRemovable()).toEqual([]); + }); + }); + + describe('getExistingManagers()', () => { + it('returns manager names as strings', async () => { + mockReadProjectSpec.mockResolvedValue( + makeProject({ + payments: [makePaymentManager('m1'), makePaymentManager('m2')], + }) + ); + + const result = await primitive.getExistingManagers(); + + expect(result).toEqual(['m1', 'm2']); + }); + + it('returns empty array on error', async () => { + mockReadProjectSpec.mockRejectedValue(new Error('fail')); + + expect(await primitive.getExistingManagers()).toEqual([]); + }); + }); + + describe('previewRemove()', () => { + it('returns summary and schema changes for a manager with connectors', async () => { + const project = makeProject({ + payments: [makePaymentManager('previewManager', [{ name: 'connA', credentialName: 'credA' }])], + credentials: [makePaymentCredential('credA')], + }); + mockReadProjectSpec.mockResolvedValue(project); + + const preview = await primitive.previewRemove('previewManager'); + + expect(preview.summary[0]).toContain('previewManager'); + expect(preview.summary.some(s => s.includes('connA'))).toBe(true); + expect(preview.schemaChanges).toHaveLength(1); + expect(preview.schemaChanges[0]!.file).toBe('agentcore/agentcore.json'); + const after = preview.schemaChanges[0]!.after as AgentCoreProjectSpec; + expect(after.payments).toHaveLength(0); + }); + + it('notes shared credential is kept in preview', async () => { + const project = makeProject({ + payments: [ + makePaymentManager('mgr1', [{ name: 'connA', credentialName: 'sharedCred' }]), + makePaymentManager('mgr2', [{ name: 'connB', credentialName: 'sharedCred' }]), + ], + credentials: [makePaymentCredential('sharedCred')], + }); + mockReadProjectSpec.mockResolvedValue(project); + + const preview = await primitive.previewRemove('mgr1'); + + expect(preview.summary.some(s => s.includes('kept'))).toBe(true); + }); + + it('throws when manager not found', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject()); + + await expect(primitive.previewRemove('missing')).rejects.toThrow('not found'); + }); + }); +}); diff --git a/src/cli/primitives/__tests__/auth-utils.test.ts b/src/cli/primitives/__tests__/auth-utils.test.ts index b1cab554d..3eac9b61f 100644 --- a/src/cli/primitives/__tests__/auth-utils.test.ts +++ b/src/cli/primitives/__tests__/auth-utils.test.ts @@ -98,6 +98,7 @@ describe('createManagedOAuthCredential', () => { httpGateways: [], harnesses: [], datasets: [], + payments: [], }; const jwtConfig: JwtConfigOptions = { diff --git a/src/cli/primitives/__tests__/credential-utils.test.ts b/src/cli/primitives/__tests__/credential-utils.test.ts new file mode 100644 index 000000000..b04424b9d --- /dev/null +++ b/src/cli/primitives/__tests__/credential-utils.test.ts @@ -0,0 +1,60 @@ +import { + computeDefaultCredentialEnvVarName, + computePaymentCredentialEnvVarNames, + computeStripePrivyCredentialEnvVarNames, +} from '../credential-utils'; +import { describe, expect, it } from 'vitest'; + +describe('computeDefaultCredentialEnvVarName', () => { + it('uppercases the credential name', () => { + expect(computeDefaultCredentialEnvVarName('myCredential')).toBe('AGENTCORE_CREDENTIAL_MYCREDENTIAL'); + }); + + it('converts hyphens to underscores', () => { + expect(computeDefaultCredentialEnvVarName('my-api-key')).toBe('AGENTCORE_CREDENTIAL_MY_API_KEY'); + }); + + it('handles names already containing underscores', () => { + expect(computeDefaultCredentialEnvVarName('my_cred')).toBe('AGENTCORE_CREDENTIAL_MY_CRED'); + }); + + it('handles mixed hyphens and underscores', () => { + expect(computeDefaultCredentialEnvVarName('my-cred_name')).toBe('AGENTCORE_CREDENTIAL_MY_CRED_NAME'); + }); +}); + +describe('computePaymentCredentialEnvVarNames', () => { + it('returns three env var names with correct suffixes', () => { + const result = computePaymentCredentialEnvVarNames('myMgr-conn-cdp'); + expect(result).toEqual({ + apiKeyId: 'AGENTCORE_CREDENTIAL_MYMGR_CONN_CDP_API_KEY_ID', + apiKeySecret: 'AGENTCORE_CREDENTIAL_MYMGR_CONN_CDP_API_KEY_SECRET', + walletSecret: 'AGENTCORE_CREDENTIAL_MYMGR_CONN_CDP_WALLET_SECRET', + }); + }); + + it('converts hyphens to underscores in prefix', () => { + const result = computePaymentCredentialEnvVarNames('a-b-c'); + expect(result.apiKeyId).toBe('AGENTCORE_CREDENTIAL_A_B_C_API_KEY_ID'); + }); +}); + +describe('computeStripePrivyCredentialEnvVarNames', () => { + it('returns four env var names with correct suffixes', () => { + const result = computeStripePrivyCredentialEnvVarNames('mgr-conn-stripe-privy'); + expect(result).toEqual({ + appId: 'AGENTCORE_CREDENTIAL_MGR_CONN_STRIPE_PRIVY_APP_ID', + appSecret: 'AGENTCORE_CREDENTIAL_MGR_CONN_STRIPE_PRIVY_APP_SECRET', + authorizationPrivateKey: 'AGENTCORE_CREDENTIAL_MGR_CONN_STRIPE_PRIVY_AUTHORIZATION_PRIVATE_KEY', + authorizationId: 'AGENTCORE_CREDENTIAL_MGR_CONN_STRIPE_PRIVY_AUTHORIZATION_ID', + }); + }); + + it('handles name with no hyphens', () => { + const result = computeStripePrivyCredentialEnvVarNames('simple'); + expect(result.appId).toBe('AGENTCORE_CREDENTIAL_SIMPLE_APP_ID'); + expect(result.appSecret).toBe('AGENTCORE_CREDENTIAL_SIMPLE_APP_SECRET'); + expect(result.authorizationPrivateKey).toBe('AGENTCORE_CREDENTIAL_SIMPLE_AUTHORIZATION_PRIVATE_KEY'); + expect(result.authorizationId).toBe('AGENTCORE_CREDENTIAL_SIMPLE_AUTHORIZATION_ID'); + }); +}); diff --git a/src/cli/primitives/__tests__/payment-validation.test.ts b/src/cli/primitives/__tests__/payment-validation.test.ts new file mode 100644 index 000000000..df4f56eac --- /dev/null +++ b/src/cli/primitives/__tests__/payment-validation.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest'; + +describe('autoPayment CLI parsing', () => { + function parseAutoPayment(value: string | boolean | undefined): boolean | undefined { + if (value === undefined) return undefined; + return !['false', 'no', '0', 'off'].includes(String(value).toLowerCase()); + } + + describe('falsy string values produce false', () => { + it.each(['false', 'False', 'FALSE', 'no', 'No', 'NO', '0', 'off', 'Off', 'OFF'])( + 'parseAutoPayment("%s") returns false', + val => { + expect(parseAutoPayment(val)).toBe(false); + } + ); + }); + + describe('truthy values produce true', () => { + it.each(['true', 'True', 'TRUE', 'yes', '1', 'on', 'anything'])('parseAutoPayment("%s") returns true', val => { + expect(parseAutoPayment(val)).toBe(true); + }); + }); + + it('boolean true passes through as true', () => { + expect(parseAutoPayment(true)).toBe(true); + }); + + it('boolean false passes through as false', () => { + expect(parseAutoPayment(false)).toBe(false); + }); + + it('undefined returns undefined', () => { + expect(parseAutoPayment(undefined)).toBeUndefined(); + }); +}); + +describe('defaultSpendLimit validation', () => { + function validateSpendLimit(value: string): { valid: boolean } { + const num = Number(value); + if (Number.isNaN(num) || num < 0) return { valid: false }; + return { valid: true }; + } + + it('accepts "0"', () => expect(validateSpendLimit('0')).toEqual({ valid: true })); + it('accepts "10.50"', () => expect(validateSpendLimit('10.50')).toEqual({ valid: true })); + it('accepts large numbers', () => expect(validateSpendLimit('999999.99')).toEqual({ valid: true })); + it('rejects negative values', () => expect(validateSpendLimit('-1')).toEqual({ valid: false })); + it('rejects non-numeric strings', () => expect(validateSpendLimit('abc')).toEqual({ valid: false })); + it('accepts empty string as 0 (Number("") === 0)', () => expect(validateSpendLimit('')).toEqual({ valid: true })); +}); + +describe('base64 key validation', () => { + const BASE64_REGEX = /^[A-Za-z0-9+/]+=*$/; + + function validateBase64Key(key: string): { valid: boolean; error?: string } { + const trimmed = key.trim(); + if (!BASE64_REGEX.test(trimmed)) return { valid: false, error: 'not base64' }; + const decoded = Buffer.from(trimmed, 'base64'); + if (decoded.length < 100 || decoded.length > 200) return { valid: false, error: 'unexpected length' }; + return { valid: true }; + } + + it('rejects non-base64 characters', () => { + expect(validateBase64Key('not-base64!').valid).toBe(false); + }); + + it('rejects too-short decoded key (< 100 bytes)', () => { + expect(validateBase64Key('dGVzdA==').valid).toBe(false); + }); + + it('rejects too-long decoded key (> 200 bytes)', () => { + const buf = Buffer.alloc(201, 0x42); + expect(validateBase64Key(buf.toString('base64')).valid).toBe(false); + }); + + it('accepts decoded key of exactly 100 bytes', () => { + const buf = Buffer.alloc(100, 0x41); + expect(validateBase64Key(buf.toString('base64')).valid).toBe(true); + }); + + it('accepts decoded key of exactly 200 bytes', () => { + const buf = Buffer.alloc(200, 0x41); + expect(validateBase64Key(buf.toString('base64')).valid).toBe(true); + }); + + it('accepts a valid ~138 byte key', () => { + const key = + 'RkFLRV9TVFJJUEVfUFJJVllfVEVTVF9LRVlfQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQQ=='; + expect(validateBase64Key(key).valid).toBe(true); + }); +}); + +describe('credential sanitization regex', () => { + const REGEX = + /("apiKeySecret"|"walletSecret"|"apiKeyId"|"appId"|"appSecret"|"authorizationPrivateKey"|"authorizationId")\s*:\s*"[^"]*"/g; + + function sanitize(body: string): string { + return body.replace(REGEX, '$1:"[REDACTED]"').slice(0, 500); + } + + it('redacts all 7 credential field names', () => { + const body = JSON.stringify({ + apiKeyId: 'key-123', + apiKeySecret: 'secret-456', + walletSecret: 'wallet-789', + appId: 'app-abc', + appSecret: 'app-secret-def', + authorizationPrivateKey: 'priv-key-ghi', + authorizationId: 'auth-jkl', + }); + const result = sanitize(body); + expect(result).not.toContain('key-123'); + expect(result).not.toContain('secret-456'); + expect(result).not.toContain('wallet-789'); + expect(result).not.toContain('app-abc'); + expect(result).not.toContain('app-secret-def'); + expect(result).not.toContain('priv-key-ghi'); + expect(result).not.toContain('auth-jkl'); + expect(result).toContain('[REDACTED]'); + }); + + it('preserves non-credential fields', () => { + const body = JSON.stringify({ message: 'Not found', code: 'ResourceNotFoundException', apiKeySecret: 'leaked' }); + const result = sanitize(body); + expect(result).toContain('Not found'); + expect(result).toContain('ResourceNotFoundException'); + expect(result).not.toContain('leaked'); + }); + + it('truncates to 500 characters', () => { + const longBody = '{"apiKeyId":"x"}'.repeat(100); + expect(sanitize(longBody).length).toBeLessThanOrEqual(500); + }); +}); diff --git a/src/cli/primitives/__tests__/wirePaymentCapability.test.ts b/src/cli/primitives/__tests__/wirePaymentCapability.test.ts new file mode 100644 index 000000000..57a88d59d --- /dev/null +++ b/src/cli/primitives/__tests__/wirePaymentCapability.test.ts @@ -0,0 +1,918 @@ +import type { AgentCoreProjectSpec } from '../../../schema'; +import { PaymentManagerPrimitive } from '../PaymentManagerPrimitive'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// --------------------------------------------------------------------------- +// Hoisted mocks — must be defined before any imports are processed +// --------------------------------------------------------------------------- +const { + mockFindConfigRoot, + mockReadProjectSpec, + mockWriteProjectSpec, + mockExistsSync, + mockMkdirSync, + mockCopyFileSync, + mockWriteFileSync, + mockReadFileSync, +} = vi.hoisted(() => ({ + mockFindConfigRoot: vi.fn().mockReturnValue('/project/agentcore'), + mockReadProjectSpec: vi.fn(), + mockWriteProjectSpec: vi.fn().mockResolvedValue(undefined), + mockExistsSync: vi.fn(), + mockMkdirSync: vi.fn(), + mockCopyFileSync: vi.fn(), + mockWriteFileSync: vi.fn(), + mockReadFileSync: vi.fn(), +})); + +vi.mock('../../../lib', () => { + const MockConfigIO = vi.fn(function (this: Record) { + this.configExists = vi.fn().mockReturnValue(true); + this.readProjectSpec = mockReadProjectSpec; + this.writeProjectSpec = mockWriteProjectSpec; + }); + return { + ConfigIO: MockConfigIO, + findConfigRoot: mockFindConfigRoot, + setEnvVar: vi.fn().mockResolvedValue(undefined), + removeEnvVars: vi.fn().mockResolvedValue(undefined), + toError: (err: unknown) => (err instanceof Error ? err : new Error(String(err))), + serializeResult: (r: unknown) => r, + ResourceNotFoundError: class extends Error { + constructor(m: string) { + super(m); + this.name = 'ResourceNotFoundError'; + } + }, + }; +}); + +vi.mock('node:fs', () => ({ + existsSync: mockExistsSync, + mkdirSync: mockMkdirSync, + copyFileSync: mockCopyFileSync, + writeFileSync: mockWriteFileSync, + readFileSync: mockReadFileSync, +})); + +vi.mock('../../templates/templateRoot', () => ({ + getTemplatePath: (...segments: string[]) => `/cli-templates/${segments.join('/')}`, +})); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Minimal valid AgentCoreProjectSpec with one runtime at the given codeLocation. + * Defaults to a Python HTTP runtime so the payment-eligibility gate accepts it. + * Tests that need to exercise the gate's reject path can pass overrides. + */ +function makeProject(codeLocation: string, runtimeOverrides: Record = {}): AgentCoreProjectSpec { + return { + name: 'test-project', + version: 1, + managedBy: 'CDK' as const, + runtimes: [ + { + name: 'my-agent', + build: 'CodeZip' as const, + entrypoint: 'main.py:handler' as any, + codeLocation: codeLocation as any, + ...runtimeOverrides, + }, + ], + memories: [], + credentials: [], + evaluators: [], + onlineEvalConfigs: [], + agentCoreGateways: [], + policyEngines: [], + configBundles: [], + abTests: [], + httpGateways: [], + harnesses: [], + payments: [], + }; +} + +/** Absolute agent directory derived from project root + codeLocation */ +const PROJECT_ROOT = '/project'; +const CODE_LOCATION = 'agents/my-agent'; +const AGENT_DIR = `${PROJECT_ROOT}/${CODE_LOCATION}`; +const CAP_DIR = `${AGENT_DIR}/capabilities/payments`; +const PAYMENTS_PY_DEST = `${CAP_DIR}/payments.py`; +const PAYMENTS_PY_SRC = `/cli-templates/python/http/strands/capabilities/payments/payments.py`; +const TEMPLATE_DIR = `/cli-templates/python/http/strands/capabilities/payments`; +const MAIN_PY = `${AGENT_DIR}/main.py`; +const CAP_INIT = `${CAP_DIR}/__init__.py`; +const PARENT_INIT = `${AGENT_DIR}/capabilities/__init__.py`; + +/** Default add options that skip duplicate/CUSTOM_JWT guards */ +const ADD_OPTIONS = { + name: 'payments-mgr', + authorizerType: 'AWS_IAM' as const, + pattern: 'interceptor' as const, +}; + +/** Call primitive.add() which internally calls wirePaymentCapability() for every runtime */ +async function callAdd(primitive: PaymentManagerPrimitive, project: AgentCoreProjectSpec) { + mockReadProjectSpec.mockResolvedValue(project); + return primitive.add(ADD_OPTIONS); +} + +// --------------------------------------------------------------------------- +// Shared setup +// --------------------------------------------------------------------------- +describe('wirePaymentCapability (via PaymentManagerPrimitive.add)', () => { + let primitive: PaymentManagerPrimitive; + + beforeEach(() => { + vi.clearAllMocks(); + primitive = new PaymentManagerPrimitive(); + + // Default: template directory exists, cap dir does NOT yet exist (so we proceed) + mockExistsSync.mockImplementation((p: string) => { + if (p === TEMPLATE_DIR) return true; + if (p === PAYMENTS_PY_DEST) return false; // not yet copied — trigger wiring + return false; // everything else absent by default + }); + + // readFileSync returns a minimal main.py by default (overridden per test) + mockReadFileSync.mockReturnValue(''); + }); + + // ========================================================================= + // Test 1 – Template agent: get_or_create_agent() pattern + // ========================================================================= + describe('template agent with get_or_create_agent() pattern', () => { + const templateMain = [ + 'import os', + 'from strands import Agent, tool', + '', + '_agent = None', + '', + 'def get_or_create_agent():', + ' global _agent', + ' if _agent is None:', + ' _agent = Agent(', + ' model=load_model(),', + ' system_prompt="You are helpful.",', + ' tools=tools,', + ' )', + ' return _agent', + '', + '@app.entrypoint', + 'async def invoke(payload, context):', + ' agent = get_or_create_agent()', + ' stream = agent.stream_async(payload.get("prompt"))', + ' async for event in stream:', + ' yield event', + ].join('\n'); + + beforeEach(() => { + mockExistsSync.mockImplementation((p: string) => { + if (p === TEMPLATE_DIR) return true; + if (p === PAYMENTS_PY_DEST) return false; + if (p === MAIN_PY) return true; + return false; + }); + mockReadFileSync.mockReturnValue(templateMain); + }); + + it('replaces "agent = get_or_create_agent()" with per-invocation plugin block', async () => { + const result = await callAdd(primitive, makeProject(CODE_LOCATION)); + expect(result.success).toBe(true); + + expect(mockWriteFileSync).toHaveBeenCalledWith(MAIN_PY, expect.any(String)); + const written: string = mockWriteFileSync.mock.calls.find( + (c: unknown[]) => (c[0] as string) === MAIN_PY + )![1] as string; + + // Original call is gone + expect(written).not.toContain('agent = get_or_create_agent()'); + + // Per-invocation plugin block inserted + expect(written).toContain('user_id = payload.get("user_id")'); + expect(written).toContain('instrument_id = payload.get("payment_instrument_id")'); + expect(written).toContain('session_id = payload.get("payment_session_id")'); + expect(written).toContain('payments_plugin = create_payments_plugin(user_id, instrument_id, session_id)'); + expect(written).toContain('plugins = [payments_plugin] if payments_plugin else []'); + + // Replacement spawns a new Agent() constructor + expect(written).toContain('agent = Agent('); + expect(written).toContain('plugins=plugins,'); + + // Import line inserted + expect(written).toContain('from capabilities.payments.payments import create_payments_plugin'); + }); + + it('inserts the import near the top of the file (before any function or class definition)', async () => { + const result = await callAdd(primitive, makeProject(CODE_LOCATION)); + expect(result.success).toBe(true); + + const written: string = mockWriteFileSync.mock.calls.find( + (c: unknown[]) => (c[0] as string) === MAIN_PY + )![1] as string; + + // The payment import is inserted at the top after any docstring / + // `from __future__` block. It must land BEFORE the first function / + // entrypoint definition. (Note the cached `_agent = None` line is + // removed by the singleton-removal pass, so we anchor on @app.entrypoint.) + const pluginImportPos = written.indexOf('from capabilities.payments.payments import create_payments_plugin'); + const entrypointPos = written.indexOf('@app.entrypoint'); + expect(pluginImportPos).toBeGreaterThanOrEqual(0); + expect(entrypointPos).toBeGreaterThan(pluginImportPos); + }); + }); + + // ========================================================================= + // Test 2 – BYO agent: Agent() constructor present but no get_or_create_agent + // ========================================================================= + describe('BYO agent with existing Agent() constructor', () => { + const byoMain = [ + 'import os', + 'from strands import Agent, tool', + '', + '@app.entrypoint', + 'async def invoke(payload, context):', + ' agent = Agent(', + ' model="anthropic.claude-3-5-sonnet-20241022-v2:0",', + ' system_prompt="You are a payment assistant.",', + ' tools=my_tools,', + ' )', + ' stream = agent.stream_async(payload.get("prompt"))', + ' async for event in stream:', + ' yield event', + ].join('\n'); + + beforeEach(() => { + mockExistsSync.mockImplementation((p: string) => { + if (p === TEMPLATE_DIR) return true; + if (p === PAYMENTS_PY_DEST) return false; + if (p === MAIN_PY) return true; + return false; + }); + mockReadFileSync.mockReturnValue(byoMain); + }); + + it('inserts plugin setup block before the existing Agent() constructor', async () => { + const result = await callAdd(primitive, makeProject(CODE_LOCATION)); + expect(result.success).toBe(true); + + const written: string = mockWriteFileSync.mock.calls.find( + (c: unknown[]) => (c[0] as string) === MAIN_PY + )![1] as string; + + // Plugin setup block is present + expect(written).toContain('user_id = payload.get("user_id")'); + expect(written).toContain('payments_plugin = create_payments_plugin(user_id, instrument_id, session_id)'); + expect(written).toContain('plugins = [payments_plugin] if payments_plugin else []'); + + // Plugin setup appears before Agent( + const pluginPos = written.indexOf('payments_plugin = create_payments_plugin'); + const agentPos = written.indexOf('agent = Agent('); + expect(pluginPos).toBeGreaterThanOrEqual(0); + expect(agentPos).toBeGreaterThan(pluginPos); + }); + + it('adds TODO comment to add plugins= to existing Agent() constructor', async () => { + const result = await callAdd(primitive, makeProject(CODE_LOCATION)); + expect(result.success).toBe(true); + + const written: string = mockWriteFileSync.mock.calls.find( + (c: unknown[]) => (c[0] as string) === MAIN_PY + )![1] as string; + + expect(written).toContain('# TODO: Add plugins=plugins to your Agent() constructor below'); + }); + + it('inserts the payment import line', async () => { + const result = await callAdd(primitive, makeProject(CODE_LOCATION)); + expect(result.success).toBe(true); + + const written: string = mockWriteFileSync.mock.calls.find( + (c: unknown[]) => (c[0] as string) === MAIN_PY + )![1] as string; + + expect(written).toContain('from capabilities.payments.payments import create_payments_plugin'); + }); + }); + + // ========================================================================= + // Test 3 – Minimal agent: no known pattern + // ========================================================================= + describe('minimal agent with neither get_or_create_agent nor Agent() pattern', () => { + const minimalMain = ['from strands import Agent', '', 'def h(e, c): pass'].join('\n'); + + beforeEach(() => { + mockExistsSync.mockImplementation((p: string) => { + if (p === TEMPLATE_DIR) return true; + if (p === PAYMENTS_PY_DEST) return false; + if (p === MAIN_PY) return true; + return false; + }); + mockReadFileSync.mockReturnValue(minimalMain); + }); + + it('adds the import line at the top when there are no existing imports', async () => { + const result = await callAdd(primitive, makeProject(CODE_LOCATION)); + expect(result.success).toBe(true); + + const written: string = mockWriteFileSync.mock.calls.find( + (c: unknown[]) => (c[0] as string) === MAIN_PY + )![1] as string; + + expect(written).toContain('from capabilities.payments.payments import create_payments_plugin'); + }); + + it('does NOT insert a plugin block when no known agent pattern found', async () => { + const result = await callAdd(primitive, makeProject(CODE_LOCATION)); + expect(result.success).toBe(true); + + const written: string = mockWriteFileSync.mock.calls.find( + (c: unknown[]) => (c[0] as string) === MAIN_PY + )![1] as string; + + // No plugin setup injected + expect(written).not.toContain('payments_plugin = create_payments_plugin'); + expect(written).not.toContain('plugins = [payments_plugin]'); + }); + }); + + // ========================================================================= + // Test 4 – Idempotency: running twice doesn't double-add imports + // ========================================================================= + describe('idempotency', () => { + it('does not re-process main.py when payments.py already exists in cap dir', async () => { + // Simulate already-wired state: payments.py already present + mockExistsSync.mockImplementation((p: string) => { + if (p === TEMPLATE_DIR) return true; + if (p === PAYMENTS_PY_DEST) return true; // already wired + return false; + }); + mockReadFileSync.mockReturnValue( + 'from capabilities.payments.payments import create_payments_plugin\ndef h(e,c): pass' + ); + + // First add + const project = makeProject(CODE_LOCATION); + await callAdd(primitive, project); + + // Second add (simulate calling add again with updated project that now has the payment manager) + const _projectWithManager: AgentCoreProjectSpec = { + ...project, + payments: [ + { + name: ADD_OPTIONS.name, + authorizerType: ADD_OPTIONS.authorizerType, + pattern: ADD_OPTIONS.pattern, + connectors: [], + }, + ], + }; + // Reset the mock to allow the second write to the project spec + mockWriteProjectSpec.mockResolvedValue(undefined); + // But now payments already exist, so checkDuplicate will reject it + // Instead test that writeFileSync on main.py is never called when payments.py already exists + vi.clearAllMocks(); + mockWriteProjectSpec.mockResolvedValue(undefined); + mockFindConfigRoot.mockReturnValue('/project/agentcore'); + mockExistsSync.mockImplementation((p: string) => { + if (p === TEMPLATE_DIR) return true; + if (p === PAYMENTS_PY_DEST) return true; // already present + return false; + }); + mockReadProjectSpec.mockResolvedValue({ ...project, payments: [] }); + + await primitive.add(ADD_OPTIONS); + + // wirePaymentCapability exits early (payments.py already exists), so main.py never written + const mainPyWrite = mockWriteFileSync.mock.calls.find((c: unknown[]) => (c[0] as string) === MAIN_PY); + expect(mainPyWrite).toBeUndefined(); + }); + + it('does not double-add import if create_payments_plugin already in main.py', async () => { + const alreadyPatched = [ + 'from strands import Agent', + 'from capabilities.payments.payments import create_payments_plugin', + '', + '@app.entrypoint', + 'async def invoke(payload, context):', + ' payments_plugin = create_payments_plugin("u", None, None)', + ' pass', + ].join('\n'); + + mockExistsSync.mockImplementation((p: string) => { + if (p === TEMPLATE_DIR) return true; + if (p === PAYMENTS_PY_DEST) return false; // cap dir missing — enter wiring + if (p === MAIN_PY) return true; + return false; + }); + mockReadFileSync.mockReturnValue(alreadyPatched); + + await callAdd(primitive, makeProject(CODE_LOCATION)); + + // main.py must NOT be written because create_payments_plugin already present + const mainPyWrite = mockWriteFileSync.mock.calls.find((c: unknown[]) => (c[0] as string) === MAIN_PY); + expect(mainPyWrite).toBeUndefined(); + }); + }); + + // ========================================================================= + // Test 5 – capabilities/payments/ directory created and payments.py copied + // ========================================================================= + describe('capabilities/payments/ directory and payments.py', () => { + beforeEach(() => { + mockExistsSync.mockImplementation((p: string) => { + if (p === TEMPLATE_DIR) return true; + if (p === PAYMENTS_PY_DEST) return false; + if (p === MAIN_PY) return true; + return false; + }); + // Strands main.py — passes the framework gate; pattern doesn't match + // either get_or_create or Agent() so file write is skipped, but cap dir + // setup still runs. + mockReadFileSync.mockReturnValue('from strands import Agent\n\ndef h(e, c): pass\n'); + }); + + it('creates capabilities/payments/ directory with recursive flag', async () => { + await callAdd(primitive, makeProject(CODE_LOCATION)); + + expect(mockMkdirSync).toHaveBeenCalledWith(CAP_DIR, { recursive: true }); + }); + + it('copies payments.py from template to capabilities/payments/', async () => { + await callAdd(primitive, makeProject(CODE_LOCATION)); + + expect(mockCopyFileSync).toHaveBeenCalledWith(PAYMENTS_PY_SRC, PAYMENTS_PY_DEST); + }); + + it('skips wiring entirely when template directory does not exist', async () => { + mockExistsSync.mockImplementation((p: string) => { + if (p === PAYMENTS_PY_DEST) return false; + if (p === TEMPLATE_DIR) return false; // template missing + return false; + }); + + await callAdd(primitive, makeProject(CODE_LOCATION)); + + expect(mockMkdirSync).not.toHaveBeenCalled(); + expect(mockCopyFileSync).not.toHaveBeenCalled(); + }); + }); + + // ========================================================================= + // Test 6 – capabilities/__init__.py created if missing + // ========================================================================= + describe('__init__.py creation', () => { + beforeEach(() => { + mockExistsSync.mockImplementation((p: string) => { + if (p === TEMPLATE_DIR) return true; + if (p === PAYMENTS_PY_DEST) return false; + if (p === MAIN_PY) return true; + return false; // init files absent + }); + // Strands main.py to pass the framework gate + mockReadFileSync.mockReturnValue('from strands import Agent\n\ndef h(e, c): pass\n'); + }); + + it('creates capabilities/payments/__init__.py when absent', async () => { + await callAdd(primitive, makeProject(CODE_LOCATION)); + + expect(mockWriteFileSync).toHaveBeenCalledWith(CAP_INIT, ''); + }); + + it('creates capabilities/__init__.py when absent', async () => { + await callAdd(primitive, makeProject(CODE_LOCATION)); + + expect(mockWriteFileSync).toHaveBeenCalledWith(PARENT_INIT, ''); + }); + + it('does not overwrite capabilities/payments/__init__.py when it already exists', async () => { + mockExistsSync.mockImplementation((p: string) => { + if (p === TEMPLATE_DIR) return true; + if (p === PAYMENTS_PY_DEST) return false; + if (p === MAIN_PY) return true; + if (p === CAP_INIT) return true; // already exists + if (p === PARENT_INIT) return true; // already exists + return false; + }); + mockReadFileSync.mockReturnValue('from strands import Agent\n\ndef h(e, c): pass\n'); + + await callAdd(primitive, makeProject(CODE_LOCATION)); + + const initWrites = mockWriteFileSync.mock.calls.filter( + (c: unknown[]) => (c[0] as string) === CAP_INIT || (c[0] as string) === PARENT_INIT + ); + expect(initWrites).toHaveLength(0); + }); + }); + + // ========================================================================= + // Test 7 – Import line inserted at the correct position + // ========================================================================= + describe('import line position', () => { + it('inserts at the top of the file regardless of existing imports', async () => { + const main = [ + 'import os', + 'import logging', + 'from strands import Agent, tool', + 'from bedrock_agentcore.runtime import BedrockAgentCoreApp', + '', + 'app = BedrockAgentCoreApp()', + '', + '@app.entrypoint', + 'async def invoke(payload, context):', + ' pass', + ].join('\n'); + + mockExistsSync.mockImplementation((p: string) => { + if (p === TEMPLATE_DIR) return true; + if (p === PAYMENTS_PY_DEST) return false; + if (p === MAIN_PY) return true; + return false; + }); + mockReadFileSync.mockReturnValue(main); + + await callAdd(primitive, makeProject(CODE_LOCATION)); + + const written: string = mockWriteFileSync.mock.calls.find( + (c: unknown[]) => (c[0] as string) === MAIN_PY + )![1] as string; + + const pluginImport = 'from capabilities.payments.payments import create_payments_plugin'; + const firstUserImport = 'import os'; + + // Import lands at the very top of the file (no docstring / __future__ + // here), BEFORE any user-level import. This is intentional: trying to + // splice into the middle of a possibly multi-line import block is the + // bug R-13-1 was filed against. + const pluginImportPos = written.indexOf(pluginImport); + const firstUserImportPos = written.indexOf(firstUserImport); + expect(pluginImportPos).toBe(0); + expect(firstUserImportPos).toBeGreaterThan(pluginImportPos); + }); + + it('handles parenthesised multi-line `from x import (...)` blocks without splicing', async () => { + // The pre-fix bug (R-13-1): a multi-line parenthesised import would have + // its first physical line picked up by the regex, then the payment + // import was inserted INSIDE the still-open parentheses, producing a + // SyntaxError. After R-13-1 we never insert mid-import. + const main = [ + 'from strands import (', + ' Agent,', + ' tool,', + ' HookProvider,', + ')', + '', + '@app.entrypoint', + 'async def invoke(payload, context):', + ' pass', + ].join('\n'); + mockExistsSync.mockImplementation((p: string) => { + if (p === TEMPLATE_DIR) return true; + if (p === PAYMENTS_PY_DEST) return false; + if (p === MAIN_PY) return true; + return false; + }); + mockReadFileSync.mockReturnValue(main); + + await callAdd(primitive, makeProject(CODE_LOCATION)); + const written: string = mockWriteFileSync.mock.calls.find( + (c: unknown[]) => (c[0] as string) === MAIN_PY + )![1] as string; + + // The parenthesised block must remain intact; no insertion inside it. + expect(written).toContain('from strands import (\n Agent,\n tool,\n HookProvider,\n)'); + // Payment import is at the top, before the strands block. + const pluginPos = written.indexOf('from capabilities.payments.payments import create_payments_plugin'); + const strandsPos = written.indexOf('from strands import ('); + expect(pluginPos).toBe(0); + expect(strandsPos).toBeGreaterThan(pluginPos); + }); + + it('handles `agent = get_or_create_agent()` with a trailing `# type: ignore` comment', async () => { + // R-13-2: prior regex required `\s*$` after the call which excluded + // any trailing comment. PEP-484-style `# type: ignore` is common. + const main = [ + 'from strands import Agent', + '', + '_agent = None', + '', + 'def get_or_create_agent():', + ' global _agent', + ' if _agent is None:', + ' _agent = Agent(model=load_model(), tools=tools)', + ' return _agent', + '', + '@app.entrypoint', + 'async def invoke(payload, context):', + ' agent = get_or_create_agent() # type: ignore', + ' pass', + ].join('\n'); + mockExistsSync.mockImplementation((p: string) => { + if (p === TEMPLATE_DIR) return true; + if (p === PAYMENTS_PY_DEST) return false; + if (p === MAIN_PY) return true; + return false; + }); + mockReadFileSync.mockReturnValue(main); + + await callAdd(primitive, makeProject(CODE_LOCATION)); + const written: string = mockWriteFileSync.mock.calls.find( + (c: unknown[]) => (c[0] as string) === MAIN_PY + )![1] as string; + + // Original call site is replaced (with or without the comment) and the + // plugin block is injected. + expect(written).not.toContain('agent = get_or_create_agent() # type: ignore'); + expect(written).toContain('payments_plugin = create_payments_plugin'); + expect(written).toContain('agent = Agent('); + }); + + it('handles `_agent: Agent | None = None` (typed annotation form) when removing the singleton', async () => { + const main = [ + 'from strands import Agent', + '', + '_agent: "Agent | None" = None', + '', + 'def get_or_create_agent():', + ' global _agent', + ' if _agent is None:', + ' _agent = Agent(model=load_model(), tools=tools)', + ' return _agent', + '', + '@app.entrypoint', + 'async def invoke(payload, context):', + ' agent = get_or_create_agent()', + ' pass', + ].join('\n'); + mockExistsSync.mockImplementation((p: string) => { + if (p === TEMPLATE_DIR) return true; + if (p === PAYMENTS_PY_DEST) return false; + if (p === MAIN_PY) return true; + return false; + }); + mockReadFileSync.mockReturnValue(main); + + await callAdd(primitive, makeProject(CODE_LOCATION)); + const written: string = mockWriteFileSync.mock.calls.find( + (c: unknown[]) => (c[0] as string) === MAIN_PY + )![1] as string; + + // The annotated singleton must be removed alongside its function — not + // left orphaned at module scope. + expect(written).not.toContain('_agent: "Agent | None" = None'); + expect(written).not.toContain('def get_or_create_agent'); + }); + + it('aborts (throws) if call-site replaced but singleton has unrecognised shape', async () => { + // Hand-crafted main where `agent = get_or_create_agent()` matches but the + // singleton uses a shape we cannot parse — emit a clean error rather than + // ship corrupted code. + const main = [ + 'from strands import Agent', + '', + // Lambda-style singleton — not the recognised `_agent = None` shape. + '_agent = (lambda: None)()', + '', + 'def get_or_create_agent():', + ' return _agent', + '', + '@app.entrypoint', + 'async def invoke(payload, context):', + ' agent = get_or_create_agent()', + ' pass', + ].join('\n'); + mockExistsSync.mockImplementation((p: string) => { + if (p === TEMPLATE_DIR) return true; + if (p === PAYMENTS_PY_DEST) return false; + if (p === MAIN_PY) return true; + return false; + }); + mockReadFileSync.mockReturnValue(main); + + const result = await callAdd(primitive, makeProject(CODE_LOCATION)); + // The add call surfaces the error; we want a clean failure, not silent + // corruption. + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain('main.py'); + } + }); + + it('S-02-1: re-add after remove still patches main.py when payments.py already exists', async () => { + // Simulate a re-add where capabilities/payments/payments.py was left + // behind by the previous add (remove() does not delete it). main.py + // does NOT yet contain `create_payments_plugin` — must still be patched. + const main = [ + 'from strands import Agent', + '', + '_agent = None', + '', + 'def get_or_create_agent():', + ' global _agent', + ' if _agent is None:', + ' _agent = Agent(model=load_model(), tools=tools)', + ' return _agent', + '', + '@app.entrypoint', + 'async def invoke(payload, context):', + ' agent = get_or_create_agent()', + ' pass', + ].join('\n'); + mockExistsSync.mockImplementation((p: string) => { + if (p === TEMPLATE_DIR) return true; + if (p === PAYMENTS_PY_DEST) return true; // left behind from prior add + if (p === MAIN_PY) return true; + return false; + }); + mockReadFileSync.mockReturnValue(main); + + await callAdd(primitive, makeProject(CODE_LOCATION)); + + const written: string = mockWriteFileSync.mock.calls.find( + (c: unknown[]) => (c[0] as string) === MAIN_PY + )![1] as string; + // main.py was patched even though payments.py was already present. + expect(written).toContain('create_payments_plugin'); + expect(written).toContain('payments_plugin = create_payments_plugin'); + // payments.py was NOT re-copied (idempotency on the file). + expect(mockCopyFileSync).not.toHaveBeenCalled(); + }); + + it('inserts after a module docstring and `from __future__` block', async () => { + const main = [ + '"""Module docstring."""', + 'from __future__ import annotations', + '', + 'from strands import Agent', + '', + '@app.entrypoint', + 'async def invoke(payload, context):', + ' pass', + ].join('\n'); + mockExistsSync.mockImplementation((p: string) => { + if (p === TEMPLATE_DIR) return true; + if (p === PAYMENTS_PY_DEST) return false; + if (p === MAIN_PY) return true; + return false; + }); + mockReadFileSync.mockReturnValue(main); + + await callAdd(primitive, makeProject(CODE_LOCATION)); + const written: string = mockWriteFileSync.mock.calls.find( + (c: unknown[]) => (c[0] as string) === MAIN_PY + )![1] as string; + + const docstringPos = written.indexOf('"""Module docstring."""'); + const futurePos = written.indexOf('from __future__ import annotations'); + const pluginPos = written.indexOf('from capabilities.payments.payments import create_payments_plugin'); + // Docstring and __future__ must remain before the new import. + expect(docstringPos).toBe(0); + expect(futurePos).toBeLessThan(pluginPos); + // Payment import lands BEFORE the user's `from strands import` (which + // is fine — Python doesn't care about the order of regular imports). + const strandsPos = written.indexOf('from strands import Agent'); + expect(pluginPos).toBeLessThan(strandsPos); + }); + + // Note: the "no existing imports" case is no longer reachable since + // wirePaymentCapability requires `from strands import` to detect the + // framework before wiring. A main.py with zero imports cannot be a + // Strands template and is correctly skipped by the framework gate + // (covered by the framework-gate tests below). + }); + + // ========================================================================= + // Test 8 – Framework gate: skip non-Strands runtimes + // ========================================================================= + describe('framework gate (non-Strands runtimes)', () => { + /** + * Each fixture is a snippet from one of the templates we ship. The + * shared expectation is the same for all: when main.py is NOT a Strands + * agent, wirePaymentCapability must NOT touch the filesystem at all + * (no cap dir, no payments.py copy, no main.py rewrite). The success + * result still returns true and lists the runtime name in skippedRuntimes. + */ + const fixtures: { framework: string; main: string }[] = [ + { + framework: 'LangChain_LangGraph', + main: [ + 'import os', + 'from langchain_core.messages import HumanMessage', + 'from langgraph.prebuilt import create_react_agent', + 'from bedrock_agentcore.runtime import BedrockAgentCoreApp', + '', + 'app = BedrockAgentCoreApp()', + '', + '@app.entrypoint', + 'async def invoke(payload, context):', + ' pass', + ].join('\n'), + }, + { + framework: 'GoogleADK', + main: [ + 'import os', + 'from google.adk.agents import Agent', + 'from google.adk.runners import Runner', + 'from bedrock_agentcore.runtime import BedrockAgentCoreApp', + '', + 'app = BedrockAgentCoreApp()', + ].join('\n'), + }, + { + framework: 'OpenAIAgents', + main: [ + 'import os', + 'from agents import Agent, Runner', + 'from bedrock_agentcore.runtime import BedrockAgentCoreApp', + '', + 'app = BedrockAgentCoreApp()', + ].join('\n'), + }, + { + framework: 'AutoGen', + main: [ + 'import os', + 'from autogen_agentchat.agents import AssistantAgent', + 'from bedrock_agentcore.runtime import BedrockAgentCoreApp', + '', + 'app = BedrockAgentCoreApp()', + ].join('\n'), + }, + ]; + + for (const fixture of fixtures) { + it(`does not wire payments into ${fixture.framework} main.py`, async () => { + mockExistsSync.mockImplementation((p: string) => { + if (p === TEMPLATE_DIR) return true; + if (p === PAYMENTS_PY_DEST) return false; + if (p === MAIN_PY) return true; + return false; + }); + mockReadFileSync.mockReturnValue(fixture.main); + + const result = await callAdd(primitive, makeProject(CODE_LOCATION)); + + // add() still succeeds — the manager goes into agentcore.json + expect(result.success).toBe(true); + + // No filesystem mutations to the agent's source tree + expect(mockMkdirSync).not.toHaveBeenCalled(); + expect(mockCopyFileSync).not.toHaveBeenCalled(); + const mainPyWrite = mockWriteFileSync.mock.calls.find((c: unknown[]) => (c[0] as string) === MAIN_PY); + expect(mainPyWrite).toBeUndefined(); + const capInitWrite = mockWriteFileSync.mock.calls.find( + (c: unknown[]) => (c[0] as string) === CAP_INIT || (c[0] as string) === PARENT_INIT + ); + expect(capInitWrite).toBeUndefined(); + + // Runtime name surfaced for the CLI to warn the user + if (result.success) { + expect(result.skippedRuntimes).toContain('my-agent'); + } + }); + } + + it('skips wiring when main.py is missing entirely (cannot detect framework)', async () => { + mockExistsSync.mockImplementation((p: string) => { + if (p === TEMPLATE_DIR) return true; + if (p === PAYMENTS_PY_DEST) return false; + if (p === MAIN_PY) return false; + return false; + }); + + const result = await callAdd(primitive, makeProject(CODE_LOCATION)); + + expect(result.success).toBe(true); + expect(mockMkdirSync).not.toHaveBeenCalled(); + expect(mockCopyFileSync).not.toHaveBeenCalled(); + if (result.success) { + expect(result.skippedRuntimes).toContain('my-agent'); + } + }); + + it('still wires when "from strands" appears in a Strands-typed main.py', async () => { + const strandsMain = ['from strands import Agent', '', '@app.entrypoint', 'def h(p, c):', ' pass'].join('\n'); + mockExistsSync.mockImplementation((p: string) => { + if (p === TEMPLATE_DIR) return true; + if (p === PAYMENTS_PY_DEST) return false; + if (p === MAIN_PY) return true; + return false; + }); + mockReadFileSync.mockReturnValue(strandsMain); + + const result = await callAdd(primitive, makeProject(CODE_LOCATION)); + + expect(result.success).toBe(true); + expect(mockMkdirSync).toHaveBeenCalledWith(CAP_DIR, { recursive: true }); + expect(mockCopyFileSync).toHaveBeenCalledWith(PAYMENTS_PY_SRC, PAYMENTS_PY_DEST); + if (result.success) { + expect(result.skippedRuntimes).toEqual([]); + } + }); + }); +}); diff --git a/src/cli/primitives/credential-utils.ts b/src/cli/primitives/credential-utils.ts index 8c1df16e0..6431a0ec6 100644 --- a/src/cli/primitives/credential-utils.ts +++ b/src/cli/primitives/credential-utils.ts @@ -15,3 +15,39 @@ export function computeDefaultCredentialEnvVarName(credentialName: string): stri export function computeManagedOAuthCredentialName(gatewayName: string): string { return `${gatewayName}-oauth`; } + +/** + * Compute the env var names for a CoinbaseCDP payment credential. + * CoinbaseCDP credentials require 3 env vars. + */ +export function computePaymentCredentialEnvVarNames(credentialName: string): { + apiKeyId: string; + apiKeySecret: string; + walletSecret: string; +} { + const prefix = `AGENTCORE_CREDENTIAL_${credentialName.replace(/-/g, '_').toUpperCase()}`; + return { + apiKeyId: `${prefix}_API_KEY_ID`, + apiKeySecret: `${prefix}_API_KEY_SECRET`, + walletSecret: `${prefix}_WALLET_SECRET`, + }; +} + +/** + * Compute the env var names for a StripePrivy payment credential. + * StripePrivy credentials require 4 env vars. + */ +export function computeStripePrivyCredentialEnvVarNames(credentialName: string): { + appId: string; + appSecret: string; + authorizationPrivateKey: string; + authorizationId: string; +} { + const prefix = `AGENTCORE_CREDENTIAL_${credentialName.replace(/-/g, '_').toUpperCase()}`; + return { + appId: `${prefix}_APP_ID`, + appSecret: `${prefix}_APP_SECRET`, + authorizationPrivateKey: `${prefix}_AUTHORIZATION_PRIVATE_KEY`, + authorizationId: `${prefix}_AUTHORIZATION_ID`, + }; +} diff --git a/src/cli/primitives/payment-eligible.ts b/src/cli/primitives/payment-eligible.ts new file mode 100644 index 000000000..aafa3731a --- /dev/null +++ b/src/cli/primitives/payment-eligible.ts @@ -0,0 +1,37 @@ +import type { AgentEnvSpec } from '../../schema'; + +/** + * Decide whether a runtime is eligible for payment auto-wiring and runtime + * env-var injection. Payments today only ships a runtime shim for Python + * Strands HTTP agents. Other runtimes (TypeScript, MCP/A2A/AGUI, non-Strands + * Python frameworks) either have no shim or would be silently corrupted by + * the Strands-shaped template / env-vars they cannot consume. + * + * Used by: + * - PaymentManagerPrimitive.add (skips wirePaymentCapability) + * - cdk-stack.ts payment loop (skips env-var injection on the runtime) + * - dev/payment-env.ts (skips dev-mode env-var injection) + * + * Detection is conservative: when in doubt, treat as ineligible. Customers + * with non-Strands runtimes are told via warning that payments must be wired + * manually. + */ +export function isPaymentEligibleRuntime(runtime: AgentEnvSpec): boolean { + // Protocol gate: payments shim is HTTP-only today. + // The protocol field is optional; treat undefined as HTTP (the default). + if (runtime.protocol && runtime.protocol !== 'HTTP') { + return false; + } + + // Language gate: shim is Python-only today. Inspect the entrypoint + // file extension. Entrypoint format is "main.py" or "main.py:handler". + const entrypoint = typeof runtime.entrypoint === 'string' ? runtime.entrypoint : ''; + const entrypointFile = entrypoint.split(':')[0] ?? ''; + if (!entrypointFile.endsWith('.py')) { + return false; + } + + // Framework gate (Strands) is enforced downstream by reading main.py + // content; we cannot determine it from the runtime spec alone. + return true; +} diff --git a/src/cli/primitives/registry.ts b/src/cli/primitives/registry.ts index 8cc8e902f..c729a68bd 100644 --- a/src/cli/primitives/registry.ts +++ b/src/cli/primitives/registry.ts @@ -11,6 +11,8 @@ import { GatewayTargetPrimitive } from './GatewayTargetPrimitive'; import { HarnessPrimitive } from './HarnessPrimitive'; import { MemoryPrimitive } from './MemoryPrimitive'; import { OnlineEvalConfigPrimitive } from './OnlineEvalConfigPrimitive'; +import { PaymentConnectorPrimitive } from './PaymentConnectorPrimitive'; +import { PaymentManagerPrimitive } from './PaymentManagerPrimitive'; import { PolicyEnginePrimitive } from './PolicyEnginePrimitive'; import { PolicyPrimitive } from './PolicyPrimitive'; import { RuntimeEndpointPrimitive } from './RuntimeEndpointPrimitive'; @@ -33,6 +35,8 @@ export const policyPrimitive = new PolicyPrimitive(); export const configBundlePrimitive = new ConfigBundlePrimitive(); export const abTestPrimitive = new ABTestPrimitive(); export const runtimeEndpointPrimitive = new RuntimeEndpointPrimitive(); +export const paymentManagerPrimitive = new PaymentManagerPrimitive(); +export const paymentConnectorPrimitive = new PaymentConnectorPrimitive(); /** * All primitives in display order. @@ -52,6 +56,8 @@ export const ALL_PRIMITIVES: BasePrimitive[] = [ configBundlePrimitive, abTestPrimitive, runtimeEndpointPrimitive, + paymentManagerPrimitive, + paymentConnectorPrimitive, ]; /** diff --git a/src/cli/project.ts b/src/cli/project.ts index 5731abc64..fa1654b43 100644 --- a/src/cli/project.ts +++ b/src/cli/project.ts @@ -23,6 +23,7 @@ export function createDefaultProjectSpec(projectName: string): AgentCoreProjectS abTests: [], httpGateways: [], datasets: [], + payments: [], tags: { 'agentcore:created-by': 'agentcore-cli', 'agentcore:project-name': projectName, diff --git a/src/cli/telemetry/schemas/command-run.ts b/src/cli/telemetry/schemas/command-run.ts index 687461c45..5ddea55fe 100644 --- a/src/cli/telemetry/schemas/command-run.ts +++ b/src/cli/telemetry/schemas/command-run.ts @@ -185,6 +185,8 @@ export const COMMAND_SCHEMAS = { 'add.policy-engine': AddPolicyEngineAttrs, 'add.policy': AddPolicyAttrs, 'add.runtime-endpoint': NoAttrs, + 'add.payment-manager': NoAttrs, + 'add.payment-connector': NoAttrs, deploy: DeployAttrs, // dev / invoke / exec @@ -233,6 +235,10 @@ export const COMMAND_SCHEMAS = { 'dataset.download': NoAttrs, 'dataset.publish-version': NoAttrs, 'dataset.remove-version': NoAttrs, + 'remove.payment-manager': NoAttrs, + 'remove.payment-connector': NoAttrs, + 'telemetry.disable': NoAttrs, + 'telemetry.enable': NoAttrs, 'telemetry.status': NoAttrs, } as const satisfies Record>; diff --git a/src/cli/templates/BaseRenderer.ts b/src/cli/templates/BaseRenderer.ts index 659722926..6d3c1f42b 100644 --- a/src/cli/templates/BaseRenderer.ts +++ b/src/cli/templates/BaseRenderer.ts @@ -1,7 +1,7 @@ import { APP_DIR } from '../../lib'; import { copyAndRenderDir } from './render'; import type { AgentRenderConfig } from './types'; -import { existsSync } from 'node:fs'; +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; import * as path from 'node:path'; export interface RendererContext { @@ -32,6 +32,10 @@ export abstract class BaseRenderer { return this.config.hasMemory; } + protected shouldRenderPayment(): boolean { + return this.config.hasPayment; + } + protected getTemplateDir(): string { const language = this.config.targetLanguage.toLowerCase(); return path.join(this.baseTemplateDir, language, this.protocolMode, this.sdkName); @@ -65,6 +69,18 @@ export abstract class BaseRenderer { } } + if (this.shouldRenderPayment()) { + const paymentCapabilityDir = path.join(templateDir, 'capabilities', 'payments'); + if (existsSync(paymentCapabilityDir)) { + const capabilitiesDir = path.join(projectDir, 'capabilities'); + mkdirSync(capabilitiesDir, { recursive: true }); + const capInitPath = path.join(capabilitiesDir, '__init__.py'); + if (!existsSync(capInitPath)) writeFileSync(capInitPath, ''); + const paymentTargetDir = path.join(capabilitiesDir, 'payments'); + await copyAndRenderDir(paymentCapabilityDir, paymentTargetDir, templateData); + } + } + // Generate Dockerfile and .dockerignore for Container builds if (this.config.buildType === 'Container') { const language = this.config.targetLanguage.toLowerCase(); diff --git a/src/cli/templates/render.ts b/src/cli/templates/render.ts index 82adede9b..6f6aaff3d 100644 --- a/src/cli/templates/render.ts +++ b/src/cli/templates/render.ts @@ -73,10 +73,10 @@ export async function copyAndRenderDir( if (entry.isDirectory()) { await copyAndRenderDir(srcPath, destPath, data); } else { + await fs.mkdir(path.dirname(destPath), { recursive: true }); const content = await fs.readFile(srcPath, 'utf-8'); const template = Handlebars.compile(content); const rendered = template(data); - await fs.mkdir(path.dirname(destPath), { recursive: true }); await fs.writeFile(destPath, rendered, 'utf-8'); } } diff --git a/src/cli/templates/types.ts b/src/cli/templates/types.ts index 8b1025871..1c596226a 100644 --- a/src/cli/templates/types.ts +++ b/src/cli/templates/types.ts @@ -52,6 +52,7 @@ export interface AgentRenderConfig { hasMemory: boolean; hasIdentity: boolean; hasGateway: boolean; + hasPayment: boolean; /** Whether agent is deployed in VPC mode (affects example MCP endpoints) */ isVpc: boolean; /** Build type: CodeZip (default) or Container */ diff --git a/src/cli/tui/components/ResourceGraph.tsx b/src/cli/tui/components/ResourceGraph.tsx index d6e6a3d04..94cf31355 100644 --- a/src/cli/tui/components/ResourceGraph.tsx +++ b/src/cli/tui/components/ResourceGraph.tsx @@ -25,6 +25,7 @@ const ICONS = { dataset: '▤', harness: '⬢', 'runtime-endpoint': '◉', + payment: '₿', } as const; interface ResourceGraphProps { diff --git a/src/cli/tui/hooks/useCdkPreflight.ts b/src/cli/tui/hooks/useCdkPreflight.ts index 3ab7256ec..c6de0ef75 100644 --- a/src/cli/tui/hooks/useCdkPreflight.ts +++ b/src/cli/tui/hooks/useCdkPreflight.ts @@ -23,10 +23,100 @@ import { synthesizeCdk, validateProject, } from '../../operations/deploy'; +import { + hasPaymentCredentialProviders, + setupPaymentCredentialProviders, +} from '../../operations/deploy/pre-deploy-identity'; import type { Step } from '../components'; import * as path from 'node:path'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +const LABEL_PAYMENTS = 'Creating payment infrastructure'; + +interface RunPaymentSetupOptions { + projectSpec: PreflightContext['projectSpec']; + awsTargets: PreflightContext['awsTargets']; + runtimeCredentials?: SecureCredentials; + logger: ExecLogger; + setSteps: React.Dispatch>; + updateStepByLabel: (label: string, update: Partial) => void; + setPhase: (phase: PreflightPhase) => void; + isRunningRef: React.MutableRefObject; + setAllCredentials: React.Dispatch< + React.SetStateAction< + Record + > + >; +} + +async function runPaymentPreDeploy(opts: RunPaymentSetupOptions): Promise { + const { + projectSpec, + awsTargets, + runtimeCredentials, + logger, + setSteps, + updateStepByLabel, + setPhase, + isRunningRef, + setAllCredentials, + } = opts; + + if (!hasPaymentCredentialProviders(projectSpec)) return true; + + setSteps(prev => { + const synthIndex = prev.findIndex(s => s.label === LABEL_SYNTH); + return [...prev.slice(0, synthIndex), { label: LABEL_PAYMENTS, status: 'running' }, ...prev.slice(synthIndex)]; + }); + logger.startStep('Setting up payment credentials...'); + + const target = awsTargets[0]!; + const paymentConfigIO = new ConfigIO(); + + const paymentResult = await setupPaymentCredentialProviders({ + projectSpec, + configBaseDir: paymentConfigIO.getConfigRoot(), + region: target.region, + runtimeCredentials: runtimeCredentials ?? undefined, + }); + + if (paymentResult.hasErrors) { + const errorMsg = paymentResult.errors.join('; '); + logger.endStep('error', errorMsg); + updateStepByLabel(LABEL_PAYMENTS, { status: 'error', error: `Payment setup failed: ${errorMsg}` }); + setPhase('error'); + isRunningRef.current = false; + return false; + } + + // Merge payment credential provider ARNs into deployed credentials (same path as identity) + const existingState = await paymentConfigIO.readDeployedState().catch(() => ({ targets: {} }) as DeployedState); + const targetState = existingState.targets?.[target.name] ?? { resources: {} }; + targetState.resources ??= {}; + const existingCreds = targetState.resources.credentials ?? {}; + for (const [name, result] of Object.entries(paymentResult.credentialProviders)) { + existingCreds[name] = { credentialProviderArn: result.credentialProviderArn }; + } + targetState.resources.credentials = existingCreds; + await paymentConfigIO.writeDeployedState({ + ...existingState, + targets: { ...existingState.targets, [target.name]: targetState }, + }); + + // Update in-memory credentials so useDeployFlow.persistDeployedState has correct ARNs + setAllCredentials(prev => { + const updated = { ...prev }; + for (const [name, result] of Object.entries(paymentResult.credentialProviders)) { + updated[name] = { credentialProviderArn: result.credentialProviderArn }; + } + return updated; + }); + + logger.endStep('success'); + updateStepByLabel(LABEL_PAYMENTS, { status: 'success' }); + return true; +} + type PreflightPhase = | 'idle' | 'running' @@ -427,6 +517,19 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { return; } + // Set up payment resources (no-identity-providers path) + const paymentOk = await runPaymentPreDeploy({ + projectSpec: preflightContext.projectSpec, + awsTargets: preflightContext.awsTargets, + logger, + setSteps, + updateStepByLabel, + setPhase, + isRunningRef, + setAllCredentials, + }); + if (!paymentOk) return; + // Step: Synthesize CloudFormation updateStepByLabel(LABEL_SYNTH, { status: 'running' }); logger.startStep('Synthesize CloudFormation'); @@ -538,11 +641,25 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { isRunningRef.current = true; const runIdentitySetup = async () => { - // If user chose to skip, go directly to synth + // If user chose to skip, still run payment setup then go to synth if (skipIdentitySetup) { logger.log('Skipping identity provider setup (user choice)'); setSkipIdentitySetup(false); // Reset for next run + // Set up payment resources even when identity is skipped + const paymentOkSkip = await runPaymentPreDeploy({ + projectSpec: context.projectSpec, + awsTargets: context.awsTargets, + runtimeCredentials: runtimeCredentials ?? undefined, + logger, + setSteps, + updateStepByLabel, + setPhase, + isRunningRef, + setAllCredentials, + }); + if (!paymentOkSkip) return; + // Synthesize CloudFormation updateStepByLabel(LABEL_SYNTH, { status: 'running' }); logger.startStep('Synthesize CloudFormation'); @@ -775,6 +892,20 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { }); } + // Set up payment resources (before CDK synth so ARNs are in deployed state) + const paymentOkIdentity = await runPaymentPreDeploy({ + projectSpec: context.projectSpec, + awsTargets: context.awsTargets, + runtimeCredentials: runtimeCredentials ?? undefined, + logger, + setSteps, + updateStepByLabel, + setPhase, + isRunningRef, + setAllCredentials, + }); + if (!paymentOkIdentity) return; + // Clear runtime credentials setRuntimeCredentials(null); diff --git a/src/cli/tui/hooks/useDevServer.ts b/src/cli/tui/hooks/useDevServer.ts index d5a6f0075..80114cceb 100644 --- a/src/cli/tui/hooks/useDevServer.ts +++ b/src/cli/tui/hooks/useDevServer.ts @@ -110,7 +110,8 @@ export function useDevServer(options: { // Load env vars from deployed state + agentcore/.env if (root) { - const devEnv = await loadDevEnv(options.workingDir); + const selectedRuntime = options.agentName ? cfg?.runtimes.find(r => r.name === options.agentName) : undefined; + const devEnv = await loadDevEnv(options.workingDir, selectedRuntime); setEnvVars(devEnv.envVars); // Show warning only when some configured memories aren't deployed yet diff --git a/src/cli/tui/hooks/useRemove.ts b/src/cli/tui/hooks/useRemove.ts index caca18ef1..3825c00c0 100644 --- a/src/cli/tui/hooks/useRemove.ts +++ b/src/cli/tui/hooks/useRemove.ts @@ -18,6 +18,8 @@ import { harnessPrimitive, memoryPrimitive, onlineEvalConfigPrimitive, + paymentConnectorPrimitive, + paymentManagerPrimitive, policyEnginePrimitive, policyPrimitive, runtimeEndpointPrimitive, @@ -198,6 +200,16 @@ export function useRemovableRuntimeEndpoints() { return { endpoints, ...rest }; } +export function useRemovablePaymentManagers() { + const { items: paymentManagers, ...rest } = useRemovableResources(() => paymentManagerPrimitive.getRemovable()); + return { paymentManagers, ...rest }; +} + +export function useRemovablePaymentConnectors() { + const { items: paymentConnectors, ...rest } = useRemovableResources(() => paymentConnectorPrimitive.getRemovable()); + return { paymentConnectors, ...rest }; +} + // ============================================================================ // Preview Hook // ============================================================================ diff --git a/src/cli/tui/screens/add/AddFlow.tsx b/src/cli/tui/screens/add/AddFlow.tsx index c4dce66ed..42a0145fa 100644 --- a/src/cli/tui/screens/add/AddFlow.tsx +++ b/src/cli/tui/screens/add/AddFlow.tsx @@ -16,6 +16,7 @@ import { AddIdentityFlow } from '../identity'; import { AddGatewayFlow, AddGatewayTargetFlow } from '../mcp'; import { AddMemoryFlow } from '../memory/AddMemoryFlow'; import { AddOnlineEvalFlow } from '../online-eval'; +import { AddPaymentFlow } from '../payment'; import { AddPolicyFlow } from '../policy'; import { AddRuntimeEndpointFlow } from '../runtime-endpoint'; import type { AddResourceType } from './AddScreen'; @@ -40,6 +41,7 @@ type FlowState = | { name: 'config-bundle-wizard' } | { name: 'ab-test-wizard' } | { name: 'runtime-endpoint-wizard' } + | { name: 'payment-wizard' } | { name: 'agent-create-success'; agentName: string; @@ -199,6 +201,8 @@ function getInitialFlowState(resource?: AddResourceType): FlowState { return { name: 'config-bundle-wizard' }; case 'ab-test': return { name: 'ab-test-wizard' }; + case 'payment': + return { name: 'payment-wizard' }; default: return { name: 'select' }; } @@ -261,6 +265,9 @@ export function AddFlow(props: AddFlowProps) { case 'runtime-endpoint': setFlow({ name: 'runtime-endpoint-wizard' }); break; + case 'payment': + setFlow({ name: 'payment-wizard' }); + break; } }, []); @@ -564,6 +571,19 @@ export function AddFlow(props: AddFlowProps) { ); } + // Payment wizard + if (flow.name === 'payment-wizard') { + return ( + setFlow({ name: 'select' })} + onDev={props.onDev} + onDeploy={props.onDeploy} + /> + ); + } + return ( ({ + name: p.name, + authorizerType: p.authorizerType, + autoPayment: p.autoPayment, + paymentToolAllowlist: p.paymentToolAllowlist, + networkPreferences: p.networkPreferences, + connectors: p.connectors.map(c => ({ + name: c.name, + credentialProviderArn: allCredentials[c.credentialName]?.credentialProviderArn ?? '', + credentialProviderName: c.credentialName, + })), + }) + ); + const payments = paymentSpecs.length > 0 ? parsePaymentOutputs(outputs, paymentSpecs) : undefined; + const existingState = await configIO.readDeployedState().catch(() => undefined); // Post-CDK: deploy imperative resources (harness) — preview mode only @@ -370,6 +399,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState policies, datasets, harnesses: deployedHarnesses, + payments, }); try { @@ -764,9 +794,22 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState } } - // After deploying the empty spec, destroy the stack entirely + // After deploying the empty spec, destroy the stack entirely. + // Clean up imperative payment credential providers before stack teardown. const targetName = context.awsTargets[0]?.name; if (targetName) { + try { + const configIO = new ConfigIO(); + const deployedState = await configIO.readDeployedState(); + const existingPayments = deployedState?.targets?.[targetName]?.resources?.payments; + if (existingPayments && Object.keys(existingPayments).length > 0) { + const target = context.awsTargets[0]!; + await cleanupPaymentCredentialProviders({ region: target.region, payments: existingPayments }); + } + } catch { + // Best-effort: continue with teardown even if credential cleanup fails + } + const teardown = await performStackTeardown(targetName); if (!teardown.success) { throw new Error(`Stack teardown failed: ${teardown.error.message}`); diff --git a/src/cli/tui/screens/payment/AddPaymentConnectorScreen.tsx b/src/cli/tui/screens/payment/AddPaymentConnectorScreen.tsx new file mode 100644 index 000000000..a238b3c70 --- /dev/null +++ b/src/cli/tui/screens/payment/AddPaymentConnectorScreen.tsx @@ -0,0 +1,305 @@ +import type { PaymentProvider } from '../../../../schema'; +import { PaymentConnectorNameSchema } from '../../../../schema'; +import { ConfirmReview, Panel, Screen, SecretInput, StepIndicator, TextInput, WizardSelect } from '../../components'; +import type { SelectableItem } from '../../components'; +import { HELP_TEXT } from '../../constants'; +import { useListNavigation } from '../../hooks'; +import { generateUniqueName } from '../../utils'; +import type { AddPaymentConnectorConfig } from './types'; +import { CONNECTOR_STEP_LABELS, PAYMENT_PROVIDER_OPTIONS } from './types'; +import { useAddPaymentConnectorWizard } from './useAddPaymentWizard'; +import React, { useMemo } from 'react'; + +interface AddPaymentConnectorScreenProps { + onComplete: (config: AddPaymentConnectorConfig) => void; + onExit: () => void; + existingManagerNames: string[]; + existingConnectorNames: string[]; + preSelectedManager?: string; + headerContent?: React.ReactNode; + /** When true, skip the confirm step and call onComplete after connector name */ + skipConfirm?: boolean; + /** Called when user selects a manager (for parent to refresh connector names) */ + onManagerSelected?: (managerName: string) => void; +} + +export function AddPaymentConnectorScreen({ + onComplete, + onExit, + existingManagerNames, + existingConnectorNames, + preSelectedManager, + headerContent: externalHeader, + skipConfirm = false, + onManagerSelected, +}: AddPaymentConnectorScreenProps) { + const wizard = useAddPaymentConnectorWizard(preSelectedManager); + + const managerItems: SelectableItem[] = useMemo( + () => + existingManagerNames.map(name => ({ + id: name, + title: name, + description: 'Payment manager', + })), + [existingManagerNames] + ); + + const providerItems: SelectableItem[] = useMemo( + () => PAYMENT_PROVIDER_OPTIONS.map(opt => ({ id: opt.id, title: opt.title, description: opt.description })), + [] + ); + + const isManagerSelectStep = wizard.step === 'manager-select'; + const isProviderStep = wizard.step === 'provider-select'; + const isApiKeyIdStep = wizard.step === 'api-key-id'; + const isApiKeySecretStep = wizard.step === 'api-key-secret'; + const isWalletSecretStep = wizard.step === 'wallet-secret'; + const isAppIdStep = wizard.step === 'app-id'; + const isAppSecretStep = wizard.step === 'app-secret'; + const isAuthorizationPrivateKeyStep = wizard.step === 'authorization-private-key'; + const isAuthorizationIdStep = wizard.step === 'authorization-id'; + const isConnectorNameStep = wizard.step === 'connector-name'; + const isConfirmStep = wizard.step === 'confirm'; + + const managerNav = useListNavigation({ + items: managerItems, + onSelect: item => { + wizard.setManagerName(item.id); + onManagerSelected?.(item.id); + }, + onExit: () => onExit(), + isActive: isManagerSelectStep, + }); + + const providerNav = useListNavigation({ + items: providerItems, + onSelect: item => wizard.setProvider(item.id as PaymentProvider), + onExit: () => { + if (wizard.currentIndex === 0) { + onExit(); + } else { + wizard.goBack(); + } + }, + isActive: isProviderStep, + }); + + useListNavigation({ + items: [{ id: 'confirm', title: 'Confirm' }], + onSelect: () => onComplete(wizard.config), + onExit: () => wizard.goBack(), + isActive: isConfirmStep, + }); + + const helpText = + isManagerSelectStep || isProviderStep + ? HELP_TEXT.NAVIGATE_SELECT + : isConfirmStep + ? HELP_TEXT.CONFIRM_CANCEL + : HELP_TEXT.TEXT_INPUT; + + const headerContent = externalHeader ?? ( + + ); + + const defaultConnectorName = generateUniqueName( + wizard.config.provider === 'StripePrivy' ? 'MyStripePrivyConnector' : 'MyCdpConnector', + existingConnectorNames + ); + + const isFirstStep = wizard.currentIndex === 0; + const goBackOrExit = isFirstStep ? onExit : () => wizard.goBack(); + + return ( + + + {isManagerSelectStep && ( + + )} + + {isProviderStep && ( + + )} + + {isApiKeyIdStep && ( + value.trim().length > 0 || 'API Key ID is required'} + revealChars={4} + /> + )} + + {isApiKeySecretStep && ( + value.trim().length > 0 || 'API Key Secret is required'} + revealChars={4} + /> + )} + + {isWalletSecretStep && ( + value.trim().length > 0 || 'Wallet Secret is required'} + revealChars={4} + /> + )} + + {isAppIdStep && ( + value.trim().length > 0 || 'App ID is required'} + revealChars={4} + /> + )} + + {isAppSecretStep && ( + value.trim().length > 0 || 'App Secret is required'} + revealChars={4} + /> + )} + + {isAuthorizationPrivateKeyStep && ( + value.trim().length > 0 || 'Authorization Private Key is required'} + revealChars={4} + /> + )} + + {isAuthorizationIdStep && ( + value.trim().length > 0 || 'Authorization ID is required'} + revealChars={4} + /> + )} + + {isConnectorNameStep && ( + { + if (skipConfirm) { + onComplete({ ...wizard.config, connectorName: name }); + } else { + wizard.setConnectorName(name); + } + }} + onCancel={goBackOrExit} + schema={PaymentConnectorNameSchema} + customValidation={value => + !existingConnectorNames.includes(value) || 'Connector name already exists in this manager' + } + /> + )} + + {isConfirmStep && ( + 8 + ? '****' + wizard.config.appId.slice(-4) + : '••••••••' + : '', + }, + { + label: 'App Secret', + value: wizard.config.appSecret + ? wizard.config.appSecret.length > 8 + ? '****' + wizard.config.appSecret.slice(-4) + : '••••••••' + : '', + }, + { + label: 'Authorization Private Key', + value: wizard.config.authorizationPrivateKey + ? wizard.config.authorizationPrivateKey.length > 8 + ? '****' + wizard.config.authorizationPrivateKey.slice(-4) + : '••••••••' + : '', + }, + { + label: 'Authorization ID', + value: wizard.config.authorizationId + ? wizard.config.authorizationId.length > 8 + ? '****' + wizard.config.authorizationId.slice(-4) + : '••••••••' + : '', + }, + ] + : [ + { + label: 'API Key ID', + value: wizard.config.apiKeyId + ? wizard.config.apiKeyId.length > 8 + ? '****' + wizard.config.apiKeyId.slice(-4) + : '••••••••' + : '', + }, + { + label: 'API Key Secret', + value: wizard.config.apiKeySecret + ? wizard.config.apiKeySecret.length > 8 + ? '****' + wizard.config.apiKeySecret.slice(-4) + : '••••••••' + : '', + }, + { + label: 'Wallet Secret', + value: wizard.config.walletSecret + ? wizard.config.walletSecret.length > 8 + ? '****' + wizard.config.walletSecret.slice(-4) + : '••••••••' + : '', + }, + ]), + ]} + /> + )} + + + ); +} diff --git a/src/cli/tui/screens/payment/AddPaymentFlow.tsx b/src/cli/tui/screens/payment/AddPaymentFlow.tsx new file mode 100644 index 000000000..99650a2f5 --- /dev/null +++ b/src/cli/tui/screens/payment/AddPaymentFlow.tsx @@ -0,0 +1,471 @@ +import { paymentManagerPrimitive } from '../../../primitives/registry'; +import { ConfirmReview, ErrorPrompt, Panel, Screen, SelectScreen } from '../../components'; +import type { SelectableItem } from '../../components'; +import { AddSuccessScreen } from '../add/AddSuccessScreen'; +import { AddPaymentConnectorScreen } from './AddPaymentConnectorScreen'; +import { AddPaymentManagerScreen } from './AddPaymentManagerScreen'; +import type { AddPaymentConnectorConfig, AddPaymentManagerConfig } from './types'; +import { useCreatePayment, useCreatePaymentConnector, useExistingConnectorNames } from './useCreatePayment'; +import { Box, Text, useInput } from 'ink'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; + +type FlowState = + | { name: 'loading' } + | { name: 'select' } + | { name: 'manager-wizard' } + | { name: 'connector-prompt'; managerConfig: AddPaymentManagerConfig } + | { name: 'connector-wizard-unified'; managerConfig: AddPaymentManagerConfig } + | { name: 'confirm'; managerConfig: AddPaymentManagerConfig; connectorConfig?: AddPaymentConnectorConfig } + | { name: 'connector-wizard'; preSelectedManager?: string } + | { name: 'success'; message: string } + | { name: 'error'; message: string }; + +interface AddPaymentFlowProps { + isInteractive?: boolean; + onExit: () => void; + onBack: () => void; + onDev?: () => void; + onDeploy?: () => void; +} + +export function AddPaymentFlow({ isInteractive = true, onExit, onBack, onDev, onDeploy }: AddPaymentFlowProps) { + const [flow, setFlow] = useState({ name: 'loading' }); + const [managerNames, setManagerNames] = useState([]); + const { createPayment, reset: resetCreate } = useCreatePayment(); + const { createConnector, reset: resetConnector } = useCreatePaymentConnector(); + const [connectorManagerName, setConnectorManagerName] = useState(undefined); + const { names: existingConnectorNames, refresh: refreshConnectorNames } = + useExistingConnectorNames(connectorManagerName); + const confirmHandlerRef = useRef<(() => void) | null>(null); + const isSubmittingRef = useRef(false); + + useInput( + (_input, key) => { + if (key.return && flow.name === 'confirm' && confirmHandlerRef.current && !isSubmittingRef.current) { + isSubmittingRef.current = true; + confirmHandlerRef.current(); + } + }, + { isActive: flow.name === 'confirm' } + ); + + useEffect(() => { + if (flow.name !== 'confirm') confirmHandlerRef.current = null; + }, [flow]); + + // Load existing managers from disk on mount — always show selection screen + useEffect(() => { + let cancelled = false; + void paymentManagerPrimitive + .getExistingManagers() + .then(names => { + if (cancelled) return; + setManagerNames(names); + setFlow({ name: 'select' }); + }) + .catch(() => { + if (cancelled) return; + setManagerNames([]); + setFlow({ name: 'select' }); + }); + return () => { + cancelled = true; + }; + }, []); + + // In non-interactive mode, exit after success + useEffect(() => { + if (!isInteractive && flow.name === 'success') { + onExit(); + } + }, [isInteractive, flow.name, onExit]); + + const buildSelectItems = useCallback((): SelectableItem[] => { + return [ + { + id: '__add_manager__', + title: 'Add a payment manager', + description: 'Create a new payment manager with authorization config', + }, + { + id: '__add_connector__', + title: 'Add a payment connector', + description: 'Link payment provider credentials to an existing manager', + }, + ]; + }, []); + + const handleSelectAction = useCallback( + (item: SelectableItem) => { + if (item.id === '__add_manager__') { + setFlow({ name: 'manager-wizard' }); + } else if (item.id === '__add_connector__') { + if (managerNames.length === 0) { + setFlow({ name: 'error', message: 'No payment managers exist. Create a manager first.' }); + } else if (managerNames.length === 1) { + // Only one manager, pre-select it + setConnectorManagerName(managerNames[0]); + void refreshConnectorNames(managerNames[0]); + setFlow({ name: 'connector-wizard', preSelectedManager: managerNames[0] }); + } else { + setFlow({ name: 'connector-wizard' }); + } + } + }, + [managerNames, refreshConnectorNames] + ); + + const handleManagerComplete = useCallback((config: AddPaymentManagerConfig) => { + setFlow({ name: 'connector-prompt', managerConfig: config }); + }, []); + + const handleConnectorComplete = useCallback( + (config: AddPaymentConnectorConfig) => { + const baseOptions = { + manager: config.managerName, + name: config.connectorName, + provider: config.provider, + } as const; + + const connectorOptions = + config.provider === 'StripePrivy' + ? { + ...baseOptions, + provider: 'StripePrivy' as const, + appId: config.appId, + appSecret: config.appSecret, + authorizationPrivateKey: config.authorizationPrivateKey, + authorizationId: config.authorizationId, + } + : { + ...baseOptions, + provider: 'CoinbaseCDP' as const, + apiKeyId: config.apiKeyId, + apiKeySecret: config.apiKeySecret, + walletSecret: config.walletSecret, + }; + + setFlow({ name: 'loading' }); + void createConnector(connectorOptions) + .then(result => { + if (result.ok) { + setFlow({ name: 'success', message: `Added payment connector: ${result.connectorName}` }); + } else { + setFlow({ name: 'error', message: result.error }); + } + }) + .catch(err => { + setFlow({ name: 'error', message: err instanceof Error ? err.message : 'Unexpected error' }); + }); + }, + [createConnector] + ); + + // Loading + if (flow.name === 'loading') { + return ( + + Loading... + + ); + } + + // Select action: add manager or add connector + if (flow.name === 'select') { + return ( + handleSelectAction(item)} + onExit={onBack} + /> + ); + } + + // Manager wizard + if (flow.name === 'manager-wizard') { + return ( + { + if (managerNames.length === 0) { + onBack(); + } else { + setFlow({ name: 'select' }); + } + }} + /> + ); + } + + // After manager config collected, ask about connector + if (flow.name === 'connector-prompt') { + const connectorChoiceItems = [ + { + id: 'add-connector', + title: 'Add a payment connector', + description: 'Link CoinbaseCDP or StripePrivy credentials', + }, + { id: 'skip', title: 'Skip for now' }, + ]; + return ( + { + if (item.id === 'add-connector') { + setFlow({ name: 'connector-wizard-unified', managerConfig: flow.managerConfig }); + } else { + setFlow({ name: 'confirm', managerConfig: flow.managerConfig }); + } + }} + onExit={() => setFlow({ name: 'manager-wizard' })} + /> + ); + } + + // Connector wizard within the unified manager flow (no confirm on this screen) + if (flow.name === 'connector-wizard-unified') { + return ( + { + setFlow({ name: 'confirm', managerConfig: flow.managerConfig, connectorConfig }); + }} + onExit={() => setFlow({ name: 'connector-prompt', managerConfig: flow.managerConfig })} + skipConfirm + /> + ); + } + + // Unified confirm screen — shows manager + optional connector, creates on Enter + if (flow.name === 'confirm') { + const managerFields = [ + { label: 'Auth Type', value: flow.managerConfig.authorizerType }, + { label: 'Manager Name', value: flow.managerConfig.managerName }, + { label: 'Pattern', value: flow.managerConfig.pattern }, + { label: 'Auto Payment', value: flow.managerConfig.autoPayment ? 'Enabled' : 'Disabled' }, + { label: 'Default Spend Limit', value: `$${flow.managerConfig.defaultSpendLimit}` }, + ...(flow.managerConfig.paymentToolAllowlist + ? [{ label: 'Tool Allowlist', value: flow.managerConfig.paymentToolAllowlist }] + : []), + ...(flow.managerConfig.networkPreferences + ? [{ label: 'Network Preferences', value: flow.managerConfig.networkPreferences }] + : []), + ]; + + const connectorFields = flow.connectorConfig + ? [ + { label: 'Connector Name', value: flow.connectorConfig.connectorName }, + { label: 'Provider', value: flow.connectorConfig.provider }, + ...(flow.connectorConfig.provider === 'StripePrivy' + ? [ + { + label: 'App ID', + value: + flow.connectorConfig.appId.length > 8 ? '****' + flow.connectorConfig.appId.slice(-4) : '••••••••', + }, + { + label: 'App Secret', + value: + flow.connectorConfig.appSecret.length > 8 + ? '****' + flow.connectorConfig.appSecret.slice(-4) + : '••••••••', + }, + { + label: 'Auth Key', + value: + flow.connectorConfig.authorizationPrivateKey.length > 8 + ? '****' + flow.connectorConfig.authorizationPrivateKey.slice(-4) + : '••••••••', + }, + { + label: 'Auth ID', + value: + flow.connectorConfig.authorizationId.length > 8 + ? '****' + flow.connectorConfig.authorizationId.slice(-4) + : '••••••••', + }, + ] + : [ + { + label: 'API Key ID', + value: + flow.connectorConfig.apiKeyId.length > 8 + ? '****' + flow.connectorConfig.apiKeyId.slice(-4) + : '••••••••', + }, + { + label: 'API Key Secret', + value: + flow.connectorConfig.apiKeySecret.length > 8 + ? '****' + flow.connectorConfig.apiKeySecret.slice(-4) + : '••••••••', + }, + { + label: 'Wallet Secret', + value: + flow.connectorConfig.walletSecret.length > 8 + ? '****' + flow.connectorConfig.walletSecret.slice(-4) + : '••••••••', + }, + ]), + ] + : []; + + const warningFields = !flow.connectorConfig + ? [{ label: '⚠ Warning', value: 'No connector — deploy will fail until you add one' }] + : []; + + const allFields = [...managerFields, ...connectorFields, ...warningFields]; + + const handleConfirmSubmit = async () => { + const mgrConfig = flow.managerConfig; + const parseList = (val: string): string[] | undefined => { + const items = val + .split(',') + .map(s => s.trim()) + .filter(Boolean); + return items.length > 0 ? items : undefined; + }; + + // Create manager + const mgrResult = await createPayment({ + name: mgrConfig.managerName, + authorizerType: mgrConfig.authorizerType, + discoveryUrl: mgrConfig.authorizerType === 'CUSTOM_JWT' ? mgrConfig.discoveryUrl : undefined, + allowedClients: mgrConfig.authorizerType === 'CUSTOM_JWT' ? parseList(mgrConfig.allowedClients) : undefined, + allowedAudience: mgrConfig.authorizerType === 'CUSTOM_JWT' ? parseList(mgrConfig.allowedAudience) : undefined, + allowedScopes: mgrConfig.authorizerType === 'CUSTOM_JWT' ? parseList(mgrConfig.allowedScopes) : undefined, + pattern: mgrConfig.pattern, + autoPayment: mgrConfig.autoPayment, + defaultSpendLimit: mgrConfig.defaultSpendLimit, + paymentToolAllowlist: mgrConfig.paymentToolAllowlist ? parseList(mgrConfig.paymentToolAllowlist) : undefined, + networkPreferences: mgrConfig.networkPreferences ? parseList(mgrConfig.networkPreferences) : undefined, + }); + + if (!mgrResult.ok) { + isSubmittingRef.current = false; + setFlow({ name: 'error', message: mgrResult.error }); + return; + } + + setManagerNames(prev => [...prev, mgrConfig.managerName]); + + // Create connector if provided + if (flow.connectorConfig) { + const connConfig = flow.connectorConfig; + const baseOptions = { + manager: mgrConfig.managerName, + name: connConfig.connectorName, + provider: connConfig.provider, + } as const; + const connectorOptions = + connConfig.provider === 'StripePrivy' + ? { + ...baseOptions, + provider: 'StripePrivy' as const, + appId: connConfig.appId, + appSecret: connConfig.appSecret, + authorizationPrivateKey: connConfig.authorizationPrivateKey, + authorizationId: connConfig.authorizationId, + } + : { + ...baseOptions, + provider: 'CoinbaseCDP' as const, + apiKeyId: connConfig.apiKeyId, + apiKeySecret: connConfig.apiKeySecret, + walletSecret: connConfig.walletSecret, + }; + + const connResult = await createConnector(connectorOptions); + if (!connResult.ok) { + isSubmittingRef.current = false; + setFlow({ + name: 'error', + message: `Manager "${mgrConfig.managerName}" was created, but connector failed: ${connResult.error}\n\nUse "Add a payment connector" to retry adding the connector.`, + }); + return; + } + } + + isSubmittingRef.current = false; + const msg = flow.connectorConfig + ? `Payment manager "${mgrConfig.managerName}" and connector "${flow.connectorConfig.connectorName}" created` + : `Payment manager "${mgrConfig.managerName}" created`; + setFlow({ name: 'success', message: msg }); + }; + + // eslint-disable-next-line react-hooks/refs -- intentional: handler must close over current flow state + confirmHandlerRef.current = () => void handleConfirmSubmit(); + + return ( + setFlow({ name: 'connector-prompt', managerConfig: flow.managerConfig })} + helpText="Enter confirm · Esc back · Ctrl+C quit" + > + + + + + ); + } + + // Connector wizard + if (flow.name === 'connector-wizard') { + return ( + { + setConnectorManagerName(name); + void refreshConnectorNames(name); + }} + onExit={() => { + resetConnector(); + setFlow({ name: 'select' }); + }} + /> + ); + } + + // Unified success screen + if (flow.name === 'success') { + return ( + + ); + } + + // Error + return ( + { + resetCreate(); + resetConnector(); + if (managerNames.length === 0) { + setFlow({ name: 'manager-wizard' }); + } else { + setFlow({ name: 'select' }); + } + }} + onExit={onExit} + /> + ); +} diff --git a/src/cli/tui/screens/payment/AddPaymentManagerScreen.tsx b/src/cli/tui/screens/payment/AddPaymentManagerScreen.tsx new file mode 100644 index 000000000..cacf8816a --- /dev/null +++ b/src/cli/tui/screens/payment/AddPaymentManagerScreen.tsx @@ -0,0 +1,336 @@ +import type { PaymentAuthorizerType, PaymentPattern } from '../../../../schema'; +import { PaymentManagerNameSchema } from '../../../../schema'; +import { Panel, Screen, StepIndicator, TextInput, WizardSelect } from '../../components'; +import type { SelectableItem } from '../../components'; +import { HELP_TEXT } from '../../constants'; +import { useListNavigation, useMultiSelectNavigation } from '../../hooks'; +import { generateUniqueName } from '../../utils'; +import type { AddPaymentManagerConfig } from './types'; +import { + AUTH_TYPE_OPTIONS, + AUTO_PAYMENT_ITEM_ID, + MANAGER_STEP_LABELS, + NETWORK_PREFS_ITEM_ID, + PAYMENT_PATTERN_OPTIONS, + TOOL_ALLOWLIST_ITEM_ID, +} from './types'; +import { useAddPaymentManagerWizard } from './useAddPaymentWizard'; +import { Box, Text } from 'ink'; +import React, { useMemo, useRef, useState } from 'react'; + +interface AddPaymentManagerScreenProps { + onComplete: (config: AddPaymentManagerConfig) => void; + onExit: () => void; + existingManagerNames: string[]; + headerContent?: React.ReactNode; +} + +export function AddPaymentManagerScreen({ + onComplete, + onExit, + existingManagerNames, + headerContent: externalHeader, +}: AddPaymentManagerScreenProps) { + const wizard = useAddPaymentManagerWizard(); + + const authTypeItems: SelectableItem[] = useMemo( + () => AUTH_TYPE_OPTIONS.map(opt => ({ id: opt.id, title: opt.title, description: opt.description })), + [] + ); + + const patternItems: SelectableItem[] = useMemo( + () => PAYMENT_PATTERN_OPTIONS.map(opt => ({ id: opt.id, title: opt.title, description: opt.description })), + [] + ); + + const BUDGET_ITEM_ID = 'default-budget'; + const advancedConfigItems: SelectableItem[] = useMemo( + () => [ + { id: AUTO_PAYMENT_ITEM_ID, title: 'Auto Payment' }, + { id: BUDGET_ITEM_ID, title: `Edit Default Budget (Current: $${wizard.config.defaultSpendLimit})` }, + { id: TOOL_ALLOWLIST_ITEM_ID, title: 'Edit Tool Allowlist' }, + { id: NETWORK_PREFS_ITEM_ID, title: 'Edit Network Preferences' }, + ], + [wizard.config.defaultSpendLimit] + ); + + const INITIAL_ADVANCED_SELECTED = [AUTO_PAYMENT_ITEM_ID]; + + // Advanced config sub-steps: 0 = multi-select, 1 = budget, 2 = tool allowlist, 3 = network prefs + const [advancedSubStep, setAdvancedSubStep] = useState(0); + const [pendingSubSteps, setPendingSubSteps] = useState([]); + const [prevWizardStep, setPrevWizardStep] = useState(wizard.step); + if (prevWizardStep !== wizard.step) { + setPrevWizardStep(wizard.step); + if (wizard.step === 'advanced-config') { + setAdvancedSubStep(0); + setPendingSubSteps([]); + } + } + + const isAuthTypeStep = wizard.step === 'auth-type'; + const isDiscoveryUrlStep = wizard.step === 'discovery-url'; + const isAllowedClientsStep = wizard.step === 'allowed-clients'; + const isAllowedAudienceStep = wizard.step === 'allowed-audience'; + const isAllowedScopesStep = wizard.step === 'allowed-scopes'; + const isManagerNameStep = wizard.step === 'manager-name'; + const isPatternStep = wizard.step === 'pattern-select'; + const isAdvancedConfigStep = wizard.step === 'advanced-config'; + + const authTypeNav = useListNavigation({ + items: authTypeItems, + onSelect: item => wizard.setAuthorizerType(item.id as PaymentAuthorizerType), + onExit: () => onExit(), + isActive: isAuthTypeStep, + }); + + const patternNav = useListNavigation({ + items: patternItems, + onSelect: item => { + wizard.setPattern(item.id as PaymentPattern); + }, + onExit: () => wizard.goBack(), + isActive: isPatternStep, + }); + + const [autoPaymentEnabled, setAutoPaymentEnabled] = useState(true); + const resolvedValuesRef = useRef({ + autoPayment: true, + defaultSpendLimit: wizard.config.defaultSpendLimit, + paymentToolAllowlist: wizard.config.paymentToolAllowlist, + networkPreferences: wizard.config.networkPreferences, + }); + + const advanceToNextSubStepOrComplete = (queue: number[]) => { + if (queue.length > 0) { + const [next, ...rest] = queue; + setPendingSubSteps(rest); + setAdvancedSubStep(next!); + } else { + onComplete({ + ...wizard.config, + autoPayment: resolvedValuesRef.current.autoPayment, + defaultSpendLimit: resolvedValuesRef.current.defaultSpendLimit, + paymentToolAllowlist: resolvedValuesRef.current.paymentToolAllowlist, + networkPreferences: resolvedValuesRef.current.networkPreferences, + }); + } + }; + + const advancedNav = useMultiSelectNavigation({ + items: advancedConfigItems, + getId: item => item.id, + initialSelectedIds: INITIAL_ADVANCED_SELECTED, + onConfirm: selectedIds => { + const autoEnabled = selectedIds.includes(AUTO_PAYMENT_ITEM_ID); + setAutoPaymentEnabled(autoEnabled); + resolvedValuesRef.current.autoPayment = autoEnabled; + const queue: number[] = []; + if (selectedIds.includes(BUDGET_ITEM_ID)) queue.push(1); + if (selectedIds.includes(TOOL_ALLOWLIST_ITEM_ID)) queue.push(2); + if (selectedIds.includes(NETWORK_PREFS_ITEM_ID)) queue.push(3); + advanceToNextSubStepOrComplete(queue); + }, + onExit: () => wizard.goBack(), + isActive: isAdvancedConfigStep && advancedSubStep === 0, + requireSelection: false, + }); + + const helpText = isAdvancedConfigStep + ? advancedSubStep === 0 + ? 'Space toggle · Enter confirm · Esc back' + : HELP_TEXT.TEXT_INPUT + : isAuthTypeStep || isPatternStep + ? HELP_TEXT.NAVIGATE_SELECT + : HELP_TEXT.TEXT_INPUT; + + const headerContent = externalHeader ?? ( + + ); + + const defaultManagerName = generateUniqueName('MyPaymentManager', existingManagerNames); + + const isFirstStep = wizard.currentIndex === 0; + const goBackOrExit = isFirstStep ? onExit : () => wizard.goBack(); + + return ( + + + {isAuthTypeStep && ( + + )} + + {isDiscoveryUrlStep && ( + { + if (!value.trim()) return 'Discovery URL is required for Custom JWT'; + try { + new URL(value.trim()); + return true; + } catch { + return 'Must be a valid URL'; + } + }} + /> + )} + + {isAllowedClientsStep && ( + + )} + + {isAllowedAudienceStep && ( + + )} + + {isAllowedScopesStep && ( + + )} + + {isManagerNameStep && ( + !existingManagerNames.includes(value) || 'Payment manager name already exists'} + /> + )} + + {isPatternStep && ( + + )} + + {isAdvancedConfigStep && advancedSubStep === 0 && ( + + Advanced Configuration + Space toggle · Enter continue · Esc back + + {advancedConfigItems.map((item, idx) => { + const isCursor = idx === advancedNav.cursorIndex; + const isChecked = advancedNav.selectedIds.has(item.id); + const checkbox = isChecked ? '[✓]' : '[ ]'; + return ( + + + {isCursor ? '❯' : ' '} + {checkbox} + {item.title} + + {isChecked ? 'Enabled' : 'Disabled'} + + ); + })} + + + Toggle items with Space. Press Enter to continue. + + + )} + + {isAdvancedConfigStep && advancedSubStep === 1 && ( + + Advanced Configuration + + Auto Payment: {autoPaymentEnabled ? '✓ Enabled' : '✗ Disabled'} + + + { + const resolved = value || '10.00'; + resolvedValuesRef.current.defaultSpendLimit = resolved; + wizard.setDefaultSpendLimit(value); + advanceToNextSubStepOrComplete(pendingSubSteps); + }} + onCancel={() => setAdvancedSubStep(0)} + customValidation={value => { + if (!value.trim()) return true; + const num = Number(value.trim()); + if (Number.isNaN(num) || num < 0) return 'Must be a valid positive number'; + return true; + }} + /> + + + )} + + {isAdvancedConfigStep && advancedSubStep === 2 && ( + + Advanced Configuration + + { + const resolved = value.trim() || undefined; + resolvedValuesRef.current.paymentToolAllowlist = resolved; + wizard.setPaymentToolAllowlist(resolved); + advanceToNextSubStepOrComplete(pendingSubSteps); + }} + onCancel={() => setAdvancedSubStep(0)} + /> + + + )} + + {isAdvancedConfigStep && advancedSubStep === 3 && ( + + Advanced Configuration + + { + const resolved = value.trim() || undefined; + resolvedValuesRef.current.networkPreferences = resolved; + wizard.setNetworkPreferences(resolved); + advanceToNextSubStepOrComplete(pendingSubSteps); + }} + onCancel={() => setAdvancedSubStep(0)} + /> + + + )} + + + ); +} diff --git a/src/cli/tui/screens/payment/index.ts b/src/cli/tui/screens/payment/index.ts new file mode 100644 index 000000000..885163eb7 --- /dev/null +++ b/src/cli/tui/screens/payment/index.ts @@ -0,0 +1,15 @@ +export { AddPaymentFlow } from './AddPaymentFlow'; +export { AddPaymentManagerScreen } from './AddPaymentManagerScreen'; +export { AddPaymentConnectorScreen } from './AddPaymentConnectorScreen'; +export type { + AddPaymentManagerConfig, + AddPaymentManagerStep, + AddPaymentConnectorConfig, + AddPaymentConnectorStep, +} from './types'; +export { + useCreatePayment, + useCreatePaymentConnector, + useExistingPaymentNames, + useExistingConnectorNames, +} from './useCreatePayment'; diff --git a/src/cli/tui/screens/payment/types.ts b/src/cli/tui/screens/payment/types.ts new file mode 100644 index 000000000..ab82d76f5 --- /dev/null +++ b/src/cli/tui/screens/payment/types.ts @@ -0,0 +1,123 @@ +import type { PaymentAuthorizerType, PaymentPattern, PaymentProvider } from '../../../../schema'; + +// ───────────────────────────────────────────────────────────────────────────── +// Payment Manager Flow Types +// ───────────────────────────────────────────────────────────────────────────── + +export type AddPaymentManagerStep = + | 'auth-type' + | 'discovery-url' + | 'allowed-clients' + | 'allowed-audience' + | 'allowed-scopes' + | 'manager-name' + | 'pattern-select' + | 'advanced-config' + | 'confirm'; + +export interface AddPaymentManagerConfig { + authorizerType: PaymentAuthorizerType; + discoveryUrl: string; + allowedClients: string; + allowedAudience: string; + allowedScopes: string; + managerName: string; + pattern: PaymentPattern; + autoPayment: boolean; + defaultSpendLimit: string; + paymentToolAllowlist?: string; + networkPreferences?: string; +} + +export const TOOL_ALLOWLIST_ITEM_ID = 'tool-allowlist'; +export const NETWORK_PREFS_ITEM_ID = 'network-preferences'; + +export const MANAGER_STEP_LABELS: Record = { + 'auth-type': 'Auth Type', + 'discovery-url': 'Discovery URL', + 'allowed-clients': 'Clients', + 'allowed-audience': 'Audience', + 'allowed-scopes': 'Scopes', + 'manager-name': 'Name', + 'pattern-select': 'Pattern', + 'advanced-config': 'Advanced', + confirm: 'Confirm', +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Payment Connector Flow Types +// ───────────────────────────────────────────────────────────────────────────── + +export type AddPaymentConnectorStep = + | 'manager-select' + | 'provider-select' + // CoinbaseCDP credentials + | 'api-key-id' + | 'api-key-secret' + | 'wallet-secret' + // StripePrivy credentials + | 'app-id' + | 'app-secret' + | 'authorization-private-key' + | 'authorization-id' + | 'connector-name' + | 'confirm'; + +export interface AddPaymentConnectorConfig { + managerName: string; + provider: PaymentProvider; + // CoinbaseCDP + apiKeyId: string; + apiKeySecret: string; + walletSecret: string; + // StripePrivy + appId: string; + appSecret: string; + authorizationPrivateKey: string; + authorizationId: string; + connectorName: string; +} + +export const CONNECTOR_STEP_LABELS: Record = { + 'manager-select': 'Manager', + 'provider-select': 'Provider', + 'api-key-id': 'API Key ID', + 'api-key-secret': 'API Key Secret', + 'wallet-secret': 'Wallet Secret', + 'app-id': 'App ID', + 'app-secret': 'App Secret', + 'authorization-private-key': 'Auth Key', + 'authorization-id': 'Auth ID', + 'connector-name': 'Name', + confirm: 'Confirm', +}; + +// ───────────────────────────────────────────────────────────────────────────── +// UI Option Constants +// ───────────────────────────────────────────────────────────────────────────── + +export const AUTH_TYPE_OPTIONS = [ + { + id: 'AWS_IAM' as const, + title: 'AWS IAM', + description: 'Use AWS IAM for authorization (default)', + }, + { + id: 'CUSTOM_JWT' as const, + title: 'Custom JWT', + description: 'Use a custom JWT authorizer via OIDC discovery', + }, +] as const; + +export const PAYMENT_PROVIDER_OPTIONS = [ + { id: 'CoinbaseCDP' as const, title: 'Coinbase CDP', description: 'Coinbase Developer Platform wallet credentials' }, + { id: 'StripePrivy' as const, title: 'Stripe + Privy', description: 'Stripe payments via Privy embedded wallets' }, +] as const; + +export const PAYMENT_PATTERN_OPTIONS = [ + { id: 'interceptor' as const, title: 'Interceptor', description: 'Automatically handle x402 payment responses' }, + { id: 'tool-based' as const, title: 'Tool-based', description: 'Expose payment as an agent tool' }, +] as const; + +/** Item ID for the auto payment toggle in the advanced config pane. */ +export const AUTO_PAYMENT_ITEM_ID = 'auto-payment'; diff --git a/src/cli/tui/screens/payment/useAddPaymentWizard.ts b/src/cli/tui/screens/payment/useAddPaymentWizard.ts new file mode 100644 index 000000000..7e5c56c35 --- /dev/null +++ b/src/cli/tui/screens/payment/useAddPaymentWizard.ts @@ -0,0 +1,348 @@ +import type { PaymentAuthorizerType, PaymentPattern, PaymentProvider } from '../../../../schema'; +import type { + AddPaymentConnectorConfig, + AddPaymentConnectorStep, + AddPaymentManagerConfig, + AddPaymentManagerStep, +} from './types'; +import { useCallback, useMemo, useState } from 'react'; + +// ───────────────────────────────────────────────────────────────────────────── +// Payment Manager Wizard +// ───────────────────────────────────────────────────────────────────────────── + +const BASE_MANAGER_STEPS: AddPaymentManagerStep[] = ['auth-type', 'manager-name', 'pattern-select', 'advanced-config']; +const JWT_MANAGER_STEPS: AddPaymentManagerStep[] = [ + 'auth-type', + 'discovery-url', + 'allowed-clients', + 'allowed-audience', + 'allowed-scopes', + 'manager-name', + 'pattern-select', + 'advanced-config', +]; + +function getDefaultManagerConfig(): AddPaymentManagerConfig { + return { + authorizerType: 'AWS_IAM', + discoveryUrl: '', + allowedClients: '', + allowedAudience: '', + allowedScopes: '', + managerName: '', + pattern: 'interceptor', + autoPayment: true, + defaultSpendLimit: '10.00', + }; +} + +export function useAddPaymentManagerWizard() { + const [config, setConfig] = useState(getDefaultManagerConfig); + const [step, setStep] = useState('auth-type'); + + const steps = useMemo( + () => (config.authorizerType === 'CUSTOM_JWT' ? JWT_MANAGER_STEPS : BASE_MANAGER_STEPS), + [config.authorizerType] + ); + + const currentIndex = steps.indexOf(step); + + const goBack = useCallback(() => { + const prevStep = steps[currentIndex - 1]; + if (prevStep) setStep(prevStep); + }, [currentIndex, steps]); + + const advanceFrom = useCallback( + (currentStep: AddPaymentManagerStep) => { + const idx = steps.indexOf(currentStep); + const next = steps[idx + 1]; + if (next) setStep(next); + }, + [steps] + ); + + const setAuthorizerType = useCallback((authorizerType: PaymentAuthorizerType) => { + setConfig(c => ({ ...c, authorizerType })); + if (authorizerType === 'AWS_IAM') { + // Skip OIDC fields, go straight to name + setStep('manager-name'); + } else { + setStep('discovery-url'); + } + }, []); + + const setDiscoveryUrl = useCallback( + (discoveryUrl: string) => { + setConfig(c => ({ ...c, discoveryUrl })); + advanceFrom('discovery-url'); + }, + [advanceFrom] + ); + + const setAllowedClients = useCallback( + (allowedClients: string) => { + setConfig(c => ({ ...c, allowedClients })); + advanceFrom('allowed-clients'); + }, + [advanceFrom] + ); + + const setAllowedAudience = useCallback( + (allowedAudience: string) => { + setConfig(c => ({ ...c, allowedAudience })); + advanceFrom('allowed-audience'); + }, + [advanceFrom] + ); + + const setAllowedScopes = useCallback( + (allowedScopes: string) => { + setConfig(c => ({ ...c, allowedScopes })); + advanceFrom('allowed-scopes'); + }, + [advanceFrom] + ); + + const setManagerName = useCallback( + (managerName: string) => { + setConfig(c => ({ ...c, managerName })); + advanceFrom('manager-name'); + }, + [advanceFrom] + ); + + const setPattern = useCallback( + (pattern: PaymentPattern) => { + setConfig(c => ({ ...c, pattern })); + advanceFrom('pattern-select'); + }, + [advanceFrom] + ); + + const setAdvancedConfig = useCallback( + (advanced: { autoPayment: boolean; defaultSpendLimit: string }) => { + setConfig(c => ({ ...c, autoPayment: advanced.autoPayment, defaultSpendLimit: advanced.defaultSpendLimit })); + advanceFrom('advanced-config'); + }, + [advanceFrom] + ); + + const setDefaultSpendLimit = useCallback((defaultSpendLimit: string) => { + setConfig(c => ({ ...c, defaultSpendLimit: defaultSpendLimit || '10.00' })); + }, []); + + const setPaymentToolAllowlist = useCallback((paymentToolAllowlist: string | undefined) => { + setConfig(c => ({ ...c, paymentToolAllowlist })); + }, []); + + const setNetworkPreferences = useCallback((networkPreferences: string | undefined) => { + setConfig(c => ({ ...c, networkPreferences })); + }, []); + + const reset = useCallback(() => { + setConfig(getDefaultManagerConfig()); + setStep('auth-type'); + }, []); + + return { + config, + step, + steps, + currentIndex, + goBack, + setAuthorizerType, + setDiscoveryUrl, + setAllowedClients, + setAllowedAudience, + setAllowedScopes, + setManagerName, + setPattern, + setAdvancedConfig, + setDefaultSpendLimit, + setPaymentToolAllowlist, + setNetworkPreferences, + reset, + }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Payment Connector Wizard +// ───────────────────────────────────────────────────────────────────────────── + +const CDP_CREDENTIAL_STEPS: AddPaymentConnectorStep[] = ['api-key-id', 'api-key-secret', 'wallet-secret']; +const STRIPE_PRIVY_CREDENTIAL_STEPS: AddPaymentConnectorStep[] = [ + 'app-id', + 'app-secret', + 'authorization-private-key', + 'authorization-id', +]; + +function getConnectorStepsForProvider( + provider: PaymentProvider, + needsManagerSelect: boolean +): AddPaymentConnectorStep[] { + const steps: AddPaymentConnectorStep[] = []; + if (needsManagerSelect) steps.push('manager-select'); + steps.push('provider-select'); + if (provider === 'StripePrivy') { + steps.push(...STRIPE_PRIVY_CREDENTIAL_STEPS); + } else { + steps.push(...CDP_CREDENTIAL_STEPS); + } + steps.push('connector-name', 'confirm'); + return steps; +} + +function getDefaultConnectorConfig(preSelectedManager?: string): AddPaymentConnectorConfig { + return { + managerName: preSelectedManager ?? '', + provider: 'CoinbaseCDP', + apiKeyId: '', + apiKeySecret: '', + walletSecret: '', + appId: '', + appSecret: '', + authorizationPrivateKey: '', + authorizationId: '', + connectorName: '', + }; +} + +export function useAddPaymentConnectorWizard(preSelectedManager?: string) { + const needsManagerSelect = !preSelectedManager; + const [config, setConfig] = useState(() => getDefaultConnectorConfig(preSelectedManager)); + + const steps = useMemo( + () => getConnectorStepsForProvider(config.provider, needsManagerSelect), + [config.provider, needsManagerSelect] + ); + const [step, setStep] = useState(steps[0]!); + + const currentIndex = steps.indexOf(step); + + const goBack = useCallback(() => { + const prevStep = steps[currentIndex - 1]; + if (prevStep) setStep(prevStep); + }, [currentIndex, steps]); + + const advanceFrom = useCallback( + (currentStep: AddPaymentConnectorStep) => { + const idx = steps.indexOf(currentStep); + const next = steps[idx + 1]; + if (next) setStep(next); + }, + [steps] + ); + + const setManagerName = useCallback( + (managerName: string) => { + setConfig(c => ({ ...c, managerName })); + advanceFrom('manager-select'); + }, + [advanceFrom] + ); + + const setProvider = useCallback((provider: PaymentProvider) => { + setConfig(c => ({ ...c, provider })); + // After selecting provider, advance to the first credential step + // The steps list will recompute via useMemo on next render + if (provider === 'StripePrivy') { + setStep('app-id'); + } else { + setStep('api-key-id'); + } + }, []); + + const setApiKeyId = useCallback( + (apiKeyId: string) => { + setConfig(c => ({ ...c, apiKeyId })); + advanceFrom('api-key-id'); + }, + [advanceFrom] + ); + + const setApiKeySecret = useCallback( + (apiKeySecret: string) => { + setConfig(c => ({ ...c, apiKeySecret })); + advanceFrom('api-key-secret'); + }, + [advanceFrom] + ); + + const setWalletSecret = useCallback( + (walletSecret: string) => { + setConfig(c => ({ ...c, walletSecret })); + advanceFrom('wallet-secret'); + }, + [advanceFrom] + ); + + const setAppId = useCallback( + (appId: string) => { + setConfig(c => ({ ...c, appId })); + advanceFrom('app-id'); + }, + [advanceFrom] + ); + + const setAppSecret = useCallback( + (appSecret: string) => { + setConfig(c => ({ ...c, appSecret })); + advanceFrom('app-secret'); + }, + [advanceFrom] + ); + + const setAuthorizationPrivateKey = useCallback( + (authorizationPrivateKey: string) => { + // AWS docs ship the key with a `wallet-auth:` prefix — strip it transparently. + const cleaned = authorizationPrivateKey.startsWith('wallet-auth:') + ? authorizationPrivateKey.slice('wallet-auth:'.length) + : authorizationPrivateKey; + setConfig(c => ({ ...c, authorizationPrivateKey: cleaned })); + advanceFrom('authorization-private-key'); + }, + [advanceFrom] + ); + + const setAuthorizationId = useCallback( + (authorizationId: string) => { + setConfig(c => ({ ...c, authorizationId })); + advanceFrom('authorization-id'); + }, + [advanceFrom] + ); + + const setConnectorName = useCallback( + (connectorName: string) => { + setConfig(c => ({ ...c, connectorName })); + advanceFrom('connector-name'); + }, + [advanceFrom] + ); + + const reset = useCallback(() => { + setConfig(getDefaultConnectorConfig(preSelectedManager)); + setStep(steps[0]!); + }, [preSelectedManager, steps]); + + return { + config, + step, + steps, + currentIndex, + goBack, + setManagerName, + setProvider, + setApiKeyId, + setApiKeySecret, + setWalletSecret, + setAppId, + setAppSecret, + setAuthorizationPrivateKey, + setAuthorizationId, + setConnectorName, + reset, + }; +} diff --git a/src/cli/tui/screens/payment/useCreatePayment.ts b/src/cli/tui/screens/payment/useCreatePayment.ts new file mode 100644 index 000000000..f70a70bac --- /dev/null +++ b/src/cli/tui/screens/payment/useCreatePayment.ts @@ -0,0 +1,151 @@ +import { ConfigIO } from '../../../../lib'; +import type { PaymentManager } from '../../../../schema'; +import type { AddPaymentConnectorOptions } from '../../../primitives/PaymentConnectorPrimitive'; +import type { AddPaymentManagerOptions } from '../../../primitives/PaymentManagerPrimitive'; +import { paymentConnectorPrimitive, paymentManagerPrimitive } from '../../../primitives/registry'; +import { useCallback, useEffect, useState } from 'react'; + +// ───────────────────────────────────────────────────────────────────────────── +// Manager creation hook +// ───────────────────────────────────────────────────────────────────────────── + +interface CreateStatus { + state: 'idle' | 'loading' | 'success' | 'error'; + error?: string; + result?: T; +} + +export function useCreatePayment() { + const [status, setStatus] = useState>({ state: 'idle' }); + + const create = useCallback(async (config: AddPaymentManagerOptions) => { + setStatus({ state: 'loading' }); + try { + const result = await paymentManagerPrimitive.add(config); + if (!result.success) { + throw result.error ?? new Error('Failed to create payment manager'); + } + const configIO = new ConfigIO(); + const project = await configIO.readProjectSpec(); + const manager = (project.payments ?? []).find(p => p.name === config.name); + if (!manager) { + throw new Error(`Payment manager "${config.name}" not found after creation`); + } + setStatus({ state: 'success', result: manager }); + return { ok: true as const, result: manager }; + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to create payment manager.'; + setStatus({ state: 'error', error: message }); + return { ok: false as const, error: message }; + } + }, []); + + const reset = useCallback(() => { + setStatus({ state: 'idle' }); + }, []); + + return { status, createPayment: create, reset }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Connector creation hook +// ───────────────────────────────────────────────────────────────────────────── + +export function useCreatePaymentConnector() { + const [status, setStatus] = useState>({ + state: 'idle', + }); + + const create = useCallback(async (config: AddPaymentConnectorOptions) => { + setStatus({ state: 'loading' }); + try { + const result = await paymentConnectorPrimitive.add(config); + if (!result.success) { + throw result.error ?? new Error('Failed to create payment connector'); + } + setStatus({ + state: 'success', + result: { connectorName: result.connectorName, managerName: result.managerName }, + }); + return { + ok: true as const, + connectorName: result.connectorName, + managerName: result.managerName, + credentialName: result.credentialName, + }; + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to create payment connector.'; + setStatus({ state: 'error', error: message }); + return { ok: false as const, error: message }; + } + }, []); + + const reset = useCallback(() => { + setStatus({ state: 'idle' }); + }, []); + + return { status, createConnector: create, reset }; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Existing names hooks +// ───────────────────────────────────────────────────────────────────────────── + +export function useExistingPaymentNames() { + const [names, setNames] = useState([]); + + useEffect(() => { + void paymentManagerPrimitive.getRemovable().then(items => setNames(items.map(i => i.name))); + }, []); + + const refresh = useCallback(async () => { + const items = await paymentManagerPrimitive.getRemovable(); + setNames(items.map(i => i.name)); + }, []); + + return { names, refresh }; +} + +export function useExistingConnectorNames(managerName?: string) { + const [names, setNames] = useState([]); + + useEffect(() => { + if (!managerName) return; + let cancelled = false; + void (async () => { + try { + const configIO = new ConfigIO(); + const project = await configIO.readProjectSpec(); + if (cancelled) return; + const manager = (project.payments ?? []).find(p => p.name === managerName); + setNames(manager ? manager.connectors.map(c => c.name) : []); + } catch { + if (!cancelled) setNames([]); + } + })(); + return () => { + cancelled = true; + }; + }, [managerName]); + + const refresh = useCallback( + async (mgr?: string) => { + const target = mgr ?? managerName; + if (!target) { + setNames([]); + return; + } + try { + const configIO = new ConfigIO(); + const project = await configIO.readProjectSpec(); + const manager = (project.payments ?? []).find(p => p.name === target); + setNames(manager ? manager.connectors.map(c => c.name) : []); + } catch { + setNames([]); + } + }, + [managerName] + ); + + return { names, refresh }; +} diff --git a/src/cli/tui/screens/remove/RemoveFlow.tsx b/src/cli/tui/screens/remove/RemoveFlow.tsx index 644bf1f73..dcf77ca1b 100644 --- a/src/cli/tui/screens/remove/RemoveFlow.tsx +++ b/src/cli/tui/screens/remove/RemoveFlow.tsx @@ -1,5 +1,6 @@ import type { RemovableGatewayTarget, RemovalPreview } from '../../../operations/remove'; -import { ErrorPrompt, Panel, Screen } from '../../components'; +import { paymentManagerPrimitive } from '../../../primitives/registry'; +import { ErrorPrompt, Panel, Screen, SelectScreen } from '../../components'; import { useRemovableABTests, useRemovableAgents, @@ -12,6 +13,7 @@ import { useRemovableIdentities, useRemovableMemories, useRemovableOnlineEvalConfigs, + useRemovablePaymentManagers, useRemovablePolicies, useRemovablePolicyEngines, useRemovableRuntimeEndpoints, @@ -70,6 +72,7 @@ type FlowState = | { name: 'select-config-bundle' } | { name: 'select-ab-test' } | { name: 'select-runtime-endpoint' } + | { name: 'select-payment' } | { name: 'confirm-agent'; agentName: string; preview: RemovalPreview } | { name: 'confirm-gateway'; gatewayName: string; preview: RemovalPreview } | { name: 'confirm-gateway-target'; tool: RemovableGatewayTarget; preview: RemovalPreview } @@ -83,6 +86,7 @@ type FlowState = | { name: 'confirm-config-bundle'; bundleName: string; preview: RemovalPreview } | { name: 'confirm-ab-test'; testName: string; preview: RemovalPreview } | { name: 'confirm-runtime-endpoint'; endpointName: string; preview: RemovalPreview } + | { name: 'confirm-payment'; managerName: string; preview: RemovalPreview } | { name: 'loading'; message: string } | { name: 'harness-success'; harnessName: string; logFilePath?: string } | { name: 'agent-success'; agentName: string; logFilePath?: string } @@ -98,6 +102,7 @@ type FlowState = | { name: 'config-bundle-success'; bundleName: string; logFilePath?: string } | { name: 'ab-test-success'; testName: string; logFilePath?: string } | { name: 'runtime-endpoint-success'; endpointName: string; logFilePath?: string } + | { name: 'payment-success'; managerName: string } | { name: 'remove-all' } | { name: 'error'; message: string }; @@ -125,6 +130,9 @@ interface RemoveFlowProps { | 'config-bundle' | 'ab-test' | 'dataset' + | 'payment' + | 'payment-manager' + | 'payment-connector' | 'all'; /** Initial resource name to auto-select (for CLI --name flag) */ initialResourceName?: string; @@ -169,6 +177,10 @@ export function RemoveFlow({ return { name: 'select-ab-test' }; case 'runtime-endpoint': return { name: 'select-runtime-endpoint' }; + case 'payment': + case 'payment-manager': + case 'payment-connector': + return { name: 'select-payment' }; case 'all': return { name: 'remove-all' }; default: @@ -208,6 +220,7 @@ export function RemoveFlow({ isLoading: isLoadingRuntimeEndpoints, refresh: refreshRuntimeEndpoints, } = useRemovableRuntimeEndpoints(); + const { paymentManagers, isLoading: isLoadingPayments, refresh: refreshPayments } = useRemovablePaymentManagers(); // Check if any data is still loading const isLoading = @@ -223,7 +236,8 @@ export function RemoveFlow({ isLoadingPolicyEngines || isLoadingPolicies || isLoadingConfigBundles || - isLoadingRuntimeEndpoints; + isLoadingRuntimeEndpoints || + isLoadingPayments; // Preview hook const { @@ -294,6 +308,7 @@ export function RemoveFlow({ 'config-bundle-success', 'ab-test-success', 'runtime-endpoint-success', + 'payment-success', ]; if (successStates.includes(flow.name)) { onExit(); @@ -348,6 +363,9 @@ export function RemoveFlow({ case 'runtime-endpoint': setFlow({ name: 'select-runtime-endpoint' }); break; + case 'payment': + setFlow({ name: 'select-payment' }); + break; case 'all': setFlow({ name: 'remove-all' }); break; @@ -668,6 +686,28 @@ export function RemoveFlow({ [loadRuntimeEndpointPreview, force, removeRuntimeEndpointOp] ); + const handleSelectPaymentManager = useCallback( + async (managerName: string) => { + try { + const preview = await paymentManagerPrimitive.previewRemove(managerName); + if (force) { + setFlow({ name: 'loading', message: `Removing payment manager ${managerName}...` }); + const removeResult = await paymentManagerPrimitive.remove(managerName); + if (removeResult.success) { + setFlow({ name: 'payment-success', managerName }); + } else { + setFlow({ name: 'error', message: removeResult.error?.message ?? 'Failed to remove payment manager' }); + } + } else { + setFlow({ name: 'confirm-payment', managerName, preview }); + } + } catch (err) { + setFlow({ name: 'error', message: err instanceof Error ? err.message : String(err) }); + } + }, + [force] + ); + // Auto-select resource when initialResourceName is provided and data is loaded useEffect(() => { if (!initialResourceName || isLoading || hasTriggeredInitialSelection.current) { @@ -716,6 +756,10 @@ export function RemoveFlow({ case 'dataset': void handleSelectDataset(initialResourceName); break; + case 'payment': + case 'payment-manager': + void handleSelectPaymentManager(initialResourceName); + break; } }, 0); }, [ @@ -734,6 +778,7 @@ export function RemoveFlow({ handleSelectConfigBundle, handleSelectABTest, handleSelectRuntimeEndpoint, + handleSelectPaymentManager, ]); // Confirm handlers - pass preview for logging @@ -1010,6 +1055,7 @@ export function RemoveFlow({ refreshPolicies(), refreshConfigBundles(), refreshRuntimeEndpoints(), + refreshPayments(), ]); }, [ refreshAgents, @@ -1025,6 +1071,7 @@ export function RemoveFlow({ refreshPolicies, refreshConfigBundles, refreshRuntimeEndpoints, + refreshPayments, ]); // Select screen - wait for data to load to avoid arrow position issues @@ -1050,6 +1097,7 @@ export function RemoveFlow({ abTestCount={abTests.length} runtimeEndpointCount={runtimeEndpoints.length} datasetCount={datasets.length} + paymentCount={paymentManagers.length} /> ); } @@ -1249,6 +1297,28 @@ export function RemoveFlow({ ); } + if (flow.name === 'select-payment') { + if (isLoading) return null; + if (paymentManagers.length === 0) { + return ( + setFlow({ name: 'select' })}> + + No payment managers to remove. + + + ); + } + const items = paymentManagers.map(m => ({ id: m.name, title: m.name, description: 'Payment manager' })); + return ( + void handleSelectPaymentManager(item.id)} + onExit={() => setFlow({ name: 'select' })} + /> + ); + } + // Confirmation screens if (flow.name === 'confirm-agent') { return ( @@ -1404,6 +1474,27 @@ export function RemoveFlow({ ); } + if (flow.name === 'confirm-payment') { + return ( + { + void (async () => { + setFlow({ name: 'loading', message: `Removing payment manager ${flow.managerName}...` }); + const result = await paymentManagerPrimitive.remove(flow.managerName); + if (result.success) { + setFlow({ name: 'payment-success', managerName: flow.managerName }); + } else { + setFlow({ name: 'error', message: result.error?.message ?? 'Failed to remove payment manager' }); + } + })(); + }} + onCancel={() => setFlow({ name: 'select-payment' })} + /> + ); + } + // Success screens if (flow.name === 'agent-success') { return ( @@ -1629,6 +1720,21 @@ export function RemoveFlow({ ); } + if (flow.name === 'payment-success') { + return ( + { + resetAll(); + void refreshAll().then(() => setFlow({ name: 'select' })); + }} + onExit={onExit} + /> + ); + } + // Remove all screen if (flow.name === 'remove-all') { return ; diff --git a/src/cli/tui/screens/remove/RemoveScreen.tsx b/src/cli/tui/screens/remove/RemoveScreen.tsx index de7d396f1..2f54c6010 100644 --- a/src/cli/tui/screens/remove/RemoveScreen.tsx +++ b/src/cli/tui/screens/remove/RemoveScreen.tsx @@ -18,6 +18,7 @@ export type RemoveResourceType = | 'ab-test' | 'runtime-endpoint' | 'dataset' + | 'payment' | 'all'; const REMOVE_RESOURCES: { id: RemoveResourceType; title: string; description: string }[] = [ @@ -31,6 +32,7 @@ const REMOVE_RESOURCES: { id: RemoveResourceType; title: string; description: st { id: 'online-eval', title: 'Online Eval Config', description: 'Remove an online eval config' }, { id: 'policy-engine', title: 'Policy Engine', description: 'Remove a policy engine' }, { id: 'policy', title: 'Policy', description: 'Remove a policy from a policy engine' }, + { id: 'payment', title: 'Payment', description: 'Remove a payment manager' }, { id: 'gateway', title: 'Gateway', description: 'Remove a gateway' }, { id: 'gateway-target', title: 'Gateway Target', description: 'Remove a gateway target' }, { id: 'config-bundle', title: 'Configuration Bundle [preview]', description: 'Remove a configuration bundle' }, @@ -71,6 +73,8 @@ interface RemoveScreenProps { runtimeEndpointCount: number; /** Number of datasets available for removal */ datasetCount: number; + /** Number of payment managers available for removal */ + paymentCount: number; } export function RemoveScreen({ @@ -90,6 +94,7 @@ export function RemoveScreen({ abTestCount, runtimeEndpointCount, datasetCount, + paymentCount, }: RemoveScreenProps) { const items: SelectableItem[] = useMemo(() => { return REMOVE_RESOURCES.map(r => { @@ -181,6 +186,12 @@ export function RemoveScreen({ description = 'No datasets to remove'; } break; + case 'payment': + if (paymentCount === 0) { + disabled = true; + description = 'No payment managers to remove'; + } + break; case 'all': // 'all' is always available break; @@ -203,6 +214,7 @@ export function RemoveScreen({ abTestCount, runtimeEndpointCount, datasetCount, + paymentCount, ]); const isDisabled = (item: SelectableItem) => item.disabled ?? false; diff --git a/src/cli/tui/screens/remove/__tests__/RemoveScreen.test.tsx b/src/cli/tui/screens/remove/__tests__/RemoveScreen.test.tsx index e28e95e3b..345062af3 100644 --- a/src/cli/tui/screens/remove/__tests__/RemoveScreen.test.tsx +++ b/src/cli/tui/screens/remove/__tests__/RemoveScreen.test.tsx @@ -26,6 +26,7 @@ describe('RemoveScreen', () => { abTestCount={0} runtimeEndpointCount={1} datasetCount={0} + paymentCount={1} /> ); @@ -61,6 +62,7 @@ describe('RemoveScreen', () => { abTestCount={0} runtimeEndpointCount={0} datasetCount={0} + paymentCount={0} /> ); @@ -92,6 +94,7 @@ describe('RemoveScreen', () => { abTestCount={2} runtimeEndpointCount={0} datasetCount={0} + paymentCount={0} /> ); @@ -121,6 +124,7 @@ describe('RemoveScreen', () => { abTestCount={0} runtimeEndpointCount={0} datasetCount={0} + paymentCount={0} /> ); diff --git a/src/cli/tui/screens/remove/useRemoveFlow.ts b/src/cli/tui/screens/remove/useRemoveFlow.ts index 36062d520..11ecb5774 100644 --- a/src/cli/tui/screens/remove/useRemoveFlow.ts +++ b/src/cli/tui/screens/remove/useRemoveFlow.ts @@ -89,6 +89,13 @@ export function useRemoveFlow({ force, dryRun }: RemoveFlowOptions): RemoveFlowS items.push(`${totalPolicies} polic${totalPolicies > 1 ? 'ies' : 'y'}`); } } + if (projectSpec.payments && projectSpec.payments.length > 0) { + items.push(`${projectSpec.payments.length} payment manager${projectSpec.payments.length > 1 ? 's' : ''}`); + const totalConnectors = projectSpec.payments.reduce((sum, p) => sum + p.connectors.length, 0); + if (totalConnectors > 0) { + items.push(`${totalConnectors} payment connector${totalConnectors > 1 ? 's' : ''}`); + } + } } catch { // Project exists but has issues - still allow reset items.push('AgentCore project (corrupted or incomplete)'); diff --git a/src/lib/packaging/__tests__/helpers.test.ts b/src/lib/packaging/__tests__/helpers.test.ts index 4b830ed41..12cad137d 100644 --- a/src/lib/packaging/__tests__/helpers.test.ts +++ b/src/lib/packaging/__tests__/helpers.test.ts @@ -162,6 +162,32 @@ describe('copySourceTree', () => { expect(existsSync(join(dest, '.venv'))).toBe(false); }); + it('excludes .env, .env.local and .env.* files at any depth (C-05-3 secret leak guard)', async () => { + const src = join(root, 'src-env-secrets'); + const dest = join(root, 'dest-env-secrets'); + mkdirSync(join(src, 'nested', 'deep'), { recursive: true }); + writeFileSync(join(src, '.env'), 'TOP_LEVEL_SECRET=abc'); + writeFileSync(join(src, '.env.local'), 'CDP_PRIVATE_KEY=xyz'); + writeFileSync(join(src, '.env.production'), 'PROD_SECRET=zzz'); + writeFileSync(join(src, 'nested', '.env.local'), 'NESTED_SECRET=def'); + writeFileSync(join(src, 'nested', 'deep', '.env'), 'DEEP_SECRET=ghi'); + writeFileSync(join(src, 'app.py'), 'pass'); + writeFileSync(join(src, 'nested', 'deep', 'lib.py'), 'pass'); + mkdirSync(dest, { recursive: true }); + + await copySourceTree(src, dest); + + // No env file at any depth survives. + expect(existsSync(join(dest, '.env'))).toBe(false); + expect(existsSync(join(dest, '.env.local'))).toBe(false); + expect(existsSync(join(dest, '.env.production'))).toBe(false); + expect(existsSync(join(dest, 'nested', '.env.local'))).toBe(false); + expect(existsSync(join(dest, 'nested', 'deep', '.env'))).toBe(false); + // Source files preserved. + expect(existsSync(join(dest, 'app.py'))).toBe(true); + expect(existsSync(join(dest, 'nested', 'deep', 'lib.py'))).toBe(true); + }); + it('throws for non-existent source', async () => { await expect(copySourceTree(join(root, 'nope'), join(root, 'x'))).rejects.toThrow('not found'); }); diff --git a/src/lib/packaging/helpers.ts b/src/lib/packaging/helpers.ts index 7a0d72557..602e2a00a 100644 --- a/src/lib/packaging/helpers.ts +++ b/src/lib/packaging/helpers.ts @@ -53,6 +53,31 @@ interface ResolvedPaths { const EXCLUDED_ENTRIES = new Set(['.git', '.venv', '__pycache__', '.pytest_cache', '.DS_Store', 'node_modules']); +/** + * Decide whether a directory entry should be skipped when packaging the + * source tree. Excludes: + * - the build-tooling artefacts in EXCLUDED_ENTRIES (.git / .venv / etc.) + * - the project agentcore/ config directory ONLY when it sits at the + * root of the package source (an in-tree dependency that ships its own + * agentcore/ sub-module — see issue #843 — must still be packaged). + * - any .env / .env.local / .env.* file at any depth (per-environment + * secrets that customers expect to stay local). + * + * The third bucket closes a footgun where a project with `--code-location .` + * (BYO at project root) would otherwise have `agentcore/.env.local` shipped + * inside the deploy zip — but is itself depth-aware to avoid breaking + * legitimate dependency code. + */ +function shouldExcludeEntry(entryName: string, source: string, rootDir: string): boolean { + if (EXCLUDED_ENTRIES.has(entryName)) return true; + if (entryName === CONFIG_DIR && resolve(source) === resolve(rootDir)) return true; + if (entryName === '.env' || entryName === '.env.local') return true; + // .env.* (e.g. .env.production, .env.development) — same family of + // environment-secret files, always local-only. + if (entryName.startsWith('.env.')) return true; + return false; +} + export const MAX_ZIP_SIZE_BYTES = 250 * 1024 * 1024; /** @@ -145,10 +170,7 @@ async function copyEntry(source: string, destination: string, rootDir: string): await mkdir(destination, { recursive: true }); const entries = await readdir(source); for (const entry of entries) { - if (EXCLUDED_ENTRIES.has(entry)) { - continue; - } - if (entry === CONFIG_DIR && resolve(source) === resolve(rootDir)) { + if (shouldExcludeEntry(entry, source, rootDir)) { continue; } await copyEntry(join(source, entry), join(destination, entry), rootDir); @@ -202,7 +224,7 @@ async function collectFiles(directory: string, basePath = ''): Promise const entries = await readdir(directory, { withFileTypes: true }); for (const entry of entries) { - if (EXCLUDED_ENTRIES.has(entry.name)) continue; + if (shouldExcludeEntry(entry.name, directory, rootDir)) continue; const fullPath = join(directory, entry.name); const zipPath = basePath ? `${basePath}/${entry.name}` : entry.name; @@ -360,10 +382,7 @@ function copyEntrySync(source: string, destination: string, rootDir: string): vo mkdirSync(destination, { recursive: true }); const entries = readdirSync(source); for (const entry of entries) { - if (EXCLUDED_ENTRIES.has(entry)) { - continue; - } - if (entry === CONFIG_DIR && resolve(source) === resolve(rootDir)) { + if (shouldExcludeEntry(entry, source, rootDir)) { continue; } copyEntrySync(join(source, entry), join(destination, entry), rootDir); @@ -402,7 +421,7 @@ function collectFilesSync(directory: string, basePath = ''): Zippable { const entries = readdirSync(directory, { withFileTypes: true }); for (const entry of entries) { - if (EXCLUDED_ENTRIES.has(entry.name)) continue; + if (shouldExcludeEntry(entry.name, directory, rootDir)) continue; const fullPath = join(directory, entry.name); const zipPath = basePath ? `${basePath}/${entry.name}` : entry.name; diff --git a/src/lib/utils/env.ts b/src/lib/utils/env.ts index 4fed9989c..f3884e096 100644 --- a/src/lib/utils/env.ts +++ b/src/lib/utils/env.ts @@ -61,3 +61,25 @@ export async function writeEnvFile(updates: Record, configRoot?: export async function setEnvVar(key: string, value: string, configRoot?: string): Promise { await writeEnvFile({ [key]: value }, configRoot); } + +/** + * Remove keys from agentcore/.env.local. + */ +export async function removeEnvVars(keys: string[], configRoot?: string): Promise { + const path = getEnvPath(configRoot); + const current = await readEnvFile(configRoot); + for (const key of keys) { + delete current[key]; + } + const entries = Object.entries(current); + const content = + entries.length > 0 + ? entries + .map( + ([k, v]) => + `${k}="${String(v).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r')}"` + ) + .join('\n') + '\n' + : ''; + await writeFile(path, content); +} diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index c97b6be4d..1904935b4 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -1,6 +1,6 @@ export { detectAwsAccount } from './aws-account'; export { SecureCredentials } from './credentials'; -export { getEnvPath, readEnvFile, writeEnvFile, getEnvVar, setEnvVar } from './env'; +export { getEnvPath, readEnvFile, writeEnvFile, getEnvVar, setEnvVar, removeEnvVars } from './env'; export { isWindows } from './platform'; export { runSubprocess, diff --git a/src/schema/schemas/__tests__/agentcore-project.test.ts b/src/schema/schemas/__tests__/agentcore-project.test.ts index 00c06ff0d..6f1accbde 100644 --- a/src/schema/schemas/__tests__/agentcore-project.test.ts +++ b/src/schema/schemas/__tests__/agentcore-project.test.ts @@ -407,6 +407,38 @@ describe('AgentCoreProjectSpecSchema', () => { } }); + it('leaves payments undefined when absent (optional, non-breaking round-trip)', () => { + // payments is .optional(), NOT .default([]) — parsing a project without a + // payments key must NOT materialize `payments: []`, so re-serializing an + // older config does not inject a payments field for customers who never + // used payments. See PR discussion on agentcore-l3-cdk-constructs#219. + const result = AgentCoreProjectSpecSchema.safeParse(minimalProject); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.payments).toBeUndefined(); + expect('payments' in result.data).toBe(false); + } + }); + + it('accepts and validates a payments array when present', () => { + const result = AgentCoreProjectSpecSchema.safeParse({ + ...minimalProject, + payments: [ + { + name: 'paymgr', + authorizerType: 'AWS_IAM', + pattern: 'interceptor', + connectors: [], + }, + ], + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.payments).toHaveLength(1); + expect(result.data.payments![0]!.name).toBe('paymgr'); + } + }); + it('accepts project with agents', () => { const result = AgentCoreProjectSpecSchema.safeParse({ ...minimalProject, diff --git a/src/schema/schemas/__tests__/payment.test.ts b/src/schema/schemas/__tests__/payment.test.ts new file mode 100644 index 000000000..8e700bbe9 --- /dev/null +++ b/src/schema/schemas/__tests__/payment.test.ts @@ -0,0 +1,114 @@ +import { PaymentConnectorNameSchema, PaymentManagerNameSchema, PaymentManagerSchema } from '../primitives/payment'; +import { describe, expect, it } from 'vitest'; + +describe('PaymentManagerNameSchema', () => { + describe('length boundaries', () => { + it('accepts exactly 48 characters', () => { + const name = 'A' + 'b'.repeat(47); + expect(name).toHaveLength(48); + expect(PaymentManagerNameSchema.safeParse(name).success).toBe(true); + }); + + it('rejects 49 characters', () => { + const name = 'A' + 'b'.repeat(48); + expect(name).toHaveLength(49); + expect(PaymentManagerNameSchema.safeParse(name).success).toBe(false); + }); + + it('rejects empty string', () => { + expect(PaymentManagerNameSchema.safeParse('').success).toBe(false); + }); + + it('accepts single letter', () => { + expect(PaymentManagerNameSchema.safeParse('A').success).toBe(true); + }); + }); + + describe('format validation', () => { + it('rejects name starting with a digit', () => { + expect(PaymentManagerNameSchema.safeParse('1manager').success).toBe(false); + }); + + it('rejects name starting with an underscore', () => { + expect(PaymentManagerNameSchema.safeParse('_manager').success).toBe(false); + }); + + it('rejects underscores (CreatePaymentManager API disallows them)', () => { + // Unlike connectors, the CreatePaymentManager API pattern is + // [a-zA-Z][a-zA-Z0-9]{0,47} — no underscores. Reject at parse time so the + // user sees the error at `add` instead of a late CFN failure. + expect(PaymentManagerNameSchema.safeParse('my_manager').success).toBe(false); + }); + + it('rejects hyphens', () => { + expect(PaymentManagerNameSchema.safeParse('my-manager').success).toBe(false); + }); + + it('rejects spaces', () => { + expect(PaymentManagerNameSchema.safeParse('my manager').success).toBe(false); + }); + + it('rejects special characters', () => { + expect(PaymentManagerNameSchema.safeParse('mgr@1').success).toBe(false); + expect(PaymentManagerNameSchema.safeParse('mgr.one').success).toBe(false); + }); + }); +}); + +describe('PaymentConnectorNameSchema', () => { + it('accepts exactly 48 characters', () => { + const name = 'C' + 'o'.repeat(47); + expect(PaymentConnectorNameSchema.safeParse(name).success).toBe(true); + }); + + it('rejects 49 characters', () => { + const name = 'C' + 'o'.repeat(48); + expect(PaymentConnectorNameSchema.safeParse(name).success).toBe(false); + }); + + it('rejects hyphens', () => { + expect(PaymentConnectorNameSchema.safeParse('my-connector').success).toBe(false); + }); + + it('rejects name starting with digit', () => { + expect(PaymentConnectorNameSchema.safeParse('9connector').success).toBe(false); + }); +}); + +describe('PaymentManagerSchema', () => { + const validBase = { name: 'testManager', pattern: 'interceptor' as const, connectors: [] }; + + describe('CUSTOM_JWT requires authorizerConfiguration', () => { + it('fails when authorizerConfiguration is missing', () => { + const result = PaymentManagerSchema.safeParse({ ...validBase, authorizerType: 'CUSTOM_JWT' }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.path.includes('authorizerConfiguration'))).toBe(true); + } + }); + + it('passes with valid customJWTAuthorizer', () => { + const result = PaymentManagerSchema.safeParse({ + ...validBase, + authorizerType: 'CUSTOM_JWT', + authorizerConfiguration: { + customJWTAuthorizer: { discoveryUrl: 'https://example.com/.well-known/openid-configuration' }, + }, + }); + expect(result.success).toBe(true); + }); + + it('passes with AWS_IAM and no authorizerConfiguration', () => { + const result = PaymentManagerSchema.safeParse({ ...validBase, authorizerType: 'AWS_IAM' }); + expect(result.success).toBe(true); + }); + }); + + describe('autoPayment defaults', () => { + it('accepts explicit false', () => { + const result = PaymentManagerSchema.safeParse({ ...validBase, autoPayment: false }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.autoPayment).toBe(false); + }); + }); +}); diff --git a/src/schema/schemas/agentcore-project.ts b/src/schema/schemas/agentcore-project.ts index 4ef4458a6..9d79fb160 100644 --- a/src/schema/schemas/agentcore-project.ts +++ b/src/schema/schemas/agentcore-project.ts @@ -29,6 +29,15 @@ import { MemoryStrategyTypeSchema, } from './primitives/memory'; import { OnlineEvalConfigSchema } from './primitives/online-eval-config'; +import { + PaymentAuthorizerTypeSchema, + PaymentConnectorNameSchema, + PaymentConnectorSchema, + PaymentManagerNameSchema, + PaymentManagerSchema, + PaymentPatternSchema, + PaymentProviderSchema, +} from './primitives/payment'; import { PolicyEngineSchema } from './primitives/policy'; import { TagsSchema } from './primitives/tags'; import { uniqueBy } from './zod-util'; @@ -93,6 +102,22 @@ export { HarnessToolTypeSchema, HarnessModelProviderSchema, } from './primitives/harness'; +export { + PaymentManagerSchema, + PaymentManagerNameSchema, + PaymentConnectorSchema, + PaymentConnectorNameSchema, + PaymentProviderSchema, + PaymentPatternSchema, + PaymentAuthorizerTypeSchema, +}; +export type { + PaymentManager, + PaymentConnector, + PaymentProvider, + PaymentPattern, + PaymentAuthorizerType, +} from './primitives/payment'; // ============================================================================ // ManagedBy Schema @@ -254,7 +279,11 @@ export const CredentialNameSchema = z .max(128, 'Credential name must be 128 characters or less') .regex(/^[a-zA-Z0-9\-_]+$/, 'Must contain only alphanumeric characters, hyphens, and underscores (1-128 chars)'); -export const CredentialTypeSchema = z.enum(['ApiKeyCredentialProvider', 'OAuthCredentialProvider']); +export const CredentialTypeSchema = z.enum([ + 'ApiKeyCredentialProvider', + 'OAuthCredentialProvider', + 'PaymentCredentialProvider', +]); export type CredentialType = z.infer; export const ApiKeyCredentialSchema = z.object({ @@ -281,7 +310,19 @@ export const OAuthCredentialSchema = z.object({ export type OAuthCredential = z.infer; -export const CredentialSchema = z.discriminatedUnion('authorizerType', [ApiKeyCredentialSchema, OAuthCredentialSchema]); +export const PaymentCredentialSchema = z.object({ + authorizerType: z.literal('PaymentCredentialProvider'), + name: CredentialNameSchema, + provider: PaymentProviderSchema, +}); + +export type PaymentCredential = z.infer; + +export const CredentialSchema = z.discriminatedUnion('authorizerType', [ + ApiKeyCredentialSchema, + OAuthCredentialSchema, + PaymentCredentialSchema, +]); export type Credential = z.infer; @@ -475,6 +516,17 @@ export const AgentCoreProjectSpecSchema = z seen.add(dataset.name); } }), + + payments: z + .array(PaymentManagerSchema) + .optional() + .superRefine((items, ctx) => { + if (!items) return; + uniqueBy( + (manager: { name: string }) => manager.name, + (name: string) => `Duplicate payment manager name: ${name}` + )(items, ctx); + }), }) .strict() .superRefine((spec, ctx) => { @@ -566,6 +618,28 @@ export const AgentCoreProjectSpecSchema = z } } } + + // Validate payment connector credential references + for (const payment of spec.payments ?? []) { + const paymentIndex = (spec.payments ?? []).indexOf(payment); + for (const connector of payment.connectors) { + const connectorIndex = payment.connectors.indexOf(connector); + const credential = spec.credentials.find(c => c.name === connector.credentialName); + if (!credential) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Payment connector "${connector.name}" in manager "${payment.name}" references credential "${connector.credentialName}" which does not exist in credentials[]`, + path: ['payments', paymentIndex, 'connectors', connectorIndex, 'credentialName'], + }); + } else if (credential.authorizerType !== 'PaymentCredentialProvider') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Payment connector "${connector.name}" in manager "${payment.name}" references credential "${connector.credentialName}" which is a ${credential.authorizerType}, not a PaymentCredentialProvider`, + path: ['payments', paymentIndex, 'connectors', connectorIndex, 'credentialName'], + }); + } + } + } }); export type AgentCoreProjectSpec = z.infer; diff --git a/src/schema/schemas/deployed-state.ts b/src/schema/schemas/deployed-state.ts index 7a1acd3c7..495dd444f 100644 --- a/src/schema/schemas/deployed-state.ts +++ b/src/schema/schemas/deployed-state.ts @@ -221,11 +221,8 @@ export type ConfigBundleDeployedState = z.infer; +// ============================================================================ +// Payment Connector Deployed State +// ============================================================================ + +export const PaymentConnectorDeployedStateSchema = z.object({ + connectorId: z.string().min(1), + credentialProviderArn: z.string().min(1), + credentialProviderName: z.string().optional(), +}); + +export type PaymentConnectorDeployedState = z.infer; + +// ============================================================================ +// Payment Deployed State +// ============================================================================ + +export const PaymentDeployedStateSchema = z.object({ + managerId: z.string().min(1), + managerArn: z.string().min(1), + connectors: z.record(z.string(), PaymentConnectorDeployedStateSchema).default({}), + processPaymentRoleArn: z.string().min(1), + resourceRetrievalRoleArn: z.string().min(1), + authorizerType: z.enum(['AWS_IAM', 'CUSTOM_JWT']).optional(), + autoPayment: z.boolean().optional(), + paymentToolAllowlist: z.array(z.string()).optional(), + networkPreferences: z.array(z.string()).optional(), +}); + +export type PaymentDeployedState = z.infer; + // ============================================================================ // Deployed Resource State // ============================================================================ @@ -277,6 +304,7 @@ export const DeployedResourceStateSchema = z.object({ policies: z.record(z.string(), PolicyDeployedStateSchema).optional(), harnesses: z.record(z.string(), HarnessDeployedStateSchema).optional(), runtimeEndpoints: z.record(z.string(), RuntimeEndpointDeployedStateSchema).optional(), + payments: z.record(z.string(), PaymentDeployedStateSchema).optional(), stackName: z.string().optional(), identityKmsKeyArn: z.string().optional(), deployHash: z.string().optional(), diff --git a/src/schema/schemas/primitives/index.ts b/src/schema/schemas/primitives/index.ts index b25fe3666..c8a38bf79 100644 --- a/src/schema/schemas/primitives/index.ts +++ b/src/schema/schemas/primitives/index.ts @@ -100,3 +100,20 @@ export { export type { HttpGateway } from './http-gateway'; export { HttpGatewayNameSchema, HttpGatewaySchema } from './http-gateway'; + +export type { + PaymentManager, + PaymentConnector, + PaymentProvider, + PaymentPattern, + PaymentAuthorizerType, +} from './payment'; +export { + PaymentManagerSchema, + PaymentManagerNameSchema, + PaymentConnectorSchema, + PaymentConnectorNameSchema, + PaymentProviderSchema, + PaymentPatternSchema, + PaymentAuthorizerTypeSchema, +} from './payment'; diff --git a/src/schema/schemas/primitives/payment.ts b/src/schema/schemas/primitives/payment.ts new file mode 100644 index 000000000..73a6067b4 --- /dev/null +++ b/src/schema/schemas/primitives/payment.ts @@ -0,0 +1,102 @@ +import { z } from 'zod'; + +// ============================================================================ +// Payment Provider Schema +// ============================================================================ + +export const PaymentProviderSchema = z.enum(['CoinbaseCDP', 'StripePrivy']); +export type PaymentProvider = z.infer; + +// ============================================================================ +// Payment Pattern Schema +// ============================================================================ + +export const PaymentPatternSchema = z.enum(['interceptor', 'tool-based']); +export type PaymentPattern = z.infer; + +// ============================================================================ +// Payment Manager Name Schema +// ============================================================================ + +// Note: the CreatePaymentManager API name pattern is [a-zA-Z][a-zA-Z0-9]{0,47} — +// no underscores (unlike connectors, which the API does allow underscores for). +// Matching it here so an invalid name is rejected at `add` time instead of failing +// late at deploy/CFN time. +export const PaymentManagerNameSchema = z + .string() + .min(1, 'Payment manager name is required') + .max(48) + .regex( + /^[a-zA-Z][a-zA-Z0-9]{0,47}$/, + 'Must begin with a letter and contain only alphanumeric characters (max 48 chars)' + ); + +// ============================================================================ +// Payment Connector Name Schema +// ============================================================================ + +export const PaymentConnectorNameSchema = z + .string() + .min(1, 'Payment connector name is required') + .max(48) + .regex( + /^[a-zA-Z][a-zA-Z0-9_]{0,47}$/, + 'Must begin with a letter and contain only alphanumeric characters and underscores (max 48 chars)' + ); + +// ============================================================================ +// Payment Connector Schema +// ============================================================================ + +export const PaymentConnectorSchema = z.object({ + name: PaymentConnectorNameSchema, + provider: PaymentProviderSchema.default('CoinbaseCDP'), + credentialName: z.string().min(1), +}); + +export type PaymentConnector = z.infer; + +// ============================================================================ +// Payment Manager Schema +// ============================================================================ + +export const PaymentManagerSchema = z + .object({ + name: PaymentManagerNameSchema, + authorizerType: z.enum(['AWS_IAM', 'CUSTOM_JWT']).default('AWS_IAM'), + authorizerConfiguration: z + .object({ + customJWTAuthorizer: z.object({ + discoveryUrl: z.string().url(), + allowedClients: z.array(z.string()).optional(), + allowedAudience: z.array(z.string()).optional(), + allowedScopes: z.array(z.string()).optional(), + }), + }) + .optional(), + pattern: PaymentPatternSchema.default('interceptor'), + connectors: z.array(PaymentConnectorSchema).default([]), + description: z.string().optional(), + autoPayment: z.boolean().optional(), + defaultSpendLimit: z.string().optional(), + paymentToolAllowlist: z.array(z.string()).optional(), + networkPreferences: z.array(z.string()).optional(), + }) + .superRefine((data, ctx) => { + if (data.authorizerType === 'CUSTOM_JWT' && !data.authorizerConfiguration?.customJWTAuthorizer) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'authorizerConfiguration with customJWTAuthorizer is required when authorizerType is CUSTOM_JWT', + path: ['authorizerConfiguration'], + }); + } + }); + +export type PaymentManager = z.infer; + +// ============================================================================ +// Payment Authorizer Type Schema (for CLI parsing) +// ============================================================================ + +export const PaymentAuthorizerTypeSchema = z.enum(['AWS_IAM', 'CUSTOM_JWT']); +export type PaymentAuthorizerType = z.infer; From 20847ba0a6613a453d835832aa1959b29d483459 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 2 Jun 2026 20:42:11 +0000 Subject: [PATCH 02/16] fix(payments): route vended CDK spec access through specAny The vended cdk/bin/cdk.ts compiles against the published @aws/agentcore-cdk schema type, which lags the CLI's AgentCoreProjectSpec (no payments/harnesses fields). Restore `const specAny = spec as any` and route payments/harnesses/ gateway field access through it, fixing TS2304 (specAny undefined) and TS2339 (payments missing) introduced while resolving the rebase conflict in this file. Regenerates the asset snapshot to match. --- .../assets.snapshot.test.ts.snap | 55 ++++++++++++------- src/assets/cdk/bin/cdk.ts | 55 ++++++++++++------- 2 files changed, 72 insertions(+), 38 deletions(-) diff --git a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap index b20c02b00..b9330ba2e 100644 --- a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap +++ b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap @@ -73,13 +73,19 @@ async function main() { const spec = await configIO.readProjectSpec(); const targets = await configIO.readAWSDeploymentTargets(); + // The vended CDK project compiles against the published @aws/agentcore-cdk + // schema type, which may lag the CLI's own AgentCoreProjectSpec (e.g. payments, + // harnesses, gateway fields). Cast once so those fields are reachable. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const specAny = spec as any; + // Extract MCP configuration from project spec. // Gateway fields are stored in agentcore.json but may not yet be on the - const mcpSpec = spec.agentCoreGateways?.length + const mcpSpec = specAny.agentCoreGateways?.length ? { - agentCoreGateways: spec.agentCoreGateways, - mcpRuntimeTools: spec.mcpRuntimeTools, - unassignedTargets: spec.unassignedTargets, + agentCoreGateways: specAny.agentCoreGateways, + mcpRuntimeTools: specAny.mcpRuntimeTools, + unassignedTargets: specAny.unassignedTargets, } : undefined; @@ -153,21 +159,32 @@ async function main() { // Payment credential provider ARNs live in the same credentials map as identity credentials const paymentCredentials = credentials; - const paymentSpec = spec.payments?.length - ? spec.payments.map(p => ({ - name: p.name, - description: p.description, - authorizerType: p.authorizerType, - authorizerConfiguration: p.authorizerConfiguration, - autoPayment: p.autoPayment, - paymentToolAllowlist: p.paymentToolAllowlist, - networkPreferences: p.networkPreferences, - connectors: p.connectors.map(c => ({ - name: c.name, - provider: c.provider, - credentialProviderArn: paymentCredentials?.[c.credentialName]?.credentialProviderArn ?? '', - })), - })) + const paymentSpec = specAny.payments?.length + ? specAny.payments.map( + (p: { + name: string; + description?: string; + authorizerType: 'AWS_IAM' | 'CUSTOM_JWT'; + authorizerConfiguration?: unknown; + autoPayment?: boolean; + paymentToolAllowlist?: string[]; + networkPreferences?: string[]; + connectors: { name: string; provider?: string; credentialName: string }[]; + }) => ({ + name: p.name, + description: p.description, + authorizerType: p.authorizerType, + authorizerConfiguration: p.authorizerConfiguration, + autoPayment: p.autoPayment, + paymentToolAllowlist: p.paymentToolAllowlist, + networkPreferences: p.networkPreferences, + connectors: p.connectors.map(c => ({ + name: c.name, + provider: c.provider, + credentialProviderArn: paymentCredentials?.[c.credentialName]?.credentialProviderArn ?? '', + })), + }) + ) : undefined; new AgentCoreStack(app, stackName, { diff --git a/src/assets/cdk/bin/cdk.ts b/src/assets/cdk/bin/cdk.ts index 41207ca96..7fa086991 100644 --- a/src/assets/cdk/bin/cdk.ts +++ b/src/assets/cdk/bin/cdk.ts @@ -28,13 +28,19 @@ async function main() { const spec = await configIO.readProjectSpec(); const targets = await configIO.readAWSDeploymentTargets(); + // The vended CDK project compiles against the published @aws/agentcore-cdk + // schema type, which may lag the CLI's own AgentCoreProjectSpec (e.g. payments, + // harnesses, gateway fields). Cast once so those fields are reachable. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const specAny = spec as any; + // Extract MCP configuration from project spec. // Gateway fields are stored in agentcore.json but may not yet be on the - const mcpSpec = spec.agentCoreGateways?.length + const mcpSpec = specAny.agentCoreGateways?.length ? { - agentCoreGateways: spec.agentCoreGateways, - mcpRuntimeTools: spec.mcpRuntimeTools, - unassignedTargets: spec.unassignedTargets, + agentCoreGateways: specAny.agentCoreGateways, + mcpRuntimeTools: specAny.mcpRuntimeTools, + unassignedTargets: specAny.unassignedTargets, } : undefined; @@ -108,21 +114,32 @@ async function main() { // Payment credential provider ARNs live in the same credentials map as identity credentials const paymentCredentials = credentials; - const paymentSpec = spec.payments?.length - ? spec.payments.map(p => ({ - name: p.name, - description: p.description, - authorizerType: p.authorizerType, - authorizerConfiguration: p.authorizerConfiguration, - autoPayment: p.autoPayment, - paymentToolAllowlist: p.paymentToolAllowlist, - networkPreferences: p.networkPreferences, - connectors: p.connectors.map(c => ({ - name: c.name, - provider: c.provider, - credentialProviderArn: paymentCredentials?.[c.credentialName]?.credentialProviderArn ?? '', - })), - })) + const paymentSpec = specAny.payments?.length + ? specAny.payments.map( + (p: { + name: string; + description?: string; + authorizerType: 'AWS_IAM' | 'CUSTOM_JWT'; + authorizerConfiguration?: unknown; + autoPayment?: boolean; + paymentToolAllowlist?: string[]; + networkPreferences?: string[]; + connectors: { name: string; provider?: string; credentialName: string }[]; + }) => ({ + name: p.name, + description: p.description, + authorizerType: p.authorizerType, + authorizerConfiguration: p.authorizerConfiguration, + autoPayment: p.autoPayment, + paymentToolAllowlist: p.paymentToolAllowlist, + networkPreferences: p.networkPreferences, + connectors: p.connectors.map(c => ({ + name: c.name, + provider: c.provider, + credentialProviderArn: paymentCredentials?.[c.credentialName]?.credentialProviderArn ?? '', + })), + }) + ) : undefined; new AgentCoreStack(app, stackName, { From a7f6b10710c88b998d9c14f1a4d60e5cdb0a8918 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 3 Jun 2026 17:21:47 +0000 Subject: [PATCH 03/16] ci(e2e): wire CoinbaseCDP creds into payment deploy test The payment E2E deploy test (payment-strands-bedrock.test.ts) gates on CDP_API_KEY_ID / CDP_API_KEY_SECRET / CDP_WALLET_SECRET but the workflow never supplied them, so it silently skipped in CI. - Map CDP_* env vars in the GA test step from the E2E secret's CDP_* JSON keys (surfaced as E2E_CDP_* by parse-json-secrets). - Add payment-strands-bedrock.test.ts to the GA run command and exclude it from change-detection GA_EXTRA to avoid double-running. Requires CDP_API_KEY_ID / CDP_API_KEY_SECRET / CDP_WALLET_SECRET added to the E2E Secrets Manager secret. Absent (e.g. forks) -> test self-skips via its hasCdpCreds gate. --- .github/workflows/e2e-tests.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 8abc57394..d44763efe 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -123,6 +123,7 @@ jobs: if [ -n "$HELPERS_CHANGED" ]; then GA_EXTRA=$(find e2e-tests -name '*.test.ts' \ | grep -v '^e2e-tests/strands-bedrock\.test\.ts$' \ + | grep -v '^e2e-tests/payment-strands-bedrock\.test\.ts$' \ | grep -v '^e2e-tests/harness-' \ | tr '\n' ' ') HARNESS_EXTRA=$(find e2e-tests -name 'harness-*.test.ts' \ @@ -131,6 +132,7 @@ jobs: else GA_EXTRA=$(git diff --name-only "$BASE_SHA"..HEAD -- 'e2e-tests/*.test.ts' \ | grep -v '^e2e-tests/strands-bedrock\.test\.ts$' \ + | grep -v '^e2e-tests/payment-strands-bedrock\.test\.ts$' \ | grep -v '^e2e-tests/harness-' \ | tr '\n' ' ') HARNESS_EXTRA=$(git diff --name-only "$BASE_SHA"..HEAD -- 'e2e-tests/harness-*.test.ts' \ @@ -153,7 +155,16 @@ jobs: E2E_S3_ACCESS_POINT_ARN: ${{ env.E2E_S3_ACCESS_POINT_ARN }} E2E_FILESYSTEM_SUBNET_ID: ${{ env.E2E_FILESYSTEM_SUBNET_ID }} E2E_FILESYSTEM_SECURITY_GROUP_ID: ${{ env.E2E_FILESYSTEM_SECURITY_GROUP_ID }} - run: npx vitest run --project e2e e2e-tests/strands-bedrock.test.ts ${{ steps.changed.outputs.ga_extra }} + # CoinbaseCDP testnet creds for payment-strands-bedrock.test.ts. Sourced from + # the same E2E secret (keys CDP_API_KEY_ID / CDP_API_KEY_SECRET / CDP_WALLET_SECRET), + # which parse-json-secrets surfaces as E2E_CDP_*; remapped here to the unprefixed + # names the test reads. Absent on forks -> test self-skips via its hasCdpCreds gate. + CDP_API_KEY_ID: ${{ env.E2E_CDP_API_KEY_ID }} + CDP_API_KEY_SECRET: ${{ env.E2E_CDP_API_KEY_SECRET }} + CDP_WALLET_SECRET: ${{ env.E2E_CDP_WALLET_SECRET }} + run: + npx vitest run --project e2e e2e-tests/strands-bedrock.test.ts e2e-tests/payment-strands-bedrock.test.ts ${{ + steps.changed.outputs.ga_extra }} - name: Install preview CLI globally run: npm install -g "$PREVIEW_TARBALL" From 6bdff979f40bc16b17f3bad18e3b4c90b8b1bd6f Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 8 Jun 2026 15:56:17 +0000 Subject: [PATCH 04/16] feat(payments): scope invoke payments to an end user via --payment-user-id Payment instruments and sessions are scoped per end user (wallet owner), but `agentcore invoke` never sent that identity to the agent: --user-id went only to the runtime/Identity header (dropped by the runtime), and the invoke body carried no user_id. The agent (main.py) already reads payload.get("user_id"), so it always fell back to "default-user" and looked up the wallet under the wrong identity ("Instrument not found"). - Add --payment-user-id; write it into the invoke body as user_id. Falls back to --user-id; when neither is set the agent's own "default-user" fallback applies (we never bake that value into the wire). --user-id stays the Identity-header axis. - Warn (stderr, non-fatal) when a payments-enabled invoke has no resolved payment identity, so spend is not silently commingled under "default-user". - Scope --auto-session's created session to the resolved payment identity so the session, instrument, and body user_id all align. - Interactive TUI: payment flags (without a prompt) now carry the payment context across the whole chat session instead of forcing CLI mode; header shows "Payments: active (wallet owner: )". --auto-session remains CLI-only. - Tests: buildInvokePayload backward-compat + snake_case wire shape; the no-identity warning matrix; --auto-session user scoping; payment-flag mode routing. - Docs: --payment-user-id, the two-user_id model, out-of-band instrument creation, and the one-time WalletHub delegated-signing consent step. No CDK or agent-template changes needed: scoping is data-plane (invoke body), and the template already reads payload.user_id. --- docs/commands.md | 41 ++-- docs/payments.md | 128 ++++++++++-- src/cli/aws/__tests__/agentcore.test.ts | 47 ++++- src/cli/aws/agentcore.ts | 17 +- .../invoke/__tests__/action-payments.test.ts | 192 ++++++++++++++++++ .../commands/invoke/__tests__/invoke.test.ts | 71 +++++++ src/cli/commands/invoke/action.ts | 28 ++- src/cli/commands/invoke/command.tsx | 20 +- src/cli/commands/invoke/types.ts | 7 + src/cli/tui/App.tsx | 6 + src/cli/tui/screens/invoke/InvokeScreen.tsx | 21 ++ src/cli/tui/screens/invoke/useInvokeFlow.ts | 33 ++- 12 files changed, 563 insertions(+), 48 deletions(-) create mode 100644 src/cli/commands/invoke/__tests__/action-payments.test.ts diff --git a/docs/commands.md b/docs/commands.md index 3105706ba..d91ce7f4b 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -897,26 +897,27 @@ agentcore invoke --exec "cat /etc/os-release" --json The prompt can come from four sources, resolved in this precedence order: `--prompt` > positional > `--prompt-file` > piped stdin. `--prompt-file` combined with piped stdin content returns a collision error — pick one. -| Flag | Description | -| ------------------------------ | ---------------------------------------------------------------- | -| `[prompt]` | Prompt text (positional argument) | -| `--prompt ` | Prompt text (flag, takes precedence over positional) | -| `--prompt-file ` | Read the prompt from a file (useful for long / structured input) | -| `--runtime ` | Specific runtime | -| `--target ` | Deployment target | -| `--session-id ` | Continue a specific session | -| `--user-id ` | User ID for runtime invocation (default: `default-user`) | -| `--stream` | Stream response in real-time | -| `--tool ` | MCP tool name (use with `call-tool` prompt) | -| `--input ` | MCP tool arguments as JSON (use with `--tool`) | -| `-H, --header ` | Custom header (`"Name: Value"`, repeatable) | -| `--bearer-token ` | Bearer token for CUSTOM_JWT auth | -| `--payment-instrument-id ` | Payment instrument ID for x402 payments | -| `--payment-session-id ` | Payment session ID for budget tracking | -| `--auto-session` | Auto-create/reuse a payment session for testing | -| `--exec` | Execute a shell command in the runtime container | -| `--timeout ` | Timeout in seconds for `--exec` commands | -| `--json` | JSON output | +| Flag | Description | +| ------------------------------ | --------------------------------------------------------------------- | +| `[prompt]` | Prompt text (positional argument) | +| `--prompt ` | Prompt text (flag, takes precedence over positional) | +| `--prompt-file ` | Read the prompt from a file (useful for long / structured input) | +| `--runtime ` | Specific runtime | +| `--target ` | Deployment target | +| `--session-id ` | Continue a specific session | +| `--user-id ` | User ID for runtime invocation (default: `default-user`) | +| `--stream` | Stream response in real-time | +| `--tool ` | MCP tool name (use with `call-tool` prompt) | +| `--input ` | MCP tool arguments as JSON (use with `--tool`) | +| `-H, --header ` | Custom header (`"Name: Value"`, repeatable) | +| `--bearer-token ` | Bearer token for CUSTOM_JWT auth | +| `--payment-instrument-id ` | Payment instrument ID for x402 payments | +| `--payment-session-id ` | Payment session ID for budget tracking | +| `--auto-session` | Auto-create/reuse a payment session for testing | +| `--payment-user-id ` | End-user (wallet owner) to scope payments to; defaults to `--user-id` | +| `--exec` | Execute a shell command in the runtime container | +| `--timeout ` | Timeout in seconds for `--exec` commands | +| `--json` | JSON output | Piped stdin is auto-detected: when no prompt is supplied and stdin is not a TTY, the prompt is read from stdin. diff --git a/docs/payments.md b/docs/payments.md index 9239d5eb0..181802b4c 100644 --- a/docs/payments.md +++ b/docs/payments.md @@ -30,12 +30,14 @@ agentcore add payment-connector \ # 4. Deploy (creates payment infrastructure on AWS) agentcore deploy -y -# 5. Invoke with auto-session (creates a test payment session) -agentcore invoke --auto-session --prompt "Use a paid tool" +# 5. Create + fund an instrument out-of-band (SDK), then invoke with auto-session +agentcore invoke --auto-session --payment-user-id alice --prompt "Use a paid tool" ``` > **Note**: `--auto-session` requires a successful deploy first because it reads from deployed state to locate the -> payment manager ARN and create a session. +> payment manager ARN and create a session. The CLI does not create payment instruments — create and fund one with the +> SDK (scoped to the same `--payment-user-id`) and grant WalletHub delegated signing before invoking. See +> [Invoking with Payment Context](#invoking-with-payment-context). ## How It Works @@ -232,33 +234,114 @@ After deploying, use `agentcore invoke` to test agents with payment capabilities ### Payment Flags -| Flag | Description | -| ------------------------------ | --------------------------------------------------------- | -| `--payment-instrument-id ` | Payment instrument ID (a funded wallet) for x402 payments | -| `--payment-session-id ` | Payment session ID for budget tracking | -| `--auto-session` | Auto-create or reuse a payment session for testing | +| Flag | Description | +| ------------------------------ | -------------------------------------------------------------------------------------------- | +| `--payment-instrument-id ` | Payment instrument ID (a funded wallet) for x402 payments | +| `--payment-session-id ` | Payment session ID for budget tracking | +| `--auto-session` | Auto-create or reuse a payment session for testing | +| `--payment-user-id ` | End-user identity (wallet owner) to scope the instrument, session, and budget to. See below. | + +### Payment identity: `--payment-user-id` + +Payment instruments and sessions are **scoped to an end user** (the wallet owner). `--payment-user-id` sets that +identity; the agent uses it to look up the right wallet and budget when settling a payment. It is written into the +invocation body as `user_id`. + +When `--payment-user-id` is omitted it falls back to `--user-id`. When neither is set, the agent scopes payments to +`default-user` — fine for single-user local testing, but **production should pass `--payment-user-id` per end user** so +that wallets and budgets are never shared across users. Invoking a payments-enabled project without an identity prints a +warning to that effect. + +> **Two different "user id"s.** `--payment-user-id` (the wallet owner, sent in the invocation body) is distinct from +> `--user-id` (the AgentCore Runtime/Identity header used for OAuth token scoping). They are independent: under +> CUSTOM_JWT auth the payment user is derived from the JWT `sub` claim and `--payment-user-id` is ignored. Set +> `--payment-user-id` for SigV4 (IAM) agents that pay on behalf of a specific end user. + +The instrument created out-of-band (below) and the `--payment-user-id` passed at invoke time **must be the same user** — +otherwise the agent looks up the wallet under the wrong identity and the payment fails with `Instrument not found`. ### Auto-Session Mode `--auto-session` creates a temporary payment session with the default spend limit, or reuses an existing one from the -current testing context. This is the simplest way to test payment flows without manually creating instruments and -sessions via the AWS API. +current testing context. This is the simplest way to test payment flows without manually creating a session via the AWS +API. The session is scoped to the resolved payment identity (`--payment-user-id`, else `--user-id`, else +`default-user`), so it aligns with the instrument and the body `user_id`. ```bash -agentcore invoke --auto-session --prompt "Search for paid research papers" +agentcore invoke --auto-session --payment-user-id alice --prompt "Search for paid research papers" ``` ### Explicit Payment Context -For production testing with specific instruments and sessions: +For testing with a specific instrument and session: ```bash agentcore invoke \ + --payment-user-id alice \ --payment-instrument-id payment-instrument-abc123 \ --payment-session-id payment-session-xyz789 \ --prompt "Process a payment for the weather API" ``` +### Interactive mode + +Passing payment flags **without** a prompt launches the interactive chat with the payment context held for the whole +session — every turn pays as that identity, against that instrument and session: + +```bash +agentcore invoke --payment-user-id alice --payment-instrument-id payment-instrument-abc123 +``` + +The interactive header shows `Payments: active (wallet owner: )` while a payment context is in effect. +(`--auto-session` is a non-interactive convenience and always runs in command mode.) + +### Creating an instrument (out-of-band) + +The CLI does not create payment instruments; create them with the AgentCore SDK or your application backend, scoped to +the end user you will invoke as: + +```python +from bedrock_agentcore.payments.manager import PaymentManager + +manager = PaymentManager(payment_manager_arn=MANAGER_ARN, region_name="us-east-1") +instrument = manager.create_payment_instrument( + payment_connector_id=CONNECTOR_ID, + payment_instrument_type="EMBEDDED_CRYPTO_WALLET", + payment_instrument_details={ + "embeddedCryptoWallet": { + "network": "ETHEREUM", + "linkedAccounts": [{"email": {"emailAddress": "alice@example.com"}}], + } + }, + user_id="alice", # MUST match the --payment-user-id you invoke with +) +# instrument["paymentInstrumentId"] -> pass as --payment-instrument-id +# instrument["paymentInstrumentDetails"]["embeddedCryptoWallet"]["walletAddress"] -> fund this address +# instrument["paymentInstrumentDetails"]["embeddedCryptoWallet"]["redirectUrl"] -> WalletHub consent (below) +``` + +Fund the returned `walletAddress` with testnet USDC ([Circle faucet](https://faucet.circle.com/), Base Sepolia) before +invoking. + +### Grant delegated signing (one-time, per end-user wallet) + +Before `ProcessPayment` can settle, the **end user who owns the wallet must grant Coinbase delegated signing** for it. +This is a one-time step per wallet, completed in the Coinbase **WalletHub** consent page: + +1. Send the end user the `redirectUrl` from the `create_payment_instrument` response. +2. The end user opens it and **signs in using the exact email passed in `linkedAccounts`** — _not_ the developer's + Coinbase account. Coinbase verifies the identity with a one-time code (OTP) sent to that email. +3. The end user clicks **Grant** to authorize delegated signing for the wallet. + +Until this grant is completed, `ProcessPayment` fails with: +`Delegated signing grant is not active for the end user wallet. Please redirect end user to the WalletHub to grant the permissions.` + +> **Common pitfall:** opening the WalletHub link while logged into your own (developer) Coinbase account shows **"no +> accounts found"** — the wallet is bound to the `linkedAccounts` email identity, not your CDP/developer account. Always +> authenticate as the linked end-user email (a fresh/incognito browser session avoids being carried in on a different +> Coinbase login). For local testing, use a `linkedAccounts` email you control (a plus-addressed alias such as +> `you+testuser@example.com` works — the OTP arrives in your real inbox), so you can complete the grant yourself. + For details on creating instruments and sessions, see [Create a payment instrument](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments-create-instrument.html). @@ -304,15 +387,18 @@ agentcore validate ## Troubleshooting -| Error | Cause | Fix | -| ------------------------------------- | ---------------------------------------- | -------------------------------------------------------------- | -| `.env.local not found` | No secrets file in project | Create `agentcore/.env.local` with credential vars | -| `Missing credentials for connector` | Env vars not set for a connector | Add the required `AGENTCORE_CREDENTIAL_*` vars to `.env.local` | -| `ServiceQuotaExceededException` | Account limit on payment managers | Request a quota increase via AWS Support | -| `No connectors for payment manager` | Manager has zero connectors | Add at least one connector before deploying | -| `PaymentCredentialProvider not found` | Orphaned reference after manual deletion | Re-run `agentcore deploy` to recreate | -| `Request timeout` | Network or service availability | Retry deploy; check internet connectivity | -| `Invalid authorizer type` | Typo in `--authorizer-type` flag | Use `AWS_IAM` or `CUSTOM_JWT` (case-sensitive) | +| Error | Cause | Fix | +| --------------------------------------- | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| `.env.local not found` | No secrets file in project | Create `agentcore/.env.local` with credential vars | +| `Missing credentials for connector` | Env vars not set for a connector | Add the required `AGENTCORE_CREDENTIAL_*` vars to `.env.local` | +| `ServiceQuotaExceededException` | Account limit on payment managers | Request a quota increase via AWS Support | +| `No connectors for payment manager` | Manager has zero connectors | Add at least one connector before deploying | +| `PaymentCredentialProvider not found` | Orphaned reference after manual deletion | Re-run `agentcore deploy` to recreate | +| `Request timeout` | Network or service availability | Retry deploy; check internet connectivity | +| `Invalid authorizer type` | Typo in `--authorizer-type` flag | Use `AWS_IAM` or `CUSTOM_JWT` (case-sensitive) | +| `Instrument not found` at invoke | `--payment-user-id` differs from the instrument's owner | Invoke with the same user the instrument was created under | +| `Delegated signing grant is not active` | End user hasn't granted WalletHub consent for the wallet | Open the instrument's `redirectUrl`, sign in as the linked email, click Grant (see above) | +| WalletHub shows `no accounts found` | Opened the consent link as the developer, not the wallet's linked email | Sign in as the `linkedAccounts` email (incognito); OTP goes to that inbox | For additional troubleshooting, see [Troubleshooting AgentCore payments](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments-troubleshooting.html). diff --git a/src/cli/aws/__tests__/agentcore.test.ts b/src/cli/aws/__tests__/agentcore.test.ts index f23ff5c83..c0b4653ee 100644 --- a/src/cli/aws/__tests__/agentcore.test.ts +++ b/src/cli/aws/__tests__/agentcore.test.ts @@ -1,6 +1,15 @@ -import { buildBearerInvokeHeaders, extractResult, parseA2AResponse, parseSSE, parseSSELine } from '../agentcore.js'; +import { + buildBearerInvokeHeaders, + buildInvokePayload, + extractResult, + parseA2AResponse, + parseSSE, + parseSSELine, +} from '../agentcore.js'; import { describe, expect, it } from 'vitest'; +const BASE = { region: 'us-east-1', runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/r' }; + describe('parseSSELine', () => { it('returns null content for non-data lines', () => { expect(parseSSELine('event: message')).toEqual({ content: null, error: null }); @@ -216,3 +225,39 @@ describe('buildBearerInvokeHeaders', () => { expect(Object.keys(headers)).toHaveLength(4); // Authorization, Content-Type, Accept, User-Id }); }); + +describe('buildInvokePayload', () => { + it('is byte-identical to the pre-payment wire format when no payment fields are set', () => { + // Backward-compat guard: every already-deployed agent depends on this shape. + expect(buildInvokePayload({ ...BASE, payload: 'hello world' })).toBe('{"prompt":"hello world"}'); + }); + + it('writes payment fields as snake_case keys the agent reads (never camelCase)', () => { + // The agent reads payload.user_id / payment_instrument_id / payment_session_id. + // A camelCase slip would serialize silently and the agent would ignore it. + const result = buildInvokePayload({ + ...BASE, + payload: 'test', + userId: 'runtime-user', // runtime/Identity header axis — must NOT reach the body + paymentUserId: 'wallet-owner', + paymentInstrumentId: 'instr-1', + paymentSessionId: 'sess-1', + }); + expect(JSON.parse(result)).toEqual({ + prompt: 'test', + user_id: 'wallet-owner', + payment_instrument_id: 'instr-1', + payment_session_id: 'sess-1', + }); + // Neither the camelCase option names nor the runtime userId leak onto the wire. + expect(result).not.toMatch(/userId|payment[A-Z]|runtimeArn|region/); + }); + + it('omits user_id when no payments identity is resolved (no baked-in default-user)', () => { + // We must NOT write user_id: "default-user" ourselves — the agent applies that + // fallback. Baking it into the body would defeat the no-user-id warning. + const result = buildInvokePayload({ ...BASE, payload: 'test', paymentInstrumentId: 'i' }); + expect(JSON.parse(result)).toEqual({ prompt: 'test', payment_instrument_id: 'i' }); + expect(result).not.toContain('user_id'); + }); +}); diff --git a/src/cli/aws/agentcore.ts b/src/cli/aws/agentcore.ts index d5fcf8f9a..ae7155d53 100644 --- a/src/cli/aws/agentcore.ts +++ b/src/cli/aws/agentcore.ts @@ -76,6 +76,14 @@ export interface InvokeAgentRuntimeOptions { paymentInstrumentId?: string; /** Payment session ID for budget tracking */ paymentSessionId?: string; + /** + * Payments end-user identity, written into the invoke body as `user_id`. + * The agent scopes the payment instrument/session/budget to this value + * (the wallet owner). Distinct from `userId`, which is the runtime/Identity + * header (X-Amzn-Bedrock-AgentCore-Runtime-User-Id) used for OAuth token + * scoping and is NOT visible to the agent's payment plugin. + */ + paymentUserId?: string; } export interface InvokeAgentRuntimeResult { @@ -159,8 +167,15 @@ export function extractResult(text: string): string { * Build the JSON payload body for an invoke request. * Includes payment context fields only when provided. */ -function buildInvokePayload(options: InvokeAgentRuntimeOptions): string { +export function buildInvokePayload(options: InvokeAgentRuntimeOptions): string { const body: Record = { prompt: options.payload }; + // The agent reads `payload.user_id` to scope the payment wallet/budget + // (main.py: payload.get("user_id") or context.user_id or "default-user"). + // Only set it when resolved; when omitted the agent applies its own + // "default-user" fallback, so we never bake that magic value into the wire. + if (options.paymentUserId) { + body.user_id = options.paymentUserId; + } if (options.paymentInstrumentId) { body.payment_instrument_id = options.paymentInstrumentId; } diff --git a/src/cli/commands/invoke/__tests__/action-payments.test.ts b/src/cli/commands/invoke/__tests__/action-payments.test.ts new file mode 100644 index 000000000..ca63938c4 --- /dev/null +++ b/src/cli/commands/invoke/__tests__/action-payments.test.ts @@ -0,0 +1,192 @@ +import type { InvokeContext } from '../action'; +import { handleInvoke } from '../action'; +import type { InvokeOptions } from '../types'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// --------------------------------------------------------------------------- +// Mock seam. +// +// The WARN footgun guard, the `resolvedPaymentUserId` resolution, and the +// auto-session user scoping all live in `handleInvoke` AFTER `resolveInvokeTarget` +// succeeds and require an HTTP agent. None of that is reachable through the +// `runCLI` integration harness without a real deployment (resolveInvokeTarget +// short-circuits on "no deployed targets" before the guard runs). So we mock the +// target resolver + the AWS calls and assert on the resolved-layer behavior. +// --------------------------------------------------------------------------- + +const mockResolveInvokeTarget = vi.fn(); +const mockGetOrCreatePaymentSession = vi.fn(); +const mockInvokeAgentRuntime = vi.fn(); +const mockInvokeAgentRuntimeStreaming = vi.fn(); + +// NOTE: vi.mock paths resolve relative to THIS test file (src/cli/commands/invoke/__tests__/), +// not relative to action.ts. action.ts's `../../aws` and `../../logging` therefore become +// `../../../aws` and `../../../logging` here, and `./resolve` becomes `../resolve`. +vi.mock('../resolve', () => ({ + resolveInvokeTarget: (...args: unknown[]) => mockResolveInvokeTarget(...args), +})); + +vi.mock('../../../feature-flags', () => ({ + isPreviewEnabled: () => false, +})); + +// Mock the entire aws barrel. Re-export the real DEFAULT_RUNTIME_USER_ID constant +// so the production fallback value stays in sync with the source of truth. +vi.mock('../../../aws', () => ({ + DEFAULT_RUNTIME_USER_ID: 'default-user', + getOrCreatePaymentSession: (...args: unknown[]) => mockGetOrCreatePaymentSession(...args), + invokeAgentRuntime: (...args: unknown[]) => mockInvokeAgentRuntime(...args), + invokeAgentRuntimeStreaming: (...args: unknown[]) => mockInvokeAgentRuntimeStreaming(...args), + // Unused-by-these-tests members the module also exports; stubbed so importing + // the barrel does not blow up. + buildAguiRunInput: vi.fn(), + executeBashCommand: vi.fn(), + invokeA2ARuntime: vi.fn(), + invokeAguiRuntime: vi.fn(), + mcpCallTool: vi.fn(), + mcpInitSession: vi.fn(), + mcpListTools: vi.fn(), +})); + +// InvokeLogger touches the filesystem on construction; replace with a no-op. +vi.mock('../../../logging', () => ({ + InvokeLogger: class { + logFilePath = '/tmp/fake.log'; + logPrompt = vi.fn(); + logResponse = vi.fn(); + logError = vi.fn(); + logInfo = vi.fn(); + }, +})); + +const HTTP_AGENT = { name: 'TestAgent', protocol: 'HTTP' as const }; + +/** Build a successful resolveInvokeTarget result with an HTTP agent. */ +function resolvedOk(overrides: Record = {}) { + return { + success: true as const, + agentSpec: HTTP_AGENT, + targetName: 'default', + targetConfig: { name: 'default', region: 'us-east-1' }, + region: 'us-east-1', + runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/r', + baggage: undefined, + ...overrides, + }; +} + +/** Minimal InvokeContext. `project.payments` controls the footgun guard. */ +function makeContext(payments: { name: string; defaultSpendLimit?: number }[] = []): InvokeContext { + return { + project: { name: 'p', runtimes: [HTTP_AGENT], payments } as never, + deployedState: { + targets: { + default: { + resources: { + payments: { pm1: { managerArn: 'arn:aws:bedrock-agentcore:us-east-1:123:payment-manager/pm1' } }, + }, + }, + }, + } as never, + awsTargets: [{ name: 'default', region: 'us-east-1' }] as never, + }; +} + +async function invoke(options: InvokeOptions, ctx: InvokeContext = makeContext()) { + return handleInvoke(ctx, { prompt: 'hi', ...options }); +} + +describe('handleInvoke — payments', () => { + let stderrSpy: ReturnType; + + beforeEach(() => { + mockResolveInvokeTarget.mockResolvedValue(resolvedOk()); + mockInvokeAgentRuntime.mockResolvedValue({ content: 'ok', sessionId: 's' }); + mockGetOrCreatePaymentSession.mockResolvedValue('sess-new'); + stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + }); + + afterEach(() => { + vi.clearAllMocks(); + stderrSpy.mockRestore(); + }); + + function stderrText(): string { + return stderrSpy.mock.calls.map((c: unknown[]) => String(c[0])).join(''); + } + + // ------------------------------------------------------------------------- + // Footgun WARN: stderr-only, never hard-fails, never pollutes JSON stdout. + // (This block also exercises resolvedPaymentUserId = paymentUserId ?? userId: + // whether the warning fires is driven entirely by that resolution.) + // ------------------------------------------------------------------------- + describe('footgun WARN', () => { + it('warns (stderr) when project has payments but no resolved payments identity', async () => { + const result = await invoke({}, makeContext([{ name: 'pm1' }])); + expect(stderrText()).toContain('no --payment-user-id'); + expect(stderrText()).toContain('default-user'); + // Never hard-fails: invoke still succeeds. + expect(result.success).toBe(true); + }); + + it('warns when a payment flag (--payment-instrument-id) is used without an identity', async () => { + await invoke({ paymentInstrumentId: 'pi-1' }, makeContext([])); + expect(stderrText()).toContain('no --payment-user-id'); + }); + + it('warns when --auto-session is used without an identity', async () => { + await invoke({ autoSession: true }, makeContext([])); + expect(stderrText()).toContain('no --payment-user-id'); + }); + + it('does NOT warn when a payments identity is resolved, even with payments enabled', async () => { + await invoke({ paymentUserId: 'alice' }, makeContext([{ name: 'pm1' }])); + expect(stderrText()).not.toContain('no --payment-user-id'); + }); + + it('does NOT warn for a non-payments invoke with no payment flags', async () => { + await invoke({}, makeContext([])); + expect(stderrText()).not.toContain('no --payment-user-id'); + }); + }); + + // ------------------------------------------------------------------------- + // Auto-session user scoping: getOrCreatePaymentSession is scoped to the SAME + // identity the agent pays as (resolvedPaymentUserId ?? DEFAULT_RUNTIME_USER_ID). + // ------------------------------------------------------------------------- + describe('--auto-session user scoping', () => { + it('scopes the session to --payment-user-id when set', async () => { + await invoke({ autoSession: true, paymentUserId: 'alice' }, makeContext([{ name: 'pm1' }])); + expect(mockGetOrCreatePaymentSession).toHaveBeenCalledWith(expect.objectContaining({ userId: 'alice' })); + }); + + it('scopes the session to --user-id when --payment-user-id is omitted', async () => { + await invoke({ autoSession: true, userId: 'bob' }, makeContext([{ name: 'pm1' }])); + expect(mockGetOrCreatePaymentSession).toHaveBeenCalledWith(expect.objectContaining({ userId: 'bob' })); + }); + + it('falls back to DEFAULT_RUNTIME_USER_ID when neither identity is set', async () => { + await invoke({ autoSession: true }, makeContext([{ name: 'pm1' }])); + expect(mockGetOrCreatePaymentSession).toHaveBeenCalledWith(expect.objectContaining({ userId: 'default-user' })); + }); + + it('errors (not call getOrCreatePaymentSession) when --auto-session and --payment-session-id collide', async () => { + const result = await invoke({ autoSession: true, paymentSessionId: 's1' }); + expect(result.success).toBe(false); + if (!result.success) expect(result.error.message).toContain('mutually exclusive'); + expect(mockGetOrCreatePaymentSession).not.toHaveBeenCalled(); + }); + }); + + // ------------------------------------------------------------------------- + // Protocol guard: payment FLAGS rejected for non-HTTP agents. + // ------------------------------------------------------------------------- + describe('protocol guard', () => { + it('rejects payment flags for non-HTTP agents', async () => { + mockResolveInvokeTarget.mockResolvedValue(resolvedOk({ agentSpec: { name: 'McpAgent', protocol: 'MCP' } })); + const result = await invoke({ paymentInstrumentId: 'pi-1' }); + expect(result.success).toBe(false); + if (!result.success) expect(result.error.message).toContain('only supported for HTTP protocol'); + }); + }); +}); diff --git a/src/cli/commands/invoke/__tests__/invoke.test.ts b/src/cli/commands/invoke/__tests__/invoke.test.ts index 4bd9495e9..72e564bc6 100644 --- a/src/cli/commands/invoke/__tests__/invoke.test.ts +++ b/src/cli/commands/invoke/__tests__/invoke.test.ts @@ -177,4 +177,75 @@ describe('invoke command', () => { }); }); }); + + // -------------------------------------------------------------------------- + // Mode routing for payments flags. + // + // The spawned CLI has no TTY (cli-runner uses stdio: ['ignore','pipe','pipe']). + // That gives us two distinguishable observable signatures: + // - CLI mode (forced) -> reaches the action/validation layer; with --json + // it prints structured JSON to stdout. + // - TUI mode (routed) -> hits requireTTY(), printing the plain-text + // "requires an interactive terminal" error (NOT JSON). + // + // This lets us assert the routing change without an interactive harness. + // -------------------------------------------------------------------------- + describe('payments mode routing', () => { + it('--auto-session still forces CLI mode (reaches action layer, not the TUI guard)', async () => { + // With a prompt to bypass the "prompt required" check, --auto-session must + // reach the action layer. The mutual-exclusion check there is action-layer + // proof that we did NOT route to the interactive TUI. + const result = await runCLI( + ['invoke', 'hi', '--auto-session', '--payment-session-id', 's1', '--json'], + projectDir, + { env: telemetry.env } + ); + expect(result.exitCode).toBe(1); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(false); + expect( + json.error.includes('mutually exclusive'), + `Expected action-layer mutual-exclusion error, got: ${json.error}` + ).toBeTruthy(); + expect(result.stderr).not.toContain('requires an interactive terminal'); + }); + + it('--auto-session without a prompt forces CLI mode (JSON error, not TUI guard)', async () => { + const result = await runCLI(['invoke', '--auto-session', '--json'], projectDir, { env: telemetry.env }); + expect(result.exitCode).toBe(1); + // Forced into CLI/JSON mode: stdout is structured JSON, NOT the TUI guard text. + const json = JSON.parse(result.stdout); + expect(json.success).toBe(false); + expect(result.stderr).not.toContain('requires an interactive terminal'); + }); + + it('a payment flag alone (no prompt/json) routes to the interactive TUI', async () => { + // NEW behavior: explicit payment flags no longer force CLI mode on their own, + // so this routes to the TUI -> requireTTY() fires (the spawned process has no + // TTY). All three payment flags share this one mode-decision branch. + const result = await runCLI(['invoke', '--payment-instrument-id', 'pi-1'], projectDir, { env: telemetry.env }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('requires an interactive terminal'); + // It did NOT take the CLI/JSON path. + expect(result.stdout).not.toContain('"success"'); + }); + + it('regression: --session-id alone (no prompt/json) still routes to the TUI', async () => { + const result = await runCLI(['invoke', '--session-id', 's1'], projectDir, { env: telemetry.env }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('requires an interactive terminal'); + }); + + it('a payment flag WITH --json is forced into CLI mode (does not route to TUI)', async () => { + // --json is in the CLI-forcing condition, so even a payment flag + --json + // stays on the CLI path and emits structured JSON. + const result = await runCLI(['invoke', 'hi', '--payment-instrument-id', 'pi-1', '--json'], projectDir, { + env: telemetry.env, + }); + expect(result.exitCode).toBe(1); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(false); + expect(result.stderr).not.toContain('requires an interactive terminal'); + }); + }); }); diff --git a/src/cli/commands/invoke/action.ts b/src/cli/commands/invoke/action.ts index 1bebf2a50..9c8584ca1 100644 --- a/src/cli/commands/invoke/action.ts +++ b/src/cli/commands/invoke/action.ts @@ -143,6 +143,28 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption }; } + // Resolve the payments end-user identity (wallet owner). Prefer the explicit + // --payment-user-id; fall back to the runtime --user-id. When neither is set, + // leave it undefined so the agent applies its own "default-user" fallback and + // we warn below — we never silently scope a real wallet to "default-user". + const resolvedPaymentUserId = options.paymentUserId ?? options.userId; + options = { ...options, paymentUserId: resolvedPaymentUserId }; + + // Footgun guard: a payments-enabled project invoked without an explicit + // payments identity will scope the wallet/budget to "default-user" on the + // agent side, commingling spend across users. Warn loudly (test-only), never + // hard-fail (backward-compatible). + const usingPaymentContext = + Boolean(options.paymentInstrumentId) || Boolean(options.paymentSessionId) || Boolean(options.autoSession); + const projectHasPayments = (project.payments?.length ?? 0) > 0; + if ((usingPaymentContext || projectHasPayments) && !resolvedPaymentUserId) { + process.stderr.write( + `${ANSI.yellow}Warning: no --payment-user-id (or --user-id) provided. Payments will be scoped to ` + + `"${DEFAULT_RUNTIME_USER_ID}" on the agent. This is intended for single-user testing only; ` + + `production should set --payment-user-id per end user so wallets and budgets are not shared.${ANSI.reset}\n` + ); + } + // Auto-session: get or create a payment session when --auto-session is set if (options.autoSession && !options.paymentSessionId) { const targetState = deployedState.targets[selectedTargetName]; @@ -158,8 +180,10 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption const paymentSpec = project.payments?.find(p => p.name === Object.keys(payments!)[0]); const sessionId = await getOrCreatePaymentSession({ region: targetConfig.region, + // Scope the session to the SAME identity the agent will pay as, so the + // session, instrument, and payload user_id all align. + userId: resolvedPaymentUserId ?? DEFAULT_RUNTIME_USER_ID, managerArn: firstManager.managerArn, - userId: options.userId ?? DEFAULT_RUNTIME_USER_ID, defaultSpendLimit: paymentSpec?.defaultSpendLimit, }); options = { ...options, paymentSessionId: sessionId }; @@ -502,6 +526,7 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption baggage, paymentInstrumentId: options.paymentInstrumentId, paymentSessionId: options.paymentSessionId, + paymentUserId: options.paymentUserId, }); for await (const chunk of result.stream) { @@ -538,6 +563,7 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption baggage, paymentInstrumentId: options.paymentInstrumentId, paymentSessionId: options.paymentSessionId, + paymentUserId: options.paymentUserId, }); logger.logResponse(response.content); diff --git a/src/cli/commands/invoke/command.tsx b/src/cli/commands/invoke/command.tsx index fd634734a..857781b9d 100644 --- a/src/cli/commands/invoke/command.tsx +++ b/src/cli/commands/invoke/command.tsx @@ -160,7 +160,11 @@ export const registerInvoke = (program: Command) => { .option('--bearer-token ', 'Bearer token for CUSTOM_JWT auth (bypasses SigV4) [non-interactive]') .option('--payment-instrument-id ', 'Payment instrument ID for x402 payments [non-interactive]') .option('--payment-session-id ', 'Payment session ID for budget tracking [non-interactive]') - .option('--auto-session', 'Auto-create/reuse a payment session for testing [non-interactive]'); + .option('--auto-session', 'Auto-create/reuse a payment session for testing [non-interactive]') + .option( + '--payment-user-id ', + 'End-user identity (wallet owner) for payments; scopes the instrument/session/budget. Defaults to --user-id when omitted.' + ); if (isPreviewEnabled()) { invokeCmd @@ -233,6 +237,7 @@ export const registerInvoke = (program: Command) => { paymentInstrumentId?: string; paymentSessionId?: string; autoSession?: boolean; + paymentUserId?: string; } ) => { try { @@ -277,8 +282,11 @@ export const registerInvoke = (program: Command) => { cliOptions.harness || cliOptions.harnessArn || cliOptions.verbose || - cliOptions.paymentInstrumentId || - cliOptions.paymentSessionId || + // --auto-session is a CLI-only convenience (it mints a session via the + // control API); it forces non-interactive mode. The explicit payment + // params (--payment-instrument-id / --payment-session-id / + // --payment-user-id) are carried into interactive mode instead, so they + // do NOT force CLI mode on their own. cliOptions.autoSession ) { const result = await withCommandRunTelemetry( @@ -337,6 +345,7 @@ export const registerInvoke = (program: Command) => { paymentInstrumentId: cliOptions.paymentInstrumentId, paymentSessionId: cliOptions.paymentSessionId, autoSession: cliOptions.autoSession, + paymentUserId: cliOptions.paymentUserId, }; return handleInvokeCLI(options, invokeContext); @@ -365,6 +374,11 @@ export const registerInvoke = (program: Command) => { userId: cliOptions.userId, headers, bearerToken: cliOptions.bearerToken, + paymentInstrumentId: cliOptions.paymentInstrumentId, + paymentSessionId: cliOptions.paymentSessionId, + // Default the payments wallet-owner identity to --user-id when + // --payment-user-id is omitted (same fallback as the command path). + paymentUserId: cliOptions.paymentUserId ?? cliOptions.userId, }, enterAltScreen: false, actionOnBack: 'exit', diff --git a/src/cli/commands/invoke/types.ts b/src/cli/commands/invoke/types.ts index dca5ca540..d06364bfb 100644 --- a/src/cli/commands/invoke/types.ts +++ b/src/cli/commands/invoke/types.ts @@ -57,6 +57,13 @@ export interface InvokeOptions { paymentSessionId?: string; /** Auto-create/reuse a payment session for testing (runs with developer ManagementRole credentials) */ autoSession?: boolean; + /** + * Payments end-user identity (wallet owner). Written into the invoke body as + * `user_id` so the agent scopes the payment instrument/session/budget to it. + * Falls back to `userId` when omitted. Distinct from `userId`, which is the + * runtime/Identity header and is not used for payment scoping. + */ + paymentUserId?: string; } export type InvokeResult = Result & { diff --git a/src/cli/tui/App.tsx b/src/cli/tui/App.tsx index 57ce52958..20eadde7b 100644 --- a/src/cli/tui/App.tsx +++ b/src/cli/tui/App.tsx @@ -42,6 +42,9 @@ type Route = headers?: Record; bearerToken?: string; isResume?: boolean; + paymentInstrumentId?: string; + paymentSessionId?: string; + paymentUserId?: string; } | { name: 'logs' } | { name: 'create' } @@ -239,6 +242,9 @@ function AppContent({ }); exit(); }} + initialPaymentInstrumentId={route.paymentInstrumentId} + initialPaymentSessionId={route.paymentSessionId} + initialPaymentUserId={route.paymentUserId} /> ); } diff --git a/src/cli/tui/screens/invoke/InvokeScreen.tsx b/src/cli/tui/screens/invoke/InvokeScreen.tsx index e6e843a80..5d1a0a70c 100644 --- a/src/cli/tui/screens/invoke/InvokeScreen.tsx +++ b/src/cli/tui/screens/invoke/InvokeScreen.tsx @@ -24,6 +24,12 @@ interface InvokeScreenProps { isResume?: boolean; /** Pre-select a harness by name, skipping the agent selection screen (preview) */ initialHarnessName?: string; + /** Payment instrument ID (wallet) forwarded on every turn when invoking with payments */ + initialPaymentInstrumentId?: string; + /** Payment session ID (budget) forwarded on every turn when invoking with payments */ + initialPaymentSessionId?: string; + /** Payments end-user identity (wallet owner) forwarded as body user_id on every turn */ + initialPaymentUserId?: string; } type Mode = 'select-agent' | 'chat' | 'input' | 'token-input'; @@ -153,6 +159,9 @@ export function InvokeScreen({ onExec, isResume, initialHarnessName, + initialPaymentInstrumentId, + initialPaymentSessionId, + initialPaymentUserId, }: InvokeScreenProps) { const preview = isPreviewEnabled(); const { @@ -167,6 +176,8 @@ export function InvokeScreen({ bearerToken, tokenFetchState, mcpToolsFetched, + paymentsActive, + paymentUserId, selectAgent, setBearerToken, fetchBearerToken, @@ -181,6 +192,9 @@ export function InvokeScreen({ initialBearerToken, isResume, initialHarnessName, + initialPaymentInstrumentId, + initialPaymentSessionId, + initialPaymentUserId, }); const [mode, setMode] = useState(initialHarnessName ? 'input' : 'select-agent'); const [isExecInput, setIsExecInput] = useState(false); @@ -507,6 +521,13 @@ export function InvokeScreen({ {userId} )} + {mode !== 'select-agent' && paymentsActive && ( + + Payments: + active + (wallet owner: {paymentUserId ?? 'default-user'}) + + )} {mode !== 'select-agent' && isCustomJwt && ( Auth: diff --git a/src/cli/tui/screens/invoke/useInvokeFlow.ts b/src/cli/tui/screens/invoke/useInvokeFlow.ts index 0a690aef0..d3355f6e9 100644 --- a/src/cli/tui/screens/invoke/useInvokeFlow.ts +++ b/src/cli/tui/screens/invoke/useInvokeFlow.ts @@ -76,6 +76,12 @@ export interface InvokeFlowOptions { isResume?: boolean; /** Pre-select a harness by name, skipping the agent selection screen (preview) */ initialHarnessName?: string; + /** Payment instrument ID (wallet) forwarded on every invocation when payments are used */ + initialPaymentInstrumentId?: string; + /** Payment session ID (budget) forwarded on every invocation when payments are used */ + initialPaymentSessionId?: string; + /** Payments end-user identity (wallet owner) forwarded as the body user_id on every invocation */ + initialPaymentUserId?: string; } export type TokenFetchState = 'idle' | 'fetching' | 'fetched' | 'error'; @@ -95,6 +101,10 @@ export interface InvokeFlowState { tokenExpiresIn: number | undefined; mcpTools: McpToolDef[]; mcpToolsFetched: boolean; + /** True when a payment instrument/session/user identity is in effect for this session */ + paymentsActive: boolean; + /** The payments end-user identity in effect (wallet owner), if any */ + paymentUserId?: string; selectAgent: (index: number) => void; setUserId: (id: string) => void; setBearerToken: (token: string) => void; @@ -106,7 +116,20 @@ export interface InvokeFlowState { } export function useInvokeFlow(options: InvokeFlowOptions = {}): InvokeFlowState { - const { initialSessionId, initialUserId, headers, initialBearerToken, isResume, initialHarnessName } = options; + const { + initialSessionId, + initialUserId, + headers, + initialBearerToken, + isResume, + initialHarnessName, + initialPaymentInstrumentId, + initialPaymentSessionId, + initialPaymentUserId, + } = options; + // Payment context is established once at session start and reused on every turn. + const paymentsActive = + Boolean(initialPaymentInstrumentId) || Boolean(initialPaymentSessionId) || Boolean(initialPaymentUserId); const [phase, setPhase] = useState<'loading' | 'ready' | 'invoking' | 'error'>('loading'); const [config, setConfig] = useState(null); const [selectedAgent, setSelectedAgent] = useState(0); @@ -729,6 +752,9 @@ export function useInvokeFlow(options: InvokeFlowOptions = {}): InvokeFlowState headers, bearerToken: bearerToken || undefined, baggage: agent.baggage, + paymentInstrumentId: initialPaymentInstrumentId, + paymentSessionId: initialPaymentSessionId, + paymentUserId: initialPaymentUserId, }); if (result.sessionId) { @@ -778,6 +804,9 @@ export function useInvokeFlow(options: InvokeFlowOptions = {}): InvokeFlowState fetchMcpTools, getMcpInvokeOptions, streamHarnessInvoke, + initialPaymentInstrumentId, + initialPaymentSessionId, + initialPaymentUserId, ] ); @@ -910,6 +939,8 @@ export function useInvokeFlow(options: InvokeFlowOptions = {}): InvokeFlowState tokenExpiresIn, mcpTools, mcpToolsFetched, + paymentsActive, + paymentUserId: initialPaymentUserId, selectAgent: setSelectedAgent, setUserId, setBearerToken, From 4c4d03c4ba4d82a886b76fc5a982c91f5bf7871b Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 8 Jun 2026 17:28:38 +0000 Subject: [PATCH 05/16] docs(payments): document delegated-signing setup and verified end-to-end settlement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refine the payments doc against the CDP delegated-signing docs and a verified on-chain settlement (Base Sepolia), so the WalletHub/grant flow is accurate: - Grant delegated signing now describes BOTH required layers: the project-level CDP "Delegated Signing" toggle (developer, once) AND the per-wallet WalletHub grant (end user, once). Hedge the OTP wording (CDP's primary, not only, method) and note WalletHub fronts CDP's createDelegation — there is no API to grant. - Add "End-to-end: get a transaction through" — the full create -> deploy -> create+fund instrument -> grant -> invoke recipe, for both command and interactive modes. - Explain the incognito requirement (avoid carrying a developer Coinbase session) and document transient on-chain settlement failures (retry succeeds; funds not debited on failure), with matching troubleshooting rows. --- docs/payments.md | 128 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 104 insertions(+), 24 deletions(-) diff --git a/docs/payments.md b/docs/payments.md index 181802b4c..4390f07b9 100644 --- a/docs/payments.md +++ b/docs/payments.md @@ -326,25 +326,104 @@ invoking. ### Grant delegated signing (one-time, per end-user wallet) Before `ProcessPayment` can settle, the **end user who owns the wallet must grant Coinbase delegated signing** for it. -This is a one-time step per wallet, completed in the Coinbase **WalletHub** consent page: +There are two layers, and **both** are required: -1. Send the end user the `redirectUrl` from the `create_payment_instrument` response. -2. The end user opens it and **signs in using the exact email passed in `linkedAccounts`** — _not_ the developer's - Coinbase account. Coinbase verifies the identity with a one-time code (OTP) sent to that email. -3. The end user clicks **Grant** to authorize delegated signing for the wallet. +**1. Project-level toggle (developer, once per CDP project).** In the Coinbase CDP dashboard, enable **Non-custodial +Wallets → Security → Delegated Signing** ("enable users to give your app permission to transact on their behalf"). This +authorizes your app to _request_ delegation at all. It's normally already enabled on the CDP project behind your +connector credentials; if you bring your own CDP credentials, turn it on or the per-wallet grant below will fail. + +**2. Per-wallet grant (end user, once per wallet).** Completed in the Coinbase **WalletHub** consent page that AgentCore +returns as the instrument's `redirectUrl`: -Until this grant is completed, `ProcessPayment` fails with: +1. Send the end user the `redirectUrl` from the `create_payment_instrument` response. +2. The end user opens it and **signs in as the exact email passed in `linkedAccounts`** — _not_ the developer's + Coinbase/CDP account. Coinbase verifies the identity (typically with a one-time code emailed to that address, valid + ~10 minutes). +3. The end user clicks **Grant**. WalletHub shows the granted permission and an expiry date (delegation is time-bound; + it persists until it expires or is revoked). + +Under the hood, WalletHub performs Coinbase CDP's `createDelegation` for the wallet — AgentCore hosts it as a redirect +page so you don't have to build the consent frontend. There is **no API to grant delegated signing**; the end-user +WalletHub consent is the only activation path (by Coinbase design), and the only way to detect it is to attempt a +payment. + +Until the grant is active, `ProcessPayment` fails with: `Delegated signing grant is not active for the end user wallet. Please redirect end user to the WalletHub to grant the permissions.` -> **Common pitfall:** opening the WalletHub link while logged into your own (developer) Coinbase account shows **"no -> accounts found"** — the wallet is bound to the `linkedAccounts` email identity, not your CDP/developer account. Always -> authenticate as the linked end-user email (a fresh/incognito browser session avoids being carried in on a different -> Coinbase login). For local testing, use a `linkedAccounts` email you control (a plus-addressed alias such as -> `you+testuser@example.com` works — the OTP arrives in your real inbox), so you can complete the grant yourself. +> **Common pitfall — "no accounts found":** opening the WalletHub link while signed into your own (developer) Coinbase +> account shows **"no accounts found"**, because the wallet is bound to the `linkedAccounts` email identity, not your +> developer account. Always authenticate as the linked end-user email. Open the link in a **fresh/incognito browser +> window** so an existing Coinbase session isn't silently used. For local testing, use a `linkedAccounts` email you +> control — a plus-addressed alias such as `you+testuser@example.com` works because the OTP is delivered to your real +> inbox, letting you complete the end-user grant yourself. -For details on creating instruments and sessions, see +For details on the underlying primitive, see +[CDP delegated signing](https://docs.cdp.coinbase.com/wallets/using-wallets/delegated-signing) and [Create a payment instrument](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments-create-instrument.html). +### End-to-end: get a transaction through + +The complete path from a fresh project to a settled on-chain payment. Steps 1–2 and 6 are CLI; steps 3–5 are out-of-band +(SDK + Coinbase) because instrument creation, funding, and the wallet grant are end-user actions. + +```bash +# 1. Create a project and add the payment manager + connector +agentcore create --name MyProject --defaults && cd MyProject +agentcore add payment-manager --name MyManager --pattern interceptor +agentcore add payment-connector --manager MyManager --name MyCDPConnector --provider CoinbaseCDP \ + --api-key-id "$CDP_API_KEY_ID" --api-key-secret "$CDP_API_KEY_SECRET" --wallet-secret "$CDP_WALLET_SECRET" + +# 2. Deploy (creates the payment manager, connector, credential provider, and IAM roles) +agentcore deploy -y +``` + +```python +# 3. Create a payment instrument for the end user you'll invoke as (SDK / app backend). +# Use the manager ARN + connector id from `agentcore status --type payment`. +from bedrock_agentcore.payments.manager import PaymentManager +manager = PaymentManager(payment_manager_arn=MANAGER_ARN, region_name="us-east-1") +inst = manager.create_payment_instrument( + payment_connector_id=CONNECTOR_ID, + payment_instrument_type="EMBEDDED_CRYPTO_WALLET", + payment_instrument_details={"embeddedCryptoWallet": { + "network": "ETHEREUM", + "linkedAccounts": [{"email": {"emailAddress": "alice@example.com"}}], + }}, + user_id="alice", +) +print(inst["paymentInstrumentId"]) # -> --payment-instrument-id +print(inst["paymentInstrumentDetails"]["embeddedCryptoWallet"]["walletAddress"]) # -> fund this +print(inst["paymentInstrumentDetails"]["embeddedCryptoWallet"]["redirectUrl"]) # -> WalletHub grant +``` + +```text +# 4. Fund the walletAddress with Base Sepolia testnet USDC: https://faucet.circle.com/ +# 5. Grant delegated signing: open the redirectUrl (incognito), sign in as alice@example.com, click Grant. +# (See "Grant delegated signing" above. Until this is done, payments fail with +# "Delegated signing grant is not active".) +``` + +```bash +# 6. Invoke — the agent makes the paid request, the plugin settles the 402, and retries: +agentcore invoke --payment-user-id alice --payment-instrument-id --auto-session \ + --prompt "Fetch and return the result" +# On success the agent returns the paid content; the wallet's USDC balance drops by the charge. +``` + +Interactive equivalent (payment context held across the whole chat session; needs an explicit `--payment-session-id` +since `--auto-session` is command-only): + +```bash +agentcore invoke --payment-user-id alice \ + --payment-instrument-id --payment-session-id # no prompt -> interactive chat +``` + +> **Transient settlement failures.** x402 settlement is an on-chain operation; an individual attempt can fail with +> `invalid_exact_evm_transaction_failed` / "Settlement failed" (e.g. two payments fired from the same wallet +> back-to-back collide on transaction timing). This is not a configuration error — **retry the request** and it +> typically settles. The funds are not debited on a failed attempt. + ## Status and Removal ### Checking Status @@ -387,18 +466,19 @@ agentcore validate ## Troubleshooting -| Error | Cause | Fix | -| --------------------------------------- | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | -| `.env.local not found` | No secrets file in project | Create `agentcore/.env.local` with credential vars | -| `Missing credentials for connector` | Env vars not set for a connector | Add the required `AGENTCORE_CREDENTIAL_*` vars to `.env.local` | -| `ServiceQuotaExceededException` | Account limit on payment managers | Request a quota increase via AWS Support | -| `No connectors for payment manager` | Manager has zero connectors | Add at least one connector before deploying | -| `PaymentCredentialProvider not found` | Orphaned reference after manual deletion | Re-run `agentcore deploy` to recreate | -| `Request timeout` | Network or service availability | Retry deploy; check internet connectivity | -| `Invalid authorizer type` | Typo in `--authorizer-type` flag | Use `AWS_IAM` or `CUSTOM_JWT` (case-sensitive) | -| `Instrument not found` at invoke | `--payment-user-id` differs from the instrument's owner | Invoke with the same user the instrument was created under | -| `Delegated signing grant is not active` | End user hasn't granted WalletHub consent for the wallet | Open the instrument's `redirectUrl`, sign in as the linked email, click Grant (see above) | -| WalletHub shows `no accounts found` | Opened the consent link as the developer, not the wallet's linked email | Sign in as the `linkedAccounts` email (incognito); OTP goes to that inbox | +| Error | Cause | Fix | +| ------------------------------------------------------------ | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| `.env.local not found` | No secrets file in project | Create `agentcore/.env.local` with credential vars | +| `Missing credentials for connector` | Env vars not set for a connector | Add the required `AGENTCORE_CREDENTIAL_*` vars to `.env.local` | +| `ServiceQuotaExceededException` | Account limit on payment managers | Request a quota increase via AWS Support | +| `No connectors for payment manager` | Manager has zero connectors | Add at least one connector before deploying | +| `PaymentCredentialProvider not found` | Orphaned reference after manual deletion | Re-run `agentcore deploy` to recreate | +| `Request timeout` | Network or service availability | Retry deploy; check internet connectivity | +| `Invalid authorizer type` | Typo in `--authorizer-type` flag | Use `AWS_IAM` or `CUSTOM_JWT` (case-sensitive) | +| `Instrument not found` at invoke | `--payment-user-id` differs from the instrument's owner | Invoke with the same user the instrument was created under | +| `Delegated signing grant is not active` | End user hasn't granted WalletHub consent for the wallet | Open the instrument's `redirectUrl`, sign in as the linked email, click Grant (see above) | +| WalletHub shows `no accounts found` | Opened the consent link as the developer, not the wallet's linked email | Sign in as the `linkedAccounts` email (incognito); OTP goes to that inbox | +| `invalid_exact_evm_transaction_failed` / `Settlement failed` | Transient on-chain failure (e.g. back-to-back payments from one wallet) | Retry the request; funds are not debited on a failed attempt | For additional troubleshooting, see [Troubleshooting AgentCore payments](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments-troubleshooting.html). From 0557ba8aacdba6f66bf6287b3bd8e2be7c11ff96 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 8 Jun 2026 21:34:22 +0000 Subject: [PATCH 06/16] fix(payments): persist autoPayment and defaultSpendLimit defaults `add payment-manager` previously dropped autoPayment and defaultSpendLimit when not explicitly passed, so the documented defaults never reached agentcore.json. Materialize them via schema .default() (matching the evaluator precedent) and in PaymentManagerPrimitive.add() using nullish coalescing so an explicit --auto-payment false is preserved. --- src/cli/primitives/PaymentManagerPrimitive.ts | 6 ++++-- .../PaymentConnectorPrimitive.test.ts | 2 ++ .../__tests__/PaymentManagerPrimitive.test.ts | 21 ++++++++++++++++++- .../__tests__/wirePaymentCapability.test.ts | 2 ++ src/schema/schemas/__tests__/payment.test.ts | 11 +++++++++- src/schema/schemas/agentcore-project.ts | 4 ++++ src/schema/schemas/primitives/index.ts | 2 ++ src/schema/schemas/primitives/payment.ts | 10 +++++++-- 8 files changed, 52 insertions(+), 6 deletions(-) diff --git a/src/cli/primitives/PaymentManagerPrimitive.ts b/src/cli/primitives/PaymentManagerPrimitive.ts index 8a59d369d..d2eb452c0 100644 --- a/src/cli/primitives/PaymentManagerPrimitive.ts +++ b/src/cli/primitives/PaymentManagerPrimitive.ts @@ -1,6 +1,8 @@ import { findConfigRoot, removeEnvVars, serializeResult, toError } from '../../lib'; import type { AgentCoreProjectSpec, PaymentAuthorizerType, PaymentPattern } from '../../schema'; import { + DEFAULT_AUTO_PAYMENT, + DEFAULT_SPEND_LIMIT, PaymentAuthorizerTypeSchema, PaymentManagerNameSchema, PaymentManagerSchema, @@ -160,8 +162,8 @@ export class PaymentManagerPrimitive extends BasePrimitive ({ name: c.name, credentialName: c.credentialName, @@ -89,9 +91,26 @@ describe('PaymentManagerPrimitive', () => { const manager = written.payments![0]!; expect(manager.name).toBe('myManager'); expect(manager.authorizerType).toBe('AWS_IAM'); - expect(manager.pattern).toBe('interceptor'); expect(manager.connectors).toEqual([]); expect(manager.authorizerConfiguration).toBeUndefined(); + // Documented defaults are materialized even when not passed. + expect(manager.autoPayment).toBe(true); + expect(manager.defaultSpendLimit).toBe('10.00'); + }); + + it('preserves an explicit autoPayment=false (not overridden by the default)', async () => { + mockReadProjectSpec.mockResolvedValue(makeProject()); + mockWriteProjectSpec.mockResolvedValue(undefined); + + await primitive.add({ + name: 'noAutoMgr', + authorizerType: 'AWS_IAM', + pattern: 'interceptor', + autoPayment: false, + }); + + const written = mockWriteProjectSpec.mock.calls[0]![0] as AgentCoreProjectSpec; + expect(written.payments![0]!.autoPayment).toBe(false); }); it('happy path writes optional fields when provided', async () => { diff --git a/src/cli/primitives/__tests__/wirePaymentCapability.test.ts b/src/cli/primitives/__tests__/wirePaymentCapability.test.ts index 57a88d59d..9e6dbeb4f 100644 --- a/src/cli/primitives/__tests__/wirePaymentCapability.test.ts +++ b/src/cli/primitives/__tests__/wirePaymentCapability.test.ts @@ -367,6 +367,8 @@ describe('wirePaymentCapability (via PaymentManagerPrimitive.add)', () => { name: ADD_OPTIONS.name, authorizerType: ADD_OPTIONS.authorizerType, pattern: ADD_OPTIONS.pattern, + autoPayment: true, + defaultSpendLimit: '10.00', connectors: [], }, ], diff --git a/src/schema/schemas/__tests__/payment.test.ts b/src/schema/schemas/__tests__/payment.test.ts index 8e700bbe9..8eb8a153f 100644 --- a/src/schema/schemas/__tests__/payment.test.ts +++ b/src/schema/schemas/__tests__/payment.test.ts @@ -104,7 +104,16 @@ describe('PaymentManagerSchema', () => { }); }); - describe('autoPayment defaults', () => { + describe('autoPayment / defaultSpendLimit defaults', () => { + it('materializes documented defaults when omitted', () => { + const result = PaymentManagerSchema.safeParse(validBase); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.autoPayment).toBe(true); + expect(result.data.defaultSpendLimit).toBe('10.00'); + } + }); + it('accepts explicit false', () => { const result = PaymentManagerSchema.safeParse({ ...validBase, autoPayment: false }); expect(result.success).toBe(true); diff --git a/src/schema/schemas/agentcore-project.ts b/src/schema/schemas/agentcore-project.ts index 9d79fb160..9d025f076 100644 --- a/src/schema/schemas/agentcore-project.ts +++ b/src/schema/schemas/agentcore-project.ts @@ -30,6 +30,8 @@ import { } from './primitives/memory'; import { OnlineEvalConfigSchema } from './primitives/online-eval-config'; import { + DEFAULT_AUTO_PAYMENT, + DEFAULT_SPEND_LIMIT, PaymentAuthorizerTypeSchema, PaymentConnectorNameSchema, PaymentConnectorSchema, @@ -103,6 +105,8 @@ export { HarnessModelProviderSchema, } from './primitives/harness'; export { + DEFAULT_AUTO_PAYMENT, + DEFAULT_SPEND_LIMIT, PaymentManagerSchema, PaymentManagerNameSchema, PaymentConnectorSchema, diff --git a/src/schema/schemas/primitives/index.ts b/src/schema/schemas/primitives/index.ts index c8a38bf79..a79717176 100644 --- a/src/schema/schemas/primitives/index.ts +++ b/src/schema/schemas/primitives/index.ts @@ -109,6 +109,8 @@ export type { PaymentAuthorizerType, } from './payment'; export { + DEFAULT_AUTO_PAYMENT, + DEFAULT_SPEND_LIMIT, PaymentManagerSchema, PaymentManagerNameSchema, PaymentConnectorSchema, diff --git a/src/schema/schemas/primitives/payment.ts b/src/schema/schemas/primitives/payment.ts index 73a6067b4..4fb3bd959 100644 --- a/src/schema/schemas/primitives/payment.ts +++ b/src/schema/schemas/primitives/payment.ts @@ -7,6 +7,12 @@ import { z } from 'zod'; export const PaymentProviderSchema = z.enum(['CoinbaseCDP', 'StripePrivy']); export type PaymentProvider = z.infer; +// Documented payment-manager defaults. Materialized on write (via the schema +// `.default()` below and in PaymentManagerPrimitive.add()) so they appear in +// agentcore.json instead of being silently re-defaulted downstream. +export const DEFAULT_AUTO_PAYMENT = true; +export const DEFAULT_SPEND_LIMIT = '10.00'; + // ============================================================================ // Payment Pattern Schema // ============================================================================ @@ -77,8 +83,8 @@ export const PaymentManagerSchema = z pattern: PaymentPatternSchema.default('interceptor'), connectors: z.array(PaymentConnectorSchema).default([]), description: z.string().optional(), - autoPayment: z.boolean().optional(), - defaultSpendLimit: z.string().optional(), + autoPayment: z.boolean().default(DEFAULT_AUTO_PAYMENT), + defaultSpendLimit: z.string().default(DEFAULT_SPEND_LIMIT), paymentToolAllowlist: z.array(z.string()).optional(), networkPreferences: z.array(z.string()).optional(), }) From e7a5184c2482419fbcd790b946c7b5ac0008bade Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 8 Jun 2026 21:42:07 +0000 Subject: [PATCH 07/16] docs(payments): clarify defaultSpendLimit is auto-session-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit defaultSpendLimit reads as a deployed-manager budget, but the service has no manager-level budget concept — it only sizes the session that `invoke --auto-session` mints for local testing. Relabel the CLI flag help, the TUI wizard hint, and the docs tables to say so explicitly, and correct the payment-manager overview that implied manager-level 'budget defaults'. --- docs/commands.md | 30 ++++++++-------- docs/configuration.md | 24 ++++++------- docs/payments.md | 35 ++++++++++--------- src/cli/primitives/PaymentManagerPrimitive.ts | 5 ++- .../payment/AddPaymentManagerScreen.tsx | 6 ++++ 5 files changed, 55 insertions(+), 45 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index d91ce7f4b..84afb8ecc 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -497,21 +497,21 @@ agentcore add payment-manager \ --network-preferences "eip155:84532" ``` -| Flag | Description | -| ---------------------------------- | ----------------------------------------------------- | -| `--name ` | Manager name (required in non-interactive mode) | -| `--authorizer-type ` | `AWS_IAM` (default) or `CUSTOM_JWT` | -| `--discovery-url ` | OIDC discovery URL (required for CUSTOM_JWT) | -| `--allowed-clients ` | Comma-separated client IDs (CUSTOM_JWT only) | -| `--allowed-audience ` | Comma-separated allowed audiences (CUSTOM_JWT only) | -| `--allowed-scopes ` | Comma-separated allowed scopes (CUSTOM_JWT only) | -| `--pattern ` | `interceptor` (default) or `tool-based` | -| `--auto-payment [value]` | Enable automatic payment: `true` (default) or `false` | -| `--default-spend-limit ` | Default session spend limit in USD (default: `10.00`) | -| `--tool-allowlist ` | Comma-separated tool names eligible for payment | -| `--network-preferences ` | Comma-separated network IDs (e.g., `eip155:84532`) | -| `--description ` | Human-readable description | -| `--json` | JSON output | +| Flag | Description | +| ---------------------------------- | --------------------------------------------------------------------------------------------------------- | +| `--name ` | Manager name (required in non-interactive mode) | +| `--authorizer-type ` | `AWS_IAM` (default) or `CUSTOM_JWT` | +| `--discovery-url ` | OIDC discovery URL (required for CUSTOM_JWT) | +| `--allowed-clients ` | Comma-separated client IDs (CUSTOM_JWT only) | +| `--allowed-audience ` | Comma-separated allowed audiences (CUSTOM_JWT only) | +| `--allowed-scopes ` | Comma-separated allowed scopes (CUSTOM_JWT only) | +| `--pattern ` | `interceptor` (default) or `tool-based` | +| `--auto-payment [value]` | Enable automatic payment: `true` (default) or `false` | +| `--default-spend-limit ` | Spend cap (USD) for `invoke --auto-session` sessions ONLY; not a deployed-agent budget (default: `10.00`) | +| `--tool-allowlist ` | Comma-separated tool names eligible for payment | +| `--network-preferences ` | Comma-separated network IDs (e.g., `eip155:84532`) | +| `--description ` | Human-readable description | +| `--json` | JSON output | ### add payment-connector diff --git a/docs/configuration.md b/docs/configuration.md index ef1c41e14..2010ab06a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -515,18 +515,18 @@ wallet credentials. See [Payments](payments.md) for the full usage guide. ### Payment Manager Fields -| Field | Required | Description | -| ------------------------- | -------- | -------------------------------------------------------------------- | -| `name` | Yes | Manager name (alphanumeric + underscore, max 48, starts with letter) | -| `authorizerType` | No | `"AWS_IAM"` (default) or `"CUSTOM_JWT"` | -| `authorizerConfiguration` | Cond. | Required when `authorizerType` is `"CUSTOM_JWT"` (see below) | -| `pattern` | No | `"interceptor"` (default) or `"tool-based"` | -| `connectors` | Yes | Array of payment connector objects | -| `autoPayment` | No | Enable automatic payment (default: `true`) | -| `defaultSpendLimit` | No | Default session budget in USD (e.g., `"10.00"`) | -| `paymentToolAllowlist` | No | Array of tool names eligible for payment | -| `networkPreferences` | No | Array of network identifiers (e.g., `"eip155:84532"`) | -| `description` | No | Human-readable description | +| Field | Required | Description | +| ------------------------- | -------- | -------------------------------------------------------------------------------------------------------- | +| `name` | Yes | Manager name (alphanumeric + underscore, max 48, starts with letter) | +| `authorizerType` | No | `"AWS_IAM"` (default) or `"CUSTOM_JWT"` | +| `authorizerConfiguration` | Cond. | Required when `authorizerType` is `"CUSTOM_JWT"` (see below) | +| `pattern` | No | `"interceptor"` (default) or `"tool-based"` | +| `connectors` | Yes | Array of payment connector objects | +| `autoPayment` | No | Enable automatic payment (default: `true`) | +| `defaultSpendLimit` | No | Spend cap (USD) for `invoke --auto-session` sessions only; not a deployed-agent budget (e.g., `"10.00"`) | +| `paymentToolAllowlist` | No | Array of tool names eligible for payment | +| `networkPreferences` | No | Array of network identifiers (e.g., `"eip155:84532"`) | +| `description` | No | Human-readable description | ### Authorizer Configuration (CUSTOM_JWT) diff --git a/docs/payments.md b/docs/payments.md index 4390f07b9..8f97b097d 100644 --- a/docs/payments.md +++ b/docs/payments.md @@ -57,8 +57,9 @@ For the full runtime flow, see ## Adding a Payment Manager -A payment manager is the top-level resource that orchestrates payment operations. It defines authorization, spending -patterns, and budget defaults. +A payment manager is the top-level resource that orchestrates payment operations. It defines authorization and +auto-payment behavior. (Spending budgets are enforced per payment session, not on the manager — see +[Auto-Session Mode](#auto-session-mode).) ### CLI Command @@ -78,21 +79,21 @@ agentcore add payment-manager \ --description "Production payment manager" ``` -| Flag | Description | -| ---------------------------------- | ------------------------------------------------------------------- | -| `--name ` | Manager name (required in non-interactive mode) | -| `--authorizer-type ` | `AWS_IAM` (default) or `CUSTOM_JWT` | -| `--discovery-url ` | OIDC discovery URL (required for CUSTOM_JWT) | -| `--allowed-clients ` | Comma-separated client IDs (CUSTOM_JWT only) | -| `--allowed-audience ` | Comma-separated allowed audiences (CUSTOM_JWT only) | -| `--allowed-scopes ` | Comma-separated allowed scopes (CUSTOM_JWT only) | -| `--pattern ` | `interceptor` (default) or `tool-based` | -| `--auto-payment [value]` | Enable automatic payment: `true` (default) or `false` | -| `--default-spend-limit ` | Default session spend limit in USD (default: `10.00`) | -| `--tool-allowlist ` | Comma-separated tool names eligible for payment | -| `--network-preferences ` | Comma-separated network IDs (e.g., `eip155:84532` for Base Sepolia) | -| `--description ` | Human-readable description | -| `--json` | Output result as JSON | +| Flag | Description | +| ---------------------------------- | --------------------------------------------------------------------------------------------------------- | +| `--name ` | Manager name (required in non-interactive mode) | +| `--authorizer-type ` | `AWS_IAM` (default) or `CUSTOM_JWT` | +| `--discovery-url ` | OIDC discovery URL (required for CUSTOM_JWT) | +| `--allowed-clients ` | Comma-separated client IDs (CUSTOM_JWT only) | +| `--allowed-audience ` | Comma-separated allowed audiences (CUSTOM_JWT only) | +| `--allowed-scopes ` | Comma-separated allowed scopes (CUSTOM_JWT only) | +| `--pattern ` | `interceptor` (default) or `tool-based` | +| `--auto-payment [value]` | Enable automatic payment: `true` (default) or `false` | +| `--default-spend-limit ` | Spend cap (USD) for `invoke --auto-session` sessions ONLY; not a deployed-agent budget (default: `10.00`) | +| `--tool-allowlist ` | Comma-separated tool names eligible for payment | +| `--network-preferences ` | Comma-separated network IDs (e.g., `eip155:84532` for Base Sepolia) | +| `--description ` | Human-readable description | +| `--json` | Output result as JSON | Name constraints: must start with a letter, contain only alphanumeric characters and underscores, max 48 characters. diff --git a/src/cli/primitives/PaymentManagerPrimitive.ts b/src/cli/primitives/PaymentManagerPrimitive.ts index d2eb452c0..dd57fe846 100644 --- a/src/cli/primitives/PaymentManagerPrimitive.ts +++ b/src/cli/primitives/PaymentManagerPrimitive.ts @@ -334,7 +334,10 @@ export class PaymentManagerPrimitive extends BasePrimitive', 'Comma-separated allowed scopes (for CUSTOM_JWT) [non-interactive]') .option('--pattern ', 'Payment pattern: interceptor or tool-based [non-interactive]') .option('--auto-payment [value]', 'Enable auto payment: true or false (default: true) [non-interactive]') - .option('--default-spend-limit ', 'Default spend limit in USD (default: 10.00) [non-interactive]') + .option( + '--default-spend-limit ', + 'Spend cap (USD) for sessions created by `invoke --auto-session` ONLY; not a deployed-agent budget (default: 10.00) [non-interactive]' + ) .option('--tool-allowlist ', 'Comma-separated tool names eligible for payment [non-interactive]') .option( '--network-preferences ', diff --git a/src/cli/tui/screens/payment/AddPaymentManagerScreen.tsx b/src/cli/tui/screens/payment/AddPaymentManagerScreen.tsx index cacf8816a..890021041 100644 --- a/src/cli/tui/screens/payment/AddPaymentManagerScreen.tsx +++ b/src/cli/tui/screens/payment/AddPaymentManagerScreen.tsx @@ -288,6 +288,12 @@ export function AddPaymentManagerScreen({ }} /> + + + Used only for sessions created by `invoke --auto-session`. It is not a deployed-agent budget — sessions + your agent creates at runtime set their own limit. + + )} From 24b3ef6f2c225956b5f434c5c8d7486f0705802f Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 8 Jun 2026 22:15:46 +0000 Subject: [PATCH 08/16] refactor(payments): remove vestigial 'pattern' field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The payment-manager 'pattern' (interceptor | tool-based) was collected via CLI flag and TUI wizard, persisted, then silently dropped before deploy (never mapped in the vended bin/cdk.ts), and 'tool-based' was implemented in no layer — shim, SDK plugin, or config. Interceptor is the only real behavior (settlement is never an agent tool, per the payments design). Remove 'pattern' from the schema, CLI flag, persist path, TUI wizard step, status detail, docs, and tests. Existing agentcore.json files carrying 'pattern' still parse cleanly (the schema is not .strict(), so Zod strips the unknown key); covered by a back-compat assertion in agentcore-project.test.ts. Status now shows auto-pay state instead. --- docs/commands.md | 3 +- docs/configuration.md | 2 -- docs/payments.md | 17 +++++------ e2e-tests/payment-strands-bedrock.test.ts | 7 ++--- e2e-tests/payment-validation.test.ts | 7 +---- integ-tests/add-remove-payment.test.ts | 21 +++----------- src/cli/commands/status/action.ts | 3 +- src/cli/primitives/PaymentManagerPrimitive.ts | 23 ++------------- .../tui/screens/payment/AddPaymentFlow.tsx | 2 -- .../payment/AddPaymentManagerScreen.tsx | 29 ++----------------- src/cli/tui/screens/payment/types.ts | 10 +------ .../screens/payment/useAddPaymentWizard.ts | 15 ++-------- .../__tests__/agentcore-project.test.ts | 4 +++ src/schema/schemas/__tests__/payment.test.ts | 2 +- src/schema/schemas/agentcore-project.ts | 10 +------ src/schema/schemas/primitives/index.ts | 9 +----- src/schema/schemas/primitives/payment.ts | 8 ----- 17 files changed, 31 insertions(+), 141 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index 84afb8ecc..6c6881692 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -478,7 +478,7 @@ agentcore add gateway-target \ Add a payment manager to the project. See [Payments](payments.md) for full usage guide. ```bash -# Minimal (defaults: AWS_IAM, interceptor, auto-payment enabled) +# Minimal (defaults: AWS_IAM, auto-payment enabled) agentcore add payment-manager --name MyManager # With CUSTOM_JWT authorization @@ -505,7 +505,6 @@ agentcore add payment-manager \ | `--allowed-clients ` | Comma-separated client IDs (CUSTOM_JWT only) | | `--allowed-audience ` | Comma-separated allowed audiences (CUSTOM_JWT only) | | `--allowed-scopes ` | Comma-separated allowed scopes (CUSTOM_JWT only) | -| `--pattern ` | `interceptor` (default) or `tool-based` | | `--auto-payment [value]` | Enable automatic payment: `true` (default) or `false` | | `--default-spend-limit ` | Spend cap (USD) for `invoke --auto-session` sessions ONLY; not a deployed-agent budget (default: `10.00`) | | `--tool-allowlist ` | Comma-separated tool names eligible for payment | diff --git a/docs/configuration.md b/docs/configuration.md index 2010ab06a..bac57bc00 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -495,7 +495,6 @@ wallet credentials. See [Payments](payments.md) for the full usage guide. { "name": "MyManager", "authorizerType": "AWS_IAM", - "pattern": "interceptor", "autoPayment": true, "defaultSpendLimit": "10.00", "paymentToolAllowlist": ["web_search", "fetch_url"], @@ -520,7 +519,6 @@ wallet credentials. See [Payments](payments.md) for the full usage guide. | `name` | Yes | Manager name (alphanumeric + underscore, max 48, starts with letter) | | `authorizerType` | No | `"AWS_IAM"` (default) or `"CUSTOM_JWT"` | | `authorizerConfiguration` | Cond. | Required when `authorizerType` is `"CUSTOM_JWT"` (see below) | -| `pattern` | No | `"interceptor"` (default) or `"tool-based"` | | `connectors` | Yes | Array of payment connector objects | | `autoPayment` | No | Enable automatic payment (default: `true`) | | `defaultSpendLimit` | No | Spend cap (USD) for `invoke --auto-session` sessions only; not a deployed-agent budget (e.g., `"10.00"`) | diff --git a/docs/payments.md b/docs/payments.md index 8f97b097d..3f7e844c1 100644 --- a/docs/payments.md +++ b/docs/payments.md @@ -16,7 +16,7 @@ agentcore create --name MyProject --defaults cd MyProject # 2. Add a payment manager -agentcore add payment-manager --name MyManager --pattern interceptor +agentcore add payment-manager --name MyManager # 3. Add a payment connector with CoinbaseCDP credentials agentcore add payment-connector \ @@ -48,12 +48,11 @@ payment requirements (amount, recipient, network). The AgentCore payments plugin For the full runtime flow, see [How AgentCore payments works](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/payments-how-it-works.html). -### Payment Patterns +### Payment Handling -| Pattern | Behavior | -| ----------- | ------------------------------------------------------------ | -| interceptor | Automatically handles 402 responses (transparent to agent) | -| tool-based | Exposes payment as an agent tool (agent decides when to pay) | +Payments are handled by an **interceptor**: the payments plugin transparently catches a tool's `402 Payment Required`, +settles the x402 payment, and retries — no explicit agent decision. (Payment settlement is never exposed as an agent +tool.) ## Adding a Payment Manager @@ -64,14 +63,13 @@ auto-payment behavior. (Spending budgets are enforced per payment session, not o ### CLI Command ```bash -# Minimal (defaults: AWS_IAM auth, interceptor pattern, auto-payment enabled) +# Minimal (defaults: AWS_IAM auth, auto-payment enabled) agentcore add payment-manager --name MyManager # With all advanced options agentcore add payment-manager \ --name MyManager \ --authorizer-type AWS_IAM \ - --pattern interceptor \ --auto-payment true \ --default-spend-limit 25.00 \ --tool-allowlist "web_search,fetch_url" \ @@ -87,7 +85,6 @@ agentcore add payment-manager \ | `--allowed-clients ` | Comma-separated client IDs (CUSTOM_JWT only) | | `--allowed-audience ` | Comma-separated allowed audiences (CUSTOM_JWT only) | | `--allowed-scopes ` | Comma-separated allowed scopes (CUSTOM_JWT only) | -| `--pattern ` | `interceptor` (default) or `tool-based` | | `--auto-payment [value]` | Enable automatic payment: `true` (default) or `false` | | `--default-spend-limit ` | Spend cap (USD) for `invoke --auto-session` sessions ONLY; not a deployed-agent budget (default: `10.00`) | | `--tool-allowlist ` | Comma-separated tool names eligible for payment | @@ -371,7 +368,7 @@ The complete path from a fresh project to a settled on-chain payment. Steps 1– ```bash # 1. Create a project and add the payment manager + connector agentcore create --name MyProject --defaults && cd MyProject -agentcore add payment-manager --name MyManager --pattern interceptor +agentcore add payment-manager --name MyManager agentcore add payment-connector --manager MyManager --name MyCDPConnector --provider CoinbaseCDP \ --api-key-id "$CDP_API_KEY_ID" --api-key-secret "$CDP_API_KEY_SECRET" --wallet-secret "$CDP_WALLET_SECRET" diff --git a/e2e-tests/payment-strands-bedrock.test.ts b/e2e-tests/payment-strands-bedrock.test.ts index 33c1eb63c..8ccb5a20c 100644 --- a/e2e-tests/payment-strands-bedrock.test.ts +++ b/e2e-tests/payment-strands-bedrock.test.ts @@ -60,10 +60,7 @@ describe.sequential('e2e: payments — create → add payment → deploy → sta projectPath = createJson.projectPath; // Add payment manager - const mgrResult = await runAgentCoreCLI( - ['add', 'payment-manager', '--name', managerName, '--pattern', 'interceptor', '--json'], - projectPath - ); + const mgrResult = await runAgentCoreCLI(['add', 'payment-manager', '--name', managerName, '--json'], projectPath); expect(mgrResult.exitCode, `Add manager failed: ${mgrResult.stderr}`).toBe(0); // Add payment connector with CDP credentials @@ -109,7 +106,7 @@ describe.sequential('e2e: payments — create → add payment → deploy → sta const manager = config.payments?.find((p: Record) => p.name === managerName); expect(manager).toBeTruthy(); expect(manager.authorizerType).toBe('AWS_IAM'); - expect(manager.pattern).toBe('interceptor'); + expect(manager.autoPayment).toBe(true); // Connector nested inside manager const connector = manager.connectors?.find((c: Record) => c.name === connectorName); diff --git a/e2e-tests/payment-validation.test.ts b/e2e-tests/payment-validation.test.ts index a60bc7ab4..b7c819f8e 100644 --- a/e2e-tests/payment-validation.test.ts +++ b/e2e-tests/payment-validation.test.ts @@ -60,8 +60,6 @@ describe.sequential('e2e: payments — validation, config, and remove lifecycle' 'payment-manager', '--name', 'cfgMgr', - '--pattern', - 'interceptor', '--auto-payment', 'false', '--default-spend-limit', @@ -213,10 +211,7 @@ describe.sequential('e2e: payments — validation, config, and remove lifecycle' // ── Validation: duplicate names ─────────────────────────────────────────── it.skipIf(!canRun)('rejects duplicate manager name', async () => { - const result = await runAgentCoreCLI( - ['add', 'payment-manager', '--name', 'cfgMgr', '--pattern', 'interceptor', '--json'], - projectPath - ); + const result = await runAgentCoreCLI(['add', 'payment-manager', '--name', 'cfgMgr', '--json'], projectPath); expect(result.exitCode).toBe(1); const json = parseJsonOutput(result.stdout) as { success: boolean; error: string }; expect(json.error).toContain('already exists'); diff --git a/integ-tests/add-remove-payment.test.ts b/integ-tests/add-remove-payment.test.ts index 68d28c013..665c58037 100644 --- a/integ-tests/add-remove-payment.test.ts +++ b/integ-tests/add-remove-payment.test.ts @@ -24,10 +24,7 @@ describe('integration: add and remove payment managers and connectors', () => { const managerName = `IntegMgr${Date.now().toString().slice(-6)}`; it('adds an AWS_IAM payment manager', async () => { - const result = await runCLI( - ['add', 'payment-manager', '--name', managerName, '--pattern', 'interceptor', '--json'], - project.projectPath - ); + const result = await runCLI(['add', 'payment-manager', '--name', managerName, '--json'], project.projectPath); expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); const json = JSON.parse(result.stdout); @@ -38,7 +35,7 @@ describe('integration: add and remove payment managers and connectors', () => { const manager = config.payments?.find((p: Record) => p.name === managerName); expect(manager, `Payment manager "${managerName}" should be in config`).toBeTruthy(); expect(manager!.authorizerType).toBe('AWS_IAM'); - expect(manager!.pattern).toBe('interceptor'); + expect(manager!.autoPayment).toBe(true); expect(manager!.connectors).toEqual([]); }); @@ -95,8 +92,6 @@ describe('integration: add and remove payment managers and connectors', () => { 'https://cognito-idp.us-east-1.amazonaws.com/us-east-1_test/.well-known/openid-configuration', '--allowed-clients', 'client-1,client-2', - '--pattern', - 'interceptor', '--json', ], project.projectPath @@ -142,7 +137,7 @@ describe('integration: add and remove payment managers and connectors', () => { const connectorName2 = `IntegConn2${Date.now().toString().slice(-6)}`; beforeAll(async () => { - await runCLI(['add', 'payment-manager', '--name', managerName, '--pattern', 'interceptor'], project.projectPath); + await runCLI(['add', 'payment-manager', '--name', managerName], project.projectPath); }); it('adds a payment connector to the manager', async () => { @@ -315,7 +310,7 @@ describe('integration: add and remove payment managers and connectors', () => { const connectorName = `IntegSpConn${Date.now().toString().slice(-6)}`; beforeAll(async () => { - await runCLI(['add', 'payment-manager', '--name', managerName, '--pattern', 'interceptor'], project.projectPath); + await runCLI(['add', 'payment-manager', '--name', managerName], project.projectPath); }); it('adds a StripePrivy connector to the manager', async () => { @@ -455,14 +450,6 @@ describe('integration: add and remove payment managers and connectors', () => { expect(result.exitCode).toBe(1); }); - it('rejects invalid pattern', async () => { - const result = await runCLI( - ['add', 'payment-manager', '--name', 'x', '--pattern', 'invalid', '--json'], - project.projectPath - ); - expect(result.exitCode).toBe(1); - }); - it('rejects invalid provider', async () => { const result = await runCLI( [ diff --git a/src/cli/commands/status/action.ts b/src/cli/commands/status/action.ts index 0d68af2c5..057aa272b 100644 --- a/src/cli/commands/status/action.ts +++ b/src/cli/commands/status/action.ts @@ -315,7 +315,8 @@ export function computeResourceStatuses( localItems: project.payments ?? [], deployedRecord: resources?.payments ?? {}, getIdentifier: deployed => deployed.managerArn, - getLocalDetail: item => `${item.authorizerType} — ${item.pattern} (${item.connectors.length} connector(s))`, + getLocalDetail: item => + `${item.authorizerType} — auto-pay ${item.autoPayment ? 'on' : 'off'} (${item.connectors.length} connector(s))`, }); return [ diff --git a/src/cli/primitives/PaymentManagerPrimitive.ts b/src/cli/primitives/PaymentManagerPrimitive.ts index dd57fe846..10f4479cd 100644 --- a/src/cli/primitives/PaymentManagerPrimitive.ts +++ b/src/cli/primitives/PaymentManagerPrimitive.ts @@ -1,12 +1,11 @@ import { findConfigRoot, removeEnvVars, serializeResult, toError } from '../../lib'; -import type { AgentCoreProjectSpec, PaymentAuthorizerType, PaymentPattern } from '../../schema'; +import type { AgentCoreProjectSpec, PaymentAuthorizerType } from '../../schema'; import { DEFAULT_AUTO_PAYMENT, DEFAULT_SPEND_LIMIT, PaymentAuthorizerTypeSchema, PaymentManagerNameSchema, PaymentManagerSchema, - PaymentPatternSchema, } from '../../schema'; import type { RemoveResult } from '../commands/remove/types'; import { getErrorMessage } from '../errors'; @@ -110,7 +109,6 @@ export interface AddPaymentManagerOptions { allowedClients?: string[]; allowedAudience?: string[]; allowedScopes?: string[]; - pattern: PaymentPattern; description?: string; autoPayment?: boolean; defaultSpendLimit?: string; @@ -159,7 +157,6 @@ export class PaymentManagerPrimitive extends BasePrimitive', 'Comma-separated allowed client IDs (for CUSTOM_JWT) [non-interactive]') .option('--allowed-audience ', 'Comma-separated allowed audiences (for CUSTOM_JWT) [non-interactive]') .option('--allowed-scopes ', 'Comma-separated allowed scopes (for CUSTOM_JWT) [non-interactive]') - .option('--pattern ', 'Payment pattern: interceptor or tool-based [non-interactive]') .option('--auto-payment [value]', 'Enable auto payment: true or false (default: true) [non-interactive]') .option( '--default-spend-limit ', @@ -353,7 +349,6 @@ export class PaymentManagerPrimitive extends BasePrimitive { diff --git a/src/cli/tui/screens/payment/AddPaymentFlow.tsx b/src/cli/tui/screens/payment/AddPaymentFlow.tsx index 99650a2f5..7adbbc641 100644 --- a/src/cli/tui/screens/payment/AddPaymentFlow.tsx +++ b/src/cli/tui/screens/payment/AddPaymentFlow.tsx @@ -246,7 +246,6 @@ export function AddPaymentFlow({ isInteractive = true, onExit, onBack, onDev, on const managerFields = [ { label: 'Auth Type', value: flow.managerConfig.authorizerType }, { label: 'Manager Name', value: flow.managerConfig.managerName }, - { label: 'Pattern', value: flow.managerConfig.pattern }, { label: 'Auto Payment', value: flow.managerConfig.autoPayment ? 'Enabled' : 'Disabled' }, { label: 'Default Spend Limit', value: `$${flow.managerConfig.defaultSpendLimit}` }, ...(flow.managerConfig.paymentToolAllowlist @@ -340,7 +339,6 @@ export function AddPaymentFlow({ isInteractive = true, onExit, onBack, onDev, on allowedClients: mgrConfig.authorizerType === 'CUSTOM_JWT' ? parseList(mgrConfig.allowedClients) : undefined, allowedAudience: mgrConfig.authorizerType === 'CUSTOM_JWT' ? parseList(mgrConfig.allowedAudience) : undefined, allowedScopes: mgrConfig.authorizerType === 'CUSTOM_JWT' ? parseList(mgrConfig.allowedScopes) : undefined, - pattern: mgrConfig.pattern, autoPayment: mgrConfig.autoPayment, defaultSpendLimit: mgrConfig.defaultSpendLimit, paymentToolAllowlist: mgrConfig.paymentToolAllowlist ? parseList(mgrConfig.paymentToolAllowlist) : undefined, diff --git a/src/cli/tui/screens/payment/AddPaymentManagerScreen.tsx b/src/cli/tui/screens/payment/AddPaymentManagerScreen.tsx index 890021041..c5b195dc7 100644 --- a/src/cli/tui/screens/payment/AddPaymentManagerScreen.tsx +++ b/src/cli/tui/screens/payment/AddPaymentManagerScreen.tsx @@ -1,4 +1,4 @@ -import type { PaymentAuthorizerType, PaymentPattern } from '../../../../schema'; +import type { PaymentAuthorizerType } from '../../../../schema'; import { PaymentManagerNameSchema } from '../../../../schema'; import { Panel, Screen, StepIndicator, TextInput, WizardSelect } from '../../components'; import type { SelectableItem } from '../../components'; @@ -11,7 +11,6 @@ import { AUTO_PAYMENT_ITEM_ID, MANAGER_STEP_LABELS, NETWORK_PREFS_ITEM_ID, - PAYMENT_PATTERN_OPTIONS, TOOL_ALLOWLIST_ITEM_ID, } from './types'; import { useAddPaymentManagerWizard } from './useAddPaymentWizard'; @@ -38,11 +37,6 @@ export function AddPaymentManagerScreen({ [] ); - const patternItems: SelectableItem[] = useMemo( - () => PAYMENT_PATTERN_OPTIONS.map(opt => ({ id: opt.id, title: opt.title, description: opt.description })), - [] - ); - const BUDGET_ITEM_ID = 'default-budget'; const advancedConfigItems: SelectableItem[] = useMemo( () => [ @@ -74,7 +68,6 @@ export function AddPaymentManagerScreen({ const isAllowedAudienceStep = wizard.step === 'allowed-audience'; const isAllowedScopesStep = wizard.step === 'allowed-scopes'; const isManagerNameStep = wizard.step === 'manager-name'; - const isPatternStep = wizard.step === 'pattern-select'; const isAdvancedConfigStep = wizard.step === 'advanced-config'; const authTypeNav = useListNavigation({ @@ -84,15 +77,6 @@ export function AddPaymentManagerScreen({ isActive: isAuthTypeStep, }); - const patternNav = useListNavigation({ - items: patternItems, - onSelect: item => { - wizard.setPattern(item.id as PaymentPattern); - }, - onExit: () => wizard.goBack(), - isActive: isPatternStep, - }); - const [autoPaymentEnabled, setAutoPaymentEnabled] = useState(true); const resolvedValuesRef = useRef({ autoPayment: true, @@ -140,7 +124,7 @@ export function AddPaymentManagerScreen({ ? advancedSubStep === 0 ? 'Space toggle · Enter confirm · Esc back' : HELP_TEXT.TEXT_INPUT - : isAuthTypeStep || isPatternStep + : isAuthTypeStep ? HELP_TEXT.NAVIGATE_SELECT : HELP_TEXT.TEXT_INPUT; @@ -226,15 +210,6 @@ export function AddPaymentManagerScreen({ /> )} - {isPatternStep && ( - - )} - {isAdvancedConfigStep && advancedSubStep === 0 && ( Advanced Configuration diff --git a/src/cli/tui/screens/payment/types.ts b/src/cli/tui/screens/payment/types.ts index ab82d76f5..c964eceb0 100644 --- a/src/cli/tui/screens/payment/types.ts +++ b/src/cli/tui/screens/payment/types.ts @@ -1,4 +1,4 @@ -import type { PaymentAuthorizerType, PaymentPattern, PaymentProvider } from '../../../../schema'; +import type { PaymentAuthorizerType, PaymentProvider } from '../../../../schema'; // ───────────────────────────────────────────────────────────────────────────── // Payment Manager Flow Types @@ -11,7 +11,6 @@ export type AddPaymentManagerStep = | 'allowed-audience' | 'allowed-scopes' | 'manager-name' - | 'pattern-select' | 'advanced-config' | 'confirm'; @@ -22,7 +21,6 @@ export interface AddPaymentManagerConfig { allowedAudience: string; allowedScopes: string; managerName: string; - pattern: PaymentPattern; autoPayment: boolean; defaultSpendLimit: string; paymentToolAllowlist?: string; @@ -39,7 +37,6 @@ export const MANAGER_STEP_LABELS: Record = { 'allowed-audience': 'Audience', 'allowed-scopes': 'Scopes', 'manager-name': 'Name', - 'pattern-select': 'Pattern', 'advanced-config': 'Advanced', confirm: 'Confirm', }; @@ -114,10 +111,5 @@ export const PAYMENT_PROVIDER_OPTIONS = [ { id: 'StripePrivy' as const, title: 'Stripe + Privy', description: 'Stripe payments via Privy embedded wallets' }, ] as const; -export const PAYMENT_PATTERN_OPTIONS = [ - { id: 'interceptor' as const, title: 'Interceptor', description: 'Automatically handle x402 payment responses' }, - { id: 'tool-based' as const, title: 'Tool-based', description: 'Expose payment as an agent tool' }, -] as const; - /** Item ID for the auto payment toggle in the advanced config pane. */ export const AUTO_PAYMENT_ITEM_ID = 'auto-payment'; diff --git a/src/cli/tui/screens/payment/useAddPaymentWizard.ts b/src/cli/tui/screens/payment/useAddPaymentWizard.ts index 7e5c56c35..589acbd8e 100644 --- a/src/cli/tui/screens/payment/useAddPaymentWizard.ts +++ b/src/cli/tui/screens/payment/useAddPaymentWizard.ts @@ -1,4 +1,4 @@ -import type { PaymentAuthorizerType, PaymentPattern, PaymentProvider } from '../../../../schema'; +import type { PaymentAuthorizerType, PaymentProvider } from '../../../../schema'; import type { AddPaymentConnectorConfig, AddPaymentConnectorStep, @@ -11,7 +11,7 @@ import { useCallback, useMemo, useState } from 'react'; // Payment Manager Wizard // ───────────────────────────────────────────────────────────────────────────── -const BASE_MANAGER_STEPS: AddPaymentManagerStep[] = ['auth-type', 'manager-name', 'pattern-select', 'advanced-config']; +const BASE_MANAGER_STEPS: AddPaymentManagerStep[] = ['auth-type', 'manager-name', 'advanced-config']; const JWT_MANAGER_STEPS: AddPaymentManagerStep[] = [ 'auth-type', 'discovery-url', @@ -19,7 +19,6 @@ const JWT_MANAGER_STEPS: AddPaymentManagerStep[] = [ 'allowed-audience', 'allowed-scopes', 'manager-name', - 'pattern-select', 'advanced-config', ]; @@ -31,7 +30,6 @@ function getDefaultManagerConfig(): AddPaymentManagerConfig { allowedAudience: '', allowedScopes: '', managerName: '', - pattern: 'interceptor', autoPayment: true, defaultSpendLimit: '10.00', }; @@ -112,14 +110,6 @@ export function useAddPaymentManagerWizard() { [advanceFrom] ); - const setPattern = useCallback( - (pattern: PaymentPattern) => { - setConfig(c => ({ ...c, pattern })); - advanceFrom('pattern-select'); - }, - [advanceFrom] - ); - const setAdvancedConfig = useCallback( (advanced: { autoPayment: boolean; defaultSpendLimit: string }) => { setConfig(c => ({ ...c, autoPayment: advanced.autoPayment, defaultSpendLimit: advanced.defaultSpendLimit })); @@ -157,7 +147,6 @@ export function useAddPaymentManagerWizard() { setAllowedAudience, setAllowedScopes, setManagerName, - setPattern, setAdvancedConfig, setDefaultSpendLimit, setPaymentToolAllowlist, diff --git a/src/schema/schemas/__tests__/agentcore-project.test.ts b/src/schema/schemas/__tests__/agentcore-project.test.ts index 6f1accbde..5423d16ab 100644 --- a/src/schema/schemas/__tests__/agentcore-project.test.ts +++ b/src/schema/schemas/__tests__/agentcore-project.test.ts @@ -427,6 +427,8 @@ describe('AgentCoreProjectSpecSchema', () => { { name: 'paymgr', authorizerType: 'AWS_IAM', + // `pattern` is a removed field; an older agentcore.json may still carry it. + // Zod strips unknown keys (the schema is not .strict()), so old configs keep parsing. pattern: 'interceptor', connectors: [], }, @@ -436,6 +438,8 @@ describe('AgentCoreProjectSpecSchema', () => { if (result.success) { expect(result.data.payments).toHaveLength(1); expect(result.data.payments![0]!.name).toBe('paymgr'); + // Back-compat: the legacy `pattern` key is dropped, not surfaced. + expect('pattern' in result.data.payments![0]!).toBe(false); } }); diff --git a/src/schema/schemas/__tests__/payment.test.ts b/src/schema/schemas/__tests__/payment.test.ts index 8eb8a153f..c4938b0f6 100644 --- a/src/schema/schemas/__tests__/payment.test.ts +++ b/src/schema/schemas/__tests__/payment.test.ts @@ -76,7 +76,7 @@ describe('PaymentConnectorNameSchema', () => { }); describe('PaymentManagerSchema', () => { - const validBase = { name: 'testManager', pattern: 'interceptor' as const, connectors: [] }; + const validBase = { name: 'testManager', connectors: [] }; describe('CUSTOM_JWT requires authorizerConfiguration', () => { it('fails when authorizerConfiguration is missing', () => { diff --git a/src/schema/schemas/agentcore-project.ts b/src/schema/schemas/agentcore-project.ts index 9d025f076..ec7ac3dba 100644 --- a/src/schema/schemas/agentcore-project.ts +++ b/src/schema/schemas/agentcore-project.ts @@ -37,7 +37,6 @@ import { PaymentConnectorSchema, PaymentManagerNameSchema, PaymentManagerSchema, - PaymentPatternSchema, PaymentProviderSchema, } from './primitives/payment'; import { PolicyEngineSchema } from './primitives/policy'; @@ -112,16 +111,9 @@ export { PaymentConnectorSchema, PaymentConnectorNameSchema, PaymentProviderSchema, - PaymentPatternSchema, PaymentAuthorizerTypeSchema, }; -export type { - PaymentManager, - PaymentConnector, - PaymentProvider, - PaymentPattern, - PaymentAuthorizerType, -} from './primitives/payment'; +export type { PaymentManager, PaymentConnector, PaymentProvider, PaymentAuthorizerType } from './primitives/payment'; // ============================================================================ // ManagedBy Schema diff --git a/src/schema/schemas/primitives/index.ts b/src/schema/schemas/primitives/index.ts index a79717176..39def1dcb 100644 --- a/src/schema/schemas/primitives/index.ts +++ b/src/schema/schemas/primitives/index.ts @@ -101,13 +101,7 @@ export { export type { HttpGateway } from './http-gateway'; export { HttpGatewayNameSchema, HttpGatewaySchema } from './http-gateway'; -export type { - PaymentManager, - PaymentConnector, - PaymentProvider, - PaymentPattern, - PaymentAuthorizerType, -} from './payment'; +export type { PaymentManager, PaymentConnector, PaymentProvider, PaymentAuthorizerType } from './payment'; export { DEFAULT_AUTO_PAYMENT, DEFAULT_SPEND_LIMIT, @@ -116,6 +110,5 @@ export { PaymentConnectorSchema, PaymentConnectorNameSchema, PaymentProviderSchema, - PaymentPatternSchema, PaymentAuthorizerTypeSchema, } from './payment'; diff --git a/src/schema/schemas/primitives/payment.ts b/src/schema/schemas/primitives/payment.ts index 4fb3bd959..8200d71b4 100644 --- a/src/schema/schemas/primitives/payment.ts +++ b/src/schema/schemas/primitives/payment.ts @@ -13,13 +13,6 @@ export type PaymentProvider = z.infer; export const DEFAULT_AUTO_PAYMENT = true; export const DEFAULT_SPEND_LIMIT = '10.00'; -// ============================================================================ -// Payment Pattern Schema -// ============================================================================ - -export const PaymentPatternSchema = z.enum(['interceptor', 'tool-based']); -export type PaymentPattern = z.infer; - // ============================================================================ // Payment Manager Name Schema // ============================================================================ @@ -80,7 +73,6 @@ export const PaymentManagerSchema = z }), }) .optional(), - pattern: PaymentPatternSchema.default('interceptor'), connectors: z.array(PaymentConnectorSchema).default([]), description: z.string().optional(), autoPayment: z.boolean().default(DEFAULT_AUTO_PAYMENT), From cc9d1e38fbef75d0292978eceb22dd3944925253 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 8 Jun 2026 22:37:30 +0000 Subject: [PATCH 09/16] feat(payments): split add TUI into Payment Manager + Connector rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Payments was the only resource hidden behind a single 'Payment' menu entry that opened a second manager/connector picker — a nested menu no other resource has. Replace it with two top-level selectable rows ('Payment Manager', 'Payment Connector'), each routing straight into its wizard via a new AddPaymentFlow initialAction prop that skips the intermediate picker. Also fixes `agentcore add payment-manager` / `add payment-connector` with no args: their interactive fallback now passes initialResource so they land directly in the right wizard instead of the generic resource menu. Esc/back (including the error state) returns to the main Add list when launched directly, never the skipped picker. --- .../primitives/PaymentConnectorPrimitive.ts | 1 + src/cli/primitives/PaymentManagerPrimitive.ts | 1 + src/cli/tui/screens/add/AddFlow.tsx | 35 +++++++-- src/cli/tui/screens/add/AddScreen.tsx | 10 ++- .../screens/add/__tests__/AddScreen.test.tsx | 10 +++ .../tui/screens/payment/AddPaymentFlow.tsx | 74 +++++++++++++++---- 6 files changed, 107 insertions(+), 24 deletions(-) diff --git a/src/cli/primitives/PaymentConnectorPrimitive.ts b/src/cli/primitives/PaymentConnectorPrimitive.ts index 559e3da80..9254b0c32 100644 --- a/src/cli/primitives/PaymentConnectorPrimitive.ts +++ b/src/cli/primitives/PaymentConnectorPrimitive.ts @@ -500,6 +500,7 @@ export class PaymentConnectorPrimitive extends BasePrimitive { clear(); unmount(); diff --git a/src/cli/primitives/PaymentManagerPrimitive.ts b/src/cli/primitives/PaymentManagerPrimitive.ts index 10f4479cd..c3bfe6bbc 100644 --- a/src/cli/primitives/PaymentManagerPrimitive.ts +++ b/src/cli/primitives/PaymentManagerPrimitive.ts @@ -472,6 +472,7 @@ export class PaymentManagerPrimitive extends BasePrimitive { clear(); unmount(); diff --git a/src/cli/tui/screens/add/AddFlow.tsx b/src/cli/tui/screens/add/AddFlow.tsx index 42a0145fa..02995be50 100644 --- a/src/cli/tui/screens/add/AddFlow.tsx +++ b/src/cli/tui/screens/add/AddFlow.tsx @@ -41,7 +41,8 @@ type FlowState = | { name: 'config-bundle-wizard' } | { name: 'ab-test-wizard' } | { name: 'runtime-endpoint-wizard' } - | { name: 'payment-wizard' } + | { name: 'payment-manager-wizard' } + | { name: 'payment-connector-wizard' } | { name: 'agent-create-success'; agentName: string; @@ -201,8 +202,10 @@ function getInitialFlowState(resource?: AddResourceType): FlowState { return { name: 'config-bundle-wizard' }; case 'ab-test': return { name: 'ab-test-wizard' }; - case 'payment': - return { name: 'payment-wizard' }; + case 'payment-manager': + return { name: 'payment-manager-wizard' }; + case 'payment-connector': + return { name: 'payment-connector-wizard' }; default: return { name: 'select' }; } @@ -265,8 +268,11 @@ export function AddFlow(props: AddFlowProps) { case 'runtime-endpoint': setFlow({ name: 'runtime-endpoint-wizard' }); break; - case 'payment': - setFlow({ name: 'payment-wizard' }); + case 'payment-manager': + setFlow({ name: 'payment-manager-wizard' }); + break; + case 'payment-connector': + setFlow({ name: 'payment-connector-wizard' }); break; } }, []); @@ -571,11 +577,26 @@ export function AddFlow(props: AddFlowProps) { ); } - // Payment wizard - if (flow.name === 'payment-wizard') { + // Payment manager wizard + if (flow.name === 'payment-manager-wizard') { + return ( + setFlow({ name: 'select' })} + onDev={props.onDev} + onDeploy={props.onDeploy} + /> + ); + } + + // Payment connector wizard + if (flow.name === 'payment-connector-wizard') { return ( setFlow({ name: 'select' })} onDev={props.onDev} diff --git a/src/cli/tui/screens/add/AddScreen.tsx b/src/cli/tui/screens/add/AddScreen.tsx index 8bef9efc0..a05db1ee1 100644 --- a/src/cli/tui/screens/add/AddScreen.tsx +++ b/src/cli/tui/screens/add/AddScreen.tsx @@ -16,7 +16,8 @@ export type AddResourceType = | 'config-bundle' | 'ab-test' | 'dataset' - | 'payment'; + | 'payment-manager' + | 'payment-connector'; const BASE_ADD_RESOURCES: { id: AddResourceType; title: string; description: string }[] = [ { id: 'agent', title: 'Agent', description: 'Deploy an HTTP, MCP, A2A, or AG-UI agent' }, @@ -31,7 +32,12 @@ const BASE_ADD_RESOURCES: { id: AddResourceType; title: string; description: str { id: 'dataset', title: 'Dataset', description: 'Evaluation dataset for testing agents' }, { id: 'config-bundle', title: 'Configuration Bundle [preview]', description: 'Versioned component configurations' }, { id: 'ab-test', title: 'AB Test [preview]', description: 'Compare agent configurations with traffic splitting' }, - { id: 'payment', title: 'Payment', description: 'x402 crypto microtransactions' }, + { id: 'payment-manager', title: 'Payment Manager', description: 'x402 crypto microtransactions config' }, + { + id: 'payment-connector', + title: 'Payment Connector', + description: 'Link payment provider credentials to a manager', + }, ]; const ADD_RESOURCES: { id: AddResourceType; title: string; description: string }[] = [ diff --git a/src/cli/tui/screens/add/__tests__/AddScreen.test.tsx b/src/cli/tui/screens/add/__tests__/AddScreen.test.tsx index 4dff9cf29..d1592e14f 100644 --- a/src/cli/tui/screens/add/__tests__/AddScreen.test.tsx +++ b/src/cli/tui/screens/add/__tests__/AddScreen.test.tsx @@ -13,4 +13,14 @@ describe('AddScreen', () => { expect(lastFrame()).toContain('Gateway'); expect(lastFrame()).toContain('Gateway Target'); }); + + it('payment manager and connector are separate top-level options', () => { + const onSelect = vi.fn(); + const onExit = vi.fn(); + + const { lastFrame } = render(); + + expect(lastFrame()).toContain('Payment Manager'); + expect(lastFrame()).toContain('Payment Connector'); + }); }); diff --git a/src/cli/tui/screens/payment/AddPaymentFlow.tsx b/src/cli/tui/screens/payment/AddPaymentFlow.tsx index 7adbbc641..cea6f19d0 100644 --- a/src/cli/tui/screens/payment/AddPaymentFlow.tsx +++ b/src/cli/tui/screens/payment/AddPaymentFlow.tsx @@ -26,9 +26,23 @@ interface AddPaymentFlowProps { onBack: () => void; onDev?: () => void; onDeploy?: () => void; + /** + * Which payment sub-resource to jump straight into, skipping the + * manager/connector picker. When set, Esc from the wizard returns to the + * caller (onBack) rather than the (skipped) picker. Defaults to 'select' + * (show the picker) for any caller that doesn't specify one. + */ + initialAction?: 'manager' | 'connector' | 'select'; } -export function AddPaymentFlow({ isInteractive = true, onExit, onBack, onDev, onDeploy }: AddPaymentFlowProps) { +export function AddPaymentFlow({ + isInteractive = true, + onExit, + onBack, + onDev, + onDeploy, + initialAction = 'select', +}: AddPaymentFlowProps) { const [flow, setFlow] = useState({ name: 'loading' }); const [managerNames, setManagerNames] = useState([]); const { createPayment, reset: resetCreate } = useCreatePayment(); @@ -53,24 +67,40 @@ export function AddPaymentFlow({ isInteractive = true, onExit, onBack, onDev, on if (flow.name !== 'confirm') confirmHandlerRef.current = null; }, [flow]); - // Load existing managers from disk on mount — always show selection screen + // Load existing managers from disk on mount, then route based on initialAction. + // - 'manager' -> jump straight into the manager wizard + // - 'connector' -> jump into the connector flow (0/1/many-manager handling, mirrors handleSelectAction) + // - 'select' -> show the manager/connector picker (default; legacy behavior) + // We branch on the freshly-loaded `names`, not the managerNames state (not yet committed this tick). useEffect(() => { let cancelled = false; + const route = (names: string[]) => { + if (cancelled) return; + setManagerNames(names); + if (initialAction === 'manager') { + setFlow({ name: 'manager-wizard' }); + } else if (initialAction === 'connector') { + if (names.length === 0) { + setFlow({ name: 'error', message: 'No payment managers exist. Create a manager first.' }); + } else if (names.length === 1) { + setConnectorManagerName(names[0]); + void refreshConnectorNames(names[0]); + setFlow({ name: 'connector-wizard', preSelectedManager: names[0] }); + } else { + setFlow({ name: 'connector-wizard' }); + } + } else { + setFlow({ name: 'select' }); + } + }; void paymentManagerPrimitive .getExistingManagers() - .then(names => { - if (cancelled) return; - setManagerNames(names); - setFlow({ name: 'select' }); - }) - .catch(() => { - if (cancelled) return; - setManagerNames([]); - setFlow({ name: 'select' }); - }); + .then(route) + .catch(() => route([])); return () => { cancelled = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- run once on mount; initialAction is stable per render of this flow }, []); // In non-interactive mode, exit after success @@ -189,7 +219,10 @@ export function AddPaymentFlow({ isInteractive = true, onExit, onBack, onDev, on existingManagerNames={managerNames} onComplete={handleManagerComplete} onExit={() => { - if (managerNames.length === 0) { + // When launched directly into the manager wizard (no picker shown), Esc + // returns to the caller. Otherwise fall back to the picker (or onBack if + // there were no managers to pick from). + if (initialAction === 'manager' || managerNames.length === 0) { onBack(); } else { setFlow({ name: 'select' }); @@ -427,7 +460,13 @@ export function AddPaymentFlow({ isInteractive = true, onExit, onBack, onDev, on }} onExit={() => { resetConnector(); - setFlow({ name: 'select' }); + // When launched directly into the connector wizard (no picker shown), + // Esc returns to the caller rather than the skipped picker. + if (initialAction === 'connector') { + onBack(); + } else { + setFlow({ name: 'select' }); + } }} /> ); @@ -457,7 +496,12 @@ export function AddPaymentFlow({ isInteractive = true, onExit, onBack, onDev, on onBack={() => { resetCreate(); resetConnector(); - if (managerNames.length === 0) { + // When launched directly into a single sub-resource (no picker shown), + // back from an error returns to the caller rather than dropping the user + // on the skipped picker or an unrequested manager wizard. + if (initialAction !== 'select') { + onBack(); + } else if (managerNames.length === 0) { setFlow({ name: 'manager-wizard' }); } else { setFlow({ name: 'select' }); From 29b20c815acf2d39388014499bc0023ce3287cfb Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 8 Jun 2026 22:42:22 +0000 Subject: [PATCH 10/16] fix(payments): show payments in the TUI status view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The interactive `status` screen (ResourceGraph) iterated every resource type except payments, so a deployed Payment Manager and its connectors were silently absent — even though the data was already computed by the shared status path and `status --type payment` showed it. Add a Payments section modeled on the Gateways block (manager as parent, connectors indented as children), keyed on `payment:` to pick up live deploy status, and include payments in the empty-state guard. --- src/cli/tui/components/ResourceGraph.tsx | 32 ++++++++++++++ .../__tests__/ResourceGraph.test.tsx | 44 +++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/src/cli/tui/components/ResourceGraph.tsx b/src/cli/tui/components/ResourceGraph.tsx index 94cf31355..6e24915b4 100644 --- a/src/cli/tui/components/ResourceGraph.tsx +++ b/src/cli/tui/components/ResourceGraph.tsx @@ -137,6 +137,7 @@ export function ResourceGraph({ project, mcp, agentName, resourceStatuses }: Res const configBundles = project.configBundles ?? []; const datasets = project.datasets ?? []; const abTests = project.abTests ?? []; + const payments = project.payments ?? []; // Build lookup map and collect pending-removal resources in a single pass const { statusMap, pendingRemovals } = useMemo(() => { @@ -169,6 +170,7 @@ export function ResourceGraph({ project, mcp, agentName, resourceStatuses }: Res policyEngines.length > 0 || mcpRuntimeTools.length > 0 || unassignedTargets.length > 0 || + payments.length > 0 || pendingRemovals.length > 0; return ( @@ -378,6 +380,36 @@ export function ResourceGraph({ project, mcp, agentName, resourceStatuses }: Res )} + {/* Payments — manager (parent) with its connectors (children) */} + {payments.length > 0 && ( + + Payments + {payments.map(manager => { + const rsEntry = statusMap.get(`payment:${manager.name}`); + const localDetail = `${manager.authorizerType} — auto-pay ${manager.autoPayment ? 'on' : 'off'}`; + return ( + + + {manager.connectors.map(connector => ( + + {' '} + {ICONS.tool} {connector.name} + [{connector.provider}] + + ))} + + ); + })} + + )} + {/* Removed locally — still deployed in AWS, will be torn down on next deploy */} {pendingRemovals.length > 0 && ( diff --git a/src/cli/tui/components/__tests__/ResourceGraph.test.tsx b/src/cli/tui/components/__tests__/ResourceGraph.test.tsx index b6eb34dfa..875cb42f0 100644 --- a/src/cli/tui/components/__tests__/ResourceGraph.test.tsx +++ b/src/cli/tui/components/__tests__/ResourceGraph.test.tsx @@ -122,6 +122,50 @@ describe('ResourceGraph', () => { expect(lastFrame()).toContain('target-a'); }); + it('renders payments section with manager and connectors', () => { + const project = { + ...baseProject, + payments: [ + { + name: 'my-manager', + authorizerType: 'AWS_IAM', + autoPayment: true, + defaultSpendLimit: '10.00', + connectors: [{ name: 'my-cdp-conn', provider: 'CoinbaseCDP', credentialName: 'my-manager-my-cdp-conn-cdp' }], + }, + ], + } as unknown as AgentCoreProjectSpec; + + const { lastFrame } = render(); + + expect(lastFrame()).toContain('Payments'); + expect(lastFrame()).toContain('my-manager'); + expect(lastFrame()).toContain('my-cdp-conn'); + expect(lastFrame()).toContain('CoinbaseCDP'); + }); + + it('renders payment manager deployment badge from resourceStatuses', () => { + const project = { + ...baseProject, + payments: [{ name: 'my-manager', authorizerType: 'AWS_IAM', autoPayment: true, connectors: [] }], + } as unknown as AgentCoreProjectSpec; + + const resourceStatuses: ResourceStatusEntry[] = [ + { + resourceType: 'payment', + name: 'my-manager', + deploymentState: 'deployed', + detail: 'AWS_IAM — auto-pay on (1 connector(s))', + identifier: 'arn:aws:bedrock-agentcore:us-east-1:123:payment-manager/my-manager-abc', + }, + ]; + + const { lastFrame } = render(); + + expect(lastFrame()).toContain('Payments'); + expect(lastFrame()).toContain('my-manager'); + }); + it('renders MCP gateway with deployment badge when resourceStatuses provided', () => { const mcp: AgentCoreMcpSpec = { agentCoreGateways: [ From 03ca61d0ea18baac6a2d35f8dd768c049e2a6c38 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 8 Jun 2026 23:11:43 +0000 Subject: [PATCH 11/16] feat(payments): support --auto-session in the interactive invoke TUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--auto-session` was CLI-only: it forced non-interactive mode and was never threaded into the TUI. Now `agentcore invoke --auto-session` (no prompt) launches the interactive chat and mints/reuses a payment session ONCE at TUI start — scoped to the resolved payment identity, reused on every turn (held in a ref since it is only read in invoke()'s closure, never rendered). Mirrors the CLI mint in action.ts, including its try/catch so a mint failure surfaces as a TUI error screen instead of a hard exit. The --auto-session + --payment-session-id mutual exclusion is enforced at the command boundary before rendering. With a prompt or --json, --auto-session still runs one-shot CLI as before. --- .../commands/invoke/__tests__/invoke.test.ts | 34 ++++++++++-- src/cli/commands/invoke/command.tsx | 28 +++++++--- src/cli/tui/App.tsx | 2 + src/cli/tui/screens/invoke/InvokeScreen.tsx | 4 ++ src/cli/tui/screens/invoke/useInvokeFlow.ts | 55 ++++++++++++++++++- 5 files changed, 107 insertions(+), 16 deletions(-) diff --git a/src/cli/commands/invoke/__tests__/invoke.test.ts b/src/cli/commands/invoke/__tests__/invoke.test.ts index 72e564bc6..49ac97335 100644 --- a/src/cli/commands/invoke/__tests__/invoke.test.ts +++ b/src/cli/commands/invoke/__tests__/invoke.test.ts @@ -191,10 +191,10 @@ describe('invoke command', () => { // This lets us assert the routing change without an interactive harness. // -------------------------------------------------------------------------- describe('payments mode routing', () => { - it('--auto-session still forces CLI mode (reaches action layer, not the TUI guard)', async () => { - // With a prompt to bypass the "prompt required" check, --auto-session must - // reach the action layer. The mutual-exclusion check there is action-layer - // proof that we did NOT route to the interactive TUI. + it('--auto-session + a prompt runs one-shot CLI (reaches action layer, not the TUI guard)', async () => { + // A resolved prompt forces CLI mode; --auto-session then mints a session in the + // action layer. The mutual-exclusion check there is action-layer proof that we + // did NOT route to the interactive TUI. const result = await runCLI( ['invoke', 'hi', '--auto-session', '--payment-session-id', 's1', '--json'], projectDir, @@ -210,15 +210,37 @@ describe('invoke command', () => { expect(result.stderr).not.toContain('requires an interactive terminal'); }); - it('--auto-session without a prompt forces CLI mode (JSON error, not TUI guard)', async () => { + it('--auto-session WITH --json stays on the CLI path (--json forces CLI)', async () => { + // --json is in the CLI-forcing condition, so --auto-session + --json emits + // structured JSON rather than routing to the TUI. const result = await runCLI(['invoke', '--auto-session', '--json'], projectDir, { env: telemetry.env }); expect(result.exitCode).toBe(1); - // Forced into CLI/JSON mode: stdout is structured JSON, NOT the TUI guard text. const json = JSON.parse(result.stdout); expect(json.success).toBe(false); expect(result.stderr).not.toContain('requires an interactive terminal'); }); + it('--auto-session alone (no prompt/json) routes to the interactive TUI', async () => { + // NEW behavior: --auto-session no longer forces CLI mode on its own. With no + // prompt and no --json it routes to the TUI -> requireTTY() fires (no TTY in + // the spawned process), proving it did NOT take the one-shot CLI path. + const result = await runCLI(['invoke', '--auto-session'], projectDir, { env: telemetry.env }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('requires an interactive terminal'); + expect(result.stdout).not.toContain('"success"'); + }); + + it('--auto-session + --payment-session-id (no prompt) is rejected before the TUI renders', async () => { + // Mutual exclusion is enforced at the command boundary before renderTUI, so the + // user gets a plain-text error, not the TUI guard and not a half-rendered screen. + const result = await runCLI(['invoke', '--auto-session', '--payment-session-id', 's1'], projectDir, { + env: telemetry.env, + }); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('mutually exclusive'); + expect(result.stderr).not.toContain('requires an interactive terminal'); + }); + it('a payment flag alone (no prompt/json) routes to the interactive TUI', async () => { // NEW behavior: explicit payment flags no longer force CLI mode on their own, // so this routes to the TUI -> requireTTY() fires (the spawned process has no diff --git a/src/cli/commands/invoke/command.tsx b/src/cli/commands/invoke/command.tsx index 857781b9d..1e17e7a89 100644 --- a/src/cli/commands/invoke/command.tsx +++ b/src/cli/commands/invoke/command.tsx @@ -281,13 +281,12 @@ export const registerInvoke = (program: Command) => { cliOptions.bearerToken || cliOptions.harness || cliOptions.harnessArn || - cliOptions.verbose || - // --auto-session is a CLI-only convenience (it mints a session via the - // control API); it forces non-interactive mode. The explicit payment - // params (--payment-instrument-id / --payment-session-id / - // --payment-user-id) are carried into interactive mode instead, so they - // do NOT force CLI mode on their own. - cliOptions.autoSession + cliOptions.verbose + // Payment params (--auto-session / --payment-instrument-id / + // --payment-session-id / --payment-user-id) do NOT force CLI mode on + // their own — with a prompt they run one-shot (resolved.prompt above), + // without a prompt they carry into the interactive TUI. --auto-session + // mints/reuses a session at TUI start; see useInvokeFlow. ) { const result = await withCommandRunTelemetry( 'invoke', @@ -359,6 +358,20 @@ export const registerInvoke = (program: Command) => { process.exit(result.exitCode ?? (result.success ? 0 : 1)); } else { // No CLI options - interactive TUI mode (headers still passed if provided) + + // Validate flag combinations BEFORE the TTY check: a conflicting + // --auto-session + --payment-session-id is wrong regardless of terminal, + // and the flag-conflict message is clearer than the TTY guard. Single + // source of truth: validateInvokeOptions. + const validation = validateInvokeOptions({ + autoSession: cliOptions.autoSession, + paymentSessionId: cliOptions.paymentSessionId, + }); + if (!validation.valid) { + console.error(validation.error); + process.exit(1); + } + requireTTY(); // Parse custom headers for TUI mode @@ -376,6 +389,7 @@ export const registerInvoke = (program: Command) => { bearerToken: cliOptions.bearerToken, paymentInstrumentId: cliOptions.paymentInstrumentId, paymentSessionId: cliOptions.paymentSessionId, + autoSession: cliOptions.autoSession, // Default the payments wallet-owner identity to --user-id when // --payment-user-id is omitted (same fallback as the command path). paymentUserId: cliOptions.paymentUserId ?? cliOptions.userId, diff --git a/src/cli/tui/App.tsx b/src/cli/tui/App.tsx index 20eadde7b..f7c578aa6 100644 --- a/src/cli/tui/App.tsx +++ b/src/cli/tui/App.tsx @@ -45,6 +45,7 @@ type Route = paymentInstrumentId?: string; paymentSessionId?: string; paymentUserId?: string; + autoSession?: boolean; } | { name: 'logs' } | { name: 'create' } @@ -245,6 +246,7 @@ function AppContent({ initialPaymentInstrumentId={route.paymentInstrumentId} initialPaymentSessionId={route.paymentSessionId} initialPaymentUserId={route.paymentUserId} + initialAutoSession={route.autoSession} /> ); } diff --git a/src/cli/tui/screens/invoke/InvokeScreen.tsx b/src/cli/tui/screens/invoke/InvokeScreen.tsx index 5d1a0a70c..860e43132 100644 --- a/src/cli/tui/screens/invoke/InvokeScreen.tsx +++ b/src/cli/tui/screens/invoke/InvokeScreen.tsx @@ -30,6 +30,8 @@ interface InvokeScreenProps { initialPaymentSessionId?: string; /** Payments end-user identity (wallet owner) forwarded as body user_id on every turn */ initialPaymentUserId?: string; + /** When true, auto-create/reuse a payment session at TUI start, reused across turns */ + initialAutoSession?: boolean; } type Mode = 'select-agent' | 'chat' | 'input' | 'token-input'; @@ -162,6 +164,7 @@ export function InvokeScreen({ initialPaymentInstrumentId, initialPaymentSessionId, initialPaymentUserId, + initialAutoSession, }: InvokeScreenProps) { const preview = isPreviewEnabled(); const { @@ -195,6 +198,7 @@ export function InvokeScreen({ initialPaymentInstrumentId, initialPaymentSessionId, initialPaymentUserId, + initialAutoSession, }); const [mode, setMode] = useState(initialHarnessName ? 'input' : 'select-agent'); const [isExecInput, setIsExecInput] = useState(false); diff --git a/src/cli/tui/screens/invoke/useInvokeFlow.ts b/src/cli/tui/screens/invoke/useInvokeFlow.ts index d3355f6e9..e015a0857 100644 --- a/src/cli/tui/screens/invoke/useInvokeFlow.ts +++ b/src/cli/tui/screens/invoke/useInvokeFlow.ts @@ -15,6 +15,7 @@ import { type McpToolDef, buildAguiRunInput, executeBashCommand, + getOrCreatePaymentSession, invokeA2ARuntime, invokeAgentRuntimeStreaming, invokeAguiRuntime, @@ -82,6 +83,8 @@ export interface InvokeFlowOptions { initialPaymentSessionId?: string; /** Payments end-user identity (wallet owner) forwarded as the body user_id on every invocation */ initialPaymentUserId?: string; + /** When true, auto-create/reuse a payment session once at TUI start, reused on every turn */ + initialAutoSession?: boolean; } export type TokenFetchState = 'idle' | 'fetching' | 'fetched' | 'error'; @@ -126,10 +129,14 @@ export function useInvokeFlow(options: InvokeFlowOptions = {}): InvokeFlowState initialPaymentInstrumentId, initialPaymentSessionId, initialPaymentUserId, + initialAutoSession, } = options; // Payment context is established once at session start and reused on every turn. const paymentsActive = - Boolean(initialPaymentInstrumentId) || Boolean(initialPaymentSessionId) || Boolean(initialPaymentUserId); + Boolean(initialPaymentInstrumentId) || + Boolean(initialPaymentSessionId) || + Boolean(initialPaymentUserId) || + Boolean(initialAutoSession); const [phase, setPhase] = useState<'loading' | 'ready' | 'invoking' | 'error'>('loading'); const [config, setConfig] = useState(null); const [selectedAgent, setSelectedAgent] = useState(0); @@ -145,6 +152,12 @@ export function useInvokeFlow(options: InvokeFlowOptions = {}): InvokeFlowState const [tokenFetchError, setTokenFetchError] = useState(null); const [tokenExpiresIn, setTokenExpiresIn] = useState(undefined); + // Payment session id actually used on each turn. Seeded from --payment-session-id; + // when --auto-session is set it is minted once during load() and reused thereafter. + // A ref (not state) because it is only read inside invoke()'s async closure — never + // rendered — so it must reflect the minted value immediately, without a render lag. + const resolvedPaymentSessionIdRef = useRef(initialPaymentSessionId); + // MCP state const [mcpTools, setMcpTools] = useState([]); const [mcpToolsFetched, setMcpToolsFetched] = useState(false); @@ -274,6 +287,41 @@ export function useInvokeFlow(options: InvokeFlowOptions = {}): InvokeFlowState setSessionId(newId); } + // --auto-session: mint (or reuse) a payment session ONCE at TUI start, + // scoped to the same identity the agent will pay as, and reuse it on every + // turn. Mirrors the CLI path in commands/invoke/action.ts. The mutual + // exclusion with --payment-session-id is enforced before render in command.tsx. + if (initialAutoSession && !initialPaymentSessionId) { + const payments = targetState?.resources?.payments; + const firstManager = payments ? Object.values(payments)[0] : undefined; + if (!firstManager?.managerArn) { + return { + success: false as const, + error: new ResourceNotFoundError( + '--auto-session requires a deployed payment manager. Run `agentcore deploy` first.' + ), + }; + } + const paymentSpec = project.payments?.find(p => p.name === Object.keys(payments!)[0]); + try { + resolvedPaymentSessionIdRef.current = await getOrCreatePaymentSession({ + region: targetConfig.region, + userId: initialPaymentUserId ?? DEFAULT_RUNTIME_USER_ID, + managerArn: firstManager.managerArn, + defaultSpendLimit: paymentSpec?.defaultSpendLimit, + }); + } catch (err) { + // Surface as a TUI error screen rather than letting the rejection + // escape to the global handler and hard-exit. Mirrors action.ts. + return { + success: false as const, + error: new Error( + `--auto-session failed to create payment session: ${err instanceof Error ? err.message : String(err)}` + ), + }; + } + } + setPhase('ready'); return { success: true as const }; } @@ -753,7 +801,9 @@ export function useInvokeFlow(options: InvokeFlowOptions = {}): InvokeFlowState bearerToken: bearerToken || undefined, baggage: agent.baggage, paymentInstrumentId: initialPaymentInstrumentId, - paymentSessionId: initialPaymentSessionId, + // Use the resolved session id (auto-minted at load() when --auto-session + // is set) so the same session is reused on every turn. + paymentSessionId: resolvedPaymentSessionIdRef.current, paymentUserId: initialPaymentUserId, }); @@ -805,7 +855,6 @@ export function useInvokeFlow(options: InvokeFlowOptions = {}): InvokeFlowState getMcpInvokeOptions, streamHarnessInvoke, initialPaymentInstrumentId, - initialPaymentSessionId, initialPaymentUserId, ] ); From 76b97a1382aae4286197912eee1576d0d4787beb Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 8 Jun 2026 23:15:05 +0000 Subject: [PATCH 12/16] feat(invoke): group --help flags into labelled sections `agentcore invoke --help` listed ~30 flags as one flat block. Apply the same pattern as `add ab-test` (opt.hidden + addHelpText): keep Core flags in the default Options list and group the rest under Payments / Output / MCP & Advanced sections, with Harness and Model-override sections gated behind preview so they only appear when those flags are registered. --- src/cli/commands/invoke/command.tsx | 92 +++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/src/cli/commands/invoke/command.tsx b/src/cli/commands/invoke/command.tsx index 1e17e7a89..ae98d7159 100644 --- a/src/cli/commands/invoke/command.tsx +++ b/src/cli/commands/invoke/command.tsx @@ -201,6 +201,98 @@ export const registerInvoke = (program: Command) => { .option('--actor-id ', 'Override memory actor ID (harness only) [non-interactive] [preview]'); } + // Group the long flag list into labelled sections (mirrors `add ab-test`). + // Core flags (prompt/prompt-file/runtime/target/session-id/user-id) stay in the + // default "Options:" block; everything else is hidden there and re-listed under a + // section heading below. Preview/harness sections are only emitted when registered. + const hiddenFromDefaultHelp = new Set([ + // Payments + '--payment-user-id', + '--payment-instrument-id', + '--payment-session-id', + '--auto-session', + // Output + '--json', + '--stream', + // MCP & advanced + '--tool', + '--input', + '--exec', + '--timeout', + '--header', + '--bearer-token', + // Harness + model overrides (preview) + '--harness', + '--harness-arn', + '--region', + '--verbose', + '--model-id', + '--model-provider', + '--api-key-arn', + '--tools', + '--max-iterations', + '--max-tokens', + '--harness-timeout', + '--skills', + '--system-prompt', + '--allowed-tools', + '--actor-id', + ]); + for (const opt of invokeCmd.options) { + if (hiddenFromDefaultHelp.has(opt.long ?? '')) { + opt.hidden = true; + } + } + + invokeCmd.addHelpText( + 'after', + ` +Payments [non-interactive] + --payment-user-id End-user/wallet-owner identity (defaults to --user-id) + --payment-instrument-id Payment instrument (wallet) ID + --payment-session-id Payment session ID for budget tracking + --auto-session Auto-create/reuse a payment session for testing + +Output [non-interactive] + --json Output as JSON + --stream Stream response in real-time + +MCP & Advanced [non-interactive] + --tool MCP tool name (use with "call-tool" prompt) + --input MCP tool arguments as JSON (use with --tool) + --exec Execute a shell command in the runtime container + --timeout Timeout in seconds for --exec commands + -H, --header
Custom header "Name: Value" (repeatable) + --bearer-token Bearer token for CUSTOM_JWT auth (bypasses SigV4) +` + ); + + if (isPreviewEnabled()) { + invokeCmd.addHelpText( + 'after', + ` +Harness [non-interactive] [preview] + --harness Select specific harness to invoke + --harness-arn Invoke a harness by ARN (no project required) + --region AWS region (required with --harness-arn) + --verbose Print verbose streaming JSON events + +Model & Runtime Overrides (harness only) [non-interactive] [preview] + --model-id Override model + --model-provider bedrock, open_ai, or gemini + --api-key-arn API key ARN for open_ai/gemini + --tools Override tools (comma-separated) + --allowed-tools Override allowed tools (comma-separated) + --skills Skills (comma-separated paths) + --system-prompt Override system prompt + --actor-id Override memory actor ID + --max-iterations Override max iterations + --max-tokens Override max tokens + --harness-timeout Override timeout seconds +` + ); + } + invokeCmd.action( async ( positionalPrompt: string | undefined, From 71eb6b1e29048fdb8cde8a3cb3c1ada5354f0185 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 8 Jun 2026 23:21:07 +0000 Subject: [PATCH 13/16] test(payments): drop stale 'pattern' from unit-test fixtures Follow-up to 0ec6cacf (pattern removal): four unit-test fixtures still passed the removed 'pattern' field to add()/manager literals. They were fixed in the working tree but missed from that commit's `git add`, leaving HEAD's tests referencing a field the production type no longer has (clean-checkout typecheck failed). Commit the fixtures so HEAD typechecks standalone. --- .../operations/deploy/__tests__/assert-env-file.test.ts | 4 ---- .../__tests__/PaymentConnectorPrimitive.test.ts | 1 - .../primitives/__tests__/PaymentManagerPrimitive.test.ts | 8 -------- .../primitives/__tests__/wirePaymentCapability.test.ts | 2 -- 4 files changed, 15 deletions(-) diff --git a/src/cli/operations/deploy/__tests__/assert-env-file.test.ts b/src/cli/operations/deploy/__tests__/assert-env-file.test.ts index 208342def..7b2a36cda 100644 --- a/src/cli/operations/deploy/__tests__/assert-env-file.test.ts +++ b/src/cli/operations/deploy/__tests__/assert-env-file.test.ts @@ -78,7 +78,6 @@ describe('assertEnvFileExists', () => { { name: 'PayMgr', authorizerType: 'AWS_IAM', - pattern: 'interceptor', connectors: [{ name: 'cdpconn', provider: 'CoinbaseCDP', credentialName: 'PayMgr-cdpconn-cdp' }], } as any, ], @@ -96,7 +95,6 @@ describe('assertEnvFileExists', () => { { name: 'PayMgr', authorizerType: 'AWS_IAM', - pattern: 'interceptor', connectors: [ { name: 'stripeconn', provider: 'StripePrivy', credentialName: 'PayMgr-stripeconn-stripe-privy' }, ], @@ -121,7 +119,6 @@ describe('assertEnvFileExists', () => { { name: 'PayMgr', authorizerType: 'AWS_IAM', - pattern: 'interceptor', connectors: [{ name: 'cdpconn', provider: 'CoinbaseCDP', credentialName: 'PayMgr-cdpconn-cdp' }], } as any, ], @@ -144,7 +141,6 @@ describe('getAllCredentials', () => { { name: 'PayMgr', authorizerType: 'AWS_IAM', - pattern: 'interceptor', connectors: [{ name: 'cdpconn', provider: 'CoinbaseCDP', credentialName: 'PayMgr-cdpconn-cdp' }], } as any, ], diff --git a/src/cli/primitives/__tests__/PaymentConnectorPrimitive.test.ts b/src/cli/primitives/__tests__/PaymentConnectorPrimitive.test.ts index 27c9db2b9..3fc1136d3 100644 --- a/src/cli/primitives/__tests__/PaymentConnectorPrimitive.test.ts +++ b/src/cli/primitives/__tests__/PaymentConnectorPrimitive.test.ts @@ -62,7 +62,6 @@ function makeManager( return { name, authorizerType: 'AWS_IAM' as const, - pattern: 'interceptor' as const, autoPayment: true, defaultSpendLimit: '10.00', connectors, diff --git a/src/cli/primitives/__tests__/PaymentManagerPrimitive.test.ts b/src/cli/primitives/__tests__/PaymentManagerPrimitive.test.ts index 1bdd05b12..05f701b60 100644 --- a/src/cli/primitives/__tests__/PaymentManagerPrimitive.test.ts +++ b/src/cli/primitives/__tests__/PaymentManagerPrimitive.test.ts @@ -48,7 +48,6 @@ function makePaymentManager( return { name, authorizerType: 'AWS_IAM' as const, - pattern: 'interceptor' as const, autoPayment: true, defaultSpendLimit: '10.00', connectors: connectors.map(c => ({ @@ -80,7 +79,6 @@ describe('PaymentManagerPrimitive', () => { const result = await primitive.add({ name: 'myManager', authorizerType: 'AWS_IAM', - pattern: 'interceptor', }); expect(result.success).toBe(true); @@ -105,7 +103,6 @@ describe('PaymentManagerPrimitive', () => { await primitive.add({ name: 'noAutoMgr', authorizerType: 'AWS_IAM', - pattern: 'interceptor', autoPayment: false, }); @@ -120,7 +117,6 @@ describe('PaymentManagerPrimitive', () => { await primitive.add({ name: 'richManager', authorizerType: 'AWS_IAM', - pattern: 'tool-based', description: 'My payment manager', autoPayment: true, defaultSpendLimit: '50.00', @@ -148,7 +144,6 @@ describe('PaymentManagerPrimitive', () => { allowedClients: ['client1', 'client2'], allowedAudience: ['aud1'], allowedScopes: ['scope1'], - pattern: 'interceptor', }); expect(result.success).toBe(true); @@ -170,7 +165,6 @@ describe('PaymentManagerPrimitive', () => { const result = await primitive.add({ name: 'existingManager', authorizerType: 'AWS_IAM', - pattern: 'interceptor', }); expect(result.success).toBe(false); @@ -187,7 +181,6 @@ describe('PaymentManagerPrimitive', () => { const result = await primitive.add({ name: 'jwtManager', authorizerType: 'CUSTOM_JWT', - pattern: 'interceptor', // no discoveryUrl }); @@ -205,7 +198,6 @@ describe('PaymentManagerPrimitive', () => { const result = await primitive.add({ name: 'anyManager', authorizerType: 'AWS_IAM', - pattern: 'interceptor', }); expect(result.success).toBe(false); diff --git a/src/cli/primitives/__tests__/wirePaymentCapability.test.ts b/src/cli/primitives/__tests__/wirePaymentCapability.test.ts index 9e6dbeb4f..2aae99017 100644 --- a/src/cli/primitives/__tests__/wirePaymentCapability.test.ts +++ b/src/cli/primitives/__tests__/wirePaymentCapability.test.ts @@ -112,7 +112,6 @@ const PARENT_INIT = `${AGENT_DIR}/capabilities/__init__.py`; const ADD_OPTIONS = { name: 'payments-mgr', authorizerType: 'AWS_IAM' as const, - pattern: 'interceptor' as const, }; /** Call primitive.add() which internally calls wirePaymentCapability() for every runtime */ @@ -366,7 +365,6 @@ describe('wirePaymentCapability (via PaymentManagerPrimitive.add)', () => { { name: ADD_OPTIONS.name, authorizerType: ADD_OPTIONS.authorizerType, - pattern: ADD_OPTIONS.pattern, autoPayment: true, defaultSpendLimit: '10.00', connectors: [], From 93fa78317bd3921935c04945d278d5e8bf203cfe Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Mon, 8 Jun 2026 23:21:52 +0000 Subject: [PATCH 14/16] fix(payments): fail fast when a connector credential ARN is unresolved The vended bin/cdk.ts mapped an unresolved payment credentialProviderArn to an empty string, which then failed opaquely server-side at CreatePaymentConnector. Throw an actionable error at synth time naming the connector, manager, and missing credential instead. Snapshot updated (vended asset). --- .../__snapshots__/assets.snapshot.test.ts.snap | 18 +++++++++++++----- src/assets/cdk/bin/cdk.ts | 18 +++++++++++++----- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap index b9330ba2e..40d6b6d1e 100644 --- a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap +++ b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap @@ -178,11 +178,19 @@ async function main() { autoPayment: p.autoPayment, paymentToolAllowlist: p.paymentToolAllowlist, networkPreferences: p.networkPreferences, - connectors: p.connectors.map(c => ({ - name: c.name, - provider: c.provider, - credentialProviderArn: paymentCredentials?.[c.credentialName]?.credentialProviderArn ?? '', - })), + connectors: p.connectors.map(c => { + const credentialProviderArn = paymentCredentials?.[c.credentialName]?.credentialProviderArn; + if (!credentialProviderArn) { + // Fail fast with an actionable message rather than passing an empty + // ARN that fails opaquely server-side at CreatePaymentConnector. + throw new Error( + \`Payment connector "\${c.name}" on manager "\${p.name}" references credential \` + + \`"\${c.credentialName}", but no deployed credential provider was found for it. \` + + \`Run \\\`agentcore deploy\\\` so the credential provider is created first.\` + ); + } + return { name: c.name, provider: c.provider, credentialProviderArn }; + }), }) ) : undefined; diff --git a/src/assets/cdk/bin/cdk.ts b/src/assets/cdk/bin/cdk.ts index 7fa086991..65ed6b8f7 100644 --- a/src/assets/cdk/bin/cdk.ts +++ b/src/assets/cdk/bin/cdk.ts @@ -133,11 +133,19 @@ async function main() { autoPayment: p.autoPayment, paymentToolAllowlist: p.paymentToolAllowlist, networkPreferences: p.networkPreferences, - connectors: p.connectors.map(c => ({ - name: c.name, - provider: c.provider, - credentialProviderArn: paymentCredentials?.[c.credentialName]?.credentialProviderArn ?? '', - })), + connectors: p.connectors.map(c => { + const credentialProviderArn = paymentCredentials?.[c.credentialName]?.credentialProviderArn; + if (!credentialProviderArn) { + // Fail fast with an actionable message rather than passing an empty + // ARN that fails opaquely server-side at CreatePaymentConnector. + throw new Error( + `Payment connector "${c.name}" on manager "${p.name}" references credential ` + + `"${c.credentialName}", but no deployed credential provider was found for it. ` + + `Run \`agentcore deploy\` so the credential provider is created first.` + ); + } + return { name: c.name, provider: c.provider, credentialProviderArn }; + }), }) ) : undefined; From 627b2021740e0af24b7100d19d1d0aa8b4eb3d17 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 9 Jun 2026 15:42:54 +0000 Subject: [PATCH 15/16] fix(payments): use isZipExcludedEntry at the zip-packaging sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rebase left collectFiles/collectFilesSync calling shouldExcludeEntry with a rootDir arg that isn't in scope there (TS2304). Restore the two-helper split from the original branch: shouldExcludeEntry (copy stage, has rootDir) and isZipExcludedEntry (zip stage, no rootDir) — both drop build artefacts and .env secret files; only the copy stage skips the config dir at root. --- src/lib/packaging/helpers.ts | 47 +++++++++++++++++++++++++----------- 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/src/lib/packaging/helpers.ts b/src/lib/packaging/helpers.ts index 602e2a00a..e45c86301 100644 --- a/src/lib/packaging/helpers.ts +++ b/src/lib/packaging/helpers.ts @@ -54,27 +54,46 @@ interface ResolvedPaths { const EXCLUDED_ENTRIES = new Set(['.git', '.venv', '__pycache__', '.pytest_cache', '.DS_Store', 'node_modules']); /** - * Decide whether a directory entry should be skipped when packaging the - * source tree. Excludes: + * True for any .env / .env.local / .env.* file — per-environment secret files + * customers expect to stay local. Excluded at every packaging stage and depth. + */ +function isEnvSecretEntry(entryName: string): boolean { + // .env.* (e.g. .env.production, .env.development) is the same family. + return entryName === '.env' || entryName === '.env.local' || entryName.startsWith('.env.'); +} + +/** + * Decide whether a directory entry should be skipped at the COPY stage (when + * staging the source tree into the artifact dir). Excludes: * - the build-tooling artefacts in EXCLUDED_ENTRIES (.git / .venv / etc.) * - the project agentcore/ config directory ONLY when it sits at the * root of the package source (an in-tree dependency that ships its own * agentcore/ sub-module — see issue #843 — must still be packaged). - * - any .env / .env.local / .env.* file at any depth (per-environment - * secrets that customers expect to stay local). + * - any .env / .env.local / .env.* file at any depth. * - * The third bucket closes a footgun where a project with `--code-location .` - * (BYO at project root) would otherwise have `agentcore/.env.local` shipped - * inside the deploy zip — but is itself depth-aware to avoid breaking - * legitimate dependency code. + * The .env bucket closes a footgun where a project with `--code-location .` + * (BYO at project root) would otherwise have `agentcore/.env.local` staged + * — but is depth-aware to avoid breaking legitimate dependency code. */ function shouldExcludeEntry(entryName: string, source: string, rootDir: string): boolean { if (EXCLUDED_ENTRIES.has(entryName)) return true; if (entryName === CONFIG_DIR && resolve(source) === resolve(rootDir)) return true; - if (entryName === '.env' || entryName === '.env.local') return true; - // .env.* (e.g. .env.production, .env.development) — same family of - // environment-secret files, always local-only. - if (entryName.startsWith('.env.')) return true; + if (isEnvSecretEntry(entryName)) return true; + return false; +} + +/** + * Decide whether a directory entry should be skipped at the ZIP stage. The zip + * runs against the staging directory (not the project root), where the + * project's own agentcore/ config dir is already absent — so a top-level + * `agentcore/` here is a real Python package (e.g. an installed dependency) + * and MUST be included (issue #1408 / PR #1424). Unlike the copy stage, we + * therefore do NOT skip CONFIG_DIR; we only drop build artefacts and .env + * secret files. + */ +function isZipExcludedEntry(entryName: string): boolean { + if (EXCLUDED_ENTRIES.has(entryName)) return true; + if (isEnvSecretEntry(entryName)) return true; return false; } @@ -224,7 +243,7 @@ async function collectFiles(directory: string, basePath = ''): Promise const entries = await readdir(directory, { withFileTypes: true }); for (const entry of entries) { - if (shouldExcludeEntry(entry.name, directory, rootDir)) continue; + if (isZipExcludedEntry(entry.name)) continue; const fullPath = join(directory, entry.name); const zipPath = basePath ? `${basePath}/${entry.name}` : entry.name; @@ -421,7 +440,7 @@ function collectFilesSync(directory: string, basePath = ''): Zippable { const entries = readdirSync(directory, { withFileTypes: true }); for (const entry of entries) { - if (shouldExcludeEntry(entry.name, directory, rootDir)) continue; + if (isZipExcludedEntry(entry.name)) continue; const fullPath = join(directory, entry.name); const zipPath = basePath ? `${basePath}/${entry.name}` : entry.name; From d6d29c6a966165da24cba049c8d9aeb750018ff6 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 9 Jun 2026 19:51:00 +0000 Subject: [PATCH 16/16] test(payments): give cfgMgr a connector before validate in e2e The 'accepts paymentToolAllowlist and networkPreferences' case reused the connector-less cfgMgr and expected `validate` to exit 0, but validate correctly rejects any manager with zero connectors. The two new fields parse fine; the failure was the 'has no connectors' business rule. Attach a connector to cfgMgr before injecting the fields and validating, and hoist the inline fs import to the top per AGENTS.md. --- e2e-tests/payment-validation.test.ts | 30 ++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/e2e-tests/payment-validation.test.ts b/e2e-tests/payment-validation.test.ts index b7c819f8e..09078487d 100644 --- a/e2e-tests/payment-validation.test.ts +++ b/e2e-tests/payment-validation.test.ts @@ -8,7 +8,7 @@ import { parseJsonOutput, prereqs } from '../src/test-utils/index.js'; import { runAgentCoreCLI } from './e2e-helper.js'; import { randomUUID } from 'node:crypto'; -import { mkdir, readFile, rm } from 'node:fs/promises'; +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; @@ -77,16 +77,38 @@ describe.sequential('e2e: payments — validation, config, and remove lifecycle' }); it.skipIf(!canRun)('agentcore.json accepts paymentToolAllowlist and networkPreferences', async () => { + // validate requires every manager to have at least one connector, so attach one to cfgMgr first. + const connResult = await runAgentCoreCLI( + [ + 'add', + 'payment-connector', + '--manager', + 'cfgMgr', + '--name', + 'cfgConn', + '--provider', + 'CoinbaseCDP', + '--api-key-id', + 'key-id', + '--api-key-secret', + 'key-secret', + '--wallet-secret', + 'wallet-secret', + '--json', + ], + projectPath + ); + expect(connResult.exitCode).toBe(0); + const configPath = join(projectPath, 'agentcore', 'agentcore.json'); const config = JSON.parse(await readFile(configPath, 'utf-8')); const mgr = config.payments.find((p: Record) => p.name === 'cfgMgr'); mgr.paymentToolAllowlist = ['http_request', 'fetch_url']; mgr.networkPreferences = ['eip155:84532']; - const { writeFile: wf } = await import('node:fs/promises'); - await wf(configPath, JSON.stringify(config, null, 2)); + await writeFile(configPath, JSON.stringify(config, null, 2)); const valResult = await runAgentCoreCLI(['validate'], projectPath); - expect(valResult.exitCode).toBe(0); + expect(valResult.exitCode, `validate failed: ${valResult.stdout} ${valResult.stderr}`).toBe(0); }); // ── Validation: whitespace credentials ────────────────────────────────────