Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
723e96a
feat(schema): add optional networkConfig.vpcId for Container VPC builds
aidandaly24 Jun 30, 2026
d7db7b9
feat(cli): add --vpc-id validation for Container VPC builds
aidandaly24 Jun 30, 2026
f9155f0
feat(cli): collect and persist networkConfig.vpcId across create/impo…
aidandaly24 Jun 30, 2026
ab56c25
fix(schema): require networkConfig.vpcId for container harness builds…
aidandaly24 Jun 30, 2026
1369d69
feat(cli): collect vpcId in create/add-agent/add-harness TUI for Cont…
aidandaly24 Jun 30, 2026
ec83dd3
feat(cli): add --vpc-id to add harness and cap container-build securi…
aidandaly24 Jun 30, 2026
0e91fe0
fix(cli): persist vpcId from the interactive create wizard for Contai…
aidandaly24 Jun 30, 2026
735ae1c
fix(cli): tighten vpc/subnet/sg ID validation to real AWS format
aidandaly24 Jun 30, 2026
b0db735
refactor(schema): centralize AWS resource-ID patterns as single sourc…
aidandaly24 Jun 30, 2026
39b5231
fix(cli): validate --vpc-id at CLI layer for container harness builds…
aidandaly24 Jun 30, 2026
1bd733f
fix(cli): resolve networkConfig.vpcId via DescribeSubnets on import/e…
aidandaly24 Jun 30, 2026
dc8e8ca
fix(cli): resolve vpcId via DescribeSubnets on starter-toolkit YAML i…
aidandaly24 Jun 30, 2026
0d87560
fix(cli): only resolve vpcId for container builds on import/export, n…
aidandaly24 Jun 30, 2026
709e9ef
fix(cli): resolve vpcId for ALL container exports incl prebuilt conta…
aidandaly24 Jun 30, 2026
4485c61
fix(vpc): address review — unify container-build detection, backfill …
aidandaly24 Jul 2, 2026
e8f71d5
style: prettier formatting for review-fix changes
aidandaly24 Jul 2, 2026
59bbde0
fix(create): thread vpcId + VPC validation through the create-harness…
aidandaly24 Jul 2, 2026
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
25 changes: 16 additions & 9 deletions docs/PERMISSIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -321,15 +321,22 @@ Required only when deploying an agent with EFS or S3 filesystem mounts and a VPC
preflight check that the agent's subnets and security groups are correctly set up for NFS (port 2049) before deploying.
These EC2 and EFS `Describe*` actions do not support resource-level scoping, so `Resource` must be `*`.

| Action | CLI Commands | Purpose |
| ----------------------------------------------------- | ------------------ | ------------------------------------------------------------------ |
| `ec2:DescribeSecurityGroups` | `create`, `deploy` | Validate agent/mount-target security groups allow NFS (port 2049) |
| `ec2:DescribeSubnets` | `create`, `deploy` | Validate mount-target subnets are in the agent's VPC and AZs |
| `elasticfilesystem:DescribeAccessPoints` | `create`, `deploy` | Resolve the EFS access point and its file system |
| `elasticfilesystem:DescribeMountTargets` | `create`, `deploy` | Find the EFS mount targets to validate against the agent's subnets |
| `elasticfilesystem:DescribeMountTargetSecurityGroups` | `create`, `deploy` | Check mount-target security groups allow NFS from the agent |
| `s3files:ListMountTargets` | `create`, `deploy` | List the S3 Files access point's mount targets |
| `s3files:GetMountTarget` | `create`, `deploy` | Inspect an S3 Files mount target's subnet/network configuration |
| Action | CLI Commands | Purpose |
| ----------------------------------------------------- | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `ec2:DescribeSecurityGroups` | `create`, `deploy` | Validate agent/mount-target security groups allow NFS (port 2049) |
| `ec2:DescribeSubnets` | `create`, `deploy`, `import` | Validate mount-target subnets are in the agent's VPC and AZs; resolve the VPC ID for Container+VPC builds (see note below) |
| `elasticfilesystem:DescribeAccessPoints` | `create`, `deploy` | Resolve the EFS access point and its file system |
| `elasticfilesystem:DescribeMountTargets` | `create`, `deploy` | Find the EFS mount targets to validate against the agent's subnets |
| `elasticfilesystem:DescribeMountTargetSecurityGroups` | `create`, `deploy` | Check mount-target security groups allow NFS from the agent |
| `s3files:ListMountTargets` | `create`, `deploy` | List the S3 Files access point's mount targets |

