Skip to content

Commit 2df1387

Browse files
feat: add gateway import command with executionRoleArn support (#855)
* feat: add gateway import command and unhide import from TUI Add `agentcore import gateway --arn <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 * 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. * 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. * 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 * chore: gitignore bugbash-resources.json and .omc/ * 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 * 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 * 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 * 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 * 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 * 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 * 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 * fix: exclude already-deployed logical IDs when building import resource list 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 * fix(import): translate AccessDenied on GetGateway to a friendly not-found error 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 * fix(import): detect AWS_REGION / ARN region mismatch before import 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 * fix(import): allow re-import of resource after remove without CDK pipeline After `agentcore remove gateway`, the gateway entry remains in deployed-state.json (correctly, since CFN still manages it) but is removed from agentcore.json. A subsequent `agentcore import gateway` would reject with "already imported" because the dedup check only looked at deployed-state. Now, when a resource exists in deployed-state but not in agentcore.json, the import skips the CDK pipeline and just re-adds the resource to agentcore.json. Applies to both the gateway-specific and generic import orchestrators. * style: fix prettier formatting for import-utils and ArnInputScreen * fix(import): address PR review blockers B4, B6, B7, H2, H5, H7, H8 - B4: Hard-fail when credential provider ARN is not found in deployed state instead of silently dropping outboundAuth - B7: Preserve outboundAuth on lambda→lambdaFunctionArn mapping and allow OAUTH/NONE auth types for lambdaFunctionArn targets - H2: Remove re-import fast path; run full CDK pipeline so out-of-band targets are properly imported. Treat noResources as success for re-imports since all resources are already in the CFN stack - H5: Replace hardcoded arn:aws: with partition-agnostic arn:[^:]+: in ARN validation and region extraction regexes - H7/H8: Add regex validation and max length for executionRoleArn, use GatewayNameSchema for resourceName, add refine ensuring both fields are set together or both omitted * fix(import): remove credential ARN from error messages to resolve CodeQL alert CodeQL flagged clear-text logging of credential provider ARNs. The target name provides sufficient context for the user to identify the issue.
1 parent 41c59ef commit 2df1387

24 files changed

Lines changed: 3095 additions & 33 deletions

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,11 @@ ProtocolTesting/
6969
.cdk-constructs-clone/
7070
.omc/
7171

72+
# E2E test artifacts
73+
e2e-tests/fixtures/import/bugbash-resources.json
74+
75+
# oh-my-claudecode
76+
.omc/
7277
# Browser tests
7378
browser-tests/.browser-test-env
7479
browser-tests/test-results/

e2e-tests/fixtures/import/cleanup_resources.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,19 @@ def main():
5151
rid = val.get("id")
5252
if not rid:
5353
continue
54+
# Gateway targets are deleted automatically when the parent gateway is deleted
55+
if "gateway" in key and "target" in key:
56+
print(f"Skipping {key} (deleted with parent gateway)")
57+
continue
5458
try:
5559
if "runtime" in key:
5660
client.delete_agent_runtime(agentRuntimeId=rid)
5761
elif "memory" in key:
5862
client.delete_memory(memoryId=rid)
5963
elif "evaluator" in key:
6064
client.delete_evaluator(evaluatorId=rid)
65+
elif "gateway" in key:
66+
client.delete_gateway(gatewayIdentifier=rid)
6167
print(f"Deleted {key}: {rid}")
6268
except Exception as e:
6369
print(f"Could not delete {key} ({rid}): {e}")

e2e-tests/fixtures/import/common.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,3 +256,48 @@ def tag_resource(client, arn, tags):
256256
"""Tag a resource via the control plane API."""
257257
print(f"Tagging resource with {tags}...")
258258
client.tag_resource(resourceArn=arn, tags=tags)
259+
260+
261+
def wait_for_gateway(client, gateway_id, timeout=120):
262+
"""Wait for a gateway to reach READY status."""
263+
print(f"Waiting for gateway {gateway_id} to become READY...")
264+
start = time.time()
265+
while time.time() - start < timeout:
266+
resp = client.get_gateway(gatewayIdentifier=gateway_id)
267+
status = resp.get("status", "UNKNOWN")
268+
if status == "READY":
269+
print(f"Gateway {gateway_id} is READY")
270+
return True
271+
if status in ("CREATE_FAILED", "FAILED"):
272+
reason = resp.get("statusReasons", [{}])
273+
print(f"ERROR: Gateway {gateway_id} status: {status}{reason}")
274+
return False
275+
elapsed = int(time.time() - start)
276+
print(f" Status: {status} ({elapsed}s elapsed)")
277+
time.sleep(5)
278+
print(f"WARNING: Gateway did not reach READY after {timeout}s")
279+
return False
280+
281+
282+
def wait_for_gateway_target(client, gateway_id, target_id, timeout=120):
283+
"""Wait for a gateway target to reach READY status."""
284+
print(f"Waiting for target {target_id} to become READY...")
285+
start = time.time()
286+
while time.time() - start < timeout:
287+
resp = client.get_gateway_target(
288+
gatewayIdentifier=gateway_id,
289+
targetId=target_id,
290+
)
291+
status = resp.get("status", "UNKNOWN")
292+
if status == "READY":
293+
print(f"Target {target_id} is READY")
294+
return True
295+
if status in ("CREATE_FAILED", "FAILED"):
296+
reason = resp.get("statusReasons", [{}])
297+
print(f"ERROR: Target {target_id} status: {status}{reason}")
298+
return False
299+
elapsed = int(time.time() - start)
300+
print(f" Status: {status} ({elapsed}s elapsed)")
301+
time.sleep(5)
302+
print(f"WARNING: Target did not reach READY after {timeout}s")
303+
return False
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
#!/usr/bin/env python3
2+
"""Setup: Gateway with MCP server target + tags.
3+
4+
Tests: gateway import, target mapping, authorizerType, enableSemanticSearch,
5+
exceptionLevel, tags, deployed state nesting under mcp.gateways.
6+
7+
Creates:
8+
1. A gateway with NONE authorizer + semantic search enabled
9+
2. An MCP Server target pointing to a public test endpoint
10+
"""
11+
import sys
12+
import os
13+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
14+
15+
import time
16+
from common import (
17+
REGION, get_control_client, ensure_role, save_resource,
18+
tag_resource, wait_for_gateway, wait_for_gateway_target,
19+
)
20+
21+
22+
def main():
23+
role_arn = ensure_role()
24+
client = get_control_client()
25+
ts = int(time.time())
26+
gateway_name = f"bugbashGw{ts}"
27+
28+
# ------------------------------------------------------------------
29+
# 1. Create gateway
30+
# ------------------------------------------------------------------
31+
print(f"Creating gateway: {gateway_name}")
32+
resp = client.create_gateway(
33+
name=gateway_name,
34+
description="Bugbash gateway for import testing",
35+
roleArn=role_arn,
36+
protocolType="MCP",
37+
protocolConfiguration={
38+
"mcp": {
39+
"supportedVersions": ["2025-03-26"],
40+
"searchType": "SEMANTIC",
41+
},
42+
},
43+
authorizerType="NONE",
44+
exceptionLevel="DEBUG",
45+
)
46+
47+
gateway_id = resp["gatewayId"]
48+
gateway_arn = resp["gatewayArn"]
49+
print(f"Gateway ID: {gateway_id}")
50+
print(f"Gateway ARN: {gateway_arn}")
51+
52+
tag_resource(client, gateway_arn, {
53+
"env": "bugbash",
54+
"team": "agentcore-cli",
55+
})
56+
57+
save_resource("gateway", gateway_arn, gateway_id)
58+
59+
if not wait_for_gateway(client, gateway_id):
60+
print("Gateway creation failed. Aborting target creation.")
61+
sys.exit(1)
62+
63+
# ------------------------------------------------------------------
64+
# 2. Create MCP Server target
65+
# ------------------------------------------------------------------
66+
target_name = "mcpTarget"
67+
print(f"\nCreating MCP Server target: {target_name}")
68+
target_resp = client.create_gateway_target(
69+
gatewayIdentifier=gateway_id,
70+
name=target_name,
71+
targetConfiguration={
72+
"mcp": {
73+
"mcpServer": {
74+
"endpoint": "https://mcp.exa.ai/mcp",
75+
},
76+
},
77+
},
78+
)
79+
80+
target_id = target_resp["targetId"]
81+
print(f"Target ID: {target_id}")
82+
83+
save_resource("gateway-target-mcp", gateway_arn, target_id)
84+
wait_for_gateway_target(client, gateway_id, target_id)
85+
86+
87+
if __name__ == "__main__":
88+
main()

e2e-tests/import-resources.test.ts

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ const hasPython =
3030
})();
3131
const canRun = prereqs.npm && prereqs.git && prereqs.uv && hasAws && hasPython;
3232

33-
describe.sequential('e2e: import runtime/memory/evaluator', () => {
33+
describe.sequential('e2e: import runtime/memory/evaluator/gateway', () => {
3434
const region = process.env.AWS_REGION ?? 'us-east-1';
3535
const fixtureDir = join(__dirname, 'fixtures', 'import');
3636
const appDir = join(fixtureDir, 'app');
@@ -40,6 +40,7 @@ describe.sequential('e2e: import runtime/memory/evaluator', () => {
4040
let runtimeArn: string;
4141
let memoryArn: string;
4242
let evaluatorArn: string;
43+
let gatewayArn: string;
4344
let projectPath: string;
4445
let testDir: string;
4546

@@ -50,7 +51,7 @@ describe.sequential('e2e: import runtime/memory/evaluator', () => {
5051
// Each script creates a resource and saves its ARN/ID to bugbash-resources.json.
5152
// Scripts run sequentially because save_resource() does a read-modify-write
5253
// on a shared bugbash-resources.json file — parallel runs would race.
53-
for (const script of ['setup_runtime_basic.py', 'setup_memory_full.py', 'setup_evaluator.py']) {
54+
for (const script of ['setup_runtime_basic.py', 'setup_memory_full.py', 'setup_evaluator.py', 'setup_gateway.py']) {
5455
const result = await spawnAndCollect('uv', ['run', '--with', 'boto3', 'python3', script], fixtureDir, {
5556
AWS_REGION: region,
5657
DEFAULT_EVALUATOR_MODEL,
@@ -68,6 +69,7 @@ describe.sequential('e2e: import runtime/memory/evaluator', () => {
6869
runtimeArn = resources['runtime-basic']!.arn;
6970
memoryArn = resources['memory-full']!.arn;
7071
evaluatorArn = resources['evaluator-llm']!.arn;
72+
gatewayArn = resources.gateway!.arn;
7173

7274
// 3. Create a destination CLI project (no agent — we'll import one)
7375
testDir = join(tmpdir(), `agentcore-e2e-import-${randomUUID()}`);
@@ -163,6 +165,22 @@ describe.sequential('e2e: import runtime/memory/evaluator', () => {
163165
600_000
164166
);
165167

168+
it.skipIf(!canRun)(
169+
'imports a gateway by ARN',
170+
async () => {
171+
const result = await run(['import', 'gateway', '--arn', gatewayArn]);
172+
173+
if (result.exitCode !== 0) {
174+
console.log('Import gateway stdout:', result.stdout);
175+
console.log('Import gateway stderr:', result.stderr);
176+
}
177+
178+
expect(result.exitCode, `Import gateway failed: ${result.stderr}`).toBe(0);
179+
expect(stripAnsi(result.stdout).toLowerCase()).toContain('imported successfully');
180+
},
181+
600_000
182+
);
183+
166184
// ── Verification tests ────────────────────────────────────────────
167185

168186
it.skipIf(!canRun)(
@@ -187,6 +205,73 @@ describe.sequential('e2e: import runtime/memory/evaluator', () => {
187205

188206
const evaluator = json.resources.find(r => r.resourceType === 'evaluator');
189207
expect(evaluator, 'Imported evaluator should appear in status').toBeDefined();
208+
209+
const gateway = json.resources.find(r => r.resourceType === 'gateway');
210+
expect(gateway, 'Imported gateway should appear in status').toBeDefined();
211+
},
212+
120_000
213+
);
214+
215+
it.skipIf(!canRun)(
216+
'agentcore.json has correct gateway fields',
217+
async () => {
218+
const configPath = join(projectPath, 'agentcore', 'agentcore.json');
219+
const config = JSON.parse(await readFile(configPath, 'utf-8')) as {
220+
agentCoreGateways: {
221+
name: string;
222+
resourceName?: string;
223+
description?: string;
224+
authorizerType: string;
225+
enableSemanticSearch: boolean;
226+
exceptionLevel: string;
227+
executionRoleArn?: string;
228+
tags?: Record<string, string>;
229+
targets: { name: string; targetType: string; endpoint?: string }[];
230+
}[];
231+
};
232+
233+
expect(config.agentCoreGateways.length, 'Should have one gateway').toBe(1);
234+
const gw = config.agentCoreGateways[0]!;
235+
236+
expect(gw.name, 'Gateway name should be set').toBeTruthy();
237+
expect(gw.resourceName, 'resourceName should preserve AWS name').toBeTruthy();
238+
expect(gw.description).toBe('Bugbash gateway for import testing');
239+
expect(gw.authorizerType).toBe('NONE');
240+
expect(gw.enableSemanticSearch).toBe(true);
241+
expect(gw.exceptionLevel).toBe('DEBUG');
242+
expect(gw.tags).toEqual({ env: 'bugbash', team: 'agentcore-cli' });
243+
244+
expect(gw.executionRoleArn, 'executionRoleArn should be preserved from AWS').toBeTruthy();
245+
expect(gw.executionRoleArn).toContain('bugbash-agentcore-role');
246+
247+
expect(gw.targets.length, 'Should have one target').toBe(1);
248+
expect(gw.targets[0]!.name).toBe('mcpTarget');
249+
expect(gw.targets[0]!.targetType).toBe('mcpServer');
250+
expect(gw.targets[0]!.endpoint).toBe('https://mcp.exa.ai/mcp');
251+
},
252+
120_000
253+
);
254+
255+
it.skipIf(!canRun)(
256+
'deployed-state.json has gateway entry',
257+
async () => {
258+
const statePath = join(projectPath, 'agentcore', '.cli', 'deployed-state.json');
259+
const state = JSON.parse(await readFile(statePath, 'utf-8')) as Record<string, unknown>;
260+
261+
// Gateway state is stored under targets.<targetName>.resources.mcp.gateways
262+
const targets = state.targets as Record<string, { resources?: { mcp?: { gateways?: Record<string, unknown> } } }>;
263+
const targetEntries = Object.values(targets);
264+
expect(targetEntries.length).toBeGreaterThan(0);
265+
266+
const firstTarget = targetEntries[0]!;
267+
const gateways = firstTarget.resources?.mcp?.gateways;
268+
expect(gateways, 'deployed-state should have mcp.gateways entry').toBeDefined();
269+
270+
const gatewayEntries = Object.values(gateways!);
271+
expect(gatewayEntries.length, 'Should have one gateway in deployed state').toBe(1);
272+
273+
const gwState = gatewayEntries[0] as { gatewayId?: string; gatewayArn?: string };
274+
expect(gwState.gatewayId, 'Gateway ID should be recorded').toBeTruthy();
190275
},
191276
120_000
192277
);

0 commit comments

Comments
 (0)