From b13c8a02334b4f70325cff4165d3b44896e76424 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Mon, 17 Aug 2026 21:10:03 +0000 Subject: [PATCH 01/15] feat(proj): add handler scaffolding for runtime --- src/handlers/project/add/index.ts | 2 + src/handlers/project/add/runtime/index.ts | 343 ++++++++++++++++++++++ src/handlers/project/types.ts | 2 +- src/projectSchemas/runtime.ts | 6 +- 4 files changed, 349 insertions(+), 4 deletions(-) create mode 100644 src/handlers/project/add/runtime/index.ts diff --git a/src/handlers/project/add/index.ts b/src/handlers/project/add/index.ts index 8545ccba3..2d7970bfa 100644 --- a/src/handlers/project/add/index.ts +++ b/src/handlers/project/add/index.ts @@ -1,11 +1,13 @@ import { withProject } from "../../../middleware/"; import { Router } from "../../../router"; import { createAddHarnessHandler } from "./harness"; +import { createAddRuntimeHandler } from "./runtime"; import type { AddProjectResourceConfig } from "./types"; export function createAddProjectResourceHandler(config: AddProjectResourceConfig): Router { const projectAdd = new Router("add", "add project resources"); projectAdd.use(withProject({ projectManager: config.projectManager, cwd: process.cwd() })); projectAdd.handler(createAddHarnessHandler(config)); + projectAdd.handler(createAddRuntimeHandler(config)); return projectAdd; } diff --git a/src/handlers/project/add/runtime/index.ts b/src/handlers/project/add/runtime/index.ts new file mode 100644 index 000000000..a87f7d15c --- /dev/null +++ b/src/handlers/project/add/runtime/index.ts @@ -0,0 +1,343 @@ +import z from "zod"; +import { createHandler, flag, ProjectKey } from "../../../../router"; +import type { AddProjectResourceConfig } from "../types"; +import { parseJsonFlag } from "../../../utils"; +import { InputValidationError } from "../../../../errors"; +import type { + AgentRuntimeArtifact, + AuthorizerConfiguration, + FilesystemConfiguration, + LifecycleConfiguration, + NetworkConfiguration, + ProtocolConfiguration, + RequestHeaderConfiguration, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { + type BuildType, + type EnvVar, + type FilesystemConfiguration as ProjectFilesystemConfiguration, + type LifecycleConfiguration as ProjectLifecycleConfiguration, + type NetworkConfig, + BuildTypeSchema, +} from "../../../../projectSchemas/runtime"; +import type { AuthorizerConfig, RuntimeAuthorizerType } from "../../../../projectSchemas/auth"; +import type { + NetworkMode, + ProtocolMode, + RuntimeVersion, +} from "../../../../projectSchemas/constants"; + +export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => + createHandler({ + name: "runtime", + description: "adds a runtime to the current project", + flags: [ + flag("name", "the name of the runtime", z.string().optional()), + flag("description", "description of the runtime", z.string().optional()), + flag( + "role-arn", + "IAM role ARN that provides permissions for the runtime", + z.string().optional(), + ), + flag( + "agent-runtime-artifact", + "runtime artifact configuration (JSON AgentRuntimeArtifact)", + z.string().optional(), + ), + flag( + "network-configuration", + "network configuration (JSON NetworkConfiguration)", + z.string().optional(), + ), + flag( + "authorizer-configuration", + "inbound authorizer configuration (JSON AuthorizerConfiguration)", + z.string().optional(), + ), + flag( + "protocol-configuration", + "protocol configuration (JSON ProtocolConfiguration)", + z.string().optional(), + ), + flag( + "request-header-configuration", + "request header passthrough configuration (JSON RequestHeaderConfiguration)", + z.string().optional(), + ), + flag( + "lifecycle-configuration", + "lifecycle configuration (JSON LifecycleConfiguration)", + z.string().optional(), + ), + flag( + "environment-variables", + "environment variables (JSON object of key/value strings)", + z.string().optional(), + ), + flag( + "filesystem-configurations", + "filesystem mount configurations (JSON FilesystemConfiguration[])", + z.string().optional(), + ), + flag("tags", "tags to apply (JSON object of key/value strings)", z.string().optional()), + flag("build", "build type for the runtime source code", BuildTypeSchema.optional()), + flag("entrypoint", "entrypoint for a codezip runtime", z.string().optional()), + flag( + "dockerfile", + "dockerfile describing the container for the runtime", + z.string().optional(), + ), + flag( + "build-context-path", + "docker build context directory relative to project root (Container only)", + z.string().optional(), + ), + flag( + "custom-docker-build-args", + "docker build args (JSON object of key/value strings, Container only)", + z.string().optional(), + ), + flag( + "instrumentation", + 'instrumentation config (JSON, e.g. {"enableOtel": true})', + z.string().optional(), + ), + flag( + "additional-policies", + "additional IAM policy ARNs or policy document paths", + z.array(z.string()).optional(), + ), + ], + handle: async (ctx, flags) => { + if (!flags.name) + throw new InputValidationError("required option '--name ' not specified"); + + const inputArtifact = parseJsonFlag( + "agent-runtime-artifact", + flags["agent-runtime-artifact"], + ); + const inputNetwork = parseJsonFlag( + "network-configuration", + flags["network-configuration"], + ); + const inputAuthConfig = parseJsonFlag( + "authorizer-configuration", + flags["authorizer-configuration"], + ); + const inputProtocol = parseJsonFlag( + "protocol-configuration", + flags["protocol-configuration"], + ); + const inputRequestHeaders = parseJsonFlag( + "request-header-configuration", + flags["request-header-configuration"], + ); + const inputLifecycle = parseJsonFlag( + "lifecycle-configuration", + flags["lifecycle-configuration"], + ); + const inputFilesystems = parseJsonFlag( + "filesystem-configurations", + flags["filesystem-configurations"], + ); + const inputEnvironmentVariables = parseJsonFlag>( + "environment-variables", + flags["environment-variables"], + ); + + const build = getBuildType(flags.build, inputArtifact); + const entrypoint = getEntrypoint(flags.entrypoint, inputArtifact); + + if (entrypoint && build === "Container") + throw new InputValidationError( + `code entrypoint cannot be provided with Container build type`, + ); + + if (flags["custom-docker-build-args"] && !flags.dockerfile && !flags["build-context-path"]) + throw new InputValidationError( + " --custom-docker-build-args requires --dockerfile or --build-context-path", + ); + + const network = toNetwork(inputNetwork); + const auth = toAuthorizer(inputAuthConfig); + const protocol = toProtocol(inputProtocol); + const requestHeaderAllowlist = toRequestHeaderAllowlist(inputRequestHeaders); + const lifecycleConfiguration = toLifecycle(inputLifecycle); + const filesystemConfigurations = toFilesystems(inputFilesystems); + + const runtimeConfig = { + name: flags.name, + description: flags.description, + build, + entrypoint, + runtimeVersion: getRuntimeVersion(inputArtifact), + dockerfile: flags.dockerfile, + buildContextPath: flags["build-context-path"], + customDockerBuildArgs: parseJsonFlag>( + "custom-docker-build-args", + flags["custom-docker-build-args"], + ), + executionRoleArn: flags["role-arn"], + additionalPolicies: flags["additional-policies"], + instrumentation: parseJsonFlag<{ enableOtel: boolean }>( + "instrumentation", + flags["instrumentation"], + ), + envVars: toEnvironmentVariables(inputEnvironmentVariables), + networkMode: network?.networkMode, + networkConfig: network?.networkConfig, + authorizerType: auth?.authorizerType, + authorizerConfiguration: auth?.authorizerConfiguration, + protocol, + requestHeaderAllowlist, + lifecycleConfiguration, + filesystemConfigurations, + tags: parseJsonFlag>("tags", flags["tags"]), + }; + + const project = ctx.require(ProjectKey); + for await (const event of config.projectManager.addResource(project, { + resourceType: "runtime", + resourceConfig: runtimeConfig, + })) { + config.io.stderr.write(`${event.message}\n`); + } + + config.io.stderr.write(`added runtime '${flags.name}' to '${project.name}'\n`); + }, + }); + +/** Converts AgentRuntimeArtifact union discriminator to project schema BuildType. */ +function getBuildType( + buildFlag: z.input | undefined, + runtimeArtifact: AgentRuntimeArtifact | undefined, +): BuildType { + if (runtimeArtifact && buildFlag) + throw new InputValidationError(`--runtime-artifact and --build are mutually exclusive`); + if (buildFlag) return buildFlag; + if (runtimeArtifact?.containerConfiguration) return "Container"; + if (runtimeArtifact?.codeConfiguration) return "CodeZip"; + throw new InputValidationError(`exactly one of --runtime-artifact and --build is required`); +} + +/** Converts codeConfiguration.entryPoint string[] to colon-joined "file.py:handler" format. */ +function getEntrypoint( + entrypointFlag: string | undefined, + runtimeArtifact: AgentRuntimeArtifact | undefined, +): string | undefined { + if (runtimeArtifact && entrypointFlag) + throw new InputValidationError(`--runtime-artifact and --entrypoint are mutually exclusive`); + if (entrypointFlag) return entrypointFlag; + if (runtimeArtifact?.codeConfiguration?.entryPoint) + return runtimeArtifact.codeConfiguration.entryPoint.join(":"); + return undefined; +} + +/** Converts codeConfiguration.runtime enum string to project schema RuntimeVersion. */ +function getRuntimeVersion(artifact: AgentRuntimeArtifact | undefined): RuntimeVersion | undefined { + if (!artifact?.codeConfiguration?.runtime) return undefined; + return artifact.codeConfiguration.runtime as RuntimeVersion; +} + +/** Converts API flat {key: value} map to project schema [{name, value}] array. */ +function toEnvironmentVariables(envVars: Record | undefined): EnvVar[] { + return envVars ? Object.entries(envVars).map(([name, value]) => ({ name, value })) : []; +} + +/** Converts API NetworkConfiguration to project schema networkMode + networkConfig fields. */ +function toNetwork( + network: NetworkConfiguration | undefined, +): { networkMode: NetworkMode; networkConfig: NetworkConfig | undefined } | undefined { + if (!network) return undefined; + return { + networkMode: network.networkMode as NetworkMode, + networkConfig: network.networkModeConfig + ? { + subnets: network.networkModeConfig.subnets ?? [], + securityGroups: network.networkModeConfig.securityGroups ?? [], + } + : undefined, + }; +} + +/** Converts API AuthorizerConfiguration union to project schema authorizerType + authorizerConfiguration. */ +function toAuthorizer( + auth: AuthorizerConfiguration | undefined, +): + { authorizerType: RuntimeAuthorizerType; authorizerConfiguration: AuthorizerConfig } | undefined { + if (!auth) return undefined; + if ("customJWTAuthorizer" in auth && auth.customJWTAuthorizer) { + const c = auth.customJWTAuthorizer; + if (!c.discoveryUrl) + throw new InputValidationError("discoveryUrl is required in authorizer configuration"); + return { + authorizerType: "CUSTOM_JWT", + authorizerConfiguration: { + customJwtAuthorizer: { + discoveryUrl: c.discoveryUrl, + allowedAudience: c.allowedAudience, + allowedClients: c.allowedClients, + allowedScopes: c.allowedScopes, + }, + }, + }; + } + throw new InputValidationError("Unrecognized authorizer configuration variant"); +} + +/** Converts API ProtocolConfiguration wrapper to project schema ProtocolMode enum. */ +function toProtocol(protocol: ProtocolConfiguration | undefined): ProtocolMode | undefined { + if (!protocol) return undefined; + return protocol.serverProtocol as ProtocolMode; +} + +/** Unwraps API RequestHeaderConfiguration union to project schema string[]. */ +function toRequestHeaderAllowlist( + headers: RequestHeaderConfiguration | undefined, +): string[] | undefined { + if (!headers) return undefined; + if ("requestHeaderAllowlist" in headers && headers.requestHeaderAllowlist) { + return headers.requestHeaderAllowlist; + } + return undefined; +} + +/** Converts API LifecycleConfiguration to project schema LifecycleConfiguration. */ +function toLifecycle( + lifecycle: LifecycleConfiguration | undefined, +): ProjectLifecycleConfiguration | undefined { + if (!lifecycle) return undefined; + return { + idleRuntimeSessionTimeout: lifecycle.idleRuntimeSessionTimeout, + maxLifetime: lifecycle.maxLifetime, + }; +} + +/** Converts API FilesystemConfiguration[] tagged unions to project schema format. */ +function toFilesystems( + filesystems: FilesystemConfiguration[] | undefined, +): ProjectFilesystemConfiguration[] | undefined { + if (!filesystems || filesystems.length === 0) return undefined; + return filesystems.map((fs): ProjectFilesystemConfiguration => { + if ("sessionStorage" in fs && fs.sessionStorage) { + return { sessionStorage: { mountPath: fs.sessionStorage.mountPath! } }; + } + if ("efsAccessPoint" in fs && fs.efsAccessPoint) { + return { + efsAccessPoint: { + accessPointArn: fs.efsAccessPoint.accessPointArn!, + mountPath: fs.efsAccessPoint.mountPath!, + }, + }; + } + if ("s3FilesAccessPoint" in fs && fs.s3FilesAccessPoint) { + return { + s3FilesAccessPoint: { + accessPointArn: fs.s3FilesAccessPoint.accessPointArn!, + mountPath: fs.s3FilesAccessPoint.mountPath!, + }, + }; + } + throw new InputValidationError("Unrecognized filesystem configuration variant"); + }); +} diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index c187abb96..0f6692fea 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -48,7 +48,7 @@ export type AddResourceInput = } | { resourceType: "runtime"; - resourceConfig: z.input; + resourceConfig: Omit, "codeLocation" | "runtimeVersion">; }; export type ProjectResource = AddResourceInput["resourceType"]; diff --git a/src/projectSchemas/runtime.ts b/src/projectSchemas/runtime.ts index 61dc95886..33cb295da 100644 --- a/src/projectSchemas/runtime.ts +++ b/src/projectSchemas/runtime.ts @@ -8,7 +8,7 @@ import { VPC_ID_PATTERN, isContainerBuild, } from "./constants"; -import type { DirectoryPath, FilePath } from "./types"; +import type { DirectoryPath } from "./types"; import { AuthorizerConfigSchema, RuntimeAuthorizerTypeSchema } from "./auth"; import { ConnectionSchema } from "./connections"; import { TagsSchema } from "./tags"; @@ -49,7 +49,7 @@ export const EntrypointSchema = z .regex( /^[a-zA-Z0-9_][a-zA-Z0-9_/.-]*\.(py|ts|js)(:[a-zA-Z_][a-zA-Z0-9_]*)?$/, 'Must be a Python (.py) or TypeScript (.ts/.js) file path with optional handler (e.g., "main.py:handler" or "index.ts")', - ) as unknown as z.ZodType; + ); const DirectoryPathSchema = z.string().min(1) as unknown as z.ZodType; const DOCKERFILE_PATH_ALLOWED_CHARS = /^[A-Za-z0-9._/-]+$/; export function isValidDockerfilePath(p: string): boolean { @@ -241,7 +241,7 @@ export const ProjectRuntimeSchema = z name: AgentNameSchema, description: z.string().max(200).optional(), build: BuildTypeSchema, - entrypoint: EntrypointSchema, + entrypoint: EntrypointSchema.optional(), codeLocation: DirectoryPathSchema, dockerfile: DockerfilePathSchema.optional(), buildContextPath: DirectoryPathSchema.optional(), From f9714a49f0dd0eb923ac42b8aa0de60723f3c638 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Tue, 18 Aug 2026 00:39:45 +0000 Subject: [PATCH 02/15] refactor(runtime): simplify add runtime to leverage a template setup --- src/handlers/project/add/runtime/index.ts | 169 ++++++------- src/handlers/project/project.test.ts | 284 +++++++++++++++++++++- src/handlers/project/types.ts | 61 ++++- 3 files changed, 414 insertions(+), 100 deletions(-) diff --git a/src/handlers/project/add/runtime/index.ts b/src/handlers/project/add/runtime/index.ts index a87f7d15c..ead37907c 100644 --- a/src/handlers/project/add/runtime/index.ts +++ b/src/handlers/project/add/runtime/index.ts @@ -4,7 +4,6 @@ import type { AddProjectResourceConfig } from "../types"; import { parseJsonFlag } from "../../../utils"; import { InputValidationError } from "../../../../errors"; import type { - AgentRuntimeArtifact, AuthorizerConfiguration, FilesystemConfiguration, LifecycleConfiguration, @@ -13,7 +12,6 @@ import type { RequestHeaderConfiguration, } from "@aws-sdk/client-bedrock-agentcore-control"; import { - type BuildType, type EnvVar, type FilesystemConfiguration as ProjectFilesystemConfiguration, type LifecycleConfiguration as ProjectLifecycleConfiguration, @@ -21,34 +19,64 @@ import { BuildTypeSchema, } from "../../../../projectSchemas/runtime"; import type { AuthorizerConfig, RuntimeAuthorizerType } from "../../../../projectSchemas/auth"; -import type { - NetworkMode, - ProtocolMode, - RuntimeVersion, +import { + type NetworkMode, + type ProtocolMode, + RuntimeVersionSchema, } from "../../../../projectSchemas/constants"; +import { RUNTIME_TEMPLATES } from "../../types"; export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => createHandler({ name: "runtime", - description: "adds a runtime to the current project", + description: "adds a runtime to the current project either from a template or BYO", flags: [ flag("name", "the name of the runtime", z.string().optional()), flag("description", "description of the runtime", z.string().optional()), + flag("template", "runtime template to scaffold from", z.enum(RUNTIME_TEMPLATES).optional()), + flag("code-location", "path to existing agent source code (BYO path)", z.string().optional()), + flag("build", "build type: CodeZip or Container (BYO only)", BuildTypeSchema.optional()), + flag("entrypoint", "entrypoint file, e.g. main.py:handler (BYO only)", z.string().optional()), + flag( + "runtime-version", + "language runtime, e.g. PYTHON_3_13, NODE_22 (BYO CodeZip only)", + RuntimeVersionSchema.optional(), + ), + flag( + "dockerfile", + "dockerfile for the container build (BYO Container only)", + z.string().optional(), + ), + flag( + "build-context-path", + "docker build context directory relative to project root (BYO Container only)", + z.string().optional(), + ), + flag( + "custom-docker-build-args", + "docker build args as JSON key/value object (BYO Container only)", + z.string().optional(), + ), flag( "role-arn", "IAM role ARN that provides permissions for the runtime", z.string().optional(), ), flag( - "agent-runtime-artifact", - "runtime artifact configuration (JSON AgentRuntimeArtifact)", - z.string().optional(), + "additional-policies", + "additional IAM policy ARNs or policy document paths", + z.array(z.string()).optional(), ), flag( "network-configuration", "network configuration (JSON NetworkConfiguration)", z.string().optional(), ), + flag( + "vpc-id", + "VPC ID for Container builds in VPC mode (CodeBuild cannot infer it from subnets)", + z.string().optional(), + ), flag( "authorizer-configuration", "inbound authorizer configuration (JSON AuthorizerConfiguration)", @@ -80,42 +108,15 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => z.string().optional(), ), flag("tags", "tags to apply (JSON object of key/value strings)", z.string().optional()), - flag("build", "build type for the runtime source code", BuildTypeSchema.optional()), - flag("entrypoint", "entrypoint for a codezip runtime", z.string().optional()), - flag( - "dockerfile", - "dockerfile describing the container for the runtime", - z.string().optional(), - ), - flag( - "build-context-path", - "docker build context directory relative to project root (Container only)", - z.string().optional(), - ), - flag( - "custom-docker-build-args", - "docker build args (JSON object of key/value strings, Container only)", - z.string().optional(), - ), - flag( - "instrumentation", - 'instrumentation config (JSON, e.g. {"enableOtel": true})', - z.string().optional(), - ), - flag( - "additional-policies", - "additional IAM policy ARNs or policy document paths", - z.array(z.string()).optional(), - ), ], handle: async (ctx, flags) => { if (!flags.name) throw new InputValidationError("required option '--name ' not specified"); - const inputArtifact = parseJsonFlag( - "agent-runtime-artifact", - flags["agent-runtime-artifact"], - ); + const sourceCount = [flags.template, flags["code-location"]].filter(Boolean).length; + if (sourceCount !== 1) + throw new InputValidationError("exactly one of --template or --code-location is required"); + const inputNetwork = parseJsonFlag( "network-configuration", flags["network-configuration"], @@ -145,47 +146,42 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => flags["environment-variables"], ); - const build = getBuildType(flags.build, inputArtifact); - const entrypoint = getEntrypoint(flags.entrypoint, inputArtifact); + const build = flags.build; + const entrypoint = flags.entrypoint; if (entrypoint && build === "Container") throw new InputValidationError( - `code entrypoint cannot be provided with Container build type`, + "code entrypoint cannot be provided with Container build type", ); + const network = toNetwork(inputNetwork); + if (flags["custom-docker-build-args"] && !flags.dockerfile && !flags["build-context-path"]) throw new InputValidationError( - " --custom-docker-build-args requires --dockerfile or --build-context-path", + "--custom-docker-build-args requires --dockerfile or --build-context-path", + ); + + if (flags["vpc-id"] && !network?.networkConfig) + throw new InputValidationError( + "--vpc-id requires --network-configuration with VPC network configuration", ); - const network = toNetwork(inputNetwork); const auth = toAuthorizer(inputAuthConfig); const protocol = toProtocol(inputProtocol); const requestHeaderAllowlist = toRequestHeaderAllowlist(inputRequestHeaders); const lifecycleConfiguration = toLifecycle(inputLifecycle); const filesystemConfigurations = toFilesystems(inputFilesystems); - const runtimeConfig = { + const infraConfig = { name: flags.name, description: flags.description, - build, - entrypoint, - runtimeVersion: getRuntimeVersion(inputArtifact), - dockerfile: flags.dockerfile, - buildContextPath: flags["build-context-path"], - customDockerBuildArgs: parseJsonFlag>( - "custom-docker-build-args", - flags["custom-docker-build-args"], - ), executionRoleArn: flags["role-arn"], additionalPolicies: flags["additional-policies"], - instrumentation: parseJsonFlag<{ enableOtel: boolean }>( - "instrumentation", - flags["instrumentation"], - ), envVars: toEnvironmentVariables(inputEnvironmentVariables), networkMode: network?.networkMode, - networkConfig: network?.networkConfig, + networkConfig: network?.networkConfig + ? { ...network.networkConfig, ...(flags["vpc-id"] ? { vpcId: flags["vpc-id"] } : {}) } + : undefined, authorizerType: auth?.authorizerType, authorizerConfiguration: auth?.authorizerConfiguration, protocol, @@ -195,6 +191,23 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => tags: parseJsonFlag>("tags", flags["tags"]), }; + const runtimeConfig = flags.template + ? { source: "template" as const, template: flags.template, ...infraConfig } + : { + source: "byo" as const, + codeLocation: flags["code-location"]!, + build: flags.build, + entrypoint, + runtimeVersion: flags["runtime-version"], + dockerfile: flags.dockerfile, + buildContextPath: flags["build-context-path"], + customDockerBuildArgs: parseJsonFlag>( + "custom-docker-build-args", + flags["custom-docker-build-args"], + ), + ...infraConfig, + }; + const project = ctx.require(ProjectKey); for await (const event of config.projectManager.addResource(project, { resourceType: "runtime", @@ -207,38 +220,6 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => }, }); -/** Converts AgentRuntimeArtifact union discriminator to project schema BuildType. */ -function getBuildType( - buildFlag: z.input | undefined, - runtimeArtifact: AgentRuntimeArtifact | undefined, -): BuildType { - if (runtimeArtifact && buildFlag) - throw new InputValidationError(`--runtime-artifact and --build are mutually exclusive`); - if (buildFlag) return buildFlag; - if (runtimeArtifact?.containerConfiguration) return "Container"; - if (runtimeArtifact?.codeConfiguration) return "CodeZip"; - throw new InputValidationError(`exactly one of --runtime-artifact and --build is required`); -} - -/** Converts codeConfiguration.entryPoint string[] to colon-joined "file.py:handler" format. */ -function getEntrypoint( - entrypointFlag: string | undefined, - runtimeArtifact: AgentRuntimeArtifact | undefined, -): string | undefined { - if (runtimeArtifact && entrypointFlag) - throw new InputValidationError(`--runtime-artifact and --entrypoint are mutually exclusive`); - if (entrypointFlag) return entrypointFlag; - if (runtimeArtifact?.codeConfiguration?.entryPoint) - return runtimeArtifact.codeConfiguration.entryPoint.join(":"); - return undefined; -} - -/** Converts codeConfiguration.runtime enum string to project schema RuntimeVersion. */ -function getRuntimeVersion(artifact: AgentRuntimeArtifact | undefined): RuntimeVersion | undefined { - if (!artifact?.codeConfiguration?.runtime) return undefined; - return artifact.codeConfiguration.runtime as RuntimeVersion; -} - /** Converts API flat {key: value} map to project schema [{name, value}] array. */ function toEnvironmentVariables(envVars: Record | undefined): EnvVar[] { return envVars ? Object.entries(envVars).map(([name, value]) => ({ name, value })) : []; @@ -299,7 +280,7 @@ function toRequestHeaderAllowlist( if ("requestHeaderAllowlist" in headers && headers.requestHeaderAllowlist) { return headers.requestHeaderAllowlist; } - return undefined; + throw new InputValidationError("Unrecognized request header configuration variant"); } /** Converts API LifecycleConfiguration to project schema LifecycleConfiguration. */ diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index ee479dac9..6fd6374ff 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -10,7 +10,7 @@ import { TestGlobalConfigAccessor, testIO, } from "../../testing"; -import { InputValidationError } from "../../errors"; +import { InputValidationError, NotImplementedError } from "../../errors"; import { FsReadWriteJson, type ReadWriteJson } from "../../io"; async function run(args: string[], opts?: { core?: TestCoreClient }) { @@ -112,7 +112,7 @@ describe("project create", () => { describe("project add harness", () => { const defaultModel = { provider: "bedrock", modelId: "global.anthropic.claude-sonnet-4-6" }; - /** Verify error case for different flags **/ + // Verifies each flag combination produces the expected harness config. test.each<[string, string[], Record]>([ ["minimal — name only", ["--name", "x"], { model: defaultModel }], [ @@ -610,6 +610,7 @@ describe("project add harness", () => { ); }); + // Rejects invalid flag combinations with InputValidationError. test.each([ ["missing --name", ["--model", '{"bedrockModelConfig":{"modelId":"x"}}']], ["model without modelId", ["--name", "x", "--model", '{"bedrockModelConfig":{}}']], @@ -715,3 +716,282 @@ describe("project build", () => { await expect(run(["build"])).rejects.toThrow(/npm install/); }); }); + +// TODO: Replace NotImplementedError assertions with output assertions once +// FsProjectManager.addResource supports the "runtime" resource type. +describe("project add runtime", () => { + const byo = ["--code-location", "app/my_agent"]; + const tpl = ["--template", "hello-world-python"]; + + // Verifies each valid flag combination passes handler validation. + test.each<[string, string[]]>([ + ["minimal — template path", ["--name", "my_agent", ...tpl]], + ["minimal — BYO path with build", ["--name", "my_agent", ...byo, "--build", "CodeZip"]], + [ + "BYO container with dockerfile", + ["--name", "my_agent", ...byo, "--build", "Container", "--dockerfile", "Dockerfile"], + ], + [ + "entrypoint + runtime-version for CodeZip", + [ + "--name", + "my_agent", + ...byo, + "--build", + "CodeZip", + "--entrypoint", + "app.py:main", + "--runtime-version", + "PYTHON_3_13", + ], + ], + ["description", ["--name", "my_agent", ...tpl, "--description", "A test agent"]], + [ + "role-arn", + ["--name", "my_agent", ...tpl, "--role-arn", "arn:aws:iam::123456789012:role/MyRole"], + ], + [ + "network-configuration — VPC", + [ + "--name", + "my_agent", + ...tpl, + "--network-configuration", + '{"networkMode":"VPC","networkModeConfig":{"subnets":["subnet-abc"],"securityGroups":["sg-123"]}}', + ], + ], + [ + "network-configuration — PUBLIC", + ["--name", "my_agent", ...tpl, "--network-configuration", '{"networkMode":"PUBLIC"}'], + ], + [ + "authorizer-configuration — customJWT", + [ + "--name", + "my_agent", + ...tpl, + "--authorizer-configuration", + '{"customJWTAuthorizer":{"discoveryUrl":"https://idp.example.com/.well-known/openid-configuration","allowedAudience":["app"]}}', + ], + ], + [ + "protocol-configuration — MCP", + ["--name", "my_agent", ...tpl, "--protocol-configuration", '{"serverProtocol":"MCP"}'], + ], + [ + "protocol-configuration — A2A", + ["--name", "my_agent", ...tpl, "--protocol-configuration", '{"serverProtocol":"A2A"}'], + ], + [ + "protocol-configuration — AGUI", + ["--name", "my_agent", ...tpl, "--protocol-configuration", '{"serverProtocol":"AGUI"}'], + ], + [ + "request-header-configuration", + [ + "--name", + "my_agent", + ...tpl, + "--request-header-configuration", + '{"requestHeaderAllowlist":["X-Custom-Header","Authorization"]}', + ], + ], + [ + "lifecycle-configuration", + [ + "--name", + "my_agent", + ...tpl, + "--lifecycle-configuration", + '{"idleRuntimeSessionTimeout":300,"maxLifetime":3600}', + ], + ], + [ + "environment-variables", + [ + "--name", + "my_agent", + ...tpl, + "--environment-variables", + '{"LOG_LEVEL":"debug","APP_ENV":"staging"}', + ], + ], + [ + "filesystem-configurations — sessionStorage", + [ + "--name", + "my_agent", + ...tpl, + "--filesystem-configurations", + '[{"sessionStorage":{"mountPath":"/mnt/data"}}]', + ], + ], + [ + "filesystem-configurations — efsAccessPoint", + [ + "--name", + "my_agent", + ...tpl, + "--filesystem-configurations", + '[{"efsAccessPoint":{"accessPointArn":"arn:aws:elasticfilesystem:us-east-1:123456789012:access-point/fsap-abc","mountPath":"/mnt/efs"}}]', + ], + ], + [ + "filesystem-configurations — s3FilesAccessPoint", + [ + "--name", + "my_agent", + ...tpl, + "--filesystem-configurations", + '[{"s3FilesAccessPoint":{"accessPointArn":"arn:aws:s3files:us-east-1:123456789012:file-system/fs-abc/access-point/fsap-def","mountPath":"/mnt/s3"}}]', + ], + ], + ["tags", ["--name", "my_agent", ...tpl, "--tags", '{"team":"ml","env":"prod"}']], + [ + "dockerfile + build-context-path", + [ + "--name", + "my_agent", + ...byo, + "--build", + "Container", + "--dockerfile", + "docker/Dockerfile.gpu", + "--build-context-path", + ".", + ], + ], + [ + "custom-docker-build-args with dockerfile", + [ + "--name", + "my_agent", + ...byo, + "--build", + "Container", + "--dockerfile", + "Dockerfile", + "--custom-docker-build-args", + '{"AGENT_NAME":"my_agent","VERSION":"1.0"}', + ], + ], + [ + "custom-docker-build-args with build-context-path", + [ + "--name", + "my_agent", + ...byo, + "--build", + "Container", + "--build-context-path", + ".", + "--custom-docker-build-args", + '{"AGENT_NAME":"my_agent"}', + ], + ], + [ + "additional-policies", + [ + "--name", + "my_agent", + ...tpl, + "--additional-policies", + "arn:aws:iam::123456789012:policy/MyPolicy", + ], + ], + [ + "vpc-id with VPC network configuration", + [ + "--name", + "my_agent", + ...byo, + "--build", + "Container", + "--dockerfile", + "Dockerfile", + "--network-configuration", + '{"networkMode":"VPC","networkModeConfig":{"subnets":["subnet-abc"],"securityGroups":["sg-123"]}}', + "--vpc-id", + "vpc-0123456789abcdef0", + ], + ], + ])("%s — accepts flags", async (_label, flags) => { + await inProject(); + await expect(run(["add", "runtime", ...flags])).rejects.toBeInstanceOf(NotImplementedError); + }); + + // Rejects invalid flag combinations with InputValidationError. + test.each<[string, string[]]>([ + ["missing --name", ["--template", "hello-world-python"]], + ["missing both --template and --code-location", ["--name", "my_agent"]], + [ + "--template and --code-location are mutually exclusive", + ["--name", "my_agent", "--template", "hello-world-python", "--code-location", "app/agent"], + ], + [ + "entrypoint cannot be provided with Container build type", + ["--name", "my_agent", ...byo, "--build", "Container", "--entrypoint", "main.py"], + ], + [ + "--custom-docker-build-args requires --dockerfile or --build-context-path", + [ + "--name", + "my_agent", + ...byo, + "--build", + "Container", + "--custom-docker-build-args", + '{"KEY":"value"}', + ], + ], + [ + "--vpc-id requires --network-configuration with VPC network configuration", + [ + "--name", + "my_agent", + ...byo, + "--build", + "Container", + "--dockerfile", + "Dockerfile", + "--vpc-id", + "vpc-0123456789abcdef0", + ], + ], + [ + "unrecognized authorizer configuration variant", + ["--name", "my_agent", ...tpl, "--authorizer-configuration", '{"unknownAuth":{}}'], + ], + [ + "missing discoveryUrl in authorizer", + [ + "--name", + "my_agent", + ...tpl, + "--authorizer-configuration", + '{"customJWTAuthorizer":{"allowedAudience":["a"]}}', + ], + ], + [ + "unrecognized filesystem configuration variant", + ["--name", "my_agent", ...tpl, "--filesystem-configurations", '[{"unknownFs":{}}]'], + ], + [ + "invalid JSON in --network-configuration", + ["--name", "my_agent", ...tpl, "--network-configuration", "{bad}"], + ], + [ + "unrecognized request header configuration variant", + [ + "--name", + "my_agent", + ...tpl, + "--request-header-configuration", + '{"unknownVariant":["X-Foo"]}', + ], + ], + ])("%s", async (_label, flags) => { + await inProject(); + await expect(run(["add", "runtime", ...flags])).rejects.toBeInstanceOf(InputValidationError); + }); +}); diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index 0f6692fea..771a86a69 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -1,14 +1,29 @@ import { HarnessSpecSchema } from "../../projectSchemas/harness"; import type { ProjectSpecSchema } from "../../projectSchemas/project"; import type z from "zod"; -import type { ProjectRuntimeSchema } from "../../projectSchemas/runtime"; +import type { + BuildType, + EnvVar, + FilesystemConfiguration, + LifecycleConfiguration, + NetworkConfig, +} from "../../projectSchemas/runtime"; +import type { AuthorizerConfig, RuntimeAuthorizerType } from "../../projectSchemas/auth"; +import type { NetworkMode, ProtocolMode, RuntimeVersion } from "../../projectSchemas/constants"; -/** Available project templates for scaffolding new AgentCore projects. */ -export const PROJECT_TEMPLATES = { +/** Available runtime templates for scaffolding agent code. A subset of {@link PROJECT_TEMPLATES} */ +export const RUNTIME_TEMPLATES = { HELLO_WORLD_PYTHON: "hello-world-python", HELLO_WORLD_PYTHON_CONTAINER: "hello-world-python-container", } as const; +export type RuntimeTemplate = (typeof RUNTIME_TEMPLATES)[keyof typeof RUNTIME_TEMPLATES]; + +/** Available project templates for scaffolding new AgentCore projects. */ +export const PROJECT_TEMPLATES = { + ...RUNTIME_TEMPLATES, +} as const; + export type ProjectTemplate = (typeof PROJECT_TEMPLATES)[keyof typeof PROJECT_TEMPLATES]; export type CreateProjectInput = { @@ -40,6 +55,44 @@ export type Project = { spec: z.infer; }; +/** Shared infrastructure config fields for a runtime (independent of source type). */ +type RuntimeInfraConfig = { + name: string; + description?: string; + executionRoleArn?: string; + additionalPolicies?: string[]; + envVars?: EnvVar[]; + networkMode?: NetworkMode; + networkConfig?: NetworkConfig; + authorizerType?: RuntimeAuthorizerType; + authorizerConfiguration?: AuthorizerConfig; + protocol?: ProtocolMode; + requestHeaderAllowlist?: string[]; + lifecycleConfiguration?: LifecycleConfiguration; + filesystemConfigurations?: FilesystemConfiguration[]; + tags?: Record; +}; + +/** BYO path: user provides existing code location and build config. */ +type RuntimeByoConfig = RuntimeInfraConfig & { + source: "byo"; + codeLocation: string; + build?: BuildType; + entrypoint?: string; + runtimeVersion?: RuntimeVersion; + dockerfile?: string; + buildContextPath?: string; + customDockerBuildArgs?: Record; +}; + +/** Template path: CLI scaffolds agent code from a template. */ +type RuntimeTemplateConfig = RuntimeInfraConfig & { + source: "template"; + template: string; +}; + +export type RuntimeResourceConfig = RuntimeByoConfig | RuntimeTemplateConfig; + /** Discriminated union input for {@link ProjectManager.addResource}. */ export type AddResourceInput = | { @@ -48,7 +101,7 @@ export type AddResourceInput = } | { resourceType: "runtime"; - resourceConfig: Omit, "codeLocation" | "runtimeVersion">; + resourceConfig: RuntimeResourceConfig; }; export type ProjectResource = AddResourceInput["resourceType"]; From 360dda2781120d93e4cf1990404e38e7cedc725a Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Tue, 18 Aug 2026 00:53:15 +0000 Subject: [PATCH 03/15] chore(runtime): undo entrypoint schema change, punt to follow-up --- src/handlers/project/add/runtime/index.ts | 9 ++------- src/handlers/project/project.test.ts | 4 ---- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/src/handlers/project/add/runtime/index.ts b/src/handlers/project/add/runtime/index.ts index ead37907c..ee1788bcf 100644 --- a/src/handlers/project/add/runtime/index.ts +++ b/src/handlers/project/add/runtime/index.ts @@ -146,13 +146,8 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => flags["environment-variables"], ); - const build = flags.build; - const entrypoint = flags.entrypoint; - - if (entrypoint && build === "Container") - throw new InputValidationError( - "code entrypoint cannot be provided with Container build type", - ); + // TODO: make entrypoint optional since container agents don't need it. + const entrypoint = flags.entrypoint ?? "main.py"; const network = toNetwork(inputNetwork); diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index 6fd6374ff..ce6947b87 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -928,10 +928,6 @@ describe("project add runtime", () => { "--template and --code-location are mutually exclusive", ["--name", "my_agent", "--template", "hello-world-python", "--code-location", "app/agent"], ], - [ - "entrypoint cannot be provided with Container build type", - ["--name", "my_agent", ...byo, "--build", "Container", "--entrypoint", "main.py"], - ], [ "--custom-docker-build-args requires --dockerfile or --build-context-path", [ From 559facfc20515805eb1559fc00dd10e445461b48 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Tue, 18 Aug 2026 14:01:03 +0000 Subject: [PATCH 04/15] refactor(runtime): clean up handler flags --- src/handlers/project/add/runtime/index.ts | 52 +++++++---------------- src/handlers/project/project.test.ts | 49 +++++++++++---------- src/handlers/project/types.ts | 4 +- src/projectSchemas/runtime.ts | 2 +- 4 files changed, 42 insertions(+), 65 deletions(-) diff --git a/src/handlers/project/add/runtime/index.ts b/src/handlers/project/add/runtime/index.ts index ee1788bcf..7b6b16ee3 100644 --- a/src/handlers/project/add/runtime/index.ts +++ b/src/handlers/project/add/runtime/index.ts @@ -14,26 +14,27 @@ import type { import { type EnvVar, type FilesystemConfiguration as ProjectFilesystemConfiguration, - type LifecycleConfiguration as ProjectLifecycleConfiguration, type NetworkConfig, BuildTypeSchema, } from "../../../../projectSchemas/runtime"; import type { AuthorizerConfig, RuntimeAuthorizerType } from "../../../../projectSchemas/auth"; -import { - type NetworkMode, - type ProtocolMode, - RuntimeVersionSchema, -} from "../../../../projectSchemas/constants"; +import { type NetworkMode, RuntimeVersionSchema } from "../../../../projectSchemas/constants"; import { RUNTIME_TEMPLATES } from "../../types"; export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => createHandler({ name: "runtime", - description: "adds a runtime to the current project either from a template or BYO", + description: + "adds a runtime to the current project either from a template or from existing local code", flags: [ flag("name", "the name of the runtime", z.string().optional()), - flag("description", "description of the runtime", z.string().optional()), - flag("template", "runtime template to scaffold from", z.enum(RUNTIME_TEMPLATES).optional()), + flag("description", "an optional description of the runtime", z.string().optional()), + flag("template", "template to scaffold from", z.enum(RUNTIME_TEMPLATES).optional()), + flag( + "role-arn", + "IAM role ARN that provides permissions for the runtime", + z.string().optional(), + ), flag("code-location", "path to existing agent source code (BYO path)", z.string().optional()), flag("build", "build type: CodeZip or Container (BYO only)", BuildTypeSchema.optional()), flag("entrypoint", "entrypoint file, e.g. main.py:handler (BYO only)", z.string().optional()), @@ -44,7 +45,7 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => ), flag( "dockerfile", - "dockerfile for the container build (BYO Container only)", + "dockerfile path for the container build (BYO Container only)", z.string().optional(), ), flag( @@ -57,14 +58,10 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => "docker build args as JSON key/value object (BYO Container only)", z.string().optional(), ), - flag( - "role-arn", - "IAM role ARN that provides permissions for the runtime", - z.string().optional(), - ), + flag( "additional-policies", - "additional IAM policy ARNs or policy document paths", + "additional IAM policy ARNs or policy document paths for the execution role", z.array(z.string()).optional(), ), flag( @@ -162,9 +159,7 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => ); const auth = toAuthorizer(inputAuthConfig); - const protocol = toProtocol(inputProtocol); const requestHeaderAllowlist = toRequestHeaderAllowlist(inputRequestHeaders); - const lifecycleConfiguration = toLifecycle(inputLifecycle); const filesystemConfigurations = toFilesystems(inputFilesystems); const infraConfig = { @@ -179,9 +174,9 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => : undefined, authorizerType: auth?.authorizerType, authorizerConfiguration: auth?.authorizerConfiguration, - protocol, + protocol: inputProtocol?.serverProtocol, requestHeaderAllowlist, - lifecycleConfiguration, + lifecycleConfiguration: inputLifecycle, filesystemConfigurations, tags: parseJsonFlag>("tags", flags["tags"]), }; @@ -261,12 +256,6 @@ function toAuthorizer( throw new InputValidationError("Unrecognized authorizer configuration variant"); } -/** Converts API ProtocolConfiguration wrapper to project schema ProtocolMode enum. */ -function toProtocol(protocol: ProtocolConfiguration | undefined): ProtocolMode | undefined { - if (!protocol) return undefined; - return protocol.serverProtocol as ProtocolMode; -} - /** Unwraps API RequestHeaderConfiguration union to project schema string[]. */ function toRequestHeaderAllowlist( headers: RequestHeaderConfiguration | undefined, @@ -278,17 +267,6 @@ function toRequestHeaderAllowlist( throw new InputValidationError("Unrecognized request header configuration variant"); } -/** Converts API LifecycleConfiguration to project schema LifecycleConfiguration. */ -function toLifecycle( - lifecycle: LifecycleConfiguration | undefined, -): ProjectLifecycleConfiguration | undefined { - if (!lifecycle) return undefined; - return { - idleRuntimeSessionTimeout: lifecycle.idleRuntimeSessionTimeout, - maxLifetime: lifecycle.maxLifetime, - }; -} - /** Converts API FilesystemConfiguration[] tagged unions to project schema format. */ function toFilesystems( filesystems: FilesystemConfiguration[] | undefined, diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index ce6947b87..2c5dff4ba 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -112,7 +112,7 @@ describe("project create", () => { describe("project add harness", () => { const defaultModel = { provider: "bedrock", modelId: "global.anthropic.claude-sonnet-4-6" }; - // Verifies each flag combination produces the expected harness config. + /** Verify error case for different flags **/ test.each<[string, string[], Record]>([ ["minimal — name only", ["--name", "x"], { model: defaultModel }], [ @@ -610,7 +610,6 @@ describe("project add harness", () => { ); }); - // Rejects invalid flag combinations with InputValidationError. test.each([ ["missing --name", ["--model", '{"bedrockModelConfig":{"modelId":"x"}}']], ["model without modelId", ["--name", "x", "--model", '{"bedrockModelConfig":{}}']], @@ -721,11 +720,11 @@ describe("project build", () => { // FsProjectManager.addResource supports the "runtime" resource type. describe("project add runtime", () => { const byo = ["--code-location", "app/my_agent"]; - const tpl = ["--template", "hello-world-python"]; + const template = ["--template", "hello-world-python"]; // Verifies each valid flag combination passes handler validation. test.each<[string, string[]]>([ - ["minimal — template path", ["--name", "my_agent", ...tpl]], + ["minimal — template path", ["--name", "my_agent", ...template]], ["minimal — BYO path with build", ["--name", "my_agent", ...byo, "--build", "CodeZip"]], [ "BYO container with dockerfile", @@ -745,53 +744,53 @@ describe("project add runtime", () => { "PYTHON_3_13", ], ], - ["description", ["--name", "my_agent", ...tpl, "--description", "A test agent"]], + ["description", ["--name", "my_agent", ...template, "--description", "A test agent"]], [ "role-arn", - ["--name", "my_agent", ...tpl, "--role-arn", "arn:aws:iam::123456789012:role/MyRole"], + ["--name", "my_agent", ...template, "--role-arn", "arn:aws:iam::123456789012:role/MyRole"], ], [ "network-configuration — VPC", [ "--name", "my_agent", - ...tpl, + ...template, "--network-configuration", '{"networkMode":"VPC","networkModeConfig":{"subnets":["subnet-abc"],"securityGroups":["sg-123"]}}', ], ], [ "network-configuration — PUBLIC", - ["--name", "my_agent", ...tpl, "--network-configuration", '{"networkMode":"PUBLIC"}'], + ["--name", "my_agent", ...template, "--network-configuration", '{"networkMode":"PUBLIC"}'], ], [ "authorizer-configuration — customJWT", [ "--name", "my_agent", - ...tpl, + ...template, "--authorizer-configuration", '{"customJWTAuthorizer":{"discoveryUrl":"https://idp.example.com/.well-known/openid-configuration","allowedAudience":["app"]}}', ], ], [ "protocol-configuration — MCP", - ["--name", "my_agent", ...tpl, "--protocol-configuration", '{"serverProtocol":"MCP"}'], + ["--name", "my_agent", ...template, "--protocol-configuration", '{"serverProtocol":"MCP"}'], ], [ "protocol-configuration — A2A", - ["--name", "my_agent", ...tpl, "--protocol-configuration", '{"serverProtocol":"A2A"}'], + ["--name", "my_agent", ...template, "--protocol-configuration", '{"serverProtocol":"A2A"}'], ], [ "protocol-configuration — AGUI", - ["--name", "my_agent", ...tpl, "--protocol-configuration", '{"serverProtocol":"AGUI"}'], + ["--name", "my_agent", ...template, "--protocol-configuration", '{"serverProtocol":"AGUI"}'], ], [ "request-header-configuration", [ "--name", "my_agent", - ...tpl, + ...template, "--request-header-configuration", '{"requestHeaderAllowlist":["X-Custom-Header","Authorization"]}', ], @@ -801,7 +800,7 @@ describe("project add runtime", () => { [ "--name", "my_agent", - ...tpl, + ...template, "--lifecycle-configuration", '{"idleRuntimeSessionTimeout":300,"maxLifetime":3600}', ], @@ -811,7 +810,7 @@ describe("project add runtime", () => { [ "--name", "my_agent", - ...tpl, + ...template, "--environment-variables", '{"LOG_LEVEL":"debug","APP_ENV":"staging"}', ], @@ -821,7 +820,7 @@ describe("project add runtime", () => { [ "--name", "my_agent", - ...tpl, + ...template, "--filesystem-configurations", '[{"sessionStorage":{"mountPath":"/mnt/data"}}]', ], @@ -831,7 +830,7 @@ describe("project add runtime", () => { [ "--name", "my_agent", - ...tpl, + ...template, "--filesystem-configurations", '[{"efsAccessPoint":{"accessPointArn":"arn:aws:elasticfilesystem:us-east-1:123456789012:access-point/fsap-abc","mountPath":"/mnt/efs"}}]', ], @@ -841,12 +840,12 @@ describe("project add runtime", () => { [ "--name", "my_agent", - ...tpl, + ...template, "--filesystem-configurations", '[{"s3FilesAccessPoint":{"accessPointArn":"arn:aws:s3files:us-east-1:123456789012:file-system/fs-abc/access-point/fsap-def","mountPath":"/mnt/s3"}}]', ], ], - ["tags", ["--name", "my_agent", ...tpl, "--tags", '{"team":"ml","env":"prod"}']], + ["tags", ["--name", "my_agent", ...template, "--tags", '{"team":"ml","env":"prod"}']], [ "dockerfile + build-context-path", [ @@ -894,7 +893,7 @@ describe("project add runtime", () => { [ "--name", "my_agent", - ...tpl, + ...template, "--additional-policies", "arn:aws:iam::123456789012:policy/MyPolicy", ], @@ -956,32 +955,32 @@ describe("project add runtime", () => { ], [ "unrecognized authorizer configuration variant", - ["--name", "my_agent", ...tpl, "--authorizer-configuration", '{"unknownAuth":{}}'], + ["--name", "my_agent", ...template, "--authorizer-configuration", '{"unknownAuth":{}}'], ], [ "missing discoveryUrl in authorizer", [ "--name", "my_agent", - ...tpl, + ...template, "--authorizer-configuration", '{"customJWTAuthorizer":{"allowedAudience":["a"]}}', ], ], [ "unrecognized filesystem configuration variant", - ["--name", "my_agent", ...tpl, "--filesystem-configurations", '[{"unknownFs":{}}]'], + ["--name", "my_agent", ...template, "--filesystem-configurations", '[{"unknownFs":{}}]'], ], [ "invalid JSON in --network-configuration", - ["--name", "my_agent", ...tpl, "--network-configuration", "{bad}"], + ["--name", "my_agent", ...template, "--network-configuration", "{bad}"], ], [ "unrecognized request header configuration variant", [ "--name", "my_agent", - ...tpl, + ...template, "--request-header-configuration", '{"unknownVariant":["X-Foo"]}', ], diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index 771a86a69..be8952fbf 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -11,7 +11,7 @@ import type { import type { AuthorizerConfig, RuntimeAuthorizerType } from "../../projectSchemas/auth"; import type { NetworkMode, ProtocolMode, RuntimeVersion } from "../../projectSchemas/constants"; -/** Available runtime templates for scaffolding agent code. A subset of {@link PROJECT_TEMPLATES} */ +/** Available runtime templates for scaffolding agent code. A subset of {@link PROJECT_TEMPLATES} describing runtimes only */ export const RUNTIME_TEMPLATES = { HELLO_WORLD_PYTHON: "hello-world-python", HELLO_WORLD_PYTHON_CONTAINER: "hello-world-python-container", @@ -88,7 +88,7 @@ type RuntimeByoConfig = RuntimeInfraConfig & { /** Template path: CLI scaffolds agent code from a template. */ type RuntimeTemplateConfig = RuntimeInfraConfig & { source: "template"; - template: string; + template: RuntimeTemplate; }; export type RuntimeResourceConfig = RuntimeByoConfig | RuntimeTemplateConfig; diff --git a/src/projectSchemas/runtime.ts b/src/projectSchemas/runtime.ts index 33cb295da..aba8d291b 100644 --- a/src/projectSchemas/runtime.ts +++ b/src/projectSchemas/runtime.ts @@ -241,7 +241,7 @@ export const ProjectRuntimeSchema = z name: AgentNameSchema, description: z.string().max(200).optional(), build: BuildTypeSchema, - entrypoint: EntrypointSchema.optional(), + entrypoint: EntrypointSchema, codeLocation: DirectoryPathSchema, dockerfile: DockerfilePathSchema.optional(), buildContextPath: DirectoryPathSchema.optional(), From a51a3a6a5265b612018cbd2f86514515f25c8239 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Tue, 18 Aug 2026 14:08:43 +0000 Subject: [PATCH 05/15] feat(runtime): default to hello world python --- src/handlers/project/add/runtime/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/handlers/project/add/runtime/index.ts b/src/handlers/project/add/runtime/index.ts index 7b6b16ee3..318d3b188 100644 --- a/src/handlers/project/add/runtime/index.ts +++ b/src/handlers/project/add/runtime/index.ts @@ -29,7 +29,11 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => flags: [ flag("name", "the name of the runtime", z.string().optional()), flag("description", "an optional description of the runtime", z.string().optional()), - flag("template", "template to scaffold from", z.enum(RUNTIME_TEMPLATES).optional()), + flag( + "template", + "template to scaffold from", + z.enum(RUNTIME_TEMPLATES).default(RUNTIME_TEMPLATES.HELLO_WORLD_PYTHON), + ), flag( "role-arn", "IAM role ARN that provides permissions for the runtime", From 4adff645ad82bbf8b3fd3b6081514477cc7c1104 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Tue, 18 Aug 2026 14:14:51 +0000 Subject: [PATCH 06/15] fix(runtime): remove dead space --- src/handlers/project/add/runtime/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/handlers/project/add/runtime/index.ts b/src/handlers/project/add/runtime/index.ts index 318d3b188..0d1eb403f 100644 --- a/src/handlers/project/add/runtime/index.ts +++ b/src/handlers/project/add/runtime/index.ts @@ -62,7 +62,6 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => "docker build args as JSON key/value object (BYO Container only)", z.string().optional(), ), - flag( "additional-policies", "additional IAM policy ARNs or policy document paths for the execution role", From f97e5127a07a375ed2e8f579027f84336846392d Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Tue, 18 Aug 2026 19:00:07 +0000 Subject: [PATCH 07/15] feat(runtime): bring parity with old command --- src/handlers/project/add/runtime/index.ts | 97 ++++++++++++++++++++--- src/handlers/project/project.test.ts | 75 ++++++++++++++++++ src/handlers/project/types.ts | 27 ++++++- 3 files changed, 187 insertions(+), 12 deletions(-) diff --git a/src/handlers/project/add/runtime/index.ts b/src/handlers/project/add/runtime/index.ts index 0d1eb403f..ef3ab1c62 100644 --- a/src/handlers/project/add/runtime/index.ts +++ b/src/handlers/project/add/runtime/index.ts @@ -3,13 +3,14 @@ import { createHandler, flag, ProjectKey } from "../../../../router"; import type { AddProjectResourceConfig } from "../types"; import { parseJsonFlag } from "../../../utils"; import { InputValidationError } from "../../../../errors"; -import type { - AuthorizerConfiguration, - FilesystemConfiguration, - LifecycleConfiguration, - NetworkConfiguration, - ProtocolConfiguration, - RequestHeaderConfiguration, +import { + ServerProtocol, + type AuthorizerConfiguration, + type FilesystemConfiguration, + type LifecycleConfiguration, + type NetworkConfiguration, + type ProtocolConfiguration, + type RequestHeaderConfiguration, } from "@aws-sdk/client-bedrock-agentcore-control"; import { type EnvVar, @@ -19,7 +20,12 @@ import { } from "../../../../projectSchemas/runtime"; import type { AuthorizerConfig, RuntimeAuthorizerType } from "../../../../projectSchemas/auth"; import { type NetworkMode, RuntimeVersionSchema } from "../../../../projectSchemas/constants"; -import { RUNTIME_TEMPLATES } from "../../types"; +import { + runtimeModelProviderSchema, + RUNTIME_TEMPLATES, + runtimeMemoryConfigSchema, +} from "../../types"; +import { SourceResolver } from "../../../../io"; export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => createHandler({ @@ -40,8 +46,23 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => z.string().optional(), ), flag("code-location", "path to existing agent source code (BYO path)", z.string().optional()), - flag("build", "build type: CodeZip or Container (BYO only)", BuildTypeSchema.optional()), + flag("build", "build type: CodeZip or Container", BuildTypeSchema.optional()), flag("entrypoint", "entrypoint file, e.g. main.py:handler (BYO only)", z.string().optional()), + flag( + "protocol", + "remote server protocol for the runtime (ex. http, mcp, a2a, etc.) shorthand for --protocol-configuration", + z.string().optional(), + ), + flag( + "api-key", + "API key source for non-bedrock model providers: '-' for stdin, 'file://path' for file", + z.string().optional(), + ), + flag( + "model-provider", + "model provider (template only)", + runtimeModelProviderSchema.optional(), + ), flag( "runtime-version", "language runtime, e.g. PYTHON_3_13, NODE_22 (BYO CodeZip only)", @@ -107,6 +128,11 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => "filesystem mount configurations (JSON FilesystemConfiguration[])", z.string().optional(), ), + flag( + "memory", + "memory configuration (JSON with mode: none | create | existing ) (template only)", + z.string().optional(), + ), flag("tags", "tags to apply (JSON object of key/value strings)", z.string().optional()), ], handle: async (ctx, flags) => { @@ -117,6 +143,29 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => if (sourceCount !== 1) throw new InputValidationError("exactly one of --template or --code-location is required"); + const isTemplate = Boolean(flags.template); + const templateOnlyFlags = (["memory", "model-provider", "api-key"] as const).filter( + (f) => flags[f], + ); + const byoOnlyFlags = ( + [ + "entrypoint", + "runtime-version", + "dockerfile", + "build-context-path", + "custom-docker-build-args", + ] as const + ).filter((f) => flags[f]); + + if (isTemplate && byoOnlyFlags.length > 0) + throw new InputValidationError( + `--${byoOnlyFlags[0]} is only available on the BYO path (--code-location)`, + ); + if (!isTemplate && templateOnlyFlags.length > 0) + throw new InputValidationError( + `--${templateOnlyFlags[0]} is only available on the template path (--template)`, + ); + const inputNetwork = parseJsonFlag( "network-configuration", flags["network-configuration"], @@ -145,12 +194,16 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => "environment-variables", flags["environment-variables"], ); + const memoryConfiguration = parseMemoryConfig(flags["memory"]); // TODO: make entrypoint optional since container agents don't need it. const entrypoint = flags.entrypoint ?? "main.py"; const network = toNetwork(inputNetwork); + const source = new SourceResolver({ stdin: config.io.stdin }); + const apiKey = await source.resolveText("api-key", flags["api-key"]); + if (flags["custom-docker-build-args"] && !flags.dockerfile && !flags["build-context-path"]) throw new InputValidationError( "--custom-docker-build-args requires --dockerfile or --build-context-path", @@ -161,6 +214,11 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => "--vpc-id requires --network-configuration with VPC network configuration", ); + if (flags["protocol"] && flags["protocol-configuration"]) + throw new InputValidationError( + "--protocol and --protocol-configuration are mutually exclusive", + ); + const auth = toAuthorizer(inputAuthConfig); const requestHeaderAllowlist = toRequestHeaderAllowlist(inputRequestHeaders); const filesystemConfigurations = toFilesystems(inputFilesystems); @@ -177,7 +235,7 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => : undefined, authorizerType: auth?.authorizerType, authorizerConfiguration: auth?.authorizerConfiguration, - protocol: inputProtocol?.serverProtocol, + protocol: (flags["protocol"] as ServerProtocol) ?? inputProtocol?.serverProtocol, requestHeaderAllowlist, lifecycleConfiguration: inputLifecycle, filesystemConfigurations, @@ -185,7 +243,13 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => }; const runtimeConfig = flags.template - ? { source: "template" as const, template: flags.template, ...infraConfig } + ? { + source: "template" as const, + template: flags.template, + memory: memoryConfiguration, + modelProvider: { apiKey, provider: flags["model-provider"] }, + ...infraConfig, + } : { source: "byo" as const, codeLocation: flags["code-location"]!, @@ -213,6 +277,17 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => }, }); +/** Parses and validates the --memory JSON flag against the runtime memory config schema. */ +function parseMemoryConfig( + raw: string | undefined, +): z.infer | undefined { + if (!raw) return undefined; + const parsed = parseJsonFlag>("memory", raw); + const result = runtimeMemoryConfigSchema.safeParse(parsed); + if (!result.success) throw new InputValidationError(z.prettifyError(result.error)); + return result.data; +} + /** Converts API flat {key: value} map to project schema [{name, value}] array. */ function toEnvironmentVariables(envVars: Record | undefined): EnvVar[] { return envVars ? Object.entries(envVars).map(([name, value]) => ({ name, value })) : []; diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index 2c5dff4ba..389754afb 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -898,6 +898,33 @@ describe("project add runtime", () => { "arn:aws:iam::123456789012:policy/MyPolicy", ], ], + ["protocol shortcut", ["--name", "my_agent", ...template, "--protocol", "MCP"]], + [ + "memory — create with strategies", + [ + "--name", + "my_agent", + ...template, + "--memory", + '{"mode":"create","strategies":["SEMANTIC","EPISODIC"]}', + ], + ], + [ + "memory — existing by ARN", + [ + "--name", + "my_agent", + ...template, + "--memory", + '{"mode":"existing","arn":"arn:aws:bedrock-agentcore:us-east-1:123456789012:memory/MyMem"}', + ], + ], + ["memory — disabled", ["--name", "my_agent", ...template, "--memory", '{"mode":"disabled"}']], + ["model-provider — openai", ["--name", "my_agent", ...template, "--model-provider", "openai"]], + [ + "build on template path (overlay)", + ["--name", "my_agent", ...template, "--build", "Container"], + ], [ "vpc-id with VPC network configuration", [ @@ -985,6 +1012,54 @@ describe("project add runtime", () => { '{"unknownVariant":["X-Foo"]}', ], ], + [ + "--protocol and --protocol-configuration are mutually exclusive", + [ + "--name", + "my_agent", + ...template, + "--protocol", + "MCP", + "--protocol-configuration", + '{"serverProtocol":"MCP"}', + ], + ], + [ + "--entrypoint is only available on BYO path", + ["--name", "my_agent", ...template, "--entrypoint", "main.py"], + ], + [ + "--runtime-version is only available on BYO path", + ["--name", "my_agent", ...template, "--runtime-version", "PYTHON_3_13"], + ], + [ + "--dockerfile is only available on BYO path", + ["--name", "my_agent", ...template, "--dockerfile", "Dockerfile"], + ], + [ + "--build-context-path is only available on BYO path", + ["--name", "my_agent", ...template, "--build-context-path", "."], + ], + [ + "--custom-docker-build-args is only available on BYO path", + ["--name", "my_agent", ...template, "--custom-docker-build-args", '{"KEY":"val"}'], + ], + [ + "--memory is only available on template path", + ["--name", "my_agent", ...byo, "--memory", '{"mode":"disabled"}'], + ], + [ + "--model-provider is only available on template path", + ["--name", "my_agent", ...byo, "--model-provider", "openai"], + ], + [ + "--api-key is only available on template path", + ["--name", "my_agent", ...byo, "--api-key", "-"], + ], + [ + "invalid memory JSON schema", + ["--name", "my_agent", ...template, "--memory", '{"mode":"invalid"}'], + ], ])("%s", async (_label, flags) => { await inProject(); await expect(run(["add", "runtime", ...flags])).rejects.toBeInstanceOf(InputValidationError); diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index be8952fbf..79a43c7ff 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -1,6 +1,6 @@ import { HarnessSpecSchema } from "../../projectSchemas/harness"; import type { ProjectSpecSchema } from "../../projectSchemas/project"; -import type z from "zod"; +import z from "zod"; import type { BuildType, EnvVar, @@ -10,6 +10,7 @@ import type { } from "../../projectSchemas/runtime"; import type { AuthorizerConfig, RuntimeAuthorizerType } from "../../projectSchemas/auth"; import type { NetworkMode, ProtocolMode, RuntimeVersion } from "../../projectSchemas/constants"; +import { MemoryStrategyType } from "@aws-sdk/client-bedrock-agentcore-control"; /** Available runtime templates for scaffolding agent code. A subset of {@link PROJECT_TEMPLATES} describing runtimes only */ export const RUNTIME_TEMPLATES = { @@ -89,6 +90,8 @@ type RuntimeByoConfig = RuntimeInfraConfig & { type RuntimeTemplateConfig = RuntimeInfraConfig & { source: "template"; template: RuntimeTemplate; + memory?: RuntimeMemoryConfig; + modelProvider?: RuntimeModelProviderConfig; }; export type RuntimeResourceConfig = RuntimeByoConfig | RuntimeTemplateConfig; @@ -122,3 +125,25 @@ export interface ProjectManager { /** Add a resource to an existing AgentCore project. */ addResource(project: Project, input: AddResourceInput): AsyncGenerator; } + +export const runtimeModelProviderSchema = z.enum(["bedrock", "anthropic", "openai", "gemini"]); +export type RuntimeModelProvider = z.infer; + +export type RuntimeModelProviderConfig = { + provider?: RuntimeModelProvider; + apiKey?: string; +}; + +export const runtimeMemoryConfigSchema = z.discriminatedUnion("mode", [ + z.object({ mode: z.literal("disabled") }), + z.object({ + mode: z.literal("create"), + strategies: z.array(z.enum(Object.values(MemoryStrategyType))), + }), + z.object({ + mode: z.literal("existing"), + arn: z.string().min(1), + }), +]); + +export type RuntimeMemoryConfig = z.input; From 0ad1145bf3868036a58eb7e35e1092c402d65e0a Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Tue, 18 Aug 2026 19:18:59 +0000 Subject: [PATCH 08/15] fix(runtime): default runtime to hello world template --- src/handlers/project/add/runtime/index.ts | 18 +++++++----------- src/handlers/project/project.test.ts | 4 ++-- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/src/handlers/project/add/runtime/index.ts b/src/handlers/project/add/runtime/index.ts index ef3ab1c62..48d3cc19e 100644 --- a/src/handlers/project/add/runtime/index.ts +++ b/src/handlers/project/add/runtime/index.ts @@ -35,11 +35,7 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => flags: [ flag("name", "the name of the runtime", z.string().optional()), flag("description", "an optional description of the runtime", z.string().optional()), - flag( - "template", - "template to scaffold from", - z.enum(RUNTIME_TEMPLATES).default(RUNTIME_TEMPLATES.HELLO_WORLD_PYTHON), - ), + flag("template", "template to scaffold from", z.enum(RUNTIME_TEMPLATES).optional()), flag( "role-arn", "IAM role ARN that provides permissions for the runtime", @@ -139,11 +135,11 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => if (!flags.name) throw new InputValidationError("required option '--name ' not specified"); - const sourceCount = [flags.template, flags["code-location"]].filter(Boolean).length; - if (sourceCount !== 1) - throw new InputValidationError("exactly one of --template or --code-location is required"); + if (flags.template && flags["code-location"]) + throw new InputValidationError("--template and --code-location are mutually exclusive"); - const isTemplate = Boolean(flags.template); + const isTemplate = !flags["code-location"]; + const template = flags.template ?? RUNTIME_TEMPLATES.HELLO_WORLD_PYTHON; const templateOnlyFlags = (["memory", "model-provider", "api-key"] as const).filter( (f) => flags[f], ); @@ -242,10 +238,10 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => tags: parseJsonFlag>("tags", flags["tags"]), }; - const runtimeConfig = flags.template + const runtimeConfig = isTemplate ? { source: "template" as const, - template: flags.template, + template, memory: memoryConfiguration, modelProvider: { apiKey, provider: flags["model-provider"] }, ...infraConfig, diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index 389754afb..d5b4a63dc 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -724,7 +724,8 @@ describe("project add runtime", () => { // Verifies each valid flag combination passes handler validation. test.each<[string, string[]]>([ - ["minimal — template path", ["--name", "my_agent", ...template]], + ["minimal — name only (defaults to template)", ["--name", "my_agent"]], + ["explicit template path", ["--name", "my_agent", ...template]], ["minimal — BYO path with build", ["--name", "my_agent", ...byo, "--build", "CodeZip"]], [ "BYO container with dockerfile", @@ -949,7 +950,6 @@ describe("project add runtime", () => { // Rejects invalid flag combinations with InputValidationError. test.each<[string, string[]]>([ ["missing --name", ["--template", "hello-world-python"]], - ["missing both --template and --code-location", ["--name", "my_agent"]], [ "--template and --code-location are mutually exclusive", ["--name", "my_agent", "--template", "hello-world-python", "--code-location", "app/agent"], From ef9de8d82898cd223777c88bb4272a0c9011c890 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Tue, 18 Aug 2026 19:24:18 +0000 Subject: [PATCH 09/15] fix(runtime): strongly type protocol --- src/handlers/project/add/runtime/index.ts | 29 +++++++++++------------ 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/src/handlers/project/add/runtime/index.ts b/src/handlers/project/add/runtime/index.ts index 48d3cc19e..f26779760 100644 --- a/src/handlers/project/add/runtime/index.ts +++ b/src/handlers/project/add/runtime/index.ts @@ -3,14 +3,13 @@ import { createHandler, flag, ProjectKey } from "../../../../router"; import type { AddProjectResourceConfig } from "../types"; import { parseJsonFlag } from "../../../utils"; import { InputValidationError } from "../../../../errors"; -import { - ServerProtocol, - type AuthorizerConfiguration, - type FilesystemConfiguration, - type LifecycleConfiguration, - type NetworkConfiguration, - type ProtocolConfiguration, - type RequestHeaderConfiguration, +import type { + AuthorizerConfiguration, + FilesystemConfiguration, + LifecycleConfiguration, + NetworkConfiguration, + ProtocolConfiguration, + RequestHeaderConfiguration, } from "@aws-sdk/client-bedrock-agentcore-control"; import { type EnvVar, @@ -19,7 +18,11 @@ import { BuildTypeSchema, } from "../../../../projectSchemas/runtime"; import type { AuthorizerConfig, RuntimeAuthorizerType } from "../../../../projectSchemas/auth"; -import { type NetworkMode, RuntimeVersionSchema } from "../../../../projectSchemas/constants"; +import { + type NetworkMode, + ProtocolModeSchema, + RuntimeVersionSchema, +} from "../../../../projectSchemas/constants"; import { runtimeModelProviderSchema, RUNTIME_TEMPLATES, @@ -44,11 +47,7 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => flag("code-location", "path to existing agent source code (BYO path)", z.string().optional()), flag("build", "build type: CodeZip or Container", BuildTypeSchema.optional()), flag("entrypoint", "entrypoint file, e.g. main.py:handler (BYO only)", z.string().optional()), - flag( - "protocol", - "remote server protocol for the runtime (ex. http, mcp, a2a, etc.) shorthand for --protocol-configuration", - z.string().optional(), - ), + flag("protocol", "server protocol ex. HTTP, MCP, A2A, AGUI", ProtocolModeSchema.optional()), flag( "api-key", "API key source for non-bedrock model providers: '-' for stdin, 'file://path' for file", @@ -231,7 +230,7 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => : undefined, authorizerType: auth?.authorizerType, authorizerConfiguration: auth?.authorizerConfiguration, - protocol: (flags["protocol"] as ServerProtocol) ?? inputProtocol?.serverProtocol, + protocol: flags["protocol"] ?? inputProtocol?.serverProtocol, requestHeaderAllowlist, lifecycleConfiguration: inputLifecycle, filesystemConfigurations, From 9316bd8afa46a64e164d62500b7df6f27d44e276 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Wed, 19 Aug 2026 01:18:51 +0000 Subject: [PATCH 10/15] refactor: add-runtime flags mirror spec schema, remove toX mappers --- .../project/add/runtime/index.test.ts | 336 +++++++++++++++++ src/handlers/project/add/runtime/index.ts | 207 ++-------- src/handlers/project/project.test.ts | 352 +----------------- 3 files changed, 376 insertions(+), 519 deletions(-) create mode 100644 src/handlers/project/add/runtime/index.test.ts diff --git a/src/handlers/project/add/runtime/index.test.ts b/src/handlers/project/add/runtime/index.test.ts new file mode 100644 index 000000000..25fc49b22 --- /dev/null +++ b/src/handlers/project/add/runtime/index.test.ts @@ -0,0 +1,336 @@ +import { afterEach, describe, expect, test } 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, + TestCoreClient, + TestGlobalConfigAccessor, + testIO, +} from "../../../../testing"; +import { InputValidationError, NotImplementedError } from "../../../../errors"; + +const originalCwd = process.cwd(); +const tempDirectories: string[] = []; + +async function inTempDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), "agentcore-runtime-")); + tempDirectories.push(directory); + process.chdir(directory); + return process.cwd(); +} + +afterEach(async () => { + process.chdir(originalCwd); + await Promise.all( + tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +async function run(args: string[]) { + const io = testIO(); + const core = new TestCoreClient(); + const root = createRootHandler(core, { + io: io.io, + globalConfigAccessor: new TestGlobalConfigAccessor(), + logger: createSilentLogger(), + }); + await root.route(["node", "agentcore", "project", ...args]); + return { io, core }; +} + +async function inProject(name = "TestProject"): Promise { + const directory = await inTempDirectory(); + await run(["create", "--name", name, "--skip-install", "--skip-git"]); + const projectRoot = join(directory, name); + process.chdir(projectRoot); + return projectRoot; +} + +// TODO: Replace NotImplementedError assertions with output assertions once +// FsProjectManager.addResource supports the "runtime" resource type. +describe("project add runtime", () => { + const byo = ["--code-location", "app/my_agent"]; + const template = ["--template", "hello-world-python"]; + + test.each<[string, string[]]>([ + ["minimal — name only (defaults to template)", ["--name", "my_agent"]], + ["explicit template path", ["--name", "my_agent", ...template]], + ["minimal — BYO path with build", ["--name", "my_agent", ...byo, "--build", "CodeZip"]], + [ + "BYO container with dockerfile", + ["--name", "my_agent", ...byo, "--build", "Container", "--dockerfile", "Dockerfile"], + ], + [ + "entrypoint + runtime-version for CodeZip", + [ + "--name", + "my_agent", + ...byo, + "--build", + "CodeZip", + "--entrypoint", + "app.py:main", + "--runtime-version", + "PYTHON_3_13", + ], + ], + ["description", ["--name", "my_agent", ...template, "--description", "A test agent"]], + [ + "role-arn", + ["--name", "my_agent", ...template, "--role-arn", "arn:aws:iam::123456789012:role/MyRole"], + ], + [ + "network — VPC", + [ + "--name", + "my_agent", + ...template, + "--network-mode", + "VPC", + "--network-config", + '{"subnets":["subnet-abc"],"securityGroups":["sg-123"]}', + ], + ], + ["network — PUBLIC", ["--name", "my_agent", ...template, "--network-mode", "PUBLIC"]], + [ + "authorizer — customJWT", + [ + "--name", + "my_agent", + ...template, + "--authorizer-type", + "CUSTOM_JWT", + "--authorizer-configuration", + '{"customJwtAuthorizer":{"discoveryUrl":"https://idp.example.com/.well-known/openid-configuration","allowedAudience":["app"]}}', + ], + ], + ["protocol — MCP", ["--name", "my_agent", ...template, "--protocol", "MCP"]], + ["protocol — A2A", ["--name", "my_agent", ...template, "--protocol", "A2A"]], + ["protocol — AGUI", ["--name", "my_agent", ...template, "--protocol", "AGUI"]], + [ + "request-header-allowlist", + [ + "--name", + "my_agent", + ...template, + "--request-header-allowlist", + "X-Custom-Header", + "Authorization", + ], + ], + [ + "lifecycle-configuration", + [ + "--name", + "my_agent", + ...template, + "--lifecycle-configuration", + '{"idleRuntimeSessionTimeout":300,"maxLifetime":3600}', + ], + ], + [ + "environment-variables", + [ + "--name", + "my_agent", + ...template, + "--environment-variables", + '{"LOG_LEVEL":"debug","APP_ENV":"staging"}', + ], + ], + [ + "filesystem-configurations — sessionStorage", + [ + "--name", + "my_agent", + ...template, + "--filesystem-configurations", + '[{"sessionStorage":{"mountPath":"/mnt/data"}}]', + ], + ], + [ + "filesystem-configurations — efsAccessPoint", + [ + "--name", + "my_agent", + ...template, + "--filesystem-configurations", + '[{"efsAccessPoint":{"accessPointArn":"arn:aws:elasticfilesystem:us-east-1:123456789012:access-point/fsap-abc","mountPath":"/mnt/efs"}}]', + ], + ], + [ + "filesystem-configurations — s3FilesAccessPoint", + [ + "--name", + "my_agent", + ...template, + "--filesystem-configurations", + '[{"s3FilesAccessPoint":{"accessPointArn":"arn:aws:s3files:us-east-1:123456789012:file-system/fs-abc/access-point/fsap-def","mountPath":"/mnt/s3"}}]', + ], + ], + ["tags", ["--name", "my_agent", ...template, "--tags", '{"team":"ml","env":"prod"}']], + [ + "dockerfile + build-context-path", + [ + "--name", + "my_agent", + ...byo, + "--build", + "Container", + "--dockerfile", + "docker/Dockerfile.gpu", + "--build-context-path", + ".", + ], + ], + [ + "custom-docker-build-args with dockerfile", + [ + "--name", + "my_agent", + ...byo, + "--build", + "Container", + "--dockerfile", + "Dockerfile", + "--custom-docker-build-args", + '{"AGENT_NAME":"my_agent","VERSION":"1.0"}', + ], + ], + [ + "custom-docker-build-args with build-context-path", + [ + "--name", + "my_agent", + ...byo, + "--build", + "Container", + "--build-context-path", + ".", + "--custom-docker-build-args", + '{"AGENT_NAME":"my_agent"}', + ], + ], + [ + "additional-policies", + [ + "--name", + "my_agent", + ...template, + "--additional-policies", + "arn:aws:iam::123456789012:policy/MyPolicy", + ], + ], + ["protocol shortcut", ["--name", "my_agent", ...template, "--protocol", "MCP"]], + [ + "memory — create with strategies", + [ + "--name", + "my_agent", + ...template, + "--memory", + '{"mode":"create","strategies":["SEMANTIC","EPISODIC"]}', + ], + ], + [ + "memory — existing by ARN", + [ + "--name", + "my_agent", + ...template, + "--memory", + '{"mode":"existing","arn":"arn:aws:bedrock-agentcore:us-east-1:123456789012:memory/MyMem"}', + ], + ], + ["memory — disabled", ["--name", "my_agent", ...template, "--memory", '{"mode":"disabled"}']], + ["model-provider — openai", ["--name", "my_agent", ...template, "--model-provider", "openai"]], + [ + "build on template path (overlay)", + ["--name", "my_agent", ...template, "--build", "Container"], + ], + [ + "network-config with vpcId", + [ + "--name", + "my_agent", + ...byo, + "--build", + "Container", + "--dockerfile", + "Dockerfile", + "--network-mode", + "VPC", + "--network-config", + '{"subnets":["subnet-abc"],"securityGroups":["sg-123"],"vpcId":"vpc-0123456789abcdef0"}', + ], + ], + ])("%s — accepts flags", async (_label, flags) => { + await inProject(); + await expect(run(["add", "runtime", ...flags])).rejects.toBeInstanceOf(NotImplementedError); + }); + + test.each<[string, string[]]>([ + ["missing --name", ["--template", "hello-world-python"]], + [ + "--template and --code-location are mutually exclusive", + ["--name", "my_agent", "--template", "hello-world-python", "--code-location", "app/agent"], + ], + [ + "--custom-docker-build-args requires --dockerfile or --build-context-path", + [ + "--name", + "my_agent", + ...byo, + "--build", + "Container", + "--custom-docker-build-args", + '{"KEY":"value"}', + ], + ], + [ + "invalid JSON in --network-config", + ["--name", "my_agent", ...template, "--network-config", "{bad}"], + ], + [ + "--entrypoint is only available on BYO path", + ["--name", "my_agent", ...template, "--entrypoint", "main.py"], + ], + [ + "--runtime-version is only available on BYO path", + ["--name", "my_agent", ...template, "--runtime-version", "PYTHON_3_13"], + ], + [ + "--dockerfile is only available on BYO path", + ["--name", "my_agent", ...template, "--dockerfile", "Dockerfile"], + ], + [ + "--build-context-path is only available on BYO path", + ["--name", "my_agent", ...template, "--build-context-path", "."], + ], + [ + "--custom-docker-build-args is only available on BYO path", + ["--name", "my_agent", ...template, "--custom-docker-build-args", '{"KEY":"val"}'], + ], + [ + "--memory is only available on template path", + ["--name", "my_agent", ...byo, "--memory", '{"mode":"disabled"}'], + ], + [ + "--model-provider is only available on template path", + ["--name", "my_agent", ...byo, "--model-provider", "openai"], + ], + [ + "--api-key is only available on template path", + ["--name", "my_agent", ...byo, "--api-key", "-"], + ], + [ + "invalid memory JSON schema", + ["--name", "my_agent", ...template, "--memory", '{"mode":"invalid"}'], + ], + ])("%s", async (_label, flags) => { + await inProject(); + await expect(run(["add", "runtime", ...flags])).rejects.toBeInstanceOf(InputValidationError); + }); +}); diff --git a/src/handlers/project/add/runtime/index.ts b/src/handlers/project/add/runtime/index.ts index f26779760..c41d93c23 100644 --- a/src/handlers/project/add/runtime/index.ts +++ b/src/handlers/project/add/runtime/index.ts @@ -3,23 +3,19 @@ import { createHandler, flag, ProjectKey } from "../../../../router"; import type { AddProjectResourceConfig } from "../types"; import { parseJsonFlag } from "../../../utils"; import { InputValidationError } from "../../../../errors"; -import type { - AuthorizerConfiguration, - FilesystemConfiguration, - LifecycleConfiguration, - NetworkConfiguration, - ProtocolConfiguration, - RequestHeaderConfiguration, -} from "@aws-sdk/client-bedrock-agentcore-control"; import { type EnvVar, - type FilesystemConfiguration as ProjectFilesystemConfiguration, + type FilesystemConfiguration, + type LifecycleConfiguration, type NetworkConfig, BuildTypeSchema, } from "../../../../projectSchemas/runtime"; -import type { AuthorizerConfig, RuntimeAuthorizerType } from "../../../../projectSchemas/auth"; import { - type NetworkMode, + RuntimeAuthorizerTypeSchema, + type AuthorizerConfig, +} from "../../../../projectSchemas/auth"; +import { + NetworkModeSchema, ProtocolModeSchema, RuntimeVersionSchema, } from "../../../../projectSchemas/constants"; @@ -47,7 +43,7 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => flag("code-location", "path to existing agent source code (BYO path)", z.string().optional()), flag("build", "build type: CodeZip or Container", BuildTypeSchema.optional()), flag("entrypoint", "entrypoint file, e.g. main.py:handler (BYO only)", z.string().optional()), - flag("protocol", "server protocol ex. HTTP, MCP, A2A, AGUI", ProtocolModeSchema.optional()), + flag("protocol", "server protocol: HTTP, MCP, A2A, AGUI", ProtocolModeSchema.optional()), flag( "api-key", "API key source for non-bedrock model providers: '-' for stdin, 'file://path' for file", @@ -84,35 +80,27 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => z.array(z.string()).optional(), ), flag( - "network-configuration", - "network configuration (JSON NetworkConfiguration)", - z.string().optional(), + "network-mode", + "network mode for the runtime environment (PUBLIC or VPC)", + NetworkModeSchema.optional(), ), + flag("network-config", "VPC network configuration (JSON)", z.string().optional()), flag( - "vpc-id", - "VPC ID for Container builds in VPC mode (CodeBuild cannot infer it from subnets)", - z.string().optional(), + "authorizer-type", + "inbound authorizer type (AWS_IAM or CUSTOM_JWT)", + RuntimeAuthorizerTypeSchema.optional(), ), flag( "authorizer-configuration", - "inbound authorizer configuration (JSON AuthorizerConfiguration)", - z.string().optional(), - ), - flag( - "protocol-configuration", - "protocol configuration (JSON ProtocolConfiguration)", + "inbound authorizer configuration (JSON)", z.string().optional(), ), flag( - "request-header-configuration", - "request header passthrough configuration (JSON RequestHeaderConfiguration)", - z.string().optional(), - ), - flag( - "lifecycle-configuration", - "lifecycle configuration (JSON LifecycleConfiguration)", - z.string().optional(), + "request-header-allowlist", + "request headers to pass through to the runtime", + z.array(z.string()).optional(), ), + flag("lifecycle-configuration", "lifecycle configuration (JSON)", z.string().optional()), flag( "environment-variables", "environment variables (JSON object of key/value strings)", @@ -120,12 +108,12 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => ), flag( "filesystem-configurations", - "filesystem mount configurations (JSON FilesystemConfiguration[])", + "filesystem mount configurations (JSON)", z.string().optional(), ), flag( "memory", - "memory configuration (JSON with mode: none | create | existing ) (template only)", + "memory configuration (JSON with mode: disabled | create | existing) (template only)", z.string().optional(), ), flag("tags", "tags to apply (JSON object of key/value strings)", z.string().optional()), @@ -161,41 +149,14 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => `--${templateOnlyFlags[0]} is only available on the template path (--template)`, ); - const inputNetwork = parseJsonFlag( - "network-configuration", - flags["network-configuration"], - ); - const inputAuthConfig = parseJsonFlag( - "authorizer-configuration", - flags["authorizer-configuration"], - ); - const inputProtocol = parseJsonFlag( - "protocol-configuration", - flags["protocol-configuration"], - ); - const inputRequestHeaders = parseJsonFlag( - "request-header-configuration", - flags["request-header-configuration"], - ); - const inputLifecycle = parseJsonFlag( - "lifecycle-configuration", - flags["lifecycle-configuration"], - ); - const inputFilesystems = parseJsonFlag( - "filesystem-configurations", - flags["filesystem-configurations"], - ); const inputEnvironmentVariables = parseJsonFlag>( "environment-variables", flags["environment-variables"], ); const memoryConfiguration = parseMemoryConfig(flags["memory"]); - // TODO: make entrypoint optional since container agents don't need it. const entrypoint = flags.entrypoint ?? "main.py"; - const network = toNetwork(inputNetwork); - const source = new SourceResolver({ stdin: config.io.stdin }); const apiKey = await source.resolveText("api-key", flags["api-key"]); @@ -204,36 +165,29 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => "--custom-docker-build-args requires --dockerfile or --build-context-path", ); - if (flags["vpc-id"] && !network?.networkConfig) - throw new InputValidationError( - "--vpc-id requires --network-configuration with VPC network configuration", - ); - - if (flags["protocol"] && flags["protocol-configuration"]) - throw new InputValidationError( - "--protocol and --protocol-configuration are mutually exclusive", - ); - - const auth = toAuthorizer(inputAuthConfig); - const requestHeaderAllowlist = toRequestHeaderAllowlist(inputRequestHeaders); - const filesystemConfigurations = toFilesystems(inputFilesystems); - const infraConfig = { name: flags.name, description: flags.description, executionRoleArn: flags["role-arn"], additionalPolicies: flags["additional-policies"], envVars: toEnvironmentVariables(inputEnvironmentVariables), - networkMode: network?.networkMode, - networkConfig: network?.networkConfig - ? { ...network.networkConfig, ...(flags["vpc-id"] ? { vpcId: flags["vpc-id"] } : {}) } - : undefined, - authorizerType: auth?.authorizerType, - authorizerConfiguration: auth?.authorizerConfiguration, - protocol: flags["protocol"] ?? inputProtocol?.serverProtocol, - requestHeaderAllowlist, - lifecycleConfiguration: inputLifecycle, - filesystemConfigurations, + networkMode: flags["network-mode"], + networkConfig: parseJsonFlag("network-config", flags["network-config"]), + authorizerType: flags["authorizer-type"], + authorizerConfiguration: parseJsonFlag( + "authorizer-configuration", + flags["authorizer-configuration"], + ), + protocol: flags["protocol"], + requestHeaderAllowlist: flags["request-header-allowlist"], + lifecycleConfiguration: parseJsonFlag( + "lifecycle-configuration", + flags["lifecycle-configuration"], + ), + filesystemConfigurations: parseJsonFlag( + "filesystem-configurations", + flags["filesystem-configurations"], + ), tags: parseJsonFlag>("tags", flags["tags"]), }; @@ -272,7 +226,6 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => }, }); -/** Parses and validates the --memory JSON flag against the runtime memory config schema. */ function parseMemoryConfig( raw: string | undefined, ): z.infer | undefined { @@ -283,88 +236,6 @@ function parseMemoryConfig( return result.data; } -/** Converts API flat {key: value} map to project schema [{name, value}] array. */ function toEnvironmentVariables(envVars: Record | undefined): EnvVar[] { return envVars ? Object.entries(envVars).map(([name, value]) => ({ name, value })) : []; } - -/** Converts API NetworkConfiguration to project schema networkMode + networkConfig fields. */ -function toNetwork( - network: NetworkConfiguration | undefined, -): { networkMode: NetworkMode; networkConfig: NetworkConfig | undefined } | undefined { - if (!network) return undefined; - return { - networkMode: network.networkMode as NetworkMode, - networkConfig: network.networkModeConfig - ? { - subnets: network.networkModeConfig.subnets ?? [], - securityGroups: network.networkModeConfig.securityGroups ?? [], - } - : undefined, - }; -} - -/** Converts API AuthorizerConfiguration union to project schema authorizerType + authorizerConfiguration. */ -function toAuthorizer( - auth: AuthorizerConfiguration | undefined, -): - { authorizerType: RuntimeAuthorizerType; authorizerConfiguration: AuthorizerConfig } | undefined { - if (!auth) return undefined; - if ("customJWTAuthorizer" in auth && auth.customJWTAuthorizer) { - const c = auth.customJWTAuthorizer; - if (!c.discoveryUrl) - throw new InputValidationError("discoveryUrl is required in authorizer configuration"); - return { - authorizerType: "CUSTOM_JWT", - authorizerConfiguration: { - customJwtAuthorizer: { - discoveryUrl: c.discoveryUrl, - allowedAudience: c.allowedAudience, - allowedClients: c.allowedClients, - allowedScopes: c.allowedScopes, - }, - }, - }; - } - throw new InputValidationError("Unrecognized authorizer configuration variant"); -} - -/** Unwraps API RequestHeaderConfiguration union to project schema string[]. */ -function toRequestHeaderAllowlist( - headers: RequestHeaderConfiguration | undefined, -): string[] | undefined { - if (!headers) return undefined; - if ("requestHeaderAllowlist" in headers && headers.requestHeaderAllowlist) { - return headers.requestHeaderAllowlist; - } - throw new InputValidationError("Unrecognized request header configuration variant"); -} - -/** Converts API FilesystemConfiguration[] tagged unions to project schema format. */ -function toFilesystems( - filesystems: FilesystemConfiguration[] | undefined, -): ProjectFilesystemConfiguration[] | undefined { - if (!filesystems || filesystems.length === 0) return undefined; - return filesystems.map((fs): ProjectFilesystemConfiguration => { - if ("sessionStorage" in fs && fs.sessionStorage) { - return { sessionStorage: { mountPath: fs.sessionStorage.mountPath! } }; - } - if ("efsAccessPoint" in fs && fs.efsAccessPoint) { - return { - efsAccessPoint: { - accessPointArn: fs.efsAccessPoint.accessPointArn!, - mountPath: fs.efsAccessPoint.mountPath!, - }, - }; - } - if ("s3FilesAccessPoint" in fs && fs.s3FilesAccessPoint) { - return { - s3FilesAccessPoint: { - accessPointArn: fs.s3FilesAccessPoint.accessPointArn!, - mountPath: fs.s3FilesAccessPoint.mountPath!, - }, - }; - } - throw new InputValidationError("Unrecognized filesystem configuration variant"); - }); -} diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index d5b4a63dc..ee479dac9 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -10,7 +10,7 @@ import { TestGlobalConfigAccessor, testIO, } from "../../testing"; -import { InputValidationError, NotImplementedError } from "../../errors"; +import { InputValidationError } from "../../errors"; import { FsReadWriteJson, type ReadWriteJson } from "../../io"; async function run(args: string[], opts?: { core?: TestCoreClient }) { @@ -715,353 +715,3 @@ describe("project build", () => { await expect(run(["build"])).rejects.toThrow(/npm install/); }); }); - -// TODO: Replace NotImplementedError assertions with output assertions once -// FsProjectManager.addResource supports the "runtime" resource type. -describe("project add runtime", () => { - const byo = ["--code-location", "app/my_agent"]; - const template = ["--template", "hello-world-python"]; - - // Verifies each valid flag combination passes handler validation. - test.each<[string, string[]]>([ - ["minimal — name only (defaults to template)", ["--name", "my_agent"]], - ["explicit template path", ["--name", "my_agent", ...template]], - ["minimal — BYO path with build", ["--name", "my_agent", ...byo, "--build", "CodeZip"]], - [ - "BYO container with dockerfile", - ["--name", "my_agent", ...byo, "--build", "Container", "--dockerfile", "Dockerfile"], - ], - [ - "entrypoint + runtime-version for CodeZip", - [ - "--name", - "my_agent", - ...byo, - "--build", - "CodeZip", - "--entrypoint", - "app.py:main", - "--runtime-version", - "PYTHON_3_13", - ], - ], - ["description", ["--name", "my_agent", ...template, "--description", "A test agent"]], - [ - "role-arn", - ["--name", "my_agent", ...template, "--role-arn", "arn:aws:iam::123456789012:role/MyRole"], - ], - [ - "network-configuration — VPC", - [ - "--name", - "my_agent", - ...template, - "--network-configuration", - '{"networkMode":"VPC","networkModeConfig":{"subnets":["subnet-abc"],"securityGroups":["sg-123"]}}', - ], - ], - [ - "network-configuration — PUBLIC", - ["--name", "my_agent", ...template, "--network-configuration", '{"networkMode":"PUBLIC"}'], - ], - [ - "authorizer-configuration — customJWT", - [ - "--name", - "my_agent", - ...template, - "--authorizer-configuration", - '{"customJWTAuthorizer":{"discoveryUrl":"https://idp.example.com/.well-known/openid-configuration","allowedAudience":["app"]}}', - ], - ], - [ - "protocol-configuration — MCP", - ["--name", "my_agent", ...template, "--protocol-configuration", '{"serverProtocol":"MCP"}'], - ], - [ - "protocol-configuration — A2A", - ["--name", "my_agent", ...template, "--protocol-configuration", '{"serverProtocol":"A2A"}'], - ], - [ - "protocol-configuration — AGUI", - ["--name", "my_agent", ...template, "--protocol-configuration", '{"serverProtocol":"AGUI"}'], - ], - [ - "request-header-configuration", - [ - "--name", - "my_agent", - ...template, - "--request-header-configuration", - '{"requestHeaderAllowlist":["X-Custom-Header","Authorization"]}', - ], - ], - [ - "lifecycle-configuration", - [ - "--name", - "my_agent", - ...template, - "--lifecycle-configuration", - '{"idleRuntimeSessionTimeout":300,"maxLifetime":3600}', - ], - ], - [ - "environment-variables", - [ - "--name", - "my_agent", - ...template, - "--environment-variables", - '{"LOG_LEVEL":"debug","APP_ENV":"staging"}', - ], - ], - [ - "filesystem-configurations — sessionStorage", - [ - "--name", - "my_agent", - ...template, - "--filesystem-configurations", - '[{"sessionStorage":{"mountPath":"/mnt/data"}}]', - ], - ], - [ - "filesystem-configurations — efsAccessPoint", - [ - "--name", - "my_agent", - ...template, - "--filesystem-configurations", - '[{"efsAccessPoint":{"accessPointArn":"arn:aws:elasticfilesystem:us-east-1:123456789012:access-point/fsap-abc","mountPath":"/mnt/efs"}}]', - ], - ], - [ - "filesystem-configurations — s3FilesAccessPoint", - [ - "--name", - "my_agent", - ...template, - "--filesystem-configurations", - '[{"s3FilesAccessPoint":{"accessPointArn":"arn:aws:s3files:us-east-1:123456789012:file-system/fs-abc/access-point/fsap-def","mountPath":"/mnt/s3"}}]', - ], - ], - ["tags", ["--name", "my_agent", ...template, "--tags", '{"team":"ml","env":"prod"}']], - [ - "dockerfile + build-context-path", - [ - "--name", - "my_agent", - ...byo, - "--build", - "Container", - "--dockerfile", - "docker/Dockerfile.gpu", - "--build-context-path", - ".", - ], - ], - [ - "custom-docker-build-args with dockerfile", - [ - "--name", - "my_agent", - ...byo, - "--build", - "Container", - "--dockerfile", - "Dockerfile", - "--custom-docker-build-args", - '{"AGENT_NAME":"my_agent","VERSION":"1.0"}', - ], - ], - [ - "custom-docker-build-args with build-context-path", - [ - "--name", - "my_agent", - ...byo, - "--build", - "Container", - "--build-context-path", - ".", - "--custom-docker-build-args", - '{"AGENT_NAME":"my_agent"}', - ], - ], - [ - "additional-policies", - [ - "--name", - "my_agent", - ...template, - "--additional-policies", - "arn:aws:iam::123456789012:policy/MyPolicy", - ], - ], - ["protocol shortcut", ["--name", "my_agent", ...template, "--protocol", "MCP"]], - [ - "memory — create with strategies", - [ - "--name", - "my_agent", - ...template, - "--memory", - '{"mode":"create","strategies":["SEMANTIC","EPISODIC"]}', - ], - ], - [ - "memory — existing by ARN", - [ - "--name", - "my_agent", - ...template, - "--memory", - '{"mode":"existing","arn":"arn:aws:bedrock-agentcore:us-east-1:123456789012:memory/MyMem"}', - ], - ], - ["memory — disabled", ["--name", "my_agent", ...template, "--memory", '{"mode":"disabled"}']], - ["model-provider — openai", ["--name", "my_agent", ...template, "--model-provider", "openai"]], - [ - "build on template path (overlay)", - ["--name", "my_agent", ...template, "--build", "Container"], - ], - [ - "vpc-id with VPC network configuration", - [ - "--name", - "my_agent", - ...byo, - "--build", - "Container", - "--dockerfile", - "Dockerfile", - "--network-configuration", - '{"networkMode":"VPC","networkModeConfig":{"subnets":["subnet-abc"],"securityGroups":["sg-123"]}}', - "--vpc-id", - "vpc-0123456789abcdef0", - ], - ], - ])("%s — accepts flags", async (_label, flags) => { - await inProject(); - await expect(run(["add", "runtime", ...flags])).rejects.toBeInstanceOf(NotImplementedError); - }); - - // Rejects invalid flag combinations with InputValidationError. - test.each<[string, string[]]>([ - ["missing --name", ["--template", "hello-world-python"]], - [ - "--template and --code-location are mutually exclusive", - ["--name", "my_agent", "--template", "hello-world-python", "--code-location", "app/agent"], - ], - [ - "--custom-docker-build-args requires --dockerfile or --build-context-path", - [ - "--name", - "my_agent", - ...byo, - "--build", - "Container", - "--custom-docker-build-args", - '{"KEY":"value"}', - ], - ], - [ - "--vpc-id requires --network-configuration with VPC network configuration", - [ - "--name", - "my_agent", - ...byo, - "--build", - "Container", - "--dockerfile", - "Dockerfile", - "--vpc-id", - "vpc-0123456789abcdef0", - ], - ], - [ - "unrecognized authorizer configuration variant", - ["--name", "my_agent", ...template, "--authorizer-configuration", '{"unknownAuth":{}}'], - ], - [ - "missing discoveryUrl in authorizer", - [ - "--name", - "my_agent", - ...template, - "--authorizer-configuration", - '{"customJWTAuthorizer":{"allowedAudience":["a"]}}', - ], - ], - [ - "unrecognized filesystem configuration variant", - ["--name", "my_agent", ...template, "--filesystem-configurations", '[{"unknownFs":{}}]'], - ], - [ - "invalid JSON in --network-configuration", - ["--name", "my_agent", ...template, "--network-configuration", "{bad}"], - ], - [ - "unrecognized request header configuration variant", - [ - "--name", - "my_agent", - ...template, - "--request-header-configuration", - '{"unknownVariant":["X-Foo"]}', - ], - ], - [ - "--protocol and --protocol-configuration are mutually exclusive", - [ - "--name", - "my_agent", - ...template, - "--protocol", - "MCP", - "--protocol-configuration", - '{"serverProtocol":"MCP"}', - ], - ], - [ - "--entrypoint is only available on BYO path", - ["--name", "my_agent", ...template, "--entrypoint", "main.py"], - ], - [ - "--runtime-version is only available on BYO path", - ["--name", "my_agent", ...template, "--runtime-version", "PYTHON_3_13"], - ], - [ - "--dockerfile is only available on BYO path", - ["--name", "my_agent", ...template, "--dockerfile", "Dockerfile"], - ], - [ - "--build-context-path is only available on BYO path", - ["--name", "my_agent", ...template, "--build-context-path", "."], - ], - [ - "--custom-docker-build-args is only available on BYO path", - ["--name", "my_agent", ...template, "--custom-docker-build-args", '{"KEY":"val"}'], - ], - [ - "--memory is only available on template path", - ["--name", "my_agent", ...byo, "--memory", '{"mode":"disabled"}'], - ], - [ - "--model-provider is only available on template path", - ["--name", "my_agent", ...byo, "--model-provider", "openai"], - ], - [ - "--api-key is only available on template path", - ["--name", "my_agent", ...byo, "--api-key", "-"], - ], - [ - "invalid memory JSON schema", - ["--name", "my_agent", ...template, "--memory", '{"mode":"invalid"}'], - ], - ])("%s", async (_label, flags) => { - await inProject(); - await expect(run(["add", "runtime", ...flags])).rejects.toBeInstanceOf(InputValidationError); - }); -}); From 4cc3fe2b3a11deb1f7abb6b21c62a040cd3d37dc Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Wed, 19 Aug 2026 01:50:49 +0000 Subject: [PATCH 11/15] refactor: define RuntimeResourceConfigSchema, validate with safeParse in handler --- .../project/add/runtime/index.test.ts | 8 +- src/handlers/project/add/runtime/index.ts | 34 +++-- src/handlers/project/types.ts | 127 +++++++++--------- 3 files changed, 79 insertions(+), 90 deletions(-) diff --git a/src/handlers/project/add/runtime/index.test.ts b/src/handlers/project/add/runtime/index.test.ts index 25fc49b22..adc4df843 100644 --- a/src/handlers/project/add/runtime/index.test.ts +++ b/src/handlers/project/add/runtime/index.test.ts @@ -90,7 +90,7 @@ describe("project add runtime", () => { "--network-mode", "VPC", "--network-config", - '{"subnets":["subnet-abc"],"securityGroups":["sg-123"]}', + '{"subnets":["subnet-0123456789abcdef0"],"securityGroups":["sg-0123456789abcdef0"]}', ], ], ["network — PUBLIC", ["--name", "my_agent", ...template, "--network-mode", "PUBLIC"]], @@ -157,7 +157,7 @@ describe("project add runtime", () => { "my_agent", ...template, "--filesystem-configurations", - '[{"efsAccessPoint":{"accessPointArn":"arn:aws:elasticfilesystem:us-east-1:123456789012:access-point/fsap-abc","mountPath":"/mnt/efs"}}]', + '[{"efsAccessPoint":{"accessPointArn":"arn:aws:elasticfilesystem:us-east-1:123456789012:access-point/fsap-0123456789abcdef0","mountPath":"/mnt/efs"}}]', ], ], [ @@ -167,7 +167,7 @@ describe("project add runtime", () => { "my_agent", ...template, "--filesystem-configurations", - '[{"s3FilesAccessPoint":{"accessPointArn":"arn:aws:s3files:us-east-1:123456789012:file-system/fs-abc/access-point/fsap-def","mountPath":"/mnt/s3"}}]', + '[{"s3FilesAccessPoint":{"accessPointArn":"arn:aws:s3files:us-east-1:123456789012:file-system/fs-0123456789abcdef01/access-point/fsap-0123456789abcdef1","mountPath":"/mnt/s3"}}]', ], ], ["tags", ["--name", "my_agent", ...template, "--tags", '{"team":"ml","env":"prod"}']], @@ -263,7 +263,7 @@ describe("project add runtime", () => { "--network-mode", "VPC", "--network-config", - '{"subnets":["subnet-abc"],"securityGroups":["sg-123"],"vpcId":"vpc-0123456789abcdef0"}', + '{"subnets":["subnet-0123456789abcdef0"],"securityGroups":["sg-0123456789abcdef0"],"vpcId":"vpc-0123456789abcdef0"}', ], ], ])("%s — accepts flags", async (_label, flags) => { diff --git a/src/handlers/project/add/runtime/index.ts b/src/handlers/project/add/runtime/index.ts index c41d93c23..0a1c5664f 100644 --- a/src/handlers/project/add/runtime/index.ts +++ b/src/handlers/project/add/runtime/index.ts @@ -3,17 +3,8 @@ import { createHandler, flag, ProjectKey } from "../../../../router"; import type { AddProjectResourceConfig } from "../types"; import { parseJsonFlag } from "../../../utils"; import { InputValidationError } from "../../../../errors"; -import { - type EnvVar, - type FilesystemConfiguration, - type LifecycleConfiguration, - type NetworkConfig, - BuildTypeSchema, -} from "../../../../projectSchemas/runtime"; -import { - RuntimeAuthorizerTypeSchema, - type AuthorizerConfig, -} from "../../../../projectSchemas/auth"; +import { type EnvVar, BuildTypeSchema } from "../../../../projectSchemas/runtime"; +import { RuntimeAuthorizerTypeSchema } from "../../../../projectSchemas/auth"; import { NetworkModeSchema, ProtocolModeSchema, @@ -21,6 +12,7 @@ import { } from "../../../../projectSchemas/constants"; import { runtimeModelProviderSchema, + RuntimeResourceConfigSchema, RUNTIME_TEMPLATES, runtimeMemoryConfigSchema, } from "../../types"; @@ -172,26 +164,26 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => additionalPolicies: flags["additional-policies"], envVars: toEnvironmentVariables(inputEnvironmentVariables), networkMode: flags["network-mode"], - networkConfig: parseJsonFlag("network-config", flags["network-config"]), + networkConfig: parseJsonFlag("network-config", flags["network-config"]), authorizerType: flags["authorizer-type"], - authorizerConfiguration: parseJsonFlag( + authorizerConfiguration: parseJsonFlag( "authorizer-configuration", flags["authorizer-configuration"], ), protocol: flags["protocol"], requestHeaderAllowlist: flags["request-header-allowlist"], - lifecycleConfiguration: parseJsonFlag( + lifecycleConfiguration: parseJsonFlag( "lifecycle-configuration", flags["lifecycle-configuration"], ), - filesystemConfigurations: parseJsonFlag( + filesystemConfigurations: parseJsonFlag( "filesystem-configurations", flags["filesystem-configurations"], ), - tags: parseJsonFlag>("tags", flags["tags"]), + tags: parseJsonFlag("tags", flags["tags"]), }; - const runtimeConfig = isTemplate + const runtimeInput = isTemplate ? { source: "template" as const, template, @@ -207,17 +199,21 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => runtimeVersion: flags["runtime-version"], dockerfile: flags.dockerfile, buildContextPath: flags["build-context-path"], - customDockerBuildArgs: parseJsonFlag>( + customDockerBuildArgs: parseJsonFlag( "custom-docker-build-args", flags["custom-docker-build-args"], ), ...infraConfig, }; + const result = RuntimeResourceConfigSchema.safeParse(runtimeInput); + if (!result.success) + throw new InputValidationError(z.prettifyError(result.error), { cause: result.error }); + const project = ctx.require(ProjectKey); for await (const event of config.projectManager.addResource(project, { resourceType: "runtime", - resourceConfig: runtimeConfig, + resourceConfig: result.data, })) { config.io.stderr.write(`${event.message}\n`); } diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index 79a43c7ff..66eba84d0 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -1,15 +1,7 @@ import { HarnessSpecSchema } from "../../projectSchemas/harness"; import type { ProjectSpecSchema } from "../../projectSchemas/project"; import z from "zod"; -import type { - BuildType, - EnvVar, - FilesystemConfiguration, - LifecycleConfiguration, - NetworkConfig, -} from "../../projectSchemas/runtime"; -import type { AuthorizerConfig, RuntimeAuthorizerType } from "../../projectSchemas/auth"; -import type { NetworkMode, ProtocolMode, RuntimeVersion } from "../../projectSchemas/constants"; +import { ProjectRuntimeSchema } from "../../projectSchemas/runtime"; import { MemoryStrategyType } from "@aws-sdk/client-bedrock-agentcore-control"; /** Available runtime templates for scaffolding agent code. A subset of {@link PROJECT_TEMPLATES} describing runtimes only */ @@ -56,45 +48,68 @@ export type Project = { spec: z.infer; }; -/** Shared infrastructure config fields for a runtime (independent of source type). */ -type RuntimeInfraConfig = { - name: string; - description?: string; - executionRoleArn?: string; - additionalPolicies?: string[]; - envVars?: EnvVar[]; - networkMode?: NetworkMode; - networkConfig?: NetworkConfig; - authorizerType?: RuntimeAuthorizerType; - authorizerConfiguration?: AuthorizerConfig; - protocol?: ProtocolMode; - requestHeaderAllowlist?: string[]; - lifecycleConfiguration?: LifecycleConfiguration; - filesystemConfigurations?: FilesystemConfiguration[]; - tags?: Record; -}; +export const runtimeModelProviderSchema = z.enum(["bedrock", "anthropic", "openai", "gemini"]); +export type RuntimeModelProvider = z.infer; -/** BYO path: user provides existing code location and build config. */ -type RuntimeByoConfig = RuntimeInfraConfig & { - source: "byo"; - codeLocation: string; - build?: BuildType; - entrypoint?: string; - runtimeVersion?: RuntimeVersion; - dockerfile?: string; - buildContextPath?: string; - customDockerBuildArgs?: Record; -}; +export const runtimeModelProviderConfigSchema = z.object({ + provider: runtimeModelProviderSchema.optional(), + apiKey: z.string().optional(), +}); +export type RuntimeModelProviderConfig = z.infer; -/** Template path: CLI scaffolds agent code from a template. */ -type RuntimeTemplateConfig = RuntimeInfraConfig & { - source: "template"; - template: RuntimeTemplate; - memory?: RuntimeMemoryConfig; - modelProvider?: RuntimeModelProviderConfig; -}; +export const runtimeMemoryConfigSchema = z.discriminatedUnion("mode", [ + z.object({ mode: z.literal("disabled") }), + z.object({ + mode: z.literal("create"), + strategies: z.array(z.enum(Object.values(MemoryStrategyType))), + }), + z.object({ + mode: z.literal("existing"), + arn: z.string().min(1), + }), +]); +export type RuntimeMemoryConfig = z.input; -export type RuntimeResourceConfig = RuntimeByoConfig | RuntimeTemplateConfig; +const RuntimeInfraConfigSchema = z.object({ + name: ProjectRuntimeSchema.shape.name, + description: ProjectRuntimeSchema.shape.description, + executionRoleArn: ProjectRuntimeSchema.shape.executionRoleArn, + additionalPolicies: ProjectRuntimeSchema.shape.additionalPolicies, + envVars: ProjectRuntimeSchema.shape.envVars, + networkMode: ProjectRuntimeSchema.shape.networkMode, + networkConfig: ProjectRuntimeSchema.shape.networkConfig, + authorizerType: ProjectRuntimeSchema.shape.authorizerType, + authorizerConfiguration: ProjectRuntimeSchema.shape.authorizerConfiguration, + protocol: ProjectRuntimeSchema.shape.protocol, + requestHeaderAllowlist: ProjectRuntimeSchema.shape.requestHeaderAllowlist, + lifecycleConfiguration: ProjectRuntimeSchema.shape.lifecycleConfiguration, + filesystemConfigurations: ProjectRuntimeSchema.shape.filesystemConfigurations, + tags: ProjectRuntimeSchema.shape.tags, +}); + +const RuntimeByoConfigSchema = RuntimeInfraConfigSchema.extend({ + source: z.literal("byo"), + codeLocation: z.string().min(1), + build: ProjectRuntimeSchema.shape.build.optional(), + entrypoint: ProjectRuntimeSchema.shape.entrypoint.optional(), + runtimeVersion: ProjectRuntimeSchema.shape.runtimeVersion, + dockerfile: ProjectRuntimeSchema.shape.dockerfile, + buildContextPath: ProjectRuntimeSchema.shape.buildContextPath, + customDockerBuildArgs: ProjectRuntimeSchema.shape.customDockerBuildArgs, +}); + +const RuntimeTemplateConfigSchema = RuntimeInfraConfigSchema.extend({ + source: z.literal("template"), + template: z.enum(RUNTIME_TEMPLATES), + memory: runtimeMemoryConfigSchema.optional(), + modelProvider: runtimeModelProviderConfigSchema.optional(), +}); + +export const RuntimeResourceConfigSchema = z.discriminatedUnion("source", [ + RuntimeByoConfigSchema, + RuntimeTemplateConfigSchema, +]); +export type RuntimeResourceConfig = z.infer; /** Discriminated union input for {@link ProjectManager.addResource}. */ export type AddResourceInput = @@ -125,25 +140,3 @@ export interface ProjectManager { /** Add a resource to an existing AgentCore project. */ addResource(project: Project, input: AddResourceInput): AsyncGenerator; } - -export const runtimeModelProviderSchema = z.enum(["bedrock", "anthropic", "openai", "gemini"]); -export type RuntimeModelProvider = z.infer; - -export type RuntimeModelProviderConfig = { - provider?: RuntimeModelProvider; - apiKey?: string; -}; - -export const runtimeMemoryConfigSchema = z.discriminatedUnion("mode", [ - z.object({ mode: z.literal("disabled") }), - z.object({ - mode: z.literal("create"), - strategies: z.array(z.enum(Object.values(MemoryStrategyType))), - }), - z.object({ - mode: z.literal("existing"), - arn: z.string().min(1), - }), -]); - -export type RuntimeMemoryConfig = z.input; From 473c989b80ae5156ab39ebaf155b33f888dae8a1 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Wed, 19 Aug 2026 15:17:21 +0000 Subject: [PATCH 12/15] refactor(runtime): move runtime specific types to their own file --- src/handlers/project/add/runtime/index.ts | 8 +-- src/handlers/project/add/runtime/types.ts | 67 +++++++++++++++++++++++ src/handlers/project/types.ts | 66 +--------------------- 3 files changed, 72 insertions(+), 69 deletions(-) create mode 100644 src/handlers/project/add/runtime/types.ts diff --git a/src/handlers/project/add/runtime/index.ts b/src/handlers/project/add/runtime/index.ts index 0a1c5664f..c3ee03417 100644 --- a/src/handlers/project/add/runtime/index.ts +++ b/src/handlers/project/add/runtime/index.ts @@ -10,13 +10,13 @@ import { ProtocolModeSchema, RuntimeVersionSchema, } from "../../../../projectSchemas/constants"; +import { SourceResolver } from "../../../../io"; +import { RUNTIME_TEMPLATES } from "../../types"; import { + runtimeMemoryConfigSchema, runtimeModelProviderSchema, RuntimeResourceConfigSchema, - RUNTIME_TEMPLATES, - runtimeMemoryConfigSchema, -} from "../../types"; -import { SourceResolver } from "../../../../io"; +} from "./types"; export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => createHandler({ diff --git a/src/handlers/project/add/runtime/types.ts b/src/handlers/project/add/runtime/types.ts new file mode 100644 index 000000000..f47fa20ae --- /dev/null +++ b/src/handlers/project/add/runtime/types.ts @@ -0,0 +1,67 @@ +import { MemoryStrategyType } from "@aws-sdk/client-bedrock-agentcore-control"; +import z from "zod"; +import { ProjectRuntimeSchema } from "../../../../projectSchemas/runtime"; +import { RUNTIME_TEMPLATES } from "../../types"; + +export const runtimeModelProviderSchema = z.enum(["bedrock", "anthropic", "openai", "gemini"]); +export type RuntimeModelProvider = z.infer; + +export const runtimeModelProviderConfigSchema = z.object({ + provider: runtimeModelProviderSchema.optional(), + apiKey: z.string().min(1).optional(), +}); +export type RuntimeModelProviderConfig = z.infer; + +export const runtimeMemoryConfigSchema = z.discriminatedUnion("mode", [ + z.object({ mode: z.literal("disabled") }), + z.object({ + mode: z.literal("create"), + strategies: z.array(z.enum(Object.values(MemoryStrategyType))), + }), + z.object({ + mode: z.literal("existing"), + arn: z.string().min(1), + }), +]); +export type RuntimeMemoryConfig = z.input; + +const RuntimeInfraConfigSchema = z.object({ + name: ProjectRuntimeSchema.shape.name, + description: ProjectRuntimeSchema.shape.description, + executionRoleArn: ProjectRuntimeSchema.shape.executionRoleArn, + additionalPolicies: ProjectRuntimeSchema.shape.additionalPolicies, + envVars: ProjectRuntimeSchema.shape.envVars, + networkMode: ProjectRuntimeSchema.shape.networkMode, + networkConfig: ProjectRuntimeSchema.shape.networkConfig, + authorizerType: ProjectRuntimeSchema.shape.authorizerType, + authorizerConfiguration: ProjectRuntimeSchema.shape.authorizerConfiguration, + protocol: ProjectRuntimeSchema.shape.protocol, + requestHeaderAllowlist: ProjectRuntimeSchema.shape.requestHeaderAllowlist, + lifecycleConfiguration: ProjectRuntimeSchema.shape.lifecycleConfiguration, + filesystemConfigurations: ProjectRuntimeSchema.shape.filesystemConfigurations, + tags: ProjectRuntimeSchema.shape.tags, +}); + +const RuntimeByoConfigSchema = RuntimeInfraConfigSchema.extend({ + source: z.literal("byo"), + codeLocation: z.string().min(1), + build: ProjectRuntimeSchema.shape.build.optional(), + entrypoint: ProjectRuntimeSchema.shape.entrypoint.optional(), + runtimeVersion: ProjectRuntimeSchema.shape.runtimeVersion, + dockerfile: ProjectRuntimeSchema.shape.dockerfile, + buildContextPath: ProjectRuntimeSchema.shape.buildContextPath, + customDockerBuildArgs: ProjectRuntimeSchema.shape.customDockerBuildArgs, +}); + +const RuntimeTemplateConfigSchema = RuntimeInfraConfigSchema.extend({ + source: z.literal("template"), + template: z.enum(RUNTIME_TEMPLATES), + memory: runtimeMemoryConfigSchema.optional(), + modelProvider: runtimeModelProviderConfigSchema.optional(), +}); + +export const RuntimeResourceConfigSchema = z.discriminatedUnion("source", [ + RuntimeByoConfigSchema, + RuntimeTemplateConfigSchema, +]); +export type RuntimeResourceConfig = z.infer; diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index 66eba84d0..cc613a22a 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -1,8 +1,7 @@ import { HarnessSpecSchema } from "../../projectSchemas/harness"; import type { ProjectSpecSchema } from "../../projectSchemas/project"; import z from "zod"; -import { ProjectRuntimeSchema } from "../../projectSchemas/runtime"; -import { MemoryStrategyType } from "@aws-sdk/client-bedrock-agentcore-control"; +import type { RuntimeResourceConfig } from "./add/runtime/types"; /** Available runtime templates for scaffolding agent code. A subset of {@link PROJECT_TEMPLATES} describing runtimes only */ export const RUNTIME_TEMPLATES = { @@ -48,69 +47,6 @@ export type Project = { spec: z.infer; }; -export const runtimeModelProviderSchema = z.enum(["bedrock", "anthropic", "openai", "gemini"]); -export type RuntimeModelProvider = z.infer; - -export const runtimeModelProviderConfigSchema = z.object({ - provider: runtimeModelProviderSchema.optional(), - apiKey: z.string().optional(), -}); -export type RuntimeModelProviderConfig = z.infer; - -export const runtimeMemoryConfigSchema = z.discriminatedUnion("mode", [ - z.object({ mode: z.literal("disabled") }), - z.object({ - mode: z.literal("create"), - strategies: z.array(z.enum(Object.values(MemoryStrategyType))), - }), - z.object({ - mode: z.literal("existing"), - arn: z.string().min(1), - }), -]); -export type RuntimeMemoryConfig = z.input; - -const RuntimeInfraConfigSchema = z.object({ - name: ProjectRuntimeSchema.shape.name, - description: ProjectRuntimeSchema.shape.description, - executionRoleArn: ProjectRuntimeSchema.shape.executionRoleArn, - additionalPolicies: ProjectRuntimeSchema.shape.additionalPolicies, - envVars: ProjectRuntimeSchema.shape.envVars, - networkMode: ProjectRuntimeSchema.shape.networkMode, - networkConfig: ProjectRuntimeSchema.shape.networkConfig, - authorizerType: ProjectRuntimeSchema.shape.authorizerType, - authorizerConfiguration: ProjectRuntimeSchema.shape.authorizerConfiguration, - protocol: ProjectRuntimeSchema.shape.protocol, - requestHeaderAllowlist: ProjectRuntimeSchema.shape.requestHeaderAllowlist, - lifecycleConfiguration: ProjectRuntimeSchema.shape.lifecycleConfiguration, - filesystemConfigurations: ProjectRuntimeSchema.shape.filesystemConfigurations, - tags: ProjectRuntimeSchema.shape.tags, -}); - -const RuntimeByoConfigSchema = RuntimeInfraConfigSchema.extend({ - source: z.literal("byo"), - codeLocation: z.string().min(1), - build: ProjectRuntimeSchema.shape.build.optional(), - entrypoint: ProjectRuntimeSchema.shape.entrypoint.optional(), - runtimeVersion: ProjectRuntimeSchema.shape.runtimeVersion, - dockerfile: ProjectRuntimeSchema.shape.dockerfile, - buildContextPath: ProjectRuntimeSchema.shape.buildContextPath, - customDockerBuildArgs: ProjectRuntimeSchema.shape.customDockerBuildArgs, -}); - -const RuntimeTemplateConfigSchema = RuntimeInfraConfigSchema.extend({ - source: z.literal("template"), - template: z.enum(RUNTIME_TEMPLATES), - memory: runtimeMemoryConfigSchema.optional(), - modelProvider: runtimeModelProviderConfigSchema.optional(), -}); - -export const RuntimeResourceConfigSchema = z.discriminatedUnion("source", [ - RuntimeByoConfigSchema, - RuntimeTemplateConfigSchema, -]); -export type RuntimeResourceConfig = z.infer; - /** Discriminated union input for {@link ProjectManager.addResource}. */ export type AddResourceInput = | { From e3ea4077c0656ac06e6fff47b168a2508afb259c Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Wed, 19 Aug 2026 21:47:45 +0000 Subject: [PATCH 13/15] refactor(tags): swap tags to shared shape --- src/handlers/project/add/runtime/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/handlers/project/add/runtime/index.ts b/src/handlers/project/add/runtime/index.ts index c3ee03417..25b017e96 100644 --- a/src/handlers/project/add/runtime/index.ts +++ b/src/handlers/project/add/runtime/index.ts @@ -1,7 +1,7 @@ import z from "zod"; import { createHandler, flag, ProjectKey } from "../../../../router"; import type { AddProjectResourceConfig } from "../types"; -import { parseJsonFlag } from "../../../utils"; +import { parseJsonFlag, parseTags } from "../../../utils"; import { InputValidationError } from "../../../../errors"; import { type EnvVar, BuildTypeSchema } from "../../../../projectSchemas/runtime"; import { RuntimeAuthorizerTypeSchema } from "../../../../projectSchemas/auth"; @@ -108,7 +108,7 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => "memory configuration (JSON with mode: disabled | create | existing) (template only)", z.string().optional(), ), - flag("tags", "tags to apply (JSON object of key/value strings)", z.string().optional()), + flag("tags", "tags as key=value (repeatable) or JSON object", z.array(z.string()).optional()), ], handle: async (ctx, flags) => { if (!flags.name) @@ -180,7 +180,7 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => "filesystem-configurations", flags["filesystem-configurations"], ), - tags: parseJsonFlag("tags", flags["tags"]), + tags: parseTags(flags["tags"]), }; const runtimeInput = isTemplate From 7c0a3017e52c906886a05da5a637d43d899ae587 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Fri, 21 Aug 2026 15:43:57 +0000 Subject: [PATCH 14/15] fix(runtime): mark api-key as sensitive --- src/handlers/project/add/runtime/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/handlers/project/add/runtime/index.ts b/src/handlers/project/add/runtime/index.ts index 25b017e96..ea900d648 100644 --- a/src/handlers/project/add/runtime/index.ts +++ b/src/handlers/project/add/runtime/index.ts @@ -40,6 +40,7 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => "api-key", "API key source for non-bedrock model providers: '-' for stdin, 'file://path' for file", z.string().optional(), + { sensitive: true }, ), flag( "model-provider", From 98115099d5988e60249f72402d8e77f3c5b54e8c Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Fri, 21 Aug 2026 15:44:36 +0000 Subject: [PATCH 15/15] fix(add): ensure failed schema writes cause scaffold rollback --- src/core/project/manager.tsx | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index c6a8dc25f..05942496a 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -184,16 +184,15 @@ export class FsProjectManager implements ProjectManager { yield { message: `Updating project spec file at '${agentCoreSpecPath}'` }; - const newSpec = { ...existingProjectSpec, [projectSpecKey]: newResources }; - const newSpecParseResult = ProjectSpecSchema.safeParse(newSpec); - - if (!newSpecParseResult.success) - throw new InputValidationError(z.prettifyError(newSpecParseResult.error), { - cause: newSpecParseResult.error, - }); - // rollback scaffolding changes on failed config writes to prevent bad state. try { + const newSpec = { ...existingProjectSpec, [projectSpecKey]: newResources }; + const newSpecParseResult = ProjectSpecSchema.safeParse(newSpec); + + if (!newSpecParseResult.success) + throw new InputValidationError(z.prettifyError(newSpecParseResult.error), { + cause: newSpecParseResult.error, + }); const newProjectSpec = await this.json.write(agentCoreSpecPath, newSpecParseResult.data); return {