> **Container builds in VPC mode also require `ec2:DescribeSubnets`.** A Container build (agent or
> dockerfile/prebuilt-image harness) that deploys in VPC mode needs an explicit VPC ID — CodeBuild's `CreateProject`
> cannot infer the VPC from subnets. When the VPC ID is not already in the config (e.g. `import` of a deployed
> Container+VPC runtime, `export` of a container harness, or `deploy` of a project created before the VPC ID field
> existed), the CLI resolves it from the first subnet via `ec2:DescribeSubnets`. Grant this action for `import`,
> `export`, and `deploy` if you use Container builds in VPC mode, even without filesystem mounts. |
> `s3files:GetMountTarget` | `create`, `deploy` | Inspect an S3 Files mount target's subnet/network configuration |

### Agent invocation

Expand Down
46 changes: 39 additions & 7 deletions docs/container-builds.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,12 +115,44 @@ agentcore deploy -y # Build via CodeBuild, push to ECR
Local packaging validates the image size (1 GB limit). If no local runtime is available, packaging is skipped and
deployment handles the build remotely.

## VPC network mode

A container agent (or dockerfile/prebuilt-image harness) can build and run inside a VPC. The build infrastructure — the
orchestrator Lambda and the shared CodeBuild project — is placed in the same VPC as the runtime:

```bash
agentcore create --project-name MyProject --name myagent \
--build Container --network-mode VPC \
--subnets subnet-0123456789abcdef0 --security-groups sg-0123456789abcdef0 \
--vpc-id vpc-0123456789abcdef0 \
--language Python --framework Strands --model-provider Bedrock
```

Key points:

