Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/cli/commands/add/__tests__/add-gateway.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
12 changes: 12 additions & 0 deletions src/cli/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
100 changes: 99 additions & 1 deletion src/cli/operations/deploy/__tests__/preflight.test.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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');
});
});
82 changes: 78 additions & 4 deletions src/cli/operations/deploy/preflight.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<PreflightContext> {
// Find the agentcore config directory, walking up from cwd if needed
const configRoot = requireConfigRoot();
Expand Down Expand Up @@ -128,6 +127,9 @@ export async function validateProject(): Promise<PreflightContext> {
// 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);

Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -275,14 +298,65 @@ 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,
stackNames: synthResult.stackNames,
};
}

// 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.
Expand Down
13 changes: 13 additions & 0 deletions src/cli/primitives/GatewayPrimitive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -420,6 +421,18 @@ export class GatewayPrimitive extends BasePrimitive<AddGatewayOptions, Removable
throw new Error(`Gateway "${config.name}" already exists.`);
}

// AWS composes the deployed gateway name as `${projectName}-${gatewayName}` and rejects it
// over MAX_GATEWAY_NAME_LENGTH chars at CreateGateway. Fail here, when the user types the
// command, rather than mid-deploy with an opaque CloudFormation CREATE_FAILED.
const combinedName = `${project.name}-${config.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.`
);
}

// Move selected unassigned targets to the new gateway
const selectedNames = new Set(config.selectedTargets ?? []);
const movedTargets: AgentCoreGatewayTarget[] = [];
Expand Down
1 change: 1 addition & 0 deletions src/cli/telemetry/schemas/common-shapes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ export const ErrorName = z.enum([
'ServiceError',
'ServiceQuotaError',
'ShellKickedError',
'StaleCdkConstructError',
'ThrottlingError',
'TimeoutError',
'UnsupportedLanguageError',
Expand Down
22 changes: 22 additions & 0 deletions src/lib/errors/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Loading