Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 28 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:

Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions src/core/payment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -85,6 +88,15 @@ export class PaymentClient implements CorePaymentClient {
);
}

async rotatePaymentConnectorCredentials(
request: RotatePaymentConnectorCredentialsRequest,
options: CoreOptions,
): Promise<RotatePaymentConnectorCredentialsResponse> {
return this.clients
.control(toClientConfig(options))
.send(new RotatePaymentConnectorCredentialsCommand(request));
}

// ─── payment sessions (data plane) ──────────────────────────────────────────

async getPaymentSession(
Expand Down
4 changes: 3 additions & 1 deletion src/handlers/payment/connector/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
39 changes: 39 additions & 0 deletions src/handlers/payment/connector/rotate-credentials/index.tsx
Original file line number Diff line number Diff line change
@@ -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);
},
});
84 changes: 69 additions & 15 deletions src/handlers/payment/payment.read.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof fixtureFactories>> = {}) {
const core = new CoreClient({
Expand All @@ -42,7 +50,7 @@ function setup(resource = "manager", overrides: Partial<ReturnType<typeof fixtur
};
}

test("registers the read-only command tree without TUI or mutation leaves", () => {
test("registers the payment command tree without TUI leaves", () => {
const payment = compile(setup().root, ValueContext.EmptyContext()).commands.find(
(c) => c.name() === "payment",
)!;
Expand All @@ -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"],
});
Expand All @@ -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", []],
Expand Down Expand Up @@ -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);
});
Expand All @@ -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("");
});
6 changes: 6 additions & 0 deletions src/handlers/payment/types.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import type {
GetPaymentManagerResponse,
ListPaymentConnectorsResponse,
ListPaymentManagersResponse,
RotatePaymentConnectorCredentialsRequest,
RotatePaymentConnectorCredentialsResponse,
} from "@aws-sdk/client-bedrock-agentcore-control";
import type {
GetPaymentInstrumentRequest,
Expand Down Expand Up @@ -45,6 +47,10 @@ export interface CorePaymentClient {
maxResults: number | undefined,
options: CoreOptions,
): Promise<ListPaymentConnectorsResponse>;
rotatePaymentConnectorCredentials(
request: RotatePaymentConnectorCredentialsRequest,
options: CoreOptions,
): Promise<RotatePaymentConnectorCredentialsResponse>;

// Core resolves the selected manager ID to the ARN required by the data plane.
getPaymentSession(
Expand Down
4 changes: 4 additions & 0 deletions src/testing/TestCoreClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import type {
GetPaymentManagerResponse,
ListPaymentConnectorsResponse,
ListPaymentManagersResponse,
RotatePaymentConnectorCredentialsResponse,
ListAgentRuntimeEndpointsResponse,
ListAgentRuntimesResponse,
ListAgentRuntimeVersionsResponse,
Expand Down Expand Up @@ -1551,6 +1552,9 @@ export class TestPaymentClient implements CorePaymentClient {
async listPaymentConnectors(): Promise<ListPaymentConnectorsResponse> {
throw new Error("Unexpected payment call");
}
async rotatePaymentConnectorCredentials(): Promise<RotatePaymentConnectorCredentialsResponse> {
throw new Error("Unexpected payment call");
}
async getPaymentSession(): Promise<GetPaymentSessionResponse> {
throw new Error("Unexpected payment call");
}
Expand Down
Loading