- **`--vpc-id` is required for Container builds in VPC mode.** CodeBuild's `CreateProject` cannot infer the VPC from
subnets alone (unlike Lambda, which does). CodeZip builds and any PUBLIC build neither need nor accept a VPC ID.
- **At most 5 security groups** for a container build in VPC mode (a CodeBuild limit; the runtime itself allows 16).
- **A NAT-routed subnet or VPC endpoints are required** so the in-VPC CodeBuild/Lambda can reach ECR, S3, CloudWatch
Logs, STS, and CodeBuild. An isolated subnet with no egress will hang the build.
- **The build needs `ec2:DescribeSubnets`** on `import`/`export`/`deploy` to resolve the VPC ID — see
[PERMISSIONS.md](./PERMISSIONS.md#filesystem-network-validation).

### Upgrading a project created before the VPC ID field existed

Earlier CLI versions let a Container+VPC agent be configured with only subnets and security groups. If you upgrade and
your `agentcore.json` has a Container+VPC agent with no `networkConfig.vpcId`, `deploy` resolves it automatically from
the first subnet (via `ec2:DescribeSubnets`) and writes it back to the config — no manual edit needed. Grant
`ec2:DescribeSubnets` before deploying, or add the `vpcId` to the config by hand.

## Troubleshooting

| Error | Fix |
| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| No container runtime found | Install Docker, Podman, or Finch |
| Runtime not ready | Docker: start Docker Desktop / `sudo systemctl start docker`. Podman: `podman machine start`. Finch: `finch vm init && finch vm start` |
| Dockerfile not found | Ensure `Dockerfile` exists in the agent's `codeLocation` directory |
| Image exceeds 2 GB | Use multi-stage builds, minimize packages, review `.dockerignore` |
| Build fails | Check `pyproject.toml` is valid; verify network access for dependency installation |
| Error | Fix |
| ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| No container runtime found | Install Docker, Podman, or Finch |
| Runtime not ready | Docker: start Docker Desktop / `sudo systemctl start docker`. Podman: `podman machine start`. Finch: `finch vm init && finch vm start` |
| Dockerfile not found | Ensure `Dockerfile` exists in the agent's `codeLocation` directory |
| Image exceeds 2 GB | Use multi-stage builds, minimize packages, review `.dockerignore` |
| `vpcId is required` at deploy/synth | Container+VPC build with no VPC ID. Grant `ec2:DescribeSubnets` so deploy can resolve it, or add `networkConfig.vpcId` to the config manually |
| Build hangs in VPC mode | The subnet has no egress. Use a NAT-routed subnet or add VPC endpoints (ECR api+dkr, S3, CloudWatch Logs, STS, CodeBuild) |
| Build fails | Check `pyproject.toml` is valid; verify network access for dependency installation |
88 changes: 88 additions & 0 deletions src/cli/aws/__tests__/agentcore-control.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ vi.mock('../account', () => ({
getCredentialProvider: vi.fn().mockReturnValue({}),
}));

const { mockResolveVpcId } = vi.hoisted(() => ({
mockResolveVpcId: vi.fn(),
}));

vi.mock('../../commands/shared/vpc-utils', () => ({
resolveVpcIdFromSubnets: (...args: unknown[]) => mockResolveVpcId(...args),
}));

describe('getAgentRuntimeStatus', () => {
beforeEach(() => {
vi.clearAllMocks();
Expand Down Expand Up @@ -502,6 +510,86 @@ describe('getAgentRuntimeDetail — new fields', () => {
});
});

describe('getAgentRuntimeDetail — VPC vpcId resolution', () => {
beforeEach(() => {
vi.clearAllMocks();
});

const vpcBaseResponse = {
agentRuntimeId: 'rt-vpc',
agentRuntimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-vpc',
agentRuntimeName: 'vpc-runtime',
status: 'READY',
roleArn: 'arn:aws:iam::123:role/test',
networkConfiguration: {
networkMode: 'VPC',
networkModeConfig: {
subnets: ['subnet-0a1b2c3d4e5f6a7b8'],
securityGroups: ['sg-0a1b2c3d4e5f6a7b8'],
},
},
protocolConfiguration: { serverProtocol: 'HTTP' },
agentRuntimeArtifact: { containerConfiguration: { containerUri: 'ecr.io/repo:tag' } },
};

it('resolves vpcId from subnets for a VPC runtime and includes it in networkConfig', async () => {
mockSend.mockResolvedValueOnce(vpcBaseResponse).mockResolvedValueOnce({ tags: {} });
mockResolveVpcId.mockResolvedValue('vpc-0abc1234567890def');

const result = await getAgentRuntimeDetail({ region: 'us-east-1', runtimeId: 'rt-vpc' });

expect(result.networkMode).toBe('VPC');
expect(result.networkConfig).toEqual({
subnets: ['subnet-0a1b2c3d4e5f6a7b8'],
securityGroups: ['sg-0a1b2c3d4e5f6a7b8'],
vpcId: 'vpc-0abc1234567890def',
});
expect(mockResolveVpcId).toHaveBeenCalledWith(['subnet-0a1b2c3d4e5f6a7b8'], 'us-east-1');
});

it('propagates DescribeSubnets error with actionable message', async () => {
mockSend.mockResolvedValue(vpcBaseResponse);
mockResolveVpcId.mockRejectedValue(
new Error(
'Failed to resolve VPC ID from subnet subnet-0a1b2c3d4e5f6a7b8: ec2:DescribeSubnets permission is required.'
)
);

await expect(getAgentRuntimeDetail({ region: 'us-east-1', runtimeId: 'rt-vpc' })).rejects.toThrow(
'ec2:DescribeSubnets'
);
});

it('sets networkConfig without vpcId when subnets list is empty', async () => {
mockSend
.mockResolvedValueOnce({
...vpcBaseResponse,
networkConfiguration: {
networkMode: 'VPC',
networkModeConfig: { subnets: [], securityGroups: ['sg-0a1b2c3d4e5f6a7b8'] },
},
})
.mockResolvedValueOnce({ tags: {} });

const result = await getAgentRuntimeDetail({ region: 'us-east-1', runtimeId: 'rt-vpc' });
expect(result.networkConfig?.vpcId).toBeUndefined();
expect(mockResolveVpcId).not.toHaveBeenCalled();
});

it('returns undefined networkConfig for PUBLIC mode', async () => {
mockSend
.mockResolvedValueOnce({
...vpcBaseResponse,
networkConfiguration: { networkMode: 'PUBLIC' },
})
.mockResolvedValueOnce({ tags: {} });

const result = await getAgentRuntimeDetail({ region: 'us-east-1', runtimeId: 'rt-vpc' });
expect(result.networkConfig).toBeUndefined();
expect(mockResolveVpcId).not.toHaveBeenCalled();
});
});

describe('listEvaluators', () => {
beforeEach(() => {
vi.clearAllMocks();
Expand Down
26 changes: 16 additions & 10 deletions src/cli/aws/agentcore-control.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type { NetworkConfig } from '../../schema';
import type { EvaluationLevel } from '../../schema/schemas/primitives/evaluator';
import { resolveVpcIdFromSubnets } from '../commands/shared/vpc-utils';

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 layering nit (not blocking): src/cli/aws/agentcore-control.ts now imports resolveVpcIdFromSubnets from ../commands/shared/vpc-utils. The general direction in this repo is commands/ → aws/, not the reverse; pulling a commands-layer helper back into a core AWS client file inverts that and risks creating an import cycle the next time someone wires another helper through vpc-utils.

No actual cycle today (vpc-utils → aws/account, agentcore-control → vpc-utils → aws/account is fine), but resolveVpcIdFromSubnets is itself just an EC2 SDK wrapper — it would fit naturally next to the existing AWS clients in src/cli/aws/ (e.g. a new ec2.ts), and both agentcore-control.ts and vpc-utils.ts could import it from there. Worth doing while the helper is new and has exactly two callers.

import { getCredentialProvider } from './account';
import { controlPlaneEndpoint } from './stage-endpoint';
import {
Expand Down Expand Up @@ -190,7 +192,7 @@ export interface AgentRuntimeDetail {
description?: string;
roleArn: string;
networkMode: string;
networkConfig?: { subnets: string[]; securityGroups: string[] };
networkConfig?: NetworkConfig;
protocol: string;
runtimeVersion?: string;
entryPoint?: string[];
Expand Down Expand Up @@ -222,18 +224,22 @@ export async function getAgentRuntimeDetail(options: GetAgentRuntimeOptions): Pr

const response = await client.send(command);

const networkMode = response.networkConfiguration?.networkMode ?? 'PUBLIC';
const networkConfig =
networkMode === 'VPC' && response.networkConfiguration?.networkModeConfig
? {
subnets: response.networkConfiguration.networkModeConfig.subnets ?? [],
securityGroups: response.networkConfiguration.networkModeConfig.securityGroups ?? [],
}
: undefined;

const isContainer = !!response.agentRuntimeArtifact?.containerConfiguration;
const codeConfig = response.agentRuntimeArtifact?.codeConfiguration;

const networkMode = response.networkConfiguration?.networkMode ?? 'PUBLIC';
let networkConfig: AgentRuntimeDetail['networkConfig'];
if (networkMode === 'VPC' && response.networkConfiguration?.networkModeConfig) {
const subnets = response.networkConfiguration.networkModeConfig.subnets ?? [];
const securityGroups = response.networkConfiguration.networkModeConfig.securityGroups ?? [];
// Resolve the VPC ID only for Container builds (the case that requires it — CodeBuild can't infer
// the VPC). CodeZip+VPC runtimes don't need it, so this avoids the extra ec2:DescribeSubnets call
// (and its IAM) for them. `isContainer` here comes from the runtime's containerConfiguration.
const vpcId =
isContainer && subnets.length > 0 ? await resolveVpcIdFromSubnets(subnets, options.region) : undefined;
networkConfig = { subnets, securityGroups, vpcId };
}

let authorizerType: string | undefined;
let authorizerConfiguration: AgentRuntimeDetail['authorizerConfiguration'];
if (response.authorizerConfiguration?.customJWTAuthorizer) {
Expand Down
110 changes: 110 additions & 0 deletions src/cli/commands/add/__tests__/harness-vpc-guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import type { AddHarnessCliOptions } from '../types';
import { validateAddHarnessOptions } from '../validate';
import { describe, expect, it } from 'vitest';

const base: AddHarnessCliOptions = {
name: 'h1',
modelProvider: 'bedrock',
modelId: 'us.anthropic.claude-haiku-4-5-20251001-v1:0',
};

describe('validateAddHarnessOptions — VPC network-mode guard', () => {
it('rejects VPC mode without subnets', () => {
const result = validateAddHarnessOptions({
...base,
networkMode: 'VPC',
securityGroups: 'sg-0a1b2c3d',
});
expect(result.valid).toBe(false);
if (!result.valid) expect(result.error).toContain('--subnets is required');
});

it('rejects VPC mode without security groups', () => {
const result = validateAddHarnessOptions({
...base,
networkMode: 'VPC',
subnets: 'subnet-0a1b2c3d',
});
expect(result.valid).toBe(false);
if (!result.valid) expect(result.error).toContain('--security-groups is required');
});

it('accepts valid VPC options for a non-container harness', () => {
const result = validateAddHarnessOptions({
...base,
networkMode: 'VPC',
subnets: 'subnet-0a1b2c3d',
securityGroups: 'sg-0a1b2c3d',
});
expect(result.valid).toBe(true);
});

it('rejects a container (dockerfile) harness with --network-mode VPC and no --vpc-id', () => {
const result = validateAddHarnessOptions({
...base,
container: './Dockerfile',
networkMode: 'VPC',
subnets: 'subnet-0a1b2c3d',
securityGroups: 'sg-0a1b2c3d',
});
expect(result.valid).toBe(false);
if (!result.valid) expect(result.error).toContain('--vpc-id is required');
});

it('accepts a container (dockerfile) harness with --network-mode VPC and a valid --vpc-id', () => {
const result = validateAddHarnessOptions({
...base,
container: './Dockerfile',
networkMode: 'VPC',
subnets: 'subnet-0a1b2c3d',
securityGroups: 'sg-0a1b2c3d',
vpcId: 'vpc-0a1b2c3d',
});
expect(result.valid).toBe(true);
});

it('requires --vpc-id for a container URI harness in VPC mode (a prebuilt image still runs CodeBuild)', () => {
// A --container <ecr-uri> harness is a container build: export emits a `FROM <uri>` Dockerfile
// that CodeBuild builds, so it needs a vpcId just like a dockerfile harness. Previously this
// slipped through and dead-ended at deploy/export.
const result = validateAddHarnessOptions({
...base,
container: '123456789012.dkr.ecr.us-east-1.amazonaws.com/my-image:latest',
networkMode: 'VPC',
subnets: 'subnet-0a1b2c3d',
securityGroups: 'sg-0a1b2c3d',
});
expect(result.valid).toBe(false);
if (!result.valid) expect(result.error).toContain('--vpc-id is required');
});

it('accepts a container URI harness in VPC mode when --vpc-id is provided', () => {
const result = validateAddHarnessOptions({
...base,
container: '123456789012.dkr.ecr.us-east-1.amazonaws.com/my-image:latest',
networkMode: 'VPC',
subnets: 'subnet-0a1b2c3d',
securityGroups: 'sg-0a1b2c3d',
vpcId: 'vpc-0a1b2c3d',
});
expect(result.valid).toBe(true);
});

it('rejects subnets provided without --network-mode VPC', () => {
const result = validateAddHarnessOptions({
...base,
subnets: 'subnet-0a1b2c3d',
});
expect(result.valid).toBe(false);
if (!result.valid) expect(result.error).toContain('only valid with --network-mode VPC');
});

it('rejects --vpc-id provided without --network-mode VPC', () => {
const result = validateAddHarnessOptions({
...base,
vpcId: 'vpc-0a1b2c3d',
});
expect(result.valid).toBe(false);
if (!result.valid) expect(result.error).toContain('only valid with --network-mode VPC');
});
});
1 change: 1 addition & 0 deletions src/cli/commands/add/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ export interface AddHarnessCliOptions {
networkMode?: string;
subnets?: string;
securityGroups?: string;
vpcId?: string;
idleTimeout?: number;
maxLifetime?: number;
sessionStorage?: string;
Expand Down
Loading
Loading