Skip to content

Commit cb79649

Browse files
feat(import): add runtime and memory import subcommands with TUI wizard (#763)
* feat(import): add runtime and memory import subcommands Add `agentcore import runtime` and `agentcore import memory` subcommands to import existing AWS resources into a CLI project. Includes 2-phase CFN import, source code copying, and shared utilities. Also adds TODO.md tracking entrypoint detection improvement and CFN Phase 2 handler investigation, and IMPORT_TESTING_SUMMARY.md with full E2E test results. Constraint: AWS API returns modified entryPoint array (with otel wrapper), not original Constraint: Commander.js parent options shadow same-named child options Rejected: --source flag on runtime subcommand | conflicts with parent import --source Confidence: high Scope-risk: moderate Not-tested: CFN Phase 2 import for runtimes (service-side HandlerInternalFailure) * fix(import): fail on undetectable entrypoint instead of silent fallback extractEntrypoint() now returns undefined when it cannot find a file with a known extension (.py/.ts/.js) in the API's entryPoint array, instead of silently falling back to main.py. Adds --entrypoint flag so users can specify the entrypoint manually when auto-detection fails. Constraint: AWS API returns modified entryPoint array with otel wrappers, not original Rejected: Silent fallback to main.py | wrong entrypoint causes silent deploy failures Confidence: high Scope-risk: narrow * chore: remove TODO.md and testing summary from branch * test(import): add unit tests for entrypoint detection and runtime import handler 11 tests for extractEntrypoint covering otel wrappers, missing/empty arrays, multiple extensions, and extensionless entries. 8 tests for handleImportRuntime covering entrypoint failure, --entrypoint override, missing --code, nonexistent source path, and duplicate runtime names. * fix(import): address PR review feedback - Validate entrypoint file exists inside --code directory - Improve --code help text to clarify it points to the entrypoint folder - Validate AWS credentials match target account via STS GetCallerIdentity - Fix project name prefix stripping to only strip known prefix, not any underscore - Rename sanitize() to replaceUnderscoresWithDashes() for clarity - Use existing Dockerfile template from assets instead of hardcoded duplicate * refactor: change --id to --arn on import runtime and memory subcommands Users now provide the full resource ARN instead of just the ID. The runtime/memory ID is extracted from the ARN's last path segment. * fix(import): extract reflectionNamespaces for EPISODIC memory strategies toMemorySpec was not mapping reflectionNamespaces from the API response, causing EPISODIC strategy imports to fail Zod schema validation which requires reflectionNamespaces for EPISODIC type strategies. * fix(import): validate ARN format, region, and account before extracting resource ID Previously, --arn was parsed with a blind split('/').pop() with no validation. Now parseAndValidateArn checks the ARN matches the expected format, resource type, and that region/account match the deployment target. * fix(import): throw on missing required fields in getMemoryDetail instead of silent defaults Previously, missing id/arn/name/eventExpiryDuration/strategy.type were silently replaced with empty strings or default values, hiding API response issues that would cause broken imports downstream. * fix(import): detect already-imported resources early and improve CFN error messages Check deployed-state.json before making any config changes to catch resources already imported in the current project. Also detect the "already exists in stack" CFN error and provide a friendlier message explaining the resource must be removed from the other stack first. * feat(import): capture tags during memory import Fetch tags via ListTagsForResource API and include them in the imported memory config. Tags already flow through the CLI schema and CDK construct, they just weren't being read from the API during import. * feat(import): capture encryptionKeyArn during memory import Add encryptionKeyArn to CLI schema, MemoryDetail, and toMemorySpec so imported memories preserve their KMS encryption key configuration. Also update CDK L3 construct to pass encryptionKeyArn through to CfnMemory. * feat(import): capture executionRoleArn during memory import Map the API field memoryExecutionRoleArn to executionRoleArn in CLI schema to match the runtime convention. Also update CDK L3 construct to use an imported role via Role.fromRoleArn when executionRoleArn is provided instead of always creating a new one. * refactor(import): deduplicate actions.ts by reusing import-utils utilities actions.ts reimplemented 5 utilities that already exist in import-utils.ts. Replace local definitions with imports and use updateDeployedState() instead of inline state manipulation. Removed: sanitize(), toStackName(), fixPyprojectForSetuptools(), COPY_EXCLUDE_DIRS, copyDirRecursive() — all duplicates of import-utils.ts. * fix(import): paginate listings, auto-select single result, and preserve runtime config Three import bugs fixed: 1. listAgentRuntimes/listMemories only fetched one page (max 100). Added listAllAgentRuntimes/listAllMemories that paginate via nextToken. 2. Single-result listing incorrectly showed "Multiple found" error. Now auto-selects when exactly one runtime/memory exists. 3. toAgentEnvSpec dropped env vars, tags, lifecycle config, and request header allowlist. Extended AgentRuntimeDetail and getAgentRuntimeDetail to extract these fields (including ListTagsForResource call for tags), and mapped them in toAgentEnvSpec. Confidence: high Scope-risk: moderate Not-tested: pagination with >100 real resources (no integration test account available) * test(import): add tests for pagination, field extraction, auto-select, and env var mapping Tests cover: - listAllAgentRuntimes/listAllMemories pagination across multiple pages - getAgentRuntimeDetail extraction of environmentVariables, tags (via ListTagsForResource), lifecycleConfiguration, requestHeaderAllowlist - toAgentEnvSpec mapping of env vars Record to envVars array, plus direct mapping of tags, lifecycle config, and header allowlist - Single-result auto-select when listing returns exactly 1 runtime - Error cases: empty listings, multiple results, absent fields * feat(import): auto-create deployment target from ARN when none exist When no deployment targets are configured, import runtime/memory now parses the --arn to extract region and account, then creates a default target automatically instead of requiring `agentcore deploy` first. * fix(import): omit runtimeVersion for Container builds Container runtimes have no runtimeVersion from the API, but toAgentEnvSpec was hardcoding PYTHON_3_12 as a fallback. Now runtimeVersion is optional in the schema and only set for non-Container builds. * fix(import): filter API-internal namespace patterns from memory import Memory strategies like SUMMARIZATION and USER_PREFERENCE include auto-generated namespace patterns (e.g. /strategies/{memoryStrategyId}/...) that are API-internal and should not be written to local agentcore.json. Constraint: Only filters namespaces containing {memoryStrategyId} template var Rejected: Strip all namespaces for non-SEMANTIC strategies | would lose user-defined namespaces Confidence: high Scope-risk: narrow * fix(import): show project context error before --code flag validation Commander's requiredOption() for --code runs before the action handler, so users outside a project see "required option not specified" instead of "no agentcore project found". Change to option() so the handler's project context check (step 1) runs first. The --code validation at step 5 still catches missing values after project context is confirmed. Constraint: Commander validates requiredOption before action handlers execute Rejected: Moving project check into a Commander hook | adds complexity for one flag Confidence: high Scope-risk: narrow * fix(import): address bugbash issues for import commands - Invalid ARN now returns "Not a valid ARN" before target resolution - Failed imports roll back agentcore.json and clean up copied app/ dirs - Discovery listings show ARNs (not just IDs) so users can copy them - Remove --target flag from import runtime/memory subcommands - Add description field to AgentEnvSpec schema and wire through import Constraint: Commander validates requiredOption before action handlers Constraint: Rollback is best-effort to avoid masking the original error Rejected: Keep --target on subcommands | silently falls back to default, confusing UX Confidence: high Scope-risk: moderate * feat(import): add interactive TUI wizard for import command Adds a multi-screen TUI flow for importing runtimes, memories, and starter toolkit configs, replacing the silent fall-through that previously occurred when selecting "import" in the TUI. Constraint: onProgress must be injectable so TUI can display step progress Rejected: Single text-input screen for all flows | each import type has different required fields Confidence: high Scope-risk: narrow * fix(import): add early name validation and allow re-import with --name Bug 5: Validate --name against the AgentNameSchema regex before any file I/O operations. Previously, a malicious --name like '../../../etc/pwned' would copy files outside the project directory and set up a Python venv there before schema validation rejected it. Now invalid names are caught immediately with a clear error message. Applied to both import-runtime and import-memory. Bug 6: Allow re-importing the same cloud resource under a different local name when --name is provided. Previously, the deployed-state duplicate check blocked all re-imports by resource ID regardless of --name. Now it only blocks when --name is not provided, and suggests using --name in the error message. When --name is provided, it warns and proceeds. Applied to both import-runtime and import-memory. * revert(import): restore original duplicate-by-ARN blocking behavior Bug 6 is not a bug — blocking re-imports of the same cloud resource ARN is correct because allowing it would create duplicate CFN logical resources referencing the same physical resource, causing deploy failures. Reverts the --name re-import allowance while keeping the Bug 5 early name validation fix. * feat(import): mark import command as experimental * fix(import): wire deploy next-step navigation and show dotfiles in file picker Two TUI fixes for the import flow: 1. ImportFlow now accepts onNavigate prop so selecting "Deploy" from next steps navigates to the deploy screen instead of going back. 2. PathInput gains a showHidden prop; YamlPathScreen uses it so .bedrock_agentcore.yaml is visible in the file picker. * refactor(import): extract shared CDK import pipeline to eliminate duplication The three import handlers (import-runtime, import-memory, actions) all repeated the same CDK build/synth/bootstrap/publish/phase1/phase2/state-update pipeline (~120 lines each). Extract this into executeCdkImportPipeline() in a new import-pipeline.ts module. Also add resolveImportContext() and failResult() helpers to import-utils.ts for shared setup and error handling. Net effect: -335 lines, zero behavior change, all 260 tests pass. Constraint: Must not change any observable behavior — pure structural refactor Rejected: Full strategy-pattern abstraction | over-engineering for 2 concrete cases Confidence: high Scope-risk: moderate Not-tested: actions.ts YAML import path with real AWS (infra limitation) * fix(import): launch TUI wizard when running agentcore import with no args Previously `agentcore import` with no --source flag showed help text. Now it launches the interactive ImportFlow TUI, matching the pattern used by `agentcore add` and other commands. * fix(import): wire deploy and status navigation from CLI-inline TUI When running `agentcore import` from CLI (not full TUI), selecting "deploy" or "status" from the next-steps menu now renders the corresponding screen instead of silently exiting. * style(import): fix prettier formatting in TUI screens * fix(security): update lodash and lodash-es to resolve high-severity vulnerabilities npm audit fix resolves CVE for code injection via _.template and prototype pollution via _.unset/_.omit in lodash <=4.17.23. * refactor(aws): extract createControlClient to avoid per-call client instantiation Each function in agentcore-control.ts was creating a new BedrockAgentCoreControlClient on every call, wasting HTTP connections and credential resolution. Extracted a shared createControlClient() factory and reuse a single client across paginated listAll* calls. * fix(import): log warnings on silent catch failures instead of swallowing errors Tag fetch failures in agentcore-control.ts and rollback failures in import-runtime.ts and import-memory.ts were silently swallowed. Users had no indication when config could be left in a broken state. Added console.warn calls matching the existing pattern in bedrock-import.ts. --------- Co-authored-by: Aidan Daly <aidandal@amazon.com>
1 parent 11ca658 commit cb79649

32 files changed

+4207
-2411
lines changed

package-lock.json

Lines changed: 965 additions & 2115 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/cli/aws/__tests__/agentcore-control.test.ts

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import {
2+
getAgentRuntimeDetail,
23
getAgentRuntimeStatus,
34
getEvaluator,
45
getOnlineEvaluationConfig,
6+
listAllAgentRuntimes,
7+
listAllMemories,
58
listEvaluators,
69
updateOnlineEvalExecutionStatus,
710
} from '../agentcore-control.js';
@@ -24,9 +27,18 @@ vi.mock('@aws-sdk/client-bedrock-agentcore-control', () => ({
2427
GetOnlineEvaluationConfigCommand: class {
2528
constructor(public input: unknown) {}
2629
},
30+
ListAgentRuntimesCommand: class {
31+
constructor(public input: unknown) {}
32+
},
33+
ListMemoriesCommand: class {
34+
constructor(public input: unknown) {}
35+
},
2736
ListEvaluatorsCommand: class {
2837
constructor(public input: unknown) {}
2938
},
39+
ListTagsForResourceCommand: class {
40+
constructor(public input: unknown) {}
41+
},
3042
UpdateOnlineEvaluationConfigCommand: class {
3143
constructor(public input: unknown) {}
3244
},
@@ -305,6 +317,191 @@ describe('getOnlineEvaluationConfig', () => {
305317
});
306318
});
307319

320+
describe('listAllAgentRuntimes', () => {
321+
beforeEach(() => {
322+
vi.clearAllMocks();
323+
});
324+
325+
it('returns all runtimes from a single page', async () => {
326+
mockSend.mockResolvedValue({
327+
agentRuntimes: [
328+
{ agentRuntimeId: 'rt-1', agentRuntimeArn: 'arn-1', agentRuntimeName: 'runtime-1', status: 'READY' },
329+
],
330+
nextToken: undefined,
331+
});
332+
333+
const result = await listAllAgentRuntimes({ region: 'us-east-1' });
334+
expect(result).toHaveLength(1);
335+
expect(result[0]!.agentRuntimeId).toBe('rt-1');
336+
expect(mockSend).toHaveBeenCalledTimes(1);
337+
});
338+
339+
it('paginates across multiple pages', async () => {
340+
mockSend
341+
.mockResolvedValueOnce({
342+
agentRuntimes: [{ agentRuntimeId: 'rt-1', agentRuntimeArn: 'arn-1', agentRuntimeName: 'r1', status: 'READY' }],
343+
nextToken: 'page2',
344+
})
345+
.mockResolvedValueOnce({
346+
agentRuntimes: [{ agentRuntimeId: 'rt-2', agentRuntimeArn: 'arn-2', agentRuntimeName: 'r2', status: 'READY' }],
347+
nextToken: 'page3',
348+
})
349+
.mockResolvedValueOnce({
350+
agentRuntimes: [{ agentRuntimeId: 'rt-3', agentRuntimeArn: 'arn-3', agentRuntimeName: 'r3', status: 'READY' }],
351+
nextToken: undefined,
352+
});
353+
354+
const result = await listAllAgentRuntimes({ region: 'us-east-1' });
355+
expect(result).toHaveLength(3);
356+
expect(result.map(r => r.agentRuntimeId)).toEqual(['rt-1', 'rt-2', 'rt-3']);
357+
expect(mockSend).toHaveBeenCalledTimes(3);
358+
});
359+
360+
it('returns empty array when no runtimes exist', async () => {
361+
mockSend.mockResolvedValue({ agentRuntimes: undefined, nextToken: undefined });
362+
363+
const result = await listAllAgentRuntimes({ region: 'us-east-1' });
364+
expect(result).toEqual([]);
365+
});
366+
});
367+
368+
describe('listAllMemories', () => {
369+
beforeEach(() => {
370+
vi.clearAllMocks();
371+
});
372+
373+
it('returns all memories from a single page', async () => {
374+
mockSend.mockResolvedValue({
375+
memories: [{ id: 'mem-1', arn: 'arn-1', status: 'ACTIVE' }],
376+
nextToken: undefined,
377+
});
378+
379+
const result = await listAllMemories({ region: 'us-east-1' });
380+
expect(result).toHaveLength(1);
381+
expect(result[0]!.memoryId).toBe('mem-1');
382+
expect(mockSend).toHaveBeenCalledTimes(1);
383+
});
384+
385+
it('paginates across multiple pages', async () => {
386+
mockSend
387+
.mockResolvedValueOnce({
388+
memories: [{ id: 'mem-1', arn: 'arn-1', status: 'ACTIVE' }],
389+
nextToken: 'page2',
390+
})
391+
.mockResolvedValueOnce({
392+
memories: [{ id: 'mem-2', arn: 'arn-2', status: 'ACTIVE' }],
393+
nextToken: undefined,
394+
});
395+
396+
const result = await listAllMemories({ region: 'us-east-1' });
397+
expect(result).toHaveLength(2);
398+
expect(result.map(m => m.memoryId)).toEqual(['mem-1', 'mem-2']);
399+
expect(mockSend).toHaveBeenCalledTimes(2);
400+
});
401+
402+
it('returns empty array when no memories exist', async () => {
403+
mockSend.mockResolvedValue({ memories: undefined, nextToken: undefined });
404+
405+
const result = await listAllMemories({ region: 'us-east-1' });
406+
expect(result).toEqual([]);
407+
});
408+
});
409+
410+
describe('getAgentRuntimeDetail — new fields', () => {
411+
beforeEach(() => {
412+
vi.clearAllMocks();
413+
});
414+
415+
const baseResponse = {
416+
agentRuntimeId: 'rt-123',
417+
agentRuntimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-123',
418+
agentRuntimeName: 'my-runtime',
419+
status: 'READY',
420+
roleArn: 'arn:aws:iam::123:role/test',
421+
networkConfiguration: { networkMode: 'PUBLIC' },
422+
protocolConfiguration: { serverProtocol: 'HTTP' },
423+
agentRuntimeArtifact: { codeConfiguration: { runtime: 'PYTHON_3_12', entryPoint: ['main.py'] } },
424+
};
425+
426+
it('extracts environmentVariables when present', async () => {
427+
mockSend.mockResolvedValue({
428+
...baseResponse,
429+
environmentVariables: { API_KEY: 'secret', DB_HOST: 'localhost' },
430+
});
431+
432+
const result = await getAgentRuntimeDetail({ region: 'us-east-1', runtimeId: 'rt-123' });
433+
expect(result.environmentVariables).toEqual({ API_KEY: 'secret', DB_HOST: 'localhost' });
434+
});
435+
436+
it('returns undefined environmentVariables when empty', async () => {
437+
mockSend.mockResolvedValue({ ...baseResponse, environmentVariables: {} });
438+
439+
const result = await getAgentRuntimeDetail({ region: 'us-east-1', runtimeId: 'rt-123' });
440+
expect(result.environmentVariables).toBeUndefined();
441+
});
442+
443+
it('extracts lifecycleConfiguration when present', async () => {
444+
mockSend.mockResolvedValue({
445+
...baseResponse,
446+
lifecycleConfiguration: { idleRuntimeSessionTimeout: 600, maxLifetime: 3600 },
447+
});
448+
449+
const result = await getAgentRuntimeDetail({ region: 'us-east-1', runtimeId: 'rt-123' });
450+
expect(result.lifecycleConfiguration).toEqual({ idleRuntimeSessionTimeout: 600, maxLifetime: 3600 });
451+
});
452+
453+
it('returns undefined lifecycleConfiguration when absent', async () => {
454+
mockSend.mockResolvedValue({ ...baseResponse });
455+
456+
const result = await getAgentRuntimeDetail({ region: 'us-east-1', runtimeId: 'rt-123' });
457+
expect(result.lifecycleConfiguration).toBeUndefined();
458+
});
459+
460+
it('extracts requestHeaderAllowlist from requestHeaderConfiguration union', async () => {
461+
mockSend.mockResolvedValue({
462+
...baseResponse,
463+
requestHeaderConfiguration: {
464+
requestHeaderAllowlist: ['X-Custom-Header', 'Authorization'],
465+
},
466+
});
467+
468+
const result = await getAgentRuntimeDetail({ region: 'us-east-1', runtimeId: 'rt-123' });
469+
expect(result.requestHeaderAllowlist).toEqual(['X-Custom-Header', 'Authorization']);
470+
});
471+
472+
it('returns undefined requestHeaderAllowlist when not present', async () => {
473+
mockSend.mockResolvedValue({ ...baseResponse });
474+
475+
const result = await getAgentRuntimeDetail({ region: 'us-east-1', runtimeId: 'rt-123' });
476+
expect(result.requestHeaderAllowlist).toBeUndefined();
477+
});
478+
479+
it('fetches tags via ListTagsForResource', async () => {
480+
// First call: GetAgentRuntime, second call: ListTagsForResource
481+
mockSend
482+
.mockResolvedValueOnce({ ...baseResponse })
483+
.mockResolvedValueOnce({ tags: { env: 'prod', team: 'platform' } });
484+
485+
const result = await getAgentRuntimeDetail({ region: 'us-east-1', runtimeId: 'rt-123' });
486+
expect(result.tags).toEqual({ env: 'prod', team: 'platform' });
487+
expect(mockSend).toHaveBeenCalledTimes(2);
488+
});
489+
490+
it('returns undefined tags when ListTagsForResource returns empty', async () => {
491+
mockSend.mockResolvedValueOnce({ ...baseResponse }).mockResolvedValueOnce({ tags: {} });
492+
493+
const result = await getAgentRuntimeDetail({ region: 'us-east-1', runtimeId: 'rt-123' });
494+
expect(result.tags).toBeUndefined();
495+
});
496+
497+
it('returns undefined tags when ListTagsForResource fails', async () => {
498+
mockSend.mockResolvedValueOnce({ ...baseResponse }).mockRejectedValueOnce(new Error('AccessDenied'));
499+
500+
const result = await getAgentRuntimeDetail({ region: 'us-east-1', runtimeId: 'rt-123' });
501+
expect(result.tags).toBeUndefined();
502+
});
503+
});
504+
308505
describe('listEvaluators', () => {
309506
beforeEach(() => {
310507
vi.clearAllMocks();

0 commit comments

Comments
 (0)