chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:58:18 +08:00
commit 6d5d58c1a9
18293 changed files with 3502153 additions and 0 deletions
@@ -0,0 +1,23 @@
#!/usr/bin/env node
import * as cdk from "aws-cdk-lib";
import { FastMainStack } from "../lib/fast-main-stack";
import { ConfigManager } from "../lib/utils/config-manager";
// Load configuration using ConfigManager
const configManager = new ConfigManager("config.yaml");
// Initial props consist of configuration parameters
const props = configManager.getProps();
const app = new cdk.App();
// Deploy the new Amplify-based stack that solves the circular dependency
const amplifyStack = new FastMainStack(app, props.stack_name_base, {
config: props,
env: {
account: process.env.CDK_DEFAULT_ACCOUNT,
region: process.env.CDK_DEFAULT_REGION,
},
});
app.synth();
@@ -0,0 +1,96 @@
{
"app": "npx ts-node --prefer-ts-exts bin/fast-cdk.ts",
"watch": {
"include": ["**"],
"exclude": [
"README.md",
"cdk*.json",
"**/*.d.ts",
"**/*.js",
"tsconfig.json",
"package*.json",
"yarn.lock",
"node_modules",
"test"
]
},
"context": {
"@aws-cdk/aws-signer:signingProfileNamePassedToCfn": true,
"@aws-cdk/aws-ecs-patterns:secGroupsDisablesImplicitOpenListener": true,
"@aws-cdk/aws-lambda:recognizeLayerVersion": true,
"@aws-cdk/core:checkSecretUsage": true,
"@aws-cdk/core:target-partitions": ["aws", "aws-cn"],
"@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true,
"@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true,
"@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true,
"@aws-cdk/aws-iam:minimizePolicies": true,
"@aws-cdk/core:validateSnapshotRemovalPolicy": true,
"@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true,
"@aws-cdk/aws-s3:createDefaultLoggingPolicy": true,
"@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true,
"@aws-cdk/aws-apigateway:disableCloudWatchRole": false,
"@aws-cdk/core:enablePartitionLiterals": true,
"@aws-cdk/aws-events:eventsTargetQueueSameAccount": true,
"@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true,
"@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName": true,
"@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true,
"@aws-cdk/aws-route53-patters:useCertificate": true,
"@aws-cdk/customresources:installLatestAwsSdkDefault": false,
"@aws-cdk/aws-rds:databaseProxyUniqueResourceName": true,
"@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup": true,
"@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId": true,
"@aws-cdk/aws-ec2:launchTemplateDefaultUserData": true,
"@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments": true,
"@aws-cdk/aws-redshift:columnId": true,
"@aws-cdk/aws-stepfunctions-tasks:enableEmrServicePolicyV2": true,
"@aws-cdk/aws-ec2:restrictDefaultSecurityGroup": true,
"@aws-cdk/aws-apigateway:requestValidatorUniqueId": true,
"@aws-cdk/aws-kms:aliasNameRef": true,
"@aws-cdk/aws-kms:applyImportedAliasPermissionsToPrincipal": true,
"@aws-cdk/aws-autoscaling:generateLaunchTemplateInsteadOfLaunchConfig": true,
"@aws-cdk/core:includePrefixInUniqueNameGeneration": true,
"@aws-cdk/aws-efs:denyAnonymousAccess": true,
"@aws-cdk/aws-opensearchservice:enableOpensearchMultiAzWithStandby": true,
"@aws-cdk/aws-lambda-nodejs:useLatestRuntimeVersion": true,
"@aws-cdk/aws-efs:mountTargetOrderInsensitiveLogicalId": true,
"@aws-cdk/aws-rds:auroraClusterChangeScopeOfInstanceParameterGroupWithEachParameters": true,
"@aws-cdk/aws-appsync:useArnForSourceApiAssociationIdentifier": true,
"@aws-cdk/aws-rds:preventRenderingDeprecatedCredentials": true,
"@aws-cdk/aws-codepipeline-actions:useNewDefaultBranchForCodeCommitSource": true,
"@aws-cdk/aws-cloudwatch-actions:changeLambdaPermissionLogicalIdForLambdaAction": true,
"@aws-cdk/aws-codepipeline:crossAccountKeysDefaultValueToFalse": true,
"@aws-cdk/aws-codepipeline:defaultPipelineTypeToV2": true,
"@aws-cdk/aws-kms:reduceCrossAccountRegionPolicyScope": true,
"@aws-cdk/aws-eks:nodegroupNameAttribute": true,
"@aws-cdk/aws-ec2:ebsDefaultGp3Volume": true,
"@aws-cdk/aws-ecs:removeDefaultDeploymentAlarm": true,
"@aws-cdk/custom-resources:logApiResponseDataPropertyTrueDefault": false,
"@aws-cdk/aws-s3:keepNotificationInImportedBucket": false,
"@aws-cdk/core:explicitStackTags": true,
"@aws-cdk/aws-ecs:enableImdsBlockingDeprecatedFeature": false,
"@aws-cdk/aws-ecs:disableEcsImdsBlocking": true,
"@aws-cdk/aws-ecs:reduceEc2FargateCloudWatchPermissions": true,
"@aws-cdk/aws-dynamodb:resourcePolicyPerReplica": true,
"@aws-cdk/aws-ec2:ec2SumTImeoutEnabled": true,
"@aws-cdk/aws-appsync:appSyncGraphQLAPIScopeLambdaPermission": true,
"@aws-cdk/aws-rds:setCorrectValueForDatabaseInstanceReadReplicaInstanceResourceId": true,
"@aws-cdk/core:cfnIncludeRejectComplexResourceUpdateCreatePolicyIntrinsics": true,
"@aws-cdk/aws-lambda-nodejs:sdkV3ExcludeSmithyPackages": true,
"@aws-cdk/aws-stepfunctions-tasks:fixRunEcsTaskPolicy": true,
"@aws-cdk/aws-ec2:bastionHostUseAmazonLinux2023ByDefault": true,
"@aws-cdk/aws-route53-targets:userPoolDomainNameMethodWithoutCustomResource": true,
"@aws-cdk/aws-elasticloadbalancingV2:albDualstackWithoutPublicIpv4SecurityGroupRulesDefault": true,
"@aws-cdk/aws-iam:oidcRejectUnauthorizedConnections": true,
"@aws-cdk/core:enableAdditionalMetadataCollection": true,
"@aws-cdk/aws-lambda:createNewPoliciesWithAddToRolePolicy": false,
"@aws-cdk/aws-s3:setUniqueReplicationRoleName": true,
"@aws-cdk/aws-events:requireEventBusPolicySid": true,
"@aws-cdk/core:aspectPrioritiesMutating": true,
"@aws-cdk/aws-dynamodb:retainTableReplica": true,
"@aws-cdk/aws-stepfunctions:useDistributedMapResultWriterV2": true,
"@aws-cdk/s3-notifications:addS3TrustKeyPolicyForSnsSubscriptions": true,
"@aws-cdk/aws-ec2:requirePrivateSubnetsForEgressOnlyInternetGateway": true,
"@aws-cdk/aws-s3:publicAccessBlockedByDefault": true,
"@aws-cdk/aws-lambda:useCdkManagedLogGroup": true
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,22 @@
{
"name": "copilotkit-runtime-lambda",
"version": "0.1.0",
"private": true,
"type": "commonjs",
"main": "dist/index.js",
"scripts": {
"build": "tsc -p tsconfig.json"
},
"dependencies": {
"@ag-ui/client": "0.0.57",
"@ag-ui/mcp-apps-middleware": "^0.0.3",
"@copilotkit/runtime": "1.62.2",
"@hono/node-server": "^1.13.7",
"hono": "^4.11.4",
"rxjs": "^7.8.2"
},
"devDependencies": {
"@types/node": "^22.15.32",
"typescript": "^5.9.3"
}
}
@@ -0,0 +1,8 @@
import { streamHandle } from "hono/aws-lambda";
import { buildApp } from "./runtime";
const app = buildApp();
export const handler: (...args: unknown[]) => unknown = streamHandle(app) as (
...args: unknown[]
) => unknown;
@@ -0,0 +1,150 @@
/**
* Shared CopilotKit runtime — the single source of truth.
* Imported by index.ts (Lambda) and server.ts (local dev).
*/
import { EventType, HttpAgent } from "@ag-ui/client";
import type { BaseEvent } from "@ag-ui/client";
import { MCPAppsMiddleware } from "@ag-ui/mcp-apps-middleware";
import {
CopilotKitIntelligence,
CopilotRuntime,
createCopilotEndpoint,
InMemoryAgentRunner,
} from "@copilotkit/runtime/v2";
import { concatMap, of } from "rxjs";
import { randomUUID } from "node:crypto";
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`${name} environment variable is required`);
return value;
}
export function buildAgents(): Record<string, HttpAgent> {
const agentUrl = requireEnv("AGENTCORE_AG_UI_URL");
const agentName = process.env.COPILOTKIT_AGENT_NAME ?? "default";
const mcpServerUrl =
process.env.MCP_SERVER_URL || "https://mcp.excalidraw.com";
const agent = new HttpAgent({ url: agentUrl, headers: {} });
agent.use(
new MCPAppsMiddleware({
mcpServers: [
{ type: "http", url: mcpServerUrl, serverId: "example_mcp_app" },
],
}),
);
return { [agentName]: agent };
}
/**
* AgentCore stores conversation history in its own memory layer (AgentCoreMemorySaver /
* AgentCoreMemorySessionManager). When CopilotKit reconnects to an existing thread
* (e.g. page refresh), it calls `connect()` which replays that stored history as a
* MESSAGES_SNAPSHOT event. Two issues arise from this that this runner fixes:
*
* 1. Unknown threads — CopilotKit may call `connect()` for a thread it has never
* `run()` against (e.g. on first load). The base runner would error; instead we
* return an empty snapshot so the UI initialises cleanly.
*
* 2. Missing tool-call results — AgentCore's snapshot includes assistant messages
* with tool calls, but the corresponding TOOL_CALL_RESULT events are absent.
* CopilotKit needs those results to reconcile its internal message state. We
* synthesise empty results for every past tool call before emitting the snapshot.
*/
export class AgentCoreRunner extends InMemoryAgentRunner {
private readonly knownThreadIds = new Set<string>();
override run(
request: Parameters<InMemoryAgentRunner["run"]>[0],
): ReturnType<InMemoryAgentRunner["run"]> {
if (request.threadId) this.knownThreadIds.add(request.threadId);
return super.run(request);
}
override connect(
request: Parameters<InMemoryAgentRunner["connect"]>[0],
): ReturnType<InMemoryAgentRunner["connect"]> {
if (!request.threadId || !this.knownThreadIds.has(request.threadId)) {
// Unknown thread — return an empty snapshot instead of erroring.
const runId =
typeof (request as { runId?: unknown }).runId === "string"
? ((request as { runId?: string }).runId ?? randomUUID())
: randomUUID();
return of(
{
type: EventType.RUN_STARTED,
threadId: request.threadId ?? randomUUID(),
runId,
} as BaseEvent,
{ type: EventType.MESSAGES_SNAPSHOT, messages: [] } as BaseEvent,
{
type: EventType.RUN_FINISHED,
threadId: request.threadId ?? randomUUID(),
runId,
} as BaseEvent,
) as unknown as ReturnType<InMemoryAgentRunner["connect"]>;
}
// Known thread — replay synthetic tool-call results before the snapshot so
// CopilotKit can reconcile its message state correctly.
return (super.connect(request) as any).pipe(
concatMap((event: any) => {
if (
event.type !== EventType.MESSAGES_SNAPSHOT ||
!("messages" in event)
)
return of(event);
const replayedResults = event.messages.flatMap((message: any) => {
if (message.role !== "assistant" || !message.toolCalls?.length)
return [];
return message.toolCalls.map(
(toolCall: any) =>
({
type: EventType.TOOL_CALL_RESULT,
toolCallId: toolCall.id,
messageId: `${toolCall.id}-result`,
content: "",
role: "tool",
}) satisfies BaseEvent,
);
});
return of(...replayedResults, event);
}),
) as ReturnType<InMemoryAgentRunner["connect"]>;
}
}
export function buildApp() {
const agents = buildAgents();
const agentName = process.env.COPILOTKIT_AGENT_NAME ?? "default";
const defaultAgent =
agents[agentName] ?? agents.default ?? Object.values(agents)[0];
if (!defaultAgent)
throw new Error("At least one CopilotKit agent URL must be configured");
const runtime = new CopilotRuntime({
agents: { ...agents, default: defaultAgent },
// --- copilotkit:intelligence (remove this block to opt out) ---
...(process.env.COPILOTKIT_LICENSE_TOKEN
? {
intelligence: new CopilotKitIntelligence({
apiKey: process.env.INTELLIGENCE_API_KEY ?? "",
apiUrl: process.env.INTELLIGENCE_API_URL ?? "http://localhost:4201",
wsUrl:
process.env.INTELLIGENCE_GATEWAY_WS_URL ?? "ws://localhost:4401",
}),
// Demo stub — replace with your real auth-derived user identity before any
// multi-user deployment, or all users share one thread history.
identifyUser: () => ({ id: "demo-user", name: "Demo User" }),
licenseToken: process.env.COPILOTKIT_LICENSE_TOKEN,
}
: { runner: new AgentCoreRunner() }),
// --- /copilotkit:intelligence ---
});
return createCopilotEndpoint({ runtime, basePath: "/copilotkit" });
}
@@ -0,0 +1,27 @@
// =============================================================================
// ██████████████████████████████████████████████████████████████████████████
// ██ ██
// ██ ⚠️ LOCAL DEVELOPMENT ONLY — DO NOT DEPLOY ⚠️ ██
// ██ ██
// ██ Serves the same CopilotKit app as index.ts (Lambda) but via ██
// ██ @hono/node-server instead of streamHandle. ██
// ██ ██
// ██ Set AGENTCORE_AG_UI_URL=http://agent:8080/invocations to point ██
// ██ at the local agent container instead of AWS. ██
// ██ ██
// ██ Production entrypoint: index.ts ██
// ██ ██
// ██████████████████████████████████████████████████████████████████████████
// =============================================================================
import { serve } from "@hono/node-server";
import { buildApp } from "./runtime";
const PORT = parseInt(process.env.PORT ?? "3001");
const app = buildApp();
serve({ fetch: app.fetch, port: PORT }, () => {
console.log(
`[local] CopilotKit bridge on :${PORT}${process.env.AGENTCORE_AG_UI_URL ?? "???"}`,
);
});
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*.ts"]
}
@@ -0,0 +1,164 @@
"""
Custom Resource Lambda for managing OAuth2 Credential Provider lifecycle.
This Lambda is invoked by CloudFormation during stack deployment to manage
an OAuth2 Credential Provider in Bedrock AgentCore Identity. It retrieves the Cognito
client secret from Secrets Manager at runtime to avoid logging sensitive data.
CloudFormation Events:
- Create: Creates OAuth2 provider with credentials from Secrets Manager
- Update: Updates OAuth2 provider properties (clientId, clientSecret, discoveryUrl)
- Delete: Deletes OAuth2 provider by name
"""
import logging
import boto3
logger = logging.getLogger()
logger.setLevel(logging.INFO)
bedrock_client = boto3.client("bedrock-agentcore-control")
secrets_client = boto3.client("secretsmanager")
def handler(event: dict, context: dict) -> dict:
"""
CloudFormation Custom Resource handler for OAuth2 Credential Provider.
Args:
event: CloudFormation event containing RequestType and ResourceProperties
context: Lambda context object
Returns:
Response dict with PhysicalResourceId and optional Data attributes
"""
request_type = event["RequestType"]
props = event["ResourceProperties"]
logger.info(f"Request type: {request_type}")
logger.info(f"Provider name: {props['ProviderName']}")
try:
if request_type == "Create":
return handle_create(props)
elif request_type == "Delete":
return handle_delete(event, props)
elif request_type == "Update":
return handle_update(event, props)
else:
raise ValueError(f"Unknown request type: {request_type}")
except Exception as e:
logger.error(f"Error handling {request_type}: {str(e)}", exc_info=True)
raise
def handle_create(props: dict) -> dict:
"""
Create OAuth2 Credential Provider.
Args:
props: ResourceProperties from CloudFormation event
Returns:
Response with PhysicalResourceId and provider ARN
"""
# Retrieve client secret from Secrets Manager (not logged)
secret_arn = props["ClientSecretArn"]
logger.info(f"Retrieving secret from: {secret_arn}")
secret_response = secrets_client.get_secret_value(SecretId=secret_arn)
client_secret = secret_response["SecretString"]
# Create OAuth2 Credential Provider
logger.info(f"Creating OAuth2 provider: {props['ProviderName']}")
response = bedrock_client.create_oauth2_credential_provider(
name=props["ProviderName"],
credentialProviderVendor="CustomOauth2",
oauth2ProviderConfigInput={
"customOauth2ProviderConfig": {
"clientId": props["ClientId"],
"clientSecret": client_secret,
"oauthDiscovery": {"discoveryUrl": props["DiscoveryUrl"]},
}
},
)
provider_arn = response["credentialProviderArn"]
logger.info(f"Created provider with ARN: {provider_arn}")
return {
"PhysicalResourceId": props["ProviderName"],
"Data": {"ProviderArn": provider_arn},
}
def handle_update(event: dict, props: dict) -> dict:
"""
Update OAuth2 Credential Provider.
Args:
event: CloudFormation event
props: ResourceProperties from CloudFormation event
Returns:
Response with PhysicalResourceId and provider ARN
"""
provider_name = event["PhysicalResourceId"]
logger.info(f"Updating OAuth2 provider: {provider_name}")
# Retrieve client secret from Secrets Manager
secret_arn = props["ClientSecretArn"]
logger.info(f"Retrieving secret from: {secret_arn}")
secret_response = secrets_client.get_secret_value(SecretId=secret_arn)
client_secret = secret_response["SecretString"]
# Update OAuth2 Credential Provider
response = bedrock_client.update_oauth2_credential_provider(
name=provider_name,
credentialProviderVendor="CustomOauth2",
oauth2ProviderConfigInput={
"customOauth2ProviderConfig": {
"clientId": props["ClientId"],
"clientSecret": client_secret,
"oauthDiscovery": {"discoveryUrl": props["DiscoveryUrl"]},
}
},
)
provider_arn = response["credentialProviderArn"]
logger.info(f"Updated provider with ARN: {provider_arn}")
return {
"PhysicalResourceId": provider_name,
"Data": {"ProviderArn": provider_arn},
}
def handle_delete(event: dict, props: dict) -> dict:
"""
Delete OAuth2 Credential Provider.
Args:
event: CloudFormation event
props: ResourceProperties from CloudFormation event
Returns:
Response with PhysicalResourceId
"""
provider_name = event["PhysicalResourceId"]
logger.info(f"Deleting OAuth2 provider: {provider_name}")
try:
bedrock_client.delete_oauth2_credential_provider(name=provider_name)
logger.info(f"Deleted provider: {provider_name}")
except bedrock_client.exceptions.ResourceNotFoundException:
logger.warning(f"Provider not found (already deleted): {provider_name}")
except Exception as e:
logger.error(f"Error deleting provider: {str(e)}")
raise
return {"PhysicalResourceId": provider_name}
@@ -0,0 +1,101 @@
import * as cdk from "aws-cdk-lib";
import * as amplify from "@aws-cdk/aws-amplify-alpha";
import * as s3 from "aws-cdk-lib/aws-s3";
import * as iam from "aws-cdk-lib/aws-iam";
import { Construct } from "constructs";
import { AppConfig } from "./utils/config-manager";
export interface AmplifyStackProps extends cdk.NestedStackProps {
config: AppConfig;
}
export class AmplifyHostingStack extends cdk.NestedStack {
public readonly amplifyApp: amplify.App;
public readonly amplifyUrl: string;
public readonly stagingBucket: s3.Bucket;
constructor(scope: Construct, id: string, props: AmplifyStackProps) {
const description =
"Fullstack AgentCore Solution Template - Amplify Hosting Stack";
super(scope, id, { ...props, description });
// Create access logs bucket for staging bucket
const accessLogsBucket = new s3.Bucket(this, "StagingBucketAccessLogs", {
removalPolicy: cdk.RemovalPolicy.DESTROY,
autoDeleteObjects: true,
publicReadAccess: false,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
lifecycleRules: [
{
id: "DeleteOldAccessLogs",
enabled: true,
expiration: cdk.Duration.days(90), // Keep access logs for 90 days
},
],
});
// Create staging bucket for Amplify deployments with dynamic name
this.stagingBucket = new s3.Bucket(this, "StagingBucket", {
removalPolicy: cdk.RemovalPolicy.DESTROY,
autoDeleteObjects: true,
versioned: true, // Enable versioning as required by Amplify
publicReadAccess: false,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
serverAccessLogsBucket: accessLogsBucket,
serverAccessLogsPrefix: "staging-bucket-access-logs/",
lifecycleRules: [
{
id: "DeleteOldDeployments",
enabled: true,
expiration: cdk.Duration.days(30), // Clean up old deployment artifacts after 30 days
},
],
});
// Add bucket policy to allow Amplify service access
this.stagingBucket.addToResourcePolicy(
new iam.PolicyStatement({
sid: "AmplifyAccess",
effect: iam.Effect.ALLOW,
principals: [new iam.ServicePrincipal("amplify.amazonaws.com")],
actions: ["s3:GetObject", "s3:GetObjectVersion"],
resources: [this.stagingBucket.arnForObjects("*")],
}),
);
// Enforce SSL/TLS for all requests to the bucket
this.stagingBucket.addToResourcePolicy(
new iam.PolicyStatement({
sid: "DenyInsecureConnections",
effect: iam.Effect.DENY,
principals: [new iam.AnyPrincipal()],
actions: ["s3:*"],
resources: [
this.stagingBucket.bucketArn,
this.stagingBucket.arnForObjects("*"),
],
conditions: {
Bool: {
"aws:SecureTransport": "false",
},
},
}),
);
// Create the Amplify app
this.amplifyApp = new amplify.App(this, "AmplifyApp", {
appName: `${props.config.stack_name_base}-frontend`,
description: `${props.config.stack_name_base} - React Frontend`,
platform: amplify.Platform.WEB,
});
// Create main branch for the Amplify app
this.amplifyApp.addBranch("main", {
stage: "PRODUCTION",
branchName: "main",
});
// The predictable domain format: https://main.{appId}.amplifyapp.com
this.amplifyUrl = `https://main.${this.amplifyApp.appId}.amplifyapp.com`;
}
}
@@ -0,0 +1,869 @@
import * as cdk from "aws-cdk-lib";
import * as cognito from "aws-cdk-lib/aws-cognito";
import * as ec2 from "aws-cdk-lib/aws-ec2";
import * as iam from "aws-cdk-lib/aws-iam";
import * as ssm from "aws-cdk-lib/aws-ssm";
import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager";
import * as apigateway from "aws-cdk-lib/aws-apigateway";
import * as logs from "aws-cdk-lib/aws-logs";
import * as agentcore from "@aws-cdk/aws-bedrock-agentcore-alpha";
import * as bedrockagentcore from "aws-cdk-lib/aws-bedrockagentcore";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as ecr_assets from "aws-cdk-lib/aws-ecr-assets";
import * as cr from "aws-cdk-lib/custom-resources";
import { Construct } from "constructs";
import { AppConfig } from "./utils/config-manager";
import { AgentCoreRole } from "./utils/agentcore-role";
import * as path from "path";
import * as fs from "fs";
import { execSync } from "child_process";
export interface BackendStackProps extends cdk.NestedStackProps {
config: AppConfig;
userPoolId: string;
userPoolClientId: string;
userPoolDomain: cognito.UserPoolDomain;
frontendUrl: string;
}
export class BackendStack extends cdk.NestedStack {
public readonly userPoolId: string;
public readonly userPoolClientId: string;
public readonly userPoolDomain: cognito.UserPoolDomain;
public copilotKitRuntimeUrl: string;
public runtimeArn: string;
public memoryArn: string;
private agentName: cdk.CfnParameter;
private userPool: cognito.IUserPool;
private machineClient: cognito.UserPoolClient;
private machineClientSecret: secretsmanager.Secret;
private runtimeCredentialProvider: cdk.CustomResource;
private agentRuntime: agentcore.Runtime;
constructor(scope: Construct, id: string, props: BackendStackProps) {
super(scope, id, props);
// Store the Cognito values
this.userPoolId = props.userPoolId;
this.userPoolClientId = props.userPoolClientId;
this.userPoolDomain = props.userPoolDomain;
// Import the Cognito resources from the other stack
this.userPool = cognito.UserPool.fromUserPoolId(
this,
"ImportedUserPoolForBackend",
props.userPoolId,
);
// then create the user pool client
cognito.UserPoolClient.fromUserPoolClientId(
this,
"ImportedUserPoolClient",
props.userPoolClientId,
);
// Create Machine-to-Machine authentication components
this.createMachineAuthentication(props.config);
// DEPLOYMENT ORDER EXPLANATION:
// 1. Cognito User Pool & Client (created in separate CognitoStack)
// 2. Machine Client & Resource Server (created above for M2M auth)
// 3. AgentCore Gateway (created next - uses machine client for auth)
// 4. AgentCore Runtime (created last - independent of gateway)
//
// This order ensures that authentication components are available before
// the gateway that depends on them, while keeping the runtime separate
// since it doesn't directly depend on the gateway.
// Create AgentCore Gateway (before Runtime)
this.createAgentCoreGateway(props.config);
// Create AgentCore Runtime resources
this.createAgentCoreRuntime(props.config);
// Store runtime ARN in SSM for frontend stack
this.createRuntimeSSMParameters(props.config);
// Store Cognito configuration in SSM for testing and frontend
this.createCognitoSSMParameters(props.config);
// Create standalone CopilotKit runtime API.
this.createCopilotKitRuntimeApi(props.config, props.frontendUrl);
}
private createAgentCoreRuntime(config: AppConfig): void {
const pattern = config.backend?.pattern || "strands-single-agent";
// Parameters
this.agentName = new cdk.CfnParameter(this, "AgentName", {
type: "String",
default: "FASTAgent",
description: "Name for the agent runtime",
});
const stack = cdk.Stack.of(this);
// Create the agent runtime artifact based on deployment type
let agentRuntimeArtifact: agentcore.AgentRuntimeArtifact;
// DOCKER DEPLOYMENT: Use container-based deployment
agentRuntimeArtifact = agentcore.AgentRuntimeArtifact.fromAsset(
path.resolve(__dirname, "..", ".."),
{
platform: ecr_assets.Platform.LINUX_ARM64,
file: `agents/${pattern}/Dockerfile`,
},
);
// Configure network mode based on config.yaml settings.
// PUBLIC: Runtime is accessible over the public internet (default).
// VPC: Runtime is deployed into a user-provided VPC for private network isolation.
// The user must ensure their VPC has the necessary VPC endpoints for AWS services.
// See docs/DEPLOYMENT.md for the full list of required VPC endpoints.
const networkConfiguration = this.buildNetworkConfiguration(config);
// Configure JWT authorizer with Cognito
const authorizerConfiguration =
agentcore.RuntimeAuthorizerConfiguration.usingJWT(
`https://cognito-idp.${stack.region}.amazonaws.com/${this.userPoolId}/.well-known/openid-configuration`,
[this.userPoolClientId],
);
// Create AgentCore execution role
const agentRole = new AgentCoreRole(this, "AgentCoreRole");
// Create memory resource with short-term memory (conversation history) as default
// To enable long-term strategies (summaries, preferences, facts), see docs/MEMORY_INTEGRATION.md
const memory = new cdk.CfnResource(this, "AgentMemory", {
type: "AWS::BedrockAgentCore::Memory",
properties: {
Name: cdk.Names.uniqueResourceName(this, { maxLength: 48 }),
EventExpiryDuration: 30,
Description: `Short-term memory for ${config.stack_name_base} agent`,
MemoryStrategies: [], // Empty array = short-term only (conversation history)
MemoryExecutionRoleArn: agentRole.roleArn,
Tags: {
Name: `${config.stack_name_base}_Memory`,
ManagedBy: "CDK",
},
},
});
const memoryId = memory.getAtt("MemoryId").toString();
const memoryArn = memory.getAtt("MemoryArn").toString();
// Store the memory ARN for access from main stack
this.memoryArn = memoryArn;
// Add memory-specific permissions to agent role
agentRole.addToPolicy(
new iam.PolicyStatement({
sid: "MemoryResourceAccess",
effect: iam.Effect.ALLOW,
actions: [
"bedrock-agentcore:CreateEvent",
"bedrock-agentcore:GetEvent",
"bedrock-agentcore:ListEvents",
"bedrock-agentcore:RetrieveMemoryRecords", // Only needed for long-term strategies
],
resources: [memoryArn],
}),
);
// Add SSM permissions for AgentCore Gateway URL lookup
agentRole.addToPolicy(
new iam.PolicyStatement({
sid: "SSMParameterAccess",
effect: iam.Effect.ALLOW,
actions: ["ssm:GetParameter", "ssm:GetParameters"],
resources: [
`arn:aws:ssm:${this.region}:${this.account}:parameter/${config.stack_name_base}/*`,
],
}),
);
// Add Code Interpreter permissions
agentRole.addToPolicy(
new iam.PolicyStatement({
sid: "CodeInterpreterAccess",
effect: iam.Effect.ALLOW,
actions: [
"bedrock-agentcore:StartCodeInterpreterSession",
"bedrock-agentcore:StopCodeInterpreterSession",
"bedrock-agentcore:InvokeCodeInterpreter",
],
resources: [
`arn:aws:bedrock-agentcore:${this.region}:aws:code-interpreter/*`,
],
}),
);
// Add OAuth2 Credential Provider access for AgentCore Runtime
// The @requires_access_token decorator performs a two-stage process:
// 1. GetOauth2CredentialProvider - Looks up provider metadata (ARN, vendor config, grant types)
// 2. GetResourceOauth2Token - Uses metadata to fetch the actual access token from Token Vault
agentRole.addToPolicy(
new iam.PolicyStatement({
sid: "OAuth2CredentialProviderAccess",
effect: iam.Effect.ALLOW,
actions: [
"bedrock-agentcore:GetOauth2CredentialProvider",
"bedrock-agentcore:GetResourceOauth2Token",
],
resources: [
`arn:aws:bedrock-agentcore:${this.region}:${this.account}:oauth2-credential-provider/*`,
`arn:aws:bedrock-agentcore:${this.region}:${this.account}:token-vault/*`,
`arn:aws:bedrock-agentcore:${this.region}:${this.account}:workload-identity-directory/*`,
],
}),
);
// Add Secrets Manager access for OAuth2
// AgentCore Runtime needs to read two secrets:
// 1. Machine client secret (created by CDK)
// 2. Token Vault OAuth2 secret (created by AgentCore Identity)
agentRole.addToPolicy(
new iam.PolicyStatement({
sid: "SecretsManagerOAuth2Access",
effect: iam.Effect.ALLOW,
actions: ["secretsmanager:GetSecretValue"],
resources: [
`arn:aws:secretsmanager:${this.region}:${this.account}:secret:/${config.stack_name_base}/machine_client_secret*`,
`arn:aws:secretsmanager:${this.region}:${this.account}:secret:bedrock-agentcore-identity!default/oauth2/${config.stack_name_base}-runtime-gateway-auth*`,
],
}),
);
// Environment variables for the runtime
const envVars: { [key: string]: string } = {
AWS_REGION: stack.region,
AWS_DEFAULT_REGION: stack.region,
MEMORY_ID: memoryId,
STACK_NAME: config.stack_name_base,
GATEWAY_CREDENTIAL_PROVIDER_NAME: `${config.stack_name_base}-runtime-gateway-auth`, // Used by @requires_access_token decorator to look up the correct provider
};
// Add claude-agent-sdk specific environment variable
if (
pattern === "claude-agent-sdk-single-agent" ||
pattern === "claude-agent-sdk-multi-agent"
) {
envVars["CLAUDE_CODE_USE_BEDROCK"] = "1";
}
// Enable AG-UI / CopilotKit protocol for LangGraph and Strands agents
if (
pattern === "langgraph-single-agent" ||
pattern === "strands-single-agent"
) {
envVars["AGUI_ENABLED"] = "true";
}
// Create the runtime using L2 construct
// requestHeaderConfiguration allows the agent to read the Authorization header
// from RequestContext.request_headers, which is needed to securely extract the
// user ID from the validated JWT token (sub claim) instead of trusting the payload body.
this.agentRuntime = new agentcore.Runtime(this, "Runtime", {
runtimeName: `${config.stack_name_base.replace(/-/g, "_")}_${this.agentName.valueAsString}`,
agentRuntimeArtifact: agentRuntimeArtifact,
executionRole: agentRole,
networkConfiguration: networkConfiguration,
protocolConfiguration: agentcore.ProtocolType.HTTP,
environmentVariables: envVars,
authorizerConfiguration: authorizerConfiguration,
requestHeaderConfiguration: {
allowlistedHeaders: ["Authorization"],
},
description: `${pattern} agent runtime for ${config.stack_name_base}`,
});
// Store the runtime ARN
this.runtimeArn = this.agentRuntime.agentRuntimeArn;
// Outputs
new cdk.CfnOutput(this, "AgentRuntimeId", {
description: "ID of the created agent runtime",
value: this.agentRuntime.agentRuntimeId,
});
new cdk.CfnOutput(this, "AgentRuntimeArn", {
description: "ARN of the created agent runtime",
value: this.agentRuntime.agentRuntimeArn,
exportName: `${config.stack_name_base}-AgentRuntimeArn`,
});
new cdk.CfnOutput(this, "AgentRoleArn", {
description: "ARN of the agent execution role",
value: agentRole.roleArn,
});
// Memory ARN output
new cdk.CfnOutput(this, "MemoryArn", {
description: "ARN of the agent memory resource",
value: memoryArn,
});
}
private createRuntimeSSMParameters(config: AppConfig): void {
// Store runtime ARN in SSM for frontend stack
new ssm.StringParameter(this, "RuntimeArnParam", {
parameterName: `/${config.stack_name_base}/runtime-arn`,
stringValue: this.runtimeArn,
});
}
private createCognitoSSMParameters(config: AppConfig): void {
// Store Cognito configuration in SSM for testing and frontend access
new ssm.StringParameter(this, "CognitoUserPoolIdParam", {
parameterName: `/${config.stack_name_base}/cognito-user-pool-id`,
stringValue: this.userPoolId,
description: "Cognito User Pool ID",
});
new ssm.StringParameter(this, "CognitoUserPoolClientIdParam", {
parameterName: `/${config.stack_name_base}/cognito-user-pool-client-id`,
stringValue: this.userPoolClientId,
description: "Cognito User Pool Client ID",
});
new ssm.StringParameter(this, "MachineClientIdParam", {
parameterName: `/${config.stack_name_base}/machine_client_id`,
stringValue: this.machineClient.userPoolClientId,
description: "Machine Client ID for M2M authentication",
});
// Use the correct Cognito domain format from the passed domain
new ssm.StringParameter(this, "CognitoDomainParam", {
parameterName: `/${config.stack_name_base}/cognito_provider`,
stringValue: `${this.userPoolDomain.domainName}.auth.${cdk.Aws.REGION}.amazoncognito.com`,
description: "Cognito domain URL for token endpoint",
});
}
private createCopilotKitRuntimeApi(
config: AppConfig,
frontendUrl: string,
): void {
const buildAgentCoreAgUiUrl = (runtimeArn: string): string => {
const encodedRuntimeArn = cdk.Fn.join(
"%2F",
cdk.Fn.split("/", cdk.Fn.join("%3A", cdk.Fn.split(":", runtimeArn))),
);
return cdk.Fn.join("", [
"https://bedrock-agentcore.",
cdk.Stack.of(this).region,
".amazonaws.com/runtimes/",
encodedRuntimeArn,
"/invocations?qualifier=DEFAULT",
]);
};
const agentCoreAgUiUrl = buildAgentCoreAgUiUrl(this.runtimeArn);
const copilotKitRuntimeLambda = new lambda.Function(
this,
"CopilotKitRuntimeLambda",
{
functionName: `${config.stack_name_base}-copilotkit-runtime`,
runtime: lambda.Runtime.NODEJS_20_X,
architecture: lambda.Architecture.ARM_64,
handler: "dist/index.handler",
code: lambda.Code.fromAsset(
path.join(__dirname, "..", "lambdas", "copilotkit-runtime"),
{
assetHashType: cdk.AssetHashType.OUTPUT,
bundling: {
local: {
tryBundle(outputDir: string) {
const runtimeDir = path.join(
__dirname,
"..",
"lambdas",
"copilotkit-runtime",
);
execSync("npm ci --no-audit --no-fund", {
cwd: runtimeDir,
stdio: "inherit",
});
execSync("npm run build", {
cwd: runtimeDir,
stdio: "inherit",
});
execSync("npm prune --omit=dev", {
cwd: runtimeDir,
stdio: "inherit",
});
execSync(
`cp -R dist node_modules package.json package-lock.json ${outputDir}/`,
{
cwd: runtimeDir,
stdio: "inherit",
},
);
return true;
},
},
image: lambda.Runtime.NODEJS_20_X.bundlingImage,
environment: {
NPM_CONFIG_CACHE: "/tmp/.npm",
NPM_CONFIG_FETCH_RETRIES: "5",
NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT: "120000",
},
command: [
"bash",
"-c",
[
"mkdir -p /tmp/.npm",
"npm ci --no-audit --no-fund",
"npm run build",
"npm prune --omit=dev",
"cp -R dist node_modules package.json package-lock.json /asset-output/",
].join(" && "),
],
},
},
),
environment: {
AGENTCORE_AG_UI_URL: agentCoreAgUiUrl,
COPILOTKIT_AGENT_NAME:
config.backend?.pattern || "langgraph-single-agent",
},
timeout: cdk.Duration.seconds(30),
memorySize: 1024,
logGroup: new logs.LogGroup(this, "CopilotKitRuntimeLambdaLogGroup", {
logGroupName: `/aws/lambda/${config.stack_name_base}-copilotkit-runtime`,
retention: logs.RetentionDays.ONE_WEEK,
removalPolicy: cdk.RemovalPolicy.DESTROY,
}),
},
);
const copilotKitApi = new apigateway.RestApi(this, "CopilotKitRuntimeApi", {
restApiName: `${config.stack_name_base}-copilotkit-runtime-api`,
description: "Standalone CopilotKit runtime API backed by Lambda",
defaultCorsPreflightOptions: {
allowOrigins: [frontendUrl, "http://localhost:3000"],
allowMethods: ["GET", "POST", "OPTIONS"],
allowHeaders: ["Content-Type", "Authorization"],
},
deployOptions: {
stageName: "prod",
},
});
const runtimeIntegration = new apigateway.LambdaIntegration(
copilotKitRuntimeLambda,
{
responseTransferMode: apigateway.ResponseTransferMode.STREAM,
},
);
const runtimeResource = copilotKitApi.root.addResource("copilotkit");
runtimeResource.addMethod("GET", runtimeIntegration, {
authorizationType: apigateway.AuthorizationType.NONE,
});
runtimeResource.addMethod("POST", runtimeIntegration, {
authorizationType: apigateway.AuthorizationType.NONE,
});
const runtimeProxy = runtimeResource.addResource("{proxy+}");
runtimeProxy.addMethod("GET", runtimeIntegration, {
authorizationType: apigateway.AuthorizationType.NONE,
});
runtimeProxy.addMethod("POST", runtimeIntegration, {
authorizationType: apigateway.AuthorizationType.NONE,
});
this.copilotKitRuntimeUrl = copilotKitApi.urlForPath("/copilotkit");
new ssm.StringParameter(this, "CopilotKitRuntimeUrlParam", {
parameterName: `/${config.stack_name_base}/copilotkit-runtime-url`,
stringValue: this.copilotKitRuntimeUrl,
description: "CopilotKit runtime API URL",
});
new cdk.CfnOutput(this, "CopilotKitRuntimeUrl", {
description: "CopilotKit runtime API URL",
value: this.copilotKitRuntimeUrl,
});
}
private createAgentCoreGateway(config: AppConfig): void {
// Create comprehensive IAM role for gateway
const gatewayRole = new iam.Role(this, "GatewayRole", {
assumedBy: new iam.ServicePrincipal("bedrock-agentcore.amazonaws.com"),
description: "Role for AgentCore Gateway with comprehensive permissions",
});
// Bedrock permissions (region-agnostic)
gatewayRole.addToPolicy(
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: [
"bedrock:InvokeModel",
"bedrock:InvokeModelWithResponseStream",
],
resources: [
"arn:aws:bedrock:*::foundation-model/*",
`arn:aws:bedrock:*:${this.account}:inference-profile/*`,
],
}),
);
// SSM parameter access
gatewayRole.addToPolicy(
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ["ssm:GetParameter", "ssm:GetParameters"],
resources: [
`arn:aws:ssm:${this.region}:${this.account}:parameter/${config.stack_name_base}/*`,
],
}),
);
// Cognito permissions
gatewayRole.addToPolicy(
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: [
"cognito-idp:DescribeUserPoolClient",
"cognito-idp:InitiateAuth",
],
resources: [this.userPool.userPoolArn],
}),
);
// CloudWatch Logs
gatewayRole.addToPolicy(
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents",
],
resources: [
`arn:aws:logs:${this.region}:${this.account}:log-group:/aws/bedrock-agentcore/*`,
],
}),
);
// Cognito OAuth2 configuration for gateway
const cognitoIssuer = `https://cognito-idp.${this.region}.amazonaws.com/${this.userPool.userPoolId}`;
const cognitoDiscoveryUrl = `${cognitoIssuer}/.well-known/openid-configuration`;
// Create OAuth2 Credential Provider for AgentCore Runtime to authenticate with AgentCore Gateway
// Uses cr.Provider pattern with explicit Lambda to avoid logging secrets in CloudWatch
const providerName = `${config.stack_name_base}-runtime-gateway-auth`;
// Lambda to create/delete OAuth2 provider
const oauth2ProviderLambda = new lambda.Function(
this,
"OAuth2ProviderLambda",
{
runtime: lambda.Runtime.PYTHON_3_13,
handler: "index.handler",
code: lambda.Code.fromAsset(
path.join(__dirname, "..", "lambdas", "oauth2-provider"),
),
timeout: cdk.Duration.minutes(5),
logGroup: new logs.LogGroup(this, "OAuth2ProviderLambdaLogGroup", {
logGroupName: `/aws/lambda/${config.stack_name_base}-oauth2-provider`,
retention: logs.RetentionDays.ONE_WEEK,
removalPolicy: cdk.RemovalPolicy.DESTROY,
}),
},
);
// Grant Lambda permissions to read machine client secret
this.machineClientSecret.grantRead(oauth2ProviderLambda);
// Grant Lambda permissions for Bedrock AgentCore operations
// OAuth2 Credential Provider operations - scoped to all providers in default Token Vault
// Note: Need both vault-level and nested resource permissions because:
// - CreateOauth2CredentialProvider checks permission on vault itself (token-vault/default)
// - Also checks permission on the nested resource path (token-vault/default/oauth2credentialprovider/*)
oauth2ProviderLambda.addToRolePolicy(
new iam.PolicyStatement({
actions: [
"bedrock-agentcore:CreateOauth2CredentialProvider",
"bedrock-agentcore:DeleteOauth2CredentialProvider",
"bedrock-agentcore:GetOauth2CredentialProvider",
],
resources: [
`arn:aws:bedrock-agentcore:${this.region}:${this.account}:token-vault/default`,
`arn:aws:bedrock-agentcore:${this.region}:${this.account}:token-vault/default/oauth2credentialprovider/*`,
],
}),
);
// Token Vault operations - scoped to default vault
// Note: Need both exact match (default) and wildcard (default/*) because:
// - AWS checks permission on the vault container itself (token-vault/default)
// - AWS also checks permission on resources inside (token-vault/default/*)
oauth2ProviderLambda.addToRolePolicy(
new iam.PolicyStatement({
actions: [
"bedrock-agentcore:CreateTokenVault",
"bedrock-agentcore:GetTokenVault",
"bedrock-agentcore:DeleteTokenVault",
],
resources: [
`arn:aws:bedrock-agentcore:${this.region}:${this.account}:token-vault/default`,
`arn:aws:bedrock-agentcore:${this.region}:${this.account}:token-vault/default/*`,
],
}),
);
// Grant Lambda permissions for Token Vault secret management
// Scoped to OAuth2 secrets in AgentCore Identity default namespace
oauth2ProviderLambda.addToRolePolicy(
new iam.PolicyStatement({
actions: [
"secretsmanager:CreateSecret",
"secretsmanager:DeleteSecret",
"secretsmanager:DescribeSecret",
"secretsmanager:PutSecretValue",
],
resources: [
`arn:aws:secretsmanager:${this.region}:${this.account}:secret:bedrock-agentcore-identity!default/oauth2/*`,
],
}),
);
// Create Custom Resource Provider
const oauth2Provider = new cr.Provider(this, "OAuth2ProviderProvider", {
onEventHandler: oauth2ProviderLambda,
});
// Create Custom Resource
const runtimeCredentialProvider = new cdk.CustomResource(
this,
"RuntimeCredentialProvider",
{
serviceToken: oauth2Provider.serviceToken,
properties: {
ProviderName: providerName,
ClientSecretArn: this.machineClientSecret.secretArn,
DiscoveryUrl: cognitoDiscoveryUrl,
ClientId: this.machineClient.userPoolClientId,
},
},
);
// Store for use in createAgentCoreRuntime()
this.runtimeCredentialProvider = runtimeCredentialProvider;
// Create Gateway using L1 construct (CfnGateway)
// This replaces the Custom Resource approach with native CloudFormation support
const gateway = new bedrockagentcore.CfnGateway(this, "AgentCoreGateway", {
name: `${config.stack_name_base}-gateway`,
roleArn: gatewayRole.roleArn,
protocolType: "MCP",
protocolConfiguration: {
mcp: {
supportedVersions: ["2025-03-26"],
// Optional: Enable semantic search for tools
// searchType: "SEMANTIC",
},
},
authorizerType: "CUSTOM_JWT",
authorizerConfiguration: {
customJwtAuthorizer: {
allowedClients: [this.machineClient.userPoolClientId],
discoveryUrl: cognitoDiscoveryUrl,
},
},
description: "AgentCore Gateway with MCP protocol and JWT authentication",
});
// Ensure proper creation order
gateway.node.addDependency(this.machineClient);
gateway.node.addDependency(gatewayRole);
// Store AgentCore Gateway URL in SSM for AgentCore Runtime access
new ssm.StringParameter(this, "GatewayUrlParam", {
parameterName: `/${config.stack_name_base}/gateway_url`,
stringValue: gateway.attrGatewayUrl,
description: "AgentCore Gateway URL",
});
// Output gateway information
new cdk.CfnOutput(this, "GatewayId", {
value: gateway.attrGatewayIdentifier,
description: "AgentCore Gateway ID",
});
new cdk.CfnOutput(this, "GatewayUrl", {
value: gateway.attrGatewayUrl,
description: "AgentCore Gateway URL",
});
new cdk.CfnOutput(this, "GatewayArn", {
value: gateway.attrGatewayArn,
description: "AgentCore Gateway ARN",
});
new cdk.CfnOutput(this, "GatewayTargetId", {
value: gatewayTarget.ref,
description: "AgentCore Gateway Target ID",
});
new cdk.CfnOutput(this, "ToolLambdaArn", {
description: "ARN of the sample tool Lambda",
value: toolLambda.functionArn,
});
}
private createMachineAuthentication(config: AppConfig): void {
// Create Resource Server for Machine-to-Machine (M2M) authentication
// This defines the API scopes that machine clients can request access to
const resourceServer = new cognito.UserPoolResourceServer(
this,
"ResourceServer",
{
userPool: this.userPool,
identifier: `${config.stack_name_base}-gateway`,
userPoolResourceServerName: `${config.stack_name_base}-gateway-resource-server`,
scopes: [
new cognito.ResourceServerScope({
scopeName: "read",
scopeDescription: "Read access to gateway",
}),
new cognito.ResourceServerScope({
scopeName: "write",
scopeDescription: "Write access to gateway",
}),
],
},
);
// Create Machine Client for AgentCore Gateway authentication
//
// WHAT IS A MACHINE CLIENT?
// A machine client is a Cognito User Pool Client configured for server-to-server authentication
// using the OAuth2 Client Credentials flow. Unlike user-facing clients, it doesn't require
// human interaction or user credentials.
//
// HOW IS IT DIFFERENT FROM THE REGULAR USER POOL CLIENT?
// - Regular client: Uses Authorization Code flow for human users (frontend login)
// - Machine client: Uses Client Credentials flow for service-to-service authentication
// - Regular client: No client secret (public client for frontend security)
// - Machine client: Has client secret (confidential client for backend security)
// - Regular client: Scopes are openid, email, profile (user identity)
// - Machine client: Scopes are custom resource server scopes (API permissions)
//
// WHY IS IT NEEDED?
// The AgentCore Gateway needs to authenticate with Cognito to validate tokens and make
// API calls on behalf of the system. The machine client provides the credentials for
// this service-to-service authentication without requiring user interaction.
this.machineClient = new cognito.UserPoolClient(this, "MachineClient", {
userPool: this.userPool,
userPoolClientName: `${config.stack_name_base}-machine-client`,
generateSecret: true, // Required for client credentials flow
oAuth: {
flows: {
clientCredentials: true, // Enable OAuth2 Client Credentials flow
},
scopes: [
// Grant access to the resource server scopes defined above
cognito.OAuthScope.resourceServer(
resourceServer,
new cognito.ResourceServerScope({
scopeName: "read",
scopeDescription: "Read access to gateway",
}),
),
cognito.OAuthScope.resourceServer(
resourceServer,
new cognito.ResourceServerScope({
scopeName: "write",
scopeDescription: "Write access to gateway",
}),
),
],
},
});
// Machine client must be created after resource server
this.machineClient.node.addDependency(resourceServer);
// Store machine client secret in Secrets Manager for testing and external access.
// This secret is used by test scripts and potentially other external tools.
this.machineClientSecret = new secretsmanager.Secret(
this,
"MachineClientSecret",
{
secretName: `/${config.stack_name_base}/machine_client_secret`,
secretStringValue: cdk.SecretValue.unsafePlainText(
this.machineClient.userPoolClientSecret.unsafeUnwrap(),
),
description: "Machine Client Secret for M2M authentication",
},
);
}
/**
* Builds the RuntimeNetworkConfiguration based on the config.yaml settings.
* When network_mode is "VPC", imports the user's existing VPC, subnets, and
* optionally security groups, then returns a VPC-based network configuration.
* When network_mode is "PUBLIC" (default), returns a public network configuration.
*
* @param config - The application configuration from config.yaml.
* @returns A RuntimeNetworkConfiguration for the AgentCore Runtime.
*/
private buildNetworkConfiguration(
config: AppConfig,
): agentcore.RuntimeNetworkConfiguration {
if (config.backend.network_mode === "VPC") {
const vpcConfig = config.backend.vpc;
// vpc config is validated in ConfigManager, but guard here for type safety
if (!vpcConfig) {
throw new Error(
"backend.vpc configuration is required when network_mode is 'VPC'.",
);
}
// Import the user's existing VPC by ID.
// This performs a context lookup at synth time to resolve VPC attributes.
const vpc = ec2.Vpc.fromLookup(this, "ImportedVpc", {
vpcId: vpcConfig.vpc_id,
});
// Import the user-specified subnets by their IDs.
// These subnets must exist within the VPC specified above.
const subnets: ec2.ISubnet[] = vpcConfig.subnet_ids.map(
(subnetId: string, index: number) =>
ec2.Subnet.fromSubnetId(this, `ImportedSubnet${index}`, subnetId),
);
// Build the VPC config props for the AgentCore L2 construct.
// Security groups are optional — if not provided, the construct creates a default one.
const securityGroups =
vpcConfig.security_group_ids && vpcConfig.security_group_ids.length > 0
? vpcConfig.security_group_ids.map((sgId: string, index: number) =>
ec2.SecurityGroup.fromSecurityGroupId(
this,
`ImportedSG${index}`,
sgId,
),
)
: undefined;
const vpcConfigProps: agentcore.VpcConfigProps = {
vpc: vpc,
vpcSubnets: {
subnets: subnets,
},
securityGroups: securityGroups,
};
return agentcore.RuntimeNetworkConfiguration.usingVpc(
this,
vpcConfigProps,
);
}
// Default: public network mode
return agentcore.RuntimeNetworkConfiguration.usingPublicNetwork();
}
}
@@ -0,0 +1,143 @@
import * as cdk from "aws-cdk-lib";
import * as cognito from "aws-cdk-lib/aws-cognito";
import { Construct } from "constructs";
import { AppConfig } from "./utils/config-manager";
export interface CognitoStackProps extends cdk.NestedStackProps {
config: AppConfig;
callbackUrls?: string[];
}
export class CognitoStack extends cdk.NestedStack {
public userPoolId: string;
public userPoolClientId: string;
public userPoolDomain: cognito.UserPoolDomain;
constructor(scope: Construct, id: string, props: CognitoStackProps) {
super(scope, id, props);
this.createCognitoUserPool(props.config, props.callbackUrls);
}
private createCognitoUserPool(
config: AppConfig,
callbackUrls?: string[],
): void {
// Use provided callback URLs or defaults
const defaultCallbackUrls = [
"http://localhost:3000",
"https://localhost:3000",
];
const finalCallbackUrls = callbackUrls || defaultCallbackUrls;
const userPool = new cognito.UserPool(this, "UserPool", {
userPoolName: `${config.stack_name_base}-user-pool`,
selfSignUpEnabled: false,
signInAliases: {
email: true,
},
autoVerify: {
email: true,
},
standardAttributes: {
email: {
required: true,
mutable: false,
},
},
passwordPolicy: {
minLength: 8,
requireLowercase: true,
requireUppercase: true,
requireDigits: true,
requireSymbols: true,
},
accountRecovery: cognito.AccountRecovery.EMAIL_ONLY,
removalPolicy: cdk.RemovalPolicy.DESTROY,
userInvitation: {
emailSubject: `Welcome to ${config.stack_name_base}!`,
emailBody: `<p>Hello {username},</p>
<p>Welcome to ${config.stack_name_base}! Your username is <strong>{username}</strong> and your temporary password is: <strong>{####}</strong></p>
<p>Please use this temporary password to log in and set your permanent password.</p>
<p>The CloudFront URL to your application is stored as an output in the "${config.stack_name_base}" stack, and will be printed to your terminal once the deployment process completes.</p>
<p>Thanks,</p>
<p>Fullstack AgentCore Solution Template Team</p>`,
},
});
const userPoolClient = new cognito.UserPoolClient(this, "UserPoolClient", {
userPool: userPool,
userPoolClientName: `${config.stack_name_base}-client`,
generateSecret: false,
authFlows: {
userPassword: true,
userSrp: true,
},
oAuth: {
flows: {
authorizationCodeGrant: true,
},
scopes: [
cognito.OAuthScope.OPENID,
cognito.OAuthScope.EMAIL,
cognito.OAuthScope.PROFILE,
],
// Support both localhost development and production URLs
callbackUrls: finalCallbackUrls,
logoutUrls: finalCallbackUrls,
},
preventUserExistenceErrors: true,
});
this.userPoolDomain = new cognito.UserPoolDomain(this, "UserPoolDomain", {
userPool: userPool,
cognitoDomain: {
domainPrefix: `${config.stack_name_base.toLowerCase()}-${cdk.Aws.ACCOUNT_ID}-${
cdk.Aws.REGION
}`,
},
// Enable the newer managed login UI (v2) with the branding designer. Comment or remove this
// if you'd like to use the old classic UI.
managedLoginVersion: cognito.ManagedLoginVersion.NEWER_MANAGED_LOGIN,
});
// Create managed login branding with Cognito's default styles
// This is required for the v2 managed login to display properly
const managedLoginBranding = new cognito.CfnManagedLoginBranding(
this,
"ManagedLoginBranding",
{
userPoolId: userPool.userPoolId,
clientId: userPoolClient.userPoolClientId,
useCognitoProvidedValues: true,
},
);
managedLoginBranding.node.addDependency(this.userPoolDomain);
// Store the IDs for export
this.userPoolId = userPool.userPoolId;
this.userPoolClientId = userPoolClient.userPoolClientId;
// Create admin user if email is provided in config
if (config.admin_user_email) {
new cognito.CfnUserPoolUser(this, "AdminUser", {
userPoolId: userPool.userPoolId,
username: config.admin_user_email,
userAttributes: [
{
name: "email",
value: config.admin_user_email,
},
],
desiredDeliveryMediums: ["EMAIL"],
});
// Output admin user creation status
new cdk.CfnOutput(this, "AdminUserCreated", {
description: "Admin user created and credentials emailed",
value: `Admin user created: ${config.admin_user_email}`,
});
}
}
}
@@ -0,0 +1,105 @@
import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import { AppConfig } from "./utils/config-manager";
// Import nested stacks
import { BackendStack } from "./backend-stack";
import { AmplifyHostingStack } from "./amplify-hosting-stack";
import { CognitoStack } from "./cognito-stack";
export interface FastAmplifyStackProps extends cdk.StackProps {
config: AppConfig;
}
export class FastMainStack extends cdk.Stack {
public readonly amplifyHostingStack: AmplifyHostingStack;
public readonly backendStack: BackendStack;
public readonly cognitoStack: CognitoStack;
constructor(scope: Construct, id: string, props: FastAmplifyStackProps) {
const description =
"CopilotKit + AWS AgentCore Integration Example (uksb-v6dos0t5g8)";
super(scope, id, { ...props, description });
// Step 1: Create the Amplify stack to get the predictable domain
this.amplifyHostingStack = new AmplifyHostingStack(this, `${id}-amplify`, {
config: props.config,
});
this.cognitoStack = new CognitoStack(this, `${id}-cognito`, {
config: props.config,
callbackUrls: [
"http://localhost:3000",
this.amplifyHostingStack.amplifyUrl,
],
});
// Step 2: Create backend stack with the predictable Amplify URL and Cognito details
this.backendStack = new BackendStack(this, `${id}-backend`, {
config: props.config,
userPoolId: this.cognitoStack.userPoolId,
userPoolClientId: this.cognitoStack.userPoolClientId,
userPoolDomain: this.cognitoStack.userPoolDomain,
frontendUrl: this.amplifyHostingStack.amplifyUrl,
});
// Outputs
new cdk.CfnOutput(this, "AmplifyAppId", {
value: this.amplifyHostingStack.amplifyApp.appId,
description: "Amplify App ID - use this for manual deployment",
exportName: `${props.config.stack_name_base}-AmplifyAppId`,
});
new cdk.CfnOutput(this, "CognitoUserPoolId", {
value: this.cognitoStack.userPoolId,
description: "Cognito User Pool ID",
exportName: `${props.config.stack_name_base}-CognitoUserPoolId`,
});
new cdk.CfnOutput(this, "CognitoClientId", {
value: this.cognitoStack.userPoolClientId,
description: "Cognito User Pool Client ID",
exportName: `${props.config.stack_name_base}-CognitoClientId`,
});
new cdk.CfnOutput(this, "CognitoDomain", {
value: `${this.cognitoStack.userPoolDomain.domainName}.auth.${cdk.Aws.REGION}.amazoncognito.com`,
description: "Cognito Domain for OAuth",
exportName: `${props.config.stack_name_base}-CognitoDomain`,
});
new cdk.CfnOutput(this, "RuntimeArn", {
value: this.backendStack.runtimeArn,
description: "AgentCore Runtime ARN",
exportName: `${props.config.stack_name_base}-RuntimeArn`,
});
new cdk.CfnOutput(this, "MemoryArn", {
value: this.backendStack.memoryArn,
description: "AgentCore Memory ARN",
exportName: `${props.config.stack_name_base}-MemoryArn`,
});
new cdk.CfnOutput(this, "CopilotKitRuntimeUrl", {
value: this.backendStack.copilotKitRuntimeUrl,
description: "CopilotKit runtime API URL",
exportName: `${props.config.stack_name_base}-CopilotKitRuntimeUrl`,
});
new cdk.CfnOutput(this, "AmplifyConsoleUrl", {
value: `https://console.aws.amazon.com/amplify/apps/${this.amplifyHostingStack.amplifyApp.appId}`,
description: "Amplify Console URL for monitoring deployments",
});
new cdk.CfnOutput(this, "AmplifyUrl", {
value: this.amplifyHostingStack.amplifyUrl,
description: "Amplify Frontend URL (available after deployment)",
});
new cdk.CfnOutput(this, "StagingBucketName", {
value: this.amplifyHostingStack.stagingBucket.bucketName,
description: "S3 bucket for Amplify deployment staging",
exportName: `${props.config.stack_name_base}-StagingBucket`,
});
}
}
@@ -0,0 +1,109 @@
import * as cdk from "aws-cdk-lib";
import * as iam from "aws-cdk-lib/aws-iam";
import { Construct } from "constructs";
export interface AgentCoreRoleProps extends iam.RoleProps {
// Additional props can be added here if needed
}
export class AgentCoreRole extends iam.Role {
constructor(scope: Construct, id: string, props?: AgentCoreRoleProps) {
const stack = cdk.Stack.of(scope);
const region = stack.region;
const accountId = stack.account;
super(scope, id, {
assumedBy: new iam.ServicePrincipal("bedrock-agentcore.amazonaws.com"),
inlinePolicies: {
AgentCorePolicy: new iam.PolicyDocument({
statements: [
new iam.PolicyStatement({
sid: "ECRImageAccess",
effect: iam.Effect.ALLOW,
actions: [
"ecr:BatchGetImage",
"ecr:GetDownloadUrlForLayer",
"ecr:BatchCheckLayerAvailability",
],
resources: [`arn:aws:ecr:${region}:${accountId}:repository/*`],
}),
new iam.PolicyStatement({
sid: "ECRTokenAccess",
effect: iam.Effect.ALLOW,
actions: ["ecr:GetAuthorizationToken"],
resources: ["*"],
}),
new iam.PolicyStatement({
sid: "CloudWatchLogsGroupAccess",
effect: iam.Effect.ALLOW,
actions: ["logs:DescribeLogStreams", "logs:CreateLogGroup"],
resources: [
`arn:aws:logs:${region}:${accountId}:log-group:/aws/bedrock-agentcore/runtimes/*`,
],
}),
new iam.PolicyStatement({
sid: "CloudWatchLogsDescribeGroups",
effect: iam.Effect.ALLOW,
actions: ["logs:DescribeLogGroups"],
resources: [`arn:aws:logs:${region}:${accountId}:log-group:*`],
}),
new iam.PolicyStatement({
sid: "CloudWatchLogsStreamAccess",
effect: iam.Effect.ALLOW,
actions: ["logs:CreateLogStream", "logs:PutLogEvents"],
resources: [
`arn:aws:logs:${region}:${accountId}:log-group:/aws/bedrock-agentcore/runtimes/*:log-stream:*`,
],
}),
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: [
"xray:PutTraceSegments",
"xray:PutTelemetryRecords",
"xray:GetSamplingRules",
"xray:GetSamplingTargets",
],
resources: ["*"],
}),
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ["cloudwatch:PutMetricData"],
resources: ["*"],
conditions: {
StringEquals: {
"cloudwatch:namespace": "bedrock-agentcore",
},
},
}),
new iam.PolicyStatement({
sid: "GetAgentAccessToken",
effect: iam.Effect.ALLOW,
actions: [
"bedrock-agentcore:GetWorkloadAccessToken",
"bedrock-agentcore:GetWorkloadAccessTokenForJWT",
"bedrock-agentcore:GetWorkloadAccessTokenForUserId",
],
resources: [
`arn:aws:bedrock-agentcore:${region}:${accountId}:workload-identity-directory/default`,
`arn:aws:bedrock-agentcore:${region}:${accountId}:workload-identity-directory/default/workload-identity/*`,
],
}),
new iam.PolicyStatement({
sid: "BedrockModelInvocation",
effect: iam.Effect.ALLOW,
actions: [
"bedrock:InvokeModel",
"bedrock:InvokeModelWithResponseStream",
],
resources: [
"arn:aws:bedrock:*::foundation-model/*",
`arn:aws:bedrock:${region}:${accountId}:*`,
],
}),
],
}),
},
...props,
});
}
}
@@ -0,0 +1,144 @@
import * as fs from "fs";
import * as path from "path";
import * as yaml from "yaml";
const MAX_STACK_NAME_BASE_LENGTH = 35;
export type DeploymentType = "docker" | "zip";
/**
* Network mode for the AgentCore Runtime.
* - PUBLIC: Runtime is accessible over the public internet (default).
* - VPC: Runtime is deployed into a user-provided VPC for private network isolation.
*/
export type NetworkMode = "PUBLIC" | "VPC";
/**
* VPC configuration for deploying the AgentCore Runtime into an existing VPC.
* Required when network_mode is "VPC".
*/
export interface VpcConfig {
/** The ID of the existing VPC to deploy into (e.g. "vpc-0abc1234def56789a"). */
vpc_id: string;
/** List of subnet IDs within the VPC where the runtime will be placed. */
subnet_ids: string[];
/** Optional list of security group IDs. If omitted, a default security group is created. */
security_group_ids?: string[];
}
export interface AppConfig {
stack_name_base: string;
admin_user_email?: string | null;
backend: {
pattern: string;
deployment_type: DeploymentType;
/** Network mode for the AgentCore Runtime. Defaults to "PUBLIC". */
network_mode: NetworkMode;
/** VPC configuration. Required when network_mode is "VPC". */
vpc?: VpcConfig;
};
}
export class ConfigManager {
private config: AppConfig;
constructor(configFile: string) {
this.config = this._loadConfig(configFile);
}
private _loadConfig(configFile: string): AppConfig {
const configPath = path.join(__dirname, "..", "..", "..", configFile);
if (!fs.existsSync(configPath)) {
throw new Error(
`Configuration file ${configPath} does not exist. Please create config.yaml file.`,
);
}
try {
const fileContent = fs.readFileSync(configPath, "utf8");
const parsedConfig = yaml.parse(fileContent) as AppConfig;
const deploymentType = parsedConfig.backend?.deployment_type || "docker";
if (deploymentType !== "docker" && deploymentType !== "zip") {
throw new Error(
`Invalid deployment_type '${deploymentType}'. Must be 'docker' or 'zip'.`,
);
}
const stackNameBase = parsedConfig.stack_name_base;
if (!stackNameBase) {
throw new Error("stack_name_base is required in config.yaml");
}
if (stackNameBase.length > MAX_STACK_NAME_BASE_LENGTH) {
throw new Error(
`stack_name_base '${stackNameBase}' is too long (${stackNameBase.length} chars). ` +
`Maximum length is ${MAX_STACK_NAME_BASE_LENGTH} characters due to AWS AgentCore runtime naming constraints.`,
);
}
// Validate network_mode if provided
const networkMode = parsedConfig.backend?.network_mode || "PUBLIC";
if (networkMode !== "PUBLIC" && networkMode !== "VPC") {
throw new Error(
`Invalid network_mode '${networkMode}'. Must be 'PUBLIC' or 'VPC'.`,
);
}
// Validate VPC configuration when network_mode is VPC
const vpcConfig = parsedConfig.backend?.vpc;
if (networkMode === "VPC") {
if (!vpcConfig) {
throw new Error(
"backend.vpc configuration is required when network_mode is 'VPC'.",
);
}
if (!vpcConfig.vpc_id) {
throw new Error(
"backend.vpc.vpc_id is required when network_mode is 'VPC'.",
);
}
if (!vpcConfig.subnet_ids || vpcConfig.subnet_ids.length === 0) {
throw new Error(
"backend.vpc.subnet_ids must contain at least one subnet ID when network_mode is 'VPC'.",
);
}
}
return {
stack_name_base: stackNameBase,
admin_user_email: parsedConfig.admin_user_email || null,
backend: {
pattern: parsedConfig.backend?.pattern || "langgraph-single-agent",
deployment_type: deploymentType,
network_mode: networkMode,
vpc: vpcConfig,
},
};
} catch (error) {
throw new Error(
`Failed to parse configuration file ${configPath}: ${error}`,
{ cause: error },
);
}
}
public getProps(): AppConfig {
return this.config;
}
public get(key: string, defaultValue?: any): any {
const keys = key.split(".");
let value: any = this.config;
for (const k of keys) {
if (typeof value === "object" && value !== null && k in value) {
value = value[k];
} else {
return defaultValue;
}
}
return value;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,32 @@
{
"name": "fast-cdk",
"version": "0.4.0",
"bin": {
"fast-cdk": "bin/fast-cdk.js"
},
"scripts": {
"build": "tsc",
"watch": "tsc -w",
"test": "jest",
"cdk": "cdk"
},
"dependencies": {
"@aws-cdk/aws-amplify-alpha": "^2.215.0-alpha.0",
"@aws-cdk/aws-bedrock-agentcore-alpha": "^2.233.0-alpha.0",
"@aws-cdk/aws-bedrock-alpha": "^2.233.0-alpha.0",
"@aws-cdk/aws-lambda-python-alpha": "^2.233.0-alpha.0",
"@aws-sdk/client-bedrock-agentcore": "^3.987.0",
"aws-cdk-lib": "^2.240.0",
"constructs": "^10.0.0",
"yaml": "^2.3.4"
},
"devDependencies": {
"@types/jest": "^29.5.14",
"@types/node": "22.7.9",
"aws-cdk": "2.1031.0",
"jest": "^29.7.0",
"ts-jest": "^29.2.5",
"ts-node": "^10.9.2",
"typescript": "~5.6.3"
}
}
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["es2022"],
"declaration": true,
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noImplicitThis": true,
"alwaysStrict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": false,
"inlineSourceMap": true,
"inlineSources": true,
"experimentalDecorators": true,
"strictPropertyInitialization": false,
"skipLibCheck": true,
"typeRoots": ["./node_modules/@types"]
},
"exclude": ["node_modules", "cdk.out"]
}