From 9eba3c058701f82336e36d34ba16bac8ed43a3a2 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 18 Sep 2026 20:30:17 +0000 Subject: [PATCH 1/3] feat(payment): add connector credential rotation to Core --- src/core/payment.tsx | 12 ++++++++++++ src/handlers/payment/types.tsx | 6 ++++++ src/testing/TestCoreClient.tsx | 4 ++++ 3 files changed, 22 insertions(+) diff --git a/src/core/payment.tsx b/src/core/payment.tsx index 58a4bc72d..045097f10 100644 --- a/src/core/payment.tsx +++ b/src/core/payment.tsx @@ -3,10 +3,13 @@ import { GetPaymentManagerCommand, ListPaymentConnectorsCommand, ListPaymentManagersCommand, + RotatePaymentConnectorCredentialsCommand, type GetPaymentConnectorResponse, type GetPaymentManagerResponse, type ListPaymentConnectorsResponse, type ListPaymentManagersResponse, + type RotatePaymentConnectorCredentialsRequest, + type RotatePaymentConnectorCredentialsResponse, } from "@aws-sdk/client-bedrock-agentcore-control"; import { GetPaymentInstrumentBalanceCommand, @@ -85,6 +88,15 @@ export class PaymentClient implements CorePaymentClient { ); } + async rotatePaymentConnectorCredentials( + request: RotatePaymentConnectorCredentialsRequest, + options: CoreOptions, + ): Promise { + return this.clients + .control(toClientConfig(options)) + .send(new RotatePaymentConnectorCredentialsCommand(request)); + } + // ─── payment sessions (data plane) ────────────────────────────────────────── async getPaymentSession( diff --git a/src/handlers/payment/types.tsx b/src/handlers/payment/types.tsx index 7456a5389..f15275d28 100644 --- a/src/handlers/payment/types.tsx +++ b/src/handlers/payment/types.tsx @@ -3,6 +3,8 @@ import type { GetPaymentManagerResponse, ListPaymentConnectorsResponse, ListPaymentManagersResponse, + RotatePaymentConnectorCredentialsRequest, + RotatePaymentConnectorCredentialsResponse, } from "@aws-sdk/client-bedrock-agentcore-control"; import type { GetPaymentInstrumentRequest, @@ -45,6 +47,10 @@ export interface CorePaymentClient { maxResults: number | undefined, options: CoreOptions, ): Promise; + rotatePaymentConnectorCredentials( + request: RotatePaymentConnectorCredentialsRequest, + options: CoreOptions, + ): Promise; // Core resolves the selected manager ID to the ARN required by the data plane. getPaymentSession( diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index ad3f12438..a87af8073 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -33,6 +33,7 @@ import type { GetPaymentManagerResponse, ListPaymentConnectorsResponse, ListPaymentManagersResponse, + RotatePaymentConnectorCredentialsResponse, ListAgentRuntimeEndpointsResponse, ListAgentRuntimesResponse, ListAgentRuntimeVersionsResponse, @@ -1551,6 +1552,9 @@ export class TestPaymentClient implements CorePaymentClient { async listPaymentConnectors(): Promise { throw new Error("Unexpected payment call"); } + async rotatePaymentConnectorCredentials(): Promise { + throw new Error("Unexpected payment call"); + } async getPaymentSession(): Promise { throw new Error("Unexpected payment call"); } From 781d9aa7657b0d4a1731b535c5ce66576572bfed Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 18 Sep 2026 20:31:57 +0000 Subject: [PATCH 2/3] feat(payment): expose connector credential rotation command --- src/handlers/payment/connector/index.tsx | 4 +- .../connector/rotate-credentials/index.tsx | 39 +++++++++ src/handlers/payment/payment.read.test.tsx | 84 +++++++++++++++---- 3 files changed, 111 insertions(+), 16 deletions(-) create mode 100644 src/handlers/payment/connector/rotate-credentials/index.tsx diff --git a/src/handlers/payment/connector/index.tsx b/src/handlers/payment/connector/index.tsx index 75a13a8fc..2c50a2237 100644 --- a/src/handlers/payment/connector/index.tsx +++ b/src/handlers/payment/connector/index.tsx @@ -4,10 +4,12 @@ import { renderTui } from "../../../tui"; import type { Core } from "../../types"; import { createGetPaymentConnectorHandler } from "./get"; import { createListPaymentConnectorsHandler } from "./list"; +import { createRotatePaymentConnectorCredentialsHandler } from "./rotate-credentials"; export function createPaymentConnectorHandler(core: Core, io: AppIO): Router { return new Router("connector", "manage connectors under a payment manager") .default(renderTui(core, io)) .handler(createGetPaymentConnectorHandler(core)) - .handler(createListPaymentConnectorsHandler(core)); + .handler(createListPaymentConnectorsHandler(core)) + .handler(createRotatePaymentConnectorCredentialsHandler(core)); } diff --git a/src/handlers/payment/connector/rotate-credentials/index.tsx b/src/handlers/payment/connector/rotate-credentials/index.tsx new file mode 100644 index 000000000..176e6204c --- /dev/null +++ b/src/handlers/payment/connector/rotate-credentials/index.tsx @@ -0,0 +1,39 @@ +import { CoinbaseCdpSecret } from "@aws-sdk/client-bedrock-agentcore-control"; +import z from "zod"; +import { createHandler, flag } from "../../../../router"; +import { JsonRendererKey } from "../../../../tui"; +import type { Core } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; + +export const createRotatePaymentConnectorCredentialsHandler = (core: Core) => + createHandler({ + name: "rotate-credentials", + description: "rotate service-managed credentials for a Quick Create Coinbase connector", + flags: [ + flag("manager-id", "the parent payment manager ID", z.string().min(1)), + flag("connector-id", "the payment connector ID", z.string().min(1)), + flag( + "secrets", + "credential kinds to rotate: API_KEY, WALLET_SECRET, or both (not secret values)", + z + .array(z.enum(CoinbaseCdpSecret)) + .min(1) + .refine((secrets) => new Set(secrets).size === secrets.length, { + message: "credential selections must be unique", + }), + ), + flag("client-token", "idempotency token for this request", z.string().optional()), + ], + handle: async (ctx, flags) => { + const response = await core.payment.rotatePaymentConnectorCredentials( + { + paymentManagerId: flags["manager-id"], + paymentConnectorId: flags["connector-id"], + credentialsToRotate: { coinbaseCDP: { secrets: flags.secrets } }, + clientToken: flags["client-token"], + }, + coreOptsFromCtx(ctx), + ); + ctx.require(JsonRendererKey).renderJson(response); + }, + }); diff --git a/src/handlers/payment/payment.read.test.tsx b/src/handlers/payment/payment.read.test.tsx index 59a2017c9..ef4fc4b9c 100644 --- a/src/handlers/payment/payment.read.test.tsx +++ b/src/handlers/payment/payment.read.test.tsx @@ -19,6 +19,14 @@ const INSTRUMENT_CONNECTOR_ID = "mycdpconnectoraidandal-okve8guw4y"; const SESSION_ID = "payment-session-nq812U4e1BJIfw1"; const INSTRUMENT_ID = "payment-instrument-CG2Tl7U1HnCGfHW"; const scope = ["--manager-id", MANAGER_ID, "--user-id", "agentcore-cli-e2e"]; +const rotationArgs = [ + "connector", + "rotate-credentials", + "--manager-id", + MANAGER_ID, + "--connector-id", + CONNECTOR_ID, +]; function setup(resource = "manager", overrides: Partial> = {}) { const core = new CoreClient({ @@ -42,7 +50,7 @@ function setup(resource = "manager", overrides: Partial { +test("registers the payment command tree without TUI leaves", () => { const payment = compile(setup().root, ValueContext.EmptyContext()).commands.find( (c) => c.name() === "payment", )!; @@ -52,7 +60,7 @@ test("registers the read-only command tree without TUI or mutation leaves", () = ), ).toEqual({ manager: ["get", "list"], - connector: ["get", "list"], + connector: ["get", "list", "rotate-credentials"], session: ["get", "list"], instrument: ["get", "list", "balance"], }); @@ -61,6 +69,48 @@ test("registers the read-only command tree without TUI or mutation leaves", () = } }); +test.each([ + { secrets: ["API_KEY"], clientToken: undefined }, + { secrets: ["WALLET_SECRET"], clientToken: "rotate-wallet" }, + { secrets: ["API_KEY", "WALLET_SECRET"], clientToken: "rotate-both" }, +])("rotates the selected connector credentials: %j", async ({ secrets, clientToken }) => { + const response = { + paymentManagerId: MANAGER_ID, + paymentConnectorId: CONNECTOR_ID, + status: "READY", + lastUpdatedAt: new Date("2026-09-18T00:00:00.000Z"), + }; + const send = mock(async (_command: { input: unknown }) => response); + const createControlClient = mock(() => ({ send }) as never); + const { run, io } = setup("connector", { createControlClient }); + await run([ + ...rotationArgs, + "--secrets", + ...secrets, + ...(clientToken ? ["--client-token", clientToken] : []), + "--endpoint-url", + "https://control.example.test", + "--json", + ]); + expect(createControlClient).toHaveBeenCalledWith({ + region: "us-west-2", + endpoint: "https://control.example.test", + }); + expect(send).toHaveBeenCalledTimes(1); + const command = send.mock.calls[0]![0]; + expect(command.constructor.name).toBe("RotatePaymentConnectorCredentialsCommand"); + expect(command.input).toEqual({ + paymentManagerId: MANAGER_ID, + paymentConnectorId: CONNECTOR_ID, + credentialsToRotate: { coinbaseCDP: { secrets } }, + clientToken, + }); + expect(JSON.parse(io.stdout())).toEqual({ + ...response, + lastUpdatedAt: response.lastUpdatedAt.toISOString(), + }); +}); + test.each([ ["manager", "get", ["--id", "mypaymentmanager-o4ks3qfgtb"]], ["manager", "list", []], @@ -180,6 +230,9 @@ test.each([ ["instrument", "get", ...scope, "--instrument-id", INSTRUMENT_ID, "--manager-arn", "arn:old"], "unknown option", ], + [rotationArgs, "--secrets"], + [[...rotationArgs, "--secrets", "INVALID"], "Invalid value for option '--secrets'"], + [[...rotationArgs, "--secrets", "API_KEY", "API_KEY"], "must be unique"], ] as const)("rejects incomplete or obsolete selectors: %j", async (args, message) => { await expect(setup().run([...args])).rejects.toThrow(message); }); @@ -197,16 +250,17 @@ test.each([ await expect(run(["session", "list", ...scope])).rejects.toThrow(message); }); -test.each(["createControlClient", "createDataClient"] as const)( - "preserves a payment service failure from %s", - async (factory) => { - const error = new Error("Payment request denied"); - const send = mock(async () => { - throw error; - }); - const { run, io } = setup("session", { [factory]: () => ({ send }) as never }); - await expect(run(["session", "list", ...scope])).rejects.toBe(error); - expect(send).toHaveBeenCalledTimes(1); - expect(io.stdout()).toBe(""); - }, -); +test.each([ + ["createControlClient", ["session", "list", ...scope]], + ["createDataClient", ["session", "list", ...scope]], + ["createControlClient", [...rotationArgs, "--secrets", "API_KEY"]], +] as const)("preserves a payment service failure from %s for %j", async (factory, args) => { + const error = new Error("Payment request denied"); + const send = mock(async () => { + throw error; + }); + const { run, io } = setup("session", { [factory]: () => ({ send }) as never }); + await expect(run([...args])).rejects.toBe(error); + expect(send).toHaveBeenCalledTimes(1); + expect(io.stdout()).toBe(""); +}); From 5a012018c6596beb72b9874c15ceec153926081a Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 18 Sep 2026 20:33:35 +0000 Subject: [PATCH 3/3] docs(payment): describe managed credential rotation --- README.md | 33 ++++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 1f36702a1..2e1680375 100644 --- a/README.md +++ b/README.md @@ -110,13 +110,14 @@ 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 │ │ ├── get # get a payment manager by id │ │ └── list # list payment managers (server-side paginated) │ ├── connector # connectors under a payment manager │ │ ├── get # get a connector (shows the Quick Create authorization URL while pending) -│ │ └── list # list a manager's connectors +│ │ ├── list # list a manager's connectors +│ │ └── rotate-credentials # rotate service-managed Coinbase credentials │ ├── session # budget-limited payment contexts (data plane) │ │ ├── get │ │ └── list @@ -308,9 +309,10 @@ directly or working outside a project. ### Inspect 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, instruments, and payment credential -providers. It does not create IAM roles or change provider credentials. +no project involved. This command family provides inspection of existing managers, +connectors, sessions, instruments, and payment credential providers, plus on-demand +rotation of service-managed connector credentials. It does not create resources +or IAM roles. Choose a manager from `manager list` and use its `paymentManagerId` below: @@ -368,6 +370,27 @@ A `CUSTOM_JWT` manager accepts only bearer tokens on its data plane, which these commands do not send yet; the CLI reports that limitation before calling the data plane. +### Rotate Payment Connector Credentials + +Only READY Coinbase Quick Create connectors support credential rotation. +`--secrets` selects `API_KEY`, `WALLET_SECRET`, or both; it does not accept secret +values. Rotation uses the connector's existing consent and the caller's +control-plane IAM permissions, without an application user ID. + +```bash +agentcore payment connector rotate-credentials \ + --manager-id "$MANAGER_ID" --connector-id "$CONNECTOR_ID" \ + --secrets API_KEY WALLET_SECRET +``` + +The service performs the rotation and returns its result. An optional +`--client-token` identifies retries of the same request. + +Wallet-secret rotation can interrupt wallet operations while the new credential +is installed. Selecting both credentials rotates the API key first, then the +wallet secret; this is not atomic. An error does not guarantee that credentials +are unchanged. + ### Examples ```bash