diff --git a/README.md b/README.md index 5a2844324..fa5871212 100644 --- a/README.md +++ b/README.md @@ -113,13 +113,19 @@ agentcore # interactive TUI │ │ └── list # list Rules under a Gateway │ └── policy │ └── generate # generate Cedar for a Gateway from a prompt (TUI when run bare) -├── payment # inspect AgentCore Payments (command line only for now) +├── payment # manage AgentCore Payments (command line only for now) │ ├── manager +│ │ ├── create # create a payment manager (auto-provisions a service role if none given) │ │ ├── get # get a payment manager by id -│ │ └── list # list payment managers (server-side paginated) +│ │ ├── list # list payment managers (server-side paginated) +│ │ ├── update # update a payment manager +│ │ └── delete # delete a payment manager (delete its connectors first) │ ├── connector # connectors under a payment manager +│ │ ├── create # create a connector from a credential provider, or --quick-create for Coinbase │ │ ├── get # get a connector (shows the Quick Create authorization URL while pending) -│ │ └── list # list a manager's connectors +│ │ ├── list # list a manager's connectors +│ │ ├── update # update a connector's description or credential provider +│ │ └── delete # delete a connector │ ├── session # budget-limited payment contexts (data plane) │ │ ├── get │ │ └── list @@ -206,28 +212,35 @@ agentcore project invoke harness \ Use `--target` to select a deployment target. When a project declares exactly one resource of the requested type, `--name` may be omitted. -### Inspect AgentCore Payments +### Manage AgentCore Payments The `payment` commands call the Payments control and data planes directly, with -no project involved. This command family currently provides read-only inspection -of existing managers, connectors, sessions, and instruments. It does not create IAM -roles. The separate `identity payment-credential-provider` commands can create, -inspect, replace, or delete stored Coinbase CDP and Stripe/Privy credentials. +no project involved. A manager created without `--role-arn` gets a default +service role named `AgentCorePayments--` (long names have a stable +hash suffix). Default roles are tagged with their CLI owner, manager name, and +region; only matching roles are reused and have their service policy refreshed. +An unowned role with the same name is not modified. Use `--role-arn` to supply an +existing role, which the CLI never edits. Old regionless default roles are not +migrated automatically, and manager deletion does not delete IAM roles. + +Default role provisioning requires IAM role read/create, tagging, and inline +policy permissions, in addition to the service's role-passing requirements. +For centrally managed IAM policies or stricter per-credential permissions, +provision the service role separately and pass `--role-arn`. ```bash -# Inspect managers and their connectors. -agentcore payment manager list --json -agentcore payment manager get --id -agentcore payment connector list --manager-id +# Create a manager, then a Coinbase connector through Quick Create. The create +# returns PENDING_AUTHENTICATION and an authorizationUrl: open it within ten +# minutes, then confirm the connector reached READY. +agentcore payment manager create --name Checkout +agentcore payment connector create --manager-id --name Coinbase --quick-create agentcore payment connector get --manager-id --connector-id -# Inspect provider metadata stored in AgentCore Identity. -agentcore identity payment-credential-provider list --json -agentcore identity payment-credential-provider get --name - -# Store provider credentials from files, not inline command arguments. +# Or bring your own provider credentials, stored in AgentCore Identity, and +# reference the provider by name (its vendor selects the connector type). agentcore identity payment-credential-provider create --name cdp-creds --vendor CoinbaseCDP \ --api-key-id --api-key-secret file://api-key-secret.txt --wallet-secret file://wallet-secret.txt +agentcore payment connector create --manager-id --name Coinbase --credential-provider cdp-creds # Session and instrument commands take the parent manager ID and a user id. agentcore payment session list --manager-id --user-id alice diff --git a/src/core/harness.tsx b/src/core/harness.tsx index 0d88822ec..5356ec0d3 100644 --- a/src/core/harness.tsx +++ b/src/core/harness.tsx @@ -44,6 +44,7 @@ import { InputValidationError } from "../errors"; import type { AwsClients, CoreOptions } from "./types"; import { abortable } from "./abortable"; import { ensureDefaultExecutionRole } from "./executionRole"; +import { retryWhileRoleUnassumable } from "./roleRetry"; import { toClientConfig } from "./utils"; // HarnessClient implements the harness-facing operations on top of the shared AWS @@ -243,25 +244,3 @@ export function harnessRuntimeFromResponse( runtimeName: runtime.agentRuntimeName, }; } - -// retryWhileRoleUnassumable retries `operation` while it fails with the -// validation error AgentCore raises for an execution role it cannot yet assume -// (fresh IAM roles propagate over several seconds). Any other failure — or -// exhausting the attempts — rethrows. -async function retryWhileRoleUnassumable( - operation: () => Promise, - attempts = 8, - delayMs = 2000, -): Promise { - for (let attempt = 1; ; attempt++) { - try { - return await operation(); - } catch (error) { - const retryable = - (error as Error).name === "ValidationException" && - /role|assume|trust/i.test((error as Error).message ?? ""); - if (!retryable || attempt >= attempts) throw error; - await new Promise((resolve) => setTimeout(resolve, delayMs)); - } - } -} diff --git a/src/core/index.tsx b/src/core/index.tsx index bff250bd0..f9f6c21c8 100644 --- a/src/core/index.tsx +++ b/src/core/index.tsx @@ -103,7 +103,9 @@ export class CoreClient implements AwsClients { ); this.gateway = new GatewayClient(this, fetch, this.logger.child({ module: "gateway" })); this.policy = new PolicyClient(this, this.logger.child({ module: "policy" })); - this.payment = new PaymentClient(this); + // Payment connectors resolve their credential provider through identity, so + // PaymentClient borrows the identity sub-client alongside the shared AWS clients. + this.payment = new PaymentClient(this, this.identity); // EvalClient shares the injected fetch: dataset content is served from a // presigned S3 URL, outside the SDK seam the other operations use. The logger // is used for batch-evaluation result-log diagnostics. diff --git a/src/core/payment.read.test.ts b/src/core/payment.read.test.ts index 66ad240fa..c824a541e 100644 --- a/src/core/payment.read.test.ts +++ b/src/core/payment.read.test.ts @@ -27,8 +27,14 @@ function setup() { const data = mock( (_config: ClientConfig) => ({ send: dataSend }) as unknown as ReturnType, ); + const unexpected = () => { + throw new Error("Unexpected mutation dependency"); + }; return { - client: new PaymentClient({ control, data }), + client: new PaymentClient( + { control, data, iam: unexpected }, + { getPaymentCredentialProvider: unexpected }, + ), control, data, controlSend, diff --git a/src/core/payment.test.ts b/src/core/payment.test.ts new file mode 100644 index 000000000..ed9d32a6f --- /dev/null +++ b/src/core/payment.test.ts @@ -0,0 +1,207 @@ +import { expect, mock, test } from "bun:test"; +import { + CreatePaymentManagerCommand, + GetPaymentConnectorCommand, + UpdatePaymentConnectorCommand, + type GetPaymentCredentialProviderResponse, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { GetRoleCommand } from "@aws-sdk/client-iam"; +import { PaymentClient } from "./payment"; +import type { AwsClients } from "./types"; + +const options = { region: "us-west-2" }; +const ROLE_ARN = "arn:aws:iam::123456789012:role/AgentCorePayments-us-west-2-Checkout"; +const PROVIDER_ARN = + "arn:aws:bedrock-agentcore:us-west-2:123456789012:token-vault/default/paymentcredentialprovider/cdp-creds"; +const connector = { managerId: "manager", name: "Coinbase" }; +const provider = { + credentialProviderArn: PROVIDER_ARN, + credentialProviderVendor: "CoinbaseCDP", +} as GetPaymentCredentialProviderResponse; +type Send = (command: { input: unknown }) => Promise; + +function setup(controlSend: Send = async () => ({}), iamSend?: Send) { + const unexpected = () => { + throw new Error("Unexpected SDK call"); + }; + const send = mock(controlSend); + const identity = { getPaymentCredentialProvider: mock(async (_name: string) => provider) }; + const clients = { + control: () => ({ send }), + data: unexpected, + iam: iamSend ? () => ({ send: iamSend }) : unexpected, + } as unknown as AwsClients; + return { client: new PaymentClient(clients, identity), send, identity }; +} + +test("an explicit manager role bypasses IAM provisioning", async () => { + const { client, send } = setup(); + const input = { name: "Checkout", authorizerType: "AWS_IAM" as const, roleArn: ROLE_ARN }; + await client.createPaymentManager(input, options); + expect(send.mock.calls[0]?.[0]).toBeInstanceOf(CreatePaymentManagerCommand); + expect(send.mock.calls[0]?.[0].input).toEqual(input); +}); + +test("a caller's access denial is not mistaken for service-role propagation", async () => { + const error = Object.assign( + new Error( + "User: arn:aws:sts::123456789012:assumed-role/Admin/session is not authorized to perform: bedrock-agentcore:CreatePaymentManager", + ), + { name: "AccessDeniedException" }, + ); + const { client, send } = setup( + async () => { + throw error; + }, + async (command) => + command instanceof GetRoleCommand + ? { + Role: { + Arn: ROLE_ARN, + Tags: [ + { Key: "agentcore:managed-by", Value: "agentcore-cli" }, + { Key: "agentcore:payment-manager", Value: "Checkout" }, + { Key: "agentcore:region", Value: options.region }, + ], + }, + } + : {}, + ); + await expect( + client.createPaymentManager({ name: "Checkout", authorizerType: "AWS_IAM" }, options), + ).rejects.toBe(error); + expect(send).toHaveBeenCalledTimes(1); +}); + +test("IAM receives explicit credentials but not the AgentCore endpoint override", async () => { + const credentials = { accessKeyId: "test-key", secretAccessKey: "test-secret" }; + const iam = mock(() => { + throw new Error("captured IAM configuration"); + }); + const control = mock(() => ({ send: async () => ({}) })); + const client = new PaymentClient({ iam, control } as unknown as AwsClients, { + getPaymentCredentialProvider: async () => provider, + }); + await expect( + client.createPaymentManager( + { name: "Checkout", authorizerType: "AWS_IAM" }, + { ...options, endpointUrl: "https://payments.example.test", credentials }, + ), + ).rejects.toThrow("captured IAM configuration"); + expect(iam).toHaveBeenCalledWith({ ...options, credentials }); + expect(control).toHaveBeenCalledWith({ + ...options, + endpoint: "https://payments.example.test", + credentials, + }); +}); + +test("a named provider supplies its ARN and vendor; a conflicting vendor is rejected", async () => { + const { client, send, identity } = setup(); + await client.createPaymentConnector({ ...connector, credentialProvider: "cdp-creds" }, options); + expect(identity.getPaymentCredentialProvider).toHaveBeenCalledWith("cdp-creds", options); + expect(send.mock.calls[0]?.[0].input).toEqual({ + paymentManagerId: "manager", + name: "Coinbase", + type: "CoinbaseCDP", + credentialProviderConfigurations: [{ coinbaseCDP: { credentialProviderArn: PROVIDER_ARN } }], + provisionMode: undefined, + }); + await expect( + client.createPaymentConnector( + { + ...connector, + credentialProvider: "cdp-creds", + type: "StripePrivy", + }, + options, + ), + ).rejects.toThrow("cannot back a StripePrivy connector"); + expect(send).toHaveBeenCalledTimes(1); +}); + +test("a provider ARN requires a vendor and skips name resolution", async () => { + const { client, send, identity } = setup(); + await expect( + client.createPaymentConnector( + { + ...connector, + credentialProvider: PROVIDER_ARN, + }, + options, + ), + ).rejects.toThrow("--type is required"); + await client.createPaymentConnector( + { + ...connector, + credentialProvider: PROVIDER_ARN, + type: "StripePrivy", + }, + options, + ); + expect(send.mock.calls[0]?.[0].input).toMatchObject({ + type: "StripePrivy", + credentialProviderConfigurations: [{ stripePrivy: { credentialProviderArn: PROVIDER_ARN } }], + }); + expect(identity.getPaymentCredentialProvider).not.toHaveBeenCalled(); +}); + +test("Quick Create rejects vendors other than Coinbase before sending a request", async () => { + const { client, send } = setup(); + await expect( + client.createPaymentConnector( + { + ...connector, + quickCreate: true, + type: "StripePrivy", + }, + options, + ), + ).rejects.toThrow("Quick Create is available only for CoinbaseCDP"); + expect(send).not.toHaveBeenCalled(); +}); + +test("connector updates resolve replacement credentials but preserve omitted credentials", async () => { + const { client, send, identity } = setup(async (command) => + command instanceof GetPaymentConnectorCommand ? { type: "CoinbaseCDP" } : {}, + ); + const input = { managerId: "manager", connectorId: "connector", description: "updated" }; + await client.updatePaymentConnector({ ...input, credentialProvider: "cdp-creds" }, options); + expect(send.mock.calls[0]?.[0]).toMatchObject({ + input: { paymentManagerId: "manager", paymentConnectorId: "connector" }, + }); + expect(send.mock.calls[1]?.[0]).toBeInstanceOf(UpdatePaymentConnectorCommand); + expect(send.mock.calls[1]?.[0].input).toEqual({ + paymentManagerId: "manager", + paymentConnectorId: "connector", + description: "updated", + credentialProviderConfigurations: [{ coinbaseCDP: { credentialProviderArn: PROVIDER_ARN } }], + clientToken: undefined, + }); + await client.updatePaymentConnector(input, options); + expect(send).toHaveBeenCalledTimes(3); + expect(send.mock.calls[2]?.[0]).toBeInstanceOf(UpdatePaymentConnectorCommand); + expect(send.mock.calls[2]?.[0].input).toEqual({ + paymentManagerId: "manager", + paymentConnectorId: "connector", + description: "updated", + credentialProviderConfigurations: undefined, + clientToken: undefined, + }); + expect(identity.getPaymentCredentialProvider).toHaveBeenCalledTimes(1); +}); + +test("Marketplace errors retain the subscription URL and product name", async () => { + const error = Object.assign(new Error("Subscription required"), { + name: "SubscriptionRequiredException", + subscriptionUrl: "https://aws.amazon.com/marketplace/pp/prodview-example", + productName: "Coinbase Wallets", + }); + const { client } = setup(async () => { + throw error; + }); + const result = client.createPaymentConnector({ ...connector, quickCreate: true }, options); + await expect(result).rejects.toThrow("Coinbase Wallets"); + await expect(result).rejects.toThrow(error.subscriptionUrl); + await expect(result).rejects.toMatchObject({ cause: error, name: error.name }); +}); diff --git a/src/core/payment.tsx b/src/core/payment.tsx index 58a4bc72d..2f93ae28c 100644 --- a/src/core/payment.tsx +++ b/src/core/payment.tsx @@ -1,12 +1,28 @@ import { + CreatePaymentConnectorCommand, + CreatePaymentManagerCommand, + DeletePaymentConnectorCommand, + DeletePaymentManagerCommand, GetPaymentConnectorCommand, GetPaymentManagerCommand, ListPaymentConnectorsCommand, ListPaymentManagersCommand, + UpdatePaymentConnectorCommand, + UpdatePaymentManagerCommand, + type CreatePaymentConnectorResponse, + type CreatePaymentManagerResponse, + type CredentialsProviderConfiguration, + type DeletePaymentConnectorRequest, + type DeletePaymentConnectorResponse, + type DeletePaymentManagerRequest, + type DeletePaymentManagerResponse, type GetPaymentConnectorResponse, type GetPaymentManagerResponse, type ListPaymentConnectorsResponse, type ListPaymentManagersResponse, + type PaymentConnectorType, + type UpdatePaymentConnectorResponse, + type UpdatePaymentManagerResponse, } from "@aws-sdk/client-bedrock-agentcore-control"; import { GetPaymentInstrumentBalanceCommand, @@ -21,26 +37,76 @@ import { type ListPaymentInstrumentsResponse, type ListPaymentSessionsResponse, } from "@aws-sdk/client-bedrock-agentcore"; -import { InputValidationError, MalformedServiceResponseError } from "../errors"; +import { + AgentCoreCLIError, + ERROR_SOURCE, + InputValidationError, + MalformedServiceResponseError, +} from "../errors"; +import type { CoreIdentityClient } from "../handlers/identity/types"; import type { CorePaymentClient, + CreatePaymentConnectorInput, + CreatePaymentManagerInput, GetPaymentSessionInput, ListPaymentSessionsInput, GetPaymentInstrumentInput, GetPaymentInstrumentBalanceInput, ListPaymentInstrumentsInput, + UpdatePaymentConnectorInput, + UpdatePaymentManagerInput, } from "../handlers/payment/types"; +import { ensurePaymentServiceRole } from "./paymentServiceRole"; +import { isRoleUnassumableValidation, retryWhileRoleUnassumable } from "./roleRetry"; import type { AwsClients, CoreOptions } from "./types"; import { toClientConfig } from "./utils"; +const QUICK_CREATE_TYPE: PaymentConnectorType = "CoinbaseCDP"; + // PaymentClient implements the payment-facing operations on top of the shared // AWS clients provided by CoreClient. Managers and connectors live on the control // plane; sessions and instruments on the data plane. export class PaymentClient implements CorePaymentClient { - constructor(private readonly clients: Pick) {} + constructor( + private readonly clients: Pick, + // Payment credential providers live in AgentCore Identity. Connector create + // and update resolve a provider name to its ARN and vendor through the + // identity client rather than re-implementing that lookup here. + private readonly identity: Pick, + ) {} // ─── payment managers ─────────────────────────────────────────────────────── + async createPaymentManager( + input: CreatePaymentManagerInput, + options: CoreOptions, + ): Promise { + const control = this.clients.control(toClientConfig(options)); + const { roleArn, ...request } = input; + if (roleArn) { + return control.send(new CreatePaymentManagerCommand({ ...request, roleArn })); + } + + // No role supplied: provision (or reuse) the default service role, then + // create the manager with it. IAM is eventually consistent — a role created + // moments ago may not yet be assumable by the service principal — so retry + // the create while the service reports the role as unusable. + const defaultRoleArn = await ensurePaymentServiceRole( + // IAM is a global service; the region only selects the endpoint, and the + // agentcore endpoint override must not leak onto it. + this.clients.iam({ + region: options.region, + ...(options.credentials ? { credentials: options.credentials } : {}), + }), + input.name!, + options.region, + ); + return retryWhileRoleUnassumable( + () => control.send(new CreatePaymentManagerCommand({ ...request, roleArn: defaultRoleArn })), + isServiceRoleUnusable(defaultRoleArn), + ); + } + async getPaymentManager(id: string, options: CoreOptions): Promise { return this.clients .control(toClientConfig(options)) @@ -57,8 +123,51 @@ export class PaymentClient implements CorePaymentClient { .send(new ListPaymentManagersCommand({ nextToken, maxResults })); } + async updatePaymentManager( + input: UpdatePaymentManagerInput, + options: CoreOptions, + ): Promise { + return this.clients + .control(toClientConfig(options)) + .send(new UpdatePaymentManagerCommand({ ...input })); + } + + async deletePaymentManager( + request: DeletePaymentManagerRequest, + options: CoreOptions, + ): Promise { + return this.clients + .control(toClientConfig(options)) + .send(new DeletePaymentManagerCommand({ ...request })); + } + // ─── payment connectors ───────────────────────────────────────────────────── + async createPaymentConnector( + input: CreatePaymentConnectorInput, + options: CoreOptions, + ): Promise { + const { type, credentialProviderConfigurations } = await this.resolveConnectorCredentials( + input, + options, + ); + try { + return await this.clients.control(toClientConfig(options)).send( + new CreatePaymentConnectorCommand({ + paymentManagerId: input.managerId, + name: input.name, + ...(input.description !== undefined ? { description: input.description } : {}), + type, + credentialProviderConfigurations, + provisionMode: input.quickCreate ? "QUICK_CREATE" : undefined, + ...(input.clientToken !== undefined ? { clientToken: input.clientToken } : {}), + }), + ); + } catch (error) { + throw subscriptionRequired(error); + } + } + async getPaymentConnector( managerId: string, connectorId: string, @@ -85,7 +194,59 @@ export class PaymentClient implements CorePaymentClient { ); } - // ─── payment sessions (data plane) ────────────────────────────────────────── + async updatePaymentConnector( + input: UpdatePaymentConnectorInput, + options: CoreOptions, + ): Promise { + const control = this.clients.control(toClientConfig(options)); + + // A replacement credential provider has to land in the union member matching + // the connector's type, and the type is not changeable, so read it first. + let credentialProviderConfigurations: CredentialsProviderConfiguration[] | undefined; + if (input.credentialProvider !== undefined) { + const current = await control.send( + new GetPaymentConnectorCommand({ + paymentManagerId: input.managerId, + paymentConnectorId: input.connectorId, + }), + ); + if (!current.type) { + throw new AgentCoreCLIError( + `payment connector "${input.connectorId}" returned no type; cannot choose a credential configuration`, + { source: ERROR_SOURCE.SERVICE }, + ); + } + const resolved = await this.resolveCredentialProvider( + input.credentialProvider, + current.type, + options, + ); + credentialProviderConfigurations = [credentialConfiguration(current.type, resolved.arn)]; + } + + try { + return await control.send( + new UpdatePaymentConnectorCommand({ + paymentManagerId: input.managerId, + paymentConnectorId: input.connectorId, + description: input.description, + credentialProviderConfigurations, + clientToken: input.clientToken, + }), + ); + } catch (error) { + throw subscriptionRequired(error); + } + } + + async deletePaymentConnector( + request: DeletePaymentConnectorRequest, + options: CoreOptions, + ): Promise { + return this.clients + .control(toClientConfig(options)) + .send(new DeletePaymentConnectorCommand({ ...request })); + } async getPaymentSession( input: GetPaymentSessionInput, @@ -107,8 +268,6 @@ export class PaymentClient implements CorePaymentClient { ); } - // ─── payment instruments (data plane) ─────────────────────────────────────── - async getPaymentInstrument( input: GetPaymentInstrumentInput, options: CoreOptions, @@ -141,6 +300,78 @@ export class PaymentClient implements CorePaymentClient { // ─── helpers ──────────────────────────────────────────────────────────────── + private async resolveConnectorCredentials( + input: Pick, + options: CoreOptions, + ): Promise<{ + type: PaymentConnectorType; + credentialProviderConfigurations: CredentialsProviderConfiguration[]; + }> { + if (input.quickCreate && input.credentialProvider !== undefined) { + throw new InputValidationError( + "Quick Create and a credential provider are mutually exclusive; specify one", + ); + } + if (input.quickCreate) { + const type = input.type ?? QUICK_CREATE_TYPE; + if (type !== QUICK_CREATE_TYPE) { + throw new InputValidationError( + `Quick Create is available only for ${QUICK_CREATE_TYPE} connectors, not ${type}`, + ); + } + return { type, credentialProviderConfigurations: [] }; + } + if (input.credentialProvider === undefined) { + throw new InputValidationError( + "a payment connector needs a credential provider, or Quick Create for CoinbaseCDP", + ); + } + const resolved = await this.resolveCredentialProvider( + input.credentialProvider, + input.type, + options, + ); + return { + type: resolved.type, + credentialProviderConfigurations: [credentialConfiguration(resolved.type, resolved.arn)], + }; + } + + // resolveCredentialProvider turns a provider reference into an ARN plus the + // connector type it backs. An ARN carries no vendor, so the type must come from + // the caller; a name is looked up in identity and its vendor is the type, + // which an explicit type must agree with. + private async resolveCredentialProvider( + reference: string, + type: PaymentConnectorType | undefined, + options: CoreOptions, + ): Promise<{ arn: string; type: PaymentConnectorType }> { + if (reference.startsWith("arn:")) { + if (!type) { + throw new InputValidationError( + "--type is required when --credential-provider is an ARN (the ARN does not name the vendor)", + ); + } + return { arn: reference, type }; + } + + const provider = await this.identity.getPaymentCredentialProvider(reference, options); + const arn = provider.credentialProviderArn; + const vendor = provider.credentialProviderVendor as PaymentConnectorType | undefined; + if (!arn || !vendor) { + throw new AgentCoreCLIError( + `payment credential provider "${reference}" returned no ARN or vendor`, + { source: ERROR_SOURCE.SERVICE }, + ); + } + if (type && type !== vendor) { + throw new InputValidationError( + `credential provider "${reference}" is a ${vendor} provider and cannot back a ${type} connector`, + ); + } + return { arn, type: vendor }; + } + private async withPaymentManagerArn( managerId: string, options: CoreOptions, @@ -163,3 +394,48 @@ export class PaymentClient implements CorePaymentClient { return send(this.clients.data(toClientConfig(options)), manager.paymentManagerArn); } } + +function credentialConfiguration( + type: PaymentConnectorType, + credentialProviderArn: string, +): CredentialsProviderConfiguration { + return type === "CoinbaseCDP" + ? { coinbaseCDP: { credentialProviderArn } } + : { stripePrivy: { credentialProviderArn } }; +} + +// isServiceRoleUnusable widens the harness predicate: the payments control plane +// assumes the role during the create itself, so a not-yet-propagated role can +// also surface as an access-denied failure. Only an access denial that names the +// provisioned role counts; a caller's own permission denial also says +// "assumed-role/... is not authorized" and must surface immediately. +function isServiceRoleUnusable(roleArn: string): (error: Error) => boolean { + const roleName = roleArn.split("/").pop() ?? roleArn; + return (error) => + isRoleUnassumableValidation(error) || + (error.name === "AccessDeniedException" && + ((error.message ?? "").includes(roleArn) || (error.message ?? "").includes(roleName))); +} + +// Connector creation and updates fail with SubscriptionRequiredException when the +// account has not subscribed to the provider's AWS Marketplace listing. The SDK +// error carries the listing URL and product name; surface both so the fix is one +// click away instead of a support search. +function subscriptionRequired(error: unknown): unknown { + if (!(error instanceof Error) || error.name !== "SubscriptionRequiredException") return error; + const { subscriptionUrl, productName } = error as Error & { + subscriptionUrl?: string; + productName?: string; + }; + const product = productName ? ` to "${productName}"` : ""; + const where = subscriptionUrl ? ` Subscribe at ${subscriptionUrl}, then retry.` : ""; + return new AgentCoreCLIError( + `${error.message} An active AWS Marketplace subscription${product} is required.${where}`, + { + cause: error, + source: ERROR_SOURCE.USER, + name: error.name, + meta: { subscriptionUrl, productName }, + }, + ); +} diff --git a/src/core/paymentServiceRole.test.ts b/src/core/paymentServiceRole.test.ts new file mode 100644 index 000000000..e8548edd2 --- /dev/null +++ b/src/core/paymentServiceRole.test.ts @@ -0,0 +1,188 @@ +import { expect, mock, test } from "bun:test"; +import { + CreateRoleCommand, + GetRoleCommand, + PutRolePolicyCommand, + type IAMClient, +} from "@aws-sdk/client-iam"; +import { + ensurePaymentServiceRole, + paymentServiceRoleName, + servicePolicy, + trustPolicy, +} from "./paymentServiceRole"; + +const REGION = "us-west-2"; +const ACCOUNT = "123456789012"; + +function statements(policy: string): { Sid?: string; Action?: unknown; Resource?: unknown }[] { + return JSON.parse(policy).Statement; +} + +test("prefixes the manager name and stays within IAM's 64-character cap", () => { + expect(paymentServiceRoleName("Checkout", REGION)).toBe("AgentCorePayments-us-west-2-Checkout"); + + const longest = paymentServiceRoleName("a".repeat(48), REGION); + expect(longest.length).toBe(64); + expect(longest.startsWith("AgentCorePayments-")).toBe(true); +}); + +test("uses a stable SHA-256 suffix across runtime distributions", () => { + expect(paymentServiceRoleName("x".repeat(48), REGION)).toBe( + "AgentCorePayments-us-west-2-xxxxxxxxxxxxxxxxxxxxxxx-c4e3d724a0b2", + ); +}); + +// Truncating alone would let two long names share one role, and provisioning is +// idempotent by name, so the second create would silently reuse the first's. +test("keeps overflowing role names distinct", () => { + const a = paymentServiceRoleName("x".repeat(44) + "AAAA", REGION); + const b = paymentServiceRoleName("x".repeat(44) + "BBBB", REGION); + expect(a.length).toBeLessThanOrEqual(64); + expect(b.length).toBeLessThanOrEqual(64); + expect(a).not.toBe(b); +}); + +test("uses distinct role names for the same manager in different regions", () => { + for (const name of ["Checkout", "x".repeat(48)]) { + expect(paymentServiceRoleName(name, "us-east-1")).not.toBe( + paymentServiceRoleName(name, "us-west-2"), + ); + } +}); + +const ownershipTags = (region: string) => [ + { Key: "agentcore:managed-by", Value: "agentcore-cli" }, + { Key: "agentcore:payment-manager", Value: "Checkout" }, + { Key: "agentcore:region", Value: region }, +]; + +test("creates a tagged default role and grants its regional policy", async () => { + const send = mock(async (command: unknown) => { + if (command instanceof GetRoleCommand) { + throw Object.assign(new Error("not found"), { name: "NoSuchEntityException" }); + } + if (command instanceof CreateRoleCommand) { + expect(command.input.Tags).toEqual(ownershipTags(REGION)); + expect(command.input.RoleName).toBe("AgentCorePayments-us-west-2-Checkout"); + return { Role: { Arn: `arn:aws:iam::${ACCOUNT}:role/${command.input.RoleName}` } }; + } + expect(command).toBeInstanceOf(PutRolePolicyCommand); + return {}; + }); + const arn = await ensurePaymentServiceRole({ send } as unknown as IAMClient, "Checkout", REGION); + expect(arn).toBe(`arn:aws:iam::${ACCOUNT}:role/AgentCorePayments-us-west-2-Checkout`); + expect(send).toHaveBeenCalledTimes(3); +}); + +test("reusing an owned role in another region cannot overwrite the first region's policy", async () => { + const policies = new Map(); + let region = "us-east-1"; + const send = mock(async (command: unknown) => { + if (command instanceof GetRoleCommand) { + return { + Role: { + Arn: `arn:aws:iam::${ACCOUNT}:role/${command.input.RoleName}`, + Tags: ownershipTags(region), + }, + }; + } + expect(command).toBeInstanceOf(PutRolePolicyCommand); + const { RoleName, PolicyName, PolicyDocument } = (command as PutRolePolicyCommand).input; + policies.set(`${RoleName}/${PolicyName}`, PolicyDocument!); + return {}; + }); + const iam = { send } as unknown as IAMClient; + await ensurePaymentServiceRole(iam, "Checkout", region); + region = "us-west-2"; + await ensurePaymentServiceRole(iam, "Checkout", region); + expect(policies.size).toBe(2); + expect([...policies.values()]).toEqual([ + servicePolicy("us-east-1", ACCOUNT), + servicePolicy("us-west-2", ACCOUNT), + ]); +}); + +test.each([ + { Tags: undefined }, + { Tags: [] }, + { Tags: [{ Key: "agentcore:managed-by", Value: "another-tool" }] }, + { Tags: ownershipTags("us-east-1") }, + { + Tags: ownershipTags(REGION).map((tag) => + tag.Key === "agentcore:payment-manager" ? { ...tag, Value: "OtherManager" } : tag, + ), + }, +])("refuses a role without matching ownership tags: %j", async ({ Tags }) => { + const send = mock(async (command: unknown) => { + if (command instanceof GetRoleCommand) { + return { + Role: { Arn: `arn:aws:iam::${ACCOUNT}:role/default-role`, Tags }, + }; + } + throw new Error("must not mutate an unrelated role"); + }); + await expect( + ensurePaymentServiceRole({ send } as unknown as IAMClient, "Checkout", REGION), + ).rejects.toThrow(/--role-arn/); + expect(send).toHaveBeenCalledTimes(1); +}); + +test("a caller's GetRole denial is surfaced without attempting creation", async () => { + const error = Object.assign(new Error("denied"), { name: "AccessDeniedException" }); + const send = mock(async () => { + throw error; + }); + await expect( + ensurePaymentServiceRole({ send } as unknown as IAMClient, "Checkout", REGION), + ).rejects.toBe(error); + expect(send).toHaveBeenCalledTimes(1); +}); + +test("trusts the AgentCore service principal", () => { + const statement = JSON.parse(trustPolicy()).Statement[0]; + expect(statement.Effect).toBe("Allow"); + expect(statement.Principal).toEqual({ Service: "bedrock-agentcore.amazonaws.com" }); + expect(statement.Action).toBe("sts:AssumeRole"); +}); + +// The action list mirrors the ResourceRetrievalRole the L3 CDK construct grants: +// the service assumes this role to mint workload tokens, read the connector's +// credential provider, and fetch payment tokens for every data-plane call. +test("grants the identity, workload token, and payment token actions", () => { + const identity = statements(servicePolicy(REGION, ACCOUNT)).find( + (s) => s.Sid === "AgentCoreIdentityAndTokens", + ); + expect(identity?.Action).toEqual([ + "bedrock-agentcore:RetrieveToken", + "bedrock-agentcore:GetWorkloadIdentity", + "bedrock-agentcore:CreateWorkloadIdentity", + "bedrock-agentcore:GetPaymentCredentialProvider", + "bedrock-agentcore:TagResource", + "bedrock-agentcore:GetWorkloadAccessToken", + "bedrock-agentcore:GetWorkloadAccessTokenForUserId", + "bedrock-agentcore:GetWorkloadAccessTokenForJWT", + "bedrock-agentcore:GetResourcePaymentToken", + ]); + expect(identity?.Resource).toBe("*"); +}); + +// Every AgentCore-managed credential secret lives under the +// `bedrock-agentcore-identity!` prefix, so scoping to it covers the connector +// secrets without exposing unrelated account secrets. Granting the prefix up +// front also means adding a connector never has to mutate the role. +test("scopes secret reads to AgentCore Identity managed secrets in the region and account", () => { + const secrets = statements(servicePolicy(REGION, ACCOUNT)).find( + (s) => s.Sid === "IdentityManagedSecrets", + ); + expect(secrets?.Action).toEqual(["secretsmanager:GetSecretValue"]); + expect(secrets?.Resource).toBe( + `arn:aws:secretsmanager:${REGION}:${ACCOUNT}:secret:bedrock-agentcore-identity!*`, + ); +}); + +test("allows sts:SetContext for workload identity tagging", () => { + const sts = statements(servicePolicy(REGION, ACCOUNT)).find((s) => s.Sid === "StsSetContext"); + expect(sts?.Action).toEqual(["sts:SetContext"]); + expect(sts?.Resource).toBe("*"); +}); diff --git a/src/core/paymentServiceRole.ts b/src/core/paymentServiceRole.ts new file mode 100644 index 000000000..86b9a1634 --- /dev/null +++ b/src/core/paymentServiceRole.ts @@ -0,0 +1,155 @@ +import { + CreateRoleCommand, + GetRoleCommand, + PutRolePolicyCommand, + type IAMClient, +} from "@aws-sdk/client-iam"; +import { createHash } from "node:crypto"; +import { InputValidationError } from "../errors"; +import { parseArn } from "./arn"; + +// Default payment service role provisioning. +// +// CreatePaymentManager requires an IAM role the AgentCore Payments service assumes +// at runtime to mint workload tokens, read the connector's credential provider, +// and fetch payment tokens. When the caller doesn't bring one, PaymentClient +// provisions a per-manager default here, mirroring core/executionRole.ts for +// harnesses: a role trusting bedrock-agentcore.amazonaws.com with one inline +// policy carrying the actions the AgentCore L3 CDK construct grants its +// ResourceRetrievalRole. Only CLI-owned roles for the same manager and region +// are reused and have their inline policy refreshed. + +const POLICY_NAME = "AgentCorePaymentsServicePolicy"; + +const ROLE_NAME_PREFIX = "AgentCorePayments-"; +const ROLE_NAME_MAX = 64; +const NAME_HASH_LENGTH = 12; + +// IAM names are account-global; the policy is regional. Hash the full identity +// before truncation so long manager names cannot collapse onto the same role. +export function paymentServiceRoleName(managerName: string, region: string): string { + const full = `${ROLE_NAME_PREFIX}${region}-${managerName}`; + if (full.length <= ROLE_NAME_MAX) return full; + + const hash = createHash("sha256").update(full).digest("hex").slice(0, NAME_HASH_LENGTH); + return `${full.slice(0, ROLE_NAME_MAX - NAME_HASH_LENGTH - 1)}-${hash}`; +} + +// trustPolicy allows the AgentCore service principal to assume the role. +export function trustPolicy(): string { + return JSON.stringify({ + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Principal: { Service: "bedrock-agentcore.amazonaws.com" }, + Action: "sts:AssumeRole", + }, + ], + }); +} + +// servicePolicy is the permissions document, parameterized on the caller's +// region and account so the secret grant stays inside them. Every +// AgentCore-managed credential secret is stored under the +// `bedrock-agentcore-identity!` prefix, so granting the prefix covers each +// connector's credentials up front and adding a connector never has to mutate +// the role. +export function servicePolicy(region: string, accountId: string): string { + return JSON.stringify({ + Version: "2012-10-17", + Statement: [ + { + Sid: "AgentCoreIdentityAndTokens", + Effect: "Allow", + Action: [ + "bedrock-agentcore:RetrieveToken", + "bedrock-agentcore:GetWorkloadIdentity", + "bedrock-agentcore:CreateWorkloadIdentity", + "bedrock-agentcore:GetPaymentCredentialProvider", + "bedrock-agentcore:TagResource", + "bedrock-agentcore:GetWorkloadAccessToken", + "bedrock-agentcore:GetWorkloadAccessTokenForUserId", + "bedrock-agentcore:GetWorkloadAccessTokenForJWT", + "bedrock-agentcore:GetResourcePaymentToken", + ], + Resource: "*", + }, + { + Sid: "IdentityManagedSecrets", + Effect: "Allow", + Action: ["secretsmanager:GetSecretValue"], + Resource: `arn:aws:secretsmanager:${region}:${accountId}:secret:bedrock-agentcore-identity!*`, + }, + { + Sid: "StsSetContext", + Effect: "Allow", + Action: ["sts:SetContext"], + Resource: "*", + }, + ], + }); +} + +// accountIdFromRoleArn extracts the account id from a role ARN +// (arn:aws:iam:::role/), which saves an STS lookup. +function accountIdFromRoleArn(arn: string): string { + const accountId = parseArn(arn)?.account; + if (!accountId) { + throw new Error(`Cannot extract an account id from role ARN "${arn}"`); + } + return accountId; +} + +// ensurePaymentServiceRole returns the ARN of the default service role for +// `managerName`, creating the role if it doesn't exist and (re)attaching the +// inline policy either way. +export async function ensurePaymentServiceRole( + iam: IAMClient, + managerName: string, + region: string, +): Promise { + const roleName = paymentServiceRoleName(managerName, region); + const tags = [ + { Key: "agentcore:managed-by", Value: "agentcore-cli" }, + { Key: "agentcore:payment-manager", Value: managerName }, + { Key: "agentcore:region", Value: region }, + ]; + + let roleArn: string; + try { + const existing = await iam.send(new GetRoleCommand({ RoleName: roleName })); + if ( + !tags.every(({ Key, Value }) => + existing.Role?.Tags?.some((tag) => tag.Key === Key && tag.Value === Value), + ) + ) { + throw new InputValidationError( + `IAM role "${roleName}" already exists but is not owned by this CLI payment manager in ${region}. ` + + "Use --role-arn to supply a role explicitly, or choose a different manager name; the existing role was not changed.", + ); + } + roleArn = existing.Role!.Arn!; + } catch (error) { + if ((error as Error).name !== "NoSuchEntityException") throw error; + const created = await iam.send( + new CreateRoleCommand({ + RoleName: roleName, + AssumeRolePolicyDocument: trustPolicy(), + Tags: tags, + Description: `Default service role for the AgentCore payment manager "${managerName}" (created by the agentcore CLI)`, + }), + ); + roleArn = created.Role!.Arn!; + } + + await iam.send( + new PutRolePolicyCommand({ + RoleName: roleName, + PolicyName: POLICY_NAME, + PolicyDocument: servicePolicy(region, accountIdFromRoleArn(roleArn)), + }), + ); + + return roleArn; +} diff --git a/src/core/roleRetry.ts b/src/core/roleRetry.ts new file mode 100644 index 000000000..adc087af0 --- /dev/null +++ b/src/core/roleRetry.ts @@ -0,0 +1,27 @@ +// isRoleUnassumableValidation is the harness predicate: AgentCore rejects a +// freshly created execution role with a ValidationException whose message names +// the role, the assume, or the trust relationship. +export function isRoleUnassumableValidation(error: Error): boolean { + return error.name === "ValidationException" && /role|assume|trust/i.test(error.message ?? ""); +} + +// retryWhileRoleUnassumable retries `operation` while it fails with the error +// AgentCore raises for a role it cannot yet assume (fresh IAM roles propagate +// over several seconds). Any other failure — or exhausting the attempts — +// rethrows. `isRetryable` decides which errors count; it defaults to the +// harness ValidationException shape. +export async function retryWhileRoleUnassumable( + operation: () => Promise, + isRetryable: (error: Error) => boolean = isRoleUnassumableValidation, + attempts = 8, + delayMs = 2000, +): Promise { + for (let attempt = 1; ; attempt++) { + try { + return await operation(); + } catch (error) { + if (!isRetryable(error as Error) || attempt >= attempts) throw error; + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } +} diff --git a/src/handlers/payment/__fixtures__/CreatePaymentManagerCommand.46791fae9fbbe940.json b/src/handlers/payment/__fixtures__/CreatePaymentManagerCommand.46791fae9fbbe940.json new file mode 100644 index 000000000..6ca49e4fa --- /dev/null +++ b/src/handlers/payment/__fixtures__/CreatePaymentManagerCommand.46791fae9fbbe940.json @@ -0,0 +1,17 @@ +{ + "paymentManagerArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:payment-manager/agentcoreclipaymente2e-ktdwha51g1", + "paymentManagerId": "agentcoreclipaymente2e-ktdwha51g1", + "name": "AgentCoreCliPaymentE2E", + "authorizerType": "AWS_IAM", + "roleArn": "arn:aws:iam::603141041947:role/AgentCorePayments-us-east-1-AgentCoreCliPaymentE2E", + "createdAt": { + "$date": "2026-09-09T00:05:18.383Z" + }, + "status": "READY", + "workloadIdentityDetails": { + "workloadIdentityArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:workload-identity-directory/default/workload-identity/agentcoreclipaymente2e-ktdwha51g1" + }, + "tags": { + "created-by": "agentcore-cli-e2e" + } +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/CreateRoleCommand.4deb88176aa9ec28.json b/src/handlers/payment/__fixtures__/CreateRoleCommand.4deb88176aa9ec28.json new file mode 100644 index 000000000..5c8c84e63 --- /dev/null +++ b/src/handlers/payment/__fixtures__/CreateRoleCommand.4deb88176aa9ec28.json @@ -0,0 +1,26 @@ +{ + "Role": { + "Path": "/", + "RoleName": "AgentCorePayments-us-east-1-AgentCoreCliPaymentE2E", + "RoleId": "AROAYY3QB54NRWRDQPR7D", + "Arn": "arn:aws:iam::603141041947:role/AgentCorePayments-us-east-1-AgentCoreCliPaymentE2E", + "CreateDate": { + "$date": "2026-09-09T00:05:06.000Z" + }, + "AssumeRolePolicyDocument": "%7B%22Version%22%3A%222012-10-17%22%2C%22Statement%22%3A%5B%7B%22Effect%22%3A%22Allow%22%2C%22Principal%22%3A%7B%22Service%22%3A%22bedrock-agentcore.amazonaws.com%22%7D%2C%22Action%22%3A%22sts%3AAssumeRole%22%7D%5D%7D", + "Tags": [ + { + "Key": "agentcore:managed-by", + "Value": "agentcore-cli" + }, + { + "Key": "agentcore:payment-manager", + "Value": "AgentCoreCliPaymentE2E" + }, + { + "Key": "agentcore:region", + "Value": "us-east-1" + } + ] + } +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/DeletePaymentManagerCommand.894895e0c24c9098.json b/src/handlers/payment/__fixtures__/DeletePaymentManagerCommand.894895e0c24c9098.json new file mode 100644 index 000000000..302c0f350 --- /dev/null +++ b/src/handlers/payment/__fixtures__/DeletePaymentManagerCommand.894895e0c24c9098.json @@ -0,0 +1,4 @@ +{ + "status": "DELETING", + "paymentManagerId": "agentcoreclipaymente2e-ktdwha51g1" +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/GetPaymentManagerCommand.894895e0c24c9098.json b/src/handlers/payment/__fixtures__/GetPaymentManagerCommand.894895e0c24c9098.json new file mode 100644 index 000000000..16c918232 --- /dev/null +++ b/src/handlers/payment/__fixtures__/GetPaymentManagerCommand.894895e0c24c9098.json @@ -0,0 +1,18 @@ +{ + "paymentManagerArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:payment-manager/agentcoreclipaymente2e-ktdwha51g1", + "paymentManagerId": "agentcoreclipaymente2e-ktdwha51g1", + "name": "AgentCoreCliPaymentE2E", + "authorizerType": "AWS_IAM", + "roleArn": "arn:aws:iam::603141041947:role/AgentCorePayments-us-east-1-AgentCoreCliPaymentE2E", + "createdAt": { + "$date": "2026-09-09T00:05:18.383Z" + }, + "lastUpdatedAt": { + "$date": "2026-09-09T00:05:18.752Z" + }, + "status": "READY", + "description": "Updated by the agentcore CLI end-to-end test", + "workloadIdentityDetails": { + "workloadIdentityArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:workload-identity-directory/default/workload-identity/agentcoreclipaymente2e-ktdwha51g1" + } +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/GetRoleCommand.6894a19eac9ccc52.json b/src/handlers/payment/__fixtures__/GetRoleCommand.6894a19eac9ccc52.json new file mode 100644 index 000000000..87a87aa39 --- /dev/null +++ b/src/handlers/payment/__fixtures__/GetRoleCommand.6894a19eac9ccc52.json @@ -0,0 +1,6 @@ +{ + "$error": { + "name": "NoSuchEntityException", + "message": "The role with name AgentCorePayments-us-east-1-AgentCoreCliPaymentE2E cannot be found." + } +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/PutRolePolicyCommand.5b1970701f13b039.json b/src/handlers/payment/__fixtures__/PutRolePolicyCommand.5b1970701f13b039.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/src/handlers/payment/__fixtures__/PutRolePolicyCommand.5b1970701f13b039.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/UpdatePaymentManagerCommand.d2e5471084d393e8.json b/src/handlers/payment/__fixtures__/UpdatePaymentManagerCommand.d2e5471084d393e8.json new file mode 100644 index 000000000..afcc5c32a --- /dev/null +++ b/src/handlers/payment/__fixtures__/UpdatePaymentManagerCommand.d2e5471084d393e8.json @@ -0,0 +1,14 @@ +{ + "paymentManagerArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:payment-manager/agentcoreclipaymente2e-ktdwha51g1", + "paymentManagerId": "agentcoreclipaymente2e-ktdwha51g1", + "name": "AgentCoreCliPaymentE2E", + "authorizerType": "AWS_IAM", + "roleArn": "arn:aws:iam::603141041947:role/AgentCorePayments-us-east-1-AgentCoreCliPaymentE2E", + "lastUpdatedAt": { + "$date": "2026-09-09T00:05:18.752Z" + }, + "status": "READY", + "workloadIdentityDetails": { + "workloadIdentityArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:workload-identity-directory/default/workload-identity/agentcoreclipaymente2e-ktdwha51g1" + } +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/after-delete/GetPaymentManagerCommand.894895e0c24c9098.json b/src/handlers/payment/__fixtures__/after-delete/GetPaymentManagerCommand.894895e0c24c9098.json new file mode 100644 index 000000000..9064c1c9f --- /dev/null +++ b/src/handlers/payment/__fixtures__/after-delete/GetPaymentManagerCommand.894895e0c24c9098.json @@ -0,0 +1,6 @@ +{ + "$error": { + "name": "ResourceNotFoundException", + "message": "Payment manager not found: agentcoreclipaymente2e-ktdwha51g1" + } +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/connector/CreatePaymentConnectorCommand.3a23138a2103205b.json b/src/handlers/payment/__fixtures__/connector/CreatePaymentConnectorCommand.3a23138a2103205b.json new file mode 100644 index 000000000..df9a2ae99 --- /dev/null +++ b/src/handlers/payment/__fixtures__/connector/CreatePaymentConnectorCommand.3a23138a2103205b.json @@ -0,0 +1,12 @@ +{ + "paymentConnectorId": "agentcorecliquicke2e-wolx3aywni", + "paymentManagerId": "mypaymentmanageraidandal-gx3nxzaira", + "name": "AgentCoreCliQuickE2E", + "type": "CoinbaseCDP", + "credentialProviderConfigurations": [], + "createdAt": { + "$date": "2026-09-08T20:14:33.909Z" + }, + "status": "PENDING_AUTHENTICATION", + "authorizationUrl": "https://bedrock-agentcore.us-west-2.amazonaws.com/identities/oauth2/authorize?request_uri=urn%3Aietf%3Aparams%3Aoauth%3Arequest_uri%3ANmI3ZmI4ZGMtZTVhNi00YTVlLWE5NzctMjY1MjQ0MGE1NWIy" +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/connector/CreatePaymentConnectorCommand.8ee89f4fdcbd5119.json b/src/handlers/payment/__fixtures__/connector/CreatePaymentConnectorCommand.8ee89f4fdcbd5119.json new file mode 100644 index 000000000..ffa9e78e2 --- /dev/null +++ b/src/handlers/payment/__fixtures__/connector/CreatePaymentConnectorCommand.8ee89f4fdcbd5119.json @@ -0,0 +1,17 @@ +{ + "paymentConnectorId": "agentcorecliconnectore2e-6rodjuiuig", + "paymentManagerId": "mypaymentmanageraidandal-gx3nxzaira", + "name": "AgentCoreCliConnectorE2E", + "type": "CoinbaseCDP", + "credentialProviderConfigurations": [ + { + "coinbaseCDP": { + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:token-vault/default/paymentcredentialprovider/MyPaymentManagerAidandal-MyCdpConnectorAidandal-cdp" + } + } + ], + "createdAt": { + "$date": "2026-09-08T20:14:31.151Z" + }, + "status": "READY" +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/connector/DeletePaymentConnectorCommand.1c6bed13c7d0db3a.json b/src/handlers/payment/__fixtures__/connector/DeletePaymentConnectorCommand.1c6bed13c7d0db3a.json new file mode 100644 index 000000000..d4c2477cb --- /dev/null +++ b/src/handlers/payment/__fixtures__/connector/DeletePaymentConnectorCommand.1c6bed13c7d0db3a.json @@ -0,0 +1,4 @@ +{ + "status": "DELETING", + "paymentConnectorId": "agentcorecliquicke2e-wolx3aywni" +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/connector/DeletePaymentConnectorCommand.9f8dfd59b8af870.json b/src/handlers/payment/__fixtures__/connector/DeletePaymentConnectorCommand.9f8dfd59b8af870.json new file mode 100644 index 000000000..9633a3865 --- /dev/null +++ b/src/handlers/payment/__fixtures__/connector/DeletePaymentConnectorCommand.9f8dfd59b8af870.json @@ -0,0 +1,4 @@ +{ + "status": "DELETING", + "paymentConnectorId": "agentcorecliconnectore2e-6rodjuiuig" +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/connector/GetPaymentCredentialProviderCommand.9b6249ebbbb54d1a.json b/src/handlers/payment/__fixtures__/connector/GetPaymentCredentialProviderCommand.9b6249ebbbb54d1a.json new file mode 100644 index 000000000..55e0e51e0 --- /dev/null +++ b/src/handlers/payment/__fixtures__/connector/GetPaymentCredentialProviderCommand.9b6249ebbbb54d1a.json @@ -0,0 +1,24 @@ +{ + "name": "MyPaymentManagerAidandal-MyCdpConnectorAidandal-cdp", + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:token-vault/default/paymentcredentialprovider/MyPaymentManagerAidandal-MyCdpConnectorAidandal-cdp", + "credentialProviderVendor": "CoinbaseCDP", + "providerConfigurationOutput": { + "coinbaseCdpConfiguration": { + "apiKeyId": "e0813a2f-8c27-4a6c-8a7b-8202c019938f", + "apiKeySecretArn": { + "secretArn": "arn:aws:secretsmanager:us-west-2:603141041947:secret:bedrock-agentcore-identity!default/payment/coinbasecdp/MyPaymentManagerAidandal-MyCdpConnectorAidandal-cdp-dc62a3e5/apikey-1N7phA" + }, + "walletSecretArn": { + "secretArn": "arn:aws:secretsmanager:us-west-2:603141041947:secret:bedrock-agentcore-identity!default/payment/coinbasecdp/MyPaymentManagerAidandal-MyCdpConnectorAidandal-cdp-dc62a3e5/wallet-MNXiN7" + }, + "apiKeySecretSource": "MANAGED", + "walletSecretSource": "MANAGED" + } + }, + "createdTime": { + "$date": "2026-06-08T18:10:19.508Z" + }, + "lastUpdatedTime": { + "$date": "2026-06-08T18:10:19.508Z" + } +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/connector/UpdatePaymentConnectorCommand.f54471b4c372f9aa.json b/src/handlers/payment/__fixtures__/connector/UpdatePaymentConnectorCommand.f54471b4c372f9aa.json new file mode 100644 index 000000000..033f90717 --- /dev/null +++ b/src/handlers/payment/__fixtures__/connector/UpdatePaymentConnectorCommand.f54471b4c372f9aa.json @@ -0,0 +1,17 @@ +{ + "paymentConnectorId": "agentcorecliconnectore2e-6rodjuiuig", + "paymentManagerId": "mypaymentmanageraidandal-gx3nxzaira", + "name": "AgentCoreCliConnectorE2E", + "type": "CoinbaseCDP", + "credentialProviderConfigurations": [ + { + "coinbaseCDP": { + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:token-vault/default/paymentcredentialprovider/MyPaymentManagerAidandal-MyCdpConnectorAidandal-cdp" + } + } + ], + "lastUpdatedAt": { + "$date": "2026-09-08T20:14:32.139Z" + }, + "status": "READY" +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/connector/after-delete/GetPaymentConnectorCommand.1c6bed13c7d0db3a.json b/src/handlers/payment/__fixtures__/connector/after-delete/GetPaymentConnectorCommand.1c6bed13c7d0db3a.json new file mode 100644 index 000000000..d79a3c462 --- /dev/null +++ b/src/handlers/payment/__fixtures__/connector/after-delete/GetPaymentConnectorCommand.1c6bed13c7d0db3a.json @@ -0,0 +1,6 @@ +{ + "$error": { + "name": "ResourceNotFoundException", + "message": "Payment connector not found: agentcorecliquicke2e-wolx3aywni" + } +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/connector/after-delete/GetPaymentConnectorCommand.9f8dfd59b8af870.json b/src/handlers/payment/__fixtures__/connector/after-delete/GetPaymentConnectorCommand.9f8dfd59b8af870.json new file mode 100644 index 000000000..bfadee775 --- /dev/null +++ b/src/handlers/payment/__fixtures__/connector/after-delete/GetPaymentConnectorCommand.9f8dfd59b8af870.json @@ -0,0 +1,6 @@ +{ + "$error": { + "name": "ResourceNotFoundException", + "message": "Payment connector not found: agentcorecliconnectore2e-6rodjuiuig" + } +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/connector/connector-create.golden.json b/src/handlers/payment/__fixtures__/connector/connector-create.golden.json new file mode 100644 index 000000000..878ab3090 --- /dev/null +++ b/src/handlers/payment/__fixtures__/connector/connector-create.golden.json @@ -0,0 +1,15 @@ +{ + "paymentConnectorId": "agentcorecliconnectore2e-6rodjuiuig", + "paymentManagerId": "mypaymentmanageraidandal-gx3nxzaira", + "name": "AgentCoreCliConnectorE2E", + "type": "CoinbaseCDP", + "credentialProviderConfigurations": [ + { + "coinbaseCDP": { + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:token-vault/default/paymentcredentialprovider/MyPaymentManagerAidandal-MyCdpConnectorAidandal-cdp" + } + } + ], + "createdAt": "2026-09-08T20:14:31.151Z", + "status": "READY" +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/connector/connector-delete.golden.json b/src/handlers/payment/__fixtures__/connector/connector-delete.golden.json new file mode 100644 index 000000000..9633a3865 --- /dev/null +++ b/src/handlers/payment/__fixtures__/connector/connector-delete.golden.json @@ -0,0 +1,4 @@ +{ + "status": "DELETING", + "paymentConnectorId": "agentcorecliconnectore2e-6rodjuiuig" +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/connector/connector-quick-create.golden.json b/src/handlers/payment/__fixtures__/connector/connector-quick-create.golden.json new file mode 100644 index 000000000..4fd5f5006 --- /dev/null +++ b/src/handlers/payment/__fixtures__/connector/connector-quick-create.golden.json @@ -0,0 +1,10 @@ +{ + "paymentConnectorId": "agentcorecliquicke2e-wolx3aywni", + "paymentManagerId": "mypaymentmanageraidandal-gx3nxzaira", + "name": "AgentCoreCliQuickE2E", + "type": "CoinbaseCDP", + "credentialProviderConfigurations": [], + "createdAt": "2026-09-08T20:14:33.909Z", + "status": "PENDING_AUTHENTICATION", + "authorizationUrl": "https://bedrock-agentcore.us-west-2.amazonaws.com/identities/oauth2/authorize?request_uri=urn%3Aietf%3Aparams%3Aoauth%3Arequest_uri%3ANmI3ZmI4ZGMtZTVhNi00YTVlLWE5NzctMjY1MjQ0MGE1NWIy" +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/connector/connector-quick-delete.golden.json b/src/handlers/payment/__fixtures__/connector/connector-quick-delete.golden.json new file mode 100644 index 000000000..d4c2477cb --- /dev/null +++ b/src/handlers/payment/__fixtures__/connector/connector-quick-delete.golden.json @@ -0,0 +1,4 @@ +{ + "status": "DELETING", + "paymentConnectorId": "agentcorecliquicke2e-wolx3aywni" +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/connector/connector-update.golden.json b/src/handlers/payment/__fixtures__/connector/connector-update.golden.json new file mode 100644 index 000000000..4d369b25f --- /dev/null +++ b/src/handlers/payment/__fixtures__/connector/connector-update.golden.json @@ -0,0 +1,15 @@ +{ + "paymentConnectorId": "agentcorecliconnectore2e-6rodjuiuig", + "paymentManagerId": "mypaymentmanageraidandal-gx3nxzaira", + "name": "AgentCoreCliConnectorE2E", + "type": "CoinbaseCDP", + "credentialProviderConfigurations": [ + { + "coinbaseCDP": { + "credentialProviderArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:token-vault/default/paymentcredentialprovider/MyPaymentManagerAidandal-MyCdpConnectorAidandal-cdp" + } + } + ], + "lastUpdatedAt": "2026-09-08T20:14:32.139Z", + "status": "READY" +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/manager-create.golden.json b/src/handlers/payment/__fixtures__/manager-create.golden.json new file mode 100644 index 000000000..3950ee0d5 --- /dev/null +++ b/src/handlers/payment/__fixtures__/manager-create.golden.json @@ -0,0 +1,15 @@ +{ + "paymentManagerArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:payment-manager/agentcoreclipaymente2e-ktdwha51g1", + "paymentManagerId": "agentcoreclipaymente2e-ktdwha51g1", + "name": "AgentCoreCliPaymentE2E", + "authorizerType": "AWS_IAM", + "roleArn": "arn:aws:iam::603141041947:role/AgentCorePayments-us-east-1-AgentCoreCliPaymentE2E", + "createdAt": "2026-09-09T00:05:18.383Z", + "status": "READY", + "workloadIdentityDetails": { + "workloadIdentityArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:workload-identity-directory/default/workload-identity/agentcoreclipaymente2e-ktdwha51g1" + }, + "tags": { + "created-by": "agentcore-cli-e2e" + } +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/manager-delete.golden.json b/src/handlers/payment/__fixtures__/manager-delete.golden.json new file mode 100644 index 000000000..302c0f350 --- /dev/null +++ b/src/handlers/payment/__fixtures__/manager-delete.golden.json @@ -0,0 +1,4 @@ +{ + "status": "DELETING", + "paymentManagerId": "agentcoreclipaymente2e-ktdwha51g1" +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/manager-update.golden.json b/src/handlers/payment/__fixtures__/manager-update.golden.json new file mode 100644 index 000000000..57a6a1ae5 --- /dev/null +++ b/src/handlers/payment/__fixtures__/manager-update.golden.json @@ -0,0 +1,12 @@ +{ + "paymentManagerArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:payment-manager/agentcoreclipaymente2e-ktdwha51g1", + "paymentManagerId": "agentcoreclipaymente2e-ktdwha51g1", + "name": "AgentCoreCliPaymentE2E", + "authorizerType": "AWS_IAM", + "roleArn": "arn:aws:iam::603141041947:role/AgentCorePayments-us-east-1-AgentCoreCliPaymentE2E", + "lastUpdatedAt": "2026-09-09T00:05:18.752Z", + "status": "READY", + "workloadIdentityDetails": { + "workloadIdentityArn": "arn:aws:bedrock-agentcore:us-east-1:603141041947:workload-identity-directory/default/workload-identity/agentcoreclipaymente2e-ktdwha51g1" + } +} \ No newline at end of file diff --git a/src/handlers/payment/connector/connector.test.tsx b/src/handlers/payment/connector/connector.test.tsx new file mode 100644 index 000000000..d6543eb9d --- /dev/null +++ b/src/handlers/payment/connector/connector.test.tsx @@ -0,0 +1,267 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import { join } from "node:path"; +import { CoreClient } from "../../../core"; +import { createRootHandler } from "../../index"; +import { + createSilentLogger, + fixtureFactories, + isRecording, + matchGolden, + parse, + TestGlobalConfigAccessor, + testIO, + waitFor, +} from "../../../testing"; +import quickCreateFixture from "../__fixtures__/connector/CreatePaymentConnectorCommand.3a23138a2103205b.json"; +import connectorGetFixture from "../__fixtures__/connector/GetPaymentConnectorCommand.9f8dfd59b8af870.json"; + +const FIXTURES = join(import.meta.dir, "..", "__fixtures__", "connector"); +const REGION = "us-west-2"; +const MANAGER_ID = "mypaymentmanageraidandal-gx3nxzaira"; +const CREDENTIAL_PROVIDER = "MyPaymentManagerAidandal-MyCdpConnectorAidandal-cdp"; +const MANUAL_NAME = "AgentCoreCliConnectorE2E"; +const QUICK_NAME = "AgentCoreCliQuickE2E"; +const scoped = ["--manager-id", MANAGER_ID]; +const quickArgs = ["create", ...scoped, "--name", QUICK_NAME, "--quick-create"]; + +function createFixtureCore(fixtures = FIXTURES): CoreClient { + return new CoreClient({ ...fixtureFactories(fixtures), logger: createSilentLogger() }); +} + +async function run( + args: string[], + { core = createFixtureCore(), regionArgs = ["--region", REGION] } = {}, +) { + const io = testIO(); + const root = createRootHandler(core, { + io: io.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + await root.route(["node", "agentcore", "payment", "connector", ...args, ...regionArgs]); + return io; +} + +async function waitForDeletion(args: string[]) { + await waitFor( + async () => { + try { + await run(["get", ...args], { core: createFixtureCore(join(FIXTURES, "after-delete")) }); + return false; + } catch (error) { + expect(error).toMatchObject({ name: "ResourceNotFoundException" }); + return true; + } + }, + isRecording() ? 300_000 : 0, + 5_000, + ); +} + +describe("payment connector write inputs", () => { + test.each([ + { args: [] }, + { args: ["--quick-create", "--credential-provider", CREDENTIAL_PROVIDER] }, + ])("requires exactly one credential source: $args", async ({ args }) => { + await expect(run(["create", ...scoped, "--name", MANUAL_NAME, ...args])).rejects.toThrow( + "specify exactly one of --quick-create, --credential-provider", + ); + }); + + test.each(["create", "update"] as const)( + "%s rejects an empty credential reference before Core", + async (command) => { + const core = createFixtureCore(); + const call = spyOn( + core.payment, + command === "create" ? "createPaymentConnector" : "updatePaymentConnector", + ); + await expect( + run( + [ + command, + ...scoped, + ...(command === "create" ? ["--name", MANUAL_NAME] : ["--connector-id", "c-1"]), + "--credential-provider", + "", + ], + { core }, + ), + ).rejects.toThrow("Invalid value for option '--credential-provider'"); + expect(call).not.toHaveBeenCalled(); + }, + ); + + test("update forwards a replacement credential reference, empty description, and client token", async () => { + const factories = fixtureFactories(FIXTURES); + const control = factories.createControlClient({ region: REGION }); + spyOn(control, "send") + .mockResolvedValueOnce(parse(JSON.stringify(connectorGetFixture))) + .mockImplementationOnce(async () => ({})); + const core = new CoreClient({ + ...factories, + createControlClient: () => control, + logger: createSilentLogger(), + }); + const update = spyOn(core.payment, "updatePaymentConnector"); + const providerArn = + connectorGetFixture.credentialProviderConfigurations[0]!.coinbaseCDP.credentialProviderArn; + + await run( + [ + "update", + ...scoped, + "--connector-id", + "c-1", + "--credential-provider", + providerArn, + "--description", + "", + "--client-token", + "token-1", + ], + { core }, + ); + expect(update).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenCalledWith( + { + managerId: MANAGER_ID, + connectorId: "c-1", + credentialProvider: providerArn, + description: "", + clientToken: "token-1", + }, + { region: REGION }, + ); + }); + + test("update cannot change the connector type", async () => { + await expect( + run(["update", ...scoped, "--connector-id", "c-1", "--type", "StripePrivy"]), + ).rejects.toThrow("unknown option '--type'"); + }); +}); + +describe("payment connector Quick Create hints", () => { + test.each([ + { + label: "explicit region over the environment", + regionArgs: ["--region", "eu-west-1"], + environment: "us-east-1", + endpoint: undefined, + }, + { + label: "environment region and shell-quoted endpoint", + regionArgs: [ + "--endpoint-url", + "https://payments.example.test/control path?mode=quick&label=O'Reilly#consent", + ], + environment: "eu-west-1", + endpoint: "https://payments.example.test/control path?mode=quick&label=O'Reilly#consent", + }, + ])("hint includes the resolved $label", async ({ regionArgs, environment, endpoint }) => { + const savedRegion = process.env.AWS_REGION; + const core = createFixtureCore(); + const create = spyOn(core.payment, "createPaymentConnector").mockResolvedValue( + parse(JSON.stringify(quickCreateFixture)), + ); + + try { + process.env.AWS_REGION = environment; + const created = await run(quickArgs, { + core, + regionArgs: [...regionArgs], + }); + const command = created.stderr().match(/`(agentcore payment connector get [^`]+)`/)?.[1]; + const endpointFlag = + endpoint === undefined + ? "" + : " --endpoint-url 'https://payments.example.test/control path?mode=quick&label=O'\\''Reilly#consent'"; + expect(command).toBe( + `agentcore payment connector get --manager-id ${MANAGER_ID} --connector-id ${quickCreateFixture.paymentConnectorId} --region eu-west-1${endpointFlag}`, + ); + expect(create.mock.calls[0]?.[1]).toEqual({ + region: "eu-west-1", + ...(endpoint === undefined ? {} : { endpointUrl: endpoint }), + }); + } finally { + if (savedRegion === undefined) delete process.env.AWS_REGION; + else process.env.AWS_REGION = savedRegion; + } + }); + + test("--json keeps the authorization URL in stdout without a stderr hint", async () => { + const core = createFixtureCore(); + spyOn(core.payment, "createPaymentConnector").mockResolvedValue( + parse(JSON.stringify(quickCreateFixture)), + ); + const io = await run([...quickArgs, "--json"], { core }); + expect(JSON.parse(io.stdout()).authorizationUrl).toMatch(/^https:\/\//); + expect(io.stderr()).toBe(""); + }); +}); + +test("payment connector lifecycle replays named-provider creation, update, and deletion through root/Core", async () => { + const created = await run([ + "create", + ...scoped, + "--name", + MANUAL_NAME, + "--description", + "Created by the agentcore CLI end-to-end test", + "--credential-provider", + CREDENTIAL_PROVIDER, + ]); + matchGolden(FIXTURES, "connector-create.golden.json", created.stdout()); + const connector = JSON.parse(created.stdout()); + expect(connector.type).toBe("CoinbaseCDP"); + const connectorArgs = [...scoped, "--connector-id", connector.paymentConnectorId]; + await waitFor( + async () => JSON.parse((await run(["get", ...connectorArgs])).stdout()).status === "READY", + isRecording() ? 300_000 : 0, + 5_000, + ); + + const description = "Updated by the agentcore CLI end-to-end test"; + const updated = await run(["update", ...connectorArgs, "--description", description]); + matchGolden(FIXTURES, "connector-update.golden.json", updated.stdout()); + await waitFor( + async () => { + const result = JSON.parse((await run(["get", ...connectorArgs])).stdout()); + return result.status === "READY" && result.description === description; + }, + isRecording() ? 300_000 : 0, + 5_000, + ); + const detail = await run(["get", ...connectorArgs]); + matchGolden(FIXTURES, "connector-get.golden.json", detail.stdout()); + expect(JSON.parse(detail.stdout())).toMatchObject({ + paymentConnectorId: connector.paymentConnectorId, + status: "READY", + description, + }); + + const deleted = await run(["delete", ...connectorArgs]); + matchGolden(FIXTURES, "connector-delete.golden.json", deleted.stdout()); + expect(JSON.parse(deleted.stdout()).status).toBe("DELETING"); + await waitForDeletion(connectorArgs); +}, 1_800_000); + +test("payment connector Quick Create lifecycle returns consent instructions and deletes the pending connector", async () => { + const created = await run(quickArgs); + matchGolden(FIXTURES, "connector-quick-create.golden.json", created.stdout()); + const connector = JSON.parse(created.stdout()); + expect(connector.status).toBe("PENDING_AUTHENTICATION"); + expect(connector.authorizationUrl).toMatch(/^https:\/\//); + expect(created.stderr()).toContain(connector.authorizationUrl); + expect(created.stderr()).toContain("10 minutes"); + expect(created.stderr()).toContain( + `agentcore payment connector get --manager-id ${MANAGER_ID} --connector-id ${connector.paymentConnectorId}`, + ); + + const connectorArgs = [...scoped, "--connector-id", connector.paymentConnectorId]; + const deleted = await run(["delete", ...connectorArgs]); + matchGolden(FIXTURES, "connector-quick-delete.golden.json", deleted.stdout()); + expect(JSON.parse(deleted.stdout()).status).toBe("DELETING"); + await waitForDeletion(connectorArgs); +}, 600_000); diff --git a/src/handlers/payment/connector/create/index.tsx b/src/handlers/payment/connector/create/index.tsx new file mode 100644 index 000000000..32162a1f6 --- /dev/null +++ b/src/handlers/payment/connector/create/index.tsx @@ -0,0 +1,96 @@ +import z from "zod"; +import { InputValidationError } from "../../../../errors"; +import type { AppIO } from "../../../../io"; +import { createHandler, flag } from "../../../../router"; +import { JsonRendererKey } from "../../../../tui"; +import { JsonKey } from "../../../keys"; +import type { Core } from "../../../types"; +import { assertMutuallyExclusiveFlags, coreOptsFromCtx } from "../../../utils"; +import type { CreatePaymentConnectorInput } from "../../types"; + +export const createCreatePaymentConnectorHandler = (core: Core, io: AppIO) => + createHandler({ + name: "create", + description: "create a payment connector under a payment manager", + flags: [ + flag("manager-id", "the parent payment manager id", z.string().optional()), + flag("name", "the payment connector name", z.string().optional()), + flag("description", "payment connector description", z.string().optional()), + flag( + "type", + "connector type: CoinbaseCDP or StripePrivy (inferred from a credential provider name; required with an ARN)", + z.enum(["CoinbaseCDP", "StripePrivy"]).optional(), + ), + flag( + "credential-provider", + "payment credential provider name or ARN that backs the connector", + z.string().min(1).optional(), + ), + flag( + "quick-create", + "let Coinbase provision the credentials after OAuth consent (CoinbaseCDP only)", + z.boolean().default(false), + ), + flag("client-token", "idempotency token", z.string().optional()), + ], + handle: async (ctx, flags) => { + // Required at runtime but declared optional so that a bare invocation can + // fall through to the TUI once a screen exists. + if (!flags["manager-id"]) { + throw new InputValidationError("required option '--manager-id ' not specified"); + } + if (!flags.name) { + throw new InputValidationError("required option '--name ' not specified"); + } + assertMutuallyExclusiveFlags(flags, ["quick-create", "credential-provider"], { + exactlyOne: true, + }); + + const input: CreatePaymentConnectorInput = { + managerId: flags["manager-id"], + name: flags.name, + ...(flags.description ? { description: flags.description } : {}), + ...(flags.type ? { type: flags.type } : {}), + ...(flags["credential-provider"] + ? { credentialProvider: flags["credential-provider"] } + : {}), + ...(flags["quick-create"] ? { quickCreate: true } : {}), + ...(flags["client-token"] ? { clientToken: flags["client-token"] } : {}), + }; + + const options = coreOptsFromCtx(ctx); + const response = await core.payment.createPaymentConnector(input, options); + ctx.require(JsonRendererKey).renderJson(response); + + // Quick Create leaves the connector waiting on OAuth consent; the URL is + // in the JSON, but a scripted caller does not need the walkthrough. + if ( + !ctx.require(JsonKey) && + response.status === "PENDING_AUTHENTICATION" && + response.authorizationUrl + ) { + const command = [ + "agentcore", + "payment", + "connector", + "get", + "--manager-id", + flags["manager-id"], + "--connector-id", + response.paymentConnectorId ?? "", + "--region", + options.region, + ...(options.endpointUrl !== undefined ? ["--endpoint-url", options.endpointUrl] : []), + ] + .map((value) => + /^[A-Za-z0-9_./:@%+=,-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`, + ) + .join(" "); + io.stderr.write( + `Open ${response.authorizationUrl} within about 10 minutes to authorize with Coinbase, then run ` + + `\`${command}\` ` + + "to confirm the connector is READY.\n", + ); + } + }, + }); diff --git a/src/handlers/payment/connector/delete/index.tsx b/src/handlers/payment/connector/delete/index.tsx new file mode 100644 index 000000000..8f44c0b84 --- /dev/null +++ b/src/handlers/payment/connector/delete/index.tsx @@ -0,0 +1,38 @@ +import z from "zod"; +import { InputValidationError } from "../../../../errors"; +import { createHandler, flag } from "../../../../router"; +import { JsonRendererKey } from "../../../../tui"; +import type { Core } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; + +export const createDeletePaymentConnectorHandler = (core: Core) => + createHandler({ + name: "delete", + description: "delete a payment connector", + flags: [ + flag("manager-id", "the parent payment manager id", z.string().optional()), + flag("connector-id", "the payment connector id", z.string().optional()), + flag("client-token", "idempotency token", z.string().optional()), + ], + handle: async (ctx, flags) => { + if (!flags["manager-id"]) { + throw new InputValidationError("required option '--manager-id ' not specified"); + } + if (!flags["connector-id"]) { + throw new InputValidationError( + "required option '--connector-id ' not specified", + ); + } + + ctx.require(JsonRendererKey).renderJson( + await core.payment.deletePaymentConnector( + { + paymentManagerId: flags["manager-id"], + paymentConnectorId: flags["connector-id"], + ...(flags["client-token"] ? { clientToken: flags["client-token"] } : {}), + }, + coreOptsFromCtx(ctx), + ), + ); + }, + }); diff --git a/src/handlers/payment/connector/get/index.tsx b/src/handlers/payment/connector/get/index.tsx index 54a548761..d8cee86ab 100644 --- a/src/handlers/payment/connector/get/index.tsx +++ b/src/handlers/payment/connector/get/index.tsx @@ -40,7 +40,8 @@ export const createGetPaymentConnectorHandler = (core: Core, io: AppIO) => response.status === "AUTHENTICATION_FAILED") ) { io.stderr.write( - `warning: connector status is ${response.status}; its authorization URL cannot be renewed.\n`, + `warning: the authorization URL of a ${response.status} connector cannot be renewed; ` + + "delete this connector and create it again with --quick-create.\n", ); } }, diff --git a/src/handlers/payment/connector/index.tsx b/src/handlers/payment/connector/index.tsx index b66523054..82a077d05 100644 --- a/src/handlers/payment/connector/index.tsx +++ b/src/handlers/payment/connector/index.tsx @@ -2,12 +2,18 @@ import type { AppIO } from "../../../io"; import { Router } from "../../../router"; import { renderTui } from "../../../tui"; import type { Core } from "../../types"; +import { createCreatePaymentConnectorHandler } from "./create"; +import { createDeletePaymentConnectorHandler } from "./delete"; import { createGetPaymentConnectorHandler } from "./get"; import { createListPaymentConnectorsHandler } from "./list"; +import { createUpdatePaymentConnectorHandler } from "./update"; export function createPaymentConnectorHandler(core: Core, io: AppIO): Router { return new Router("connector", "manage connectors under a payment manager") .default(renderTui(core, io)) + .handler(createCreatePaymentConnectorHandler(core, io)) .handler(createGetPaymentConnectorHandler(core, io)) - .handler(createListPaymentConnectorsHandler(core)); + .handler(createListPaymentConnectorsHandler(core)) + .handler(createUpdatePaymentConnectorHandler(core)) + .handler(createDeletePaymentConnectorHandler(core)); } diff --git a/src/handlers/payment/connector/update/index.tsx b/src/handlers/payment/connector/update/index.tsx new file mode 100644 index 000000000..de6e97b9a --- /dev/null +++ b/src/handlers/payment/connector/update/index.tsx @@ -0,0 +1,51 @@ +import z from "zod"; +import { InputValidationError } from "../../../../errors"; +import { createHandler, flag } from "../../../../router"; +import { JsonRendererKey } from "../../../../tui"; +import type { Core } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; +import type { UpdatePaymentConnectorInput } from "../../types"; + +// No --type flag: the service rejects any change to a connector's type after +// creation. Like the manager leaf, an omitted flag leaves the field unchanged +// and there is no way to unset a description, so no --clear-* flags either. +export const createUpdatePaymentConnectorHandler = (core: Core) => + createHandler({ + name: "update", + description: "update a payment connector", + flags: [ + flag("manager-id", "the parent payment manager id", z.string().optional()), + flag("connector-id", "the payment connector id", z.string().optional()), + flag("description", "updated description", z.string().optional()), + flag( + "credential-provider", + "replacement payment credential provider name or ARN (must match the connector type)", + z.string().min(1).optional(), + ), + flag("client-token", "idempotency token", z.string().optional()), + ], + handle: async (ctx, flags) => { + if (!flags["manager-id"]) { + throw new InputValidationError("required option '--manager-id ' not specified"); + } + if (!flags["connector-id"]) { + throw new InputValidationError( + "required option '--connector-id ' not specified", + ); + } + + const input: UpdatePaymentConnectorInput = { + managerId: flags["manager-id"], + connectorId: flags["connector-id"], + ...(flags.description !== undefined ? { description: flags.description } : {}), + ...(flags["credential-provider"] + ? { credentialProvider: flags["credential-provider"] } + : {}), + ...(flags["client-token"] ? { clientToken: flags["client-token"] } : {}), + }; + + ctx + .require(JsonRendererKey) + .renderJson(await core.payment.updatePaymentConnector(input, coreOptsFromCtx(ctx))); + }, + }); diff --git a/src/handlers/payment/manager/create/index.tsx b/src/handlers/payment/manager/create/index.tsx new file mode 100644 index 000000000..3114cf077 --- /dev/null +++ b/src/handlers/payment/manager/create/index.tsx @@ -0,0 +1,86 @@ +import type { AuthorizerConfiguration } from "@aws-sdk/client-bedrock-agentcore-control"; +import z from "zod"; +import { InputValidationError } from "../../../../errors"; +import { type AppIO, SourceResolver } from "../../../../io"; +import { createHandler, flag } from "../../../../router"; +import { JsonRendererKey } from "../../../../tui"; +import type { Core } from "../../../types"; +import { coreOptsFromCtx, parseJsonObjectFlag, parseTags } from "../../../utils"; +import type { CreatePaymentManagerInput } from "../../types"; + +export const createCreatePaymentManagerHandler = (core: Core, io: AppIO) => + createHandler({ + name: "create", + description: "create a payment manager (auto-provisions a service role if none given)", + flags: [ + flag( + "name", + "the payment manager name (letters and digits, up to 48 characters)", + z.string().optional(), + ), + flag("description", "payment manager description", z.string().optional()), + flag( + "authorizer-type", + "how agents authenticate to the data plane: AWS_IAM (default) or CUSTOM_JWT", + z.enum(["AWS_IAM", "CUSTOM_JWT"]).default("AWS_IAM"), + ), + flag( + "authorizer-configuration", + "CUSTOM_JWT configuration (JSON AuthorizerConfiguration; inline, file://, or - for stdin)", + z.string().optional(), + ), + flag( + "role-arn", + "IAM role the Payments service assumes; a default service role is created when omitted", + z.string().min(1).optional(), + ), + flag( + "kms-key-arn", + "customer managed KMS key ARN for encrypting sensitive data at rest", + z.string().min(1).optional(), + ), + flag("tags", "tags as key=value (repeatable) or JSON object", z.array(z.string()).optional()), + flag("client-token", "idempotency token", z.string().optional()), + ], + handle: async (ctx, flags) => { + // Required at runtime but declared optional so that a bare invocation can + // fall through to the TUI once a screen exists. + if (!flags.name) { + throw new InputValidationError("required option '--name ' not specified"); + } + if ( + flags["authorizer-type"] === "CUSTOM_JWT" && + flags["authorizer-configuration"] === undefined + ) { + throw new InputValidationError("CUSTOM_JWT requires --authorizer-configuration"); + } + if ( + flags["authorizer-type"] !== "CUSTOM_JWT" && + flags["authorizer-configuration"] !== undefined + ) { + throw new InputValidationError("--authorizer-configuration is valid only with CUSTOM_JWT"); + } + + const source = new SourceResolver({ stdin: io.stdin }); + const authorizerConfiguration = parseJsonObjectFlag( + "authorizer-configuration", + await source.resolveText("authorizer-configuration", flags["authorizer-configuration"]), + ); + const tags = parseTags(flags.tags); + + const input: CreatePaymentManagerInput = { + name: flags.name, + authorizerType: flags["authorizer-type"], + ...(flags.description ? { description: flags.description } : {}), + ...(authorizerConfiguration ? { authorizerConfiguration } : {}), + ...(flags["role-arn"] ? { roleArn: flags["role-arn"] } : {}), + ...(flags["kms-key-arn"] ? { kmsKeyArn: flags["kms-key-arn"] } : {}), + ...(tags ? { tags } : {}), + ...(flags["client-token"] ? { clientToken: flags["client-token"] } : {}), + }; + + ctx + .require(JsonRendererKey) + .renderJson(await core.payment.createPaymentManager(input, coreOptsFromCtx(ctx))); + }, + }); diff --git a/src/handlers/payment/manager/delete/index.tsx b/src/handlers/payment/manager/delete/index.tsx new file mode 100644 index 000000000..4f9136aec --- /dev/null +++ b/src/handlers/payment/manager/delete/index.tsx @@ -0,0 +1,31 @@ +import z from "zod"; +import { InputValidationError } from "../../../../errors"; +import { createHandler, flag } from "../../../../router"; +import { JsonRendererKey } from "../../../../tui"; +import type { Core } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; + +export const createDeletePaymentManagerHandler = (core: Core) => + createHandler({ + name: "delete", + description: "delete a payment manager (delete its connectors first)", + flags: [ + flag("id", "the payment manager id", z.string().optional()), + flag("client-token", "idempotency token", z.string().optional()), + ], + handle: async (ctx, flags) => { + if (!flags.id) { + throw new InputValidationError("required option '--id ' not specified"); + } + + ctx.require(JsonRendererKey).renderJson( + await core.payment.deletePaymentManager( + { + paymentManagerId: flags.id, + ...(flags["client-token"] ? { clientToken: flags["client-token"] } : {}), + }, + coreOptsFromCtx(ctx), + ), + ); + }, + }); diff --git a/src/handlers/payment/manager/index.tsx b/src/handlers/payment/manager/index.tsx index 1cd6b8652..7bd935043 100644 --- a/src/handlers/payment/manager/index.tsx +++ b/src/handlers/payment/manager/index.tsx @@ -2,12 +2,18 @@ import type { AppIO } from "../../../io"; import { Router } from "../../../router"; import { renderTui } from "../../../tui"; import type { Core } from "../../types"; +import { createCreatePaymentManagerHandler } from "./create"; +import { createDeletePaymentManagerHandler } from "./delete"; import { createGetPaymentManagerHandler } from "./get"; import { createListPaymentManagersHandler } from "./list"; +import { createUpdatePaymentManagerHandler } from "./update"; export function createPaymentManagerHandler(core: Core, io: AppIO): Router { return new Router("manager", "manage AgentCore payment managers") .default(renderTui(core, io)) + .handler(createCreatePaymentManagerHandler(core, io)) .handler(createGetPaymentManagerHandler(core)) - .handler(createListPaymentManagersHandler(core)); + .handler(createListPaymentManagersHandler(core)) + .handler(createUpdatePaymentManagerHandler(core, io)) + .handler(createDeletePaymentManagerHandler(core)); } diff --git a/src/handlers/payment/manager/update/index.tsx b/src/handlers/payment/manager/update/index.tsx new file mode 100644 index 000000000..d5e7dc32b --- /dev/null +++ b/src/handlers/payment/manager/update/index.tsx @@ -0,0 +1,70 @@ +import type { AuthorizerConfiguration } from "@aws-sdk/client-bedrock-agentcore-control"; +import z from "zod"; +import { InputValidationError } from "../../../../errors"; +import { type AppIO, SourceResolver } from "../../../../io"; +import { createHandler, flag } from "../../../../router"; +import { JsonRendererKey } from "../../../../tui"; +import type { Core } from "../../../types"; +import { coreOptsFromCtx, parseJsonObjectFlag } from "../../../utils"; +import type { UpdatePaymentManagerInput } from "../../types"; + +// The payment APIs are PATCH-style with no clear wrapper: an omitted flag leaves +// the field unchanged, and there is no way to unset a description, KMS key, or +// authorizer configuration, so the CLI offers no --clear-* flags here. +export const createUpdatePaymentManagerHandler = (core: Core, io: AppIO) => + createHandler({ + name: "update", + description: "update a payment manager", + flags: [ + flag("id", "the payment manager id", z.string().optional()), + flag("description", "updated description", z.string().optional()), + flag( + "authorizer-type", + "updated data-plane authorizer: AWS_IAM or CUSTOM_JWT", + z.enum(["AWS_IAM", "CUSTOM_JWT"]).optional(), + ), + flag( + "authorizer-configuration", + "replacement CUSTOM_JWT configuration (JSON AuthorizerConfiguration; inline, file://, or - for stdin)", + z.string().optional(), + ), + flag( + "role-arn", + "updated IAM role the Payments service assumes", + z.string().min(1).optional(), + ), + flag("kms-key-arn", "updated customer managed KMS key ARN", z.string().min(1).optional()), + flag("client-token", "idempotency token", z.string().optional()), + ], + handle: async (ctx, flags) => { + if (!flags.id) { + throw new InputValidationError("required option '--id ' not specified"); + } + if ( + flags["authorizer-type"] === "AWS_IAM" && + flags["authorizer-configuration"] !== undefined + ) { + throw new InputValidationError("--authorizer-configuration is valid only with CUSTOM_JWT"); + } + + const source = new SourceResolver({ stdin: io.stdin }); + const authorizerConfiguration = parseJsonObjectFlag( + "authorizer-configuration", + await source.resolveText("authorizer-configuration", flags["authorizer-configuration"]), + ); + + const input: UpdatePaymentManagerInput = { + paymentManagerId: flags.id, + ...(flags.description !== undefined ? { description: flags.description } : {}), + ...(flags["authorizer-type"] ? { authorizerType: flags["authorizer-type"] } : {}), + ...(authorizerConfiguration ? { authorizerConfiguration } : {}), + ...(flags["role-arn"] ? { roleArn: flags["role-arn"] } : {}), + ...(flags["kms-key-arn"] ? { kmsKeyArn: flags["kms-key-arn"] } : {}), + ...(flags["client-token"] ? { clientToken: flags["client-token"] } : {}), + }; + + ctx + .require(JsonRendererKey) + .renderJson(await core.payment.updatePaymentManager(input, coreOptsFromCtx(ctx))); + }, + }); diff --git a/src/handlers/payment/payment.read.test.tsx b/src/handlers/payment/payment.read.test.tsx index 2cb8dd8d3..eb54b6431 100644 --- a/src/handlers/payment/payment.read.test.tsx +++ b/src/handlers/payment/payment.read.test.tsx @@ -41,7 +41,7 @@ function setup(resource = "manager", overrides: Partial { +test("registers reads and mutations as CLI-only commands", () => { const payment = compile(setup().root, ValueContext.EmptyContext()).commands.find( (c) => c.name() === "payment", )!; @@ -50,8 +50,8 @@ test("registers the read-only command tree without TUI or mutation leaves", () = payment.commands.map((resource) => [resource.name(), resource.commands.map((c) => c.name())]), ), ).toEqual({ - manager: ["get", "list"], - connector: ["get", "list"], + manager: ["create", "get", "list", "update", "delete"], + connector: ["create", "get", "list", "update", "delete"], session: ["get", "list"], instrument: ["get", "list", "balance"], }); diff --git a/src/handlers/payment/payment.test.tsx b/src/handlers/payment/payment.test.tsx new file mode 100644 index 000000000..289eeb32a --- /dev/null +++ b/src/handlers/payment/payment.test.tsx @@ -0,0 +1,201 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import { join } from "node:path"; +import { CoreClient } from "../../core"; +import { paymentServiceRoleName } from "../../core/paymentServiceRole"; +import { createRootHandler } from "../index"; +import { + createSilentLogger, + fixtureFactories, + isRecording, + matchGolden, + TestGlobalConfigAccessor, + testIO, + waitFor, +} from "../../testing"; + +const FIXTURES = join(import.meta.dir, "__fixtures__"); +// The recorded manager lifecycle uses us-east-1; read fixtures use us-west-2. +const REGION = "us-east-1"; +const E2E_NAME = "AgentCoreCliPaymentE2E"; + +function createFixtureCore(fixtures = FIXTURES): CoreClient { + return new CoreClient({ ...fixtureFactories(fixtures), logger: createSilentLogger() }); +} + +async function run(args: string[], core = createFixtureCore(), io = testIO()): Promise { + const root = createRootHandler(core, { + io: io.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + await root.route(["node", "agentcore", "payment", "manager", ...args, "--region", REGION]); + return io.stdout(); +} + +describe("payment manager write inputs", () => { + test.each(["create", "update"] as const)( + "%s preserves explicit references and JWT configuration from stdin", + async (command) => { + const factories = fixtureFactories(FIXTURES); + const control = factories.createControlClient({ region: REGION }); + spyOn(control, "send").mockImplementation(async () => ({})); + const core = new CoreClient({ + ...factories, + createControlClient: () => control, + logger: createSilentLogger(), + }); + const call = spyOn( + core.payment, + command === "create" ? "createPaymentManager" : "updatePaymentManager", + ); + const roleArn = "arn:aws:iam::123456789012:role/PaymentRole"; + const kmsKeyArn = + "arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012"; + const authorizerConfiguration = { + customJWTAuthorizer: { + discoveryUrl: "https://example.test/.well-known/openid-configuration", + }, + }; + + await run( + [ + command, + ...(command === "create" + ? ["--name", "ExplicitReferences", "--authorizer-type", "CUSTOM_JWT"] + : ["--id", "manager-1", "--description", ""]), + "--role-arn", + roleArn, + "--kms-key-arn", + kmsKeyArn, + "--authorizer-configuration", + "-", + "--client-token", + "token-1", + ], + core, + testIO({ stdin: JSON.stringify(authorizerConfiguration) }), + ); + + expect(call).toHaveBeenCalledTimes(1); + expect(call).toHaveBeenCalledWith( + { + ...(command === "create" + ? { name: "ExplicitReferences", authorizerType: "CUSTOM_JWT" } + : { paymentManagerId: "manager-1", description: "" }), + roleArn, + kmsKeyArn, + authorizerConfiguration, + clientToken: "token-1", + }, + { region: REGION }, + ); + }, + ); + + test.each([ + ["create", "role-arn"], + ["create", "kms-key-arn"], + ["update", "role-arn"], + ["update", "kms-key-arn"], + ] as const)("%s rejects empty --%s before Core or stdin", async (command, flag) => { + const core = createFixtureCore(); + const call = spyOn( + core.payment, + command === "create" ? "createPaymentManager" : "updatePaymentManager", + ); + const io = testIO({ stdin: "{}" }); + + await expect( + run( + [ + command, + ...(command === "create" ? ["--name", "EmptyReference"] : ["--id", "manager-1"]), + `--${flag}`, + "", + "--authorizer-type", + "CUSTOM_JWT", + "--authorizer-configuration", + "-", + ], + core, + io, + ), + ).rejects.toThrow(`Invalid value for option '--${flag}'`); + expect(call).not.toHaveBeenCalled(); + expect(io.io.stdin.readableLength).toBe(2); + expect(io.stdout()).toBe(""); + }); + + test("enforces JWT configuration combinations for create and update", async () => { + await expect( + run(["create", "--name", "Jwt", "--authorizer-type", "CUSTOM_JWT"]), + ).rejects.toThrow("CUSTOM_JWT requires --authorizer-configuration"); + await expect( + run(["create", "--name", "Iam", "--authorizer-configuration", "{}"]), + ).rejects.toThrow("--authorizer-configuration is valid only with CUSTOM_JWT"); + await expect( + run([ + "update", + "--id", + "manager-1", + "--authorizer-type", + "AWS_IAM", + "--authorizer-configuration", + "{}", + ]), + ).rejects.toThrow("--authorizer-configuration is valid only with CUSTOM_JWT"); + }); +}); + +test("payment manager lifecycle replays default-role creation, update, and deletion through root/Core", async () => { + const created = await run([ + "create", + "--name", + E2E_NAME, + "--description", + "Created by the agentcore CLI end-to-end test", + "--tags", + "created-by=agentcore-cli-e2e", + ]); + matchGolden(FIXTURES, "manager-create.golden.json", created); + const manager = JSON.parse(created); + expect(manager.authorizerType).toBe("AWS_IAM"); + expect(manager.roleArn).toContain(paymentServiceRoleName(E2E_NAME, REGION)); + const scoped = ["--id", manager.paymentManagerId]; + await waitFor( + async () => JSON.parse(await run(["get", ...scoped])).status === "READY", + isRecording() ? 300_000 : 0, + 5_000, + ); + + const description = "Updated by the agentcore CLI end-to-end test"; + const updated = await run(["update", ...scoped, "--description", description]); + matchGolden(FIXTURES, "manager-update.golden.json", updated); + await waitFor( + async () => { + const detail = JSON.parse(await run(["get", ...scoped])); + return detail.status === "READY" && detail.description === description; + }, + isRecording() ? 300_000 : 0, + 5_000, + ); + + const deleted = await run(["delete", ...scoped]); + matchGolden(FIXTURES, "manager-delete.golden.json", deleted); + expect(JSON.parse(deleted).status).toBe("DELETING"); + + // The same Get request has a separate post-delete fixture. + await waitFor( + async () => { + try { + await run(["get", ...scoped], createFixtureCore(join(FIXTURES, "after-delete"))); + return false; + } catch (error) { + expect(error).toMatchObject({ name: "ResourceNotFoundException" }); + return true; + } + }, + isRecording() ? 300_000 : 0, + 5_000, + ); +}, 1_800_000); diff --git a/src/handlers/payment/types.tsx b/src/handlers/payment/types.tsx index 7456a5389..fe0cc2ce7 100644 --- a/src/handlers/payment/types.tsx +++ b/src/handlers/payment/types.tsx @@ -1,8 +1,20 @@ import type { + CreatePaymentConnectorResponse, + CreatePaymentManagerRequest, + CreatePaymentManagerResponse, + DeletePaymentConnectorRequest, + DeletePaymentConnectorResponse, + DeletePaymentManagerRequest, + DeletePaymentManagerResponse, GetPaymentConnectorResponse, GetPaymentManagerResponse, ListPaymentConnectorsResponse, ListPaymentManagersResponse, + PaymentConnectorType, + UpdatePaymentConnectorRequest, + UpdatePaymentConnectorResponse, + UpdatePaymentManagerRequest, + UpdatePaymentManagerResponse, } from "@aws-sdk/client-bedrock-agentcore-control"; import type { GetPaymentInstrumentRequest, @@ -18,8 +30,42 @@ import type { } from "@aws-sdk/client-bedrock-agentcore"; import type { CoreOptions } from "../../core/types"; -type WithPaymentManagerId = Omit & { managerId: string }; +// CreatePaymentManagerInput is CreatePaymentManagerRequest with the service role +// made optional: when omitted, Core provisions the default service role in IAM and +// creates the manager with it. +export type CreatePaymentManagerInput = Omit & { + roleArn?: string; +}; + +export type UpdatePaymentManagerInput = UpdatePaymentManagerRequest; + +// CreatePaymentConnectorInput names a credential provider instead of carrying the +// SDK's configuration list. Core resolves a provider name to its ARN and vendor +// through identity, derives the connector type from that vendor when the caller +// omits it, and builds the single-entry union the service expects. Quick Create +// sends no credentials; the service provisions them after OAuth consent. +export type CreatePaymentConnectorInput = { + managerId: string; + name: string; + description?: string; + type?: PaymentConnectorType; + // A payment credential provider name or ARN. Required unless quickCreate is set. + credentialProvider?: string; + quickCreate?: boolean; + clientToken?: string; +}; + +// UpdatePaymentConnectorInput omits `type`: the service rejects any change to a +// connector's type after creation, so the CLI does not offer it. +export type UpdatePaymentConnectorInput = { + managerId: string; + connectorId: string; + description?: UpdatePaymentConnectorRequest["description"]; + credentialProvider?: string; + clientToken?: string; +}; +type WithPaymentManagerId = Omit & { managerId: string }; export type GetPaymentSessionInput = WithPaymentManagerId; export type ListPaymentSessionsInput = WithPaymentManagerId; export type GetPaymentInstrumentInput = WithPaymentManagerId; @@ -28,12 +74,29 @@ export type GetPaymentInstrumentBalanceInput = export type ListPaymentInstrumentsInput = WithPaymentManagerId; export interface CorePaymentClient { + createPaymentManager( + input: CreatePaymentManagerInput, + options: CoreOptions, + ): Promise; getPaymentManager(id: string, options: CoreOptions): Promise; listPaymentManagers( nextToken: string | undefined, maxResults: number | undefined, options: CoreOptions, ): Promise; + updatePaymentManager( + input: UpdatePaymentManagerInput, + options: CoreOptions, + ): Promise; + deletePaymentManager( + request: DeletePaymentManagerRequest, + options: CoreOptions, + ): Promise; + + createPaymentConnector( + input: CreatePaymentConnectorInput, + options: CoreOptions, + ): Promise; getPaymentConnector( managerId: string, connectorId: string, @@ -45,8 +108,14 @@ export interface CorePaymentClient { maxResults: number | undefined, options: CoreOptions, ): Promise; - - // Core resolves the selected manager ID to the ARN required by the data plane. + updatePaymentConnector( + input: UpdatePaymentConnectorInput, + options: CoreOptions, + ): Promise; + deletePaymentConnector( + request: DeletePaymentConnectorRequest, + options: CoreOptions, + ): Promise; getPaymentSession( request: GetPaymentSessionInput, options: CoreOptions, diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index 62909a9ab..3d6429523 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -33,6 +33,12 @@ import type { GetPaymentManagerResponse, ListPaymentConnectorsResponse, ListPaymentManagersResponse, + CreatePaymentManagerResponse, + UpdatePaymentManagerResponse, + DeletePaymentManagerResponse, + CreatePaymentConnectorResponse, + UpdatePaymentConnectorResponse, + DeletePaymentConnectorResponse, ListAgentRuntimeEndpointsResponse, ListAgentRuntimesResponse, ListAgentRuntimeVersionsResponse, @@ -1535,6 +1541,24 @@ export class TestIdentityClient implements CoreIdentityClient { // Payment command tests use real Core clients; configure a stub explicitly if a // future screen test needs one. export class TestPaymentClient implements CorePaymentClient { + async createPaymentManager(): Promise { + throw new Error("Unexpected payment call"); + } + async updatePaymentManager(): Promise { + throw new Error("Unexpected payment call"); + } + async deletePaymentManager(): Promise { + throw new Error("Unexpected payment call"); + } + async createPaymentConnector(): Promise { + throw new Error("Unexpected payment call"); + } + async updatePaymentConnector(): Promise { + throw new Error("Unexpected payment call"); + } + async deletePaymentConnector(): Promise { + throw new Error("Unexpected payment call"); + } async getPaymentManager(): Promise { throw new Error("Unexpected payment call"); }