Skip to content
Closed
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
36 changes: 18 additions & 18 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,24 +175,24 @@ on the next deployment.
}
```

| Field | Required | Description |
| ------------------------- | -------- | --------------------------------------------------------------- |
| `name` | Yes | Agent name (1-48 chars, alphanumeric + underscore) |
| `build` | Yes | `"CodeZip"` or `"Container"` |
| `entrypoint` | Yes | Entry file (e.g., `main.py` or `main.py:handler`) |
| `codeLocation` | Yes | Directory containing agent code |
| `runtimeVersion` | Yes | Runtime version (see below) |
| `networkMode` | No | `"PUBLIC"` (default) or `"VPC"` |
| `networkConfig` | No | VPC configuration (subnets, security groups) |
| `protocol` | No | `"HTTP"` (default), `"MCP"`, or `"A2A"` |
| `envVars` | No | Custom environment variables |
| `instrumentation` | No | OpenTelemetry settings |
| `authorizerType` | No | `"AWS_IAM"` or `"CUSTOM_JWT"` |
| `authorizerConfiguration` | No | JWT authorizer settings (for `CUSTOM_JWT`) |
| `requestHeaderAllowlist` | No | Headers to forward to the agent |
| `lifecycleConfiguration` | No | Runtime session lifecycle settings (idle timeout, max lifetime) |
| `executionRoleArn` | No | ARN of an existing IAM execution role (skips CDK-managed role) |
| `tags` | No | Agent-level tags |
| Field | Required | Description |
| ------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name` | Yes | Agent name (1-48 chars, alphanumeric + underscore) |
| `build` | Yes | `"CodeZip"` or `"Container"` |
| `entrypoint` | Yes | Entry file (e.g., `main.py` or `main.py:handler`) |
| `codeLocation` | Yes | Directory containing agent code |
| `runtimeVersion` | Yes | Runtime version (see below) |
| `networkMode` | No | `"PUBLIC"` (default) or `"VPC"` |
| `networkConfig` | No | VPC configuration (subnets, security groups) |
| `protocol` | No | `"HTTP"` (default), `"MCP"`, or `"A2A"` |
| `envVars` | No | Custom environment variables |
| `instrumentation` | No | OpenTelemetry settings |
| `authorizerType` | No | `"AWS_IAM"` or `"CUSTOM_JWT"` |
| `authorizerConfiguration` | No | JWT authorizer settings (for `CUSTOM_JWT`) |
| `requestHeaderAllowlist` | No | Headers to forward to the agent (max 20). Accepts any non-restricted HTTP header (alphanumerics/hyphens/underscores), `Authorization`, or names starting with `X-Amzn-Bedrock-AgentCore-Runtime-Custom-`. See the [AWS docs](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-header-allowlist.html). |
| `lifecycleConfiguration` | No | Runtime session lifecycle settings (idle timeout, max lifetime) |
| `executionRoleArn` | No | ARN of an existing IAM execution role (skips CDK-managed role) |
| `tags` | No | Agent-level tags |

### Runtime Versions

Expand Down
98 changes: 69 additions & 29 deletions src/cli/commands/shared/__tests__/header-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,11 @@ describe('normalizeHeaderName', () => {
);
});

it('auto-prefixes a bare suffix like "MyHeader"', () => {
expect(normalizeHeaderName('MyHeader')).toBe('X-Amzn-Bedrock-AgentCore-Runtime-Custom-MyHeader');
});

it('auto-prefixes suffix with hyphens like "My-Custom-Header"', () => {
expect(normalizeHeaderName('My-Custom-Header')).toBe('X-Amzn-Bedrock-AgentCore-Runtime-Custom-My-Custom-Header');
it('preserves arbitrary header names without auto-prefixing', () => {
expect(normalizeHeaderName('MyHeader')).toBe('MyHeader');
expect(normalizeHeaderName('My-Custom-Header')).toBe('My-Custom-Header');
expect(normalizeHeaderName('X-Custom-Signature')).toBe('X-Custom-Signature');
expect(normalizeHeaderName('X-Api-Key')).toBe('X-Api-Key');
});
});

