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 8922102a6..d99e74e7a 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -139,14 +139,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 @@ -472,6 +472,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. @@ -659,19 +738,22 @@ agentcore remove gateway-target --name WeatherTools agentcore remove policy-engine --name MyPolicyEngine agentcore remove policy --name AdminAccess --engine MyPolicyEngine agentcore remove runtime-endpoint --name prod +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 | --- @@ -735,23 +817,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 9fad266c2..2ef1eb0a2 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; @@ -114,10 +110,31 @@ 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, + paymentSpec, env, description: \`AgentCore stack for \${spec.name} deployed to \${target.name} (\${target.region})\`, tags: { @@ -132,7 +149,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); }); " `; @@ -259,12 +276,33 @@ 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 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. @@ -278,6 +316,30 @@ export interface AgentCoreStackProps extends StackProps { * Credential provider ARNs from deployed state, keyed by credential name. */ credentials?: Record; + /** + * 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'); } /** @@ -293,7 +355,7 @@ export class AgentCoreStack extends Stack { constructor(scope: Construct, id: string, props: AgentCoreStackProps) { super(scope, id, props); - const { spec, mcpSpec, credentials } = props; + const { spec, mcpSpec, credentials, paymentSpec } = props; // Create AgentCoreApplication with all agents this.application = new AgentCoreApplication(this, 'Application', { @@ -311,6 +373,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', @@ -383,9 +561,9 @@ test('AgentCoreStack synthesizes with empty spec', () => { evaluators: [], onlineEvalConfigs: [], policyEngines: [], + payments: [], + configBundles: [], agentCoreGateways: [], - mcpRuntimeTools: [], - unassignedTargets: [], }, }); const template = Template.fromStack(stack); @@ -539,6 +717,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", @@ -1180,8 +1360,8 @@ requires-python = ">=3.10" dependencies = [ "a2a-sdk >= 0.2.0, < 1.0.0", "aws-opentelemetry-distro", - "bedrock-agentcore[a2a] >= 1.0.3", - "google-adk >= 1.0.0, < 2.0.0", + "bedrock-agentcore[a2a] >= 1.8.0", + "google-adk >= 1.0.0", "google-genai >= 1.0.0", ] @@ -1546,7 +1726,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", ] @@ -2127,7 +2307,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", @@ -2442,7 +2622,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", @@ -3191,7 +3371,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")}} @@ -3613,8 +3793,8 @@ requires-python = ">=3.10" dependencies = [ "opentelemetry-distro", "opentelemetry-exporter-otlp", - "google-adk >= 1.17.0, < 2.0.0", - "bedrock-agentcore >= 1.0.3", + "google-adk >= 1.17.0", + "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}} @@ -4129,7 +4309,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", @@ -4556,7 +4736,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}} @@ -4675,6 +4855,9 @@ from memory.session import get_memory_session_manager {{#if sessionStorageMountPath}} import os {{/if}} +{{#if hasPayment}} +from capabilities.payments.payments import create_payments_plugin, PAYMENT_SYSTEM_PROMPT +{{/if}} app = BedrockAgentCoreApp() log = app.logger @@ -4804,12 +4987,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), @@ -4820,6 +5003,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(): @@ -4830,6 +5014,7 @@ def create_agent(): hooks=[ConfigBundleHook()], ) {{else}} +{{#unless hasPayment}} _agent = None def get_or_create_agent(): @@ -4838,9 +5023,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}} @@ -4849,16 +5035,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 @@ -5171,6 +5388,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 7a78b71cd..f3f1e8329 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; @@ -69,10 +65,31 @@ 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, + paymentSpec, env, description: `AgentCore stack for ${spec.name} deployed to ${target.name} (${target.region})`, tags: { @@ -87,5 +104,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 a4d277821..29a42e65c 100644 --- a/src/assets/cdk/lib/cdk-stack.ts +++ b/src/assets/cdk/lib/cdk-stack.ts @@ -1,12 +1,33 @@ 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 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. @@ -20,6 +41,30 @@ export interface AgentCoreStackProps extends StackProps { * Credential provider ARNs from deployed state, keyed by credential name. */ credentials?: Record; + /** + * 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'); } /** @@ -35,7 +80,7 @@ export class AgentCoreStack extends Stack { constructor(scope: Construct, id: string, props: AgentCoreStackProps) { super(scope, id, props); - const { spec, mcpSpec, credentials } = props; + const { spec, mcpSpec, credentials, paymentSpec } = props; // Create AgentCoreApplication with all agents this.application = new AgentCoreApplication(this, 'Application', { @@ -53,6 +98,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 df5c767f9..fdef7572b 100644 --- a/src/assets/cdk/test/cdk.test.ts +++ b/src/assets/cdk/test/cdk.test.ts @@ -15,9 +15,9 @@ test('AgentCoreStack synthesizes with empty spec', () => { evaluators: [], onlineEvalConfigs: [], policyEngines: [], + payments: [], + configBundles: [], agentCoreGateways: [], - mcpRuntimeTools: [], - unassignedTargets: [], }, }); const template = Template.fromStack(stack); diff --git a/src/assets/python/a2a/googleadk/base/pyproject.toml b/src/assets/python/a2a/googleadk/base/pyproject.toml index c7f0e237a..b2a024a51 100644 --- a/src/assets/python/a2a/googleadk/base/pyproject.toml +++ b/src/assets/python/a2a/googleadk/base/pyproject.toml @@ -11,8 +11,8 @@ requires-python = ">=3.10" dependencies = [ "a2a-sdk >= 0.2.0, < 1.0.0", "aws-opentelemetry-distro", - "bedrock-agentcore[a2a] >= 1.0.3", - "google-adk >= 1.0.0, < 2.0.0", + "bedrock-agentcore[a2a] >= 1.8.0", + "google-adk >= 1.0.0", "google-genai >= 1.0.0", ] diff --git a/src/assets/python/a2a/langchain_langgraph/base/pyproject.toml b/src/assets/python/a2a/langchain_langgraph/base/pyproject.toml index c906639ed..386d9a272 100644 --- a/src/assets/python/a2a/langchain_langgraph/base/pyproject.toml +++ b/src/assets/python/a2a/langchain_langgraph/base/pyproject.toml @@ -16,7 +16,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 b07d45567..2ac834668 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", diff --git a/src/assets/python/agui/langchain_langgraph/base/pyproject.toml b/src/assets/python/agui/langchain_langgraph/base/pyproject.toml index 6e0e59de7..39e65cfaa 100644 --- a/src/assets/python/agui/langchain_langgraph/base/pyproject.toml +++ b/src/assets/python/agui/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 >= 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/googleadk/base/pyproject.toml b/src/assets/python/http/googleadk/base/pyproject.toml index 9a885cf3a..44eb74afb 100644 --- a/src/assets/python/http/googleadk/base/pyproject.toml +++ b/src/assets/python/http/googleadk/base/pyproject.toml @@ -11,8 +11,8 @@ requires-python = ">=3.10" dependencies = [ "opentelemetry-distro", "opentelemetry-exporter-otlp", - "google-adk >= 1.17.0, < 2.0.0", - "bedrock-agentcore >= 1.0.3", + "google-adk >= 1.17.0", + "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/langchain_langgraph/base/pyproject.toml b/src/assets/python/http/langchain_langgraph/base/pyproject.toml index ce261f6bf..6bf078a02 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 0cc8771ad..731cb4cb0 100644 --- a/src/assets/python/http/strands/base/main.py +++ b/src/assets/python/http/strands/base/main.py @@ -18,6 +18,9 @@ {{#if sessionStorageMountPath}} import os {{/if}} +{{#if hasPayment}} +from capabilities.payments.payments import create_payments_plugin, PAYMENT_SYSTEM_PROMPT +{{/if}} app = BedrockAgentCoreApp() log = app.logger @@ -147,12 +150,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), @@ -163,6 +166,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(): @@ -173,6 +177,7 @@ def create_agent(): hooks=[ConfigBundleHook()], ) {{else}} +{{#unless hasPayment}} _agent = None def get_or_create_agent(): @@ -181,9 +186,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}} @@ -192,16 +198,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 b99ea2f4e..5883f9584 100644 --- a/src/cli/aws/agentcore.ts +++ b/src/cli/aws/agentcore.ts @@ -68,6 +68,10 @@ export interface InvokeAgentRuntimeOptions { bearerToken?: string; /** W3C baggage header value (e.g. config bundle ref for runtime) */ baggage?: string; + /** Payment instrument ID for x402 payments */ + paymentInstrumentId?: string; + /** Payment session ID for budget tracking */ + paymentSessionId?: string; } export interface InvokeAgentRuntimeResult { @@ -147,6 +151,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 // --------------------------------------------------------------------------- @@ -198,7 +217,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) { @@ -284,7 +303,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) { @@ -316,7 +335,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, @@ -412,7 +431,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 7c87fec34..a5ad2effb 100644 --- a/src/cli/aws/index.ts +++ b/src/cli/aws/index.ts @@ -26,6 +26,14 @@ export { type GetPolicyGenerationOptions, type GetPolicyGenerationResult, } from './policy-generation'; +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 4444f2795..7db2eaea0 100644 --- a/src/cli/cdk/toolkit-lib/wrapper.ts +++ b/src/cli/cdk/toolkit-lib/wrapper.ts @@ -102,9 +102,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, @@ -113,6 +117,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 377cc3e9d..43482502f 100644 --- a/src/cli/cloudformation/outputs.ts +++ b/src/cli/cloudformation/outputs.ts @@ -4,6 +4,7 @@ import type { EvaluatorDeployedState, MemoryDeployedState, OnlineEvalDeployedState, + PaymentDeployedState, PolicyDeployedState, PolicyEngineDeployedState, RuntimeEndpointDeployedState, @@ -375,6 +376,73 @@ export function parseRuntimeEndpointOutputs( return endpoints; } +/** + * 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; @@ -389,6 +457,7 @@ export interface BuildDeployedStateOptions { policyEngines?: Record; policies?: Record; runtimeEndpoints?: Record; + payments?: Record; } /** @@ -409,6 +478,7 @@ export function buildDeployedState(opts: BuildDeployedStateOptions): DeployedSta policyEngines, policies, runtimeEndpoints, + payments, } = opts; const targetState: TargetDeployedState = { resources: { @@ -466,6 +536,11 @@ export function buildDeployedState(opts: BuildDeployedStateOptions): DeployedSta targetState.resources!.httpGateways = existingHttpGateways; } + // 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 eba2ab113..aa972c987 100644 --- a/src/cli/commands/deploy/actions.ts +++ b/src/cli/commands/deploy/actions.ts @@ -12,6 +12,7 @@ import { parseGatewayOutputs, parseMemoryOutputs, parseOnlineEvalOutputs, + parsePaymentOutputs, parsePolicyEngineOutputs, parsePolicyOutputs, parseRuntimeEndpointOutputs, @@ -19,6 +20,7 @@ import { import { getErrorMessage } from '../../errors'; import { ExecLogger } from '../../logging'; import { + assertEnvFileExists, bootstrapEnvironment, buildCdkProject, checkBootstrapNeeded, @@ -41,6 +43,11 @@ import { } from '../../operations/deploy/post-deploy-config-bundles'; 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'; @@ -171,6 +178,14 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise = {}; @@ -253,6 +268,40 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise 0) { const existingPreSynthState = await configIO.readDeployedState().catch(() => ({ targets: {} }) as DeployedState); @@ -269,10 +318,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); @@ -463,6 +526,21 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise ({ + 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; + const existingState = await configIO.readDeployedState().catch(() => undefined); let deployedState = buildDeployedState({ targetName: target.name, @@ -478,8 +556,27 @@ 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 9bdcd6987..aeb434d51 100644 --- a/src/cli/commands/dev/browser-mode.ts +++ b/src/cli/commands/dev/browser-mode.ts @@ -134,6 +134,11 @@ export async function runBrowserMode(opts: BrowserModeOptions): Promise { const { workingDir, project, agentName, 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 0f67615fc..9ce7ffdf9 100644 --- a/src/cli/commands/dev/command.tsx +++ b/src/cli/commands/dev/command.tsx @@ -341,7 +341,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 ad21c7113..b2bf33cf1 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 } from '../../../schema'; import { + DEFAULT_RUNTIME_USER_ID, buildAguiRunInput, executeBashCommand, + getOrCreatePaymentSession, invokeA2ARuntime, invokeAgentRuntime, invokeAgentRuntimeStreaming, @@ -162,6 +164,57 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption options = { ...options, sessionId: generateSessionId() }; } + // 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 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({ @@ -485,6 +538,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) { @@ -519,6 +574,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 1359232c3..b8632b348 100644 --- a/src/cli/commands/invoke/command.tsx +++ b/src/cli/commands/invoke/command.tsx @@ -127,6 +127,9 @@ export const registerInvoke = (program: Command) => { [] as string[] ) .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]') .action( async ( positionalPrompt: string | undefined, @@ -145,6 +148,9 @@ export const registerInvoke = (program: Command) => { timeout?: number; header?: string[]; bearerToken?: string; + paymentInstrumentId?: string; + paymentSessionId?: string; + autoSession?: boolean; } ) => { try { @@ -182,7 +188,10 @@ export const registerInvoke = (program: Command) => { cliOptions.runtime || cliOptions.tool || cliOptions.exec || - cliOptions.bearerToken + cliOptions.bearerToken || + cliOptions.paymentInstrumentId || + cliOptions.paymentSessionId || + cliOptions.autoSession ) { const result = await withCommandRunTelemetry( 'invoke', @@ -220,6 +229,9 @@ export const registerInvoke = (program: Command) => { timeout: cliOptions.timeout, headers, bearerToken: cliOptions.bearerToken, + 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 86411214c..d207c1ed2 100644 --- a/src/cli/commands/invoke/types.ts +++ b/src/cli/commands/invoke/types.ts @@ -22,6 +22,12 @@ export interface InvokeOptions { headers?: Record; /** Bearer token for CUSTOM_JWT auth (bypasses SigV4) */ bearerToken?: 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 1cd58c625..0aca8b124 100644 --- a/src/cli/commands/logs/__tests__/action.test.ts +++ b/src/cli/commands/logs/__tests__/action.test.ts @@ -63,6 +63,7 @@ describe('resolveAgentContext', () => { configBundles: [], abTests: [], httpGateways: [], + payments: [], }, deployedState: { targets: { @@ -127,6 +128,7 @@ describe('resolveAgentContext', () => { configBundles: [], abTests: [], httpGateways: [], + payments: [], }, }); const result = resolveAgentContext(context, {}); @@ -171,6 +173,7 @@ describe('resolveAgentContext', () => { configBundles: [], abTests: [], httpGateways: [], + payments: [], }, deployedState: { targets: { @@ -225,6 +228,7 @@ describe('resolveAgentContext', () => { configBundles: [], abTests: [], httpGateways: [], + payments: [], }, }); const result = resolveAgentContext(context, {}); diff --git a/src/cli/commands/remove/command.tsx b/src/cli/commands/remove/command.tsx index 369a323d7..3238c2307 100644 --- a/src/cli/commands/remove/command.tsx +++ b/src/cli/commands/remove/command.tsx @@ -1,5 +1,9 @@ -import { ConfigIO, serializeResult, toError } from '../../../lib'; +import { ConfigIO, removeEnvVars, serializeResult, toError } from '../../../lib'; import { getErrorMessage } from '../../errors'; +import { + computePaymentCredentialEnvVarNames, + computeStripePrivyCredentialEnvVarNames, +} from '../../primitives/credential-utils'; import { runCliCommand } from '../../telemetry/cli-command-run.js'; import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; import { requireProject, requireTTY } from '../../tui/guards'; @@ -10,10 +14,26 @@ import type { Command } from '@commander-js/extra-typings'; import { Text, render } from 'ink'; import React from 'react'; -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 { @@ -23,11 +43,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 b45c3ba4a..752802cad 100644 --- a/src/cli/commands/remove/types.ts +++ b/src/cli/commands/remove/types.ts @@ -12,7 +12,9 @@ export type ResourceType = | 'policy-engine' | 'policy' | 'config-bundle' - | 'ab-test'; + | 'ab-test' + | '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 e821b1f32..c96ebe489 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 { ExecLogger } from '../../logging'; @@ -23,7 +24,8 @@ export interface ResourceStatusEntry { | 'policy' | 'config-bundle' | 'ab-test' - | 'runtime-endpoint'; + | 'runtime-endpoint' + | 'payment'; name: string; deploymentState: ResourceDeploymentState; identifier?: string; @@ -286,6 +288,14 @@ export function computeResourceStatuses( getParentName: item => item.agentName, }); + 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, @@ -298,6 +308,7 @@ export function computeResourceStatuses( ...policies, ...configBundles, ...abTests, + ...payments, ]; } @@ -471,6 +482,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 a155d71f0..715b7928e 100644 --- a/src/cli/commands/status/command.tsx +++ b/src/cli/commands/status/command.tsx @@ -16,6 +16,7 @@ const VALID_RESOURCE_TYPES = [ 'gateway', 'evaluator', 'online-eval', + 'payment', 'policy-engine', 'policy', 'config-bundle', @@ -62,7 +63,7 @@ export const registerStatus = (program: Command) => { .option('--target ', 'Select deployment target') .option( '--type ', - 'Filter by resource type (agent, runtime-endpoint, memory, credential, gateway, evaluator, online-eval, policy-engine, policy, config-bundle, ab-test)' + 'Filter by resource type (agent, runtime-endpoint, memory, credential, gateway, evaluator, online-eval, policy-engine, policy, config-bundle, ab-test, payment)' ) .option('--state ', 'Filter by deployment state (deployed, local-only, pending-removal)') .option('--runtime ', 'Filter to a specific runtime') @@ -77,7 +78,7 @@ export const registerStatus = (program: Command) => { Invalid resource type '{cliOptions.type}'. Valid types: {VALID_RESOURCE_TYPES.join(', ')} ); - return; + process.exit(1); } // Validate --state @@ -87,7 +88,7 @@ export const registerStatus = (program: Command) => { Invalid state '{cliOptions.state}'. Valid states: {VALID_STATES.join(', ')} ); - return; + process.exit(1); } try { @@ -153,7 +154,7 @@ export const registerStatus = (program: Command) => { const policies = filtered.filter(r => r.resourceType === 'policy'); const configBundles = filtered.filter(r => r.resourceType === 'config-bundle'); const abTests = filtered.filter(r => r.resourceType === 'ab-test'); - // TODO: Add http-gateway resource type when diffResourceSet for HTTP gateways is added to action.ts + const payments = filtered.filter(r => r.resourceType === 'payment'); render( @@ -292,7 +293,14 @@ export const registerStatus = (program: Command) => { )} - {/* TODO: Add HTTP Gateways render section when diffResourceSet is added to action.ts */} + {payments.length > 0 && ( + + Payments + {payments.map(entry => ( + + ))} + + )} {filtered.length === 0 && No resources match the given filters.} 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 7f2fb2bff..20c947d6a 100644 --- a/src/cli/commands/validate/command.tsx +++ b/src/cli/commands/validate/command.tsx @@ -7,11 +7,23 @@ 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 handleValidate(options); - if (result.success) { + if (options.json) { + if (result.success) { + console.log(JSON.stringify({ success: true })); + } else { + // codeql[js/clear-text-logging]: result.error.message contains validation messages + // and env-var NAMES (e.g. "AGENTCORE_CREDENTIAL_FOO_API_KEY_SECRET" — the variable + // name, not its value). It never contains credential values; the validate flow only + // checks .env.local presence and references vars by name. + 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 462d9be14..58d52dcae 100644 --- a/src/cli/external-requirements/__tests__/checks-extended.test.ts +++ b/src/cli/external-requirements/__tests__/checks-extended.test.ts @@ -56,6 +56,7 @@ describe('requiresUv', () => { configBundles: [], abTests: [], httpGateways: [], + payments: [], }; expect(requiresUv(project)).toBe(true); }); @@ -84,6 +85,7 @@ describe('requiresUv', () => { configBundles: [], abTests: [], httpGateways: [], + payments: [], }; expect(requiresUv(project)).toBe(false); }); @@ -103,6 +105,7 @@ describe('requiresUv', () => { configBundles: [], abTests: [], httpGateways: [], + payments: [], }; expect(requiresUv(project)).toBe(false); }); @@ -133,6 +136,7 @@ describe('requiresContainerRuntime', () => { configBundles: [], abTests: [], httpGateways: [], + payments: [], }; expect(requiresContainerRuntime(project)).toBe(true); }); @@ -161,6 +165,7 @@ describe('requiresContainerRuntime', () => { configBundles: [], abTests: [], httpGateways: [], + payments: [], }; expect(requiresContainerRuntime(project)).toBe(false); }); @@ -180,6 +185,7 @@ describe('requiresContainerRuntime', () => { configBundles: [], abTests: [], httpGateways: [], + payments: [], }; expect(requiresContainerRuntime(project)).toBe(false); }); @@ -216,6 +222,7 @@ describe('requiresContainerRuntime', () => { configBundles: [], abTests: [], httpGateways: [], + payments: [], }; expect(requiresContainerRuntime(project)).toBe(true); }); @@ -286,6 +293,7 @@ describe('checkDependencyVersions', () => { configBundles: [], abTests: [], httpGateways: [], + payments: [], }; const result = await checkDependencyVersions(project); @@ -309,6 +317,7 @@ describe('checkDependencyVersions', () => { configBundles: [], abTests: [], httpGateways: [], + payments: [], }; const result = await checkDependencyVersions(project); @@ -340,6 +349,7 @@ describe('checkDependencyVersions', () => { configBundles: [], abTests: [], httpGateways: [], + payments: [], }; const result = await checkDependencyVersions(project); diff --git a/src/cli/logging/remove-logger.ts b/src/cli/logging/remove-logger.ts index 54f8aa0ba..4d0f42b50 100644 --- a/src/cli/logging/remove-logger.ts +++ b/src/cli/logging/remove-logger.ts @@ -19,7 +19,9 @@ export interface RemoveLoggerOptions { | 'policy-engine' | 'policy' | 'config-bundle' - | 'ab-test'; + | 'ab-test' + | '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 523c89a61..d82111454 100644 --- a/src/cli/operations/agent/generate/schema-mapper.ts +++ b/src/cli/operations/agent/generate/schema-mapper.ts @@ -280,6 +280,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 38c89fd85..f40c208bf 100644 --- a/src/cli/operations/agent/generate/write-agent-to-project.ts +++ b/src/cli/operations/agent/generate/write-agent-to-project.ts @@ -74,6 +74,7 @@ export async function writeAgentToProject(config: GenerateConfig, options?: Writ configBundles: [], abTests: [], httpGateways: [], + 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 75f36ebcc..2d9d3cceb 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 @@ -69,6 +69,7 @@ function makeProjectSpec(abTests: AgentCoreProjectSpec['abTests'] = []): AgentCo configBundles: [], httpGateways: [], abTests, + 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 ecfc285cd..e3c701b9c 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 @@ -507,6 +507,7 @@ describe('resolveConfigBundleComponentKeys', () => { configBundles, httpGateways: [], abTests: [], + 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 32c7e6252..0450b135a 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 @@ -81,6 +81,7 @@ function makeProjectSpec(httpGateways: AgentCoreProjectSpec['httpGateways'] = [] configBundles: [], abTests: [], httpGateways, + 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 93cdb6353..9699b88a6 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..f8b3e29c5 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: AgentCoreProjectSpec['payments'][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 ba423a088..bf6f79af7 100644 --- a/src/cli/operations/deploy/preflight.ts +++ b/src/cli/operations/deploy/preflight.ts @@ -89,8 +89,9 @@ 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 && !hasGateways && !hasMemories && !hasEvaluators && !hasPolicyEngines) { + if (!hasAgents && !hasGateways && !hasMemories && !hasEvaluators && !hasPolicyEngines && !hasPayments) { let hasExistingStack = false; try { const deployedState = await configIO.readDeployedState(); @@ -208,6 +209,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; } /** @@ -228,6 +231,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 { configBundles: [], abTests: [], httpGateways: [], + payments: [], }; const config = getDevConfig(workingDir, project); @@ -55,6 +56,7 @@ describe('getDevConfig', () => { configBundles: [], abTests: [], httpGateways: [], + payments: [], }; const config = getDevConfig(workingDir, project); @@ -85,6 +87,7 @@ describe('getDevConfig', () => { configBundles: [], abTests: [], httpGateways: [], + payments: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -121,6 +124,7 @@ describe('getDevConfig', () => { configBundles: [], abTests: [], httpGateways: [], + payments: [], }; expect(() => getDevConfig(workingDir, project, undefined, 'NonExistentAgent')).toThrow( @@ -152,6 +156,7 @@ describe('getDevConfig', () => { configBundles: [], abTests: [], httpGateways: [], + payments: [], }; const config = getDevConfig(workingDir, project, undefined, 'TsAgent'); @@ -184,6 +189,7 @@ describe('getDevConfig', () => { configBundles: [], abTests: [], httpGateways: [], + payments: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -216,6 +222,7 @@ describe('getDevConfig', () => { configBundles: [], abTests: [], httpGateways: [], + payments: [], }; // No configRoot provided @@ -248,6 +255,7 @@ describe('getDevConfig', () => { configBundles: [], abTests: [], httpGateways: [], + payments: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -280,6 +288,7 @@ describe('getDevConfig', () => { configBundles: [], abTests: [], httpGateways: [], + payments: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -311,6 +320,7 @@ describe('getDevConfig', () => { configBundles: [], abTests: [], httpGateways: [], + payments: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -342,6 +352,7 @@ describe('getDevConfig', () => { configBundles: [], abTests: [], httpGateways: [], + payments: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -373,6 +384,7 @@ describe('getDevConfig', () => { configBundles: [], abTests: [], httpGateways: [], + payments: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -404,6 +416,7 @@ describe('getDevConfig', () => { configBundles: [], abTests: [], httpGateways: [], + payments: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -436,6 +449,7 @@ describe('getDevConfig', () => { configBundles: [], abTests: [], httpGateways: [], + payments: [], }; const config = getDevConfig(workingDir, project, '/test/project/agentcore'); @@ -481,6 +495,7 @@ describe('getAgentPort', () => { configBundles: [], abTests: [], httpGateways: [], + payments: [], }; expect(getAgentPort(project, 'Agent1', 8080)).toBe(8080); @@ -502,6 +517,7 @@ describe('getAgentPort', () => { configBundles: [], abTests: [], httpGateways: [], + payments: [], }; expect(getAgentPort(project, 'NonExistent', 9000)).toBe(9000); @@ -528,6 +544,7 @@ describe('getDevSupportedAgents', () => { configBundles: [], abTests: [], httpGateways: [], + payments: [], }; expect(getDevSupportedAgents(project)).toEqual([]); @@ -557,6 +574,7 @@ describe('getDevSupportedAgents', () => { configBundles: [], abTests: [], httpGateways: [], + payments: [], }; const supported = getDevSupportedAgents(project); @@ -596,6 +614,7 @@ describe('getDevSupportedAgents', () => { configBundles: [], abTests: [], httpGateways: [], + payments: [], }; const supported = getDevSupportedAgents(project); @@ -626,6 +645,7 @@ describe('getDevSupportedAgents', () => { configBundles: [], abTests: [], httpGateways: [], + payments: [], }; const supported = getDevSupportedAgents(project); @@ -665,6 +685,7 @@ describe('getDevSupportedAgents', () => { configBundles: [], abTests: [], httpGateways: [], + 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..66be6bf4f --- /dev/null +++ b/src/cli/primitives/PaymentConnectorPrimitive.ts @@ -0,0 +1,597 @@ +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(); + + 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(); + + 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(); + + 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..98599df92 --- /dev/null +++ b/src/cli/primitives/PaymentManagerPrimitive.ts @@ -0,0 +1,713 @@ +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(); + + 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(); + + 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(); + + 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 fb53e095d..c9c1f351c 100644 --- a/src/cli/primitives/__tests__/GatewayPrimitive.test.ts +++ b/src/cli/primitives/__tests__/GatewayPrimitive.test.ts @@ -16,6 +16,7 @@ const defaultProject: AgentCoreProjectSpec = { configBundles: [], abTests: [], httpGateways: [], + 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..d024e66fc --- /dev/null +++ b/src/cli/primitives/__tests__/PaymentConnectorPrimitive.test.ts @@ -0,0 +1,479 @@ +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: [], + 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..c351ea50d --- /dev/null +++ b/src/cli/primitives/__tests__/PaymentManagerPrimitive.test.ts @@ -0,0 +1,391 @@ +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: [], + 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 5f0e1a7c9..088acef4c 100644 --- a/src/cli/primitives/__tests__/auth-utils.test.ts +++ b/src/cli/primitives/__tests__/auth-utils.test.ts @@ -96,6 +96,7 @@ describe('createManagedOAuthCredential', () => { configBundles: [], abTests: [], httpGateways: [], + 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..7d80dc4d8 --- /dev/null +++ b/src/cli/primitives/__tests__/wirePaymentCapability.test.ts @@ -0,0 +1,917 @@ +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: [], + 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 754b4e182..f4a210dba 100644 --- a/src/cli/primitives/registry.ts +++ b/src/cli/primitives/registry.ts @@ -8,6 +8,8 @@ import { GatewayPrimitive } from './GatewayPrimitive'; import { GatewayTargetPrimitive } from './GatewayTargetPrimitive'; 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'; @@ -28,6 +30,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. @@ -45,6 +49,8 @@ export const ALL_PRIMITIVES: BasePrimitive[] = [ configBundlePrimitive, abTestPrimitive, runtimeEndpointPrimitive, + paymentManagerPrimitive, + paymentConnectorPrimitive, ]; /** diff --git a/src/cli/project.ts b/src/cli/project.ts index 14ea7be3c..3af4b9d19 100644 --- a/src/cli/project.ts +++ b/src/cli/project.ts @@ -21,6 +21,7 @@ export function createDefaultProjectSpec(projectName: string): AgentCoreProjectS configBundles: [], abTests: [], httpGateways: [], + 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 d6943a64e..0ad9e8576 100644 --- a/src/cli/telemetry/schemas/command-run.ts +++ b/src/cli/telemetry/schemas/command-run.ts @@ -164,6 +164,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: DevAttrs, invoke: InvokeAttrs, @@ -202,6 +204,8 @@ export const COMMAND_SCHEMAS = { 'remove.runtime-endpoint': NoAttrs, 'remove.config-bundle': NoAttrs, 'remove.ab-test': NoAttrs, + 'remove.payment-manager': NoAttrs, + 'remove.payment-connector': NoAttrs, 'telemetry.disable': NoAttrs, 'telemetry.enable': NoAttrs, 'telemetry.status': NoAttrs, 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 55c228555..0d737f4ec 100644 --- a/src/cli/templates/render.ts +++ b/src/cli/templates/render.ts @@ -2,6 +2,8 @@ import Handlebars from 'handlebars'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; +const BINARY_EXTENSIONS = new Set(['.whl', '.tgz', '.gz', '.zip', '.tar', '.png', '.jpg', '.ico', '.pyc']); + // Register custom Handlebars helpers Handlebars.registerHelper('eq', (a: unknown, b: unknown) => a === b); Handlebars.registerHelper('includes', (array: unknown[], value: unknown) => { @@ -65,11 +67,16 @@ export async function copyAndRenderDir( if (entry.isDirectory()) { await copyAndRenderDir(srcPath, destPath, data); } else { - 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'); + const ext = path.extname(entry.name).toLowerCase(); + if (BINARY_EXTENSIONS.has(ext)) { + await fs.copyFile(srcPath, destPath); + } else { + const content = await fs.readFile(srcPath, 'utf-8'); + const template = Handlebars.compile(content); + const rendered = template(data); + await fs.writeFile(destPath, rendered, 'utf-8'); + } } } } diff --git a/src/cli/templates/types.ts b/src/cli/templates/types.ts index 907cc99dd..60db2b7c7 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 36504cd62..24363ba69 100644 --- a/src/cli/tui/components/ResourceGraph.tsx +++ b/src/cli/tui/components/ResourceGraph.tsx @@ -23,6 +23,7 @@ const ICONS = { 'config-bundle': '⬡', 'ab-test': '⚗', 'runtime-endpoint': '◉', + payment: '₿', } as const; interface ResourceGraphProps { diff --git a/src/cli/tui/hooks/useCdkPreflight.ts b/src/cli/tui/hooks/useCdkPreflight.ts index 4602c1c14..46db8812d 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' @@ -424,6 +514,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'); @@ -539,11 +642,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'); @@ -782,6 +899,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 9400ea2ad..d553686cf 100644 --- a/src/cli/tui/hooks/useRemove.ts +++ b/src/cli/tui/hooks/useRemove.ts @@ -16,6 +16,8 @@ import { gatewayTargetPrimitive, memoryPrimitive, onlineEvalConfigPrimitive, + paymentConnectorPrimitive, + paymentManagerPrimitive, policyEnginePrimitive, policyPrimitive, runtimeEndpointPrimitive, @@ -184,6 +186,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 eef7f4db2..4c10807df 100644 --- a/src/cli/tui/screens/add/AddFlow.tsx +++ b/src/cli/tui/screens/add/AddFlow.tsx @@ -14,6 +14,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'; @@ -36,6 +37,7 @@ type FlowState = | { name: 'config-bundle-wizard' } | { name: 'ab-test-wizard' } | { name: 'runtime-endpoint-wizard' } + | { name: 'payment-wizard' } | { name: 'agent-create-success'; agentName: string; @@ -191,6 +193,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' }; } @@ -247,6 +251,9 @@ export function AddFlow(props: AddFlowProps) { case 'runtime-endpoint': setFlow({ name: 'runtime-endpoint-wizard' }); break; + case 'payment': + setFlow({ name: 'payment-wizard' }); + break; } }, []); @@ -518,6 +525,19 @@ export function AddFlow(props: AddFlowProps) { ); } + // Payment wizard + if (flow.name === 'payment-wizard') { + return ( + setFlow({ name: 'select' })} + onDev={props.onDev} + onDeploy={props.onDeploy} + /> + ); + } + return ( ({ diff --git a/src/cli/tui/screens/deploy/useDeployFlow.ts b/src/cli/tui/screens/deploy/useDeployFlow.ts index cdeff0915..6adc98c72 100644 --- a/src/cli/tui/screens/deploy/useDeployFlow.ts +++ b/src/cli/tui/screens/deploy/useDeployFlow.ts @@ -8,13 +8,18 @@ import { parseGatewayOutputs, parseMemoryOutputs, parseOnlineEvalOutputs, + parsePaymentOutputs, parsePolicyEngineOutputs, parsePolicyOutputs, } from '../../../cloudformation'; import { DEFAULT_DEPLOY_ATTRS, computeDeployAttrs } from '../../../commands/deploy/utils.js'; import { getErrorMessage, isChangesetInProgressError, isExpiredTokenError } from '../../../errors'; import { ExecLogger } from '../../../logging'; -import { performStackTeardown, setupTransactionSearch } from '../../../operations/deploy'; +import { + cleanupPaymentCredentialProviders, + performStackTeardown, + setupTransactionSearch, +} from '../../../operations/deploy'; import { getGatewayTargetStatuses } from '../../../operations/deploy/gateway-status'; import { deleteOrphanedABTests, setupABTests } from '../../../operations/deploy/post-deploy-ab-tests'; import { @@ -312,6 +317,30 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState // Expose outputs to UI setStackOutputs(outputs); + // Parse payment outputs from CFN stack + const paymentSpecs = (ctx.projectSpec.payments ?? []).map( + (p: { + name: string; + authorizerType?: 'AWS_IAM' | 'CUSTOM_JWT'; + autoPayment?: boolean; + paymentToolAllowlist?: string[]; + networkPreferences?: string[]; + connectors: { name: string; credentialName: string }[]; + }) => ({ + 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); let deployedState = buildDeployedState({ targetName: target.name, @@ -326,6 +355,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState credentials: Object.keys(allCredentials).length > 0 ? allCredentials : undefined, policyEngines, policies, + payments, }); await configIO.writeDeployedState(deployedState); @@ -622,9 +652,21 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState await cdkToolkitWrapper.deploy(); if (context?.isTeardownDeploy) { - // 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..ac59417e2 --- /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 696107486..5a65bb72b 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, @@ -10,6 +11,7 @@ import { useRemovableIdentities, useRemovableMemories, useRemovableOnlineEvalConfigs, + useRemovablePaymentManagers, useRemovablePolicies, useRemovablePolicyEngines, useRemovableRuntimeEndpoints, @@ -62,6 +64,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 } @@ -74,6 +77,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: 'agent-success'; agentName: string; logFilePath?: string } | { name: 'gateway-success'; gatewayName: string; logFilePath?: string } @@ -87,6 +91,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 }; @@ -111,7 +116,10 @@ interface RemoveFlowProps { | 'policy-engine' | 'policy' | 'config-bundle' - | 'ab-test'; + | 'ab-test' + | 'payment' + | 'payment-manager' + | 'payment-connector'; /** Initial resource name to auto-select (for CLI --name flag) */ initialResourceName?: string; } @@ -151,6 +159,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' }; default: return { name: 'select' }; } @@ -186,6 +198,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 = @@ -199,7 +212,8 @@ export function RemoveFlow({ isLoadingPolicyEngines || isLoadingPolicies || isLoadingConfigBundles || - isLoadingRuntimeEndpoints; + isLoadingRuntimeEndpoints || + isLoadingPayments; // Preview hook const { @@ -264,6 +278,7 @@ export function RemoveFlow({ 'config-bundle-success', 'ab-test-success', 'runtime-endpoint-success', + 'payment-success', ]; if (successStates.includes(flow.name)) { onExit(); @@ -312,6 +327,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; @@ -588,6 +606,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) { @@ -633,6 +673,10 @@ export function RemoveFlow({ case 'runtime-endpoint': void handleSelectRuntimeEndpoint(initialResourceName); break; + case 'payment': + case 'payment-manager': + void handleSelectPaymentManager(initialResourceName); + break; } }, 0); }, [ @@ -650,6 +694,7 @@ export function RemoveFlow({ handleSelectConfigBundle, handleSelectABTest, handleSelectRuntimeEndpoint, + handleSelectPaymentManager, ]); // Confirm handlers - pass preview for logging @@ -888,6 +933,7 @@ export function RemoveFlow({ refreshPolicies(), refreshConfigBundles(), refreshRuntimeEndpoints(), + refreshPayments(), ]); }, [ refreshAgents, @@ -901,6 +947,7 @@ export function RemoveFlow({ refreshPolicies, refreshConfigBundles, refreshRuntimeEndpoints, + refreshPayments, ]); // Select screen - wait for data to load to avoid arrow position issues @@ -924,6 +971,7 @@ export function RemoveFlow({ configBundleCount={configBundles.length} abTestCount={abTests.length} runtimeEndpointCount={runtimeEndpoints.length} + paymentCount={paymentManagers.length} /> ); } @@ -1097,6 +1145,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 ( @@ -1230,6 +1300,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 ( @@ -1423,6 +1514,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 b1178e530..ac6b838e9 100644 --- a/src/cli/tui/screens/remove/RemoveScreen.tsx +++ b/src/cli/tui/screens/remove/RemoveScreen.tsx @@ -10,6 +10,7 @@ const REMOVE_RESOURCES = [ { 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' }, @@ -47,6 +48,8 @@ interface RemoveScreenProps { abTestCount: number; /** Number of runtime endpoints available for removal */ runtimeEndpointCount: number; + /** Number of payment managers available for removal */ + paymentCount: number; } export function RemoveScreen({ @@ -64,6 +67,7 @@ export function RemoveScreen({ configBundleCount, abTestCount, runtimeEndpointCount, + paymentCount, }: RemoveScreenProps) { const items: SelectableItem[] = useMemo(() => { return REMOVE_RESOURCES.map(r => { @@ -143,6 +147,12 @@ export function RemoveScreen({ description = 'No runtime endpoints to remove'; } break; + case 'payment': + if (paymentCount === 0) { + disabled = true; + description = 'No payment managers to remove'; + } + break; case 'all': // 'all' is always available break; @@ -163,6 +173,7 @@ export function RemoveScreen({ configBundleCount, abTestCount, runtimeEndpointCount, + 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 ccc59e9da..5ca9dbd6a 100644 --- a/src/cli/tui/screens/remove/__tests__/RemoveScreen.test.tsx +++ b/src/cli/tui/screens/remove/__tests__/RemoveScreen.test.tsx @@ -24,6 +24,7 @@ describe('RemoveScreen', () => { configBundleCount={1} abTestCount={0} runtimeEndpointCount={1} + paymentCount={1} /> ); @@ -57,6 +58,7 @@ describe('RemoveScreen', () => { configBundleCount={0} abTestCount={0} runtimeEndpointCount={0} + paymentCount={0} /> ); @@ -86,6 +88,7 @@ describe('RemoveScreen', () => { configBundleCount={0} abTestCount={2} runtimeEndpointCount={0} + paymentCount={0} /> ); @@ -113,6 +116,7 @@ describe('RemoveScreen', () => { configBundleCount={0} abTestCount={0} runtimeEndpointCount={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 a554edb0e..8792a45e2 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 caa3d3792..5dc4cb781 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,8 +224,7 @@ async function collectFiles(directory: string, rootDir: string, basePath = ''): const entries = await readdir(directory, { withFileTypes: true }); for (const entry of entries) { - if (EXCLUDED_ENTRIES.has(entry.name)) continue; - if (entry.name === CONFIG_DIR && resolve(directory) === resolve(rootDir)) continue; + if (shouldExcludeEntry(entry.name, directory, rootDir)) continue; const fullPath = join(directory, entry.name); const zipPath = basePath ? `${basePath}/${entry.name}` : entry.name; @@ -361,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); @@ -403,8 +421,7 @@ function collectFilesSync(directory: string, rootDir: string, basePath = ''): Zi const entries = readdirSync(directory, { withFileTypes: true }); for (const entry of entries) { - if (EXCLUDED_ENTRIES.has(entry.name)) continue; - if (entry.name === CONFIG_DIR && resolve(directory) === resolve(rootDir)) 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 1ab339321..e4e14d858 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__/payment.test.ts b/src/schema/schemas/__tests__/payment.test.ts new file mode 100644 index 000000000..7cf948375 --- /dev/null +++ b/src/schema/schemas/__tests__/payment.test.ts @@ -0,0 +1,111 @@ +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('accepts underscores after first character', () => { + expect(PaymentManagerNameSchema.safeParse('my_manager').success).toBe(true); + }); + + 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 8cf22df2a..95d95a1d1 100644 --- a/src/schema/schemas/agentcore-project.ts +++ b/src/schema/schemas/agentcore-project.ts @@ -27,6 +27,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'; @@ -73,6 +82,22 @@ export type { ABTestMode, TargetRef, GatewayFilter, PerVariantOnlineEvaluationCo export { ABTestModeSchema, TargetRefSchema, GatewayFilterSchema } from './primitives/ab-test'; export type { HttpGatewayTarget } from './primitives/http-gateway'; export { HttpGatewayTargetSchema } from './primitives/http-gateway'; +export { + PaymentManagerSchema, + PaymentManagerNameSchema, + PaymentConnectorSchema, + PaymentConnectorNameSchema, + PaymentProviderSchema, + PaymentPatternSchema, + PaymentAuthorizerTypeSchema, +}; +export type { + PaymentManager, + PaymentConnector, + PaymentProvider, + PaymentPattern, + PaymentAuthorizerType, +} from './primitives/payment'; // ============================================================================ // ManagedBy Schema @@ -234,7 +259,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({ @@ -261,7 +290,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; @@ -420,6 +461,16 @@ export const AgentCoreProjectSpecSchema = z name => `Duplicate HTTP gateway name: ${name}` ) ), + + payments: z + .array(PaymentManagerSchema) + .default([]) + .superRefine( + uniqueBy( + manager => manager.name, + name => `Duplicate payment manager name: ${name}` + ) + ), }) .strict() .superRefine((spec, ctx) => { @@ -511,6 +562,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 a37469799..3a1247959 100644 --- a/src/schema/schemas/deployed-state.ts +++ b/src/schema/schemas/deployed-state.ts @@ -193,11 +193,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 // ============================================================================ @@ -247,6 +274,7 @@ export const DeployedResourceStateSchema = z.object({ policyEngines: z.record(z.string(), PolicyEngineDeployedStateSchema).optional(), policies: z.record(z.string(), PolicyDeployedStateSchema).optional(), runtimeEndpoints: z.record(z.string(), RuntimeEndpointDeployedStateSchema).optional(), + payments: z.record(z.string(), PaymentDeployedStateSchema).optional(), stackName: z.string().optional(), identityKmsKeyArn: z.string().optional(), }); diff --git a/src/schema/schemas/primitives/index.ts b/src/schema/schemas/primitives/index.ts index 38967a181..f37b73c30 100644 --- a/src/schema/schemas/primitives/index.ts +++ b/src/schema/schemas/primitives/index.ts @@ -70,3 +70,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..6ec13ddf3 --- /dev/null +++ b/src/schema/schemas/primitives/payment.ts @@ -0,0 +1,98 @@ +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 +// ============================================================================ + +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 and underscores (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;