From e5823f28dc619dd8dbf700946a4901ed6c5828b3 Mon Sep 17 00:00:00 2001 From: Jesse Turner Date: Tue, 14 Apr 2026 15:02:55 +0000 Subject: [PATCH 01/15] feat: add gateway import command and unhide import from TUI Add `agentcore import gateway --arn ` to import existing AWS gateways (with all targets) into a local CLI project. Also remove import from the HIDDEN_FROM_TUI list so it appears in the interactive TUI. - Add AWS SDK wrappers for gateway/target list/get APIs - Add import-gateway.ts with multi-resource CFN import support - Add resourceName schema field to preserve actual AWS gateway name during import - Register gateway in TUI ImportSelectScreen and ImportProgressScreen - Extend ARN pattern, deployed state, and CFN constants for gateway type --- src/cli/aws/agentcore-control.ts | 360 ++++++++++ src/cli/commands/import/command.ts | 4 +- src/cli/commands/import/constants.ts | 1 + src/cli/commands/import/import-gateway.ts | 639 ++++++++++++++++++ src/cli/commands/import/import-utils.ts | 22 +- src/cli/commands/import/types.ts | 2 +- .../screens/import/ImportProgressScreen.tsx | 4 +- .../tui/screens/import/ImportSelectScreen.tsx | 7 +- src/schema/schemas/mcp.ts | 2 + 9 files changed, 1033 insertions(+), 8 deletions(-) create mode 100644 src/cli/commands/import/import-gateway.ts diff --git a/src/cli/aws/agentcore-control.ts b/src/cli/aws/agentcore-control.ts index d44c6473..c79a0228 100644 --- a/src/cli/aws/agentcore-control.ts +++ b/src/cli/aws/agentcore-control.ts @@ -4,10 +4,14 @@ import { BedrockAgentCoreControlClient, GetAgentRuntimeCommand, GetEvaluatorCommand, + GetGatewayCommand, + GetGatewayTargetCommand, GetMemoryCommand, GetOnlineEvaluationConfigCommand, ListAgentRuntimesCommand, ListEvaluatorsCommand, + ListGatewayTargetsCommand, + ListGatewaysCommand, ListMemoriesCommand, ListOnlineEvaluationConfigsCommand, ListTagsForResourceCommand, @@ -781,3 +785,359 @@ export async function getOnlineEvaluationConfig( evaluatorIds, }; } + +// ============================================================================ +// Gateways — List & Get +// ============================================================================ + +export interface GatewaySummary { + gatewayId: string; + name: string; + status: string; + description?: string; + authorizerType: string; +} + +export interface GatewayDetail { + gatewayId: string; + gatewayArn: string; + gatewayUrl?: string; + name: string; + status: string; + description?: string; + authorizerType: string; + authorizerConfiguration?: { + customJwtAuthorizer?: { + discoveryUrl: string; + allowedAudience?: string[]; + allowedClients?: string[]; + allowedScopes?: string[]; + customClaims?: { + inboundTokenClaimName: string; + inboundTokenClaimValueType: string; + authorizingClaimMatchValue: { + claimMatchValue: { matchValueString?: string; matchValueStringList?: string[] }; + claimMatchOperator: string; + }; + }[]; + }; + }; + protocolConfiguration?: { + mcp?: { searchType?: string }; + }; + exceptionLevel?: string; + policyEngineConfiguration?: { + arn: string; + mode: string; + }; + tags?: Record; +} + +export interface ListGatewaysResult { + gateways: GatewaySummary[]; + nextToken?: string; +} + +export async function listGatewaysPage( + options: { region: string; maxResults?: number; nextToken?: string }, + client?: BedrockAgentCoreControlClient +): Promise { + const resolvedClient = client ?? createControlClient(options.region); + + const command = new ListGatewaysCommand({ + maxResults: options.maxResults, + nextToken: options.nextToken, + }); + + const response = await resolvedClient.send(command); + + return { + gateways: (response.items ?? []).map(g => ({ + gatewayId: g.gatewayId ?? '', + name: g.name ?? '', + status: g.status ?? 'UNKNOWN', + description: g.description, + authorizerType: g.authorizerType ?? 'NONE', + })), + nextToken: response.nextToken, + }; +} + +/** + * List all Gateways in the given region, paginating through all pages. + */ +export async function listAllGateways(options: { region: string }): Promise { + return paginateAll(options.region, async (opts, client) => { + const result = await listGatewaysPage(opts, client); + return { items: result.gateways, nextToken: result.nextToken }; + }); +} + +/** + * Get full details of a Gateway by ID. + */ +export async function getGatewayDetail(options: { region: string; gatewayId: string }): Promise { + const client = createControlClient(options.region); + + const command = new GetGatewayCommand({ + gatewayIdentifier: options.gatewayId, + }); + + const response = await client.send(command); + + let authorizerConfiguration: GatewayDetail['authorizerConfiguration']; + if (response.authorizerConfiguration && 'customJWTAuthorizer' in response.authorizerConfiguration) { + const jwt = response.authorizerConfiguration.customJWTAuthorizer; + if (jwt) { + authorizerConfiguration = { + customJwtAuthorizer: { + discoveryUrl: jwt.discoveryUrl ?? '', + allowedAudience: jwt.allowedAudience, + allowedClients: jwt.allowedClients, + allowedScopes: jwt.allowedScopes, + customClaims: jwt.customClaims?.map(c => ({ + inboundTokenClaimName: c.inboundTokenClaimName ?? '', + inboundTokenClaimValueType: c.inboundTokenClaimValueType ?? 'STRING', + authorizingClaimMatchValue: { + claimMatchValue: { + matchValueString: + c.authorizingClaimMatchValue?.claimMatchValue && + 'matchValueString' in c.authorizingClaimMatchValue.claimMatchValue + ? c.authorizingClaimMatchValue.claimMatchValue.matchValueString + : undefined, + matchValueStringList: + c.authorizingClaimMatchValue?.claimMatchValue && + 'matchValueStringList' in c.authorizingClaimMatchValue.claimMatchValue + ? c.authorizingClaimMatchValue.claimMatchValue.matchValueStringList + : undefined, + }, + claimMatchOperator: c.authorizingClaimMatchValue?.claimMatchOperator ?? 'EQUALS', + }, + })), + }, + }; + } + } + + let protocolConfiguration: GatewayDetail['protocolConfiguration']; + if (response.protocolConfiguration && 'mcp' in response.protocolConfiguration) { + protocolConfiguration = { + mcp: { searchType: response.protocolConfiguration.mcp?.searchType }, + }; + } + + const tags = await fetchTags(client, response.gatewayArn, 'gateway'); + + return { + gatewayId: response.gatewayId ?? '', + gatewayArn: response.gatewayArn ?? '', + gatewayUrl: response.gatewayUrl, + name: response.name ?? '', + status: response.status ?? 'UNKNOWN', + description: response.description, + authorizerType: response.authorizerType ?? 'NONE', + authorizerConfiguration, + protocolConfiguration, + exceptionLevel: response.exceptionLevel, + policyEngineConfiguration: response.policyEngineConfiguration + ? { arn: response.policyEngineConfiguration.arn ?? '', mode: response.policyEngineConfiguration.mode ?? '' } + : undefined, + tags, + }; +} + +// ============================================================================ +// Gateway Targets — List & Get +// ============================================================================ + +export interface GatewayTargetSummary { + targetId: string; + name: string; + status: string; + description?: string; +} + +/* eslint-disable @typescript-eslint/no-explicit-any */ +export interface GatewayTargetDetail { + targetId: string; + name: string; + status: string; + description?: string; + targetConfiguration?: { + mcp?: { + mcpServer?: { endpoint: string }; + apiGateway?: { + restApiId: string; + stage: string; + apiGatewayToolConfiguration?: { + toolFilters?: { filterPath: string; methods: string[] }[]; + toolOverrides?: { name: string; path: string; method: string; description?: string }[]; + }; + }; + openApiSchema?: { s3?: { uri: string; bucketOwnerAccountId?: string }; inlinePayload?: string }; + smithyModel?: { s3?: { uri: string; bucketOwnerAccountId?: string }; inlinePayload?: string }; + lambda?: { lambdaArn: string; toolSchema?: any }; + }; + }; + credentialProviderConfigurations?: { + credentialProviderType: string; + credentialProvider?: { + oauthCredentialProvider?: { providerArn: string; scopes?: string[] }; + apiKeyCredentialProvider?: { providerArn: string }; + }; + }[]; +} +/* eslint-enable @typescript-eslint/no-explicit-any */ + +export interface ListGatewayTargetsResult { + targets: GatewayTargetSummary[]; + nextToken?: string; +} + +export async function listGatewayTargetsPage( + options: { region: string; gatewayId: string; maxResults?: number; nextToken?: string }, + client?: BedrockAgentCoreControlClient +): Promise { + const resolvedClient = client ?? createControlClient(options.region); + + const command = new ListGatewayTargetsCommand({ + gatewayIdentifier: options.gatewayId, + maxResults: options.maxResults, + nextToken: options.nextToken, + }); + + const response = await resolvedClient.send(command); + + return { + targets: (response.items ?? []).map(t => ({ + targetId: t.targetId ?? '', + name: t.name ?? '', + status: t.status ?? 'UNKNOWN', + description: t.description, + })), + nextToken: response.nextToken, + }; +} + +/** + * List all targets for a Gateway, paginating through all pages. + */ +export async function listAllGatewayTargets(options: { + region: string; + gatewayId: string; +}): Promise { + const client = createControlClient(options.region); + const items: GatewayTargetSummary[] = []; + let nextToken: string | undefined; + + do { + const result = await listGatewayTargetsPage( + { region: options.region, gatewayId: options.gatewayId, maxResults: 100, nextToken }, + client + ); + items.push(...result.targets); + nextToken = result.nextToken; + } while (nextToken); + + return items; +} + +/** + * Get full details of a Gateway Target by gateway ID and target ID. + */ +export async function getGatewayTargetDetail(options: { + region: string; + gatewayId: string; + targetId: string; +}): Promise { + const client = createControlClient(options.region); + + const command = new GetGatewayTargetCommand({ + gatewayIdentifier: options.gatewayId, + targetId: options.targetId, + }); + + const response = await client.send(command); + + /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call */ + let targetConfiguration: GatewayTargetDetail['targetConfiguration']; + if (response.targetConfiguration && 'mcp' in response.targetConfiguration) { + const mcp = response.targetConfiguration.mcp as any; + targetConfiguration = { mcp: {} }; + + if (mcp?.mcpServer) { + targetConfiguration.mcp!.mcpServer = { endpoint: mcp.mcpServer.endpoint ?? '' }; + } + if (mcp?.apiGateway) { + targetConfiguration.mcp!.apiGateway = { + restApiId: mcp.apiGateway.restApiId ?? '', + stage: mcp.apiGateway.stage ?? '', + apiGatewayToolConfiguration: mcp.apiGateway.apiGatewayToolConfiguration + ? { + toolFilters: mcp.apiGateway.apiGatewayToolConfiguration.toolFilters?.map((f: any) => ({ + filterPath: f.filterPath ?? '', + methods: f.methods ?? [], + })), + toolOverrides: mcp.apiGateway.apiGatewayToolConfiguration.toolOverrides?.map((o: any) => ({ + name: o.name ?? '', + path: o.path ?? '', + method: o.method ?? '', + description: o.description, + })), + } + : undefined, + }; + } + if (mcp?.openApiSchema) { + targetConfiguration.mcp!.openApiSchema = { + s3: mcp.openApiSchema.s3 + ? { uri: mcp.openApiSchema.s3.uri ?? '', bucketOwnerAccountId: mcp.openApiSchema.s3.bucketOwnerAccountId } + : undefined, + inlinePayload: mcp.openApiSchema.inlinePayload, + }; + } + if (mcp?.smithyModel) { + targetConfiguration.mcp!.smithyModel = { + s3: mcp.smithyModel.s3 + ? { uri: mcp.smithyModel.s3.uri ?? '', bucketOwnerAccountId: mcp.smithyModel.s3.bucketOwnerAccountId } + : undefined, + inlinePayload: mcp.smithyModel.inlinePayload, + }; + } + if (mcp?.lambda) { + targetConfiguration.mcp!.lambda = { + lambdaArn: mcp.lambda.lambdaArn ?? '', + toolSchema: mcp.lambda.toolSchema, + }; + } + } + + const credentialProviderConfigurations: GatewayTargetDetail['credentialProviderConfigurations'] = ( + response.credentialProviderConfigurations ?? [] + ).map((c: any) => ({ + credentialProviderType: c.credentialProviderType ?? '', + credentialProvider: c.credentialProvider + ? { + oauthCredentialProvider: c.credentialProvider.oauthCredentialProvider + ? { + providerArn: c.credentialProvider.oauthCredentialProvider.providerArn ?? '', + scopes: c.credentialProvider.oauthCredentialProvider.scopes, + } + : undefined, + apiKeyCredentialProvider: c.credentialProvider.apiKeyCredentialProvider + ? { providerArn: c.credentialProvider.apiKeyCredentialProvider.providerArn ?? '' } + : undefined, + } + : undefined, + })); + /* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call */ + + return { + targetId: response.targetId ?? '', + name: response.name ?? '', + status: response.status ?? 'UNKNOWN', + description: response.description, + targetConfiguration, + credentialProviderConfigurations, + }; +} diff --git a/src/cli/commands/import/command.ts b/src/cli/commands/import/command.ts index 3fd4f745..ea783d0a 100644 --- a/src/cli/commands/import/command.ts +++ b/src/cli/commands/import/command.ts @@ -1,6 +1,7 @@ import { handleImport } from './actions'; import { ANSI } from './constants'; import { registerImportEvaluator } from './import-evaluator'; +import { registerImportGateway } from './import-gateway'; import { registerImportMemory } from './import-memory'; import { registerImportOnlineEval } from './import-online-eval'; import { registerImportRuntime } from './import-runtime'; @@ -12,7 +13,7 @@ const { green, yellow, cyan, dim, reset } = ANSI; export const registerImport = (program: Command) => { const importCmd = program .command('import') - .description('Import a runtime, memory, or starter toolkit into this project. [experimental]'); + .description('Import a runtime, memory, gateway, or starter toolkit into this project. [experimental]'); // Existing YAML flow: agentcore import --source importCmd @@ -152,4 +153,5 @@ export const registerImport = (program: Command) => { registerImportMemory(importCmd); registerImportEvaluator(importCmd); registerImportOnlineEval(importCmd); + registerImportGateway(importCmd); }; diff --git a/src/cli/commands/import/constants.ts b/src/cli/commands/import/constants.ts index 93c25f90..e2291384 100644 --- a/src/cli/commands/import/constants.ts +++ b/src/cli/commands/import/constants.ts @@ -18,6 +18,7 @@ export const CFN_RESOURCE_IDENTIFIERS: Record = { 'AWS::BedrockAgentCore::Runtime': ['AgentRuntimeId'], 'AWS::BedrockAgentCore::Memory': ['MemoryId'], 'AWS::BedrockAgentCore::Gateway': ['GatewayIdentifier'], + 'AWS::BedrockAgentCore::GatewayTarget': ['GatewayIdentifier', 'TargetId'], 'AWS::BedrockAgentCore::Evaluator': ['EvaluatorId'], 'AWS::BedrockAgentCore::OnlineEvaluationConfig': ['OnlineEvaluationConfigId'], }; diff --git a/src/cli/commands/import/import-gateway.ts b/src/cli/commands/import/import-gateway.ts new file mode 100644 index 00000000..fbe10f4a --- /dev/null +++ b/src/cli/commands/import/import-gateway.ts @@ -0,0 +1,639 @@ +import type { + AgentCoreGateway, + AgentCoreGatewayTarget, + AgentCoreProjectSpec, + AuthorizerConfig, + CustomClaimValidation, + GatewayAuthorizerType, + GatewayExceptionLevel, + GatewayPolicyEngineConfiguration, + OutboundAuth, +} from '../../../schema'; +import type { GatewayDetail, GatewayTargetDetail } from '../../aws/agentcore-control'; +import { + getGatewayDetail, + getGatewayTargetDetail, + listAllGatewayTargets, + listAllGateways, +} from '../../aws/agentcore-control'; +import { ANSI, NAME_REGEX } from './constants'; +import { executeCdkImportPipeline } from './import-pipeline'; +import { + failResult, + findResourceInDeployedState, + parseAndValidateArn, + resolveImportContext, + toStackName, +} from './import-utils'; +import { findLogicalIdByProperty, findLogicalIdsByType } from './template-utils'; +import type { ImportResourceOptions, ImportResourceResult, ResourceToImport } from './types'; +import type { Command } from '@commander-js/extra-typings'; + +// ============================================================================ +// AWS → CLI Schema Mapping +// ============================================================================ + +/** + * Map GetGatewayTarget response to CLI AgentCoreGatewayTarget schema. + * Determines target type from the targetConfiguration.mcp union. + */ +function toGatewayTargetSpec( + detail: GatewayTargetDetail, + credentials: Map, + onProgress: (msg: string) => void +): AgentCoreGatewayTarget | undefined { + const mcp = detail.targetConfiguration?.mcp; + if (!mcp) { + onProgress(`Warning: Target "${detail.name}" has no MCP configuration, skipping`); + return undefined; + } + + const outboundAuth = resolveOutboundAuth(detail, credentials, onProgress); + + // MCP Server (external endpoint) + if (mcp.mcpServer) { + return { + name: detail.name, + targetType: 'mcpServer', + endpoint: mcp.mcpServer.endpoint, + ...(outboundAuth && { outboundAuth }), + }; + } + + // API Gateway + if (mcp.apiGateway) { + const apigw = mcp.apiGateway; + /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment */ + const target: AgentCoreGatewayTarget = { + name: detail.name, + targetType: 'apiGateway', + apiGateway: { + restApiId: apigw.restApiId, + stage: apigw.stage, + apiGatewayToolConfiguration: { + toolFilters: (apigw.apiGatewayToolConfiguration?.toolFilters ?? []).map(f => ({ + filterPath: f.filterPath, + methods: f.methods, + })) as any, + ...(apigw.apiGatewayToolConfiguration?.toolOverrides && { + toolOverrides: apigw.apiGatewayToolConfiguration.toolOverrides.map(o => ({ + name: o.name, + path: o.path, + method: o.method, + ...(o.description && { description: o.description }), + })), + }), + }, + } as any, + ...(outboundAuth && { outboundAuth }), + }; + /* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment */ + return target; + } + + // OpenAPI Schema + if (mcp.openApiSchema) { + const schema = mcp.openApiSchema; + if (schema.s3?.uri) { + return { + name: detail.name, + targetType: 'openApiSchema', + schemaSource: { + s3: { + uri: schema.s3.uri, + ...(schema.s3.bucketOwnerAccountId && { bucketOwnerAccountId: schema.s3.bucketOwnerAccountId }), + }, + }, + ...(outboundAuth && { outboundAuth }), + }; + } + onProgress(`Warning: Target "${detail.name}" (openApiSchema) has no S3 URI, skipping`); + return undefined; + } + + // Smithy Model + if (mcp.smithyModel) { + const schema = mcp.smithyModel; + if (schema.s3?.uri) { + return { + name: detail.name, + targetType: 'smithyModel', + schemaSource: { + s3: { + uri: schema.s3.uri, + ...(schema.s3.bucketOwnerAccountId && { bucketOwnerAccountId: schema.s3.bucketOwnerAccountId }), + }, + }, + ...(outboundAuth && { outboundAuth }), + }; + } + onProgress(`Warning: Target "${detail.name}" (smithyModel) has no S3 URI, skipping`); + return undefined; + } + + // Lambda (compute-backed) → map to lambdaFunctionArn + if (mcp.lambda) { + const lambdaArn = mcp.lambda.lambdaArn; + if (!lambdaArn) { + onProgress(`Warning: Target "${detail.name}" (lambda) has no ARN, skipping`); + return undefined; + } + + // Extract tool schema S3 URI if available + /* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment */ + const toolSchema = mcp.lambda.toolSchema; + const s3Uri: string | undefined = toolSchema?.s3?.uri; + /* eslint-enable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment */ + + if (s3Uri) { + onProgress(`Mapping compute-backed Lambda target "${detail.name}" to lambdaFunctionArn type`); + return { + name: detail.name, + targetType: 'lambdaFunctionArn', + lambdaFunctionArn: { + lambdaArn, + toolSchemaFile: s3Uri, + }, + }; + } + + // Lambda without S3 schema — can't import as lambdaFunctionArn since toolSchemaFile is required + onProgress(`Warning: Target "${detail.name}" (lambda) has inline tool schema, which cannot be imported. Skipping.`); + return undefined; + } + + onProgress(`Warning: Target "${detail.name}" has an unrecognized target type, skipping`); + return undefined; +} + +/** + * Resolve outbound auth from credential provider configurations. + */ +function resolveOutboundAuth( + detail: GatewayTargetDetail, + credentials: Map, + onProgress: (msg: string) => void +): OutboundAuth | undefined { + const configs = detail.credentialProviderConfigurations; + if (!configs || configs.length === 0) return undefined; + + for (const config of configs) { + if (config.credentialProviderType === 'OAUTH' && config.credentialProvider?.oauthCredentialProvider) { + const providerArn = config.credentialProvider.oauthCredentialProvider.providerArn; + const credentialName = credentials.get(providerArn); + if (credentialName) { + return { + type: 'OAUTH', + credentialName, + ...(config.credentialProvider.oauthCredentialProvider.scopes?.length && { + scopes: config.credentialProvider.oauthCredentialProvider.scopes, + }), + }; + } + onProgress( + `Warning: Target "${detail.name}" uses OAuth credential (${providerArn}) not found in project. ` + + 'Configure credentials manually after import with `agentcore add credential`.' + ); + return undefined; + } + + if (config.credentialProviderType === 'API_KEY' && config.credentialProvider?.apiKeyCredentialProvider) { + const providerArn = config.credentialProvider.apiKeyCredentialProvider.providerArn; + const credentialName = credentials.get(providerArn); + if (credentialName) { + return { type: 'API_KEY', credentialName }; + } + onProgress( + `Warning: Target "${detail.name}" uses API Key credential (${providerArn}) not found in project. ` + + 'Configure credentials manually after import with `agentcore add credential`.' + ); + return undefined; + } + + // GATEWAY_IAM_ROLE — no outbound auth needed + } + + return undefined; +} + +/** + * Map GetGateway + GetGatewayTarget[] responses to CLI AgentCoreGateway schema. + */ +function toGatewaySpec(gateway: GatewayDetail, targets: AgentCoreGatewayTarget[], localName: string): AgentCoreGateway { + const authorizerType = (gateway.authorizerType ?? 'NONE') as GatewayAuthorizerType; + + let authorizerConfiguration: AuthorizerConfig | undefined; + if (authorizerType === 'CUSTOM_JWT' && gateway.authorizerConfiguration?.customJwtAuthorizer) { + const jwt = gateway.authorizerConfiguration.customJwtAuthorizer; + authorizerConfiguration = { + customJwtAuthorizer: { + discoveryUrl: jwt.discoveryUrl, + ...(jwt.allowedAudience?.length && { allowedAudience: jwt.allowedAudience }), + ...(jwt.allowedClients?.length && { allowedClients: jwt.allowedClients }), + ...(jwt.allowedScopes?.length && { allowedScopes: jwt.allowedScopes }), + ...(jwt.customClaims?.length && { + customClaims: jwt.customClaims.map( + (c): CustomClaimValidation => ({ + inboundTokenClaimName: c.inboundTokenClaimName, + inboundTokenClaimValueType: c.inboundTokenClaimValueType as 'STRING' | 'STRING_ARRAY', + authorizingClaimMatchValue: { + claimMatchOperator: c.authorizingClaimMatchValue.claimMatchOperator as + | 'EQUALS' + | 'CONTAINS' + | 'CONTAINS_ANY', + claimMatchValue: { + ...(c.authorizingClaimMatchValue.claimMatchValue.matchValueString && { + matchValueString: c.authorizingClaimMatchValue.claimMatchValue.matchValueString, + }), + ...(c.authorizingClaimMatchValue.claimMatchValue.matchValueStringList && { + matchValueStringList: c.authorizingClaimMatchValue.claimMatchValue.matchValueStringList, + }), + }, + }, + }) + ), + }), + }, + }; + } + + const enableSemanticSearch = gateway.protocolConfiguration?.mcp?.searchType === 'SEMANTIC'; + const exceptionLevel: GatewayExceptionLevel = gateway.exceptionLevel === 'DEBUG' ? 'DEBUG' : 'NONE'; + + let policyEngineConfiguration: GatewayPolicyEngineConfiguration | undefined; + if (gateway.policyEngineConfiguration) { + // Extract policy engine name from ARN (last segment after /) + const arnParts = gateway.policyEngineConfiguration.arn.split('/'); + const policyEngineName = arnParts[arnParts.length - 1] ?? gateway.policyEngineConfiguration.arn; + policyEngineConfiguration = { + policyEngineName, + mode: gateway.policyEngineConfiguration.mode as 'LOG_ONLY' | 'ENFORCE', + }; + } + + return { + name: localName, + resourceName: gateway.name, + ...(gateway.description && { description: gateway.description }), + targets, + authorizerType, + ...(authorizerConfiguration && { authorizerConfiguration }), + enableSemanticSearch, + exceptionLevel, + ...(policyEngineConfiguration && { policyEngineConfiguration }), + ...(gateway.tags && Object.keys(gateway.tags).length > 0 && { tags: gateway.tags }), + }; +} + +// ============================================================================ +// Credential ARN → Name Resolution +// ============================================================================ + +/** + * Build a map from credential provider ARN → credential name + * using the project's deployed state. + */ +async function buildCredentialArnMap( + configIO: { readDeployedState: () => Promise }, + targetName: string +): Promise> { + const map = new Map(); + try { + /* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument */ + const state = (await configIO.readDeployedState()) as any; + const credentials = state?.targets?.[targetName]?.resources?.credentials; + if (credentials && typeof credentials === 'object') { + for (const [name, entry] of Object.entries(credentials)) { + const arn = (entry as any)?.credentialProviderArn; + if (typeof arn === 'string') { + map.set(arn, name); + } + } + } + /* eslint-enable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument */ + } catch { + // No deployed state — credentials won't be resolved + } + return map; +} + +// ============================================================================ +// Import Flow +// ============================================================================ + +/** + * Handle `agentcore import gateway`. + */ +export async function handleImportGateway(options: ImportResourceOptions): Promise { + let configSnapshot: AgentCoreProjectSpec | undefined; + let configWritten = false; + let importCtx: Awaited> | undefined; + + const rollback = async () => { + if (configWritten && configSnapshot && importCtx) { + try { + await importCtx.ctx.configIO.writeProjectSpec(configSnapshot); + } catch (err) { + console.warn(`Warning: Could not restore agentcore.json: ${err instanceof Error ? err.message : String(err)}`); + } + } + }; + + try { + // 1-2. Validate project context and resolve target + importCtx = await resolveImportContext(options, 'import-gateway'); + const { ctx, target, logger, onProgress } = importCtx; + + // 3. Fetch gateway from AWS + logger.startStep('Fetch gateway from AWS'); + let gatewayId: string; + + if (options.arn) { + gatewayId = parseAndValidateArn(options.arn, 'gateway', target).resourceId; + } else { + onProgress('Listing gateways in your account...'); + const summaries = await listAllGateways({ region: target.region }); + + if (summaries.length === 0) { + return failResult(logger, 'No gateways found in your account.', 'gateway', ''); + } + + if (summaries.length === 1) { + gatewayId = summaries[0]!.gatewayId; + onProgress(`Found 1 gateway: ${summaries[0]!.name} (${gatewayId}). Auto-selecting.`); + } else { + console.log(`\nFound ${summaries.length} gateway(s):\n`); + for (let i = 0; i < summaries.length; i++) { + const s = summaries[i]!; + console.log( + ` ${ANSI.dim}[${i + 1}]${ANSI.reset} ${s.name} — ${s.status}\n` + + ` ${ANSI.dim}${s.gatewayId} (${s.authorizerType})${ANSI.reset}` + ); + } + console.log(''); + return failResult( + logger, + 'Multiple gateways found. Use --arn to specify which gateway to import.', + 'gateway', + '' + ); + } + } + + onProgress(`Fetching gateway details for ${gatewayId}...`); + const gatewayDetail = await getGatewayDetail({ region: target.region, gatewayId }); + + if (gatewayDetail.status !== 'READY') { + onProgress(`Warning: Gateway status is ${gatewayDetail.status}, not READY`); + } + + // 3b. Fetch all targets + onProgress('Listing gateway targets...'); + const targetSummaries = await listAllGatewayTargets({ region: target.region, gatewayId }); + onProgress(`Found ${targetSummaries.length} target(s) for gateway`); + + const targetDetails: GatewayTargetDetail[] = []; + for (const ts of targetSummaries) { + const td = await getGatewayTargetDetail({ region: target.region, gatewayId, targetId: ts.targetId }); + targetDetails.push(td); + } + logger.endStep('success'); + + // 4. Validate name + logger.startStep('Validate name'); + const localName = options.name ?? gatewayDetail.name; + if (!NAME_REGEX.test(localName)) { + return failResult( + logger, + `Invalid name "${localName}". Name must start with a letter and contain only letters, numbers, and underscores (max 48 chars).`, + 'gateway', + localName + ); + } + onProgress(`Gateway: ${gatewayDetail.name} -> local name: ${localName}`); + logger.endStep('success'); + + // 5. Check for duplicates + logger.startStep('Check for duplicates'); + const projectSpec = await ctx.configIO.readProjectSpec(); + const existingNames = new Set(projectSpec.agentCoreGateways.map(g => g.name)); + if (existingNames.has(localName)) { + return failResult( + logger, + `Gateway "${localName}" already exists in the project. Use --name to specify a different local name.`, + 'gateway', + localName + ); + } + const targetName = target.name ?? 'default'; + const existingResource = await findResourceInDeployedState(ctx.configIO, targetName, 'gateway', gatewayId); + if (existingResource) { + return failResult( + logger, + `Gateway "${gatewayId}" is already imported in this project as "${existingResource}". Remove it first before re-importing.`, + 'gateway', + localName + ); + } + logger.endStep('success'); + + // 6. Map AWS responses to CLI schema + logger.startStep('Map gateway to project schema'); + const credentialArnMap = await buildCredentialArnMap(ctx.configIO, targetName); + + const mappedTargets: AgentCoreGatewayTarget[] = []; + for (const td of targetDetails) { + const mapped = toGatewayTargetSpec(td, credentialArnMap, onProgress); + if (mapped) { + mappedTargets.push(mapped); + } + } + + const gatewaySpec = toGatewaySpec(gatewayDetail, mappedTargets, localName); + onProgress(`Mapped gateway with ${mappedTargets.length} target(s)`); + if (mappedTargets.length < targetDetails.length) { + onProgress( + `Warning: ${targetDetails.length - mappedTargets.length} target(s) could not be mapped and were skipped` + ); + } + logger.endStep('success'); + + // 7. Update project config + logger.startStep('Update project config'); + configSnapshot = JSON.parse(JSON.stringify(projectSpec)) as AgentCoreProjectSpec; + projectSpec.agentCoreGateways.push(gatewaySpec); + await ctx.configIO.writeProjectSpec(projectSpec); + configWritten = true; + onProgress(`Added gateway "${localName}" to agentcore.json`); + logger.endStep('success'); + + // 8. CDK build -> synth -> bootstrap -> phase 1 -> phase 2 -> update state + logger.startStep('Build and synth CDK'); + const stackName = toStackName(ctx.projectName, targetName); + + // Build target ID map for CFN import: target name → physical target ID + const targetIdMap = new Map(); + for (const td of targetDetails) { + const mappedTarget = mappedTargets.find(mt => mt.name === td.name); + if (mappedTarget) { + targetIdMap.set(td.name, td.targetId); + } + } + + const pipelineResult = await executeCdkImportPipeline({ + projectRoot: ctx.projectRoot, + stackName, + target, + configIO: ctx.configIO, + targetName, + onProgress, + buildResourcesToImport: synthTemplate => { + const resourcesToImport: ResourceToImport[] = []; + + // Find gateway logical ID + const gatewayResourceName = `${ctx.projectName}-${localName}`; + let gatewayLogicalId = findLogicalIdByProperty( + synthTemplate, + 'AWS::BedrockAgentCore::Gateway', + 'Name', + gatewayResourceName + ); + gatewayLogicalId ??= findLogicalIdByProperty( + synthTemplate, + 'AWS::BedrockAgentCore::Gateway', + 'Name', + localName + ); + if (!gatewayLogicalId) { + const allGatewayIds = findLogicalIdsByType(synthTemplate, 'AWS::BedrockAgentCore::Gateway'); + if (allGatewayIds.length === 1) { + gatewayLogicalId = allGatewayIds[0]; + } + } + + if (!gatewayLogicalId) { + return []; + } + + resourcesToImport.push({ + resourceType: 'AWS::BedrockAgentCore::Gateway', + logicalResourceId: gatewayLogicalId, + resourceIdentifier: { GatewayIdentifier: gatewayId }, + }); + + // Find target logical IDs + const allTargetLogicalIds = findLogicalIdsByType(synthTemplate, 'AWS::BedrockAgentCore::GatewayTarget'); + + for (const [tName, tId] of targetIdMap) { + // Try name-based matching first + let targetLogicalId = findLogicalIdByProperty( + synthTemplate, + 'AWS::BedrockAgentCore::GatewayTarget', + 'Name', + tName + ); + + // Fall back: if exactly one unmatched target logical ID remains, use it + if (!targetLogicalId && allTargetLogicalIds.length === 1 && targetIdMap.size === 1) { + targetLogicalId = allTargetLogicalIds[0]; + } + + if (targetLogicalId) { + resourcesToImport.push({ + resourceType: 'AWS::BedrockAgentCore::GatewayTarget', + logicalResourceId: targetLogicalId, + resourceIdentifier: { GatewayIdentifier: gatewayId, TargetId: tId }, + }); + } else { + onProgress(`Warning: Could not find logical ID for target "${tName}" in CloudFormation template`); + } + } + + return resourcesToImport; + }, + deployedStateEntries: [{ type: 'gateway', name: localName, id: gatewayId, arn: gatewayDetail.gatewayArn }], + }); + + if (pipelineResult.noResources) { + const error = `Could not find logical ID for gateway "${localName}" in CloudFormation template`; + await rollback(); + return failResult(logger, error, 'gateway', localName); + } + + if (!pipelineResult.success) { + await rollback(); + logger.endStep('error', pipelineResult.error); + logger.finalize(false); + return { + success: false, + error: pipelineResult.error, + resourceType: 'gateway', + resourceName: localName, + logPath: logger.getRelativeLogPath(), + }; + } + logger.endStep('success'); + + // 9. Return success + logger.finalize(true); + return { + success: true, + resourceType: 'gateway', + resourceName: localName, + resourceId: gatewayId, + logPath: logger.getRelativeLogPath(), + }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + await rollback(); + if (importCtx) { + importCtx.logger.log(message, 'error'); + importCtx.logger.finalize(false); + } + return { + success: false, + error: message, + resourceType: 'gateway', + resourceName: options.name ?? '', + logPath: importCtx?.logger.getRelativeLogPath(), + }; + } +} + +// ============================================================================ +// Command Registration +// ============================================================================ + +/** + * Register the `import gateway` subcommand. + */ +export function registerImportGateway(importCmd: Command): void { + importCmd + .command('gateway') + .description('Import an existing AgentCore Gateway (with targets) from your AWS account') + .option('--arn ', 'Gateway ARN to import') + .option('--name ', 'Local name for the imported gateway') + .option('-y, --yes', 'Auto-confirm prompts') + .action(async (cliOptions: ImportResourceOptions) => { + const result = await handleImportGateway(cliOptions); + + if (result.success) { + console.log(''); + console.log(`${ANSI.green}Gateway imported successfully!${ANSI.reset}`); + console.log(` Name: ${result.resourceName}`); + console.log(` ID: ${result.resourceId}`); + console.log(''); + console.log(`${ANSI.dim}Next steps:${ANSI.reset}`); + console.log(` agentcore deploy ${ANSI.dim}Deploy the imported stack${ANSI.reset}`); + console.log(` agentcore status ${ANSI.dim}Verify resource status${ANSI.reset}`); + console.log(` agentcore fetch access ${ANSI.dim}Get gateway URL and token${ANSI.reset}`); + console.log(''); + } else { + console.error(`\n${ANSI.red}[error]${ANSI.reset} ${result.error}`); + if (result.logPath) { + console.error(`Log: ${result.logPath}`); + } + process.exit(1); + } + }); +} diff --git a/src/cli/commands/import/import-utils.ts b/src/cli/commands/import/import-utils.ts index d224870e..337f0bc5 100644 --- a/src/cli/commands/import/import-utils.ts +++ b/src/cli/commands/import/import-utils.ts @@ -130,10 +130,12 @@ export async function resolveImportTarget(options: ResolveTargetOptions): Promis // Validate ARN format early if provided if ( arn && - !/^arn:aws:bedrock-agentcore:([^:]+):([^:]+):(runtime|memory|evaluator|online-evaluation-config)\/(.+)$/.test(arn) + !/^arn:aws:bedrock-agentcore:([^:]+):([^:]+):(runtime|memory|evaluator|online-evaluation-config|gateway)\/(.+)$/.test( + arn + ) ) { throw new Error( - `Not a valid ARN: "${arn}".\nExpected format: arn:aws:bedrock-agentcore:::/` + `Not a valid ARN: "${arn}".\nExpected format: arn:aws:bedrock-agentcore:::/` ); } @@ -210,7 +212,7 @@ export interface ParsedArn { } const ARN_PATTERN = - /^arn:aws:bedrock-agentcore:([^:]+):([^:]+):(runtime|memory|evaluator|online-evaluation-config)\/(.+)$/; + /^arn:aws:bedrock-agentcore:([^:]+):([^:]+):(runtime|memory|evaluator|online-evaluation-config|gateway)\/(.+)$/; /** Unified config for each importable resource type — ARN mapping, deployed state keys. */ const RESOURCE_TYPE_CONFIG: Record< @@ -229,6 +231,7 @@ const RESOURCE_TYPE_CONFIG: Record< collectionKey: 'onlineEvalConfigs', idField: 'onlineEvaluationConfigId', }, + gateway: { arnType: 'gateway', collectionKey: 'mcp.gateways', idField: 'gatewayId' }, }; /** @@ -302,7 +305,11 @@ export async function findResourceInDeployedState( const { collectionKey, idField } = RESOURCE_TYPE_CONFIG[resourceType]; - const collection = targetState.resources[collectionKey]; + // Handle nested path (e.g., 'mcp.gateways') by traversing dot-separated keys + let collection: any = targetState.resources; + for (const key of collectionKey.split('.')) { + collection = collection?.[key]; + } if (!collection) return undefined; for (const [name, entry] of Object.entries(collection)) { if ((entry as any)[idField] === resourceId) return name; @@ -360,6 +367,13 @@ export async function updateDeployedState( onlineEvaluationConfigId: resource.id, onlineEvaluationConfigArn: resource.arn, }; + } else if (resource.type === 'gateway') { + targetState.resources.mcp ??= {}; + targetState.resources.mcp.gateways ??= {}; + targetState.resources.mcp.gateways[resource.name] = { + gatewayId: resource.id, + gatewayArn: resource.arn, + }; } } diff --git a/src/cli/commands/import/types.ts b/src/cli/commands/import/types.ts index eab11c0a..a3dbc0a4 100644 --- a/src/cli/commands/import/types.ts +++ b/src/cli/commands/import/types.ts @@ -74,7 +74,7 @@ export interface ParsedStarterToolkitConfig { * Resource types supported by the import subcommands. * Use the array for runtime checks (e.g., IMPORTABLE_RESOURCES.includes(x)). */ -export const IMPORTABLE_RESOURCES = ['runtime', 'memory', 'evaluator', 'online-eval'] as const; +export const IMPORTABLE_RESOURCES = ['runtime', 'memory', 'evaluator', 'online-eval', 'gateway'] as const; export type ImportableResourceType = (typeof IMPORTABLE_RESOURCES)[number]; /** diff --git a/src/cli/tui/screens/import/ImportProgressScreen.tsx b/src/cli/tui/screens/import/ImportProgressScreen.tsx index bd7096d5..3771ffba 100644 --- a/src/cli/tui/screens/import/ImportProgressScreen.tsx +++ b/src/cli/tui/screens/import/ImportProgressScreen.tsx @@ -49,7 +49,9 @@ export function ImportProgressScreen({ ? (await import('../../../commands/import/import-memory')).handleImportMemory : importType === 'evaluator' ? (await import('../../../commands/import/import-evaluator')).handleImportEvaluator - : (await import('../../../commands/import/import-online-eval')).handleImportOnlineEval; + : importType === 'gateway' + ? (await import('../../../commands/import/import-gateway')).handleImportGateway + : (await import('../../../commands/import/import-online-eval')).handleImportOnlineEval; const result = await handler({ arn, code, onProgress }); if (result.success) { diff --git a/src/cli/tui/screens/import/ImportSelectScreen.tsx b/src/cli/tui/screens/import/ImportSelectScreen.tsx index 21ab114f..77ee7580 100644 --- a/src/cli/tui/screens/import/ImportSelectScreen.tsx +++ b/src/cli/tui/screens/import/ImportSelectScreen.tsx @@ -2,7 +2,7 @@ import type { SelectableItem } from '../../components/SelectList'; import { SelectScreen } from '../../components/SelectScreen'; import { Text } from 'ink'; -export type ImportType = 'runtime' | 'memory' | 'evaluator' | 'online-eval' | 'starter-toolkit'; +export type ImportType = 'runtime' | 'memory' | 'evaluator' | 'online-eval' | 'gateway' | 'starter-toolkit'; interface ImportSelectItem extends SelectableItem { id: ImportType; @@ -29,6 +29,11 @@ const IMPORT_OPTIONS: ImportSelectItem[] = [ title: 'Online Eval Config', description: 'Import an existing AgentCore Online Evaluation Config from your AWS account', }, + { + id: 'gateway', + title: 'Gateway', + description: 'Import an existing AgentCore Gateway (with targets) from your AWS account', + }, { id: 'starter-toolkit', title: 'From Starter Toolkit', diff --git a/src/schema/schemas/mcp.ts b/src/schema/schemas/mcp.ts index aaaa4e9c..153d514b 100644 --- a/src/schema/schemas/mcp.ts +++ b/src/schema/schemas/mcp.ts @@ -580,6 +580,8 @@ export type GatewayPolicyEngineConfiguration = z.infer Date: Tue, 14 Apr 2026 15:03:44 +0000 Subject: [PATCH 02/15] fix: expand ARN input to show full resource ARN and add gateway support The ARN text input was truncating long ARNs. Use the expandable prop to wrap text across multiple lines. Also add gateway to the ARN validation pattern and resource type labels. --- src/cli/tui/screens/import/ArnInputScreen.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/cli/tui/screens/import/ArnInputScreen.tsx b/src/cli/tui/screens/import/ArnInputScreen.tsx index 188f9a69..a33f527a 100644 --- a/src/cli/tui/screens/import/ArnInputScreen.tsx +++ b/src/cli/tui/screens/import/ArnInputScreen.tsx @@ -4,7 +4,8 @@ import { Screen } from '../../components/Screen'; import { TextInput } from '../../components/TextInput'; import { HELP_TEXT } from '../../constants'; -const ARN_PATTERN = /^arn:aws:bedrock-agentcore:[^:]+:[^:]+:(runtime|memory|evaluator|online-evaluation-config)\/.+$/; +const ARN_PATTERN = + /^arn:aws:bedrock-agentcore:[^:]+:[^:]+:(runtime|memory|evaluator|online-evaluation-config|gateway)\/.+$/; function validateArn(value: string): true | string { if (!ARN_PATTERN.test(value)) { @@ -24,6 +25,7 @@ const RESOURCE_TYPE_LABELS: Record = { memory: 'Import Memory', evaluator: 'Import Evaluator', 'online-eval': 'Import Online Eval Config', + gateway: 'Import Gateway', }; export function ArnInputScreen({ resourceType, onSubmit, onExit }: ArnInputScreenProps) { @@ -40,6 +42,7 @@ export function ArnInputScreen({ resourceType, onSubmit, onExit }: ArnInputScree onSubmit={onSubmit} onCancel={onExit} customValidation={validateArn} + expandable /> From d2671e833cb7742c66ce3b14f053df12040d446e Mon Sep 17 00:00:00 2001 From: Jesse Turner Date: Tue, 14 Apr 2026 15:55:44 +0000 Subject: [PATCH 03/15] refactor: remove --name and --yes flags from import gateway command Remove --name (confusing local rename) and --yes (no prompts to confirm) from the gateway import command. The gateway's AWS name is used directly. --- src/cli/commands/import/import-gateway.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/cli/commands/import/import-gateway.ts b/src/cli/commands/import/import-gateway.ts index fbe10f4a..c6c2860c 100644 --- a/src/cli/commands/import/import-gateway.ts +++ b/src/cli/commands/import/import-gateway.ts @@ -612,8 +612,6 @@ export function registerImportGateway(importCmd: Command): void { .command('gateway') .description('Import an existing AgentCore Gateway (with targets) from your AWS account') .option('--arn ', 'Gateway ARN to import') - .option('--name ', 'Local name for the imported gateway') - .option('-y, --yes', 'Auto-confirm prompts') .action(async (cliOptions: ImportResourceOptions) => { const result = await handleImportGateway(cliOptions); From 916c10eaed171b6181213047696fe7a858f41d64 Mon Sep 17 00:00:00 2001 From: Jesse Turner Date: Tue, 14 Apr 2026 19:56:50 +0000 Subject: [PATCH 04/15] feat: add e2e tests for import gateway command Add end-to-end tests that create a real AWS gateway with an MCP server target, import it via `agentcore import gateway --arn`, and verify the resulting agentcore.json fields and deployed-state.json entries. New files: - e2e-tests/fixtures/import/setup_gateway.py: creates gateway + target - e2e-tests/fixtures/import/common.py: gateway wait helpers - e2e-tests/fixtures/import/cleanup_resources.py: gateway cleanup Constraint: Tests follow the existing import-resources.test.ts pattern Confidence: high Scope-risk: narrow --- .../fixtures/import/cleanup_resources.py | 6 ++ e2e-tests/fixtures/import/common.py | 45 ++++++++++ e2e-tests/fixtures/import/setup_gateway.py | 88 +++++++++++++++++++ e2e-tests/import-resources.test.ts | 85 +++++++++++++++++- 4 files changed, 222 insertions(+), 2 deletions(-) create mode 100644 e2e-tests/fixtures/import/setup_gateway.py diff --git a/e2e-tests/fixtures/import/cleanup_resources.py b/e2e-tests/fixtures/import/cleanup_resources.py index 120ced18..0728b711 100644 --- a/e2e-tests/fixtures/import/cleanup_resources.py +++ b/e2e-tests/fixtures/import/cleanup_resources.py @@ -51,6 +51,10 @@ def main(): rid = val.get("id") if not rid: continue + # Gateway targets are deleted automatically when the parent gateway is deleted + if "gateway" in key and "target" in key: + print(f"Skipping {key} (deleted with parent gateway)") + continue try: if "runtime" in key: client.delete_agent_runtime(agentRuntimeId=rid) @@ -58,6 +62,8 @@ def main(): client.delete_memory(memoryId=rid) elif "evaluator" in key: client.delete_evaluator(evaluatorId=rid) + elif "gateway" in key: + client.delete_gateway(gatewayIdentifier=rid) print(f"Deleted {key}: {rid}") except Exception as e: print(f"Could not delete {key} ({rid}): {e}") diff --git a/e2e-tests/fixtures/import/common.py b/e2e-tests/fixtures/import/common.py index b49ffb27..369ec0bb 100644 --- a/e2e-tests/fixtures/import/common.py +++ b/e2e-tests/fixtures/import/common.py @@ -256,3 +256,48 @@ def tag_resource(client, arn, tags): """Tag a resource via the control plane API.""" print(f"Tagging resource with {tags}...") client.tag_resource(resourceArn=arn, tags=tags) + + +def wait_for_gateway(client, gateway_id, timeout=120): + """Wait for a gateway to reach READY status.""" + print(f"Waiting for gateway {gateway_id} to become READY...") + start = time.time() + while time.time() - start < timeout: + resp = client.get_gateway(gatewayIdentifier=gateway_id) + status = resp.get("status", "UNKNOWN") + if status == "READY": + print(f"Gateway {gateway_id} is READY") + return True + if status in ("CREATE_FAILED", "FAILED"): + reason = resp.get("statusReasons", [{}]) + print(f"ERROR: Gateway {gateway_id} status: {status} — {reason}") + return False + elapsed = int(time.time() - start) + print(f" Status: {status} ({elapsed}s elapsed)") + time.sleep(5) + print(f"WARNING: Gateway did not reach READY after {timeout}s") + return False + + +def wait_for_gateway_target(client, gateway_id, target_id, timeout=120): + """Wait for a gateway target to reach READY status.""" + print(f"Waiting for target {target_id} to become READY...") + start = time.time() + while time.time() - start < timeout: + resp = client.get_gateway_target( + gatewayIdentifier=gateway_id, + targetId=target_id, + ) + status = resp.get("status", "UNKNOWN") + if status == "READY": + print(f"Target {target_id} is READY") + return True + if status in ("CREATE_FAILED", "FAILED"): + reason = resp.get("statusReasons", [{}]) + print(f"ERROR: Target {target_id} status: {status} — {reason}") + return False + elapsed = int(time.time() - start) + print(f" Status: {status} ({elapsed}s elapsed)") + time.sleep(5) + print(f"WARNING: Target did not reach READY after {timeout}s") + return False diff --git a/e2e-tests/fixtures/import/setup_gateway.py b/e2e-tests/fixtures/import/setup_gateway.py new file mode 100644 index 00000000..e190d0df --- /dev/null +++ b/e2e-tests/fixtures/import/setup_gateway.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Setup: Gateway with MCP server target + tags. + +Tests: gateway import, target mapping, authorizerType, enableSemanticSearch, + exceptionLevel, tags, deployed state nesting under mcp.gateways. + +Creates: + 1. A gateway with NONE authorizer + semantic search enabled + 2. An MCP Server target pointing to a public test endpoint +""" +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import time +from common import ( + REGION, get_control_client, ensure_role, save_resource, + tag_resource, wait_for_gateway, wait_for_gateway_target, +) + + +def main(): + role_arn = ensure_role() + client = get_control_client() + ts = int(time.time()) + gateway_name = f"bugbashGw{ts}" + + # ------------------------------------------------------------------ + # 1. Create gateway + # ------------------------------------------------------------------ + print(f"Creating gateway: {gateway_name}") + resp = client.create_gateway( + name=gateway_name, + description="Bugbash gateway for import testing", + roleArn=role_arn, + protocolType="MCP", + protocolConfiguration={ + "mcp": { + "supportedVersions": ["2025-03-26"], + "searchType": "SEMANTIC", + }, + }, + authorizerType="NONE", + exceptionLevel="DEBUG", + ) + + gateway_id = resp["gatewayId"] + gateway_arn = resp["gatewayArn"] + print(f"Gateway ID: {gateway_id}") + print(f"Gateway ARN: {gateway_arn}") + + tag_resource(client, gateway_arn, { + "env": "bugbash", + "team": "agentcore-cli", + }) + + save_resource("gateway", gateway_arn, gateway_id) + + if not wait_for_gateway(client, gateway_id): + print("Gateway creation failed. Aborting target creation.") + sys.exit(1) + + # ------------------------------------------------------------------ + # 2. Create MCP Server target + # ------------------------------------------------------------------ + target_name = "mcpTarget" + print(f"\nCreating MCP Server target: {target_name}") + target_resp = client.create_gateway_target( + gatewayIdentifier=gateway_id, + name=target_name, + targetConfiguration={ + "mcp": { + "mcpServer": { + "endpoint": "https://mcp.exa.ai/mcp", + }, + }, + }, + ) + + target_id = target_resp["targetId"] + print(f"Target ID: {target_id}") + + save_resource("gateway-target-mcp", gateway_arn, target_id) + wait_for_gateway_target(client, gateway_id, target_id) + + +if __name__ == "__main__": + main() diff --git a/e2e-tests/import-resources.test.ts b/e2e-tests/import-resources.test.ts index f3122b7c..fd90ebb3 100644 --- a/e2e-tests/import-resources.test.ts +++ b/e2e-tests/import-resources.test.ts @@ -30,7 +30,7 @@ const hasPython = })(); const canRun = prereqs.npm && prereqs.git && prereqs.uv && hasAws && hasPython; -describe.sequential('e2e: import runtime/memory/evaluator', () => { +describe.sequential('e2e: import runtime/memory/evaluator/gateway', () => { const region = process.env.AWS_REGION ?? 'us-east-1'; const fixtureDir = join(__dirname, 'fixtures', 'import'); const appDir = join(fixtureDir, 'app'); @@ -40,6 +40,7 @@ describe.sequential('e2e: import runtime/memory/evaluator', () => { let runtimeArn: string; let memoryArn: string; let evaluatorArn: string; + let gatewayArn: string; let projectPath: string; let testDir: string; @@ -50,7 +51,7 @@ describe.sequential('e2e: import runtime/memory/evaluator', () => { // Each script creates a resource and saves its ARN/ID to bugbash-resources.json. // Scripts run sequentially because save_resource() does a read-modify-write // on a shared bugbash-resources.json file — parallel runs would race. - for (const script of ['setup_runtime_basic.py', 'setup_memory_full.py', 'setup_evaluator.py']) { + for (const script of ['setup_runtime_basic.py', 'setup_memory_full.py', 'setup_evaluator.py', 'setup_gateway.py']) { const result = await spawnAndCollect('python3', [script], fixtureDir, { AWS_REGION: region, DEFAULT_EVALUATOR_MODEL, @@ -68,6 +69,7 @@ describe.sequential('e2e: import runtime/memory/evaluator', () => { runtimeArn = resources['runtime-basic']!.arn; memoryArn = resources['memory-full']!.arn; evaluatorArn = resources['evaluator-llm']!.arn; + gatewayArn = resources.gateway!.arn; // 3. Create a destination CLI project (no agent — we'll import one) testDir = join(tmpdir(), `agentcore-e2e-import-${randomUUID()}`); @@ -161,6 +163,22 @@ describe.sequential('e2e: import runtime/memory/evaluator', () => { 600_000 ); + it.skipIf(!canRun)( + 'imports a gateway by ARN', + async () => { + const result = await run(['import', 'gateway', '--arn', gatewayArn]); + + if (result.exitCode !== 0) { + console.log('Import gateway stdout:', result.stdout); + console.log('Import gateway stderr:', result.stderr); + } + + expect(result.exitCode, `Import gateway failed: ${result.stderr}`).toBe(0); + expect(stripAnsi(result.stdout).toLowerCase()).toContain('imported successfully'); + }, + 600_000 + ); + // ── Verification tests ──────────────────────────────────────────── it.skipIf(!canRun)( @@ -185,6 +203,69 @@ describe.sequential('e2e: import runtime/memory/evaluator', () => { const evaluator = json.resources.find(r => r.resourceType === 'evaluator'); expect(evaluator, 'Imported evaluator should appear in status').toBeDefined(); + + const gateway = json.resources.find(r => r.resourceType === 'gateway'); + expect(gateway, 'Imported gateway should appear in status').toBeDefined(); + }, + 120_000 + ); + + it.skipIf(!canRun)( + 'agentcore.json has correct gateway fields', + async () => { + const configPath = join(projectPath, 'agentcore', 'agentcore.json'); + const config = JSON.parse(await readFile(configPath, 'utf-8')) as { + agentCoreGateways: { + name: string; + resourceName?: string; + description?: string; + authorizerType: string; + enableSemanticSearch: boolean; + exceptionLevel: string; + tags?: Record; + targets: { name: string; targetType: string; endpoint?: string }[]; + }[]; + }; + + expect(config.agentCoreGateways.length, 'Should have one gateway').toBe(1); + const gw = config.agentCoreGateways[0]!; + + expect(gw.name, 'Gateway name should be set').toBeTruthy(); + expect(gw.resourceName, 'resourceName should preserve AWS name').toBeTruthy(); + expect(gw.description).toBe('Bugbash gateway for import testing'); + expect(gw.authorizerType).toBe('NONE'); + expect(gw.enableSemanticSearch).toBe(true); + expect(gw.exceptionLevel).toBe('DEBUG'); + expect(gw.tags).toEqual({ env: 'bugbash', team: 'agentcore-cli' }); + + expect(gw.targets.length, 'Should have one target').toBe(1); + expect(gw.targets[0]!.name).toBe('mcpTarget'); + expect(gw.targets[0]!.targetType).toBe('mcpServer'); + expect(gw.targets[0]!.endpoint).toBe('https://mcp.exa.ai/mcp'); + }, + 120_000 + ); + + it.skipIf(!canRun)( + 'deployed-state.json has gateway entry', + async () => { + const statePath = join(projectPath, 'agentcore', '.cli', 'deployed-state.json'); + const state = JSON.parse(await readFile(statePath, 'utf-8')) as Record; + + // Gateway state is stored under targets..resources.mcp.gateways + const targets = state.targets as Record } } }>; + const targetEntries = Object.values(targets); + expect(targetEntries.length).toBeGreaterThan(0); + + const firstTarget = targetEntries[0]!; + const gateways = firstTarget.resources?.mcp?.gateways; + expect(gateways, 'deployed-state should have mcp.gateways entry').toBeDefined(); + + const gatewayEntries = Object.values(gateways!); + expect(gatewayEntries.length, 'Should have one gateway in deployed state').toBe(1); + + const gwState = gatewayEntries[0] as { gatewayId?: string; gatewayArn?: string }; + expect(gwState.gatewayId, 'Gateway ID should be recorded').toBeTruthy(); }, 120_000 ); From 631e235de2b8f5a01100057b1c27231451f405c5 Mon Sep 17 00:00:00 2001 From: Jesse Turner Date: Tue, 14 Apr 2026 19:58:40 +0000 Subject: [PATCH 05/15] chore: gitignore bugbash-resources.json and .omc/ --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitignore b/.gitignore index 3b00d92a..eb1399ef 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,9 @@ ProtocolTesting/ # Auto-cloned CDK constructs (from scripts/bundle.mjs) .cdk-constructs-clone/ + +# E2E test artifacts +e2e-tests/fixtures/import/bugbash-resources.json + +# oh-my-claudecode +.omc/ From 9a0a57145b769e1f30fb02a3a714c501d10175ed Mon Sep 17 00:00:00 2001 From: Jesse Turner Date: Wed, 15 Apr 2026 14:04:27 +0000 Subject: [PATCH 06/15] feat: preserve gateway executionRoleArn during import Extract roleArn from the AWS GetGateway response and map it to executionRoleArn in agentcore.json. On deploy, CDK uses iam.Role.fromRoleArn() instead of creating a new role, keeping the original permissions intact. Constraint: imported roles use mutable: false so CDK cannot modify them Rejected: always create new role | breaks permissions on re-import Confidence: high Scope-risk: narrow --- e2e-tests/import-resources.test.ts | 4 ++++ src/cli/aws/agentcore-control.ts | 2 ++ src/cli/commands/import/import-gateway.ts | 1 + src/schema/schemas/mcp.ts | 2 ++ 4 files changed, 9 insertions(+) diff --git a/e2e-tests/import-resources.test.ts b/e2e-tests/import-resources.test.ts index fd90ebb3..7b8064fa 100644 --- a/e2e-tests/import-resources.test.ts +++ b/e2e-tests/import-resources.test.ts @@ -222,6 +222,7 @@ describe.sequential('e2e: import runtime/memory/evaluator/gateway', () => { authorizerType: string; enableSemanticSearch: boolean; exceptionLevel: string; + executionRoleArn?: string; tags?: Record; targets: { name: string; targetType: string; endpoint?: string }[]; }[]; @@ -238,6 +239,9 @@ describe.sequential('e2e: import runtime/memory/evaluator/gateway', () => { expect(gw.exceptionLevel).toBe('DEBUG'); expect(gw.tags).toEqual({ env: 'bugbash', team: 'agentcore-cli' }); + expect(gw.executionRoleArn, 'executionRoleArn should be preserved from AWS').toBeTruthy(); + expect(gw.executionRoleArn).toContain('bugbash-agentcore-role'); + expect(gw.targets.length, 'Should have one target').toBe(1); expect(gw.targets[0]!.name).toBe('mcpTarget'); expect(gw.targets[0]!.targetType).toBe('mcpServer'); diff --git a/src/cli/aws/agentcore-control.ts b/src/cli/aws/agentcore-control.ts index c79a0228..162c2b3a 100644 --- a/src/cli/aws/agentcore-control.ts +++ b/src/cli/aws/agentcore-control.ts @@ -806,6 +806,7 @@ export interface GatewayDetail { status: string; description?: string; authorizerType: string; + roleArn?: string; authorizerConfiguration?: { customJwtAuthorizer?: { discoveryUrl: string; @@ -936,6 +937,7 @@ export async function getGatewayDetail(options: { region: string; gatewayId: str status: response.status ?? 'UNKNOWN', description: response.description, authorizerType: response.authorizerType ?? 'NONE', + roleArn: response.roleArn, authorizerConfiguration, protocolConfiguration, exceptionLevel: response.exceptionLevel, diff --git a/src/cli/commands/import/import-gateway.ts b/src/cli/commands/import/import-gateway.ts index c6c2860c..fbd993d2 100644 --- a/src/cli/commands/import/import-gateway.ts +++ b/src/cli/commands/import/import-gateway.ts @@ -281,6 +281,7 @@ function toGatewaySpec(gateway: GatewayDetail, targets: AgentCoreGatewayTarget[] enableSemanticSearch, exceptionLevel, ...(policyEngineConfiguration && { policyEngineConfiguration }), + ...(gateway.roleArn && { executionRoleArn: gateway.roleArn }), ...(gateway.tags && Object.keys(gateway.tags).length > 0 && { tags: gateway.tags }), }; } diff --git a/src/schema/schemas/mcp.ts b/src/schema/schemas/mcp.ts index 153d514b..6b857a15 100644 --- a/src/schema/schemas/mcp.ts +++ b/src/schema/schemas/mcp.ts @@ -594,6 +594,8 @@ export const AgentCoreGatewaySchema = z exceptionLevel: GatewayExceptionLevelSchema.default('NONE'), /** Policy engine configuration for Cedar-based authorization of tool calls. */ policyEngineConfiguration: GatewayPolicyEngineConfigurationSchema.optional(), + /** ARN of an existing IAM execution role. When set, CDK uses this role instead of creating a new one. */ + executionRoleArn: z.string().optional(), tags: TagsSchema.optional(), }) .strict() From b09a1338ace3d268c58a392581bf0b5da0559189 Mon Sep 17 00:00:00 2001 From: Jesse Turner Date: Thu, 16 Apr 2026 14:04:40 +0000 Subject: [PATCH 07/15] refactor: export internal gateway import functions for unit testing Add @internal exports for toGatewayTargetSpec, resolveOutboundAuth, toGatewaySpec, and buildCredentialArnMap to enable direct unit testing of the pure mapping functions in import-gateway.ts. Confidence: high Scope-risk: narrow --- src/cli/commands/import/import-gateway.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/cli/commands/import/import-gateway.ts b/src/cli/commands/import/import-gateway.ts index fbd993d2..d4ad7812 100644 --- a/src/cli/commands/import/import-gateway.ts +++ b/src/cli/commands/import/import-gateway.ts @@ -218,8 +218,13 @@ function resolveOutboundAuth( /** * Map GetGateway + GetGatewayTarget[] responses to CLI AgentCoreGateway schema. + * @internal */ -function toGatewaySpec(gateway: GatewayDetail, targets: AgentCoreGatewayTarget[], localName: string): AgentCoreGateway { +export function toGatewaySpec( + gateway: GatewayDetail, + targets: AgentCoreGatewayTarget[], + localName: string +): AgentCoreGateway { const authorizerType = (gateway.authorizerType ?? 'NONE') as GatewayAuthorizerType; let authorizerConfiguration: AuthorizerConfig | undefined; @@ -293,8 +298,9 @@ function toGatewaySpec(gateway: GatewayDetail, targets: AgentCoreGatewayTarget[] /** * Build a map from credential provider ARN → credential name * using the project's deployed state. + * @internal */ -async function buildCredentialArnMap( +export async function buildCredentialArnMap( configIO: { readDeployedState: () => Promise }, targetName: string ): Promise> { @@ -601,6 +607,13 @@ export async function handleImportGateway(options: ImportResourceOptions): Promi } } +/** @internal — exported for unit testing */ +export { + toGatewayTargetSpec as _toGatewayTargetSpec, + toGatewayTargetSpec, + resolveOutboundAuth as _resolveOutboundAuth, +}; + // ============================================================================ // Command Registration // ============================================================================ From 6b56208261d0220c477aa69fe4979541e1c339ed Mon Sep 17 00:00:00 2001 From: Jesse Turner Date: Thu, 16 Apr 2026 14:05:18 +0000 Subject: [PATCH 08/15] test: add unit tests for mcpServer target mapping and credential resolution Bugbash coverage for toGatewayTargetSpec and resolveOutboundAuth: - mcpServer with no auth, OAuth, and API_KEY credentials - Credential resolution warnings when ARNs not in project - Targets with no MCP configuration - OAuth scopes pass-through and empty scopes omission 8 tests, all passing. Confidence: high Scope-risk: narrow --- .../import/__tests__/import-gateway.test.ts | 264 ++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 src/cli/commands/import/__tests__/import-gateway.test.ts diff --git a/src/cli/commands/import/__tests__/import-gateway.test.ts b/src/cli/commands/import/__tests__/import-gateway.test.ts new file mode 100644 index 00000000..bdde88bb --- /dev/null +++ b/src/cli/commands/import/__tests__/import-gateway.test.ts @@ -0,0 +1,264 @@ +/** + * Tests for toGatewayTargetSpec() — mcpServer target mapping and credential resolution. + */ +import type { GatewayTargetDetail } from '../../../aws/agentcore-control'; +import { + _resolveOutboundAuth as resolveOutboundAuth, + _toGatewayTargetSpec as toGatewayTargetSpec, +} from '../import-gateway'; +import { describe, expect, it, vi } from 'vitest'; + +// ============================================================================ +// Helpers +// ============================================================================ + +function makeDetail(overrides: Partial = {}): GatewayTargetDetail { + return { + targetId: 'tgt-001', + name: 'my-mcp-target', + status: 'READY', + ...overrides, + }; +} + +// ============================================================================ +// toGatewayTargetSpec — mcpServer mapping +// ============================================================================ + +describe('toGatewayTargetSpec — mcpServer targets', () => { + it('maps mcpServer with no auth', () => { + const detail = makeDetail({ + targetConfiguration: { + mcp: { + mcpServer: { endpoint: 'https://example.com/mcp' }, + }, + }, + }); + const credentials = new Map(); + const onProgress = vi.fn(); + + const result = toGatewayTargetSpec(detail, credentials, onProgress); + + expect(result).toEqual({ + name: 'my-mcp-target', + targetType: 'mcpServer', + endpoint: 'https://example.com/mcp', + }); + expect(result).not.toHaveProperty('outboundAuth'); + expect(onProgress).not.toHaveBeenCalled(); + }); + + it('maps mcpServer with OAuth credential (resolved)', () => { + const providerArn = 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/my-oauth'; + const detail = makeDetail({ + targetConfiguration: { + mcp: { + mcpServer: { endpoint: 'https://example.com/mcp' }, + }, + }, + credentialProviderConfigurations: [ + { + credentialProviderType: 'OAUTH', + credentialProvider: { + oauthCredentialProvider: { + providerArn, + scopes: ['read', 'write'], + }, + }, + }, + ], + }); + const credentials = new Map([[providerArn, 'my-oauth-cred']]); + const onProgress = vi.fn(); + + const result = toGatewayTargetSpec(detail, credentials, onProgress); + + expect(result).toEqual({ + name: 'my-mcp-target', + targetType: 'mcpServer', + endpoint: 'https://example.com/mcp', + outboundAuth: { + type: 'OAUTH', + credentialName: 'my-oauth-cred', + scopes: ['read', 'write'], + }, + }); + }); + + it('maps mcpServer with API_KEY credential (resolved)', () => { + const providerArn = 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/my-apikey'; + const detail = makeDetail({ + targetConfiguration: { + mcp: { + mcpServer: { endpoint: 'https://example.com/mcp' }, + }, + }, + credentialProviderConfigurations: [ + { + credentialProviderType: 'API_KEY', + credentialProvider: { + apiKeyCredentialProvider: { + providerArn, + }, + }, + }, + ], + }); + const credentials = new Map([[providerArn, 'my-api-key-cred']]); + const onProgress = vi.fn(); + + const result = toGatewayTargetSpec(detail, credentials, onProgress); + + expect(result).toEqual({ + name: 'my-mcp-target', + targetType: 'mcpServer', + endpoint: 'https://example.com/mcp', + outboundAuth: { + type: 'API_KEY', + credentialName: 'my-api-key-cred', + }, + }); + }); + + it('returns undefined outboundAuth and warns when OAuth credential not in project', () => { + const providerArn = 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/missing-oauth'; + const detail = makeDetail({ + targetConfiguration: { + mcp: { + mcpServer: { endpoint: 'https://example.com/mcp' }, + }, + }, + credentialProviderConfigurations: [ + { + credentialProviderType: 'OAUTH', + credentialProvider: { + oauthCredentialProvider: { + providerArn, + scopes: ['read'], + }, + }, + }, + ], + }); + const credentials = new Map(); // empty — not resolved + const onProgress = vi.fn(); + + const result = toGatewayTargetSpec(detail, credentials, onProgress); + + expect(result).toEqual({ + name: 'my-mcp-target', + targetType: 'mcpServer', + endpoint: 'https://example.com/mcp', + }); + expect(result).not.toHaveProperty('outboundAuth'); + expect(onProgress).toHaveBeenCalledWith(expect.stringContaining('OAuth credential')); + expect(onProgress).toHaveBeenCalledWith(expect.stringContaining('Configure credentials manually after import')); + }); + + it('returns undefined outboundAuth and warns when API_KEY credential not in project', () => { + const providerArn = 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/missing-apikey'; + const detail = makeDetail({ + targetConfiguration: { + mcp: { + mcpServer: { endpoint: 'https://example.com/mcp' }, + }, + }, + credentialProviderConfigurations: [ + { + credentialProviderType: 'API_KEY', + credentialProvider: { + apiKeyCredentialProvider: { + providerArn, + }, + }, + }, + ], + }); + const credentials = new Map(); // empty — not resolved + const onProgress = vi.fn(); + + const result = toGatewayTargetSpec(detail, credentials, onProgress); + + expect(result).toEqual({ + name: 'my-mcp-target', + targetType: 'mcpServer', + endpoint: 'https://example.com/mcp', + }); + expect(result).not.toHaveProperty('outboundAuth'); + expect(onProgress).toHaveBeenCalledWith(expect.stringContaining('API Key credential')); + expect(onProgress).toHaveBeenCalledWith(expect.stringContaining('Configure credentials manually after import')); + }); + + it('returns undefined and warns when target has no MCP configuration', () => { + const detail = makeDetail({ + targetConfiguration: undefined, + }); + const credentials = new Map(); + const onProgress = vi.fn(); + + const result = toGatewayTargetSpec(detail, credentials, onProgress); + + expect(result).toBeUndefined(); + expect(onProgress).toHaveBeenCalledWith(expect.stringContaining('no MCP configuration')); + }); +}); + +// ============================================================================ +// resolveOutboundAuth — OAuth scopes handling +// ============================================================================ + +describe('resolveOutboundAuth — scopes handling', () => { + it('includes scopes when OAuth provider has non-empty scopes array', () => { + const providerArn = 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/oauth-scoped'; + const detail = makeDetail({ + credentialProviderConfigurations: [ + { + credentialProviderType: 'OAUTH', + credentialProvider: { + oauthCredentialProvider: { + providerArn, + scopes: ['openid', 'profile', 'email'], + }, + }, + }, + ], + }); + const credentials = new Map([[providerArn, 'scoped-cred']]); + const onProgress = vi.fn(); + + const result = resolveOutboundAuth(detail, credentials, onProgress); + + expect(result).toEqual({ + type: 'OAUTH', + credentialName: 'scoped-cred', + scopes: ['openid', 'profile', 'email'], + }); + }); + + it('omits scopes when OAuth provider has empty scopes array', () => { + const providerArn = 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/oauth-no-scope'; + const detail = makeDetail({ + credentialProviderConfigurations: [ + { + credentialProviderType: 'OAUTH', + credentialProvider: { + oauthCredentialProvider: { + providerArn, + scopes: [], + }, + }, + }, + ], + }); + const credentials = new Map([[providerArn, 'no-scope-cred']]); + const onProgress = vi.fn(); + + const result = resolveOutboundAuth(detail, credentials, onProgress); + + expect(result).toEqual({ + type: 'OAUTH', + credentialName: 'no-scope-cred', + }); + expect(result).not.toHaveProperty('scopes'); + }); +}); From 53599c5fd304cd9f37c26ca5cceef748daf69590 Mon Sep 17 00:00:00 2001 From: Jesse Turner Date: Thu, 16 Apr 2026 14:05:52 +0000 Subject: [PATCH 09/15] test: add unit tests for apiGateway, openApiSchema, smithyModel, lambda target mapping Bugbash coverage for toGatewayTargetSpec non-mcpServer target types: - apiGateway: restApiId, stage, toolFilters, toolOverrides mapping - openApiSchema: S3 URI mapping, missing URI warning - smithyModel: S3 URI mapping, missing URI warning - lambda: S3 tool schema to lambdaFunctionArn mapping, missing ARN, inline-only schema warning, progress messages - Unrecognized target type warning 13 tests, all passing. Confidence: high Scope-risk: narrow --- .../__tests__/import-gateway-targets.test.ts | 355 ++++++++++++++++++ 1 file changed, 355 insertions(+) create mode 100644 src/cli/commands/import/__tests__/import-gateway-targets.test.ts diff --git a/src/cli/commands/import/__tests__/import-gateway-targets.test.ts b/src/cli/commands/import/__tests__/import-gateway-targets.test.ts new file mode 100644 index 00000000..3624ce54 --- /dev/null +++ b/src/cli/commands/import/__tests__/import-gateway-targets.test.ts @@ -0,0 +1,355 @@ +/** + * Import Gateway Target Mapping Unit Tests + * + * Covers toGatewayTargetSpec for non-mcpServer target types: + * - apiGateway: toolFilters, toolOverrides, outboundAuth + * - openApiSchema: S3 URI mapping, missing URI warning + * - smithyModel: S3 URI mapping, missing URI warning + * - lambda: lambdaFunctionArn mapping, missing ARN, inline-only schema + * - Unrecognized target type + */ +import type { GatewayTargetDetail } from '../../../aws/agentcore-control'; +import { toGatewayTargetSpec } from '../import-gateway'; +import { describe, expect, it, vi } from 'vitest'; + +/** Helper to build a minimal GatewayTargetDetail with only the fields under test. */ +function baseDetail(overrides: Partial = {}): GatewayTargetDetail { + return { + targetId: 'tgt-001', + name: 'test_target', + status: 'READY', + ...overrides, + }; +} + +// ============================================================================ +// apiGateway target +// ============================================================================ + +describe('toGatewayTargetSpec — apiGateway', () => { + it('maps restApiId, stage, and toolFilters correctly', () => { + const detail = baseDetail({ + targetConfiguration: { + mcp: { + apiGateway: { + restApiId: 'abc123', + stage: 'prod', + apiGatewayToolConfiguration: { + toolFilters: [ + { filterPath: '/pets', methods: ['GET', 'POST'] }, + { filterPath: '/users', methods: ['GET'] }, + ], + }, + }, + }, + }, + }); + + const onProgress = vi.fn(); + const result = toGatewayTargetSpec(detail, new Map(), onProgress); + + expect(result).toBeDefined(); + expect(result!.name).toBe('test_target'); + expect(result!.targetType).toBe('apiGateway'); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const apigw = (result as any).apiGateway; + expect(apigw.restApiId).toBe('abc123'); + expect(apigw.stage).toBe('prod'); + expect(apigw.apiGatewayToolConfiguration.toolFilters).toEqual([ + { filterPath: '/pets', methods: ['GET', 'POST'] }, + { filterPath: '/users', methods: ['GET'] }, + ]); + }); + + it('maps toolOverrides when present', () => { + const detail = baseDetail({ + targetConfiguration: { + mcp: { + apiGateway: { + restApiId: 'abc123', + stage: 'prod', + apiGatewayToolConfiguration: { + toolFilters: [], + toolOverrides: [ + { name: 'listPets', path: '/pets', method: 'GET', description: 'List all pets' }, + { name: 'createPet', path: '/pets', method: 'POST' }, + ], + }, + }, + }, + }, + }); + + const onProgress = vi.fn(); + const result = toGatewayTargetSpec(detail, new Map(), onProgress); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const apigw = (result as any).apiGateway; + expect(apigw.apiGatewayToolConfiguration.toolOverrides).toEqual([ + { name: 'listPets', path: '/pets', method: 'GET', description: 'List all pets' }, + { name: 'createPet', path: '/pets', method: 'POST' }, + ]); + }); + + it('omits toolOverrides when not present', () => { + const detail = baseDetail({ + targetConfiguration: { + mcp: { + apiGateway: { + restApiId: 'abc123', + stage: 'prod', + apiGatewayToolConfiguration: { + toolFilters: [{ filterPath: '/pets', methods: ['GET'] }], + }, + }, + }, + }, + }); + + const onProgress = vi.fn(); + const result = toGatewayTargetSpec(detail, new Map(), onProgress); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const apigw = (result as any).apiGateway; + expect(apigw.apiGatewayToolConfiguration.toolOverrides).toBeUndefined(); + }); + + it('returns outboundAuth when OAuth credential is configured', () => { + const providerArn = 'arn:aws:bedrock-agentcore:us-west-2:123456789012:credential-provider/cred-001'; + const detail = baseDetail({ + targetConfiguration: { + mcp: { + apiGateway: { + restApiId: 'abc123', + stage: 'prod', + apiGatewayToolConfiguration: { toolFilters: [] }, + }, + }, + }, + credentialProviderConfigurations: [ + { + credentialProviderType: 'OAUTH', + credentialProvider: { + oauthCredentialProvider: { + providerArn, + scopes: ['read', 'write'], + }, + }, + }, + ], + }); + + const credentials = new Map([[providerArn, 'my_oauth_cred']]); + const onProgress = vi.fn(); + const result = toGatewayTargetSpec(detail, credentials, onProgress); + + expect(result).toBeDefined(); + expect(result!.outboundAuth).toEqual({ + type: 'OAUTH', + credentialName: 'my_oauth_cred', + scopes: ['read', 'write'], + }); + }); +}); + +// ============================================================================ +// openApiSchema target +// ============================================================================ + +describe('toGatewayTargetSpec — openApiSchema', () => { + it('maps S3 URI and bucketOwnerAccountId correctly', () => { + const detail = baseDetail({ + targetConfiguration: { + mcp: { + openApiSchema: { + s3: { uri: 's3://my-bucket/schema.yaml', bucketOwnerAccountId: '123456789012' }, + }, + }, + }, + }); + + const onProgress = vi.fn(); + const result = toGatewayTargetSpec(detail, new Map(), onProgress); + + expect(result).toBeDefined(); + expect(result!.name).toBe('test_target'); + expect(result!.targetType).toBe('openApiSchema'); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const schemaSource = (result as any).schemaSource; + expect(schemaSource.s3.uri).toBe('s3://my-bucket/schema.yaml'); + expect(schemaSource.s3.bucketOwnerAccountId).toBe('123456789012'); + }); + + it('returns undefined and emits warning when S3 URI is missing', () => { + const detail = baseDetail({ + targetConfiguration: { + mcp: { + openApiSchema: { inlinePayload: '{"openapi":"3.0.0"}' }, + }, + }, + }); + + const onProgress = vi.fn(); + const result = toGatewayTargetSpec(detail, new Map(), onProgress); + + expect(result).toBeUndefined(); + expect(onProgress).toHaveBeenCalledWith(expect.stringContaining('(openApiSchema) has no S3 URI, skipping')); + }); +}); + +// ============================================================================ +// smithyModel target +// ============================================================================ + +describe('toGatewayTargetSpec — smithyModel', () => { + it('maps S3 URI correctly', () => { + const detail = baseDetail({ + targetConfiguration: { + mcp: { + smithyModel: { + s3: { uri: 's3://models-bucket/model.json' }, + }, + }, + }, + }); + + const onProgress = vi.fn(); + const result = toGatewayTargetSpec(detail, new Map(), onProgress); + + expect(result).toBeDefined(); + expect(result!.name).toBe('test_target'); + expect(result!.targetType).toBe('smithyModel'); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const schemaSource = (result as any).schemaSource; + expect(schemaSource.s3.uri).toBe('s3://models-bucket/model.json'); + expect(schemaSource.s3.bucketOwnerAccountId).toBeUndefined(); + }); + + it('returns undefined and emits warning when S3 URI is missing', () => { + const detail = baseDetail({ + targetConfiguration: { + mcp: { + smithyModel: { inlinePayload: '{"smithy":"1.0"}' }, + }, + }, + }); + + const onProgress = vi.fn(); + const result = toGatewayTargetSpec(detail, new Map(), onProgress); + + expect(result).toBeUndefined(); + expect(onProgress).toHaveBeenCalledWith(expect.stringContaining('(smithyModel) has no S3 URI, skipping')); + }); +}); + +// ============================================================================ +// lambda target +// ============================================================================ + +describe('toGatewayTargetSpec — lambda', () => { + it('maps lambda with S3 tool schema to lambdaFunctionArn type', () => { + const detail = baseDetail({ + targetConfiguration: { + mcp: { + lambda: { + lambdaArn: 'arn:aws:lambda:us-west-2:123456789012:function:my-func', + toolSchema: { s3: { uri: 's3://schemas/tools.json' } }, + }, + }, + }, + }); + + const onProgress = vi.fn(); + const result = toGatewayTargetSpec(detail, new Map(), onProgress); + + expect(result).toBeDefined(); + expect(result!.name).toBe('test_target'); + expect(result!.targetType).toBe('lambdaFunctionArn'); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const lambdaConfig = (result as any).lambdaFunctionArn; + expect(lambdaConfig.lambdaArn).toBe('arn:aws:lambda:us-west-2:123456789012:function:my-func'); + expect(lambdaConfig.toolSchemaFile).toBe('s3://schemas/tools.json'); + }); + + it('returns undefined and emits warning when lambdaArn is missing', () => { + const detail = baseDetail({ + targetConfiguration: { + mcp: { + lambda: { + lambdaArn: '', + toolSchema: { s3: { uri: 's3://schemas/tools.json' } }, + }, + }, + }, + }); + + const onProgress = vi.fn(); + const result = toGatewayTargetSpec(detail, new Map(), onProgress); + + expect(result).toBeUndefined(); + expect(onProgress).toHaveBeenCalledWith(expect.stringContaining('(lambda) has no ARN, skipping')); + }); + + it('returns undefined and emits warning when lambda has inline schema only', () => { + const detail = baseDetail({ + targetConfiguration: { + mcp: { + lambda: { + lambdaArn: 'arn:aws:lambda:us-west-2:123456789012:function:my-func', + toolSchema: { inlinePayload: '{"tools":[]}' }, + }, + }, + }, + }); + + const onProgress = vi.fn(); + const result = toGatewayTargetSpec(detail, new Map(), onProgress); + + expect(result).toBeUndefined(); + expect(onProgress).toHaveBeenCalledWith( + expect.stringContaining('has inline tool schema, which cannot be imported') + ); + }); + + it('emits progress message for successful lambda mapping', () => { + const detail = baseDetail({ + targetConfiguration: { + mcp: { + lambda: { + lambdaArn: 'arn:aws:lambda:us-west-2:123456789012:function:my-func', + toolSchema: { s3: { uri: 's3://schemas/tools.json' } }, + }, + }, + }, + }); + + const onProgress = vi.fn(); + toGatewayTargetSpec(detail, new Map(), onProgress); + + expect(onProgress).toHaveBeenCalledWith(expect.stringContaining('Mapping compute-backed Lambda target')); + }); +}); + +// ============================================================================ +// Unrecognized target type +// ============================================================================ + +describe('toGatewayTargetSpec — unrecognized target type', () => { + it('returns undefined and emits warning when no known mcp type matches', () => { + const detail = baseDetail({ + targetConfiguration: { + mcp: {}, + }, + }); + + const onProgress = vi.fn(); + const result = toGatewayTargetSpec(detail, new Map(), onProgress); + + expect(result).toBeUndefined(); + expect(onProgress).toHaveBeenCalledWith(expect.stringContaining('unrecognized target type')); + }); +}); From 0f36496fa0592bb89bd3c419958853a698859304 Mon Sep 17 00:00:00 2001 From: Jesse Turner Date: Thu, 16 Apr 2026 14:06:18 +0000 Subject: [PATCH 10/15] test: add unit tests for toGatewaySpec gateway-level field mapping Bugbash coverage for toGatewaySpec AWS-to-CLI schema mapping: - Authorizer types: NONE, AWS_IAM, CUSTOM_JWT with all JWT fields - CUSTOM_JWT customClaims with full claim structure - Semantic search: SEMANTIC/KEYWORD/missing protocolConfiguration - Exception level: DEBUG/undefined/other values - Policy engine: ARN name extraction, mode preservation - Optional fields: resourceName, description, tags, executionRoleArn - Edge cases: empty tags object omitted, empty JWT arrays omitted 23 tests, all passing. Confidence: high Scope-risk: narrow --- .../__tests__/import-gateway-spec.test.ts | 311 ++++++++++++++++++ 1 file changed, 311 insertions(+) create mode 100644 src/cli/commands/import/__tests__/import-gateway-spec.test.ts diff --git a/src/cli/commands/import/__tests__/import-gateway-spec.test.ts b/src/cli/commands/import/__tests__/import-gateway-spec.test.ts new file mode 100644 index 00000000..7c2963ed --- /dev/null +++ b/src/cli/commands/import/__tests__/import-gateway-spec.test.ts @@ -0,0 +1,311 @@ +/** + * toGatewaySpec Unit Tests + * + * Covers gateway-level field mapping from AWS GetGateway response + * to CLI AgentCoreGateway schema: + * - Authorizer type mapping (NONE, AWS_IAM, CUSTOM_JWT with claims, empty arrays) + * - Semantic search configuration + * - Exception level mapping + * - Policy engine configuration + * - Description, tags, resourceName, executionRoleArn + */ +import type { AgentCoreGatewayTarget } from '../../../../schema'; +import type { GatewayDetail } from '../../../aws/agentcore-control'; +import { toGatewaySpec } from '../import-gateway'; +import { describe, expect, it } from 'vitest'; + +/** Helper to build a minimal GatewayDetail for tests. */ +function makeGateway(overrides: Partial = {}): GatewayDetail { + return { + gatewayId: 'gw-test-001', + gatewayArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:gateway/gw-test-001', + name: 'TestGateway', + status: 'READY', + authorizerType: 'NONE', + ...overrides, + }; +} + +const emptyTargets: AgentCoreGatewayTarget[] = []; + +// ============================================================================ +// Authorizer Type Mapping +// ============================================================================ + +describe('toGatewaySpec – authorizer type mapping', () => { + it('NONE authorizerType: no authorizerConfiguration in output', () => { + const gw = makeGateway({ authorizerType: 'NONE' }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result.authorizerType).toBe('NONE'); + expect(result).not.toHaveProperty('authorizerConfiguration'); + }); + + it('AWS_IAM authorizerType: maps to AWS_IAM, no authorizerConfiguration', () => { + const gw = makeGateway({ authorizerType: 'AWS_IAM' }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result.authorizerType).toBe('AWS_IAM'); + expect(result).not.toHaveProperty('authorizerConfiguration'); + }); + + it('CUSTOM_JWT basic: maps discoveryUrl, allowedAudience, allowedClients, allowedScopes', () => { + const gw = makeGateway({ + authorizerType: 'CUSTOM_JWT', + authorizerConfiguration: { + customJwtAuthorizer: { + discoveryUrl: 'https://example.com/.well-known/openid-configuration', + allowedAudience: ['aud1', 'aud2'], + allowedClients: ['client1'], + allowedScopes: ['read', 'write'], + }, + }, + }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result.authorizerType).toBe('CUSTOM_JWT'); + expect(result.authorizerConfiguration).toBeDefined(); + const jwt = result.authorizerConfiguration!.customJwtAuthorizer!; + expect(jwt.discoveryUrl).toBe('https://example.com/.well-known/openid-configuration'); + expect(jwt.allowedAudience).toEqual(['aud1', 'aud2']); + expect(jwt.allowedClients).toEqual(['client1']); + expect(jwt.allowedScopes).toEqual(['read', 'write']); + }); + + it('CUSTOM_JWT with customClaims: maps full claim structure', () => { + const gw = makeGateway({ + authorizerType: 'CUSTOM_JWT', + authorizerConfiguration: { + customJwtAuthorizer: { + discoveryUrl: 'https://example.com/.well-known/openid-configuration', + allowedAudience: ['aud1'], + customClaims: [ + { + inboundTokenClaimName: 'department', + inboundTokenClaimValueType: 'STRING', + authorizingClaimMatchValue: { + claimMatchOperator: 'EQUALS', + claimMatchValue: { matchValueString: 'engineering' }, + }, + }, + { + inboundTokenClaimName: 'roles', + inboundTokenClaimValueType: 'STRING_ARRAY', + authorizingClaimMatchValue: { + claimMatchOperator: 'CONTAINS_ANY', + claimMatchValue: { matchValueStringList: ['admin', 'editor'] }, + }, + }, + ], + }, + }, + }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + const claims = result.authorizerConfiguration!.customJwtAuthorizer!.customClaims!; + expect(claims).toHaveLength(2); + + expect(claims[0]!.inboundTokenClaimName).toBe('department'); + expect(claims[0]!.inboundTokenClaimValueType).toBe('STRING'); + expect(claims[0]!.authorizingClaimMatchValue.claimMatchOperator).toBe('EQUALS'); + expect(claims[0]!.authorizingClaimMatchValue.claimMatchValue.matchValueString).toBe('engineering'); + expect(claims[0]!.authorizingClaimMatchValue.claimMatchValue).not.toHaveProperty('matchValueStringList'); + + expect(claims[1]!.inboundTokenClaimName).toBe('roles'); + expect(claims[1]!.inboundTokenClaimValueType).toBe('STRING_ARRAY'); + expect(claims[1]!.authorizingClaimMatchValue.claimMatchOperator).toBe('CONTAINS_ANY'); + expect(claims[1]!.authorizingClaimMatchValue.claimMatchValue.matchValueStringList).toEqual(['admin', 'editor']); + expect(claims[1]!.authorizingClaimMatchValue.claimMatchValue).not.toHaveProperty('matchValueString'); + }); + + it('CUSTOM_JWT with empty arrays: allowedAudience=[], allowedClients=[] are omitted', () => { + const gw = makeGateway({ + authorizerType: 'CUSTOM_JWT', + authorizerConfiguration: { + customJwtAuthorizer: { + discoveryUrl: 'https://example.com/.well-known/openid-configuration', + allowedAudience: [], + allowedClients: [], + allowedScopes: ['openid'], + }, + }, + }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + const jwt = result.authorizerConfiguration!.customJwtAuthorizer!; + expect(jwt).not.toHaveProperty('allowedAudience'); + expect(jwt).not.toHaveProperty('allowedClients'); + expect(jwt.allowedScopes).toEqual(['openid']); + }); + + it('missing authorizerType: defaults to NONE', () => { + const gw = makeGateway(); + // Simulate undefined authorizerType by deleting after construction + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (gw as any).authorizerType; + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result.authorizerType).toBe('NONE'); + expect(result).not.toHaveProperty('authorizerConfiguration'); + }); +}); + +// ============================================================================ +// Semantic Search +// ============================================================================ + +describe('toGatewaySpec – semantic search', () => { + it('searchType=SEMANTIC: enableSemanticSearch is true', () => { + const gw = makeGateway({ + protocolConfiguration: { mcp: { searchType: 'SEMANTIC' } }, + }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result.enableSemanticSearch).toBe(true); + }); + + it('searchType=KEYWORD: enableSemanticSearch is false', () => { + const gw = makeGateway({ + protocolConfiguration: { mcp: { searchType: 'KEYWORD' } }, + }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result.enableSemanticSearch).toBe(false); + }); + + it('protocolConfiguration missing: enableSemanticSearch is false', () => { + const gw = makeGateway(); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result.enableSemanticSearch).toBe(false); + }); +}); + +// ============================================================================ +// Exception Level +// ============================================================================ + +describe('toGatewaySpec – exception level', () => { + it('exceptionLevel=DEBUG: maps to DEBUG', () => { + const gw = makeGateway({ exceptionLevel: 'DEBUG' }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result.exceptionLevel).toBe('DEBUG'); + }); + + it('exceptionLevel undefined: maps to NONE', () => { + const gw = makeGateway({ exceptionLevel: undefined }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result.exceptionLevel).toBe('NONE'); + }); + + it('exceptionLevel other value: maps to NONE', () => { + const gw = makeGateway({ exceptionLevel: 'VERBOSE' }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result.exceptionLevel).toBe('NONE'); + }); +}); + +// ============================================================================ +// Policy Engine +// ============================================================================ + +describe('toGatewaySpec – policy engine', () => { + it('policyEngineConfiguration present: extracts name from ARN last segment, preserves mode', () => { + const gw = makeGateway({ + policyEngineConfiguration: { + arn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:policy-engine/my_policy_engine', + mode: 'ENFORCE', + }, + }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result.policyEngineConfiguration).toBeDefined(); + expect(result.policyEngineConfiguration!.policyEngineName).toBe('my_policy_engine'); + expect(result.policyEngineConfiguration!.mode).toBe('ENFORCE'); + }); + + it('policyEngineConfiguration absent: field omitted', () => { + const gw = makeGateway(); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result).not.toHaveProperty('policyEngineConfiguration'); + }); +}); + +// ============================================================================ +// Other Fields +// ============================================================================ + +describe('toGatewaySpec – other fields', () => { + it('resourceName is always set to gateway.name', () => { + const gw = makeGateway({ name: 'AwsGatewayName' }); + const result = toGatewaySpec(gw, emptyTargets, 'local_name'); + + expect(result.resourceName).toBe('AwsGatewayName'); + expect(result.name).toBe('local_name'); + }); + + it('description present: included in output', () => { + const gw = makeGateway({ description: 'My gateway description' }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result.description).toBe('My gateway description'); + }); + + it('description undefined: omitted from output', () => { + const gw = makeGateway({ description: undefined }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result).not.toHaveProperty('description'); + }); + + it('tags present with entries: included in output', () => { + const gw = makeGateway({ tags: { env: 'prod', team: 'platform' } }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result.tags).toEqual({ env: 'prod', team: 'platform' }); + }); + + it('tags empty object: omitted from output', () => { + const gw = makeGateway({ tags: {} }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result).not.toHaveProperty('tags'); + }); + + it('tags undefined: omitted from output', () => { + const gw = makeGateway({ tags: undefined }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result).not.toHaveProperty('tags'); + }); + + it('executionRoleArn: mapped from gateway.roleArn', () => { + const gw = makeGateway({ roleArn: 'arn:aws:iam::123456789012:role/GatewayRole' }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result.executionRoleArn).toBe('arn:aws:iam::123456789012:role/GatewayRole'); + }); + + it('roleArn undefined: executionRoleArn omitted from output', () => { + const gw = makeGateway({ roleArn: undefined }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result).not.toHaveProperty('executionRoleArn'); + }); + + it('targets are passed through to output', () => { + const targets: AgentCoreGatewayTarget[] = [ + { name: 'target1', targetType: 'mcpServer', endpoint: 'https://mcp.example.com' }, + ]; + const gw = makeGateway(); + const result = toGatewaySpec(gw, targets, 'my_gw'); + + expect(result.targets).toBe(targets); + expect(result.targets).toHaveLength(1); + expect(result.targets[0]!.name).toBe('target1'); + }); +}); From d95937c5c6e3707b21767b0dfae4878ca9a7a5c3 Mon Sep 17 00:00:00 2001 From: Jesse Turner Date: Thu, 16 Apr 2026 14:07:31 +0000 Subject: [PATCH 11/15] test: add unit tests for handleImportGateway full flow validation Bugbash coverage for the main gateway import flow: - Happy path: successful import with --arn, config written, result verified - Rollback: pipeline failure restores original config, noResources error - Duplicate detection: name collision, resource ID already tracked - Name validation: invalid name regex, --name override preserves resourceName - Auto-select: single gateway auto-selected, multiple gateways error, no gateways error - Target mapping: skipped targets warning, non-READY gateway continues 12 tests, all passing. Confidence: high Scope-risk: narrow --- .../__tests__/import-gateway-flow.test.ts | 374 ++++++++++++++++++ 1 file changed, 374 insertions(+) create mode 100644 src/cli/commands/import/__tests__/import-gateway-flow.test.ts diff --git a/src/cli/commands/import/__tests__/import-gateway-flow.test.ts b/src/cli/commands/import/__tests__/import-gateway-flow.test.ts new file mode 100644 index 00000000..b7ecd1ed --- /dev/null +++ b/src/cli/commands/import/__tests__/import-gateway-flow.test.ts @@ -0,0 +1,374 @@ +/** + * Tests for handleImportGateway — the main gateway import flow. + * + * Covers: + * - Happy path: successful import with --arn + * - Rollback on pipeline failure and noResources + * - Duplicate detection (name + deployed state ID) + * - Name validation (invalid name, --name override) + * - Auto-select / multi-gateway / no gateways + * - Skipped targets warning + * - Non-READY gateway warning + */ +import { handleImportGateway } from '../import-gateway'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// ── Hoisted mock fns ──────────────────────────────────────────────────────── + +const { + mockFindConfigRoot, + mockConfigIOInstance, + MockConfigIOClass, + mockValidateAwsCredentials, + mockDetectAccount, + mockGetGatewayDetail, + mockListAllGateways, + mockListAllGatewayTargets, + mockGetGatewayTargetDetail, + mockExecuteCdkImportPipeline, +} = vi.hoisted(() => { + const inst = { + readProjectSpec: vi.fn(), + writeProjectSpec: vi.fn(), + readAWSDeploymentTargets: vi.fn(), + readDeployedState: vi.fn(), + writeDeployedState: vi.fn(), + }; + return { + mockFindConfigRoot: vi.fn(), + mockConfigIOInstance: inst, + MockConfigIOClass: vi.fn(function (this: any) { + Object.assign(this, inst); + return this; + }), + mockValidateAwsCredentials: vi.fn(), + mockDetectAccount: vi.fn(), + mockGetGatewayDetail: vi.fn(), + mockListAllGateways: vi.fn(), + mockListAllGatewayTargets: vi.fn(), + mockGetGatewayTargetDetail: vi.fn(), + mockExecuteCdkImportPipeline: vi.fn(), + }; +}); + +// ── Module mocks ───────────────────────────────────────────────────────────── + +vi.mock('../../../../lib', () => ({ + APP_DIR: 'app', + ConfigIO: MockConfigIOClass, + findConfigRoot: (...args: unknown[]) => mockFindConfigRoot(...args), +})); + +vi.mock('../../../aws/account', () => ({ + validateAwsCredentials: (...args: unknown[]) => mockValidateAwsCredentials(...args), + detectAccount: (...args: unknown[]) => mockDetectAccount(...args), +})); + +vi.mock('../../../aws/agentcore-control', () => ({ + getGatewayDetail: (...args: unknown[]) => mockGetGatewayDetail(...args), + listAllGateways: (...args: unknown[]) => mockListAllGateways(...args), + listAllGatewayTargets: (...args: unknown[]) => mockListAllGatewayTargets(...args), + getGatewayTargetDetail: (...args: unknown[]) => mockGetGatewayTargetDetail(...args), +})); + +vi.mock('../../../logging', () => ({ + ExecLogger: class MockExecLogger { + startStep = vi.fn(); + endStep = vi.fn(); + log = vi.fn(); + finalize = vi.fn(); + getRelativeLogPath = vi.fn().mockReturnValue('agentcore/.cli/logs/import/import-gateway-mock.log'); + logFilePath = 'agentcore/.cli/logs/import/import-gateway-mock.log'; + }, +})); + +vi.mock('../import-pipeline', () => ({ + executeCdkImportPipeline: (...args: unknown[]) => mockExecuteCdkImportPipeline(...args), +})); + +// ── Test Fixtures ──────────────────────────────────────────────────────────── + +const ACCOUNT = '123456789012'; +const REGION = 'us-east-1'; +const GATEWAY_ID = 'gw-abc123'; +const GATEWAY_ARN = `arn:aws:bedrock-agentcore:${REGION}:${ACCOUNT}:gateway/${GATEWAY_ID}`; +const GATEWAY_NAME = 'MyGateway'; + +function makeProjectSpec(gateways: any[] = []) { + return { + name: 'TestProject', + version: 1, + runtimes: [], + memories: [], + credentials: [], + agentCoreGateways: gateways, + }; +} + +function makeGatewayDetail(overrides?: Record) { + return { + gatewayId: GATEWAY_ID, + gatewayArn: GATEWAY_ARN, + name: GATEWAY_NAME, + status: 'READY', + authorizerType: 'NONE', + ...overrides, + }; +} + +function makeTargetSummary(id: string, name: string) { + return { targetId: id, name, status: 'READY' }; +} + +function makeTargetDetail(id: string, name: string, endpoint: string) { + return { + targetId: id, + name, + status: 'READY', + targetConfiguration: { + mcp: { + mcpServer: { endpoint }, + }, + }, + }; +} + +// ── Common setup ───────────────────────────────────────────────────────────── + +function setupCommonMocks() { + mockFindConfigRoot.mockReturnValue('/tmp/project/agentcore'); + + mockConfigIOInstance.readAWSDeploymentTargets.mockResolvedValue([ + { name: 'default', account: ACCOUNT, region: REGION }, + ]); + + mockValidateAwsCredentials.mockResolvedValue(undefined); + mockDetectAccount.mockResolvedValue(ACCOUNT); + + mockConfigIOInstance.readProjectSpec.mockResolvedValue(makeProjectSpec()); + mockConfigIOInstance.writeProjectSpec.mockResolvedValue(undefined); + mockConfigIOInstance.readDeployedState.mockResolvedValue({ targets: {} }); + + mockGetGatewayDetail.mockResolvedValue(makeGatewayDetail()); + mockListAllGateways.mockResolvedValue([ + { gatewayId: GATEWAY_ID, name: GATEWAY_NAME, status: 'READY', authorizerType: 'NONE' }, + ]); + mockListAllGatewayTargets.mockResolvedValue([makeTargetSummary('tgt-1', 'target1')]); + mockGetGatewayTargetDetail.mockResolvedValue(makeTargetDetail('tgt-1', 'target1', 'https://example.com/mcp')); + + mockExecuteCdkImportPipeline.mockResolvedValue({ success: true }); +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe('handleImportGateway', () => { + beforeEach(() => { + vi.clearAllMocks(); + setupCommonMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ── Happy path ────────────────────────────────────────────────────────── + + describe('Happy path', () => { + it('successfully imports a gateway with --arn', async () => { + const result = await handleImportGateway({ arn: GATEWAY_ARN }); + + expect(result.success).toBe(true); + expect(result.resourceId).toBe(GATEWAY_ID); + expect(result.resourceType).toBe('gateway'); + expect(result.resourceName).toBe(GATEWAY_NAME); + + // writeProjectSpec called once with gateway added + expect(mockConfigIOInstance.writeProjectSpec).toHaveBeenCalledTimes(1); + const writtenSpec = mockConfigIOInstance.writeProjectSpec.mock.calls[0]![0]; + expect(writtenSpec.agentCoreGateways).toHaveLength(1); + expect(writtenSpec.agentCoreGateways[0].name).toBe(GATEWAY_NAME); + expect(writtenSpec.agentCoreGateways[0].targets).toHaveLength(1); + }); + }); + + // ── Rollback ──────────────────────────────────────────────────────────── + + describe('Rollback', () => { + it('rolls back config on pipeline failure', async () => { + mockExecuteCdkImportPipeline.mockResolvedValue({ success: false, error: 'Phase 2 failed' }); + + const result = await handleImportGateway({ arn: GATEWAY_ARN }); + + expect(result.success).toBe(false); + expect(result.error).toBe('Phase 2 failed'); + + // First call = write merged config, second call = rollback + expect(mockConfigIOInstance.writeProjectSpec).toHaveBeenCalledTimes(2); + const rollbackSpec = mockConfigIOInstance.writeProjectSpec.mock.calls[1]![0]; + expect(rollbackSpec.agentCoreGateways).toEqual([]); + }); + + it('rolls back config on noResources (logical ID not found)', async () => { + mockExecuteCdkImportPipeline.mockResolvedValue({ success: true, noResources: true }); + + const result = await handleImportGateway({ arn: GATEWAY_ARN }); + + expect(result.success).toBe(false); + expect(result.error).toContain('Could not find logical ID'); + + // First call = write merged config, second call = rollback + expect(mockConfigIOInstance.writeProjectSpec).toHaveBeenCalledTimes(2); + const rollbackSpec = mockConfigIOInstance.writeProjectSpec.mock.calls[1]![0]; + expect(rollbackSpec.agentCoreGateways).toEqual([]); + }); + }); + + // ── Duplicate detection ───────────────────────────────────────────────── + + describe('Duplicate detection', () => { + it('rejects when gateway name already exists in project', async () => { + mockConfigIOInstance.readProjectSpec.mockResolvedValue(makeProjectSpec([{ name: GATEWAY_NAME, targets: [] }])); + + const result = await handleImportGateway({ arn: GATEWAY_ARN }); + + expect(result.success).toBe(false); + expect(result.error).toContain('already exists'); + expect(mockConfigIOInstance.writeProjectSpec).not.toHaveBeenCalled(); + }); + + it('rejects when gateway ID is already tracked in deployed state', async () => { + mockConfigIOInstance.readDeployedState.mockResolvedValue({ + targets: { + default: { + resources: { + mcp: { + gateways: { + ExistingGateway: { gatewayId: GATEWAY_ID }, + }, + }, + }, + }, + }, + }); + + const result = await handleImportGateway({ arn: GATEWAY_ARN }); + + expect(result.success).toBe(false); + expect(result.error).toContain('already imported'); + expect(mockConfigIOInstance.writeProjectSpec).not.toHaveBeenCalled(); + }); + }); + + // ── Name validation ───────────────────────────────────────────────────── + + describe('Name validation', () => { + it('rejects invalid name starting with a number', async () => { + mockGetGatewayDetail.mockResolvedValue(makeGatewayDetail({ name: '123gateway' })); + + const result = await handleImportGateway({ arn: GATEWAY_ARN }); + + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid name'); + expect(result.error).toContain('must start with a letter'); + expect(mockConfigIOInstance.writeProjectSpec).not.toHaveBeenCalled(); + }); + + it('uses --name override with original resourceName preserved', async () => { + const result = await handleImportGateway({ arn: GATEWAY_ARN, name: 'myCustomName' }); + + expect(result.success).toBe(true); + expect(result.resourceName).toBe('myCustomName'); + + const writtenSpec = mockConfigIOInstance.writeProjectSpec.mock.calls[0]![0]; + const addedGateway = writtenSpec.agentCoreGateways[0]; + expect(addedGateway.name).toBe('myCustomName'); + expect(addedGateway.resourceName).toBe(GATEWAY_NAME); + }); + }); + + // ── Auto-select / multi-gateway ───────────────────────────────────────── + + describe('Auto-select / multi-gateway', () => { + it('auto-selects when only 1 gateway exists and no --arn', async () => { + mockListAllGateways.mockResolvedValue([ + { gatewayId: GATEWAY_ID, name: GATEWAY_NAME, status: 'READY', authorizerType: 'NONE' }, + ]); + + const result = await handleImportGateway({}); + + expect(result.success).toBe(true); + expect(result.resourceId).toBe(GATEWAY_ID); + expect(mockGetGatewayDetail).toHaveBeenCalledWith({ region: REGION, gatewayId: GATEWAY_ID }); + }); + + it('fails when multiple gateways exist and no --arn', async () => { + mockListAllGateways.mockResolvedValue([ + { gatewayId: 'gw-1', name: 'Gateway1', status: 'READY', authorizerType: 'NONE' }, + { gatewayId: 'gw-2', name: 'Gateway2', status: 'READY', authorizerType: 'NONE' }, + ]); + + const result = await handleImportGateway({}); + + expect(result.success).toBe(false); + expect(result.error).toContain('Multiple gateways found'); + }); + + it('fails when no gateways exist and no --arn', async () => { + mockListAllGateways.mockResolvedValue([]); + + const result = await handleImportGateway({}); + + expect(result.success).toBe(false); + expect(result.error).toContain('No gateways found'); + }); + }); + + // ── Target mapping ────────────────────────────────────────────────────── + + describe('Target mapping', () => { + it('emits warning when some targets cannot be mapped', async () => { + // 2 target summaries, but one has no MCP config so it will be skipped + mockListAllGatewayTargets.mockResolvedValue([ + makeTargetSummary('tgt-1', 'goodTarget'), + makeTargetSummary('tgt-2', 'badTarget'), + ]); + + mockGetGatewayTargetDetail.mockImplementation((opts: { targetId: string }) => { + if (opts.targetId === 'tgt-1') { + return Promise.resolve(makeTargetDetail('tgt-1', 'goodTarget', 'https://example.com/mcp')); + } + // No MCP config => will be skipped + return Promise.resolve({ + targetId: 'tgt-2', + name: 'badTarget', + status: 'READY', + targetConfiguration: {}, + }); + }); + + const progressMessages: string[] = []; + const result = await handleImportGateway({ + arn: GATEWAY_ARN, + onProgress: (msg: string) => progressMessages.push(msg), + }); + + expect(result.success).toBe(true); + + // Verify warning about unmapped targets + expect(progressMessages.some(m => m.includes('1 target(s) could not be mapped'))).toBe(true); + }); + + it('emits warning for non-READY gateway but continues', async () => { + mockGetGatewayDetail.mockResolvedValue(makeGatewayDetail({ status: 'CREATING' })); + + const progressMessages: string[] = []; + const result = await handleImportGateway({ + arn: GATEWAY_ARN, + onProgress: (msg: string) => progressMessages.push(msg), + }); + + expect(result.success).toBe(true); + expect(progressMessages.some(m => m.includes('CREATING') && m.includes('not READY'))).toBe(true); + }); + }); +}); From 435fa22dd76fa09ee9e2cd890fe8986a98f5536f Mon Sep 17 00:00:00 2001 From: Jesse Turner Date: Thu, 16 Apr 2026 14:09:30 +0000 Subject: [PATCH 12/15] test: add unit tests for buildCredentialArnMap and CFN template matching Bugbash coverage for credential resolution and CFN resource matching: - buildCredentialArnMap: reads ARN-to-name map from deployed state, handles multiple credentials, empty/missing state, thrown errors - findLogicalIdByProperty: gateway by Name property, resourceName fallback, target by Name, Fn::Join/Fn::Sub intrinsic function patterns, regex boundary check prevents false substring matches - findLogicalIdsByType: single gateway fallback, single target fallback, multiple targets prevent fallback 14 tests, all passing. Confidence: high Scope-risk: narrow --- .../__tests__/import-gateway-cfn.test.ts | 279 ++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100644 src/cli/commands/import/__tests__/import-gateway-cfn.test.ts diff --git a/src/cli/commands/import/__tests__/import-gateway-cfn.test.ts b/src/cli/commands/import/__tests__/import-gateway-cfn.test.ts new file mode 100644 index 00000000..a402cc2e --- /dev/null +++ b/src/cli/commands/import/__tests__/import-gateway-cfn.test.ts @@ -0,0 +1,279 @@ +/** + * Tests for buildCredentialArnMap and CFN template resource matching logic + * used in the gateway import flow. + */ +import { buildCredentialArnMap } from '../import-gateway'; +import type { CfnTemplate } from '../template-utils'; +import { findLogicalIdByProperty, findLogicalIdsByType } from '../template-utils'; +import { describe, expect, it } from 'vitest'; + +// ============================================================================ +// Part 1: buildCredentialArnMap +// ============================================================================ + +describe('buildCredentialArnMap', () => { + it('reads credentials from deployed state', async () => { + const configIO = { + readDeployedState: () => + Promise.resolve({ + targets: { + default: { + resources: { + credentials: { + myCred: { credentialProviderArn: 'arn:aws:bedrock:us-east-1:123456789012:credential/myCred' }, + }, + }, + }, + }, + }), + }; + + const map = await buildCredentialArnMap(configIO, 'default'); + expect(map.size).toBe(1); + expect(map.get('arn:aws:bedrock:us-east-1:123456789012:credential/myCred')).toBe('myCred'); + }); + + it('handles multiple credentials', async () => { + const configIO = { + readDeployedState: () => + Promise.resolve({ + targets: { + default: { + resources: { + credentials: { + oauthCred: { credentialProviderArn: 'arn:aws:bedrock:us-east-1:123456789012:credential/oauth' }, + apiKeyCred: { credentialProviderArn: 'arn:aws:bedrock:us-east-1:123456789012:credential/apikey' }, + }, + }, + }, + }, + }), + }; + + const map = await buildCredentialArnMap(configIO, 'default'); + expect(map.size).toBe(2); + expect(map.get('arn:aws:bedrock:us-east-1:123456789012:credential/oauth')).toBe('oauthCred'); + expect(map.get('arn:aws:bedrock:us-east-1:123456789012:credential/apikey')).toBe('apiKeyCred'); + }); + + it('returns empty map when readDeployedState throws', async () => { + const configIO = { + readDeployedState: () => Promise.reject(new Error('No deployed state file')), + }; + + const map = await buildCredentialArnMap(configIO, 'default'); + expect(map.size).toBe(0); + }); + + it('returns empty map when no credentials key exists', async () => { + const configIO = { + readDeployedState: () => + Promise.resolve({ + targets: { + default: { + resources: {}, + }, + }, + }), + }; + + const map = await buildCredentialArnMap(configIO, 'default'); + expect(map.size).toBe(0); + }); + + it('returns empty map when targets is empty', async () => { + const configIO = { + readDeployedState: () => Promise.resolve({ targets: {} }), + }; + + const map = await buildCredentialArnMap(configIO, 'default'); + expect(map.size).toBe(0); + }); +}); + +// ============================================================================ +// Part 2: CFN template matching (findLogicalIdByProperty, findLogicalIdsByType) +// ============================================================================ + +describe('findLogicalIdByProperty – gateway scenarios', () => { + it('finds gateway by Name = projectName-localName', () => { + const template: CfnTemplate = { + Resources: { + MyGatewayResource: { + Type: 'AWS::BedrockAgentCore::Gateway', + Properties: { + Name: 'myProject-myGateway', + }, + }, + }, + }; + + const result = findLogicalIdByProperty(template, 'AWS::BedrockAgentCore::Gateway', 'Name', 'myProject-myGateway'); + expect(result).toBe('MyGatewayResource'); + }); + + it('finds gateway by resourceName (localName only) as fallback', () => { + const template: CfnTemplate = { + Resources: { + GatewayA: { + Type: 'AWS::BedrockAgentCore::Gateway', + Properties: { + Name: 'someOtherName', + }, + }, + GatewayB: { + Type: 'AWS::BedrockAgentCore::Gateway', + Properties: { + Name: 'myGateway', + }, + }, + }, + }; + + const result = findLogicalIdByProperty(template, 'AWS::BedrockAgentCore::Gateway', 'Name', 'myGateway'); + expect(result).toBe('GatewayB'); + }); + + it('finds target by Name property', () => { + const template: CfnTemplate = { + Resources: { + TargetLogical1: { + Type: 'AWS::BedrockAgentCore::GatewayTarget', + Properties: { + Name: 'mcpTarget', + }, + }, + }, + }; + + const result = findLogicalIdByProperty(template, 'AWS::BedrockAgentCore::GatewayTarget', 'Name', 'mcpTarget'); + expect(result).toBe('TargetLogical1'); + }); +}); + +describe('findLogicalIdsByType – gateway fallback', () => { + it('returns the single gateway when name-based lookup fails', () => { + const template: CfnTemplate = { + Resources: { + OnlyGateway: { + Type: 'AWS::BedrockAgentCore::Gateway', + Properties: { + Name: 'completely-different-name', + }, + }, + SomeRole: { + Type: 'AWS::IAM::Role', + Properties: {}, + }, + }, + }; + + // Name-based lookup fails + const byName = findLogicalIdByProperty(template, 'AWS::BedrockAgentCore::Gateway', 'Name', 'myProject-myGateway'); + expect(byName).toBeUndefined(); + + // Type-based fallback returns the single gateway + const allGateways = findLogicalIdsByType(template, 'AWS::BedrockAgentCore::Gateway'); + expect(allGateways).toHaveLength(1); + expect(allGateways[0]).toBe('OnlyGateway'); + }); + + it('returns single target for fallback when one target and one in targetIdMap', () => { + const template: CfnTemplate = { + Resources: { + OnlyTarget: { + Type: 'AWS::BedrockAgentCore::GatewayTarget', + Properties: { + Name: 'different-name', + }, + }, + }, + }; + + const allTargets = findLogicalIdsByType(template, 'AWS::BedrockAgentCore::GatewayTarget'); + expect(allTargets).toHaveLength(1); + expect(allTargets[0]).toBe('OnlyTarget'); + }); + + it('returns multiple targets preventing fallback when more than one exists', () => { + const template: CfnTemplate = { + Resources: { + Target1: { + Type: 'AWS::BedrockAgentCore::GatewayTarget', + Properties: { Name: 'targetA' }, + }, + Target2: { + Type: 'AWS::BedrockAgentCore::GatewayTarget', + Properties: { Name: 'targetB' }, + }, + }, + }; + + const allTargets = findLogicalIdsByType(template, 'AWS::BedrockAgentCore::GatewayTarget'); + expect(allTargets).toHaveLength(2); + + // Name-based matching must succeed — fallback is not safe with multiple targets + // Simulate the import-gateway logic: only fallback if allTargets.length === 1 && targetIdMap.size === 1 + const targetIdMap = new Map([ + ['targetA', 'tid-1'], + ['targetB', 'tid-2'], + ]); + const shouldFallback = allTargets.length === 1 && targetIdMap.size === 1; + expect(shouldFallback).toBe(false); + }); +}); + +// ============================================================================ +// Part 3: Fn::Join / Fn::Sub patterns in findLogicalIdByProperty +// ============================================================================ + +describe('findLogicalIdByProperty – intrinsic function patterns', () => { + it('matches Fn::Join Name via regex second pass', () => { + const template: CfnTemplate = { + Resources: { + JoinGateway: { + Type: 'AWS::BedrockAgentCore::Gateway', + Properties: { + Name: { 'Fn::Join': ['-', ['prefix', 'myGateway']] }, + }, + }, + }, + }; + + const result = findLogicalIdByProperty(template, 'AWS::BedrockAgentCore::Gateway', 'Name', 'myGateway'); + expect(result).toBe('JoinGateway'); + }); + + it('avoids false substring matches with regex boundary check', () => { + const template: CfnTemplate = { + Resources: { + WrongGateway: { + Type: 'AWS::BedrockAgentCore::Gateway', + Properties: { + Name: { 'Fn::Join': ['-', ['prefix', 'myGateway_v2']] }, + }, + }, + }, + }; + + const result = findLogicalIdByProperty(template, 'AWS::BedrockAgentCore::Gateway', 'Name', 'myGateway'); + // "myGateway" should NOT match "myGateway_v2" due to boundary check + expect(result).toBeUndefined(); + }); + + it('matches Fn::Sub Name via regex second pass', () => { + const template: CfnTemplate = { + Resources: { + SubGateway: { + Type: 'AWS::BedrockAgentCore::Gateway', + Properties: { + Name: { 'Fn::Sub': '${AWS::StackName}-myGateway' }, + }, + }, + }, + }; + + const result = findLogicalIdByProperty(template, 'AWS::BedrockAgentCore::Gateway', 'Name', 'myGateway'); + expect(result).toBe('SubGateway'); + }); +}); From f518b159178ff934b56acdfd9598ef4f56af4b42 Mon Sep 17 00:00:00 2001 From: Jesse Turner Date: Tue, 21 Apr 2026 18:46:57 +0000 Subject: [PATCH 13/15] fix: exclude already-deployed logical IDs when building import resource list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a project already contains an imported resource (gateway + target, agent, memory, etc.), a subsequent import of a different resource that shares a Name with the deployed one caused buildResourcesToImport to resolve the OLD logical ID via findLogicalIdByProperty. The resulting CFN change set then failed with "Resources [...] passed in ResourceToImport are already in a stack and cannot be imported." Thread the deployed template into every buildResourcesToImport callback and skip logical IDs already present in the stack during both the name lookup and the single-candidate fallback. Constraint: GatewayTarget has no structural parent ref in Properties — only the physical-ID tuple (GatewayIdentifier, TargetId), so scoping the synth search by parent gateway is not available. Rejected: Parse Fn::Ref/Fn::GetAtt from GatewayIdentifier | brittle intrinsic traversal Rejected: Match by physical TargetId | synth template has no physical ID for new resources Rejected: Strip deployed resources from synth before lookup | breaks buildImportTemplate Confidence: high Scope-risk: narrow Directive: new callbacks into executeCdkImportPipeline must accept and honor the deployedTemplate arg Not-tested: multi-region / cross-stack-identifier collisions --- .../__tests__/import-gateway-flow.test.ts | 81 +++++++++++++++++++ src/cli/commands/import/actions.ts | 22 +++-- src/cli/commands/import/import-gateway.ts | 33 +++++--- src/cli/commands/import/import-pipeline.ts | 4 +- src/cli/commands/import/resource-import.ts | 14 +++- src/cli/commands/import/template-utils.ts | 7 +- 6 files changed, 137 insertions(+), 24 deletions(-) diff --git a/src/cli/commands/import/__tests__/import-gateway-flow.test.ts b/src/cli/commands/import/__tests__/import-gateway-flow.test.ts index b7ecd1ed..1e58d507 100644 --- a/src/cli/commands/import/__tests__/import-gateway-flow.test.ts +++ b/src/cli/commands/import/__tests__/import-gateway-flow.test.ts @@ -371,4 +371,85 @@ describe('handleImportGateway', () => { expect(progressMessages.some(m => m.includes('CREATING') && m.includes('not READY'))).toBe(true); }); }); + + // ── Re-import into existing stack (logical-ID collision) ──────────────── + + describe('buildResourcesToImport — excludes already-deployed logical IDs', () => { + it('skips deployed targets with the same Name when importing a new gateway', async () => { + await handleImportGateway({ arn: GATEWAY_ARN }); + + const pipelineInput = mockExecuteCdkImportPipeline.mock.calls[0]![0]; + const build = pipelineInput.buildResourcesToImport; + + // Deployed template already contains a gateway + target (from a prior import) + // whose target Name collides with the one being newly imported. + const deployedTemplate = { + Resources: { + OldGatewayLogicalId: { + Type: 'AWS::BedrockAgentCore::Gateway', + Properties: { Name: `TestProject-${GATEWAY_NAME}` }, + }, + OldTargetLogicalId: { + Type: 'AWS::BedrockAgentCore::GatewayTarget', + Properties: { Name: 'target1' }, + }, + }, + }; + + // Synth template contains both old and new resources (same names). + const synthTemplate = { + Resources: { + OldGatewayLogicalId: { + Type: 'AWS::BedrockAgentCore::Gateway', + Properties: { Name: `TestProject-${GATEWAY_NAME}` }, + }, + OldTargetLogicalId: { + Type: 'AWS::BedrockAgentCore::GatewayTarget', + Properties: { Name: 'target1' }, + }, + NewGatewayLogicalId: { + Type: 'AWS::BedrockAgentCore::Gateway', + Properties: { Name: `TestProject-${GATEWAY_NAME}` }, + }, + NewTargetLogicalId: { + Type: 'AWS::BedrockAgentCore::GatewayTarget', + Properties: { Name: 'target1' }, + }, + }, + }; + + const resources = build(synthTemplate, deployedTemplate); + + const logicalIds = resources.map((r: { logicalResourceId: string }) => r.logicalResourceId); + expect(logicalIds).toContain('NewGatewayLogicalId'); + expect(logicalIds).toContain('NewTargetLogicalId'); + expect(logicalIds).not.toContain('OldGatewayLogicalId'); + expect(logicalIds).not.toContain('OldTargetLogicalId'); + }); + + it('first-ever import (empty deployed template) still resolves resources', async () => { + await handleImportGateway({ arn: GATEWAY_ARN }); + + const pipelineInput = mockExecuteCdkImportPipeline.mock.calls[0]![0]; + const build = pipelineInput.buildResourcesToImport; + + const deployedTemplate = { Resources: {} }; + const synthTemplate = { + Resources: { + GatewayLogicalId: { + Type: 'AWS::BedrockAgentCore::Gateway', + Properties: { Name: `TestProject-${GATEWAY_NAME}` }, + }, + TargetLogicalId: { + Type: 'AWS::BedrockAgentCore::GatewayTarget', + Properties: { Name: 'target1' }, + }, + }, + }; + + const resources = build(synthTemplate, deployedTemplate); + const logicalIds = resources.map((r: { logicalResourceId: string }) => r.logicalResourceId); + expect(logicalIds).toEqual(['GatewayLogicalId', 'TargetLogicalId']); + }); + }); }); diff --git a/src/cli/commands/import/actions.ts b/src/cli/commands/import/actions.ts index c0bdc337..e6bccca2 100644 --- a/src/cli/commands/import/actions.ts +++ b/src/cli/commands/import/actions.ts @@ -542,11 +542,14 @@ export async function handleImport(options: ImportOptions): Promise { + buildResourcesToImport: (synthTemplate, deployedTemplate) => { const resourcesToImport: ResourceToImport[] = []; + const deployedIds = new Set(Object.keys(deployedTemplate.Resources)); for (const agent of agentsToImport) { - const runtimeLogicalIds = findLogicalIdsByType(synthTemplate, 'AWS::BedrockAgentCore::Runtime'); + const runtimeLogicalIds = findLogicalIdsByType(synthTemplate, 'AWS::BedrockAgentCore::Runtime').filter( + id => !deployedIds.has(id) + ); let logicalId: string | undefined; const expectedRuntimeName = `${projectName}_${agent.name}`; @@ -554,7 +557,8 @@ export async function handleImport(options: ImportOptions): Promise !deployedIds.has(id) + ); let logicalId: string | undefined; - logicalId = findLogicalIdByProperty(synthTemplate, 'AWS::BedrockAgentCore::Memory', 'Name', memory.name); + logicalId = findLogicalIdByProperty(synthTemplate, 'AWS::BedrockAgentCore::Memory', 'Name', memory.name, { + excludeLogicalIds: deployedIds, + }); // CDK prefixes memory names with the project name (e.g. "myproject_Agent_mem"), // so also try matching with the project name prefix. if (!logicalId) { const prefixedName = `${projectName}_${memory.name}`; - logicalId = findLogicalIdByProperty(synthTemplate, 'AWS::BedrockAgentCore::Memory', 'Name', prefixedName); + logicalId = findLogicalIdByProperty(synthTemplate, 'AWS::BedrockAgentCore::Memory', 'Name', prefixedName, { + excludeLogicalIds: deployedIds, + }); } if (!logicalId && memoryLogicalIds.length === 1) { diff --git a/src/cli/commands/import/import-gateway.ts b/src/cli/commands/import/import-gateway.ts index d4ad7812..8b0d0389 100644 --- a/src/cli/commands/import/import-gateway.ts +++ b/src/cli/commands/import/import-gateway.ts @@ -494,27 +494,35 @@ export async function handleImportGateway(options: ImportResourceOptions): Promi configIO: ctx.configIO, targetName, onProgress, - buildResourcesToImport: synthTemplate => { + buildResourcesToImport: (synthTemplate, deployedTemplate) => { const resourcesToImport: ResourceToImport[] = []; + // Exclude logical IDs already managed by the stack so we never re-import + // a previously-imported gateway or target with a colliding Name. + const deployedIds = new Set(Object.keys(deployedTemplate.Resources)); + // Find gateway logical ID const gatewayResourceName = `${ctx.projectName}-${localName}`; let gatewayLogicalId = findLogicalIdByProperty( synthTemplate, 'AWS::BedrockAgentCore::Gateway', 'Name', - gatewayResourceName + gatewayResourceName, + { excludeLogicalIds: deployedIds } ); gatewayLogicalId ??= findLogicalIdByProperty( synthTemplate, 'AWS::BedrockAgentCore::Gateway', 'Name', - localName + localName, + { excludeLogicalIds: deployedIds } ); if (!gatewayLogicalId) { - const allGatewayIds = findLogicalIdsByType(synthTemplate, 'AWS::BedrockAgentCore::Gateway'); - if (allGatewayIds.length === 1) { - gatewayLogicalId = allGatewayIds[0]; + const candidateGatewayIds = findLogicalIdsByType(synthTemplate, 'AWS::BedrockAgentCore::Gateway').filter( + id => !deployedIds.has(id) + ); + if (candidateGatewayIds.length === 1) { + gatewayLogicalId = candidateGatewayIds[0]; } } @@ -528,8 +536,10 @@ export async function handleImportGateway(options: ImportResourceOptions): Promi resourceIdentifier: { GatewayIdentifier: gatewayId }, }); - // Find target logical IDs - const allTargetLogicalIds = findLogicalIdsByType(synthTemplate, 'AWS::BedrockAgentCore::GatewayTarget'); + // Find target logical IDs (excluding those already in the deployed stack) + const candidateTargetIds = findLogicalIdsByType(synthTemplate, 'AWS::BedrockAgentCore::GatewayTarget').filter( + id => !deployedIds.has(id) + ); for (const [tName, tId] of targetIdMap) { // Try name-based matching first @@ -537,12 +547,13 @@ export async function handleImportGateway(options: ImportResourceOptions): Promi synthTemplate, 'AWS::BedrockAgentCore::GatewayTarget', 'Name', - tName + tName, + { excludeLogicalIds: deployedIds } ); // Fall back: if exactly one unmatched target logical ID remains, use it - if (!targetLogicalId && allTargetLogicalIds.length === 1 && targetIdMap.size === 1) { - targetLogicalId = allTargetLogicalIds[0]; + if (!targetLogicalId && candidateTargetIds.length === 1 && targetIdMap.size === 1) { + targetLogicalId = candidateTargetIds[0]; } if (targetLogicalId) { diff --git a/src/cli/commands/import/import-pipeline.ts b/src/cli/commands/import/import-pipeline.ts index 6f6444f9..75b9c70d 100644 --- a/src/cli/commands/import/import-pipeline.ts +++ b/src/cli/commands/import/import-pipeline.ts @@ -21,7 +21,7 @@ export interface CdkImportPipelineInput { onProgress: (message: string) => void; /** Caller builds the import resource list from the synthesized template. */ - buildResourcesToImport: (synthTemplate: CfnTemplate) => ResourceToImport[]; + buildResourcesToImport: (synthTemplate: CfnTemplate, deployedTemplate: CfnTemplate) => ResourceToImport[]; /** Entries to write into deployed-state.json after a successful import. */ deployedStateEntries: ImportedResource[]; @@ -114,7 +114,7 @@ export async function executeCdkImportPipeline(input: CdkImportPipelineInput): P } // 7. Build resources to import (caller-specific logic) - const resourcesToImport = buildResourcesToImport(synthTemplate); + const resourcesToImport = buildResourcesToImport(synthTemplate, deployedTemplate); if (resourcesToImport.length === 0) { return { success: true, noResources: true }; diff --git a/src/cli/commands/import/resource-import.ts b/src/cli/commands/import/resource-import.ts index 6418e367..6ce79b82 100644 --- a/src/cli/commands/import/resource-import.ts +++ b/src/cli/commands/import/resource-import.ts @@ -158,13 +158,16 @@ export async function executeResourceImport( configIO: ctx.configIO, targetName, onProgress, - buildResourcesToImport: synthTemplate => { + buildResourcesToImport: (synthTemplate, deployedTemplate) => { + const deployedIds = new Set(Object.keys(deployedTemplate.Resources)); + // Try matching by name property (plain name first, then prefixed) let logicalId = findLogicalIdByProperty( synthTemplate, descriptor.cfnResourceType, descriptor.cfnNameProperty, - localName + localName, + { excludeLogicalIds: deployedIds } ); if (!logicalId) { @@ -173,13 +176,16 @@ export async function executeResourceImport( synthTemplate, descriptor.cfnResourceType, descriptor.cfnNameProperty, - prefixedName + prefixedName, + { excludeLogicalIds: deployedIds } ); } // Fall back to single resource by type if (!logicalId) { - const allLogicalIds = findLogicalIdsByType(synthTemplate, descriptor.cfnResourceType); + const allLogicalIds = findLogicalIdsByType(synthTemplate, descriptor.cfnResourceType).filter( + id => !deployedIds.has(id) + ); if (allLogicalIds.length === 1) { logicalId = allLogicalIds[0]; } diff --git a/src/cli/commands/import/template-utils.ts b/src/cli/commands/import/template-utils.ts index 4e6a516a..e4d4f35e 100644 --- a/src/cli/commands/import/template-utils.ts +++ b/src/cli/commands/import/template-utils.ts @@ -192,10 +192,14 @@ export function findLogicalIdByProperty( template: CfnTemplate, resourceType: string, propertyName: string, - propertyValue: string + propertyValue: string, + options?: { excludeLogicalIds?: ReadonlySet } ): string | undefined { + const exclude = options?.excludeLogicalIds; + // First pass: exact string match (highest confidence) for (const [logicalId, resource] of Object.entries(template.Resources)) { + if (exclude?.has(logicalId)) continue; if (resource.Type === resourceType && resource.Properties) { if (resource.Properties[propertyName] === propertyValue) { return logicalId; @@ -211,6 +215,7 @@ export function findLogicalIdByProperty( const pattern = new RegExp(escaped + '(?=[^a-zA-Z0-9_]|$)'); for (const [logicalId, resource] of Object.entries(template.Resources)) { + if (exclude?.has(logicalId)) continue; if (resource.Type === resourceType && resource.Properties) { const propVal = resource.Properties[propertyName]; if (typeof propVal === 'object' && propVal !== null) { From d314a7279550ab2c749d1c74254cca7fba1b2686 Mon Sep 17 00:00:00 2001 From: Jesse Turner Date: Tue, 21 Apr 2026 20:11:21 +0000 Subject: [PATCH 14/15] fix(import): translate AccessDenied on GetGateway to a friendly not-found error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When importing a gateway by a well-formed but nonexistent ARN, the BedrockAgentCore control plane returns AccessDenied (not ResourceNotFound) for bedrock-agentcore:GetGateway. The CLI surfaced the raw SDK error — which is misleading when the caller has full Admin access and the gateway simply doesn't exist. Catch AccessDenied from getGatewayDetail and return a targeted failure with guidance: the gateway is likely nonexistent / the ARN is malformed / the caller lacks GetGateway. Point the user at list-gateways so they can confirm. Constraint: AWS returns AccessDenied instead of ResourceNotFound for nonexistent gateway IDs; we cannot distinguish the two server-side Rejected: Client-side ARN existence probe via ListGateways | extra latency on the happy path and still racy Confidence: high Scope-risk: narrow Directive: Do not swallow other error classes here — only AccessDenied is reinterpreted --- src/cli/commands/import/import-gateway.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/cli/commands/import/import-gateway.ts b/src/cli/commands/import/import-gateway.ts index 8b0d0389..b300ed0e 100644 --- a/src/cli/commands/import/import-gateway.ts +++ b/src/cli/commands/import/import-gateway.ts @@ -16,6 +16,7 @@ import { listAllGatewayTargets, listAllGateways, } from '../../aws/agentcore-control'; +import { isAccessDeniedError } from '../../errors'; import { ANSI, NAME_REGEX } from './constants'; import { executeCdkImportPipeline } from './import-pipeline'; import { @@ -388,7 +389,23 @@ export async function handleImportGateway(options: ImportResourceOptions): Promi } onProgress(`Fetching gateway details for ${gatewayId}...`); - const gatewayDetail = await getGatewayDetail({ region: target.region, gatewayId }); + let gatewayDetail; + try { + gatewayDetail = await getGatewayDetail({ region: target.region, gatewayId }); + } catch (err) { + if (isAccessDeniedError(err)) { + return failResult( + logger, + `Gateway "${gatewayId}" could not be found in region ${target.region}. ` + + `AWS returned AccessDenied, which for this service typically means the gateway does not exist, ` + + `the ARN is malformed, or your credentials lack bedrock-agentcore:GetGateway permission. ` + + `Verify the ARN with: aws bedrock-agentcore-control list-gateways --region ${target.region}`, + 'gateway', + options.name ?? '' + ); + } + throw err; + } if (gatewayDetail.status !== 'READY') { onProgress(`Warning: Gateway status is ${gatewayDetail.status}, not READY`); From 4932cd9c271a844f0fc93b523dfe1fc649c4439c Mon Sep 17 00:00:00 2001 From: Jesse Turner Date: Tue, 21 Apr 2026 20:11:45 +0000 Subject: [PATCH 15/15] fix(import): detect AWS_REGION / ARN region mismatch before import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously when a user ran import with AWS_REGION=us-west-2 against a us-east-1 ARN, and no deployment targets existed yet, the CLI silently synthesized a default target from the ARN's region and proceeded — so the user would unknowingly import from a different region than they intended, leaving agentcore.json pointed at the wrong region and causing cross-region CFN errors on later deploy. Short-circuit resolveImportTarget when AWS_REGION (or AWS_DEFAULT_REGION) is set and disagrees with the ARN's region, and ask the user to reconcile explicitly. Constraint: Must fail fast before any side effects (writing aws-targets.json, calling GetGateway) Rejected: Warn-and-continue | a silent cross-region import is exactly the failure mode we're preventing Confidence: high Scope-risk: narrow Directive: Only throw when both env region AND ARN region are present — do not require AWS_REGION to be set --- src/cli/commands/import/import-utils.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/cli/commands/import/import-utils.ts b/src/cli/commands/import/import-utils.ts index 337f0bc5..8aa9dd0a 100644 --- a/src/cli/commands/import/import-utils.ts +++ b/src/cli/commands/import/import-utils.ts @@ -139,6 +139,21 @@ export async function resolveImportTarget(options: ResolveTargetOptions): Promis ); } + // Detect region mismatch between caller's AWS_REGION and the ARN's region up front. + // Without this the ARN's region silently wins and the user can import cross-region + // by accident, leaving agentcore.json pointed at a region they didn't intend. + if (arn) { + const arnRegionMatch = /^arn:aws:bedrock-agentcore:([^:]+):/.exec(arn); + const arnRegion = arnRegionMatch?.[1]; + const envRegion = process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION; + if (arnRegion && envRegion && envRegion !== arnRegion) { + throw new Error( + `Region mismatch: AWS_REGION is "${envRegion}" but the ARN is in "${arnRegion}". ` + + `Either re-run with AWS_REGION=${arnRegion} or pass an ARN from ${envRegion}.` + ); + } + } + let targets = await configIO.readAWSDeploymentTargets(); if (targets.length === 0) {