Expand All @@ -52,15 +51,18 @@ describe('parseAndNormalizeHeaders', () => {

it('splits comma-separated and normalizes', () => {
const result = parseAndNormalizeHeaders('MyHeader, authorization, Another-Header');
expect(result).toEqual([
'X-Amzn-Bedrock-AgentCore-Runtime-Custom-MyHeader',
'Authorization',
'X-Amzn-Bedrock-AgentCore-Runtime-Custom-Another-Header',
]);
expect(result).toEqual(['MyHeader', 'Authorization', 'Another-Header']);
});

it('deduplicates after normalization (case-insensitive)', () => {
const result = parseAndNormalizeHeaders('MyHeader, myheader, MYHEADER');
expect(result).toEqual(['MyHeader']);
});

it('deduplicates after normalization', () => {
const result = parseAndNormalizeHeaders('MyHeader, X-Amzn-Bedrock-AgentCore-Runtime-Custom-MyHeader');
it('deduplicates the AgentCore custom prefix variations', () => {
const result = parseAndNormalizeHeaders(
'X-Amzn-Bedrock-AgentCore-Runtime-Custom-MyHeader, x-amzn-bedrock-agentcore-runtime-custom-MyHeader'
);
expect(result).toEqual(['X-Amzn-Bedrock-AgentCore-Runtime-Custom-MyHeader']);
});

Expand All @@ -71,11 +73,7 @@ describe('parseAndNormalizeHeaders', () => {

it('trims whitespace around values', () => {
const result = parseAndNormalizeHeaders(' MyHeader , authorization , Another-Header ');
expect(result).toEqual([
'X-Amzn-Bedrock-AgentCore-Runtime-Custom-MyHeader',
'Authorization',
'X-Amzn-Bedrock-AgentCore-Runtime-Custom-Another-Header',
]);
expect(result).toEqual(['MyHeader', 'Authorization', 'Another-Header']);
});
});

Expand All @@ -85,11 +83,14 @@ describe('validateHeaderAllowlist', () => {
expect(validateHeaderAllowlist(' ')).toEqual({ success: true });
});

it('returns success for valid custom header suffix', () => {
it('accepts arbitrary custom headers (no longer requires the AgentCore prefix)', () => {
expect(validateHeaderAllowlist('MyHeader')).toEqual({ success: true });
expect(validateHeaderAllowlist('X-Custom-Signature')).toEqual({ success: true });
expect(validateHeaderAllowlist('X-Api-Key')).toEqual({ success: true });
expect(validateHeaderAllowlist('Some_Header_With_Underscores')).toEqual({ success: true });
});

it('returns success for valid full header name', () => {
it('returns success for valid full AgentCore custom header name', () => {
expect(validateHeaderAllowlist('X-Amzn-Bedrock-AgentCore-Runtime-Custom-MyHeader')).toEqual({ success: true });
});

Expand All @@ -99,9 +100,9 @@ describe('validateHeaderAllowlist', () => {
});

it('returns success for mixed valid headers', () => {
expect(validateHeaderAllowlist('Authorization, MyHeader, X-Amzn-Bedrock-AgentCore-Runtime-Custom-Another')).toEqual(
{ success: true }
);
expect(
validateHeaderAllowlist('Authorization, X-Custom-Signature, X-Amzn-Bedrock-AgentCore-Runtime-Custom-Another')
).toEqual({ success: true });
});

it('returns error when exceeding max 20 headers', () => {
Expand All @@ -127,19 +128,51 @@ describe('validateHeaderAllowlist', () => {
expect(result.success).toBe(false);
expect(result.error).toContain('Invalid header name');
});

it('rejects restricted headers (Cookie, Host, Accept, Content-Type, etc.)', () => {
for (const restricted of ['Cookie', 'Host', 'Accept', 'Content-Type', 'User-Agent', 'Connection']) {
const result = validateHeaderAllowlist(restricted);
expect(result.success, `expected "${restricted}" to be rejected`).toBe(false);
expect(result.error).toMatch(/restricted/i);
}
});

it('rejects restricted headers case-insensitively', () => {
const result = validateHeaderAllowlist('cookie');
expect(result.success).toBe(false);
expect(result.error).toMatch(/restricted/i);
});

it('rejects headers starting with x-amz-', () => {
const result = validateHeaderAllowlist('X-Amz-Date');
expect(result.success).toBe(false);
expect(result.error).toMatch(/x-amz-/i);
});

it('rejects headers starting with x-amzn- that are not the AgentCore custom prefix', () => {
const result = validateHeaderAllowlist('X-Amzn-Foo');
expect(result.success).toBe(false);
expect(result.error).toMatch(/x-amzn-/i);
});

it('rejects duplicate headers (case-insensitive)', () => {
const result = validateHeaderAllowlist('MyHeader, myheader');
expect(result.success).toBe(false);
expect(result.error).toMatch(/[Dd]uplicate/);
});
});

describe('parseHeaderFlag', () => {
it('parses "Key: Value" format', () => {
expect(parseHeaderFlag('MyHeader: some-value')).toEqual({
name: 'X-Amzn-Bedrock-AgentCore-Runtime-Custom-MyHeader',
name: 'MyHeader',
value: 'some-value',
});
});

it('parses "Key:Value" format without space', () => {
expect(parseHeaderFlag('MyHeader:some-value')).toEqual({
name: 'X-Amzn-Bedrock-AgentCore-Runtime-Custom-MyHeader',
name: 'MyHeader',
value: 'some-value',
});
});
Expand All @@ -151,13 +184,20 @@ describe('parseHeaderFlag', () => {
});
});

it('normalizes header names', () => {
it('normalizes Authorization casing', () => {
expect(parseHeaderFlag('authorization: token')).toEqual({
name: 'Authorization',
value: 'token',
});
});

it('preserves case for arbitrary headers (no auto-prefixing)', () => {
expect(parseHeaderFlag('X-Custom-Signature: abc123')).toEqual({
name: 'X-Custom-Signature',
value: 'abc123',
});
});

it('returns null for missing colon', () => {
expect(parseHeaderFlag('no-colon-here')).toBeNull();
});
Expand All @@ -168,7 +208,7 @@ describe('parseHeaderFlag', () => {

it('trims whitespace from key and value', () => {
expect(parseHeaderFlag(' MyHeader : some-value ')).toEqual({
name: 'X-Amzn-Bedrock-AgentCore-Runtime-Custom-MyHeader',
name: 'MyHeader',
value: 'some-value',
});
});
Expand All @@ -178,7 +218,7 @@ describe('parseHeaderFlags', () => {
it('parses multiple headers', () => {
const result = parseHeaderFlags(['MyHeader: value1', 'Authorization: Bearer token']);
expect(result).toEqual({
'X-Amzn-Bedrock-AgentCore-Runtime-Custom-MyHeader': 'value1',
MyHeader: 'value1',
Authorization: 'Bearer token',
});
});
Expand All @@ -190,7 +230,7 @@ describe('parseHeaderFlags', () => {
it('last value wins for duplicate keys', () => {
const result = parseHeaderFlags(['MyHeader: first', 'MyHeader: second']);
expect(result).toEqual({
'X-Amzn-Bedrock-AgentCore-Runtime-Custom-MyHeader': 'second',
MyHeader: 'second',
});
});

Expand Down
47 changes: 36 additions & 11 deletions src/cli/commands/shared/header-utils.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import {
HEADER_ALLOWLIST_PREFIX as HEADER_ALLOWLIST_PREFIX_FROM_SCHEMA,
MAX_HEADER_ALLOWLIST_SIZE as MAX_HEADER_ALLOWLIST_SIZE_FROM_SCHEMA,
getHeaderRejectionReason,
} from '../../../schema/schemas/agent-env';

export const HEADER_ALLOWLIST_PREFIX = HEADER_ALLOWLIST_PREFIX_FROM_SCHEMA;
export const MAX_HEADER_ALLOWLIST_SIZE = MAX_HEADER_ALLOWLIST_SIZE_FROM_SCHEMA;

const HEADER_NAME_PATTERN = /^[A-Za-z0-9-]+$/;

/**
* Normalize a header name according to AgentCore Runtime rules:
* - "Authorization" (case-insensitive) -> "Authorization"
* - Headers already starting with the prefix (case-insensitive) -> canonical prefix + original suffix
* - Other headers -> prepend the prefix
* - Headers already starting with the AgentCore custom prefix
* (case-insensitive) -> canonical prefix + original suffix
* - Otherwise -> the input is returned unchanged. The allowlist now accepts any
* non-restricted HTTP header name (alphanumerics, hyphens, underscores), so
* we no longer auto-prepend the AgentCore custom prefix.
*/
export function normalizeHeaderName(input: string): string {
if (input.toLowerCase() === 'authorization') {
Expand All @@ -21,12 +23,12 @@ export function normalizeHeaderName(input: string): string {
if (input.toLowerCase().startsWith(HEADER_ALLOWLIST_PREFIX.toLowerCase())) {
return `${HEADER_ALLOWLIST_PREFIX}${input.slice(HEADER_ALLOWLIST_PREFIX.length)}`;
}
return `${HEADER_ALLOWLIST_PREFIX}${input}`;
return input;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Breaking behavior change for agentcore invoke -H and agentcore dev -H.

Because parseHeaderFlag (→ normalizeHeaderName) is on the hot path for the -H / --header flags in both src/cli/commands/invoke/command.tsx and src/cli/commands/dev/command.tsx, and the resulting headers are forwarded as literal HTTP headers to the runtime (see invoke/action.ts lines 161/245/325/369/426/460), this change alters what gets sent on the wire. Previously:

agentcore invoke -H "MyHeader: val"

sent X-Amzn-Bedrock-AgentCore-Runtime-Custom-MyHeader: val. After this PR it sends MyHeader: val verbatim. Users with an existing deployed runtime whose allowlist still contains X-Amzn-Bedrock-AgentCore-Runtime-Custom-MyHeader will silently stop receiving the header at the agent — the invoke will appear to succeed but the agent won't see the value. This is a behavior break that is distinct from the schema relaxation and isn't called out in the PR description or CHANGELOG.md.

Options to address:

  1. Preserve auto-prefix only in parseHeaderFlag (the invoke/dev CLI path) and remove it only from the allowlist-normalization path. The two paths don't actually need to share normalization — one configures the runtime, the other talks to it.
  2. Keep this change but add an explicit "Breaking changes" section to the PR body + CHANGELOG.md, and ideally have parseHeaderFlag emit a one-time warning when a bare name (not Authorization, not X-Amzn-Bedrock-…-Custom-) is passed via -H, suggesting the user either add the full prefix or the literal name to their allowlist.
  3. If (1) is rejected because we want the CLI to always send what the user typed, at least document the migration explicitly so users don't debug silently-dropped headers.

}

/**
* Parse a comma-separated string of header names, normalize each, and deduplicate.
* Returns an array of normalized header names.
* Parse a comma-separated string of header names, normalize each, and deduplicate
* (case-insensitive; first occurrence wins).
*/
export function parseAndNormalizeHeaders(input: string): string[] {
const headers = input
Expand All @@ -35,7 +37,16 @@ export function parseAndNormalizeHeaders(input: string): string[] {
.filter(Boolean)
.map(normalizeHeaderName);

return Array.from(new Set(headers));
const seen = new Set<string>();
const result: string[] = [];
for (const h of headers) {
const key = h.toLowerCase();
if (!seen.has(key)) {
seen.add(key);
result.push(h);
}
}
return result;
}

/**
Expand All @@ -52,16 +63,30 @@ export function validateHeaderAllowlist(value: string): { success: boolean; erro
.split(',')
.map(s => s.trim())
.filter(Boolean);

// Validate each header name against the allowlist rules (regex + restricted
// names + reserved prefixes).
for (const name of rawNames) {
if (!HEADER_NAME_PATTERN.test(name)) {
const rejection = getHeaderRejectionReason(normalizeHeaderName(name));
if (rejection) {
return { success: false, error: rejection };
}
}

// Detect duplicates (case-insensitive, after normalization).
const headers = parseAndNormalizeHeaders(value);
const seen = new Set<string>();
for (const raw of rawNames) {
const key = normalizeHeaderName(raw).toLowerCase();
if (seen.has(key)) {
return {
success: false,
error: `Invalid header name "${name}". Header names may only contain letters, numbers, and hyphens.`,
error: `Duplicate header (case-insensitive): "${raw}".`,
};
}
seen.add(key);

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: validateHeaderAllowlist now computes headers = parseAndNormalizeHeaders(value) on line 77 (which already dedups case-insensitively), then runs a second manual dedup loop over rawNames on lines 79–88 to produce the duplicate-rejection error. Only headers.length from the first call is used after that. Functionally fine today, but the two passes will silently disagree if parseAndNormalizeHeaders's dedup semantics ever change. Worth collapsing to a single enumeration of rawNames that produces both the count and the duplicate diagnostic — especially since there are no direct tests for the schema's dedup yet (see my other comment).

}

const headers = parseAndNormalizeHeaders(value);
if (headers.length > MAX_HEADER_ALLOWLIST_SIZE) {
return {
success: false,
Expand Down
2 changes: 1 addition & 1 deletion src/cli/primitives/AgentPrimitive.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ export class AgentPrimitive extends BasePrimitive<AddAgentOptions, RemovableReso
.option('--client-secret <secret>', 'OAuth client secret [non-interactive]')
.option(
'--request-header-allowlist <headers>',
'Comma-separated list of custom header names to allow (auto-prefixed with X-Amzn-Bedrock-AgentCore-Runtime-Custom-) [non-interactive]'
'Comma-separated list of request header names to forward to the runtime. Accepts any non-restricted HTTP header (alphanumerics, hyphens, underscores), "Authorization", or names starting with X-Amzn-Bedrock-AgentCore-Runtime-Custom-. Max 20. [non-interactive]'
)
.option(
'--idle-timeout <seconds>',
Expand Down
Loading
Loading