Skip to content
46 changes: 2 additions & 44 deletions src/assets/cdk/bin/cdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,61 +146,19 @@ async function main() {

// Extract credentials from deployed state for this target
const targetState = (deployedState as Record<string, unknown>)?.targets as
| Record<string, Record<string, unknown>>
| undefined;
Record<string, Record<string, unknown>> | undefined;
const targetResources = target
? (targetState?.[target.name]?.resources as Record<string, unknown> | undefined)
: undefined;
const credentials = targetResources?.credentials as
| Record<string, { credentialProviderArn: string; clientSecretArn?: string }>
| undefined;

// Payment credential provider ARNs live in the same credentials map as identity credentials
const paymentCredentials = credentials;

const paymentSpec = specAny.payments?.length
? specAny.payments.map(
(p: {
name: string;
description?: string;
authorizerType: 'AWS_IAM' | 'CUSTOM_JWT';
authorizerConfiguration?: unknown;
autoPayment?: boolean;
paymentToolAllowlist?: string[];
networkPreferences?: string[];
connectors: { name: string; provider?: string; credentialName: string }[];
}) => ({
name: p.name,
description: p.description,
authorizerType: p.authorizerType,
authorizerConfiguration: p.authorizerConfiguration,
autoPayment: p.autoPayment,
paymentToolAllowlist: p.paymentToolAllowlist,
networkPreferences: p.networkPreferences,
connectors: p.connectors.map(c => {
const credentialProviderArn = paymentCredentials?.[c.credentialName]?.credentialProviderArn;
if (!credentialProviderArn) {
// Fail fast with an actionable message rather than passing an empty
// ARN that fails opaquely server-side at CreatePaymentConnector.
throw new Error(
`Payment connector "${c.name}" on manager "${p.name}" references credential ` +
`"${c.credentialName}", but no deployed credential provider was found for it. ` +
`Run \`agentcore deploy\` so the credential provider is created first.`
);
}
return { name: c.name, provider: c.provider, credentialProviderArn };
}),
})
)
: undefined;
Record<string, { credentialProviderArn: string; clientSecretArn?: string }> | undefined;

new AgentCoreStack(app, stackName, {
spec,
mcpSpec,
credentials,
connectorParametersByFile,
harnesses: harnessConfigs.length > 0 ? harnessConfigs : undefined,
paymentSpec,
env,
description: target
? `AgentCore stack for ${spec.name} deployed to ${target.name} (${target.region})`
Expand Down
169 changes: 7 additions & 162 deletions src/assets/cdk/lib/cdk-stack.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
import {
AgentCoreApplication,
AgentCoreMcp,
AgentCorePaymentManager,
AgentCorePaymentConnector,
AgentCorePayments,
type AgentCoreProjectSpec,
type AgentCoreMcpSpec,
type CustomJWTAuthorizerConfig,
type HarnessDeploymentConfig,
} from '@aws/agentcore-cdk';
import { CfnOutput, Stack, type StackProps } from 'aws-cdk-lib';
import * as iam from 'aws-cdk-lib/aws-iam';
import { Construct } from 'constructs';

/**
Expand All @@ -19,23 +16,6 @@ import { Construct } from 'constructs';
*/
export type HarnessConfig = HarnessDeploymentConfig;

export interface PaymentConnectorSpec {
name: string;
provider: 'CoinbaseCDP' | 'StripePrivy';
credentialProviderArn: string;
}

export interface PaymentSpec {
name: string;
description?: string;
authorizerType: 'AWS_IAM' | 'CUSTOM_JWT';
authorizerConfiguration?: { customJWTAuthorizer: CustomJWTAuthorizerConfig };
autoPayment?: boolean;
paymentToolAllowlist?: string[];
networkPreferences?: string[];
connectors: PaymentConnectorSpec[];
}

export interface AgentCoreStackProps extends StackProps {
/**
* The AgentCore project specification containing agents, memories, and credentials.
Expand All @@ -58,30 +38,6 @@ export interface AgentCoreStackProps extends StackProps {
* connectorConfigFile path. Forwarded to AgentCoreApplication.
*/
connectorParametersByFile?: Record<string, Record<string, unknown>>;
/**
* Payment specifications with resolved credential provider ARNs.
*/
paymentSpec?: PaymentSpec[];
}

function toCdkId(name: string): string {
return name.replace(/_/g, '');
}

/**
* Decide whether a deployed runtime should receive payment env vars + IAM grants.
* Payments today only ships a runtime shim for Python HTTP runtimes; injecting
* AGENTCORE_PAYMENT_* env vars into TypeScript / MCP / A2A / AGUI runtimes
* would surface env vars they cannot consume and would dilute least-privilege
* IAM grants for runtimes that never call ProcessPayment.
*/
function isPaymentEligibleAgent(agent: { entrypoint?: string; protocol?: string }): boolean {
if (agent.protocol && agent.protocol !== 'HTTP') {
return false;
}
const entrypoint = typeof agent.entrypoint === 'string' ? agent.entrypoint : '';
const entrypointFile = entrypoint.split(':')[0] ?? '';
return entrypointFile.endsWith('.py');
}

/**
Expand All @@ -97,7 +53,7 @@ export class AgentCoreStack extends Stack {
constructor(scope: Construct, id: string, props: AgentCoreStackProps) {
super(scope, id, props);

const { spec, mcpSpec, credentials, harnesses, connectorParametersByFile, paymentSpec } = props;
const { spec, mcpSpec, credentials, harnesses, connectorParametersByFile } = props;

// Create AgentCoreApplication with all agents and harness roles
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand All @@ -112,6 +68,11 @@ export class AgentCoreStack extends Stack {
appProps.credentials = credentials;
}
this.application = new AgentCoreApplication(this, 'Application', appProps as any);
new AgentCorePayments(this, 'Payments', {
spec,
credentials,
agentCoreApplication: this.application,
});

// Create AgentCoreMcp if there are gateways configured
if (mcpSpec?.agentCoreGateways && mcpSpec.agentCoreGateways.length > 0) {
Expand All @@ -124,122 +85,6 @@ export class AgentCoreStack extends Stack {
});
}

// Create payment infrastructure via CFN constructs
if (paymentSpec && paymentSpec.length > 0) {
for (const payment of paymentSpec) {
const mgrId = toCdkId(payment.name);
const manager = new AgentCorePaymentManager(this, `Payment${mgrId}`, {
projectName: spec.name,
name: payment.name,
authorizerType: payment.authorizerType,
description: payment.description,
authorizerConfiguration: payment.authorizerConfiguration,
tags: spec.tags,
});

const prefix = `AGENTCORE_PAYMENT_${payment.name.toUpperCase().replace(/-/g, '_')}`;

// Wire env vars from construct output tokens into eligible agent environments only.
// See isPaymentEligibleAgent — non-Python or non-HTTP runtimes have no shim that
// can consume these env vars, and giving them sts:AssumeRole on the
// ProcessPaymentRole would broaden the privilege surface unnecessarily.
for (const env of this.application.environments.values()) {
if (!isPaymentEligibleAgent(env.agent)) {
continue;
}
env.runtime.addEnvironmentVariable(`${prefix}_MANAGER_ARN`, manager.paymentManagerArn);
env.runtime.addEnvironmentVariable(`${prefix}_PROCESS_PAYMENT_ROLE_ARN`, manager.processPaymentRoleArn);

// Grant runtime execution role permission to assume the ProcessPaymentRole.
// The ProcessPaymentRole's trust policy allows AccountRootPrincipal, but the
// caller still needs sts:AssumeRole on its own role to perform the assumption.
env.runtime.role.addToPrincipalPolicy(
new iam.PolicyStatement({
actions: ['sts:AssumeRole'],
resources: [manager.processPaymentRoleArn],
})
);

// Grant payment data-plane actions directly to the runtime role.
//
// NOTE: This deviates from the canonical role model in the AgentCore Payments
// beta guide, which assigns Get/List/Create instrument+session actions to a
// separate ManagementRole and limits the agent's role to ProcessPayment only.
// The current SDK plugin (AgentCorePaymentsPlugin.generate_payment_header)
// calls GetPaymentInstrument internally during the 402 auto-pay path, so the
// runtime role needs read access. CreatePaymentSession is included so
// `agentcore invoke --auto-session` works without a separate ManagementRole
// call. Tighten this if the SDK is updated to accept pre-fetched instrument
// details and split create-session into a backend-only flow.
env.runtime.role.addToPrincipalPolicy(
new iam.PolicyStatement({
actions: [
'bedrock-agentcore:GetPaymentInstrument',
'bedrock-agentcore:ListPaymentInstruments',
'bedrock-agentcore:GetPaymentInstrumentBalance',
'bedrock-agentcore:GetPaymentSession',
'bedrock-agentcore:ListPaymentSessions',
'bedrock-agentcore:CreatePaymentSession',
'bedrock-agentcore:ProcessPayment',
],
resources: [manager.paymentManagerArn, `${manager.paymentManagerArn}/*`],
})
);

if (payment.autoPayment !== undefined) {
env.runtime.addEnvironmentVariable(`${prefix}_AUTO_PAYMENT`, String(payment.autoPayment));
}
if (payment.paymentToolAllowlist) {
env.runtime.addEnvironmentVariable(`${prefix}_TOOL_ALLOWLIST`, payment.paymentToolAllowlist.join(','));
}
if (payment.networkPreferences) {
env.runtime.addEnvironmentVariable(`${prefix}_NETWORK_PREFERENCES`, payment.networkPreferences.join(','));
}
if (payment.authorizerType === 'CUSTOM_JWT') {
env.runtime.addEnvironmentVariable(`${prefix}_AUTH_MODE`, 'bearer');
}
}

// Create connectors for this manager
for (const connector of payment.connectors) {
const connId = toCdkId(connector.name);
const conn = new AgentCorePaymentConnector(this, `Payment${mgrId}${connId}`, {
projectName: spec.name,
paymentManager: manager,
connectorName: connector.name,
connectorType: connector.provider,
credentialProviderArn: connector.credentialProviderArn,
});

// Wire first connector's ID as env var (eligible agents only)
if (connector === payment.connectors[0]) {
for (const env of this.application.environments.values()) {
if (!isPaymentEligibleAgent(env.agent)) continue;
env.runtime.addEnvironmentVariable(`${prefix}_CONNECTOR_ID`, conn.paymentConnectorId);
}
}

new CfnOutput(this, `Payment${mgrId}${connId}ConnectorId`, {
value: conn.paymentConnectorId,
});
}

// CFN Outputs for post-deploy state parsing
new CfnOutput(this, `Payment${mgrId}ManagerArn`, {
value: manager.paymentManagerArn,
});
new CfnOutput(this, `Payment${mgrId}ManagerId`, {
value: manager.paymentManagerId,
});
new CfnOutput(this, `Payment${mgrId}ProcessPaymentRoleArn`, {
value: manager.processPaymentRoleArn,
});
new CfnOutput(this, `Payment${mgrId}ResourceRetrievalRoleArn`, {
value: manager.resourceRetrievalRoleArn,
});
}
}

// Stack-level output
new CfnOutput(this, 'StackNameOutput', {
description: 'Name of the CloudFormation Stack',
Expand Down
2 changes: 1 addition & 1 deletion src/assets/cdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"typescript": "~5.9.3"
},
"dependencies": {
"@aws/agentcore-cdk": "0.1.0-alpha.45",
"@aws/agentcore-cdk": "0.1.0-alpha.51",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I presume this is the crucial bit. Do we know how these changes will impact existing projects? There's a lot of changes here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The changes should only be additive. We haven't made any breaking changes to the CDK. The main version of the CLI also already uses up to 0.1.0-alpha.50.

"aws-cdk-lib": "~2.266.0",
"constructs": "~10.7.0"
}
Expand Down
93 changes: 91 additions & 2 deletions src/assets/cdk/test/cdk.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,29 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import * as cdk from 'aws-cdk-lib';
import { Template } from 'aws-cdk-lib/assertions';
import { AgentCoreStack } from '../lib/cdk-stack';
import { Match, Template } from 'aws-cdk-lib/assertions';

const originalCwd = process.cwd();
const originalInitCwd = process.env.INIT_CWD;
const testRoot = mkdtempSync(join(tmpdir(), 'agentcore-cdk-test-'));
const testConfigDir = join(testRoot, 'agentcore');
let AgentCoreStack: typeof import('../lib/cdk-stack').AgentCoreStack;

beforeAll(async () => {
process.chdir(testRoot);
process.env.INIT_CWD = testRoot;
mkdirSync(testConfigDir, { recursive: true });
writeFileSync(join(testConfigDir, 'agentcore.json'), '{}');
({ AgentCoreStack } = await import('../lib/cdk-stack'));
});

afterAll(() => {
process.chdir(originalCwd);
if (originalInitCwd === undefined) delete process.env.INIT_CWD;
else process.env.INIT_CWD = originalInitCwd;
rmSync(testRoot, { recursive: true, force: true });
});

test('AgentCoreStack synthesizes with empty spec', () => {
const app = new cdk.App();
Expand Down Expand Up @@ -29,3 +52,69 @@ test('AgentCoreStack synthesizes with empty spec', () => {
Description: 'Name of the CloudFormation Stack',
});
});

test('AgentCoreStack synthesizes manual and Quick Create payment connectors', () => {
const app = new cdk.App();
const stack = new AgentCoreStack(app, 'TestStack', {
spec: {
name: 'testproject',
version: 1,
managedBy: 'CDK' as const,
runtimes: [],
memories: [],
credentials: [
{
authorizerType: 'PaymentCredentialProvider',
name: 'coinbase',
provider: 'CoinbaseCDP',
},
],
evaluators: [],
onlineEvalConfigs: [],
configBundles: [],
policyEngines: [],
payments: [
{
name: 'Payments',
authorizerType: 'AWS_IAM',
connectors: [
{
name: 'Manual',
provider: 'CoinbaseCDP',
credentialName: 'coinbase',
},
{
name: 'Quick',
provider: 'CoinbaseCDP',
provisionMode: 'QUICK_CREATE',
},
],
},
],
agentCoreGateways: [],
mcpRuntimeTools: [],
unassignedTargets: [],
datasets: [],
knowledgeBases: [],
},
credentials: {
coinbase: {
credentialProviderArn:
'arn:aws:bedrock-agentcore:us-east-1:123456789012:token-vault/default/paymentcredentialprovider/coinbase',
},
},
});
const template = Template.fromStack(stack);

template.resourceCountIs('AWS::BedrockAgentCore::PaymentConnector', 2);
template.hasResourceProperties('AWS::BedrockAgentCore::PaymentConnector', {
ConnectorName: 'Manual',
ProvisionMode: Match.absent(),
});
template.hasResourceProperties('AWS::BedrockAgentCore::PaymentConnector', {
ConnectorName: 'Quick',
ConnectorType: 'CoinbaseCDP',
ProvisionMode: 'QUICK_CREATE',
CredentialProviderConfigurations: [],
});
});
Loading
Loading