diff --git a/integ-tests/add-remove-config-bundle.test.ts b/integ-tests/add-remove-config-bundle.test.ts index c6c37c257..e8d207d5f 100644 --- a/integ-tests/add-remove-config-bundle.test.ts +++ b/integ-tests/add-remove-config-bundle.test.ts @@ -77,12 +77,13 @@ describe('integration: add and remove config-bundle', () => { expect(Object.keys(bundle!.components)).toHaveLength(2); }); - it('adds a config bundle with optional description, branch, and commit message', async () => { + it('adds a config bundle with optional description, branch, commit message, and KMS key', async () => { const components = JSON.stringify({ '{{runtime:MyAgent}}': { configuration: { systemPrompt: 'Placeholder-based bundle' }, }, }); + const kmsKeyArn = 'arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012'; const json = await runSuccess( [ @@ -98,6 +99,8 @@ describe('integration: add and remove config-bundle', () => { 'feature-branch', '--commit-message', 'initial config', + '--kms-key', + kmsKeyArn, '--json', ], project.projectPath @@ -111,6 +114,7 @@ describe('integration: add and remove config-bundle', () => { expect(bundle!.description).toBe('A bundle with all optional fields'); expect(bundle!.branchName).toBe('feature-branch'); expect(bundle!.commitMessage).toBe('initial config'); + expect(bundle!.kmsKeyArn).toBe(kmsKeyArn); }); it('adds a config bundle with placeholder component keys', async () => { @@ -214,6 +218,31 @@ describe('integration: add and remove config-bundle', () => { expect(json.error).toBeDefined(); }); + it('rejects an invalid KMS key ARN', async () => { + const components = JSON.stringify({ + 'arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/rt-kms': { + configuration: { foo: 'bar' }, + }, + }); + + const json = await runFailure( + [ + 'add', + 'config-bundle', + '--name', + 'BadKmsBundle', + '--components', + components, + '--kms-key', + 'not-a-valid-kms-arn', + '--json', + ], + project.projectPath + ); + + expect(json.error).toContain('--kms-key must be a valid KMS key ARN'); + }); + it('rejects bundle name starting with a number', async () => { const components = JSON.stringify({ 'arn:test': { configuration: {} }, diff --git a/schemas/agentcore.schema.v1.json b/schemas/agentcore.schema.v1.json index dedc5b64e..01f21bd64 100644 --- a/schemas/agentcore.schema.v1.json +++ b/schemas/agentcore.schema.v1.json @@ -154,6 +154,13 @@ "executionRoleArn": { "type": "string" }, + "additionalPolicies": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, "authorizerType": { "type": "string", "enum": ["AWS_IAM", "CUSTOM_JWT"] @@ -536,6 +543,183 @@ "required": ["version"], "additionalProperties": false } + }, + "connections": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[a-zA-Z][a-zA-Z0-9_-]{0,63}$" + }, + "to": { + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "memory" + }, + "arn": { + "type": "string", + "pattern": "^arn:[^:]+:bedrock-agentcore:[a-z0-9-]+:\\d{12}:memory\\/.+$" + }, + "namespaces": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "required": ["type", "arn"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "gateway" + }, + "arn": { + "type": "string", + "pattern": "^arn:[^:]+:bedrock-agentcore:[a-z0-9-]+:\\d{12}:gateway\\/.+$" + }, + "outboundAuth": { + "anyOf": [ + { + "type": "object", + "properties": { + "awsIam": { + "type": "object", + "properties": {}, + "additionalProperties": false + } + }, + "required": ["awsIam"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "none": { + "type": "object", + "properties": {}, + "additionalProperties": false + } + }, + "required": ["none"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "oauth": { + "type": "object", + "properties": { + "providerArn": { + "type": "string", + "minLength": 1 + }, + "scopes": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "grantType": { + "type": "string", + "enum": ["CLIENT_CREDENTIALS", "AUTHORIZATION_CODE", "TOKEN_EXCHANGE"] + }, + "customParameters": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["providerArn", "scopes"], + "additionalProperties": false + } + }, + "required": ["oauth"], + "additionalProperties": false + } + ] + } + }, + "required": ["type", "arn"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "runtime" + }, + "arn": { + "type": "string", + "pattern": "^arn:[^:]+:bedrock-agentcore:[a-z0-9-]+:\\d{12}:runtime\\/.+$" + }, + "exec": { + "type": "boolean" + } + }, + "required": ["type", "arn"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "browser" + }, + "arn": { + "type": "string", + "pattern": "^arn:[^:]+:bedrock-agentcore:[a-z0-9-]+:(\\d{12}|aws):browser(-custom)?\\/.+$" + } + }, + "required": ["type"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "codeInterpreter" + }, + "arn": { + "type": "string", + "pattern": "^arn:[^:]+:bedrock-agentcore:[a-z0-9-]+:(\\d{12}|aws):code-interpreter(-custom)?\\/.+$" + } + }, + "required": ["type"], + "additionalProperties": false + } + ] + }, + "access": { + "type": "string", + "enum": ["read", "readwrite"] + }, + "description": { + "type": "string", + "maxLength": 200 + } + }, + "required": ["to"], + "additionalProperties": false + } } }, "required": ["name", "build", "entrypoint", "codeLocation"], @@ -2635,6 +2819,10 @@ "commitMessage": { "type": "string", "maxLength": 500 + }, + "kmsKeyArn": { + "type": "string", + "pattern": "^arn:[^:]+:kms:[a-zA-Z0-9-]*:[0-9]{12}:key\\/[a-zA-Z0-9-]{36}$" } }, "required": ["name", "components"], diff --git a/src/cli/aws/__tests__/agentcore-config-bundles.test.ts b/src/cli/aws/__tests__/agentcore-config-bundles.test.ts index 30a776b13..e707d622f 100644 --- a/src/cli/aws/__tests__/agentcore-config-bundles.test.ts +++ b/src/cli/aws/__tests__/agentcore-config-bundles.test.ts @@ -1,4 +1,4 @@ -import { listConfigurationBundles } from '../agentcore-config-bundles.js'; +import { createConfigurationBundle, listConfigurationBundles } from '../agentcore-config-bundles.js'; import { beforeEach, describe, expect, it, vi } from 'vitest'; const mockFetch = vi.fn(); @@ -43,6 +43,40 @@ describe('agentcore-config-bundles', () => { vi.clearAllMocks(); }); + describe('createConfigurationBundle', () => { + const components = { + 'arn:aws:bedrock-agentcore:us-west-2:123456789012:runtime/myRuntime-abc': { + configuration: { systemPrompt: 'hi' }, + }, + }; + + function parseRequestBody(): Record { + const init = mockFetch.mock.calls[0]![1] as { body: string }; + return JSON.parse(init.body) as Record; + } + + it('includes kmsKeyArn in the request body when provided', async () => { + mockFetch.mockResolvedValue( + mockJsonResponse({ bundleArn: 'arn', bundleId: 'id', versionId: 'v1', createdAt: '2026-01-01' }) + ); + const kmsKeyArn = 'arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012'; + + await createConfigurationBundle({ region: 'us-west-2', bundleName: 'b', components, kmsKeyArn }); + + expect(parseRequestBody().kmsKeyArn).toBe(kmsKeyArn); + }); + + it('omits kmsKeyArn from the request body when not provided', async () => { + mockFetch.mockResolvedValue( + mockJsonResponse({ bundleArn: 'arn', bundleId: 'id', versionId: 'v1', createdAt: '2026-01-01' }) + ); + + await createConfigurationBundle({ region: 'us-west-2', bundleName: 'b', components }); + + expect(parseRequestBody()).not.toHaveProperty('kmsKeyArn'); + }); + }); + describe('listConfigurationBundles', () => { it('returns bundles with createdAt timestamp', async () => { mockFetch.mockResolvedValue( diff --git a/src/cli/aws/agentcore-config-bundles.ts b/src/cli/aws/agentcore-config-bundles.ts index e2941acfe..3da1a7e37 100644 --- a/src/cli/aws/agentcore-config-bundles.ts +++ b/src/cli/aws/agentcore-config-bundles.ts @@ -43,6 +43,7 @@ export interface CreateConfigurationBundleOptions { components: ComponentConfigurationMap; branchName?: string; commitMessage?: string; + kmsKeyArn?: string; createdBy?: { name: string; arn?: string }; } @@ -243,6 +244,7 @@ export async function createConfigurationBundle( components: options.components, ...(options.branchName && { branchName: options.branchName }), ...(options.commitMessage && { commitMessage: options.commitMessage }), + ...(options.kmsKeyArn && { kmsKeyArn: options.kmsKeyArn }), ...(options.createdBy && { createdBy: options.createdBy }), }); diff --git a/src/cli/primitives/ConfigBundlePrimitive.ts b/src/cli/primitives/ConfigBundlePrimitive.ts index 2e41a3432..fc3e328db 100644 --- a/src/cli/primitives/ConfigBundlePrimitive.ts +++ b/src/cli/primitives/ConfigBundlePrimitive.ts @@ -1,7 +1,7 @@ import { ResourceNotFoundError, findConfigRoot, serializeResult, toError } from '../../lib'; import type { Result } from '../../lib/result'; import type { ConfigBundle } from '../../schema'; -import { ConfigBundleSchema } from '../../schema'; +import { ConfigBundleSchema, isValidKmsKeyArn } from '../../schema'; import { getErrorMessage } from '../errors'; import type { RemovalPreview, SchemaChange } from '../operations/remove/types'; import { BasePrimitive } from './BasePrimitive'; @@ -15,6 +15,7 @@ export interface AddConfigBundleOptions { components: Record }>; branchName?: string; commitMessage?: string; + kmsKeyArn?: string; } export type RemovableConfigBundle = RemovableResource; @@ -116,6 +117,7 @@ export class ConfigBundlePrimitive extends BasePrimitive', 'Path to components JSON file (same format as --components)') .option('--branch ', 'Branch name for versioning') .option('--commit-message ', 'Commit message for this version') + .option('--kms-key ', 'KMS key ARN for encrypting the configuration bundle') .option('--json', 'Output as JSON') .action( async (cliOptions: { @@ -125,6 +127,7 @@ export class ConfigBundlePrimitive extends BasePrimitive { try { @@ -151,6 +154,12 @@ export class ConfigBundlePrimitive extends BasePrimitive }>; if (cliOptions.componentsFile) { const raw = readFileSync(cliOptions.componentsFile, 'utf-8'); @@ -168,6 +177,7 @@ export class ConfigBundlePrimitive extends BasePrimitive }>; branchName?: string; commitMessage?: string; + kmsKeyArn?: string; } export function useCreateConfigBundle() { @@ -23,6 +24,7 @@ export function useCreateConfigBundle() { components: config.components, branchName: config.branchName, commitMessage: config.commitMessage, + kmsKeyArn: config.kmsKeyArn, }); if (!addResult.success) { throw new Error(addResult.error?.message ?? 'Failed to create configuration bundle'); diff --git a/src/cli/tui/screens/config-bundle/AddConfigBundleFlow.tsx b/src/cli/tui/screens/config-bundle/AddConfigBundleFlow.tsx index 511265ece..b9067656e 100644 --- a/src/cli/tui/screens/config-bundle/AddConfigBundleFlow.tsx +++ b/src/cli/tui/screens/config-bundle/AddConfigBundleFlow.tsx @@ -110,6 +110,7 @@ export function AddConfigBundleFlow({ components: config.components, branchName: config.branchName || 'mainline', commitMessage: config.commitMessage || `Create ${config.name}`, + kmsKeyArn: config.kmsKeyArn || undefined, }).then(result => { if (result.ok) { setFlow(prev => { diff --git a/src/cli/tui/screens/config-bundle/AddConfigBundleScreen.tsx b/src/cli/tui/screens/config-bundle/AddConfigBundleScreen.tsx index aab3b555a..1b21d7a6f 100644 --- a/src/cli/tui/screens/config-bundle/AddConfigBundleScreen.tsx +++ b/src/cli/tui/screens/config-bundle/AddConfigBundleScreen.tsx @@ -1,4 +1,4 @@ -import { ConfigBundleNameSchema } from '../../../../schema'; +import { ConfigBundleNameSchema, isValidKmsKeyArn } from '../../../../schema'; import type { SelectableItem } from '../../components'; import { ConfirmReview, Panel, Screen, StepIndicator, TextInput, WizardSelect } from '../../components'; import { HELP_TEXT } from '../../constants'; @@ -81,6 +81,7 @@ export function AddConfigBundleScreen({ const isAddAnotherStep = wizard.step === 'addAnother'; const isBranchNameStep = wizard.step === 'branchName'; const isCommitMessageStep = wizard.step === 'commitMessage'; + const isKmsKeyStep = wizard.step === 'kmsKey'; const isConfirmStep = wizard.step === 'confirm'; const componentTypeNav = useListNavigation({ @@ -278,6 +279,19 @@ export function AddConfigBundleScreen({ /> )} + {isKmsKeyStep && ( + wizard.goBack()} + customValidation={value => value === '' || isValidKmsKeyArn(value) || 'Must be a valid KMS key ARN'} + /> + )} + {isConfirmStep && ( )} diff --git a/src/cli/tui/screens/config-bundle/__tests__/useAddConfigBundleWizard.test.tsx b/src/cli/tui/screens/config-bundle/__tests__/useAddConfigBundleWizard.test.tsx index 19854a95d..7a09c8075 100644 --- a/src/cli/tui/screens/config-bundle/__tests__/useAddConfigBundleWizard.test.tsx +++ b/src/cli/tui/screens/config-bundle/__tests__/useAddConfigBundleWizard.test.tsx @@ -91,6 +91,46 @@ describe('useAddConfigBundleWizard — add-another back-navigation (BUG TUI-B)', }); }); +describe('useAddConfigBundleWizard — KMS key step', () => { + const KMS_ARN = 'arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012'; + + function advanceToKmsKey(ref: React.RefObject) { + advanceToAddAnother(ref); + act(() => ref.current!.wizard.doneAddingComponents()); + act(() => ref.current!.wizard.setBranchName('mainline')); + act(() => ref.current!.wizard.setCommitMessage('msg')); + } + + it('commitMessage advances to the kmsKey step', () => { + const { ref } = setup(); + advanceToKmsKey(ref); + expect(ref.current!.wizard.step).toBe('kmsKey'); + }); + + it('setKmsKey stores the ARN and advances to confirm', () => { + const { ref } = setup(); + advanceToKmsKey(ref); + act(() => ref.current!.wizard.setKmsKey(KMS_ARN)); + expect(ref.current!.wizard.step).toBe('confirm'); + expect(ref.current!.wizard.config.kmsKeyArn).toBe(KMS_ARN); + }); + + it('skipping the kmsKey step (empty) leaves kmsKeyArn empty and advances to confirm', () => { + const { ref } = setup(); + advanceToKmsKey(ref); + act(() => ref.current!.wizard.setKmsKey('')); + expect(ref.current!.wizard.step).toBe('confirm'); + expect(ref.current!.wizard.config.kmsKeyArn).toBe(''); + }); + + it('goBack from kmsKey returns to commitMessage', () => { + const { ref } = setup(); + advanceToKmsKey(ref); + act(() => ref.current!.wizard.goBack()); + expect(ref.current!.wizard.step).toBe('commitMessage'); + }); +}); + describe('useAddConfigBundleWizard — custom ARN component (Part 1)', () => { /** Drive the wizard to the componentType step. */ function advanceToComponentType(ref: React.RefObject) { diff --git a/src/cli/tui/screens/config-bundle/types.ts b/src/cli/tui/screens/config-bundle/types.ts index aed0c56b9..8112fb0fa 100644 --- a/src/cli/tui/screens/config-bundle/types.ts +++ b/src/cli/tui/screens/config-bundle/types.ts @@ -14,6 +14,7 @@ export type AddConfigBundleStep = | 'addAnother' | 'branchName' | 'commitMessage' + | 'kmsKey' | 'confirm'; export type ComponentType = 'runtime' | 'gateway' | 'custom'; @@ -34,6 +35,8 @@ export interface AddConfigBundleConfig { componentsRaw: string; branchName: string; commitMessage: string; + /** Optional KMS key ARN for customer-managed encryption of the bundle. */ + kmsKeyArn: string; /** Currently selected component type in wizard. */ currentComponentType?: ComponentType; /** Currently selected component ARN in wizard. */ @@ -50,6 +53,7 @@ export const CONFIG_BUNDLE_STEP_LABELS: Record = { addAnother: 'More?', branchName: 'Branch', commitMessage: 'Message', + kmsKey: 'KMS Key', confirm: 'Confirm', }; diff --git a/src/cli/tui/screens/config-bundle/useAddConfigBundleWizard.ts b/src/cli/tui/screens/config-bundle/useAddConfigBundleWizard.ts index ecc8eb7f0..2b2533823 100644 --- a/src/cli/tui/screens/config-bundle/useAddConfigBundleWizard.ts +++ b/src/cli/tui/screens/config-bundle/useAddConfigBundleWizard.ts @@ -12,6 +12,7 @@ const ALL_STEPS: AddConfigBundleStep[] = [ 'addAnother', 'branchName', 'commitMessage', + 'kmsKey', 'confirm', ]; @@ -23,6 +24,7 @@ function getDefaultConfig(): AddConfigBundleConfig { componentsRaw: '', branchName: 'mainline', commitMessage: '', + kmsKeyArn: '', }; } @@ -112,6 +114,11 @@ export function useAddConfigBundleWizard() { const setCommitMessage = useCallback((commitMessage: string) => { setConfig(c => ({ ...c, commitMessage })); + setStep('kmsKey'); + }, []); + + const setKmsKey = useCallback((kmsKeyArn: string) => { + setConfig(c => ({ ...c, kmsKeyArn })); setStep('confirm'); }, []); @@ -137,6 +144,7 @@ export function useAddConfigBundleWizard() { doneAddingComponents, setBranchName, setCommitMessage, + setKmsKey, reset, }; } diff --git a/src/schema/schemas/primitives/config-bundle.ts b/src/schema/schemas/primitives/config-bundle.ts index 06702bd3c..ba5032482 100644 --- a/src/schema/schemas/primitives/config-bundle.ts +++ b/src/schema/schemas/primitives/config-bundle.ts @@ -1,3 +1,4 @@ +import { KmsKeyArnSchema } from './evaluator'; import { z } from 'zod'; // ============================================================================ @@ -44,6 +45,8 @@ export const ConfigBundleSchema = z.object({ branchName: z.string().max(128).optional(), /** Optional commit message for this version. */ commitMessage: z.string().max(500).optional(), + /** Optional KMS key ARN for customer-managed encryption of the bundle. */ + kmsKeyArn: KmsKeyArnSchema.optional(), }); export type ConfigBundle = z.infer;