Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion integ-tests/add-remove-config-bundle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
[
Expand All @@ -98,6 +99,8 @@ describe('integration: add and remove config-bundle', () => {
'feature-branch',
'--commit-message',
'initial config',
'--kms-key',
kmsKeyArn,
'--json',
],
project.projectPath
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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: {} },
Expand Down
36 changes: 35 additions & 1 deletion src/cli/aws/__tests__/agentcore-config-bundles.test.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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<string, unknown> {
const init = mockFetch.mock.calls[0]![1] as { body: string };
return JSON.parse(init.body) as Record<string, unknown>;
}

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(
Expand Down
2 changes: 2 additions & 0 deletions src/cli/aws/agentcore-config-bundles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export interface CreateConfigurationBundleOptions {
components: ComponentConfigurationMap;
branchName?: string;
commitMessage?: string;
kmsKeyArn?: string;
createdBy?: { name: string; arn?: string };
}

Expand Down Expand Up @@ -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 }),
});

Expand Down
13 changes: 12 additions & 1 deletion src/cli/primitives/ConfigBundlePrimitive.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -15,6 +15,7 @@ export interface AddConfigBundleOptions {
components: Record<string, { configuration: Record<string, unknown> }>;
branchName?: string;
commitMessage?: string;
kmsKeyArn?: string;
}

