diff --git a/README.md b/README.md index fa5871212..fe347637a 100644 --- a/README.md +++ b/README.md @@ -127,11 +127,15 @@ agentcore # interactive TUI │ │ ├── update # update a connector's description or credential provider │ │ └── delete # delete a connector │ ├── session # budget-limited payment contexts (data plane) +│ │ ├── create # create a session with an expiry and optional spend limit │ │ ├── get -│ │ └── list +│ │ ├── list +│ │ └── delete │ └── instrument # embedded crypto wallets (data plane) +│ ├── create # create a wallet for a user on a connector │ ├── get │ ├── list +│ ├── delete │ └── balance # read token balance on an explicit chain (default token: USDC) ├── eval # evaluate and optimize AgentCore agents │ └── evaluator # manage AgentCore evaluators @@ -243,8 +247,8 @@ agentcore identity payment-credential-provider create --name cdp-creds --vendor 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 -agentcore payment instrument list --manager-id --user-id alice +agentcore payment session create --manager-id --user-id alice \ + --expiry-minutes 60 --max-spend 10.00 --currency USD # Check funding on one chain. USDC is the default token. agentcore payment instrument balance --manager-id --user-id alice \ diff --git a/src/core/payment.tsx b/src/core/payment.tsx index 2f93ae28c..1de9a675f 100644 --- a/src/core/payment.tsx +++ b/src/core/payment.tsx @@ -25,12 +25,20 @@ import { type UpdatePaymentManagerResponse, } from "@aws-sdk/client-bedrock-agentcore-control"; import { + CreatePaymentInstrumentCommand, + CreatePaymentSessionCommand, + DeletePaymentInstrumentCommand, + DeletePaymentSessionCommand, GetPaymentInstrumentBalanceCommand, GetPaymentInstrumentCommand, GetPaymentSessionCommand, ListPaymentInstrumentsCommand, ListPaymentSessionsCommand, type BedrockAgentCoreClient, + type CreatePaymentInstrumentResponse, + type CreatePaymentSessionResponse, + type DeletePaymentInstrumentResponse, + type DeletePaymentSessionResponse, type GetPaymentInstrumentResponse, type GetPaymentInstrumentBalanceResponse, type GetPaymentSessionResponse, @@ -48,11 +56,15 @@ import type { CorePaymentClient, CreatePaymentConnectorInput, CreatePaymentManagerInput, + CreatePaymentSessionInput, GetPaymentSessionInput, ListPaymentSessionsInput, + DeletePaymentSessionInput, + CreatePaymentInstrumentInput, GetPaymentInstrumentInput, GetPaymentInstrumentBalanceInput, ListPaymentInstrumentsInput, + DeletePaymentInstrumentInput, UpdatePaymentConnectorInput, UpdatePaymentManagerInput, } from "../handlers/payment/types"; @@ -248,6 +260,18 @@ export class PaymentClient implements CorePaymentClient { .send(new DeletePaymentConnectorCommand({ ...request })); } + // ─── payment sessions (data plane) ────────────────────────────────────────── + + async createPaymentSession( + input: CreatePaymentSessionInput, + options: CoreOptions, + ): Promise { + const { managerId, ...request } = input; + return this.withPaymentManagerArn(managerId, options, (data, paymentManagerArn) => + data.send(new CreatePaymentSessionCommand({ paymentManagerArn, ...request })), + ); + } + async getPaymentSession( input: GetPaymentSessionInput, options: CoreOptions, @@ -268,6 +292,28 @@ export class PaymentClient implements CorePaymentClient { ); } + async deletePaymentSession( + input: DeletePaymentSessionInput, + options: CoreOptions, + ): Promise { + const { managerId, ...request } = input; + return this.withPaymentManagerArn(managerId, options, (data, paymentManagerArn) => + data.send(new DeletePaymentSessionCommand({ paymentManagerArn, ...request })), + ); + } + + // ─── payment instruments (data plane) ─────────────────────────────────────── + + async createPaymentInstrument( + input: CreatePaymentInstrumentInput, + options: CoreOptions, + ): Promise { + const { managerId, ...request } = input; + return this.withPaymentManagerArn(managerId, options, (data, paymentManagerArn) => + data.send(new CreatePaymentInstrumentCommand({ paymentManagerArn, ...request })), + ); + } + async getPaymentInstrument( input: GetPaymentInstrumentInput, options: CoreOptions, @@ -298,6 +344,16 @@ export class PaymentClient implements CorePaymentClient { ); } + async deletePaymentInstrument( + input: DeletePaymentInstrumentInput, + options: CoreOptions, + ): Promise { + const { managerId, ...request } = input; + return this.withPaymentManagerArn(managerId, options, (data, paymentManagerArn) => + data.send(new DeletePaymentInstrumentCommand({ paymentManagerArn, ...request })), + ); + } + // ─── helpers ──────────────────────────────────────────────────────────────── private async resolveConnectorCredentials( diff --git a/src/handlers/payment/__fixtures__/instrument/CreatePaymentInstrumentCommand.7b6d22c9eab936d3.json b/src/handlers/payment/__fixtures__/instrument/CreatePaymentInstrumentCommand.7b6d22c9eab936d3.json new file mode 100644 index 000000000..045ee3a27 --- /dev/null +++ b/src/handlers/payment/__fixtures__/instrument/CreatePaymentInstrumentCommand.7b6d22c9eab936d3.json @@ -0,0 +1,23 @@ +{ + "paymentInstrument": { + "paymentInstrumentId": "payment-instrument-CG2Tl7U1HnCGfHW", + "paymentManagerArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:payment-manager/mypaymentmanageraidandal-gx3nxzaira", + "paymentConnectorId": "mycdpconnectoraidandal-okve8guw4y", + "userId": "agentcore-cli-e2e", + "paymentInstrumentType": "EMBEDDED_CRYPTO_WALLET", + "paymentInstrumentDetails": { + "embeddedCryptoWallet": { + "network": "ETHEREUM", + "walletAddress": "0x93581aB831Cc862aA451E91fBf8365e098930859", + "redirectUrl": "https://hub.cdp.coinbase.com/e3eae6406a52" + } + }, + "createdAt": { + "$date": "2026-09-08T20:24:40.849Z" + }, + "status": "ACTIVE", + "updatedAt": { + "$date": "2026-09-08T20:24:41.673Z" + } + } +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/instrument/DeletePaymentInstrumentCommand.4b151101264cb8f8.json b/src/handlers/payment/__fixtures__/instrument/DeletePaymentInstrumentCommand.4b151101264cb8f8.json new file mode 100644 index 000000000..a0cce6623 --- /dev/null +++ b/src/handlers/payment/__fixtures__/instrument/DeletePaymentInstrumentCommand.4b151101264cb8f8.json @@ -0,0 +1,3 @@ +{ + "status": "DELETED" +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/instrument/after-delete/GetPaymentInstrumentCommand.1f6c9a8ae4328e61.json b/src/handlers/payment/__fixtures__/instrument/after-delete/GetPaymentInstrumentCommand.1f6c9a8ae4328e61.json new file mode 100644 index 000000000..e9e7d7731 --- /dev/null +++ b/src/handlers/payment/__fixtures__/instrument/after-delete/GetPaymentInstrumentCommand.1f6c9a8ae4328e61.json @@ -0,0 +1,6 @@ +{ + "$error": { + "name": "ResourceNotFoundException", + "message": "Payment instrument not found: payment-instrument-CG2Tl7U1HnCGfHW for the given user and manager." + } +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/instrument/instrument-create.golden.json b/src/handlers/payment/__fixtures__/instrument/instrument-create.golden.json new file mode 100644 index 000000000..468d5d808 --- /dev/null +++ b/src/handlers/payment/__fixtures__/instrument/instrument-create.golden.json @@ -0,0 +1,19 @@ +{ + "paymentInstrument": { + "paymentInstrumentId": "payment-instrument-CG2Tl7U1HnCGfHW", + "paymentManagerArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:payment-manager/mypaymentmanageraidandal-gx3nxzaira", + "paymentConnectorId": "mycdpconnectoraidandal-okve8guw4y", + "userId": "agentcore-cli-e2e", + "paymentInstrumentType": "EMBEDDED_CRYPTO_WALLET", + "paymentInstrumentDetails": { + "embeddedCryptoWallet": { + "network": "ETHEREUM", + "walletAddress": "0x93581aB831Cc862aA451E91fBf8365e098930859", + "redirectUrl": "https://hub.cdp.coinbase.com/e3eae6406a52" + } + }, + "createdAt": "2026-09-08T20:24:40.849Z", + "status": "ACTIVE", + "updatedAt": "2026-09-08T20:24:41.673Z" + } +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/instrument/instrument-delete.golden.json b/src/handlers/payment/__fixtures__/instrument/instrument-delete.golden.json new file mode 100644 index 000000000..a0cce6623 --- /dev/null +++ b/src/handlers/payment/__fixtures__/instrument/instrument-delete.golden.json @@ -0,0 +1,3 @@ +{ + "status": "DELETED" +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/session/CreatePaymentSessionCommand.902bade07933ebb1.json b/src/handlers/payment/__fixtures__/session/CreatePaymentSessionCommand.902bade07933ebb1.json new file mode 100644 index 000000000..a0a7107be --- /dev/null +++ b/src/handlers/payment/__fixtures__/session/CreatePaymentSessionCommand.902bade07933ebb1.json @@ -0,0 +1,29 @@ +{ + "paymentSession": { + "paymentSessionId": "payment-session-nq812U4e1BJIfw1", + "paymentManagerArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:payment-manager/mypaymentmanageraidandal-gx3nxzaira", + "userId": "agentcore-cli-e2e", + "expiryTimeInMinutes": 15, + "createdAt": { + "$date": "2026-09-08T20:20:28.618Z" + }, + "updatedAt": { + "$date": "2026-09-08T20:20:28.618Z" + }, + "limits": { + "maxSpendAmount": { + "value": "1.00", + "currency": "USD" + } + }, + "availableLimits": { + "availableSpendAmount": { + "value": "1.00", + "currency": "USD" + }, + "updatedAt": { + "$date": "2026-09-08T20:20:28.697Z" + } + } + } +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/session/DeletePaymentSessionCommand.86f3e58b4b886322.json b/src/handlers/payment/__fixtures__/session/DeletePaymentSessionCommand.86f3e58b4b886322.json new file mode 100644 index 000000000..a0cce6623 --- /dev/null +++ b/src/handlers/payment/__fixtures__/session/DeletePaymentSessionCommand.86f3e58b4b886322.json @@ -0,0 +1,3 @@ +{ + "status": "DELETED" +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/session/after-delete/GetPaymentSessionCommand.86f3e58b4b886322.json b/src/handlers/payment/__fixtures__/session/after-delete/GetPaymentSessionCommand.86f3e58b4b886322.json new file mode 100644 index 000000000..aedf25702 --- /dev/null +++ b/src/handlers/payment/__fixtures__/session/after-delete/GetPaymentSessionCommand.86f3e58b4b886322.json @@ -0,0 +1,6 @@ +{ + "$error": { + "name": "ResourceNotFoundException", + "message": "Payment session not found: payment-session-nq812U4e1BJIfw1" + } +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/session/session-create.golden.json b/src/handlers/payment/__fixtures__/session/session-create.golden.json new file mode 100644 index 000000000..c6d24d46a --- /dev/null +++ b/src/handlers/payment/__fixtures__/session/session-create.golden.json @@ -0,0 +1,23 @@ +{ + "paymentSession": { + "paymentSessionId": "payment-session-nq812U4e1BJIfw1", + "paymentManagerArn": "arn:aws:bedrock-agentcore:us-west-2:603141041947:payment-manager/mypaymentmanageraidandal-gx3nxzaira", + "userId": "agentcore-cli-e2e", + "expiryTimeInMinutes": 15, + "createdAt": "2026-09-08T20:20:28.618Z", + "updatedAt": "2026-09-08T20:20:28.618Z", + "limits": { + "maxSpendAmount": { + "value": "1.00", + "currency": "USD" + } + }, + "availableLimits": { + "availableSpendAmount": { + "value": "1.00", + "currency": "USD" + }, + "updatedAt": "2026-09-08T20:20:28.697Z" + } + } +} \ No newline at end of file diff --git a/src/handlers/payment/__fixtures__/session/session-delete.golden.json b/src/handlers/payment/__fixtures__/session/session-delete.golden.json new file mode 100644 index 000000000..a0cce6623 --- /dev/null +++ b/src/handlers/payment/__fixtures__/session/session-delete.golden.json @@ -0,0 +1,3 @@ +{ + "status": "DELETED" +} \ No newline at end of file diff --git a/src/handlers/payment/instrument/create/index.tsx b/src/handlers/payment/instrument/create/index.tsx new file mode 100644 index 000000000..04db18908 --- /dev/null +++ b/src/handlers/payment/instrument/create/index.tsx @@ -0,0 +1,140 @@ +import type { + CryptoWalletNetwork, + EmbeddedCryptoWallet, + LinkedAccount, + PaymentInstrumentType, +} from "@aws-sdk/client-bedrock-agentcore"; +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 { assertMutuallyExclusiveFlags, coreOptsFromCtx, parseJsonObjectFlag } from "../../../utils"; +import type { CreatePaymentInstrumentInput } from "../../types"; + +// Pinning these lists against the SDK types turns a new enum value into a +// compile-time reminder to widen the flag. +const INSTRUMENT_TYPES = [ + "EMBEDDED_CRYPTO_WALLET", +] as const satisfies readonly PaymentInstrumentType[]; +const DEFAULT_INSTRUMENT_TYPE: PaymentInstrumentType = "EMBEDDED_CRYPTO_WALLET"; +const NETWORKS = ["ETHEREUM", "SOLANA"] as const satisfies readonly CryptoWalletNetwork[]; + +export const createCreatePaymentInstrumentHandler = (core: Core, io: AppIO) => + createHandler({ + name: "create", + description: "create a payment instrument (an embedded crypto wallet) for a user", + flags: [ + flag("manager-id", "the payment manager ID that owns the instrument", z.string().optional()), + flag( + "user-id", + "the user the instrument belongs to (required for IAM-authenticated calls)", + z.string().optional(), + ), + flag("agent-name", "agent name recorded for observability", z.string().optional()), + flag( + "connector-id", + "the payment connector that provisions the wallet", + z.string().optional(), + ), + flag( + "type", + `instrument type (${INSTRUMENT_TYPES.join(" | ")}; default ${DEFAULT_INSTRUMENT_TYPE})`, + z.enum(INSTRUMENT_TYPES).default(DEFAULT_INSTRUMENT_TYPE), + ), + flag( + "network", + `blockchain network of the wallet (${NETWORKS.join(" | ")}; shorthand form)`, + z.enum(NETWORKS).optional(), + ), + flag( + "email", + "email address linked to the wallet (repeatable; shorthand form)", + z.array(z.string()).optional(), + ), + flag( + "phone-number", + "E.164 phone number linked to the wallet (repeatable; shorthand form)", + z.array(z.string()).optional(), + ), + flag( + "instrument-details", + "full wallet definition (JSON EmbeddedCryptoWallet; inline, file://, or - for stdin); replaces the shorthand flags", + 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["manager-id"]) { + throw new InputValidationError("required option '--manager-id ' not specified"); + } + if (!flags["user-id"]) { + throw new InputValidationError("required option '--user-id ' not specified"); + } + if (!flags["connector-id"]) { + throw new InputValidationError( + "required option '--connector-id ' not specified", + ); + } + + const source = new SourceResolver({ stdin: io.stdin }); + const wallet = await resolveWallet(flags, source); + + const request: CreatePaymentInstrumentInput = { + managerId: flags["manager-id"], + userId: flags["user-id"], + paymentConnectorId: flags["connector-id"], + paymentInstrumentType: flags.type, + paymentInstrumentDetails: { embeddedCryptoWallet: wallet }, + ...(flags["agent-name"] ? { agentName: flags["agent-name"] } : {}), + ...(flags["client-token"] ? { clientToken: flags["client-token"] } : {}), + }; + + ctx + .require(JsonRendererKey) + .renderJson(await core.payment.createPaymentInstrument(request, coreOptsFromCtx(ctx))); + }, + }); + +type WalletFlags = { + network?: CryptoWalletNetwork; + email?: string[]; + "phone-number"?: string[]; + "instrument-details"?: string; +}; + +// resolveWallet builds the EmbeddedCryptoWallet from whichever input form the +// caller chose. The JSON form is passed through as-is (deep validation is left to +// the service); the shorthand form covers the common email/SMS onboarding case. +async function resolveWallet( + flags: WalletFlags, + source: SourceResolver, +): Promise { + for (const name of ["network", "email", "phone-number"]) { + assertMutuallyExclusiveFlags(flags, ["instrument-details", name]); + } + + if (flags["instrument-details"] !== undefined) { + return parseJsonObjectFlag( + "instrument-details", + await source.resolveText("instrument-details", flags["instrument-details"]), + )!; + } + + if (!flags.network) { + throw new InputValidationError("required option '--network ' not specified"); + } + const linkedAccounts: LinkedAccount[] = [ + ...(flags.email ?? []).map((emailAddress) => ({ email: { emailAddress } })), + ...(flags["phone-number"] ?? []).map((phoneNumber) => ({ sms: { phoneNumber } })), + ]; + if (linkedAccounts.length === 0) { + throw new InputValidationError( + "the shorthand form needs at least one --email or --phone-number to link to the wallet", + ); + } + return { network: flags.network, linkedAccounts }; +} diff --git a/src/handlers/payment/instrument/delete/index.tsx b/src/handlers/payment/instrument/delete/index.tsx new file mode 100644 index 000000000..75aceedd4 --- /dev/null +++ b/src/handlers/payment/instrument/delete/index.tsx @@ -0,0 +1,58 @@ +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 { DeletePaymentInstrumentInput } from "../../types"; + +// DeletePaymentInstrumentInput carries no agentName, so unlike the other +// instrument leaves this one offers no --agent-name. +export const createDeletePaymentInstrumentHandler = (core: Core) => + createHandler({ + name: "delete", + description: "delete a payment instrument", + flags: [ + flag("manager-id", "the payment manager ID that owns the instrument", z.string().optional()), + flag( + "user-id", + "the user the instrument belongs to (required for IAM-authenticated calls)", + z.string().optional(), + ), + flag( + "connector-id", + "the payment connector the instrument was created under", + z.string().optional(), + ), + flag("instrument-id", "the payment instrument id", z.string().optional()), + ], + handle: async (ctx, flags) => { + if (!flags["manager-id"]) { + throw new InputValidationError("required option '--manager-id ' not specified"); + } + if (!flags["user-id"]) { + throw new InputValidationError("required option '--user-id ' not specified"); + } + if (!flags["connector-id"]) { + throw new InputValidationError( + "required option '--connector-id ' not specified", + ); + } + if (!flags["instrument-id"]) { + throw new InputValidationError( + "required option '--instrument-id ' not specified", + ); + } + + const request: DeletePaymentInstrumentInput = { + managerId: flags["manager-id"], + userId: flags["user-id"], + paymentConnectorId: flags["connector-id"], + paymentInstrumentId: flags["instrument-id"], + }; + + ctx + .require(JsonRendererKey) + .renderJson(await core.payment.deletePaymentInstrument(request, coreOptsFromCtx(ctx))); + }, + }); diff --git a/src/handlers/payment/instrument/index.tsx b/src/handlers/payment/instrument/index.tsx index 6b76db261..152bdfcef 100644 --- a/src/handlers/payment/instrument/index.tsx +++ b/src/handlers/payment/instrument/index.tsx @@ -2,6 +2,8 @@ import type { AppIO } from "../../../io"; import { Router } from "../../../router"; import { renderTui } from "../../../tui"; import type { Core } from "../../types"; +import { createCreatePaymentInstrumentHandler } from "./create"; +import { createDeletePaymentInstrumentHandler } from "./delete"; import { createGetPaymentInstrumentHandler } from "./get"; import { createListPaymentInstrumentsHandler } from "./list"; import { createGetPaymentInstrumentBalanceHandler } from "./balance"; @@ -9,7 +11,9 @@ import { createGetPaymentInstrumentBalanceHandler } from "./balance"; export function createPaymentInstrumentHandler(core: Core, io: AppIO): Router { return new Router("instrument", "manage payment instruments (embedded crypto wallets)") .default(renderTui(core, io)) + .handler(createCreatePaymentInstrumentHandler(core, io)) .handler(createGetPaymentInstrumentHandler(core)) .handler(createListPaymentInstrumentsHandler(core)) + .handler(createDeletePaymentInstrumentHandler(core)) .handler(createGetPaymentInstrumentBalanceHandler(core)); } diff --git a/src/handlers/payment/instrument/instrument.test.tsx b/src/handlers/payment/instrument/instrument.test.tsx new file mode 100644 index 000000000..b76bdf93d --- /dev/null +++ b/src/handlers/payment/instrument/instrument.test.tsx @@ -0,0 +1,173 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import { join } from "node:path"; +import type { + CreatePaymentInstrumentRequest, + EmbeddedCryptoWallet, +} from "@aws-sdk/client-bedrock-agentcore"; +import { CoreClient } from "../../../core"; +import { createRootHandler } from "../../index"; +import { + createSilentLogger, + fixtureFactories, + matchGolden, + parse, + TestGlobalConfigAccessor, + testIO, +} from "../../../testing"; +import instrumentCreateFixture from "../__fixtures__/instrument/CreatePaymentInstrumentCommand.7b6d22c9eab936d3.json"; + +const PAYMENT_FIXTURES = join(import.meta.dir, "..", "__fixtures__"); +const FIXTURES = join(PAYMENT_FIXTURES, "instrument"); +const REGION = "us-west-2"; +const MANAGER_ID = "mypaymentmanageraidandal-gx3nxzaira"; +const CONNECTOR_ID = "mycdpconnectoraidandal-okve8guw4y"; +const USER_ID = "agentcore-cli-e2e"; +const EMAIL = "agentcore-cli-e2e@example.com"; +const scoped = ["--manager-id", MANAGER_ID, "--user-id", USER_ID]; +const connectorScoped = [...scoped, "--connector-id", CONNECTOR_ID]; +const shorthand = ["--network", "ETHEREUM", "--email", EMAIL]; + +function createFixtureCore(fixtures = FIXTURES): CoreClient { + return new CoreClient({ + ...fixtureFactories(PAYMENT_FIXTURES), + createDataClient: fixtureFactories(fixtures).createDataClient, + 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", "instrument", ...args, "--region", REGION]); + return io.stdout(); +} + +async function capture(args: string[], stdin?: string): Promise { + const data = fixtureFactories(FIXTURES).createDataClient({ region: REGION }); + const send = spyOn(data, "send").mockResolvedValue( + parse(JSON.stringify(instrumentCreateFixture)), + ); + const core = new CoreClient({ + ...fixtureFactories(PAYMENT_FIXTURES), + createDataClient: () => data, + logger: createSilentLogger(), + }); + await run(["create", ...connectorScoped, ...args], core, testIO({ stdin })); + expect(send).toHaveBeenCalledTimes(1); + return send.mock.calls[0]![0].input as CreatePaymentInstrumentRequest; +} + +describe("payment instrument wallet inputs", () => { + test.each([ + { flags: ["--network", "ETHEREUM"], conflict: "network" }, + { flags: ["--email", EMAIL], conflict: "email" }, + { flags: ["--phone-number", "+15555550100"], conflict: "phone-number" }, + ])( + "rejects shorthand $flags alongside JSON before reading stdin", + async ({ flags, conflict }) => { + const core = createFixtureCore(); + const create = spyOn(core.payment, "createPaymentInstrument"); + const io = testIO({ stdin: "{}" }); + await expect( + run(["create", ...connectorScoped, ...flags, "--instrument-details", "-"], core, io), + ).rejects.toThrow(`--instrument-details, --${conflict} are mutually exclusive`); + expect(create).not.toHaveBeenCalled(); + expect(io.io.stdin.readableLength).toBe(2); + }, + ); + + test("shorthand needs a network and at least one linked account", async () => { + await expect(run(["create", ...connectorScoped, "--email", EMAIL])).rejects.toThrow( + "required option '--network ' not specified", + ); + await expect(run(["create", ...connectorScoped, "--network", "ETHEREUM"])).rejects.toThrow( + "at least one --email or --phone-number", + ); + }); + + test("shorthand preserves repeated email/SMS accounts and omits unset metadata", async () => { + const request = await capture([ + "--network", + "SOLANA", + "--email", + "one@example.com", + "--email", + "two@example.com", + "--phone-number", + "+15555550100", + ]); + expect(request.paymentInstrumentType).toBe("EMBEDDED_CRYPTO_WALLET"); + expect(request.paymentInstrumentDetails).toEqual({ + embeddedCryptoWallet: { + network: "SOLANA", + linkedAccounts: [ + { email: { emailAddress: "one@example.com" } }, + { email: { emailAddress: "two@example.com" } }, + { sms: { phoneNumber: "+15555550100" } }, + ], + }, + }); + expect(request).not.toHaveProperty("agentName"); + expect(request).not.toHaveProperty("clientToken"); + }); + + test.each(["inline", "stdin"])( + "passes the full wallet and optional metadata from %s", + async (source) => { + const wallet: EmbeddedCryptoWallet = { + network: "ETHEREUM", + linkedAccounts: [ + { developerJwt: { kid: "key-1", sub: "user-1" } }, + { oAuth2: { google: { sub: "google-sub", emailAddress: "g@example.com" } } }, + ], + walletAddress: "0x1234567890abcdef1234567890abcdef12345678", + redirectUrl: "https://example.test/return", + }; + const json = JSON.stringify(wallet); + const request = await capture( + [ + "--instrument-details", + source === "stdin" ? "-" : json, + "--agent-name", + "my-agent", + "--client-token", + "token-1", + ], + source === "stdin" ? json : undefined, + ); + expect(request.paymentInstrumentDetails).toEqual({ embeddedCryptoWallet: wallet }); + expect(request.paymentInstrumentType).toBe("EMBEDDED_CRYPTO_WALLET"); + expect(request.agentName).toBe("my-agent"); + expect(request.clientToken).toBe("token-1"); + }, + ); +}); + +test("payment instrument lifecycle replays wallet provisioning and deletion through root/Core", async () => { + const created = await run(["create", ...connectorScoped, ...shorthand]); + matchGolden(FIXTURES, "instrument-create.golden.json", created); + const { paymentInstrument } = JSON.parse(created); + const instrumentArgs = ["--instrument-id", paymentInstrument.paymentInstrumentId]; + + const detail = await run(["get", ...scoped, ...instrumentArgs]); + matchGolden(FIXTURES, "instrument-get.golden.json", detail); + const wallet = JSON.parse(detail).paymentInstrument; + expect(wallet.paymentInstrumentId).toBe(paymentInstrument.paymentInstrumentId); + expect(wallet.paymentConnectorId).toBe(CONNECTOR_ID); + expect(wallet.status).toBe("ACTIVE"); + expect(wallet.paymentInstrumentDetails.embeddedCryptoWallet.walletAddress).toMatch( + /^0x[0-9a-fA-F]{40}$/, + ); + + const deleted = await run(["delete", ...connectorScoped, ...instrumentArgs]); + matchGolden(FIXTURES, "instrument-delete.golden.json", deleted); + expect(JSON.parse(deleted).status).toBe("DELETED"); + + // The same Get request has a separate post-delete fixture. + await expect( + run(["get", ...scoped, ...instrumentArgs], createFixtureCore(join(FIXTURES, "after-delete"))), + ).rejects.toThrow(/ResourceNotFound|not found/i); +}); diff --git a/src/handlers/payment/payment.read.test.tsx b/src/handlers/payment/payment.read.test.tsx index eb54b6431..9962fe6d6 100644 --- a/src/handlers/payment/payment.read.test.tsx +++ b/src/handlers/payment/payment.read.test.tsx @@ -52,8 +52,8 @@ test("registers reads and mutations as CLI-only commands", () => { ).toEqual({ manager: ["create", "get", "list", "update", "delete"], connector: ["create", "get", "list", "update", "delete"], - session: ["get", "list"], - instrument: ["get", "list", "balance"], + session: ["create", "get", "list", "delete"], + instrument: ["create", "get", "list", "delete", "balance"], }); for (const resource of payment.commands) { for (const command of resource.commands) expect(isTuiCommandSupported(command)).toBe(false); diff --git a/src/handlers/payment/session/create/index.tsx b/src/handlers/payment/session/create/index.tsx new file mode 100644 index 000000000..d6eb875c0 --- /dev/null +++ b/src/handlers/payment/session/create/index.tsx @@ -0,0 +1,88 @@ +import type { Currency } from "@aws-sdk/client-bedrock-agentcore"; +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 { CreatePaymentSessionInput } from "../../types"; + +// The service accepts only USD today. Pinning the list against the SDK type +// turns a new Currency value into a compile-time reminder to widen this. +const CURRENCIES = ["USD"] as const satisfies readonly Currency[]; +const DEFAULT_CURRENCY: Currency = "USD"; + +export const createCreatePaymentSessionHandler = (core: Core) => + createHandler({ + name: "create", + description: "create a payment session (a time-boxed payment context with a spend limit)", + flags: [ + flag("manager-id", "the payment manager ID that owns the session", z.string().optional()), + flag( + "user-id", + "the user the session is scoped to (required for IAM-authenticated calls)", + z.string().optional(), + ), + flag("agent-name", "agent name recorded for observability", z.string().optional()), + flag( + "expiry-minutes", + "how long the session stays active, in minutes (15 to 480)", + z.number().int().min(15).max(480).optional(), + ), + flag( + "max-spend", + "maximum amount the session may spend, as a decimal string (e.g. 25.00)", + z.string().optional(), + ), + flag( + "currency", + `currency of --max-spend (${CURRENCIES.join(" | ")}; default ${DEFAULT_CURRENCY})`, + z.enum(CURRENCIES).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["manager-id"]) { + throw new InputValidationError("required option '--manager-id ' not specified"); + } + if (!flags["user-id"]) { + throw new InputValidationError("required option '--user-id ' not specified"); + } + if (flags["expiry-minutes"] === undefined) { + throw new InputValidationError( + "required option '--expiry-minutes ' not specified", + ); + } + const maxSpend = flags["max-spend"]; + if (maxSpend !== undefined && maxSpend.trim() === "") { + throw new InputValidationError("--max-spend must not be empty or whitespace"); + } + if (flags.currency !== undefined && maxSpend === undefined) { + throw new InputValidationError("--currency requires --max-spend"); + } + + const request: CreatePaymentSessionInput = { + managerId: flags["manager-id"], + userId: flags["user-id"], + expiryTimeInMinutes: flags["expiry-minutes"], + ...(flags["agent-name"] ? { agentName: flags["agent-name"] } : {}), + ...(maxSpend !== undefined + ? { + limits: { + maxSpendAmount: { + value: maxSpend, + currency: flags.currency ?? DEFAULT_CURRENCY, + }, + }, + } + : {}), + ...(flags["client-token"] ? { clientToken: flags["client-token"] } : {}), + }; + + ctx + .require(JsonRendererKey) + .renderJson(await core.payment.createPaymentSession(request, coreOptsFromCtx(ctx))); + }, + }); diff --git a/src/handlers/payment/session/delete/index.tsx b/src/handlers/payment/session/delete/index.tsx new file mode 100644 index 000000000..21a2eac0e --- /dev/null +++ b/src/handlers/payment/session/delete/index.tsx @@ -0,0 +1,45 @@ +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 { DeletePaymentSessionInput } from "../../types"; + +// DeletePaymentSessionInput carries no agentName, so unlike the other session +// leaves this one offers no --agent-name. +export const createDeletePaymentSessionHandler = (core: Core) => + createHandler({ + name: "delete", + description: "delete a payment session", + flags: [ + flag("manager-id", "the payment manager ID that owns the session", z.string().optional()), + flag( + "user-id", + "the user the session is scoped to (required for IAM-authenticated calls)", + z.string().optional(), + ), + flag("session-id", "the payment session id", z.string().optional()), + ], + handle: async (ctx, flags) => { + if (!flags["manager-id"]) { + throw new InputValidationError("required option '--manager-id ' not specified"); + } + if (!flags["user-id"]) { + throw new InputValidationError("required option '--user-id ' not specified"); + } + if (!flags["session-id"]) { + throw new InputValidationError("required option '--session-id ' not specified"); + } + + const request: DeletePaymentSessionInput = { + managerId: flags["manager-id"], + userId: flags["user-id"], + paymentSessionId: flags["session-id"], + }; + + ctx + .require(JsonRendererKey) + .renderJson(await core.payment.deletePaymentSession(request, coreOptsFromCtx(ctx))); + }, + }); diff --git a/src/handlers/payment/session/index.tsx b/src/handlers/payment/session/index.tsx index dff3e769a..881f0de66 100644 --- a/src/handlers/payment/session/index.tsx +++ b/src/handlers/payment/session/index.tsx @@ -2,12 +2,16 @@ import type { AppIO } from "../../../io"; import { Router } from "../../../router"; import { renderTui } from "../../../tui"; import type { Core } from "../../types"; +import { createCreatePaymentSessionHandler } from "./create"; +import { createDeletePaymentSessionHandler } from "./delete"; import { createGetPaymentSessionHandler } from "./get"; import { createListPaymentSessionsHandler } from "./list"; export function createPaymentSessionHandler(core: Core, io: AppIO): Router { return new Router("session", "manage payment sessions (budget-limited payment contexts)") .default(renderTui(core, io)) + .handler(createCreatePaymentSessionHandler(core)) .handler(createGetPaymentSessionHandler(core)) - .handler(createListPaymentSessionsHandler(core)); + .handler(createListPaymentSessionsHandler(core)) + .handler(createDeletePaymentSessionHandler(core)); } diff --git a/src/handlers/payment/session/session.test.tsx b/src/handlers/payment/session/session.test.tsx new file mode 100644 index 000000000..87798aa6d --- /dev/null +++ b/src/handlers/payment/session/session.test.tsx @@ -0,0 +1,119 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import { join } from "node:path"; +import { CoreClient } from "../../../core"; +import { createRootHandler } from "../../index"; +import { + createSilentLogger, + fixtureFactories, + matchGolden, + parse, + TestGlobalConfigAccessor, + testIO, +} from "../../../testing"; +import sessionCreateFixture from "../__fixtures__/session/CreatePaymentSessionCommand.902bade07933ebb1.json"; + +const PAYMENT_FIXTURES = join(import.meta.dir, "..", "__fixtures__"); +const FIXTURES = join(PAYMENT_FIXTURES, "session"); +const REGION = "us-west-2"; +const MANAGER_ID = "mypaymentmanageraidandal-gx3nxzaira"; +const USER_ID = "agentcore-cli-e2e"; +const scoped = ["--manager-id", MANAGER_ID, "--user-id", USER_ID]; +const createArgs = ["create", ...scoped, "--expiry-minutes", "15"]; + +function createFixtureCore(fixtures = FIXTURES): CoreClient { + return new CoreClient({ + ...fixtureFactories(PAYMENT_FIXTURES), + createDataClient: fixtureFactories(fixtures).createDataClient, + logger: createSilentLogger(), + }); +} + +async function run(args: string[], core = createFixtureCore()): Promise { + const io = testIO(); + const root = createRootHandler(core, { + io: io.io, + logger: createSilentLogger(), + globalConfigAccessor: new TestGlobalConfigAccessor(), + }); + await root.route(["node", "agentcore", "payment", "session", ...args, "--region", REGION]); + return io.stdout(); +} + +describe("payment session create", () => { + test.each(["14", "481", "15.5"])( + "rejects expiry outside whole minutes 15..480: %s", + async (value) => { + await expect(run(["create", ...scoped, "--expiry-minutes", value])).rejects.toThrow( + "Invalid value for option '--expiry-minutes'", + ); + }, + ); + + test.each([ + { args: ["--currency", "USD"], error: "--currency requires --max-spend" }, + { args: ["--max-spend", ""], error: "--max-spend must not be empty or whitespace" }, + { + args: ["--max-spend", " \t\n ", "--currency", "USD"], + error: "--max-spend must not be empty or whitespace", + }, + { + args: ["--max-spend", "1.00", "--currency", "EUR"], + error: "Invalid value for option '--currency'", + }, + ])("rejects invalid spend flags $args before Core", async ({ args, error }) => { + const core = createFixtureCore(); + const create = spyOn(core.payment, "createPaymentSession"); + await expect(run([...createArgs, ...args], core)).rejects.toThrow(error); + expect(create).not.toHaveBeenCalled(); + }); + + test.each([undefined, "0", "10.00"])( + "preserves spend %j without numeric coercion", + async (value) => { + const data = fixtureFactories(FIXTURES).createDataClient({ region: REGION }); + const send = spyOn(data, "send").mockResolvedValue( + parse(JSON.stringify(sessionCreateFixture)), + ); + const core = new CoreClient({ + ...fixtureFactories(PAYMENT_FIXTURES), + createDataClient: () => data, + logger: createSilentLogger(), + }); + + await run([...createArgs, ...(value === undefined ? [] : ["--max-spend", value])], core); + + expect(send).toHaveBeenCalledTimes(1); + const request = send.mock.calls[0]![0].input; + if (value === undefined) { + expect(request).not.toHaveProperty("limits"); + } else { + expect(request).toHaveProperty("limits", { + maxSpendAmount: { value, currency: "USD" }, + }); + } + }, + ); +}); + +test("payment session lifecycle replays create, read-back, and delete through root/Core", async () => { + const created = await run([...createArgs, "--max-spend", "1.00", "--currency", "USD"]); + matchGolden(FIXTURES, "session-create.golden.json", created); + const { paymentSession } = JSON.parse(created); + expect(paymentSession.expiryTimeInMinutes).toBe(15); + expect(paymentSession.limits.maxSpendAmount.currency).toBe("USD"); + expect(Number(paymentSession.limits.maxSpendAmount.value)).toBe(1); + + const sessionArgs = [...scoped, "--session-id", paymentSession.paymentSessionId]; + const detail = await run(["get", ...sessionArgs]); + matchGolden(FIXTURES, "session-get.golden.json", detail); + expect(JSON.parse(detail).paymentSession.paymentSessionId).toBe(paymentSession.paymentSessionId); + + const deleted = await run(["delete", ...sessionArgs]); + matchGolden(FIXTURES, "session-delete.golden.json", deleted); + expect(JSON.parse(deleted).status).toBe("DELETED"); + + // The same Get request has a separate post-delete fixture. + await expect( + run(["get", ...sessionArgs], createFixtureCore(join(FIXTURES, "after-delete"))), + ).rejects.toThrow(/ResourceNotFound|not found/i); +}); diff --git a/src/handlers/payment/types.tsx b/src/handlers/payment/types.tsx index fe0cc2ce7..42e669d38 100644 --- a/src/handlers/payment/types.tsx +++ b/src/handlers/payment/types.tsx @@ -17,6 +17,14 @@ import type { UpdatePaymentManagerResponse, } from "@aws-sdk/client-bedrock-agentcore-control"; import type { + CreatePaymentInstrumentRequest, + CreatePaymentInstrumentResponse, + CreatePaymentSessionRequest, + CreatePaymentSessionResponse, + DeletePaymentInstrumentRequest, + DeletePaymentInstrumentResponse, + DeletePaymentSessionRequest, + DeletePaymentSessionResponse, GetPaymentInstrumentRequest, GetPaymentInstrumentResponse, GetPaymentInstrumentBalanceRequest, @@ -66,12 +74,17 @@ export type UpdatePaymentConnectorInput = { }; type WithPaymentManagerId = Omit & { managerId: string }; + +export type CreatePaymentSessionInput = WithPaymentManagerId; export type GetPaymentSessionInput = WithPaymentManagerId; export type ListPaymentSessionsInput = WithPaymentManagerId; +export type DeletePaymentSessionInput = WithPaymentManagerId; +export type CreatePaymentInstrumentInput = WithPaymentManagerId; export type GetPaymentInstrumentInput = WithPaymentManagerId; export type GetPaymentInstrumentBalanceInput = WithPaymentManagerId; export type ListPaymentInstrumentsInput = WithPaymentManagerId; +export type DeletePaymentInstrumentInput = WithPaymentManagerId; export interface CorePaymentClient { createPaymentManager( @@ -116,6 +129,12 @@ export interface CorePaymentClient { request: DeletePaymentConnectorRequest, options: CoreOptions, ): Promise; + + // Core resolves the selected manager ID to the ARN required by the data plane. + createPaymentSession( + request: CreatePaymentSessionInput, + options: CoreOptions, + ): Promise; getPaymentSession( request: GetPaymentSessionInput, options: CoreOptions, @@ -124,6 +143,15 @@ export interface CorePaymentClient { request: ListPaymentSessionsInput, options: CoreOptions, ): Promise; + deletePaymentSession( + request: DeletePaymentSessionInput, + options: CoreOptions, + ): Promise; + + createPaymentInstrument( + request: CreatePaymentInstrumentInput, + options: CoreOptions, + ): Promise; getPaymentInstrument( request: GetPaymentInstrumentInput, options: CoreOptions, @@ -136,4 +164,8 @@ export interface CorePaymentClient { request: ListPaymentInstrumentsInput, options: CoreOptions, ): Promise; + deletePaymentInstrument( + request: DeletePaymentInstrumentInput, + options: CoreOptions, + ): Promise; } diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index 3d6429523..ffb9dcb92 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -153,6 +153,10 @@ import type { GetPaymentSessionResponse, ListPaymentInstrumentsResponse, ListPaymentSessionsResponse, + CreatePaymentSessionResponse, + DeletePaymentSessionResponse, + CreatePaymentInstrumentResponse, + DeletePaymentInstrumentResponse, } from "@aws-sdk/client-bedrock-agentcore"; import type { CorePaymentClient } from "../handlers/payment/types"; import type { CoreMemoryClient } from "../handlers/memory/types"; @@ -1559,6 +1563,18 @@ export class TestPaymentClient implements CorePaymentClient { async deletePaymentConnector(): Promise { throw new Error("Unexpected payment call"); } + async createPaymentSession(): Promise { + throw new Error("Unexpected payment call"); + } + async deletePaymentSession(): Promise { + throw new Error("Unexpected payment call"); + } + async createPaymentInstrument(): Promise { + throw new Error("Unexpected payment call"); + } + async deletePaymentInstrument(): Promise { + throw new Error("Unexpected payment call"); + } async getPaymentManager(): Promise { throw new Error("Unexpected payment call"); }