From c37bc3d28d06004a9acd70408f69fa945e5f63f9 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Wed, 22 Jul 2026 16:26:26 -0400 Subject: [PATCH 1/2] 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/2] 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 {