From c37bc3d28d06004a9acd70408f69fa945e5f63f9 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Wed, 22 Jul 2026 16:26:26 -0400 Subject: [PATCH 1/5] feat(assets): add AssetManager and CDK scaffold templates Introduce the asset embedding + rendering subsystem: AssetManager renders asset trees from either Bun.embeddedFiles (compiled binary) or the source filesystem (dev/node), applying Handlebars templating with HTML escaping disabled and atomic writes. - src/fs/atomicWrite: central temp-file + rename write util - src/assetManager: AssetManager, types, barrel, tests, snapshot baseline - src/assets/cdk: CDK project scaffold templates (payload, not source) - tsconfig/bunfig/oxlint/prettier: exclude src/assets from all tooling - add handlebars dependency --- .oxlintrc.json | 2 +- .prettierignore | 3 + bun.lock | 13 + bunfig.toml | 5 +- package.json | 1 + src/assetManager/AssetManager.test.ts | 129 +++++++++ src/assetManager/AssetManager.ts | 109 ++++++++ .../__snapshots__/AssetManager.test.ts.snap | 17 ++ src/assetManager/index.ts | 2 + src/assetManager/types.ts | 13 + src/assets/cdk/.prettierrc | 8 + src/assets/cdk/README.md | 26 ++ src/assets/cdk/bin/cdk.ts | 192 ++++++++++++++ src/assets/cdk/cdk.json | 88 +++++++ src/assets/cdk/gitignore.template | 9 + src/assets/cdk/jest.config.js | 9 + src/assets/cdk/lib/cdk-stack.ts | 249 ++++++++++++++++++ src/assets/cdk/npmignore.template | 6 + src/assets/cdk/package.json | 30 +++ src/assets/cdk/test/cdk.test.ts | 31 +++ src/assets/cdk/tsconfig.json | 28 ++ src/fs/atomicWrite.test.ts | 47 ++++ src/fs/atomicWrite.ts | 14 + src/fs/index.ts | 1 + tsconfig.json | 3 +- 25 files changed, 1030 insertions(+), 5 deletions(-) create mode 100644 src/assetManager/AssetManager.test.ts create mode 100644 src/assetManager/AssetManager.ts create mode 100644 src/assetManager/__snapshots__/AssetManager.test.ts.snap create mode 100644 src/assetManager/index.ts create mode 100644 src/assetManager/types.ts create mode 100644 src/assets/cdk/.prettierrc create mode 100644 src/assets/cdk/README.md create mode 100644 src/assets/cdk/bin/cdk.ts create mode 100644 src/assets/cdk/cdk.json create mode 100644 src/assets/cdk/gitignore.template create mode 100644 src/assets/cdk/jest.config.js create mode 100644 src/assets/cdk/lib/cdk-stack.ts create mode 100644 src/assets/cdk/npmignore.template create mode 100644 src/assets/cdk/package.json create mode 100644 src/assets/cdk/test/cdk.test.ts create mode 100644 src/assets/cdk/tsconfig.json create mode 100644 src/fs/atomicWrite.test.ts create mode 100644 src/fs/atomicWrite.ts create mode 100644 src/fs/index.ts diff --git a/.oxlintrc.json b/.oxlintrc.json index b99c28331..0be6af4aa 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -4,6 +4,6 @@ "categories": { "correctness": "error" }, - "ignorePatterns": ["dist/", "node_modules/"], + "ignorePatterns": ["dist/", "node_modules/", "src/assets/"], "overrides": [] } diff --git a/.prettierignore b/.prettierignore index f66abac46..d32842846 100644 --- a/.prettierignore +++ b/.prettierignore @@ -10,3 +10,6 @@ bun.lock # rewrites the recorded content (e.g. collapsing arrays) and breaks the exact # comparison the golden tests rely on. Refresh them with RECORD=1 instead. __fixtures__ + +*.snap +src/assets diff --git a/bun.lock b/bun.lock index f001ff39c..f60868b8d 100644 --- a/bun.lock +++ b/bun.lock @@ -11,6 +11,7 @@ "@inkui-cli/data-table": "^0.2.0", "@tanstack/react-query": "^5.101.2", "commander": "^15.0.0", + "handlebars": "^4.7.9", "ink": "^7.1.0", "ink-scroll-view": "^0.3.7", "lodash": "^4.18.1", @@ -204,6 +205,8 @@ "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], + "handlebars": ["handlebars@4.7.9", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ=="], + "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], "indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], @@ -238,10 +241,14 @@ "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "moment": ["moment@2.30.1", "", {}, "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], + "object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="], "one-time": ["one-time@1.0.0", "", { "dependencies": { "fn.name": "1.x.x" } }, "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g=="], @@ -282,6 +289,8 @@ "slice-ansi": ["slice-ansi@9.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA=="], + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "stack-trace": ["stack-trace@0.0.10", "", {}, "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg=="], "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], @@ -310,6 +319,8 @@ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="], + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], @@ -322,6 +333,8 @@ "winston-transport": ["winston-transport@4.9.0", "", { "dependencies": { "logform": "^2.7.0", "readable-stream": "^3.6.2", "triple-beam": "^1.3.0" } }, "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A=="], + "wordwrap": ["wordwrap@1.0.0", "", {}, "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="], + "wrap-ansi": ["wrap-ansi@10.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "string-width": "^8.2.0", "strip-ansi": "^7.1.2" } }, "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ=="], "ws": ["ws@8.21.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw=="], diff --git a/bunfig.toml b/bunfig.toml index 62427b31e..1b46fe0f7 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -3,6 +3,5 @@ # frames are plain text (no ANSI color codes) regardless of whether stdout is a # TTY, keeping frame assertions deterministic across `bun test` and piped runs. preload = ["./src/testing/setup.ts"] -coveragePathIgnorePatterns = [ - "src/testing/**" -] +pathIgnorePatterns = ["src/assets/**"] +coveragePathIgnorePatterns = ["src/testing/**", "src/assets/**"] diff --git a/package.json b/package.json index 9a5e0d80e..2e91fb149 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "react-router": "^8.1.0", "winston": "^3.19.0", "winston-daily-rotate-file": "^5.0.0", + "handlebars": "^4.7.9", "zod": "^4.4.3" } } diff --git a/src/assetManager/AssetManager.test.ts b/src/assetManager/AssetManager.test.ts new file mode 100644 index 000000000..e969a4428 --- /dev/null +++ b/src/assetManager/AssetManager.test.ts @@ -0,0 +1,129 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, mkdir, readdir, rm } from "node:fs/promises"; +import { join, relative, resolve } from "node:path"; +import { tmpdir } from "node:os"; +import { AssetManager, resolveSourceRoot } from "./AssetManager"; + +const tempDirectories: string[] = []; + +async function makeTempDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), "agentcore-assets-")); + tempDirectories.push(directory); + return directory; +} + +afterEach(async () => { + await Promise.all( + tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +describe("AssetManager", () => { + test("renders a filesystem asset tree", async () => { + const root = await makeTempDirectory(); + const source = join(root, "assets", "cdk"); + const destination = join(root, "output"); + + await mkdir(join(source, "lib"), { recursive: true }); + await Bun.write(join(source, "README.md"), "Hello {{projectName}}"); + await Bun.write(join(source, ".prettierrc"), "{}"); + await Bun.write(join(source, "gitignore.template"), "dist/"); + await Bun.write(join(source, "lib", "stack.ts"), "export const name = '{{projectName}}';"); + + await new AssetManager([], join(root, "assets")).render("cdk", destination, { + projectName: "example", + }); + + expect(await Bun.file(join(destination, "README.md")).text()).toBe("Hello example"); + expect(await Bun.file(join(destination, ".prettierrc")).text()).toBe("{}"); + expect(await Bun.file(join(destination, ".gitignore")).text()).toBe("dist/"); + expect(await Bun.file(join(destination, "lib", "stack.ts")).text()).toBe( + "export const name = 'example';", + ); + }); + + test("renders embedded asset files", async () => { + const root = await makeTempDirectory(); + const destination = join(root, "output"); + const embedded = new File( + ["Hello {{projectName}}"], + "agentcore-assets/src/assets/cdk/README.md", + ); + + await new AssetManager([embedded]).render("cdk", destination, { + projectName: "embedded", + }); + + expect(await Bun.file(join(destination, "README.md")).text()).toBe("Hello embedded"); + }); + + test("does not HTML-escape code template values", async () => { + const root = await makeTempDirectory(); + const source = join(root, "assets", "cdk"); + const destination = join(root, "output"); + + await mkdir(source, { recursive: true }); + await Bun.write(join(source, "config.ts"), "export const expr = {{expr}};"); + + await new AssetManager([], join(root, "assets")).render("cdk", destination, { + expr: "a && b < c", + }); + + expect(await Bun.file(join(destination, "config.ts")).text()).toBe( + "export const expr = a && b < c;", + ); + }); + + test("rejects an embedded path that escapes the destination", async () => { + const root = await makeTempDirectory(); + const destination = join(root, "output"); + const embedded = new File( + ["pwned"], + "agentcore-assets/src/assets/cdk/../../../../../../etc/evil", + ); + + await expect(new AssetManager([embedded]).render("cdk", destination)).rejects.toThrow( + "Unsafe asset path", + ); + }); + + describe("resolveSourceRoot", () => { + test("returns the bundled root when assets/ sits beside the module", async () => { + const moduleDir = await makeTempDirectory(); + await mkdir(join(moduleDir, "assets"), { recursive: true }); + + expect(resolveSourceRoot(moduleDir)).toBe(resolve(moduleDir, "assets")); + }); + + test("falls back to ../assets when no sibling assets/ exists", async () => { + const moduleDir = await makeTempDirectory(); + + expect(resolveSourceRoot(moduleDir)).toBe(resolve(moduleDir, "../assets")); + }); + }); + + test("cdk renders the expected output tree", async () => { + // Real cdk asset, source-tree mode (no injected root, no embedded files). + // Snapshots the rendered manifest so adding/removing/renaming an asset file + // is a reviewable diff. Content is covered by the byte-for-byte tests above. + const destination = await makeTempDirectory(); + await new AssetManager().render("cdk", destination); + + const rendered = (await readdir(destination, { recursive: true, withFileTypes: true })) + .filter((entry) => entry.isFile()) + .map((entry) => + relative(destination, join(entry.parentPath, entry.name)).replaceAll("\\", "/"), + ) + .sort(); + + expect(rendered).toMatchSnapshot(); + }); + + test("rejects a missing asset", async () => { + const root = await makeTempDirectory(); + + await expect( + new AssetManager([], join(root, "assets")).render("cdk", join(root, "output")), + ).rejects.toThrow("Asset 'cdk' does not exist"); + }); +}); diff --git a/src/assetManager/AssetManager.ts b/src/assetManager/AssetManager.ts new file mode 100644 index 000000000..893a47973 --- /dev/null +++ b/src/assetManager/AssetManager.ts @@ -0,0 +1,109 @@ +import Handlebars from "handlebars"; +import { existsSync } from "node:fs"; +import { mkdir, readFile, readdir } from "node:fs/promises"; +import { basename, dirname, join, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import { atomicWrite } from "../fs"; +import type { AssetFile, AssetName, AssetVariables, EmbeddedFile } from "./types"; + +// Bundled builds place `assets/` beside the module; source layout has it one up. +// Directory-injected so it's testable without a real install layout. +export function resolveSourceRoot( + moduleDirectory = dirname(fileURLToPath(import.meta.url)), +): string { + const bundledRoot = resolve(moduleDirectory, "assets"); + if (existsSync(bundledRoot)) { + return bundledRoot; + } + + return resolve(moduleDirectory, "../assets"); +} + +export class AssetManager { + private handlebars = Handlebars.create(); + constructor( + private readonly embeddedFiles: readonly EmbeddedFile[] = [], + private readonly sourceRoot?: string, + ) {} + + async render( + asset: AssetName, + destination: string, + variables: AssetVariables = {}, + ): Promise { + const assetFiles = + this.embeddedFiles.length > 0 + ? this.listEmbeddedFiles(asset) + : await this.listFileSystemFiles(asset); + + if (assetFiles.length === 0) { + throw new Error(`Asset '${asset}' does not exist`); + } + + const resolvedDestination = resolve(destination); + for (const file of assetFiles) { + const outputPath = this.resolveOutputPath(resolvedDestination, file.relativePath); + const rendered = this.handlebars.compile(await file.text(), { noEscape: true })(variables); + + await mkdir(dirname(outputPath), { recursive: true }); + await atomicWrite(outputPath, rendered); + } + } + + private async listFileSystemFiles(asset: AssetName): Promise { + const assetRoot = join(this.sourceRoot ?? resolveSourceRoot(), asset); + + let entries; + try { + entries = await readdir(assetRoot, { recursive: true, withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return []; + } + throw error; + } + + return entries + .filter((entry) => entry.isFile()) + .map((entry) => relative(assetRoot, join(entry.parentPath, entry.name))) + .sort() + .map((relativePath) => ({ + relativePath, + text: () => readFile(join(assetRoot, relativePath), "utf8"), + })); + } + + private listEmbeddedFiles(asset: AssetName): AssetFile[] { + const prefix = `agentcore-assets/src/assets/${asset}/`; + + return this.embeddedFiles + .filter((file) => file.name.startsWith(prefix)) + .map((file) => ({ + relativePath: file.name.slice(prefix.length), + text: () => file.text(), + })) + .sort((a, b) => a.relativePath.localeCompare(b.relativePath)); + } + + private resolveOutputPath(resolvedDestination: string, relativePath: string): string { + const mapped = this.resolveTemplateName(relativePath).replaceAll("\\", "/"); + const segments = mapped.split("/"); + + if (mapped.startsWith("/") || segments.some((s) => s === "" || s === "." || s === "..")) { + throw new Error(`Unsafe asset path '${relativePath}'`); + } + + const outputPath = resolve(resolvedDestination, mapped); + if (outputPath !== resolvedDestination && !outputPath.startsWith(resolvedDestination + sep)) { + throw new Error(`Asset path '${relativePath}' escapes destination`); + } + + return outputPath; + } + + private resolveTemplateName(relativePath: string): string { + const filename = basename(relativePath); + const ignore = filename.match(/^(docker|git|npm)ignore\.template$/); + return join(dirname(relativePath), ignore ? `.${ignore[1]}ignore` : filename); + } +} diff --git a/src/assetManager/__snapshots__/AssetManager.test.ts.snap b/src/assetManager/__snapshots__/AssetManager.test.ts.snap new file mode 100644 index 000000000..0738c7ac1 --- /dev/null +++ b/src/assetManager/__snapshots__/AssetManager.test.ts.snap @@ -0,0 +1,17 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`AssetManager cdk renders the expected output tree 1`] = ` +[ + ".gitignore", + ".npmignore", + ".prettierrc", + "README.md", + "bin/cdk.ts", + "cdk.json", + "jest.config.js", + "lib/cdk-stack.ts", + "package.json", + "test/cdk.test.ts", + "tsconfig.json", +] +`; diff --git a/src/assetManager/index.ts b/src/assetManager/index.ts new file mode 100644 index 000000000..a2679a04b --- /dev/null +++ b/src/assetManager/index.ts @@ -0,0 +1,2 @@ +export { AssetManager } from "./AssetManager"; +export { ASSET_NAMES, type AssetName, type AssetVariables } from "./types"; diff --git a/src/assetManager/types.ts b/src/assetManager/types.ts new file mode 100644 index 000000000..a6310ae35 --- /dev/null +++ b/src/assetManager/types.ts @@ -0,0 +1,13 @@ +export const ASSET_NAMES = ["cdk"] as const; + +export type AssetName = (typeof ASSET_NAMES)[number]; + +export type AssetVariables = Record; +export interface AssetFile { + relativePath: string; + text(): Promise; +} +export interface EmbeddedFile { + readonly name: string; + text(): Promise; +} diff --git a/src/assets/cdk/.prettierrc b/src/assets/cdk/.prettierrc new file mode 100644 index 000000000..5563802ee --- /dev/null +++ b/src/assets/cdk/.prettierrc @@ -0,0 +1,8 @@ +{ + "trailingComma": "es5", + "printWidth": 120, + "tabWidth": 2, + "semi": true, + "singleQuote": true, + "arrowParens": "avoid" +} diff --git a/src/assets/cdk/README.md b/src/assets/cdk/README.md new file mode 100644 index 000000000..5fa522fc6 --- /dev/null +++ b/src/assets/cdk/README.md @@ -0,0 +1,26 @@ +# AgentCore CDK Project + +This CDK project is managed by the AgentCore CLI. It deploys your agent infrastructure into AWS using the `@aws/agentcore-cdk` L3 constructs. + +## Structure + +- `bin/cdk.ts` — Entry point. Reads project configuration from `agentcore/` and creates a stack per deployment target. +- `lib/cdk-stack.ts` — Defines `AgentCoreStack`, which wraps the `AgentCoreApplication` L3 construct. +- `test/cdk.test.ts` — Unit tests for stack synthesis. + +## Useful commands + +- `npm run build` compile TypeScript to JavaScript +- `npm run test` run unit tests +- `npx cdk synth` emit the synthesized CloudFormation template +- `npx cdk deploy` deploy this stack to your default AWS account/region +- `npx cdk diff` compare deployed stack with current state + +## Usage + +You typically don't need to interact with this directory directly. The AgentCore CLI handles synthesis and deployment: + +```bash +agentcore deploy # synthesizes and deploys via CDK +agentcore status # checks deployment status +``` diff --git a/src/assets/cdk/bin/cdk.ts b/src/assets/cdk/bin/cdk.ts new file mode 100644 index 000000000..b15b3281d --- /dev/null +++ b/src/assets/cdk/bin/cdk.ts @@ -0,0 +1,192 @@ +#!/usr/bin/env node +import { AgentCoreStack, type HarnessConfig } from '../lib/cdk-stack'; +import { ConfigIO, HarnessSpecSchema, type AwsDeploymentTarget } from '@aws/agentcore-cdk'; +import { App, type Environment } from 'aws-cdk-lib'; +import * as path from 'path'; +import * as fs from 'fs'; + +function toEnvironment(target: AwsDeploymentTarget): Environment { + return { + account: target.account, + region: target.region, + }; +} + +function sanitize(name: string): string { + return name.replace(/_/g, '-'); +} + +function toStackName(projectName: string, targetName: string): string { + return `AgentCore-${sanitize(projectName)}-${sanitize(targetName)}`; +} + +async function main() { + // Config root is parent of cdk/ directory. The CLI sets process.cwd() to agentcore/cdk/. + const configRoot = path.resolve(process.cwd(), '..'); + const configIO = new ConfigIO({ baseDir: configRoot }); + + const spec = await configIO.readProjectSpec(); + const targets = await configIO.readAWSDeploymentTargets(); + + // The vended CDK project compiles against the published @aws/agentcore-cdk + // schema type, which may lag the CLI's own AgentCoreProjectSpec (e.g. payments, + // harnesses, gateway fields). Cast once so those fields are reachable. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const specAny = spec as any; + + // Extract MCP configuration from project spec. + // Gateway fields are stored in agentcore.json but may not yet be on the + const mcpSpec = specAny.agentCoreGateways?.length + ? { + agentCoreGateways: specAny.agentCoreGateways, + mcpRuntimeTools: specAny.mcpRuntimeTools, + unassignedTargets: specAny.unassignedTargets, + } + : undefined; + + // Read deployed state for credential ARNs (populated by pre-deploy identity setup) + let deployedState: Record | undefined; + try { + deployedState = JSON.parse(fs.readFileSync(path.join(configRoot, '.cli', 'deployed-state.json'), 'utf8')); + } catch { + // Deployed state may not exist on first deploy + } + + if (targets.length === 0) { + throw new Error('No deployment targets configured. Please define targets in agentcore/aws-targets.json'); + } + + // Read harness configs: the full validated spec drives the CFN resource; the + // role-scoped fields drive the IAM role + container build. + const projectRoot = path.resolve(configRoot, '..'); + + // Read non-S3 KB connector-config files and pass their parsed contents to the + // L3 verbatim. The L3 does not read files; it expects the parsed + // connectorParameters keyed by the data source's connectorConfigFile path. + const connectorParametersByFile: Record> = {}; + for (const kb of specAny.knowledgeBases ?? []) { + for (const ds of kb.dataSources ?? []) { + if (ds.type !== 'S3' && ds.connectorConfigFile) { + const abs = path.resolve(projectRoot, ds.connectorConfigFile); + try { + connectorParametersByFile[ds.connectorConfigFile] = JSON.parse(fs.readFileSync(abs, 'utf-8')); + } catch (err) { + throw new Error( + `Could not read connector config '${ds.connectorConfigFile}' for knowledge base '${kb.name}' at ${abs}: ${err instanceof Error ? err.message : err}` + ); + } + } + } + } + + // Synthesize an AWS::BedrockAgentCore::Harness resource for each harness entry in the spec. + const harnessConfigs: HarnessConfig[] = []; + for (const entry of specAny.harnesses ?? []) { + const harnessDir = path.resolve(projectRoot, entry.path); + const harnessPath = path.resolve(harnessDir, 'harness.json'); + try { + const harnessSpec = HarnessSpecSchema.parse(JSON.parse(fs.readFileSync(harnessPath, 'utf-8'))); + harnessConfigs.push({ + name: entry.name, + executionRoleArn: harnessSpec.executionRoleArn, + // Only an `existing` memory ref carries a name to wire IAM against; managed memory is + // owned by the harness (no sibling) and disabled has none — both resolve to undefined. + memoryName: harnessSpec.memory?.mode === 'existing' ? harnessSpec.memory.name : undefined, + containerUri: harnessSpec.containerUri, + hasDockerfile: !!harnessSpec.dockerfile, + dockerfile: harnessSpec.dockerfile, + codeLocation: harnessSpec.dockerfile ? harnessDir : undefined, + tools: harnessSpec.tools, + skills: harnessSpec.skills, + apiKeyArn: harnessSpec.model?.apiKeyArn, + efsAccessPoints: harnessSpec.efsAccessPoints, + s3AccessPoints: harnessSpec.s3AccessPoints, + apiFormat: harnessSpec.model?.apiFormat, + // Full spec + dir drive the AWS::BedrockAgentCore::Harness CFN resource. + spec: harnessSpec, + harnessDir, + }); + } catch (err) { + throw new Error( + `Could not read harness.json for "${entry.name}" at ${harnessPath}: ${err instanceof Error ? err.message : err}` + ); + } + } + + const app = new App(); + + for (const target of targets) { + const env = toEnvironment(target); + const stackName = toStackName(spec.name, target.name); + + // Extract credentials from deployed state for this target + const targetState = (deployedState as Record)?.targets as + | Record> + | undefined; + const targetResources = targetState?.[target.name]?.resources as Record | undefined; + const credentials = targetResources?.credentials as + | Record + | 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; + + new AgentCoreStack(app, stackName, { + spec, + mcpSpec, + credentials, + connectorParametersByFile, + harnesses: harnessConfigs.length > 0 ? harnessConfigs : undefined, + paymentSpec, + env, + description: `AgentCore stack for ${spec.name} deployed to ${target.name} (${target.region})`, + tags: { + 'agentcore:project-name': spec.name, + 'agentcore:target-name': target.name, + }, + }); + } + + app.synth(); +} + +main().catch((error: unknown) => { + console.error('AgentCore CDK synthesis failed:', error instanceof Error ? error.message : error); + process.exit(1); +}); diff --git a/src/assets/cdk/cdk.json b/src/assets/cdk/cdk.json new file mode 100644 index 000000000..19e6983ab --- /dev/null +++ b/src/assets/cdk/cdk.json @@ -0,0 +1,88 @@ +{ + "app": "node dist/bin/cdk.js", + "watch": { + "include": ["**"], + "exclude": ["README.md", "cdk*.json", "tsconfig.json", "package*.json", "yarn.lock", "node_modules", "dist", "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-us-gov"], + "@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": true, + "@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, + "@aws-cdk/aws-elasticloadbalancingv2:networkLoadBalancerWithSecurityGroupByDefault": true, + "@aws-cdk/aws-ecs-patterns:uniqueTargetGroupId": true + } +} diff --git a/src/assets/cdk/gitignore.template b/src/assets/cdk/gitignore.template new file mode 100644 index 000000000..964b4d89c --- /dev/null +++ b/src/assets/cdk/gitignore.template @@ -0,0 +1,9 @@ +# Build output +dist/ + +# Dependencies +node_modules/ + +# CDK asset staging directory +.cdk.staging +cdk.out diff --git a/src/assets/cdk/jest.config.js b/src/assets/cdk/jest.config.js new file mode 100644 index 000000000..0077a6547 --- /dev/null +++ b/src/assets/cdk/jest.config.js @@ -0,0 +1,9 @@ +module.exports = { + testEnvironment: 'node', + roots: ['/test'], + testMatch: ['**/*.test.ts'], + transform: { + '^.+\\.tsx?$': 'ts-jest', + }, + setupFilesAfterEnv: ['aws-cdk-lib/testhelpers/jest-autoclean'], +}; diff --git a/src/assets/cdk/lib/cdk-stack.ts b/src/assets/cdk/lib/cdk-stack.ts new file mode 100644 index 000000000..3dac0669d --- /dev/null +++ b/src/assets/cdk/lib/cdk-stack.ts @@ -0,0 +1,249 @@ +import { + AgentCoreApplication, + AgentCoreMcp, + AgentCorePaymentManager, + AgentCorePaymentConnector, + 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'; + +/** + * Harness deployment config: role-scoped fields (for IAM role + container build) + * plus the full validated spec + its config directory so the L3 construct can + * synthesize the AWS::BedrockAgentCore::Harness resource. + */ +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. + */ + spec: AgentCoreProjectSpec; + /** + * The MCP specification containing gateways and servers. + */ + mcpSpec?: AgentCoreMcpSpec; + /** + * Credential provider ARNs from deployed state, keyed by credential name. + */ + credentials?: Record; + /** + * Harness role configurations. + */ + harnesses?: HarnessConfig[]; + /** + * Parsed connectorParameters for non-S3 KB data sources, keyed by + * connectorConfigFile path. Forwarded to AgentCoreApplication. + */ + connectorParametersByFile?: Record>; + /** + * 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'); +} + +/** + * CDK Stack that deploys AgentCore infrastructure. + * + * This is a thin wrapper that instantiates L3 constructs. + * All resource logic and outputs are contained within the L3 constructs. + */ +export class AgentCoreStack extends Stack { + /** The AgentCore application containing all agent environments */ + public readonly application: AgentCoreApplication; + + constructor(scope: Construct, id: string, props: AgentCoreStackProps) { + super(scope, id, props); + + const { spec, mcpSpec, credentials, harnesses, connectorParametersByFile, paymentSpec } = props; + + // Create AgentCoreApplication with all agents and harness roles + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const appProps: Record = { spec }; + if (harnesses?.length) { + appProps.harnesses = harnesses; + } + if (connectorParametersByFile && Object.keys(connectorParametersByFile).length > 0) { + appProps.connectorParametersByFile = connectorParametersByFile; + } + if (credentials) { + appProps.credentials = credentials; + } + this.application = new AgentCoreApplication(this, 'Application', appProps as any); + + // Create AgentCoreMcp if there are gateways configured + if (mcpSpec?.agentCoreGateways && mcpSpec.agentCoreGateways.length > 0) { + new AgentCoreMcp(this, 'Mcp', { + projectName: spec.name, + mcpSpec, + agentCoreApplication: this.application, + credentials, + projectTags: spec.tags, + }); + } + + // 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', + value: this.stackName, + }); + } +} diff --git a/src/assets/cdk/npmignore.template b/src/assets/cdk/npmignore.template new file mode 100644 index 000000000..c1d6d45dc --- /dev/null +++ b/src/assets/cdk/npmignore.template @@ -0,0 +1,6 @@ +*.ts +!*.d.ts + +# CDK asset staging directory +.cdk.staging +cdk.out diff --git a/src/assets/cdk/package.json b/src/assets/cdk/package.json new file mode 100644 index 000000000..4646bfb3e --- /dev/null +++ b/src/assets/cdk/package.json @@ -0,0 +1,30 @@ +{ + "name": "agentcore-cdk-app", + "version": "0.1.0", + "bin": { + "cdk": "dist/bin/cdk.js" + }, + "scripts": { + "build": "tsc", + "watch": "tsc -w", + "test": "jest", + "cdk": "npm run build && cdk", + "clean": "rm -rf dist", + "format": "prettier --write .", + "format:check": "prettier --check ." + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "@types/node": "^24.10.1", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "aws-cdk": "2.1126.0", + "prettier": "^3.4.2", + "typescript": "~5.9.3" + }, + "dependencies": { + "@aws/agentcore-cdk": "^0.1.0-alpha.19", + "aws-cdk-lib": "^2.248.0", + "constructs": "^10.0.0" + } +} diff --git a/src/assets/cdk/test/cdk.test.ts b/src/assets/cdk/test/cdk.test.ts new file mode 100644 index 000000000..8db318ada --- /dev/null +++ b/src/assets/cdk/test/cdk.test.ts @@ -0,0 +1,31 @@ +import * as cdk from 'aws-cdk-lib'; +import { Template } from 'aws-cdk-lib/assertions'; +import { AgentCoreStack } from '../lib/cdk-stack'; + +test('AgentCoreStack synthesizes with empty spec', () => { + const app = new cdk.App(); + const stack = new AgentCoreStack(app, 'TestStack', { + spec: { + name: 'testproject', + version: 1, + managedBy: 'CDK' as const, + runtimes: [], + memories: [], + credentials: [], + evaluators: [], + onlineEvalConfigs: [], + configBundles: [], + policyEngines: [], + payments: [], + agentCoreGateways: [], + mcpRuntimeTools: [], + unassignedTargets: [], + datasets: [], + knowledgeBases: [], + }, + }); + const template = Template.fromStack(stack); + template.hasOutput('StackNameOutput', { + Description: 'Name of the CloudFormation Stack', + }); +}); diff --git a/src/assets/cdk/tsconfig.json b/src/assets/cdk/tsconfig.json new file mode 100644 index 000000000..c70b0d444 --- /dev/null +++ b/src/assets/cdk/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "Node", + "lib": ["es2022"], + "declaration": true, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noImplicitThis": true, + "alwaysStrict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "inlineSourceMap": true, + "inlineSources": true, + "experimentalDecorators": true, + "strictPropertyInitialization": true, + "skipLibCheck": true, + "typeRoots": ["./node_modules/@types"], + "rootDir": ".", + "outDir": "dist" + }, + "include": ["bin/**/*", "lib/**/*", "test/**/*"], + "exclude": ["node_modules", "cdk.out", "dist"] +} diff --git a/src/fs/atomicWrite.test.ts b/src/fs/atomicWrite.test.ts new file mode 100644 index 000000000..391e4311b --- /dev/null +++ b/src/fs/atomicWrite.test.ts @@ -0,0 +1,47 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { atomicWrite } from "./atomicWrite"; + +const dirs: string[] = []; +afterEach(async () => { + await Promise.all(dirs.splice(0).map((d) => rm(d, { recursive: true, force: true }))); +}); + +async function tempDir(): Promise { + const d = await mkdtemp(join(tmpdir(), "atomic-write-")); + dirs.push(d); + return d; +} + +test("writes contents and leaves no temp file", async () => { + const dir = await tempDir(); + const target = join(dir, "out.txt"); + + await atomicWrite(target, "hello"); + + expect(await Bun.file(target).text()).toBe("hello"); + expect(await readdir(dir)).toEqual(["out.txt"]); +}); + +test("overwrites an existing file", async () => { + const dir = await tempDir(); + const target = join(dir, "out.txt"); + await writeFile(target, "old"); + + await atomicWrite(target, "new"); + + expect(await Bun.file(target).text()).toBe("new"); + expect(await readdir(dir)).toEqual(["out.txt"]); +}); + +test("cleans up the temp file when rename fails", async () => { + const dir = await tempDir(); + // Target path is a directory → rename onto it fails, exercising the catch. + const target = join(dir, "adir"); + await Bun.write(join(target, "keep"), "x"); + + await expect(atomicWrite(target, "data")).rejects.toThrow(); + expect(await readdir(dir)).toEqual(["adir"]); +}); diff --git a/src/fs/atomicWrite.ts b/src/fs/atomicWrite.ts new file mode 100644 index 000000000..68abdef90 --- /dev/null +++ b/src/fs/atomicWrite.ts @@ -0,0 +1,14 @@ +import { randomUUID } from "node:crypto"; +import { rename, rm, writeFile } from "node:fs/promises"; +import { basename, dirname, join } from "node:path"; + +export async function atomicWrite(path: string, contents: string | Uint8Array): Promise { + const tempPath = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`); + try { + await writeFile(tempPath, contents); + await rename(tempPath, path); + } catch (error) { + await rm(tempPath, { force: true }); + throw error; + } +} diff --git a/src/fs/index.ts b/src/fs/index.ts new file mode 100644 index 000000000..5f8d9bd8d --- /dev/null +++ b/src/fs/index.ts @@ -0,0 +1 @@ +export { atomicWrite } from "./atomicWrite"; diff --git a/tsconfig.json b/tsconfig.json index b2e7497d8..bffe35ab3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,5 +26,6 @@ "noUnusedLocals": false, "noUnusedParameters": false, "noPropertyAccessFromIndexSignature": false - } + }, + "exclude": ["src/assets"] } From 356105c5fe80b2090e6a72a8b03a64b44cff29d2 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath <42380254+tejaskash@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:56:47 -0400 Subject: [PATCH 2/5] Remove comment Remove comment about directory injection for testability. --- src/assetManager/AssetManager.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/assetManager/AssetManager.ts b/src/assetManager/AssetManager.ts index 893a47973..420e905ae 100644 --- a/src/assetManager/AssetManager.ts +++ b/src/assetManager/AssetManager.ts @@ -7,7 +7,6 @@ import { atomicWrite } from "../fs"; import type { AssetFile, AssetName, AssetVariables, EmbeddedFile } from "./types"; // Bundled builds place `assets/` beside the module; source layout has it one up. -// Directory-injected so it's testable without a real install layout. export function resolveSourceRoot( moduleDirectory = dirname(fileURLToPath(import.meta.url)), ): string { From f7ea873420011c13eddf5eb2af6b3c99d17e84c0 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Fri, 24 Jul 2026 10:38:12 -0400 Subject: [PATCH 3/5] fix(assets): strict template rendering and deterministic sort Address review feedback on the AssetManager: - Handlebars strict mode: a template referencing an undefined variable now throws instead of silently rendering an empty string into a generated project file. - drop the speculative `docker` branch from the ignore-template rename; only git/npm ignores ship as assets today. - use code-unit ordering for embedded files so both list paths (embedded and filesystem) sort identically and deterministically. --- src/assetManager/AssetManager.test.ts | 13 +++++++++++++ src/assetManager/AssetManager.ts | 9 ++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/assetManager/AssetManager.test.ts b/src/assetManager/AssetManager.test.ts index e969a4428..da74b262a 100644 --- a/src/assetManager/AssetManager.test.ts +++ b/src/assetManager/AssetManager.test.ts @@ -74,6 +74,19 @@ describe("AssetManager", () => { ); }); + test("throws on a template referencing an undefined variable", async () => { + const root = await makeTempDirectory(); + const source = join(root, "assets", "cdk"); + const destination = join(root, "output"); + + await mkdir(source, { recursive: true }); + await Bun.write(join(source, "README.md"), "Hello {{projectName}}"); + + await expect( + new AssetManager([], join(root, "assets")).render("cdk", destination), + ).rejects.toThrow(/projectName/); + }); + test("rejects an embedded path that escapes the destination", async () => { const root = await makeTempDirectory(); const destination = join(root, "output"); diff --git a/src/assetManager/AssetManager.ts b/src/assetManager/AssetManager.ts index 420e905ae..02c1a4402 100644 --- a/src/assetManager/AssetManager.ts +++ b/src/assetManager/AssetManager.ts @@ -42,7 +42,10 @@ export class AssetManager { const resolvedDestination = resolve(destination); for (const file of assetFiles) { const outputPath = this.resolveOutputPath(resolvedDestination, file.relativePath); - const rendered = this.handlebars.compile(await file.text(), { noEscape: true })(variables); + const rendered = this.handlebars.compile(await file.text(), { + noEscape: true, + strict: true, + })(variables); await mkdir(dirname(outputPath), { recursive: true }); await atomicWrite(outputPath, rendered); @@ -81,7 +84,7 @@ export class AssetManager { relativePath: file.name.slice(prefix.length), text: () => file.text(), })) - .sort((a, b) => a.relativePath.localeCompare(b.relativePath)); + .sort((a, b) => (a.relativePath < b.relativePath ? -1 : 1)); } private resolveOutputPath(resolvedDestination: string, relativePath: string): string { @@ -102,7 +105,7 @@ export class AssetManager { private resolveTemplateName(relativePath: string): string { const filename = basename(relativePath); - const ignore = filename.match(/^(docker|git|npm)ignore\.template$/); + const ignore = filename.match(/^(git|npm)ignore\.template$/); return join(dirname(relativePath), ignore ? `.${ignore[1]}ignore` : filename); } } From 8efb2853d06409a1776f409118555af1a45326c2 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Fri, 24 Jul 2026 11:18:27 -0400 Subject: [PATCH 4/5] refactor(assets): rename to kebab-case, doc types, sync cdk pins Address review feedback from @Hweinstock: - rename AssetManager.ts -> manager.ts (only non-React .ts in the repo with an uppercase name; feature dirs use role-named lowercase files like core/project/manager.tsx). - document AssetFile vs EmbeddedFile so the distinction between a resolved tree entry and a raw Bun.embeddedFiles blob is explicit. - sync src/assets/cdk/package.json to the minor-version pins landed in #1777 (aws-cdk-lib ~2.261.0, @aws/agentcore-cdk 0.1.0-alpha.45, etc.). --- ...nager.test.ts.snap => manager.test.ts.snap} | 0 src/assetManager/index.ts | 2 +- .../{AssetManager.test.ts => manager.test.ts} | 2 +- .../{AssetManager.ts => manager.ts} | 0 src/assetManager/types.ts | 13 +++++++++++++ src/assets/cdk/package.json | 18 +++++++++--------- 6 files changed, 24 insertions(+), 11 deletions(-) rename src/assetManager/__snapshots__/{AssetManager.test.ts.snap => manager.test.ts.snap} (100%) rename src/assetManager/{AssetManager.test.ts => manager.test.ts} (98%) rename src/assetManager/{AssetManager.ts => manager.ts} (100%) diff --git a/src/assetManager/__snapshots__/AssetManager.test.ts.snap b/src/assetManager/__snapshots__/manager.test.ts.snap similarity index 100% rename from src/assetManager/__snapshots__/AssetManager.test.ts.snap rename to src/assetManager/__snapshots__/manager.test.ts.snap diff --git a/src/assetManager/index.ts b/src/assetManager/index.ts index a2679a04b..fa83cb979 100644 --- a/src/assetManager/index.ts +++ b/src/assetManager/index.ts @@ -1,2 +1,2 @@ -export { AssetManager } from "./AssetManager"; +export { AssetManager } from "./manager"; export { ASSET_NAMES, type AssetName, type AssetVariables } from "./types"; diff --git a/src/assetManager/AssetManager.test.ts b/src/assetManager/manager.test.ts similarity index 98% rename from src/assetManager/AssetManager.test.ts rename to src/assetManager/manager.test.ts index da74b262a..19fa97009 100644 --- a/src/assetManager/AssetManager.test.ts +++ b/src/assetManager/manager.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { mkdtemp, mkdir, readdir, rm } from "node:fs/promises"; import { join, relative, resolve } from "node:path"; import { tmpdir } from "node:os"; -import { AssetManager, resolveSourceRoot } from "./AssetManager"; +import { AssetManager, resolveSourceRoot } from "./manager"; const tempDirectories: string[] = []; diff --git a/src/assetManager/AssetManager.ts b/src/assetManager/manager.ts similarity index 100% rename from src/assetManager/AssetManager.ts rename to src/assetManager/manager.ts diff --git a/src/assetManager/types.ts b/src/assetManager/types.ts index a6310ae35..2351beb7f 100644 --- a/src/assetManager/types.ts +++ b/src/assetManager/types.ts @@ -3,10 +3,23 @@ export const ASSET_NAMES = ["cdk"] as const; export type AssetName = (typeof ASSET_NAMES)[number]; export type AssetVariables = Record; + +/** + * A single asset resolved to its position within an asset tree, ready to + * render. `relativePath` is relative to the asset root (e.g. `bin/cdk.ts`), + * regardless of whether the bytes came from disk or an embedded blob. + */ export interface AssetFile { relativePath: string; text(): Promise; } + +/** + * A raw file embedded in the compiled binary, as exposed by `Bun.embeddedFiles`. + * `name` is the full build-time virtual path (e.g. + * `agentcore-assets/src/assets/cdk/bin/cdk.ts`), which the manager strips down + * to an `AssetFile.relativePath`. + */ export interface EmbeddedFile { readonly name: string; text(): Promise; diff --git a/src/assets/cdk/package.json b/src/assets/cdk/package.json index 4646bfb3e..550a52797 100644 --- a/src/assets/cdk/package.json +++ b/src/assets/cdk/package.json @@ -14,17 +14,17 @@ "format:check": "prettier --check ." }, "devDependencies": { - "@types/jest": "^29.5.14", - "@types/node": "^24.10.1", - "jest": "^29.7.0", - "ts-jest": "^29.2.5", - "aws-cdk": "2.1126.0", - "prettier": "^3.4.2", + "@types/jest": "~29.5.14", + "@types/node": "~24.13.3", + "jest": "~29.7.0", + "ts-jest": "~29.4.11", + "aws-cdk": "~2.1126.0", + "prettier": "~3.9.5", "typescript": "~5.9.3" }, "dependencies": { - "@aws/agentcore-cdk": "^0.1.0-alpha.19", - "aws-cdk-lib": "^2.248.0", - "constructs": "^10.0.0" + "@aws/agentcore-cdk": "0.1.0-alpha.45", + "aws-cdk-lib": "~2.261.0", + "constructs": "~10.7.0" } } From 2e060dacb69e9a3153296b68f4157dd71e34ae66 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Fri, 24 Jul 2026 11:23:17 -0400 Subject: [PATCH 5/5] docs(assets): note cdk README commands are provisional Add a source-only TODO (HTML comment, invisible in the rendered README shipped to generated projects) flagging that the agentcore deploy/status commands may need a project prefix once the project CLI surface is final. --- src/assets/cdk/README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/assets/cdk/README.md b/src/assets/cdk/README.md index 5fa522fc6..be548731f 100644 --- a/src/assets/cdk/README.md +++ b/src/assets/cdk/README.md @@ -20,6 +20,9 @@ This CDK project is managed by the AgentCore CLI. It deploys your agent infrastr You typically don't need to interact with this directory directly. The AgentCore CLI handles synthesis and deployment: + + ```bash agentcore deploy # synthesizes and deploys via CDK agentcore status # checks deployment status