diff --git a/src/cli/commands/add/__tests__/add-gateway.test.ts b/src/cli/commands/add/__tests__/add-gateway.test.ts index fee61d565..6d6647b3c 100644 --- a/src/cli/commands/add/__tests__/add-gateway.test.ts +++ b/src/cli/commands/add/__tests__/add-gateway.test.ts @@ -61,6 +61,18 @@ describe('add gateway command', () => { expect(json.success).toBe(false); }); + it('rejects a gateway whose composed name exceeds the 48-char AWS limit', async () => { + // "TestProj" (8) + "-" (1) + 41-char name = 50 > 48 + const gatewayName = 'a'.repeat(41); + const result = await runCLI(['add', 'gateway', '--name', gatewayName, '--json'], projectDir); + + expect(result.exitCode).toBe(1); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(false); + expect(json.error.includes('Gateway name too long'), `Error: ${json.error}`).toBeTruthy(); + expect(json.error.includes('48 characters'), `Error: ${json.error}`).toBeTruthy(); + }); + it('rejects duplicate gateway name', async () => { const gatewayName = 'dup-gateway'; diff --git a/src/cli/constants.ts b/src/cli/constants.ts index deda321d7..c5a428e6d 100644 --- a/src/cli/constants.ts +++ b/src/cli/constants.ts @@ -96,6 +96,18 @@ export const CDK_PROJECT_DIR = 'cdk'; */ export const CDK_APP_ENTRY = 'dist/bin/cdk.js'; +/** + * Max length AWS BedrockAgentCore allows for a runtime name (combined projectName_agentName). + */ +export const MAX_RUNTIME_NAME_LENGTH = 48; + +/** + * Max length AWS BedrockAgentCore allows for a gateway name. The deployed name is composed as + * `${projectName}-${gatewayName}` (see @aws/agentcore-cdk Gateway.ts), and AWS rejects names + * over this limit at CreateGateway with pattern ([0-9a-zA-Z][-]?){1,48}. + */ +export const MAX_GATEWAY_NAME_LENGTH = 48; + /** * Current schema version for AgentCore configuration files. */ diff --git a/src/cli/operations/deploy/__tests__/preflight.test.ts b/src/cli/operations/deploy/__tests__/preflight.test.ts index 320a4be53..c6454d868 100644 --- a/src/cli/operations/deploy/__tests__/preflight.test.ts +++ b/src/cli/operations/deploy/__tests__/preflight.test.ts @@ -1,4 +1,5 @@ -import { formatError, validateProject } from '../preflight.js'; +import { StaleCdkConstructError } from '../../../../lib/errors/types.js'; +import { extractUnknownKeys, formatError, rewriteIfStaleCdkConstruct, validateProject } from '../preflight.js'; import { afterEach, describe, expect, it, vi } from 'vitest'; const { mockReadProjectSpec, mockReadAWSDeploymentTargets, mockReadDeployedState, mockConfigExists } = vi.hoisted( @@ -193,6 +194,55 @@ describe('validateProject', () => { const result = await validateProject(); expect(result.projectSpec.name).toBe('myproject'); }); + + it('rejects a gateway whose composed name exceeds 48 chars', async () => { + mockRequireConfigRoot.mockReturnValue('/project/agentcore'); + mockValidate.mockReturnValue(undefined); + // "ZeroTrustTodayACProj" (20) + "-" + "ZeroTrustTodayAgentCoreGateway" (30) = 51 > 48 + mockReadProjectSpec.mockResolvedValue({ + name: 'ZeroTrustTodayACProj', + runtimes: [], + agentCoreGateways: [{ name: 'ZeroTrustTodayAgentCoreGateway' }], + }); + mockReadAWSDeploymentTargets.mockResolvedValue([]); + mockValidateAwsCredentials.mockResolvedValue(undefined); + + await expect(validateProject()).rejects.toThrow( + 'Gateway name too long: "ZeroTrustTodayACProj-ZeroTrustTodayAgentCoreGateway" (51 chars).' + ); + }); + + it('accepts a composed gateway name exactly at the 48-char limit', async () => { + mockRequireConfigRoot.mockReturnValue('/project/agentcore'); + mockValidate.mockReturnValue(undefined); + // "proj" (4) + "-" (1) + 43-char name = 48 == limit + mockReadProjectSpec.mockResolvedValue({ + name: 'proj', + runtimes: [], + agentCoreGateways: [{ name: 'a'.repeat(43) }], + }); + mockReadAWSDeploymentTargets.mockResolvedValue([]); + mockValidateAwsCredentials.mockResolvedValue(undefined); + + const result = await validateProject(); + expect(result.projectSpec.name).toBe('proj'); + }); + + it('skips the length check for imported gateways that carry an explicit resourceName', async () => { + mockRequireConfigRoot.mockReturnValue('/project/agentcore'); + mockValidate.mockReturnValue(undefined); + // Composed name would be 51 chars, but resourceName is AWS-accepted already → skip. + mockReadProjectSpec.mockResolvedValue({ + name: 'ZeroTrustTodayACProj', + runtimes: [], + agentCoreGateways: [{ name: 'ZeroTrustTodayAgentCoreGateway', resourceName: 'short-existing-gw' }], + }); + mockReadAWSDeploymentTargets.mockResolvedValue([]); + mockValidateAwsCredentials.mockResolvedValue(undefined); + + const result = await validateProject(); + expect(result.projectSpec.name).toBe('ZeroTrustTodayACProj'); + }); }); describe('formatError', () => { @@ -251,3 +301,51 @@ describe('formatError', () => { expect(result).toContain('inner'); }); }); + +describe('extractUnknownKeys', () => { + it('extracts a single rejected key', () => { + const err = new Error('agentCoreGateways[0]: unknown keys (remove): "protocolType"'); + expect(extractUnknownKeys(err)).toEqual(['protocolType']); + }); + + it('extracts multiple rejected keys', () => { + const err = new Error('agentCoreGateways[0]: unknown keys (remove): "protocolType", "newField"'); + expect(extractUnknownKeys(err)).toEqual(['protocolType', 'newField']); + }); + + it('finds the key when nested in a cause chain (how it reaches the CLI)', () => { + const cause = new Error('agentcore.json:\n - agentCoreGateways[0]: unknown keys (remove): "protocolType"'); + const wrapped = new Error('CDK synth failed: Subprocess exited with error 1', { cause }); + expect(extractUnknownKeys(wrapped)).toEqual(['protocolType']); + }); + + it('returns [] for unrelated synth errors', () => { + expect(extractUnknownKeys(new Error('CDK synth failed: bad region'))).toEqual([]); + }); +}); + +describe('rewriteIfStaleCdkConstruct', () => { + it('rewrites an unknown-keys synth failure into a StaleCdkConstructError with a fix hint', () => { + const err = new Error('agentCoreGateways[0]: unknown keys (remove): "protocolType"'); + const rewritten = rewriteIfStaleCdkConstruct(err, '/project/agentcore/cdk'); + expect(rewritten).toBeInstanceOf(StaleCdkConstructError); + const message = (rewritten as Error).message; + expect(message).toContain('"protocolType"'); + expect(message).toContain('npm update @aws/agentcore-cdk'); + // Original error is preserved as the cause for debugging. + expect((rewritten as Error).cause).toBe(err); + }); + + it('passes unrelated synth errors through untouched', () => { + const err = new Error('CDK synth failed: insufficient permissions'); + const result = rewriteIfStaleCdkConstruct(err, '/project/agentcore/cdk'); + expect(result).toBe(err); + expect(result).not.toBeInstanceOf(StaleCdkConstructError); + }); + + it('normalizes a non-Error throw into an Error when not an unknown-keys failure', () => { + const result = rewriteIfStaleCdkConstruct('some string failure', '/project/agentcore/cdk'); + expect(result).toBeInstanceOf(Error); + expect((result as Error).message).toBe('some string failure'); + }); +}); diff --git a/src/cli/operations/deploy/preflight.ts b/src/cli/operations/deploy/preflight.ts index a06321578..bf307b87f 100644 --- a/src/cli/operations/deploy/preflight.ts +++ b/src/cli/operations/deploy/preflight.ts @@ -1,10 +1,11 @@ import { ConfigIO, DOCKERFILE_NAME, getDockerfilePath, requireConfigRoot, resolveCodeLocation } from '../../../lib'; -import { ValidationError } from '../../../lib/errors/types'; +import { StaleCdkConstructError, ValidationError } from '../../../lib/errors/types'; import type { AgentCoreProjectSpec, AwsDeploymentTarget } from '../../../schema'; import { validateAwsCredentials } from '../../aws/account'; import { LocalCdkProject } from '../../cdk/local-cdk-project'; import { CdkToolkitWrapper, createCdkToolkitWrapper, silentIoHost } from '../../cdk/toolkit-lib'; import { checkBootstrapStatus, checkStacksStatus, formatCdkEnvironment } from '../../cloudformation'; +import { MAX_GATEWAY_NAME_LENGTH, MAX_RUNTIME_NAME_LENGTH } from '../../constants'; import { cleanupStaleLockFiles } from '../../tui/utils'; import type { IIoHost } from '@aws-cdk/toolkit-lib'; import { existsSync, readFileSync } from 'node:fs'; @@ -63,8 +64,6 @@ export function formatError(err: unknown): string { * Also validates AWS credentials are configured before proceeding. * Returns the project context needed for subsequent steps. */ -const MAX_RUNTIME_NAME_LENGTH = 48; - export async function validateProject(): Promise { // Find the agentcore config directory, walking up from cwd if needed const configRoot = requireConfigRoot(); @@ -128,6 +127,9 @@ export async function validateProject(): Promise { // Validate runtime names don't exceed AWS limits validateRuntimeNames(projectSpec); + // Validate gateway names don't exceed AWS limits + validateGatewayNames(projectSpec); + // Validate Container agents have Dockerfiles validateContainerAgents(projectSpec, configRoot); @@ -168,6 +170,27 @@ function validateRuntimeNames(projectSpec: AgentCoreProjectSpec): void { } } +/** + * Validates that combined gateway names (projectName-gatewayName) don't exceed AWS limits. + * The deployed gateway resource name is `${projectName}-${gatewayName}` and AWS rejects names + * over 48 chars at CreateGateway — surface that here instead of as an opaque CREATE_FAILED. + */ +export function validateGatewayNames(projectSpec: AgentCoreProjectSpec): void { + const projectName = projectSpec.name; + for (const gateway of projectSpec.agentCoreGateways || []) { + // Imported gateways carry an explicit resourceName that AWS already accepted; skip those. + if (gateway.resourceName) continue; + const combinedName = `${projectName}-${gateway.name}`; + if (combinedName.length > MAX_GATEWAY_NAME_LENGTH) { + throw new ValidationError( + `Gateway name too long: "${combinedName}" (${combinedName.length} chars). ` + + `AWS limits gateway names to ${MAX_GATEWAY_NAME_LENGTH} characters. ` + + `Shorten the project name or gateway name in agentcore.json.` + ); + } + } +} + /** * Validates that Container agents have required Dockerfiles. */ @@ -275,7 +298,12 @@ export async function synthesizeCdk(cdkProject: LocalCdkProject, options?: Synth }); // synth() produces the assembly internally and stores the directory for later use - const synthResult = await toolkitWrapper.synth(); + let synthResult: { stackNames: string[]; assemblyDirectory: string }; + try { + synthResult = await toolkitWrapper.synth(); + } catch (err) { + throw rewriteIfStaleCdkConstruct(err, cdkProject.projectDir); + } return { toolkitWrapper, @@ -283,6 +311,52 @@ export async function synthesizeCdk(cdkProject: LocalCdkProject, options?: Synth }; } +// Matches the construct's config validator output: `path: unknown keys (remove): "a", "b"`. +// See @aws/agentcore-cdk src/lib/errors/config.ts (unrecognized_keys formatting). +const UNKNOWN_KEYS_PATTERN = /unknown keys \(remove\): (.+?)(?:\n|$)/; + +/** + * Pull the rejected key names out of an "unknown keys (remove)" message anywhere in the + * error's cause chain. Returns [] when the error is not an unknown-keys rejection. + */ +export function extractUnknownKeys(err: unknown): string[] { + const message = formatError(err); + const match = UNKNOWN_KEYS_PATTERN.exec(message); + if (!match?.[1]) return []; + return match[1] + .split(',') + .map(k => k.trim().replace(/^"|"$/g, '')) + .filter(Boolean); +} + +/** + * Read the version of @aws/agentcore-cdk actually installed in the vended CDK project. + * Returns undefined when it can't be determined (the error message degrades gracefully). + */ +export function readInstalledCdkConstructVersion(cdkProjectDir: string): string | undefined { + try { + const pkgPath = path.join(cdkProjectDir, 'node_modules', '@aws', 'agentcore-cdk', 'package.json'); + const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as { version?: string }; + return pkg.version; + } catch { + return undefined; + } +} + +/** + * If a synth failure is an "unknown keys" rejection, it is provably a version skew: the CLI + * strict-validates agentcore.json against its own schema during preflight (validateProject), + * *before* synth runs. So any unknown-key the CLI wrote is one the CLI considers valid, and the + * rejection means the project's bundled @aws/agentcore-cdk is older than the CLI. Rewrite to a + * StaleCdkConstructError with an actionable hint. Any other error passes through untouched. + */ +export function rewriteIfStaleCdkConstruct(err: unknown, cdkProjectDir: string): unknown { + const rejectedKeys = extractUnknownKeys(err); + if (rejectedKeys.length === 0) return err instanceof Error ? err : new Error(String(err)); + const installedVersion = readInstalledCdkConstructVersion(cdkProjectDir); + return new StaleCdkConstructError(rejectedKeys, installedVersion, { cause: err }); +} + /** * Checks if the CloudFormation stacks are in a deployable state. * Returns information about any stack that would block deployment. diff --git a/src/cli/primitives/GatewayPrimitive.ts b/src/cli/primitives/GatewayPrimitive.ts index a17911890..7d0f22514 100644 --- a/src/cli/primitives/GatewayPrimitive.ts +++ b/src/cli/primitives/GatewayPrimitive.ts @@ -11,6 +11,7 @@ import type { import { AgentCoreGatewaySchema, PolicyEngineModeSchema } from '../../schema'; import type { AddGatewayOptions as CLIAddGatewayOptions } from '../commands/add/types'; import { validateAddGatewayOptions } from '../commands/add/validate'; +import { MAX_GATEWAY_NAME_LENGTH } from '../constants'; import { getErrorMessage } from '../errors'; import type { RemovalPreview, SchemaChange } from '../operations/remove/types'; import { runCliCommand, withCommandRunTelemetry } from '../telemetry/cli-command-run.js'; @@ -420,6 +421,18 @@ export class GatewayPrimitive extends BasePrimitive MAX_GATEWAY_NAME_LENGTH) { + throw new ValidationError( + `Gateway name too long: "${combinedName}" (${combinedName.length} chars). ` + + `AWS limits gateway names to ${MAX_GATEWAY_NAME_LENGTH} characters. ` + + `Shorten the project name or gateway name.` + ); + } + // Move selected unassigned targets to the new gateway const selectedNames = new Set(config.selectedTargets ?? []); const movedTargets: AgentCoreGatewayTarget[] = []; diff --git a/src/cli/telemetry/schemas/common-shapes.ts b/src/cli/telemetry/schemas/common-shapes.ts index bf7679d80..0304a72d7 100644 --- a/src/cli/telemetry/schemas/common-shapes.ts +++ b/src/cli/telemetry/schemas/common-shapes.ts @@ -128,6 +128,7 @@ export const ErrorName = z.enum([ 'ServiceError', 'ServiceQuotaError', 'ShellKickedError', + 'StaleCdkConstructError', 'ThrottlingError', 'TimeoutError', 'UnsupportedLanguageError', diff --git a/src/lib/errors/types.ts b/src/lib/errors/types.ts index e7e7f7c33..95510fff1 100644 --- a/src/lib/errors/types.ts +++ b/src/lib/errors/types.ts @@ -97,6 +97,28 @@ export class ValidationError extends BaseError { } } +/** + * Error thrown when CDK synth fails because the project's installed `@aws/agentcore-cdk` + * construct is older than the running CLI and rejects a config key the CLI legitimately writes. + * + * The CLI strict-validates agentcore.json against its own schema during preflight, *before* + * synth. So an "unknown keys (remove)" failure reaching synth proves the key is valid to the + * CLI but unknown to the older bundled construct — i.e. version skew, not a bad config. + */ +export class StaleCdkConstructError extends BaseError { + constructor(rejectedKeys: string[], installedVersion: string | undefined, options?: BaseErrorOptions) { + const keyList = rejectedKeys.map(k => `"${k}"`).join(', '); + const versionPart = installedVersion ? ` (currently ${installedVersion})` : ''; + super( + `CDK synth rejected ${keyList}, which this CLI writes but the project's installed ` + + `@aws/agentcore-cdk${versionPart} does not recognize. If you did not hand-edit ` + + `agentcore.json, the bundled CDK constructs are likely behind the CLI. Run ` + + `\`npm update @aws/agentcore-cdk\` in agentcore/cdk/, then re-run \`agentcore deploy\`.`, + { defaultSource: 'user', ...options } + ); + } +} + /** * Error thrown when AWS credentials are not configured or invalid. * Supports both a short message (for interactive mode) and detailed message (for CLI mode).