From 2ab684d64c227baf0edcd2b6f24921dbe6afeee1 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Fri, 13 Mar 2026 13:59:45 -0400 Subject: [PATCH 1/5] feat: add VPC network mode support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the PRIVATE network mode placeholder with VPC across the full CLI stack: schema validation, TUI wizards, CLI flags, template rendering, and CDK config persistence. Schema: - NetworkModeSchema enum: PUBLIC | PRIVATE → PUBLIC | VPC - Add NetworkConfigSchema with subnet/security group ID validation - Cross-field superRefine: VPC requires networkConfig, non-VPC forbids it CLI flags: - Add --network-mode, --subnets, --security-groups to create and add agent - Shared vpc-utils.ts with parseCommaSeparatedList, validateVpcOptions, validateSubnetIds, validateSecurityGroupIds, and VPC_ENDPOINT_WARNING TUI: - VPC prompts in both create (GenerateWizard) and BYO (AddAgentScreen) paths - Inline validation for subnet/SG ID format in TextInput fields - VPC endpoint warning on completion screens Template rendering: - Add isVpc flag to AgentRenderConfig - Skip Exa AI MCP example endpoint in VPC mode (unreachable without NAT) - VPC stubs return None/empty so main.py null-checks work unchanged Data persistence: - AgentPrimitive passes VPC config through both handleCreatePath and handleByoPath to agentcore.json - useAddAgent mappers (mapByoConfigToAgent, mapAddAgentConfigToGenerateConfig) thread VPC fields through TUI path Warnings: - dev command warns about VPC behavior differences in local mode - invoke command warns about VPC endpoint requirements --- .../assets.snapshot.test.ts.snap | 46 + .../python/autogen/base/mcp_client/client.py | 10 + .../googleadk/base/mcp_client/client.py | 9 + .../base/mcp_client/client.py | 9 + .../openaiagents/base/mcp_client/client.py | 9 + .../python/strands/base/mcp_client/client.py | 9 + .../commands/add/__tests__/validate.test.ts | 1032 +---------------- src/cli/commands/add/types.ts | 3 + src/cli/commands/add/validate.ts | 7 + .../create/__tests__/validate.test.ts | 209 ++-- src/cli/commands/create/action.ts | 10 + src/cli/commands/create/command.tsx | 9 +- src/cli/commands/create/types.ts | 3 + src/cli/commands/create/validate.ts | 7 + src/cli/commands/dev/command.tsx | 8 + src/cli/commands/invoke/action.ts | 7 + .../shared/__tests__/vpc-utils.test.ts | 81 ++ src/cli/commands/shared/vpc-utils.ts | 79 ++ .../generate/__tests__/schema-mapper.test.ts | 268 ++--- .../agent/generate/schema-mapper.ts | 12 +- src/cli/primitives/AgentPrimitive.tsx | 27 + src/cli/templates/types.ts | 2 + src/cli/tui/screens/add/AddFlow.tsx | 6 + src/cli/tui/screens/agent/AddAgentScreen.tsx | 123 +- src/cli/tui/screens/agent/types.ts | 26 +- src/cli/tui/screens/agent/useAddAgent.ts | 14 +- src/cli/tui/screens/create/CreateScreen.tsx | 7 + .../tui/screens/generate/GenerateWizardUI.tsx | 63 +- src/cli/tui/screens/generate/types.ts | 19 +- .../tui/screens/generate/useGenerateWizard.ts | 41 +- src/schema/__tests__/constants.test.ts | 6 +- src/schema/constants.ts | 2 +- src/schema/llm-compacted/agentcore.ts | 8 +- src/schema/llm-compacted/mcp.ts | 2 +- .../schemas/__tests__/agent-env.test.ts | 77 +- src/schema/schemas/__tests__/mcp.test.ts | 4 +- src/schema/schemas/agent-env.ts | 67 +- 37 files changed, 991 insertions(+), 1330 deletions(-) create mode 100644 src/cli/commands/shared/__tests__/vpc-utils.test.ts create mode 100644 src/cli/commands/shared/vpc-utils.ts diff --git a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap index 0e2f5950..8a5594ab 100644 --- a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap +++ b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap @@ -1012,6 +1012,15 @@ exports[`Assets Directory Snapshots > Python framework assets > python/python/au exports[`Assets Directory Snapshots > Python framework assets > python/python/autogen/base/mcp_client/client.py should match snapshot 1`] = ` "from typing import List +{{#if isVpc}} +# VPC mode: external MCP endpoints are not reachable without a NAT gateway. +# Add an AgentCore Gateway with \`agentcore add gateway\`, or configure your own endpoint below. + + +async def get_streamable_http_mcp_tools() -> List: + """No MCP server configured. Add a gateway with \`agentcore add gateway\`.""" + return [] +{{else}} from autogen_ext.tools.mcp import ( StreamableHttpMcpToolAdapter, StreamableHttpServerParams, @@ -1029,6 +1038,7 @@ async def get_streamable_http_mcp_tools() -> List[StreamableHttpMcpToolAdapter]: # to use an MCP server that supports bearer authentication, add headers={"Authorization": f"Bearer {access_token}"} server_params = StreamableHttpServerParams(url=EXAMPLE_MCP_ENDPOINT) return await mcp_server_tools(server_params) +{{/if}} " `; @@ -1792,6 +1802,14 @@ def get_all_gateway_mcp_toolsets() -> list[MCPToolset]: {{/each}} return toolsets {{else}} +{{#if isVpc}} +# VPC mode: external MCP endpoints are not reachable without a NAT gateway. +# Add an AgentCore Gateway with \`agentcore add gateway\`, or configure your own endpoint below. + +def get_streamable_http_mcp_client() -> MCPToolset | None: + """No MCP server configured. Add a gateway with \`agentcore add gateway\`.""" + return None +{{else}} # ExaAI provides information about code through web searches, crawling and code context searches through their platform. Requires no authentication EXAMPLE_MCP_ENDPOINT = "https://mcp.exa.ai/mcp" @@ -1803,6 +1821,7 @@ def get_streamable_http_mcp_client() -> MCPToolset: connection_params=StreamableHTTPConnectionParams(url=EXAMPLE_MCP_ENDPOINT) ) {{/if}} +{{/if}} " `; @@ -2098,6 +2117,14 @@ def get_all_gateway_mcp_client() -> MultiServerMCPClient | None: return None return MultiServerMCPClient(servers) {{else}} +{{#if isVpc}} +# VPC mode: external MCP endpoints are not reachable without a NAT gateway. +# Add an AgentCore Gateway with \`agentcore add gateway\`, or configure your own endpoint below. + +def get_streamable_http_mcp_client() -> MultiServerMCPClient | None: + """No MCP server configured. Add a gateway with \`agentcore add gateway\`.""" + return None +{{else}} # ExaAI provides information about code through web searches, crawling and code context searches through their platform. Requires no authentication EXAMPLE_MCP_ENDPOINT = "https://mcp.exa.ai/mcp" @@ -2114,6 +2141,7 @@ def get_streamable_http_mcp_client() -> MultiServerMCPClient: } ) {{/if}} +{{/if}} " `; @@ -2545,6 +2573,14 @@ def get_all_gateway_mcp_servers() -> list[MCPServerStreamableHttp]: {{/each}} return servers {{else}} +{{#if isVpc}} +# VPC mode: external MCP endpoints are not reachable without a NAT gateway. +# Add an AgentCore Gateway with \`agentcore add gateway\`, or configure your own endpoint below. + +def get_streamable_http_mcp_client() -> MCPServerStreamableHttp | None: + """No MCP server configured. Add a gateway with \`agentcore add gateway\`.""" + return None +{{else}} # ExaAI provides information about code through web searches, crawling and code context searches through their platform. Requires no authentication EXAMPLE_MCP_ENDPOINT = "https://mcp.exa.ai/mcp" @@ -2556,6 +2592,7 @@ def get_streamable_http_mcp_client() -> MCPServerStreamableHttp: name="AgentCore Gateway MCP", params={"url": EXAMPLE_MCP_ENDPOINT} ) {{/if}} +{{/if}} " `; @@ -2879,6 +2916,14 @@ def get_all_gateway_mcp_clients() -> list[MCPClient]: {{/each}} return clients {{else}} +{{#if isVpc}} +# VPC mode: external MCP endpoints are not reachable without a NAT gateway. +# Add an AgentCore Gateway with \`agentcore add gateway\`, or configure your own endpoint below. + +def get_streamable_http_mcp_client() -> MCPClient | None: + """No MCP server configured. Add a gateway with \`agentcore add gateway\`.""" + return None +{{else}} # ExaAI provides information about code through web searches, crawling and code context searches through their platform. Requires no authentication EXAMPLE_MCP_ENDPOINT = "https://mcp.exa.ai/mcp" @@ -2887,6 +2932,7 @@ def get_streamable_http_mcp_client() -> MCPClient: # to use an MCP server that supports bearer authentication, add headers={"Authorization": f"Bearer {access_token}"} return MCPClient(lambda: streamablehttp_client(EXAMPLE_MCP_ENDPOINT)) {{/if}} +{{/if}} " `; diff --git a/src/assets/python/autogen/base/mcp_client/client.py b/src/assets/python/autogen/base/mcp_client/client.py index 7f4c5c3b..4bdc6a93 100644 --- a/src/assets/python/autogen/base/mcp_client/client.py +++ b/src/assets/python/autogen/base/mcp_client/client.py @@ -1,4 +1,13 @@ from typing import List +{{#if isVpc}} +# VPC mode: external MCP endpoints are not reachable without a NAT gateway. +# Add an AgentCore Gateway with `agentcore add gateway`, or configure your own endpoint below. + + +async def get_streamable_http_mcp_tools() -> List: + """No MCP server configured. Add a gateway with `agentcore add gateway`.""" + return [] +{{else}} from autogen_ext.tools.mcp import ( StreamableHttpMcpToolAdapter, StreamableHttpServerParams, @@ -16,3 +25,4 @@ async def get_streamable_http_mcp_tools() -> List[StreamableHttpMcpToolAdapter]: # to use an MCP server that supports bearer authentication, add headers={"Authorization": f"Bearer {access_token}"} server_params = StreamableHttpServerParams(url=EXAMPLE_MCP_ENDPOINT) return await mcp_server_tools(server_params) +{{/if}} diff --git a/src/assets/python/googleadk/base/mcp_client/client.py b/src/assets/python/googleadk/base/mcp_client/client.py index e6dddd62..df9e0512 100644 --- a/src/assets/python/googleadk/base/mcp_client/client.py +++ b/src/assets/python/googleadk/base/mcp_client/client.py @@ -53,6 +53,14 @@ def get_all_gateway_mcp_toolsets() -> list[MCPToolset]: {{/each}} return toolsets {{else}} +{{#if isVpc}} +# VPC mode: external MCP endpoints are not reachable without a NAT gateway. +# Add an AgentCore Gateway with `agentcore add gateway`, or configure your own endpoint below. + +def get_streamable_http_mcp_client() -> MCPToolset | None: + """No MCP server configured. Add a gateway with `agentcore add gateway`.""" + return None +{{else}} # ExaAI provides information about code through web searches, crawling and code context searches through their platform. Requires no authentication EXAMPLE_MCP_ENDPOINT = "https://mcp.exa.ai/mcp" @@ -64,3 +72,4 @@ def get_streamable_http_mcp_client() -> MCPToolset: connection_params=StreamableHTTPConnectionParams(url=EXAMPLE_MCP_ENDPOINT) ) {{/if}} +{{/if}} diff --git a/src/assets/python/langchain_langgraph/base/mcp_client/client.py b/src/assets/python/langchain_langgraph/base/mcp_client/client.py index 71b336d2..8fbf92da 100644 --- a/src/assets/python/langchain_langgraph/base/mcp_client/client.py +++ b/src/assets/python/langchain_langgraph/base/mcp_client/client.py @@ -50,6 +50,14 @@ def get_all_gateway_mcp_client() -> MultiServerMCPClient | None: return None return MultiServerMCPClient(servers) {{else}} +{{#if isVpc}} +# VPC mode: external MCP endpoints are not reachable without a NAT gateway. +# Add an AgentCore Gateway with `agentcore add gateway`, or configure your own endpoint below. + +def get_streamable_http_mcp_client() -> MultiServerMCPClient | None: + """No MCP server configured. Add a gateway with `agentcore add gateway`.""" + return None +{{else}} # ExaAI provides information about code through web searches, crawling and code context searches through their platform. Requires no authentication EXAMPLE_MCP_ENDPOINT = "https://mcp.exa.ai/mcp" @@ -66,3 +74,4 @@ def get_streamable_http_mcp_client() -> MultiServerMCPClient: } ) {{/if}} +{{/if}} diff --git a/src/assets/python/openaiagents/base/mcp_client/client.py b/src/assets/python/openaiagents/base/mcp_client/client.py index 2fe91136..901fe5fa 100644 --- a/src/assets/python/openaiagents/base/mcp_client/client.py +++ b/src/assets/python/openaiagents/base/mcp_client/client.py @@ -52,6 +52,14 @@ def get_all_gateway_mcp_servers() -> list[MCPServerStreamableHttp]: {{/each}} return servers {{else}} +{{#if isVpc}} +# VPC mode: external MCP endpoints are not reachable without a NAT gateway. +# Add an AgentCore Gateway with `agentcore add gateway`, or configure your own endpoint below. + +def get_streamable_http_mcp_client() -> MCPServerStreamableHttp | None: + """No MCP server configured. Add a gateway with `agentcore add gateway`.""" + return None +{{else}} # ExaAI provides information about code through web searches, crawling and code context searches through their platform. Requires no authentication EXAMPLE_MCP_ENDPOINT = "https://mcp.exa.ai/mcp" @@ -63,3 +71,4 @@ def get_streamable_http_mcp_client() -> MCPServerStreamableHttp: name="AgentCore Gateway MCP", params={"url": EXAMPLE_MCP_ENDPOINT} ) {{/if}} +{{/if}} diff --git a/src/assets/python/strands/base/mcp_client/client.py b/src/assets/python/strands/base/mcp_client/client.py index 01457de2..981c806a 100644 --- a/src/assets/python/strands/base/mcp_client/client.py +++ b/src/assets/python/strands/base/mcp_client/client.py @@ -54,6 +54,14 @@ def get_all_gateway_mcp_clients() -> list[MCPClient]: {{/each}} return clients {{else}} +{{#if isVpc}} +# VPC mode: external MCP endpoints are not reachable without a NAT gateway. +# Add an AgentCore Gateway with `agentcore add gateway`, or configure your own endpoint below. + +def get_streamable_http_mcp_client() -> MCPClient | None: + """No MCP server configured. Add a gateway with `agentcore add gateway`.""" + return None +{{else}} # ExaAI provides information about code through web searches, crawling and code context searches through their platform. Requires no authentication EXAMPLE_MCP_ENDPOINT = "https://mcp.exa.ai/mcp" @@ -62,3 +70,4 @@ def get_streamable_http_mcp_client() -> MCPClient: # to use an MCP server that supports bearer authentication, add headers={"Authorization": f"Bearer {access_token}"} return MCPClient(lambda: streamablehttp_client(EXAMPLE_MCP_ENDPOINT)) {{/if}} +{{/if}} diff --git a/src/cli/commands/add/__tests__/validate.test.ts b/src/cli/commands/add/__tests__/validate.test.ts index 9a24db86..a4c0ecf6 100644 --- a/src/cli/commands/add/__tests__/validate.test.ts +++ b/src/cli/commands/add/__tests__/validate.test.ts @@ -1,991 +1,79 @@ -import type { - AddAgentOptions, - AddGatewayOptions, - AddGatewayTargetOptions, - AddIdentityOptions, - AddMemoryOptions, -} from '../types.js'; -import { - validateAddAgentOptions, - validateAddGatewayOptions, - validateAddGatewayTargetOptions, - validateAddIdentityOptions, - validateAddMemoryOptions, -} from '../validate.js'; -import { existsSync, readFileSync } from 'fs'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -const mockReadProjectSpec = vi.fn(); -const mockConfigExists = vi.fn().mockReturnValue(true); -const mockReadMcpSpec = vi.fn(); - -vi.mock('../../../../lib/index.js', () => ({ - ConfigIO: class { - readProjectSpec = mockReadProjectSpec; - configExists = mockConfigExists; - readMcpSpec = mockReadMcpSpec; - }, - findConfigRoot: vi.fn().mockReturnValue('/mock/project/agentcore'), -})); - -vi.mock('fs', async importOriginal => { - const actual = await importOriginal(); - return { ...actual, existsSync: vi.fn().mockReturnValue(true), readFileSync: vi.fn().mockReturnValue('[]') }; -}); - -// Helper: valid base options for each type -const validAgentOptionsByo: AddAgentOptions = { - name: 'TestAgent', - type: 'byo', - language: 'Python', - framework: 'Strands', - modelProvider: 'Bedrock', - codeLocation: '/path/to/code', -}; - -const validAgentOptionsCreate: AddAgentOptions = { - name: 'TestAgent', - type: 'create', - language: 'Python', - framework: 'Strands', - modelProvider: 'Bedrock', - memory: 'none', -}; - -const validGatewayOptionsNone: AddGatewayOptions = { - name: 'test-gateway', - authorizerType: 'NONE', -}; - -const validGatewayOptionsJwt: AddGatewayOptions = { - name: 'test-gateway', - authorizerType: 'CUSTOM_JWT', - discoveryUrl: 'https://example.com/.well-known/openid-configuration', - allowedAudience: 'aud1,aud2', - allowedClients: 'client1,client2', -}; - -const validGatewayTargetOptions: AddGatewayTargetOptions = { - name: 'test-tool', - type: 'mcp-server', - endpoint: 'https://example.com/mcp', - gateway: 'my-gateway', -}; - -const validMemoryOptions: AddMemoryOptions = { - name: 'test-memory', - strategies: 'SEMANTIC,SUMMARIZATION', -}; - -const validIdentityOptions: AddIdentityOptions = { - name: 'test-identity', - apiKey: 'test-key', -}; - -describe('validate', () => { - afterEach(() => vi.clearAllMocks()); - - describe('validateAddAgentOptions', () => { - // AC1: All required fields validated - it('returns error for missing required fields', () => { - const requiredFields: { field: keyof AddAgentOptions; error: string }[] = [ - { field: 'name', error: '--name is required' }, - { field: 'framework', error: '--framework is required' }, - { field: 'modelProvider', error: '--model-provider is required' }, - { field: 'language', error: '--language is required' }, - ]; - - for (const { field, error } of requiredFields) { - const opts = { ...validAgentOptionsByo, [field]: undefined }; - const result = validateAddAgentOptions(opts); - expect(result.valid, `Should fail for missing ${String(field)}`).toBe(false); - expect(result.error).toBe(error); - } - }); - - // AC2: Invalid schema values rejected - it('returns error for invalid schema values', () => { - // Invalid name - let result = validateAddAgentOptions({ ...validAgentOptionsByo, name: '123invalid' }); - expect(result.valid).toBe(false); - expect(result.error?.includes('begin with') || result.error?.includes('letter')).toBeTruthy(); - - // Invalid framework - result = validateAddAgentOptions({ ...validAgentOptionsByo, framework: 'InvalidFW' as any }); - expect(result.valid).toBe(false); - expect(result.error?.includes('Invalid framework')).toBeTruthy(); - - // Invalid modelProvider - result = validateAddAgentOptions({ ...validAgentOptionsByo, modelProvider: 'InvalidMP' as any }); - expect(result.valid).toBe(false); - expect(result.error?.includes('Invalid model provider')).toBeTruthy(); - - // Invalid language - result = validateAddAgentOptions({ ...validAgentOptionsByo, language: 'InvalidLang' as any }); - expect(result.valid).toBe(false); - expect(result.error?.includes('Invalid language')).toBeTruthy(); - }); - - // Case-insensitive flag values - it('accepts lowercase flag values and normalizes them', () => { - const result = validateAddAgentOptions({ - ...validAgentOptionsByo, - framework: 'strands' as any, - modelProvider: 'bedrock' as any, - language: 'python' as any, - }); - expect(result.valid).toBe(true); - }); - - it('accepts uppercase flag values and normalizes them', () => { - const result = validateAddAgentOptions({ - ...validAgentOptionsByo, - framework: 'STRANDS' as any, - modelProvider: 'BEDROCK' as any, - language: 'PYTHON' as any, - }); - expect(result.valid).toBe(true); - }); - - // AC3: Framework/model provider compatibility - it('returns error for incompatible framework and model provider', () => { - const result = validateAddAgentOptions({ - ...validAgentOptionsByo, - framework: 'GoogleADK', - modelProvider: 'Bedrock', - }); - expect(result.valid).toBe(false); - expect(result.error?.includes('does not support')).toBeTruthy(); - }); - - // AC4: BYO path requires codeLocation - it('returns error for BYO path without codeLocation', () => { - const result = validateAddAgentOptions({ - ...validAgentOptionsByo, - type: 'byo', - codeLocation: undefined, - }); - expect(result.valid).toBe(false); - expect(result.error).toBe('--code-location is required for BYO path'); - }); - - // AC5: Create path language restrictions - it('returns error for create path with TypeScript or Other', () => { - let result = validateAddAgentOptions({ ...validAgentOptionsCreate, language: 'TypeScript' }); - expect(result.valid).toBe(false); - expect(result.error?.includes('Python')).toBeTruthy(); - - result = validateAddAgentOptions({ ...validAgentOptionsCreate, language: 'Other' }); - expect(result.valid).toBe(false); - expect(result.error?.includes('Python')).toBeTruthy(); - }); - - // AC6: Create path requires memory - it('returns error for create path without memory or invalid memory', () => { - let result = validateAddAgentOptions({ ...validAgentOptionsCreate, memory: undefined }); - expect(result.valid).toBe(false); - expect(result.error).toBe('--memory is required for create path'); - - result = validateAddAgentOptions({ ...validAgentOptionsCreate, memory: 'invalid' as any }); - expect(result.valid).toBe(false); - expect(result.error?.includes('Invalid memory option')).toBeTruthy(); - }); - - // AC7: Valid options pass - it('passes for valid options', () => { - expect(validateAddAgentOptions(validAgentOptionsByo)).toEqual({ valid: true }); - expect(validateAddAgentOptions(validAgentOptionsCreate)).toEqual({ valid: true }); - }); +import { validateAddAgentOptions } from '../validate.js'; +import { describe, expect, it } from 'vitest'; + +describe('validateAddAgentOptions - VPC validation', () => { + const baseOptions = { + name: 'TestAgent', + type: 'byo' as const, + language: 'Python' as const, + framework: 'Strands' as const, + modelProvider: 'Bedrock' as const, + build: 'CodeZip', + codeLocation: './app/test/', + }; + + it('accepts valid VPC options', () => { + const result = validateAddAgentOptions({ + ...baseOptions, + networkMode: 'VPC', + subnets: 'subnet-12345678', + securityGroups: 'sg-12345678', + }); + expect(result.valid).toBe(true); }); - describe('validateAddGatewayOptions', () => { - // AC8: Required fields validated - it('returns error for missing name', () => { - const result = validateAddGatewayOptions({ ...validGatewayOptionsNone, name: undefined }); - expect(result.valid).toBe(false); - expect(result.error).toBe('--name is required'); - }); - - // AC9: Invalid name rejected - it('returns error for invalid gateway name', () => { - const result = validateAddGatewayOptions({ ...validGatewayOptionsNone, name: 'INVALID_NAME!' }); - expect(result.valid).toBe(false); - expect(result.error).toBeTruthy(); - }); - - // AC10: Invalid authorizerType rejected - it('returns error for invalid authorizerType', () => { - const result = validateAddGatewayOptions({ ...validGatewayOptionsNone, authorizerType: 'INVALID' as any }); - expect(result.valid).toBe(false); - expect(result.error?.includes('Invalid authorizer type')).toBeTruthy(); - }); - - // AC11: CUSTOM_JWT requires discoveryUrl and allowedClients (allowedAudience is optional) - it('returns error for CUSTOM_JWT missing required fields', () => { - const jwtFields: { field: keyof AddGatewayOptions; error: string }[] = [ - { field: 'discoveryUrl', error: '--discovery-url is required for CUSTOM_JWT authorizer' }, - { field: 'allowedClients', error: '--allowed-clients is required for CUSTOM_JWT authorizer' }, - ]; - - for (const { field, error } of jwtFields) { - const opts = { ...validGatewayOptionsJwt, [field]: undefined }; - const result = validateAddGatewayOptions(opts); - expect(result.valid, `Should fail for missing ${String(field)}`).toBe(false); - expect(result.error).toBe(error); - } - }); - - // AC11b: allowedAudience is optional - it('allows CUSTOM_JWT without allowedAudience', () => { - const opts = { ...validGatewayOptionsJwt, allowedAudience: undefined }; - const result = validateAddGatewayOptions(opts); - expect(result.valid).toBe(true); - }); - - // AC12: discoveryUrl validation - it('returns error for invalid discoveryUrl', () => { - // Invalid URL format - let result = validateAddGatewayOptions({ ...validGatewayOptionsJwt, discoveryUrl: 'not-a-url' }); - expect(result.valid).toBe(false); - expect(result.error?.includes('valid URL')).toBeTruthy(); - - // Missing well-known suffix - result = validateAddGatewayOptions({ ...validGatewayOptionsJwt, discoveryUrl: 'https://example.com/oauth' }); - expect(result.valid).toBe(false); - expect(result.error?.includes('.well-known/openid-configuration')).toBeTruthy(); - }); - - // AC13: Empty comma-separated clients rejected (audience can be empty) - it('returns error for empty clients', () => { - const result = validateAddGatewayOptions({ ...validGatewayOptionsJwt, allowedClients: ' , ' }); - expect(result.valid).toBe(false); - expect(result.error).toBe('At least one client value is required'); - }); - - // AC14: Valid options pass - it('passes for valid options', () => { - expect(validateAddGatewayOptions(validGatewayOptionsNone)).toEqual({ valid: true }); - expect(validateAddGatewayOptions(validGatewayOptionsJwt)).toEqual({ valid: true }); - }); - - // AC15: agentClientId and agentClientSecret must be provided together - it('returns error when agentClientId provided without agentClientSecret', () => { - const result = validateAddGatewayOptions({ - ...validGatewayOptionsJwt, - agentClientId: 'my-client-id', - }); - expect(result.valid).toBe(false); - expect(result.error).toBe('Both --agent-client-id and --agent-client-secret must be provided together'); - }); - - it('returns error when agentClientSecret provided without agentClientId', () => { - const result = validateAddGatewayOptions({ - ...validGatewayOptionsJwt, - agentClientSecret: 'my-secret', - }); - expect(result.valid).toBe(false); - expect(result.error).toBe('Both --agent-client-id and --agent-client-secret must be provided together'); - }); - - // AC16: agent credentials only valid with CUSTOM_JWT - it('returns error when agent credentials used with non-CUSTOM_JWT authorizer', () => { - const result = validateAddGatewayOptions({ - ...validGatewayOptionsNone, - agentClientId: 'my-client-id', - agentClientSecret: 'my-secret', - }); - expect(result.valid).toBe(false); - expect(result.error).toBe('Agent OAuth credentials are only valid with CUSTOM_JWT authorizer'); - }); - - // AC17: valid CUSTOM_JWT with agent credentials passes - it('passes for CUSTOM_JWT with agent credentials', () => { - const result = validateAddGatewayOptions({ - ...validGatewayOptionsJwt, - agentClientId: 'my-client-id', - agentClientSecret: 'my-secret', - allowedScopes: 'scope1,scope2', - }); - expect(result.valid).toBe(true); + it('accepts PUBLIC network mode without VPC options', () => { + const result = validateAddAgentOptions({ + ...baseOptions, + networkMode: 'PUBLIC', }); + expect(result.valid).toBe(true); }); - describe('validateAddGatewayTargetOptions', () => { - beforeEach(() => { - // By default, mock that the gateway from validGatewayTargetOptions exists - mockReadMcpSpec.mockResolvedValue({ agentCoreGateways: [{ name: 'my-gateway' }] }); - }); - - // AC15: Required fields validated - it('returns error for missing name', async () => { - const opts = { ...validGatewayTargetOptions, name: undefined }; - const result = await validateAddGatewayTargetOptions(opts); - expect(result.valid).toBe(false); - expect(result.error).toBe('--name is required'); - }); - - it('returns error when --gateway is missing', async () => { - const opts = { ...validGatewayTargetOptions, gateway: undefined }; - const result = await validateAddGatewayTargetOptions(opts); - expect(result.valid).toBe(false); - expect(result.error).toContain('--gateway is required'); - }); - - it('returns error when no gateways exist', async () => { - mockReadMcpSpec.mockResolvedValue({ agentCoreGateways: [] }); - const result = await validateAddGatewayTargetOptions({ ...validGatewayTargetOptions }); - expect(result.valid).toBe(false); - expect(result.error).toContain('No gateways found'); - expect(result.error).toContain('agentcore add gateway'); - }); - - it('returns error when specified gateway does not exist', async () => { - mockReadMcpSpec.mockResolvedValue({ agentCoreGateways: [{ name: 'other-gateway' }] }); - const result = await validateAddGatewayTargetOptions({ ...validGatewayTargetOptions }); - expect(result.valid).toBe(false); - expect(result.error).toContain('Gateway "my-gateway" not found'); - expect(result.error).toContain('other-gateway'); - }); - - // AC18: Valid options pass - it('passes for valid gateway target options', async () => { - const result = await validateAddGatewayTargetOptions({ ...validGatewayTargetOptions }); - expect(result.valid).toBe(true); - }); - // AC20: type validation - it('returns error when --type is missing', async () => { - const options: AddGatewayTargetOptions = { - name: 'test-tool', - gateway: 'my-gateway', - }; - const result = await validateAddGatewayTargetOptions(options); - expect(result.valid).toBe(false); - expect(result.error).toContain('--type is required'); - }); - - it('accepts --type mcp-server', async () => { - const options: AddGatewayTargetOptions = { - name: 'test-tool', - type: 'mcp-server', - endpoint: 'https://example.com/mcp', - gateway: 'my-gateway', - }; - const result = await validateAddGatewayTargetOptions(options); - expect(result.valid).toBe(true); - expect(options.language).toBe('Other'); - }); - - it('returns error for invalid --type', async () => { - const options: AddGatewayTargetOptions = { - name: 'test-tool', - type: 'invalid', - gateway: 'my-gateway', - }; - const result = await validateAddGatewayTargetOptions(options); - expect(result.valid).toBe(false); - expect(result.error).toContain('Invalid type'); - }); - - it('passes for mcp-server with https endpoint', async () => { - const options: AddGatewayTargetOptions = { - name: 'test-tool', - type: 'mcp-server', - endpoint: 'https://example.com/mcp', - gateway: 'my-gateway', - }; - const result = await validateAddGatewayTargetOptions(options); - expect(result.valid).toBe(true); - }); - - it('passes for mcp-server with http endpoint', async () => { - const options: AddGatewayTargetOptions = { - name: 'test-tool', - type: 'mcp-server', - endpoint: 'http://localhost:3000/mcp', - gateway: 'my-gateway', - }; - const result = await validateAddGatewayTargetOptions(options); - expect(result.valid).toBe(true); - }); - - it('returns error for mcp-server without endpoint', async () => { - const options: AddGatewayTargetOptions = { - name: 'test-tool', - type: 'mcp-server', - gateway: 'my-gateway', - }; - const result = await validateAddGatewayTargetOptions(options); - expect(result.valid).toBe(false); - expect(result.error).toContain('--endpoint is required'); - }); - - it('returns error for mcp-server with non-http(s) URL', async () => { - const options: AddGatewayTargetOptions = { - name: 'test-tool', - type: 'mcp-server', - endpoint: 'ftp://example.com/mcp', - gateway: 'my-gateway', - }; - const result = await validateAddGatewayTargetOptions(options); - expect(result.valid).toBe(false); - expect(result.error).toBe('Endpoint must use http:// or https:// protocol'); - }); - - it('returns error for mcp-server with invalid URL', async () => { - const options: AddGatewayTargetOptions = { - name: 'test-tool', - type: 'mcp-server', - endpoint: 'not-a-url', - gateway: 'my-gateway', - }; - const result = await validateAddGatewayTargetOptions(options); - expect(result.valid).toBe(false); - expect(result.error).toBe('Endpoint must be a valid URL (e.g. https://example.com/mcp)'); - }); - - // AC21: credential validation through outbound auth - it('returns error when credential not found', async () => { - mockReadProjectSpec.mockResolvedValue({ - credentials: [{ name: 'existing-cred', type: 'ApiKey' }], - }); - - const options: AddGatewayTargetOptions = { - name: 'test-tool', - type: 'mcp-server', - endpoint: 'https://example.com/mcp', - gateway: 'my-gateway', - outboundAuthType: 'API_KEY', - credentialName: 'missing-cred', - }; - const result = await validateAddGatewayTargetOptions(options); - expect(result.valid).toBe(false); - expect(result.error).toContain('Credential "missing-cred" not found'); - }); - - it('returns error when no credentials configured', async () => { - mockReadProjectSpec.mockResolvedValue({ - credentials: [], - }); - - const options: AddGatewayTargetOptions = { - name: 'test-tool', - type: 'mcp-server', - endpoint: 'https://example.com/mcp', - gateway: 'my-gateway', - outboundAuthType: 'API_KEY', - credentialName: 'any-cred', - }; - const result = await validateAddGatewayTargetOptions(options); - expect(result.valid).toBe(false); - expect(result.error).toContain('No credentials are configured'); - }); - - it('passes when credential exists', async () => { - mockReadProjectSpec.mockResolvedValue({ - credentials: [{ name: 'valid-cred', type: 'ApiKey' }], - }); - - const options: AddGatewayTargetOptions = { - name: 'test-tool', - type: 'mcp-server', - endpoint: 'https://example.com/mcp', - gateway: 'my-gateway', - outboundAuthType: 'API_KEY', - credentialName: 'valid-cred', - }; - const result = await validateAddGatewayTargetOptions(options); - expect(result.valid).toBe(true); - }); - - // Outbound auth inline OAuth validation - it('passes for OAUTH with inline OAuth fields', async () => { - const result = await validateAddGatewayTargetOptions({ - ...validGatewayTargetOptions, - outboundAuthType: 'OAUTH', - oauthClientId: 'cid', - oauthClientSecret: 'csec', - oauthDiscoveryUrl: 'https://auth.example.com', - }); - expect(result.valid).toBe(true); - }); - - it('returns error for OAUTH without credential-name or inline fields', async () => { - const result = await validateAddGatewayTargetOptions({ - ...validGatewayTargetOptions, - outboundAuthType: 'OAUTH', - }); - expect(result.valid).toBe(false); - expect(result.error).toContain('--credential-name or inline OAuth fields'); - }); - - it('returns error for incomplete inline OAuth (missing client-secret)', async () => { - const result = await validateAddGatewayTargetOptions({ - ...validGatewayTargetOptions, - outboundAuthType: 'OAUTH', - oauthClientId: 'cid', - oauthDiscoveryUrl: 'https://auth.example.com', - }); - expect(result.valid).toBe(false); - expect(result.error).toContain('--oauth-client-secret'); - }); - - it('returns error for API_KEY with inline OAuth fields', async () => { - const result = await validateAddGatewayTargetOptions({ - ...validGatewayTargetOptions, - outboundAuthType: 'API_KEY', - oauthClientId: 'cid', - oauthClientSecret: 'csec', - oauthDiscoveryUrl: 'https://auth.example.com', - }); - expect(result.valid).toBe(false); - expect(result.error).toContain('cannot be used with API_KEY'); - }); - - it('returns error for API_KEY without credential-name', async () => { - const result = await validateAddGatewayTargetOptions({ - ...validGatewayTargetOptions, - outboundAuthType: 'API_KEY', - }); - expect(result.valid).toBe(false); - expect(result.error).toContain('--credential-name is required'); - }); - - it('returns error for invalid OAuth discovery URL', async () => { - const result = await validateAddGatewayTargetOptions({ - ...validGatewayTargetOptions, - outboundAuthType: 'OAUTH', - oauthClientId: 'cid', - oauthClientSecret: 'csec', - oauthDiscoveryUrl: 'not-a-url', - }); - expect(result.valid).toBe(false); - expect(result.error).toBe('--oauth-discovery-url must be a valid URL'); - }); - - it('accepts valid api-gateway options', async () => { - const result = await validateAddGatewayTargetOptions({ - name: 'my-api', - type: 'api-gateway', - restApiId: 'abc123', - stage: 'prod', - gateway: 'my-gateway', - }); - expect(result.valid).toBe(true); - }); - - it('rejects api-gateway without --rest-api-id', async () => { - const result = await validateAddGatewayTargetOptions({ - name: 'my-api', - type: 'api-gateway', - stage: 'prod', - gateway: 'my-gateway', - }); - expect(result.valid).toBe(false); - expect(result.error).toContain('--rest-api-id is required'); - }); - - it('rejects api-gateway without --stage', async () => { - const result = await validateAddGatewayTargetOptions({ - name: 'my-api', - type: 'api-gateway', - restApiId: 'abc123', - gateway: 'my-gateway', - }); - expect(result.valid).toBe(false); - expect(result.error).toContain('--stage is required'); - }); - - it('rejects --endpoint for api-gateway type', async () => { - const result = await validateAddGatewayTargetOptions({ - name: 'my-api', - type: 'api-gateway', - restApiId: 'abc123', - stage: 'prod', - gateway: 'my-gateway', - endpoint: 'https://example.com', - }); - expect(result.valid).toBe(false); - expect(result.error).toContain('not applicable'); - }); - - it('rejects --host for api-gateway type', async () => { - const result = await validateAddGatewayTargetOptions({ - name: 'my-api', - type: 'api-gateway', - restApiId: 'abc123', - stage: 'prod', - gateway: 'my-gateway', - host: 'Lambda', - }); - expect(result.valid).toBe(false); - expect(result.error).toContain('not applicable'); - }); - - it('rejects --outbound-auth oauth for api-gateway type', async () => { - const result = await validateAddGatewayTargetOptions({ - name: 'my-api', - type: 'api-gateway', - restApiId: 'abc123', - stage: 'prod', - gateway: 'my-gateway', - outboundAuthType: 'OAUTH', - }); - expect(result.valid).toBe(false); - expect(result.error).toContain('is not supported for api-gateway type'); - }); - - it('accepts --outbound-auth api-key with --credential-name for api-gateway type', async () => { - const result = await validateAddGatewayTargetOptions({ - name: 'my-api', - type: 'api-gateway', - restApiId: 'abc123', - stage: 'prod', - gateway: 'my-gateway', - outboundAuthType: 'API_KEY', - credentialName: 'my-key', - }); - expect(result.valid).toBe(true); - }); - - it('rejects --outbound-auth api-key without --credential-name for api-gateway type', async () => { - const result = await validateAddGatewayTargetOptions({ - name: 'my-api', - type: 'api-gateway', - restApiId: 'abc123', - stage: 'prod', - gateway: 'my-gateway', - outboundAuthType: 'API_KEY', - }); - expect(result.valid).toBe(false); - expect(result.error).toContain('--credential-name is required'); - }); - - // Lambda Function ARN target validation - it('accepts valid lambda-function-arn options', async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFileSync).mockReturnValue('[{"name":"tool1","description":"desc"}]'); - const result = await validateAddGatewayTargetOptions({ - name: 'my-lambda', - type: 'lambda-function-arn', - lambdaArn: 'arn:aws:lambda:us-east-1:123456789012:function:my-func', - toolSchemaFile: './tools.json', - gateway: 'my-gateway', - }); - expect(result.valid).toBe(true); - }); - - it('rejects lambda-function-arn without --lambda-arn', async () => { - const result = await validateAddGatewayTargetOptions({ - name: 'my-lambda', - type: 'lambda-function-arn', - toolSchemaFile: './tools.json', - gateway: 'my-gateway', - }); - expect(result.valid).toBe(false); - expect(result.error).toContain('--lambda-arn is required'); - }); - - it('rejects lambda-function-arn without --tool-schema-file', async () => { - const result = await validateAddGatewayTargetOptions({ - name: 'my-lambda', - type: 'lambda-function-arn', - lambdaArn: 'arn:aws:lambda:us-east-1:123456789012:function:my-func', - gateway: 'my-gateway', - }); - expect(result.valid).toBe(false); - expect(result.error).toContain('--tool-schema-file is required'); - }); - - it('accepts lambda-function-arn with absolute path', async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFileSync).mockReturnValue(JSON.stringify([{ name: 'tool1', description: 'desc' }])); - const result = await validateAddGatewayTargetOptions({ - name: 'my-lambda', - type: 'lambda-function-arn', - lambdaArn: 'arn:aws:lambda:us-east-1:123456789012:function:my-func', - toolSchemaFile: '/absolute/path/tools.json', - gateway: 'my-gateway', - }); - expect(result.valid).toBe(true); - // Verify the absolute path was used as-is, not joined with project root - expect(vi.mocked(existsSync)).toHaveBeenCalledWith('/absolute/path/tools.json'); - expect(vi.mocked(readFileSync)).toHaveBeenCalledWith('/absolute/path/tools.json', 'utf-8'); - }); - - it('accepts lambda-function-arn with relative path resolved from project root', async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFileSync).mockReturnValue(JSON.stringify([{ name: 'tool1', description: 'desc' }])); - const result = await validateAddGatewayTargetOptions({ - name: 'my-lambda', - type: 'lambda-function-arn', - lambdaArn: 'arn:aws:lambda:us-east-1:123456789012:function:my-func', - toolSchemaFile: './tools.json', - gateway: 'my-gateway', - }); - expect(result.valid).toBe(true); - // Verify relative path was resolved from project root (dirname of configRoot) - const calledPath = vi.mocked(existsSync).mock.calls.find(c => String(c[0]).includes('tools.json')); - expect(calledPath).toBeDefined(); - expect(String(calledPath![0])).not.toBe('./tools.json'); // Should be resolved, not raw - }); - - it('rejects lambda-function-arn when file not found', async () => { - vi.mocked(existsSync).mockReturnValue(false); - const result = await validateAddGatewayTargetOptions({ - name: 'my-lambda', - type: 'lambda-function-arn', - lambdaArn: 'arn:aws:lambda:us-east-1:123456789012:function:my-func', - toolSchemaFile: './tools.json', - gateway: 'my-gateway', - }); - expect(result.valid).toBe(false); - expect(result.error).toContain('not found'); - }); - - it('rejects lambda-function-arn with invalid JSON', async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFileSync).mockReturnValue('not json'); - const result = await validateAddGatewayTargetOptions({ - name: 'my-lambda', - type: 'lambda-function-arn', - lambdaArn: 'arn:aws:lambda:us-east-1:123456789012:function:my-func', - toolSchemaFile: './tools.json', - gateway: 'my-gateway', - }); - expect(result.valid).toBe(false); - expect(result.error).toContain('not valid JSON'); - }); - - it('rejects lambda-function-arn with non-array JSON', async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFileSync).mockReturnValue('{}'); - const result = await validateAddGatewayTargetOptions({ - name: 'my-lambda', - type: 'lambda-function-arn', - lambdaArn: 'arn:aws:lambda:us-east-1:123456789012:function:my-func', - toolSchemaFile: './tools.json', - gateway: 'my-gateway', - }); - expect(result.valid).toBe(false); - expect(result.error).toContain('JSON array'); - }); - - it('rejects lambda-function-arn with empty array', async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFileSync).mockReturnValue('[]'); - const result = await validateAddGatewayTargetOptions({ - name: 'my-lambda', - type: 'lambda-function-arn', - lambdaArn: 'arn:aws:lambda:us-east-1:123456789012:function:my-func', - toolSchemaFile: './tools.json', - gateway: 'my-gateway', - }); - expect(result.valid).toBe(false); - expect(result.error).toContain('at least one tool definition'); - }); - - it('rejects lambda-function-arn with missing name in element', async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFileSync).mockReturnValue('[{"description":"d"}]'); - const result = await validateAddGatewayTargetOptions({ - name: 'my-lambda', - type: 'lambda-function-arn', - lambdaArn: 'arn:aws:lambda:us-east-1:123456789012:function:my-func', - toolSchemaFile: './tools.json', - gateway: 'my-gateway', - }); - expect(result.valid).toBe(false); - expect(result.error).toContain('missing a valid "name"'); - }); - - it('rejects --endpoint for lambda-function-arn type', async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFileSync).mockReturnValue('[{"name":"tool1","description":"desc"}]'); - const result = await validateAddGatewayTargetOptions({ - name: 'my-lambda', - type: 'lambda-function-arn', - lambdaArn: 'arn:aws:lambda:us-east-1:123456789012:function:my-func', - toolSchemaFile: './tools.json', - gateway: 'my-gateway', - endpoint: 'https://example.com', - }); - expect(result.valid).toBe(false); - expect(result.error).toContain('not applicable'); - }); - - it('rejects --outbound-auth for lambda-function-arn type', async () => { - vi.mocked(existsSync).mockReturnValue(true); - vi.mocked(readFileSync).mockReturnValue('[{"name":"tool1","description":"desc"}]'); - const result = await validateAddGatewayTargetOptions({ - name: 'my-lambda', - type: 'lambda-function-arn', - lambdaArn: 'arn:aws:lambda:us-east-1:123456789012:function:my-func', - toolSchemaFile: './tools.json', - gateway: 'my-gateway', - outboundAuthType: 'NONE', - }); - expect(result.valid).toBe(false); - expect(result.error).toContain('not applicable'); - }); - - it('rejects --host with mcp-server type', async () => { - const options: AddGatewayTargetOptions = { - name: 'test-tool', - type: 'mcp-server', - endpoint: 'https://example.com/mcp', - host: 'Lambda', - gateway: 'my-gateway', - }; - const result = await validateAddGatewayTargetOptions(options); - expect(result.valid).toBe(false); - expect(result.error).toBe('--host is not applicable for MCP server targets'); + it('rejects invalid network mode', () => { + const result = validateAddAgentOptions({ + ...baseOptions, + networkMode: 'INVALID', }); + expect(result.valid).toBe(false); + expect(result.error).toContain('Invalid network mode'); }); - describe('validateAddMemoryOptions', () => { - // AC20: Required fields validated - it('returns error for missing name', () => { - const result = validateAddMemoryOptions({ ...validMemoryOptions, name: undefined }); - expect(result.valid).toBe(false); - expect(result.error).toBe('--name is required'); - }); - - // AC21: Invalid strategies rejected, empty strategies allowed - it('returns error for invalid strategies', () => { - const result = validateAddMemoryOptions({ ...validMemoryOptions, strategies: 'INVALID' }); - expect(result.valid).toBe(false); - expect(result.error?.includes('Invalid strategy')).toBeTruthy(); - expect(result.error?.includes('SEMANTIC')).toBeTruthy(); - }); - - it('allows empty strategies', () => { - expect(validateAddMemoryOptions({ ...validMemoryOptions, strategies: ',,,' })).toEqual({ valid: true }); - expect(validateAddMemoryOptions({ ...validMemoryOptions, strategies: undefined })).toEqual({ valid: true }); - }); - - // AC22: Valid options pass - it('passes for valid options', () => { - expect(validateAddMemoryOptions(validMemoryOptions)).toEqual({ valid: true }); - // Test all valid strategies - expect( - validateAddMemoryOptions({ ...validMemoryOptions, strategies: 'SEMANTIC,SUMMARIZATION,USER_PREFERENCE' }) - ).toEqual({ valid: true }); - }); - - // AC23: CUSTOM strategy is not supported (Issue #235) - it('rejects CUSTOM strategy', () => { - const result = validateAddMemoryOptions({ ...validMemoryOptions, strategies: 'CUSTOM' }); - expect(result.valid).toBe(false); - expect(result.error).toContain('Invalid strategy: CUSTOM'); - }); - - it('rejects CUSTOM even when mixed with valid strategies', () => { - const result = validateAddMemoryOptions({ ...validMemoryOptions, strategies: 'SEMANTIC,CUSTOM' }); - expect(result.valid).toBe(false); - expect(result.error).toContain('Invalid strategy: CUSTOM'); - }); - - // AC24: Each individual valid strategy should pass - it('accepts each valid strategy individually', () => { - expect(validateAddMemoryOptions({ ...validMemoryOptions, strategies: 'SEMANTIC' })).toEqual({ valid: true }); - expect(validateAddMemoryOptions({ ...validMemoryOptions, strategies: 'SUMMARIZATION' })).toEqual({ valid: true }); - expect(validateAddMemoryOptions({ ...validMemoryOptions, strategies: 'USER_PREFERENCE' })).toEqual({ - valid: true, - }); - }); - - // AC25: Valid strategy combinations should pass - it('accepts valid strategy combinations', () => { - expect(validateAddMemoryOptions({ ...validMemoryOptions, strategies: 'SEMANTIC,SUMMARIZATION' })).toEqual({ - valid: true, - }); - expect(validateAddMemoryOptions({ ...validMemoryOptions, strategies: 'SEMANTIC,USER_PREFERENCE' })).toEqual({ - valid: true, - }); - expect(validateAddMemoryOptions({ ...validMemoryOptions, strategies: 'SUMMARIZATION,USER_PREFERENCE' })).toEqual({ - valid: true, - }); - }); - - // AC26: Strategies with whitespace should be handled - it('handles strategies with whitespace', () => { - expect(validateAddMemoryOptions({ ...validMemoryOptions, strategies: ' SEMANTIC , SUMMARIZATION ' })).toEqual({ - valid: true, - }); + it('rejects VPC mode without subnets', () => { + const result = validateAddAgentOptions({ + ...baseOptions, + networkMode: 'VPC', + securityGroups: 'sg-12345678', }); + expect(result.valid).toBe(false); + expect(result.error).toContain('--subnets is required'); }); - describe('validateAddIdentityOptions', () => { - // AC23: Required fields validated - it('returns error for missing required fields', () => { - const requiredFields: { field: keyof AddIdentityOptions; error: string }[] = [ - { field: 'name', error: '--name is required' }, - { field: 'apiKey', error: '--api-key is required' }, - ]; - - for (const { field, error } of requiredFields) { - const opts = { ...validIdentityOptions, [field]: undefined }; - const result = validateAddIdentityOptions(opts); - expect(result.valid, `Should fail for missing ${String(field)}`).toBe(false); - expect(result.error).toBe(error); - } - }); - - // AC25: Valid options pass - it('passes for valid options', () => { - expect(validateAddIdentityOptions(validIdentityOptions)).toEqual({ valid: true }); + it('rejects VPC mode without security groups', () => { + const result = validateAddAgentOptions({ + ...baseOptions, + networkMode: 'VPC', + subnets: 'subnet-12345678', }); + expect(result.valid).toBe(false); + expect(result.error).toContain('--security-groups is required'); }); - describe('validateAddIdentityOptions OAuth', () => { - it('passes for valid OAuth identity', () => { - const result = validateAddIdentityOptions({ - name: 'my-oauth', - type: 'oauth', - discoveryUrl: 'https://auth.example.com/.well-known/openid-configuration', - clientId: 'client123', - clientSecret: 'secret456', - }); - expect(result.valid).toBe(true); - }); - - it('returns error for OAuth without discovery-url', () => { - const result = validateAddIdentityOptions({ - name: 'my-oauth', - type: 'oauth', - clientId: 'client123', - clientSecret: 'secret456', - }); - expect(result.valid).toBe(false); - expect(result.error).toContain('--discovery-url'); - }); - - it('returns error for OAuth without client-id', () => { - const result = validateAddIdentityOptions({ - name: 'my-oauth', - type: 'oauth', - discoveryUrl: 'https://auth.example.com', - clientSecret: 'secret456', - }); - expect(result.valid).toBe(false); - expect(result.error).toContain('--client-id'); - }); - - it('returns error for OAuth without client-secret', () => { - const result = validateAddIdentityOptions({ - name: 'my-oauth', - type: 'oauth', - discoveryUrl: 'https://auth.example.com', - clientId: 'client123', - }); - expect(result.valid).toBe(false); - expect(result.error).toContain('--client-secret'); + it('rejects subnets without VPC mode', () => { + const result = validateAddAgentOptions({ + ...baseOptions, + subnets: 'subnet-12345678', }); + expect(result.valid).toBe(false); + expect(result.error).toContain('only valid with --network-mode VPC'); + }); - it('still requires api-key for default type', () => { - const result = validateAddIdentityOptions({ name: 'my-key' }); - expect(result.valid).toBe(false); - expect(result.error).toContain('--api-key'); + it('rejects security groups without VPC mode', () => { + const result = validateAddAgentOptions({ + ...baseOptions, + securityGroups: 'sg-12345678', }); + expect(result.valid).toBe(false); + expect(result.error).toContain('only valid with --network-mode VPC'); }); }); diff --git a/src/cli/commands/add/types.ts b/src/cli/commands/add/types.ts index 6f39c224..73f90c96 100644 --- a/src/cli/commands/add/types.ts +++ b/src/cli/commands/add/types.ts @@ -11,6 +11,9 @@ export interface AddAgentOptions { modelProvider?: ModelProvider; apiKey?: string; memory?: MemoryOption; + networkMode?: string; + subnets?: string; + securityGroups?: string; codeLocation?: string; entrypoint?: string; json?: boolean; diff --git a/src/cli/commands/add/validate.ts b/src/cli/commands/add/validate.ts index 8c77476d..7ee0c4e8 100644 --- a/src/cli/commands/add/validate.ts +++ b/src/cli/commands/add/validate.ts @@ -11,6 +11,7 @@ import { getSupportedModelProviders, matchEnumValue, } from '../../../schema'; +import { validateVpcOptions } from '../shared/vpc-utils'; import type { AddAgentOptions, AddGatewayOptions, @@ -150,6 +151,12 @@ export function validateAddAgentOptions(options: AddAgentOptions): ValidationRes } } + // Validate VPC options + const vpcResult = validateVpcOptions(options); + if (!vpcResult.valid) { + return { valid: false, error: vpcResult.error }; + } + return { valid: true }; } diff --git a/src/cli/commands/create/__tests__/validate.test.ts b/src/cli/commands/create/__tests__/validate.test.ts index 8137f5d7..0a3ab118 100644 --- a/src/cli/commands/create/__tests__/validate.test.ts +++ b/src/cli/commands/create/__tests__/validate.test.ts @@ -1,167 +1,120 @@ -import { validateCreateOptions, validateFolderNotExists } from '../validate.js'; -import { randomUUID } from 'node:crypto'; -import { mkdirSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; - -describe('validateFolderNotExists', () => { - let testDir: string; - - beforeAll(() => { - testDir = join(tmpdir(), `create-validate-${randomUUID()}`); - mkdirSync(testDir, { recursive: true }); - mkdirSync(join(testDir, 'existing-project'), { recursive: true }); - }); - - afterAll(() => { - rmSync(testDir, { recursive: true, force: true }); - }); - - it('returns true when folder does not exist', () => { - expect(validateFolderNotExists('new-project', testDir)).toBe(true); - }); - - it('returns error string when folder exists', () => { - const result = validateFolderNotExists('existing-project', testDir); - expect(typeof result).toBe('string'); - expect(result).toContain('already exists'); - }); -}); - -describe('validateCreateOptions', () => { - let testDir: string; - - beforeAll(() => { - testDir = join(tmpdir(), `create-opts-${randomUUID()}`); - mkdirSync(testDir, { recursive: true }); - }); - - afterAll(() => { - rmSync(testDir, { recursive: true, force: true }); - }); - - it('returns invalid when name is missing', () => { - const result = validateCreateOptions({}, testDir); - expect(result.valid).toBe(false); - expect(result.error).toContain('--name is required'); - }); - - it('returns invalid for invalid project name', () => { - const result = validateCreateOptions({ name: '!!invalid!!' }, testDir); - expect(result.valid).toBe(false); - }); - - it('returns invalid when folder already exists', () => { - mkdirSync(join(testDir, 'TakenName'), { recursive: true }); - const result = validateCreateOptions({ name: 'TakenName' }, testDir); - expect(result.valid).toBe(false); - expect(result.error).toContain('already exists'); - }); - - it('returns valid with --no-agent flag', () => { - const result = validateCreateOptions({ name: 'NoAgentProject', agent: false }, testDir); - expect(result.valid).toBe(true); - }); - - it('returns invalid when agent options are incomplete', () => { - const result = validateCreateOptions({ name: 'TestProj', framework: 'Strands' }, testDir); - expect(result.valid).toBe(false); - expect(result.error).toContain('--framework'); - }); - - it('returns invalid when language is missing', () => { +import { validateCreateOptions } from '../validate.js'; +import { mkdtempSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { describe, expect, it } from 'vitest'; + +describe('validateCreateOptions - VPC validation', () => { + const cwd = mkdtempSync(join(tmpdir(), 'agentcore-test-')); + + const baseOptions = { + name: 'TestProject', + language: 'Python', + framework: 'Strands', + modelProvider: 'Bedrock', + memory: 'none', + }; + + it('accepts valid VPC options', () => { const result = validateCreateOptions( - { name: 'TestProj2', framework: 'Strands', modelProvider: 'Bedrock', memory: 'none' }, - testDir + { + ...baseOptions, + networkMode: 'VPC', + subnets: 'subnet-12345678', + securityGroups: 'sg-12345678', + }, + cwd ); - expect(result.valid).toBe(false); - expect(result.error).toContain('--language'); + expect(result.valid).toBe(true); }); - it('returns invalid for invalid language', () => { + it('accepts PUBLIC network mode without VPC options', () => { const result = validateCreateOptions( - { name: 'TestProj3', language: 'Rust', framework: 'Strands', modelProvider: 'Bedrock', memory: 'none' }, - testDir + { + ...baseOptions, + networkMode: 'PUBLIC', + }, + cwd ); - expect(result.valid).toBe(false); - expect(result.error).toContain('Invalid language'); + expect(result.valid).toBe(true); }); - it('returns invalid for TypeScript language', () => { - const result = validateCreateOptions( - { name: 'TestProj4', language: 'TypeScript', framework: 'Strands', modelProvider: 'Bedrock', memory: 'none' }, - testDir - ); - expect(result.valid).toBe(false); - expect(result.error).toContain('TypeScript is not yet supported'); + it('accepts no network mode (defaults to PUBLIC)', () => { + const result = validateCreateOptions({ ...baseOptions }, cwd); + expect(result.valid).toBe(true); }); - it('returns invalid for invalid framework', () => { + it('rejects invalid network mode', () => { const result = validateCreateOptions( - { name: 'TestProj5', language: 'Python', framework: 'InvalidFW', modelProvider: 'Bedrock', memory: 'none' }, - testDir + { + ...baseOptions, + networkMode: 'INVALID', + }, + cwd ); expect(result.valid).toBe(false); - expect(result.error).toContain('Invalid framework'); + expect(result.error).toContain('Invalid network mode'); }); - it('returns invalid for invalid model provider', () => { + it('rejects VPC mode without subnets', () => { const result = validateCreateOptions( - { name: 'TestProj6', language: 'Python', framework: 'Strands', modelProvider: 'InvalidMP', memory: 'none' }, - testDir + { + ...baseOptions, + networkMode: 'VPC', + securityGroups: 'sg-12345678', + }, + cwd ); expect(result.valid).toBe(false); - expect(result.error).toContain('Invalid model provider'); + expect(result.error).toContain('--subnets is required'); }); - it('returns invalid for invalid memory option', () => { + it('rejects VPC mode without security groups', () => { const result = validateCreateOptions( - { name: 'TestProj7', language: 'Python', framework: 'Strands', modelProvider: 'Bedrock', memory: 'invalid' }, - testDir + { + ...baseOptions, + networkMode: 'VPC', + subnets: 'subnet-12345678', + }, + cwd ); expect(result.valid).toBe(false); - expect(result.error).toContain('Invalid memory option'); - }); - - it('returns valid with all valid options', () => { - const result = validateCreateOptions( - { name: 'TestProj8', language: 'Python', framework: 'Strands', modelProvider: 'Bedrock', memory: 'none' }, - testDir - ); - expect(result.valid).toBe(true); + expect(result.error).toContain('--security-groups is required'); }); - it('accepts lowercase flag values and normalizes them', () => { + it('rejects subnets without VPC mode', () => { const result = validateCreateOptions( - { name: 'TestProjLower', language: 'python', framework: 'strands', modelProvider: 'bedrock', memory: 'none' }, - testDir + { + ...baseOptions, + subnets: 'subnet-12345678', + }, + cwd ); - expect(result.valid).toBe(true); + expect(result.valid).toBe(false); + expect(result.error).toContain('only valid with --network-mode VPC'); }); - it('accepts uppercase flag values and normalizes them', () => { + it('rejects security groups without VPC mode', () => { const result = validateCreateOptions( - { name: 'TestProjUpper', language: 'PYTHON', framework: 'STRANDS', modelProvider: 'BEDROCK', memory: 'none' }, - testDir + { + ...baseOptions, + securityGroups: 'sg-12345678', + }, + cwd ); - expect(result.valid).toBe(true); + expect(result.valid).toBe(false); + expect(result.error).toContain('only valid with --network-mode VPC'); }); - it('returns invalid for unsupported framework/model combination', () => { - // GoogleADK only supports certain providers, not all + it('rejects VPC mode missing both subnets and security groups', () => { const result = validateCreateOptions( { - name: 'TestProj9', - language: 'Python', - framework: 'GoogleADK', - modelProvider: 'Bedrock', - memory: 'none', + ...baseOptions, + networkMode: 'VPC', }, - testDir + cwd ); - // This may or may not be valid depending on getSupportedModelProviders - // The test verifies the validation logic runs without error - expect(typeof result.valid).toBe('boolean'); + expect(result.valid).toBe(false); + expect(result.error).toContain('--subnets is required'); }); }); diff --git a/src/cli/commands/create/action.ts b/src/cli/commands/create/action.ts index c99f69dc..ac1c06fa 100644 --- a/src/cli/commands/create/action.ts +++ b/src/cli/commands/create/action.ts @@ -4,6 +4,7 @@ import type { BuildType, DeployedState, ModelProvider, + NetworkMode, SDKFramework, TargetLanguage, } from '../../../schema'; @@ -120,6 +121,9 @@ export interface CreateWithAgentOptions { modelProvider: ModelProvider; apiKey?: string; memory: MemoryOption; + networkMode?: NetworkMode; + subnets?: string[]; + securityGroups?: string[]; skipGit?: boolean; skipPythonSetup?: boolean; onProgress?: ProgressCallback; @@ -135,6 +139,9 @@ export async function createProjectWithAgent(options: CreateWithAgentOptions): P modelProvider, apiKey, memory, + networkMode, + subnets, + securityGroups, skipGit, skipPythonSetup, onProgress, @@ -172,6 +179,9 @@ export async function createProjectWithAgent(options: CreateWithAgentOptions): P apiKey, memory, language, + networkMode, + subnets, + securityGroups, }; // Resolve credential strategy FIRST (new project has no existing credentials) diff --git a/src/cli/commands/create/command.tsx b/src/cli/commands/create/command.tsx index ada69dd8..eada2793 100644 --- a/src/cli/commands/create/command.tsx +++ b/src/cli/commands/create/command.tsx @@ -1,8 +1,9 @@ import { getWorkingDirectory } from '../../../lib'; -import type { BuildType, ModelProvider, SDKFramework, TargetLanguage } from '../../../schema'; +import type { BuildType, ModelProvider, NetworkMode, SDKFramework, TargetLanguage } from '../../../schema'; import { getErrorMessage } from '../../errors'; import { COMMAND_DESCRIPTIONS } from '../../tui/copy'; import { CreateScreen } from '../../tui/screens/create'; +import { parseCommaSeparatedList } from '../shared/vpc-utils'; import { type ProgressCallback, createProject, createProjectWithAgent, getDryRunInfo } from './action'; import type { CreateOptions } from './types'; import { validateCreateOptions } from './validate'; @@ -120,6 +121,9 @@ async function handleCreateCLI(options: CreateOptions): Promise { modelProvider: options.modelProvider as ModelProvider, apiKey: options.apiKey, memory: options.memory as 'none' | 'shortTerm' | 'longAndShortTerm', + networkMode: options.networkMode as NetworkMode | undefined, + subnets: parseCommaSeparatedList(options.subnets), + securityGroups: parseCommaSeparatedList(options.securityGroups), skipGit: options.skipGit, skipPythonSetup: options.skipPythonSetup, onProgress, @@ -152,6 +156,9 @@ export const registerCreate = (program: Command) => { .option('--model-provider ', 'Model provider (Bedrock, Anthropic, OpenAI, Gemini) [non-interactive]') .option('--api-key ', 'API key for non-Bedrock providers [non-interactive]') .option('--memory