diff --git a/.gitignore b/.gitignore index 3da67f960..89cc7556b 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,5 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json # Finder (MacOS) folder config .DS_Store + +.agentreview 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 4be453a15..06a5cff65 100644 --- a/bun.lock +++ b/bun.lock @@ -12,6 +12,7 @@ "@smithy/core": "3.29.3", "@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", @@ -205,6 +206,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=="], @@ -239,10 +242,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=="], @@ -283,6 +290,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=="], @@ -311,6 +320,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=="], @@ -323,6 +334,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 63625847d..caca70296 100644 --- a/package.json +++ b/package.json @@ -11,14 +11,14 @@ "dist" ], "scripts": { - "build": "bun build ./src/index.ts --target node --outdir ./dist --minify", + "build": "bun scripts/build.ts bundle", "compile": "bun run compile:darwin-x64 && bun run compile:darwin-arm64 && bun run compile:linux-x64 && bun run compile:linux-arm64 && bun run compile:windows-x64 && bun run compile:windows-arm64", - "compile:darwin-x64": "bun build --compile --minify --target=bun-darwin-x64 ./src/index.ts --outfile dist/bin/agentcore-darwin-x64", - "compile:darwin-arm64": "bun build --compile --minify --target=bun-darwin-arm64 ./src/index.ts --outfile dist/bin/agentcore-darwin-arm64", - "compile:linux-x64": "bun build --compile --minify --target=bun-linux-x64 ./src/index.ts --outfile dist/bin/agentcore-linux-x64", - "compile:linux-arm64": "bun build --compile --minify --target=bun-linux-arm64 ./src/index.ts --outfile dist/bin/agentcore-linux-arm64", - "compile:windows-x64": "bun build --compile --minify --target=bun-windows-x64 ./src/index.ts --outfile dist/bin/agentcore-windows-x64", - "compile:windows-arm64": "bun build --compile --minify --target=bun-windows-arm64 ./src/index.ts --outfile dist/bin/agentcore-windows-arm64", + "compile:darwin-x64": "bun scripts/build.ts compile bun-darwin-x64", + "compile:darwin-arm64": "bun scripts/build.ts compile bun-darwin-arm64", + "compile:linux-x64": "bun scripts/build.ts compile bun-linux-x64", + "compile:linux-arm64": "bun scripts/build.ts compile bun-linux-arm64", + "compile:windows-x64": "bun scripts/build.ts compile bun-windows-x64", + "compile:windows-arm64": "bun scripts/build.ts compile bun-windows-arm64", "start": "bun run src/index.ts", "test": "bun test", "typecheck": "tsc --noEmit", @@ -64,6 +64,7 @@ "react-router": "^8.3.0", "winston": "^3.19.0", "winston-daily-rotate-file": "^5.0.0", + "handlebars": "^4.7.9", "zod": "^4.4.3" } } diff --git a/scripts/build.ts b/scripts/build.ts new file mode 100644 index 000000000..ea2b316ec --- /dev/null +++ b/scripts/build.ts @@ -0,0 +1,99 @@ +#!/usr/bin/env bun + +import { $ } from "bun"; +import { join, resolve } from "node:path"; +import { runWithExitCode } from "../src/runnable"; + +const REPO_ROOT = resolve(import.meta.dir, ".."); +const ASSETS_DIR = join(REPO_ROOT, "src", "assets"); +const ENTRYPOINT = join(REPO_ROOT, "src", "index.ts"); +const DIST = join(REPO_ROOT, "dist"); + +const ASSET_NAMING = "agentcore-assets/[dir]/[name].[ext]"; + +// Shrink whitespace/syntax but keep identifiers: minified names make stack +// traces unreadable and erase error names telemetry keys on. +const MINIFY = { whitespace: true, syntax: true, identifiers: false } as const; + +/** Absolute paths of every asset file. dot:true so hidden files (.prettierrc) are included. */ +function discoverAssets(): string[] { + const files = [...new Bun.Glob("**/*").scanSync({ cwd: ASSETS_DIR, onlyFiles: true, dot: true })]; + return files.sort().map((relativePath) => join(ASSETS_DIR, relativePath)); +} + +/** Force asset files through the file loader so template .ts/.js are embedded as bytes, not compiled. */ +function assetLoaderPlugin(): Bun.BunPlugin { + return { + name: "asset-file-loader", + setup(build) { + build.onLoad({ filter: /src[/\\]assets[/\\]/ }, async ({ path }) => ({ + contents: await Bun.file(path).bytes(), + loader: "file", + })); + }, + }; +} + +/** Fail loudly on a non-UTF-8 asset — the source reads every asset as text. */ +async function assertAssetsAreText(assets: string[]): Promise { + const decoder = new TextDecoder("utf-8", { fatal: true }); + for (const path of assets) { + try { + decoder.decode(await Bun.file(path).bytes()); + } catch { + throw new Error(`Asset is not valid UTF-8: ${path}`); + } + } +} + +// Bun.build rejects with an AggregateError on failure (throw defaults to true), +// so build errors propagate to runWithExitCode like any other. +async function bundle(): Promise { + await Bun.build({ + entrypoints: [ENTRYPOINT], + outdir: DIST, + target: "node", + minify: MINIFY, + }); + + // Mirror assets beside the emitted module for resolveAssetsRoot(). + const distAssets = join(DIST, "assets"); + await $`rm -rf ${distAssets}`; + await $`cp -R ${ASSETS_DIR} ${distAssets}`; + console.log(`Bundled to ${join(DIST, "index.js")} with assets/`); +} + +async function compile(target: string): Promise { + const assets = discoverAssets(); + await assertAssetsAreText(assets); + + const outfile = join(DIST, "bin", `agentcore-${target.replace(/^bun-/, "")}`); + await $`mkdir -p ${join(DIST, "bin")}`; + + await Bun.build({ + entrypoints: [ENTRYPOINT, ...assets], + compile: { target: target as Bun.Build.CompileTarget, outfile }, + minify: MINIFY, + root: REPO_ROOT, + naming: { asset: ASSET_NAMING }, + plugins: [assetLoaderPlugin()], + }); + console.log(`Compiled ${target} → ${outfile} (${assets.length} assets embedded)`); +} + +process.exit( + await runWithExitCode(async () => { + const [command, target] = process.argv.slice(2); + + if (command === "bundle") { + await bundle(); + } else if (command === "compile") { + if (!target) { + throw new Error("Usage: bun scripts/build.ts compile "); + } + await compile(target); + } else { + throw new Error("Usage: bun scripts/build.ts >"); + } + }), +); 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..be548731f --- /dev/null +++ b/src/assets/cdk/README.md @@ -0,0 +1,29 @@ +# 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..c8450e3a9 --- /dev/null +++ b/src/assets/cdk/bin/cdk.ts @@ -0,0 +1,208 @@ +#!/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)}`; +} + +// 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). This alias documents each read of those not-yet-published fields. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type SpecWithLatestFields = any; + +// Extract MCP configuration from the project spec. Gateway fields are stored in +// agentcore.json but may not yet be on the published spec type, so they are read +// off the loosened alias. +function resolveMcpSpec(spec: SpecWithLatestFields) { + return spec.agentCoreGateways?.length + ? { + agentCoreGateways: spec.agentCoreGateways, + mcpRuntimeTools: spec.mcpRuntimeTools, + unassignedTargets: spec.unassignedTargets, + } + : undefined; +} + +// Read non-S3 KB connector-config files and return their parsed contents keyed by +// the data source's connectorConfigFile path. The L3 does not read files; it +// expects these parsed connectorParameters verbatim. +function resolveConnectorParametersByFile( + spec: SpecWithLatestFields, + projectRoot: string +): Record> { + const connectorParametersByFile: Record> = {}; + for (const kb of spec.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}` + ); + } + } + } + } + return connectorParametersByFile; +} + +// Synthesize a HarnessConfig for each harness entry in the spec. The full validated +// spec drives the AWS::BedrockAgentCore::Harness CFN resource; the role-scoped +// fields drive the IAM role + container build. +function resolveHarnessConfigs(spec: SpecWithLatestFields, projectRoot: string): HarnessConfig[] { + const harnessConfigs: HarnessConfig[] = []; + for (const entry of spec.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}` + ); + } + } + return harnessConfigs; +} + +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(); + if (targets.length === 0) { + throw new Error('No deployment targets configured. Please define targets in agentcore/aws-targets.json'); + } + + const specAny: SpecWithLatestFields = spec; + const projectRoot = path.resolve(configRoot, '..'); + + const mcpSpec = resolveMcpSpec(specAny); + const connectorParametersByFile = resolveConnectorParametersByFile(specAny, projectRoot); + const harnessConfigs = resolveHarnessConfigs(specAny, projectRoot); + + // 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 + } + + 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..550a52797 --- /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.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.45", + "aws-cdk-lib": "~2.261.0", + "constructs": "~10.7.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/assets/templates/hello-world-python/main.py b/src/assets/templates/hello-world-python/main.py new file mode 100644 index 000000000..182bfd57e --- /dev/null +++ b/src/assets/templates/hello-world-python/main.py @@ -0,0 +1,17 @@ +from bedrock_agentcore.runtime import BedrockAgentCoreApp +from strands import Agent + +app = BedrockAgentCoreApp() +agent = Agent(system_prompt="You are a helpful assistant.") + + +@app.entrypoint +async def invoke(payload, context): + """Stream the agent's response to the caller's prompt.""" + prompt = payload.get("prompt", "Hello!") + async for event in agent.stream_async(prompt): + yield event + + +if __name__ == "__main__": + app.run() diff --git a/src/assets/templates/hello-world-python/pyproject.toml b/src/assets/templates/hello-world-python/pyproject.toml new file mode 100644 index 000000000..09070bf8e --- /dev/null +++ b/src/assets/templates/hello-world-python/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "hello-world" +version = "0.1.0" +description = "AgentCore Runtime application using the Strands SDK" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "aws-opentelemetry-distro", + "bedrock-agentcore >= 1.9.1", + "botocore[crt] >= 1.35.0", + "strands-agents >= 1.15.0", +] + +[tool.hatch.build.targets.wheel] +packages = ["."] diff --git a/src/core/project/__snapshots__/manager.test.ts.snap b/src/core/project/__snapshots__/manager.test.ts.snap new file mode 100644 index 000000000..87d602318 --- /dev/null +++ b/src/core/project/__snapshots__/manager.test.ts.snap @@ -0,0 +1,21 @@ +// Bun Snapshot v1, https://bun.sh/docs/test/snapshots + +exports[`FsProjectManager.create scaffolds the expected file tree into a fresh directory 1`] = ` +[ + "agentcore/agentcore.json", + "agentcore/aws-targets.json", + "agentcore/cdk/.gitignore", + "agentcore/cdk/.npmignore", + "agentcore/cdk/.prettierrc", + "agentcore/cdk/README.md", + "agentcore/cdk/bin/cdk.ts", + "agentcore/cdk/cdk.json", + "agentcore/cdk/jest.config.js", + "agentcore/cdk/lib/cdk-stack.ts", + "agentcore/cdk/package.json", + "agentcore/cdk/test/cdk.test.ts", + "agentcore/cdk/tsconfig.json", + "app/hello-world/main.py", + "app/hello-world/pyproject.toml", +] +`; diff --git a/src/core/project/compose.ts b/src/core/project/compose.ts new file mode 100644 index 000000000..06b3239c6 --- /dev/null +++ b/src/core/project/compose.ts @@ -0,0 +1,79 @@ +import type { DirNode, ProjectNode } from "./tree"; +import { dir, file } from "./tree"; +import type { AssetSource } from "./source"; +import { TEMPLATES } from "./templates"; +import type { ProjectTemplate } from "../../handlers/project/types"; + +/** Serializes a value as pretty-printed JSON with a trailing newline. */ +const json = (value: unknown): string => `${JSON.stringify(value, null, 2)}\n`; + +/** + * Expands the flat asset listing under assetDir into a nested tree of nodes. + * Ignore templates are renamed to dotfiles because npm strips real dotfiles when publishing. + */ +async function expandDir(src: AssetSource, assetDir: string): Promise { + const paths = await src.list(assetDir); + const root: ProjectNode[] = []; + + for (const assetPath of paths) { + const relative = assetPath.slice(assetDir.length + 1); + const segments = relative.split("/"); + if (segments.some((s) => s === "" || s === "." || s === "..")) { + throw new Error(`Unsafe asset path: ${assetPath}`); + } + + let cursor = root; + segments.forEach((segment, index) => { + if (index === segments.length - 1) { + cursor.push(file(renderName(segment), () => src.read(assetPath))); + return; + } + let child = cursor.find((n): n is DirNode => n.kind === "dir" && n.name === segment); + if (!child) { + child = dir(segment, []); + cursor.push(child); + } + cursor = child.children; + }); + } + + return root; +} + +function renderName(filename: string): string { + const ignore = filename.match(/^(git|npm)ignore\.template$/); + return ignore ? `.${ignore[1]}ignore` : filename; +} + +/** + * Builds the agentcore.json spec by adding the template's resource sections to the shared base. + * The base fields and template sections never overlap so this is a plain spread. + */ +function agentcoreSpec(name: string, template: ProjectTemplate): unknown { + return { + name, + version: 1, + managedBy: "CDK", + ...TEMPLATES[template].spec, + }; +} + +/** + * Composes the full project tree for a template rooted at the destination. + * Config files live under agentcore/ because that is where the deploy tooling discovers a project. + */ +export async function projectTree( + name: string, + template: ProjectTemplate, + src: AssetSource, +): Promise { + const { appDir, assetDir } = TEMPLATES[template]; + return dir(".", [ + dir("agentcore", [ + dir("cdk", await expandDir(src, "cdk")), + file("agentcore.json", async () => json(agentcoreSpec(name, template))), + file("aws-targets.json", async () => json([])), + ]), + dir("app", [dir(appDir, await expandDir(src, assetDir))]), + ]); +} diff --git a/src/core/project/manager.test.ts b/src/core/project/manager.test.ts new file mode 100644 index 000000000..6ca54d238 --- /dev/null +++ b/src/core/project/manager.test.ts @@ -0,0 +1,72 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, readdir, rm } from "node:fs/promises"; +import { join, relative } from "node:path"; +import { tmpdir } from "node:os"; +import { FsProjectManager } from "./manager"; +import { ProjectFileExistsError } from "./tree"; +import { PROJECT_TEMPLATES } from "../../handlers/project/types"; +import { createSilentLogger } from "../../testing"; + +const originalCwd = process.cwd(); +const tempDirectories: string[] = []; + +async function inTempDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), "agentcore-manager-")); + tempDirectories.push(directory); + process.chdir(directory); + return directory; +} + +afterEach(async () => { + process.chdir(originalCwd); + await Promise.all( + tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +function manager(): FsProjectManager { + return new FsProjectManager({ logger: createSilentLogger() }); +} + +describe("FsProjectManager.create", () => { + test("scaffolds the expected file tree into a fresh directory", async () => { + const directory = await inTempDirectory(); + await manager().create({ name: "example", template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON }); + + const projectRoot = join(directory, "example"); + const manifest = (await readdir(projectRoot, { recursive: true, withFileTypes: true })) + .filter((entry) => entry.isFile()) + .map((entry) => + relative(projectRoot, join(entry.parentPath, entry.name)).replaceAll("\\", "/"), + ) + .sort(); + + expect(manifest).toMatchSnapshot(); + }); + + test("writes a deploy-ready agentcore.json registering the template agent", async () => { + const directory = await inTempDirectory(); + await manager().create({ name: "example", template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON }); + + const configDir = join(directory, "example", "agentcore"); + const spec = await Bun.file(join(configDir, "agentcore.json")).json(); + expect(spec.name).toBe("example"); + expect(spec.runtimes).toEqual([ + { + name: "hello_world", + build: "CodeZip", + entrypoint: "main.py", + codeLocation: "app/hello-world", + }, + ]); + expect(await Bun.file(join(configDir, "aws-targets.json")).json()).toEqual([]); + }); + + test("refuses to overwrite an existing project", async () => { + await inTempDirectory(); + const input = { name: "example", template: PROJECT_TEMPLATES.HELLO_WORLD_PYTHON }; + + await manager().create(input); + await expect(manager().create(input)).rejects.toBeInstanceOf(ProjectFileExistsError); + }); +}); diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index 5e91ff693..a87c8689f 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -1,3 +1,4 @@ +import { join } from "node:path"; import type { CreateProjectInput, ResolveProjectInput, @@ -5,22 +6,39 @@ import type { ProjectManager, } from "../../handlers/project/types"; import type { Logger } from "../../logging"; +import { projectTree } from "./compose"; +import { defaultSource, type AssetSource } from "./source"; +import { writeTree } from "./tree"; type ProjectManagerConfig = { logger: Logger; + source?: AssetSource; // Bun executable or dist/assets depending on runtime }; /** * An implementation of {@link ProjectManager} that relies on the local file system to manage projects. */ export class FsProjectManager implements ProjectManager { - constructor(_config: ProjectManagerConfig) {} + private readonly logger: Logger; + private readonly source: AssetSource; + + constructor(config: ProjectManagerConfig) { + this.logger = config.logger; + this.source = config.source ?? defaultSource(); + } public resolve(_input: ResolveProjectInput): Promise { throw new Error(`ProjectManager.resolve is not implemented yet`); } - public create(_input: CreateProjectInput): Promise { - throw new Error(`ProjectManager.create is not implemented yet`); + public async create(input: CreateProjectInput): Promise { + // Scaffold into a fresh directory. + const destination = join(process.cwd(), input.name); + this.logger.debug(`scaffolding project "${input.name}" from template "${input.template}"`); + + const tree = await projectTree(input.name, input.template, this.source); + await writeTree(tree, destination); + + return { name: input.name }; } } diff --git a/src/core/project/source.test.ts b/src/core/project/source.test.ts new file mode 100644 index 000000000..e933e15c4 --- /dev/null +++ b/src/core/project/source.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, test } from "bun:test"; +import { EmbeddedAssetSource } from "./source"; + +describe("EmbeddedAssetSource", () => { + test("throws when the asset is not embedded", () => { + expect(new EmbeddedAssetSource().read("cdk/package.json")).rejects.toThrow( + /Embedded asset not found/, + ); + }); +}); diff --git a/src/core/project/source.ts b/src/core/project/source.ts new file mode 100644 index 000000000..823b118e1 --- /dev/null +++ b/src/core/project/source.ts @@ -0,0 +1,82 @@ +import { existsSync } from "node:fs"; +import { readFile, readdir } from "node:fs/promises"; +import { dirname, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * Reads and lists asset files by path relative to the asset root. + * This is the one place that knows whether assets come from disk or from the compiled executable. + */ +export interface AssetSource { + /** Reads the text of the asset at assetPath. */ + read(assetPath: string): Promise; + /** Lists asset paths of every file under assetDir, sorted, recursively. */ + list(assetDir: string): Promise; +} + +// Prefix the build script gives every embedded asset name at compile time. +const EMBEDDED_PREFIX = "agentcore-assets/src/assets/"; + +// Embedded files carry a name property that Bun's types widen to Blob. +type NamedBlob = Blob & { readonly name: string }; + +/** Reads assets embedded in the compiled standalone executable. */ +export class EmbeddedAssetSource implements AssetSource { + private blobs(): readonly NamedBlob[] { + return Bun.embeddedFiles as readonly NamedBlob[]; + } + + public async read(assetPath: string): Promise { + const name = `${EMBEDDED_PREFIX}${assetPath}`; + const blob = this.blobs().find((f) => f.name === name); + if (!blob) { + throw new Error(`Embedded asset not found: ${assetPath}`); + } + return blob.text(); + } + + public async list(assetDir: string): Promise { + const prefix = `${EMBEDDED_PREFIX}${assetDir}/`; + return this.blobs() + .filter((f) => f.name.startsWith(prefix)) + .map((f) => f.name.slice(EMBEDDED_PREFIX.length)) + .sort(); + } +} + +/** Reads assets from the assets directory on disk. */ +export class FsAssetSource implements AssetSource { + constructor(private readonly assetsRoot: string = resolveAssetsRoot()) {} + + public read(assetPath: string): Promise { + return readFile(join(this.assetsRoot, assetPath), "utf8"); + } + + public async list(assetDir: string): Promise { + const root = join(this.assetsRoot, assetDir); + const entries = await readdir(root, { recursive: true, withFileTypes: true }); + return entries + .filter((entry) => entry.isFile()) + .map((entry) => join(assetDir, relative(root, join(entry.parentPath, entry.name)))) + .map((p) => p.replaceAll("\\", "/")) + .sort(); + } +} + +// Bundled builds place assets beside the emitted module and the source layout keeps them two levels up. +function resolveAssetsRoot(moduleDirectory = dirname(fileURLToPath(import.meta.url))): string { + const bundledRoot = resolve(moduleDirectory, "assets"); + if (existsSync(bundledRoot)) { + return bundledRoot; + } + return resolve(moduleDirectory, "../../assets"); +} + +/** + * Selects the asset source for the current runtime: embedded assets when running + * as a compiled Bun executable, disk otherwise. + */ +export function defaultSource(): AssetSource { + const embedded = typeof Bun !== "undefined" && Bun.embeddedFiles.length > 0; + return embedded ? new EmbeddedAssetSource() : new FsAssetSource(); +} diff --git a/src/core/project/templates.ts b/src/core/project/templates.ts new file mode 100644 index 000000000..558b0d531 --- /dev/null +++ b/src/core/project/templates.ts @@ -0,0 +1,37 @@ +import { PROJECT_TEMPLATES, type ProjectTemplate } from "../../handlers/project/types"; + +type TemplateSpec = { + runtimes?: unknown[]; + memories?: unknown[]; + harnesses?: unknown[]; +}; + +/** + * A project template pairs the agent code scaffolded under app/ with the resource + * sections it registers in agentcore.json. Adding a template is one entry here plus its assets. + */ +type Template = { + /** Directory under app/ the template code is written to. */ + appDir: string; + /** Asset directory relative to the asset root, expanded into the app directory. */ + assetDir: string; + /** Resource sections this template contributes to agentcore.json. */ + spec: TemplateSpec; +}; + +export const TEMPLATES: Record = { + [PROJECT_TEMPLATES.HELLO_WORLD_PYTHON]: { + appDir: "hello-world", + assetDir: "templates/hello-world-python", + spec: { + runtimes: [ + { + name: "hello_world", + build: "CodeZip", + entrypoint: "main.py", + codeLocation: "app/hello-world", + }, + ], + }, + }, +}; diff --git a/src/core/project/tree.ts b/src/core/project/tree.ts new file mode 100644 index 000000000..52c0dd109 --- /dev/null +++ b/src/core/project/tree.ts @@ -0,0 +1,65 @@ +import { existsSync } from "node:fs"; +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { AgentCoreCLIError, ERROR_SOURCE } from "../../errors"; +import { atomicWrite } from "../../io"; + +/** + * A node in a project's file tree where directories nest and files are leaves. + * File bytes come from a thunk so the tree never knows where the bytes originate. + */ +export type ProjectNode = DirNode | FileNode; + +export type DirNode = { + kind: "dir"; + name: string; + children: ProjectNode[]; +}; + +export type FileNode = { + kind: "file"; + name: string; + bytes: () => Promise; +}; + +export const dir = (name: string, children: ProjectNode[]): DirNode => ({ + kind: "dir", + name, + children, +}); + +export const file = (name: string, bytes: () => Promise): FileNode => ({ + kind: "file", + name, + bytes, +}); + +/** Thrown when scaffolding would overwrite a file that already exists. */ +export class ProjectFileExistsError extends AgentCoreCLIError { + constructor(public readonly path: string) { + super(`Refusing to overwrite existing file: ${path}`, { + source: ERROR_SOURCE.USER, + meta: { path }, + }); + } +} + +/** + * Writes a project tree to the destination with atomic file writes. + * Refuses to overwrite an existing file so a re-run fails loudly instead of clobbering user work. + */ +export async function writeTree(node: ProjectNode, destination: string): Promise { + const path = join(destination, node.name); + if (node.kind === "dir") { + await mkdir(path, { recursive: true }); + for (const child of node.children) { + await writeTree(child, path); + } + return; + } + + if (existsSync(path)) { + throw new ProjectFileExistsError(path); + } + await atomicWrite(path, await node.bytes()); +} diff --git a/src/handlers/project/create/index.ts b/src/handlers/project/create/index.ts index aef7c9788..9967cdb7e 100644 --- a/src/handlers/project/create/index.ts +++ b/src/handlers/project/create/index.ts @@ -1,6 +1,6 @@ import z from "zod"; import { createHandler, flag } from "../../../router"; -import { PROJECT_TEMPLATES, type ProjectManager } from "../types"; +import { PROJECT_TEMPLATES, ProjectNameSchema, type ProjectManager } from "../types"; type CreateProjectHandlerConfig = { projectManager: ProjectManager; @@ -11,15 +11,17 @@ export const createCreateProjectHandler = (config: CreateProjectHandlerConfig) = name: "create", description: "create a new AgentCore project", flags: [ + flag("project-name", "name of the project to create", ProjectNameSchema), flag( "template", "project template to scaffold from", - z.enum(PROJECT_TEMPLATES).default(PROJECT_TEMPLATES.BAREBONES), + z.enum(PROJECT_TEMPLATES).default(PROJECT_TEMPLATES.HELLO_WORLD_PYTHON), ), ], handle: async (_ctx, flags) => { await config.projectManager.create({ - template: flags.template, + name: flags["project-name"], + template: flags["template"], }); }, }); diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index d92defe5e..7e1d13c0f 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -1,4 +1,7 @@ -import { test, expect, describe } from "bun:test"; +import { afterEach, test, expect, describe } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; import { createRootHandler } from "../index"; import { createSilentLogger, @@ -23,16 +26,48 @@ describe.each(["add", "remove", "dev", "deploy", "status", "build"])("project %s }); }); +const originalCwd = process.cwd(); +const tempDirectories: string[] = []; + +async function inTempDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), "agentcore-project-")); + tempDirectories.push(directory); + process.chdir(directory); + return directory; +} + +afterEach(async () => { + process.chdir(originalCwd); + await Promise.all( + tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + describe("project create", () => { - test("throws because it is not implemented yet", async () => { - await expect(run(["create"])).rejects.toThrow(/not implemented/); + test("scaffolds the project into a fresh directory named for the project", async () => { + const directory = await inTempDirectory(); + await run(["create", "--project-name", "MyAgent"]); + + // One existence check proves the handler→manager pipe; the full manifest + // is covered by the FsProjectManager snapshot test. + const projectRoot = join(directory, "MyAgent"); + expect(await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).exists()).toBe(true); + }); + + test("rejects an invalid --project-name", async () => { + await inTempDirectory(); + await expect(run(["create", "--project-name", "1-bad"])).rejects.toThrow(); }); - test("accepts a known --template value", async () => { - await expect(run(["create", "--template", "barebones"])).rejects.toThrow(/not implemented/); + test("rejects a reserved --project-name", async () => { + await inTempDirectory(); + await expect(run(["create", "--project-name", "test"])).rejects.toThrow(/conflicts with/); }); test("rejects an unknown --template value", async () => { - await expect(run(["create", "--template", "nonsense"])).rejects.toThrow(); + await inTempDirectory(); + await expect( + run(["create", "--project-name", "MyAgent", "--template", "nonsense"]), + ).rejects.toThrow(); }); }); diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index 1215cfd51..458d562c4 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -1,11 +1,91 @@ +import z from "zod"; + /** Available project templates for scaffolding new AgentCore projects. */ export const PROJECT_TEMPLATES = { - BAREBONES: "barebones", + HELLO_WORLD_PYTHON: "hello-world-python", } as const; export type ProjectTemplate = (typeof PROJECT_TEMPLATES)[keyof typeof PROJECT_TEMPLATES]; +// A generated project's Python package is named after the +// project, so a name that collides with a dependency breaks imports. +const RESERVED_PROJECT_NAMES: readonly string[] = [ + // Core SDK packages + "anthropic", + "autogen", + "autogenagentchat", + "autogenext", + "bedrock", + "bedrockagentcore", + "crewai", + "crewaitools", + "googleadk", + "googlegenerativeai", + "langchain", + "langchainanthropic", + "langchainaws", + "langchaingooglegenai", + "langchainmcpadapters", + "langchainopenai", + "langgraph", + "mcp", + "openai", + "openaiagents", + "strands", + "strandsagents", + "strandsagentstools", + // AG-UI adapter packages + "agui", + "aguistrands", + "aguilanggraph", + "aguiadk", + "aguiprotocol", + "vercelai", + "aisdk", + // Common utilities + "httpx", + "pytest", + "pytestasyncio", + "pythondotenv", + "tiktoken", + // Build tools + "hatchling", + "setuptools", + "wheel", + // AWS packages + "awsopentelemetrydistro", + "boto3", + "botocore", + // Common Python stdlib/package names that could cause issues + "test", + "tests", + "src", + "lib", + "dist", + "build", + "env", + "venv", + "site", + "pip", + "uv", +]; + +// Mirrors ProjectNameSchema in @aws/agentcore-cdk +export const ProjectNameSchema = z + .string() + .min(1, "project name is required") + .max(23, "project name must be 23 characters or less") + .regex( + /^[A-Za-z][A-Za-z0-9]{0,22}$/, + "project name must start with a letter and contain only letters and digits", + ) + .refine((name) => !RESERVED_PROJECT_NAMES.includes(name.toLowerCase()), { + message: "this name conflicts with a Python package dependency; choose a different name", + }); + export type CreateProjectInput = { + /** The name of the project; also the directory it is scaffolded into. */ + name: string; /** The project template to scaffold from. */ template: ProjectTemplate; }; diff --git a/src/io/atomicWrite.test.ts b/src/io/atomicWrite.test.ts new file mode 100644 index 000000000..391e4311b --- /dev/null +++ b/src/io/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/io/atomicWrite.ts b/src/io/atomicWrite.ts new file mode 100644 index 000000000..68abdef90 --- /dev/null +++ b/src/io/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/io/index.ts b/src/io/index.ts index 07095428c..92b32d0c0 100644 --- a/src/io/index.ts +++ b/src/io/index.ts @@ -1,3 +1,4 @@ +export { atomicWrite } from "./atomicWrite"; export { FsReadWriteJson } from "./json"; export { SourceResolutionError, SourceResolver, type SourceResolverConfig } from "./source"; export type { AppIO, ReadWriteJson } from "./types"; 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"] }