Skip to content
Closed
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
47 changes: 30 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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-<region>-<name>` (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 <manager id>
agentcore payment connector list --manager-id <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 <manager id> --name Coinbase --quick-create
agentcore payment connector get --manager-id <manager id> --connector-id <connector id>

# Inspect provider metadata stored in AgentCore Identity.
agentcore identity payment-credential-provider list --json
agentcore identity payment-credential-provider get --name <provider 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 <id> --api-key-secret file://api-key-secret.txt --wallet-secret file://wallet-secret.txt
agentcore payment connector create --manager-id <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 <manager id> --user-id alice
Expand Down
23 changes: 1 addition & 22 deletions src/core/harness.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<T>(
operation: () => Promise<T>,
attempts = 8,
delayMs = 2000,
): Promise<T> {
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));
}
}
}
4 changes: 3 additions & 1 deletion src/core/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 7 additions & 1 deletion src/core/payment.read.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,14 @@ function setup() {
const data = mock(
(_config: ClientConfig) => ({ send: dataSend }) as unknown as ReturnType<AwsClients["data"]>,
);
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,
Expand Down
207 changes: 207 additions & 0 deletions src/core/payment.test.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>;

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 });
});
Loading
Loading