export type RemovableConfigBundle = RemovableResource;
Expand Down Expand Up @@ -116,6 +117,7 @@ export class ConfigBundlePrimitive extends BasePrimitive<AddConfigBundleOptions,
.option('--components-file <path>', 'Path to components JSON file (same format as --components)')
.option('--branch <name>', 'Branch name for versioning')
.option('--commit-message <text>', 'Commit message for this version')
.option('--kms-key <arn>', 'KMS key ARN for encrypting the configuration bundle')
.option('--json', 'Output as JSON')
.action(
async (cliOptions: {
Expand All @@ -125,6 +127,7 @@ export class ConfigBundlePrimitive extends BasePrimitive<AddConfigBundleOptions,
componentsFile?: string;
branch?: string;
commitMessage?: string;
kmsKey?: string;
json?: boolean;
}) => {
try {
Expand All @@ -151,6 +154,12 @@ export class ConfigBundlePrimitive extends BasePrimitive<AddConfigBundleOptions,
fail('Either --components or --components-file is required');
}

if (cliOptions.kmsKey && !isValidKmsKeyArn(cliOptions.kmsKey)) {
fail(
'--kms-key must be a valid KMS key ARN (e.g. arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012)'
);
}

let components: Record<string, { configuration: Record<string, unknown> }>;
if (cliOptions.componentsFile) {
const raw = readFileSync(cliOptions.componentsFile, 'utf-8');
Expand All @@ -168,6 +177,7 @@ export class ConfigBundlePrimitive extends BasePrimitive<AddConfigBundleOptions,
components,
branchName: cliOptions.branch,
commitMessage: cliOptions.commitMessage,
kmsKeyArn: cliOptions.kmsKey,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent up-front validation + missing telemetry on this handler.

(1) EvaluatorPrimitive.ts:298 validates kmsKeyArn with isValidKmsKeyArn right before calling this.add(...) and fails with a clear, flag-named message. Here, an invalid ARN falls through to writeProjectSpec's Zod validation, producing a generic schema error. The integ test (integ-tests/add-remove-config-bundle.test.ts:243) only asserts json.error is defined, so the worse message wouldn't be caught. Either:

  • Mirror EvaluatorPrimitive and add an explicit isValidKmsKeyArn check just before this this.add({...}) call that calls fail('--kms-key must be a valid KMS key ARN ...').
  • Or keep relying on the Zod schema, but tighten the integ test to assert the actual error message users see.

(2) Missing telemetry instrumentation. Per src/cli/telemetry/README.md, new features should be instrumented. add.config-bundle isn't in COMMAND_SCHEMAS (src/cli/telemetry/schemas/command-run.ts) at all today, and this PR is a natural opportunity to add it since we now have an interesting attribute to track (CMK adoption). Suggested:

// command-run.ts
const AddConfigBundleAttrs = safeSchema({
  has_kms_key: z.boolean(),
  component_count: Count,
});
// ...
'add.config-bundle': AddConfigBundleAttrs,

Then wrap this non-interactive handler in runCliCommand('add.config-bundle', !!cliOptions.json, async () => { ...; return { has_kms_key: !!cliOptions.kmsKey, component_count: Object.keys(components).length }; }).

});

if (cliOptions.json) {
Expand Down Expand Up @@ -227,6 +237,7 @@ export class ConfigBundlePrimitive extends BasePrimitive<AddConfigBundleOptions,
components: options.components,
branchName: options.branchName ?? 'mainline',
...(options.commitMessage && { commitMessage: options.commitMessage }),
...(options.kmsKeyArn && { kmsKeyArn: options.kmsKeyArn }),
};

project.configBundles ??= [];
Expand Down
2 changes: 2 additions & 0 deletions src/cli/tui/hooks/useCreateConfigBundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ interface CreateConfigBundleConfig {
components: Record<string, { configuration: Record<string, unknown> }>;
branchName?: string;
commitMessage?: string;
kmsKeyArn?: string;
}

export function useCreateConfigBundle() {
Expand All @@ -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');
Expand Down
1 change: 1 addition & 0 deletions src/cli/tui/screens/config-bundle/AddConfigBundleFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
17 changes: 16 additions & 1 deletion src/cli/tui/screens/config-bundle/AddConfigBundleScreen.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -278,6 +279,19 @@ export function AddConfigBundleScreen({
/>
)}

{isKmsKeyStep && (
<TextInput
key="kmsKey"
prompt="KMS key ARN (optional, press Enter to skip)"
placeholder="arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012"
initialValue=""
allowEmpty
onSubmit={wizard.setKmsKey}
onCancel={() => wizard.goBack()}
customValidation={value => value === '' || isValidKmsKeyArn(value) || 'Must be a valid KMS key ARN'}
/>
)}

{isConfirmStep && (
<ConfirmReview
fields={[
Expand All @@ -290,6 +304,7 @@ export function AddConfigBundleScreen({
})),
{ label: 'Branch', value: wizard.config.branchName || 'mainline' },
{ label: 'Message', value: wizard.config.commitMessage || `Create ${wizard.config.name}` },
...(wizard.config.kmsKeyArn ? [{ label: 'KMS Key', value: wizard.config.kmsKeyArn }] : []),
]}
/>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<HarnessHandle | null>) {
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<HarnessHandle | null>) {
Expand Down
4 changes: 4 additions & 0 deletions src/cli/tui/screens/config-bundle/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export type AddConfigBundleStep =
| 'addAnother'
| 'branchName'
| 'commitMessage'
| 'kmsKey'
| 'confirm';

export type ComponentType = 'runtime' | 'gateway' | 'custom';
Expand All @@ -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. */
Expand All @@ -50,6 +53,7 @@ export const CONFIG_BUNDLE_STEP_LABELS: Record<AddConfigBundleStep, string> = {
addAnother: 'More?',
branchName: 'Branch',
commitMessage: 'Message',
kmsKey: 'KMS Key',
confirm: 'Confirm',
};

Expand Down
8 changes: 8 additions & 0 deletions src/cli/tui/screens/config-bundle/useAddConfigBundleWizard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const ALL_STEPS: AddConfigBundleStep[] = [
'addAnother',
'branchName',
'commitMessage',
'kmsKey',
'confirm',
];

Expand All @@ -23,6 +24,7 @@ function getDefaultConfig(): AddConfigBundleConfig {
componentsRaw: '',
branchName: 'mainline',
commitMessage: '',
kmsKeyArn: '',
};
}

Expand Down Expand Up @@ -112,6 +114,11 @@ export function useAddConfigBundleWizard() {

const setCommitMessage = useCallback((commitMessage: string) => {
setConfig(c => ({ ...c, commitMessage }));
setStep('kmsKey');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor UX note (non-blocking): the wizard now unconditionally shows the KMS step to every user creating a config bundle, even though CMK is an advanced/opt-in feature. Given the prompt explicitly says "press Enter to skip", this is probably acceptable, but consider whether this should be gated behind a yes/no question ("Encrypt with a customer-managed KMS key?") so the common path doesn't grow an extra prompt. Feel free to ignore if this was a deliberate design choice.

}, []);

const setKmsKey = useCallback((kmsKeyArn: string) => {
setConfig(c => ({ ...c, kmsKeyArn }));
setStep('confirm');
}, []);

Expand All @@ -137,6 +144,7 @@ export function useAddConfigBundleWizard() {
doneAddingComponents,
setBranchName,
setCommitMessage,
setKmsKey,
reset,
};
}
3 changes: 3 additions & 0 deletions src/schema/schemas/primitives/config-bundle.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { KmsKeyArnSchema } from './evaluator';
import { z } from 'zod';

// ============================================================================
Expand Down Expand Up @@ -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(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reusing KmsKeyArnSchema from ./evaluator is fine, but that schema's JSDoc in evaluator.ts:128-130 explicitly says it's the pattern accepted by the AgentCore Evaluation service, and notes alias ARNs are unsupported for evaluator encryption. Since this is now shared across services (evaluator + config bundle), please either:

  1. Move KmsKeyArnSchema/KMS_KEY_ARN_PATTERN/isValidKmsKeyArn to a shared/generic location with a service-agnostic docstring, or
  2. At minimum, update the JSDoc on KmsKeyArnSchema in evaluator.ts to reflect that it's the general KMS key-ARN pattern used across multiple AgentCore resources, and confirm the Configuration Bundle service has the same alias-ARN constraint as the Evaluator service (so users aren't getting a needlessly stricter check than the service requires).

});

export type ConfigBundle = z.infer<typeof ConfigBundleSchema>;
Loading