Skip to content
Open
38 changes: 20 additions & 18 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,24 +177,26 @@ 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 (and the `Dockerfile` for Container builds) |
| `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 |
| `buildContextPath` | No | **Container only.** Docker build context directory (replaces `codeLocation` as context). |
| `customDockerBuildArgs` | No | **Container only.** Key/value pairs forwarded as `--build-arg` flags. |

### Runtime Versions

Expand Down
58 changes: 58 additions & 0 deletions docs/container-builds.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,64 @@ All other fields work the same as CodeZip agents.
> must also add a `Dockerfile` and `.dockerignore` to the agent's code directory. The easiest way is to create a
> throwaway container agent with `agentcore add agent --build Container` and copy the generated files.

### Advanced: Shared Dockerfile (monorepo)

When multiple agents share the same build logic, you can point them all at a single `Dockerfile` using two optional
fields:

| Field | Description |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `buildContextPath` | Docker build context directory. Replaces `codeLocation` as the `docker build` context and as the root the `dockerfile` path is resolved against. |
| `customDockerBuildArgs` | Key/value pairs forwarded as `--build-arg` flags, allowing a shared Dockerfile to branch per agent. Keys must be valid identifiers. |

Both fields are honored identically by local `agentcore dev` / `agentcore package` and by `agentcore deploy` (which
builds in CodeBuild). The `dockerfile` field is resolved relative to the build context, so with `buildContextPath: "."`
the default `Dockerfile` refers to `./Dockerfile` at the project root. `dockerfile` may also be a relative subpath (e.g.
`docker/Dockerfile`); absolute paths and `..` traversal are rejected.

**Example — two agents, one shared `Dockerfile` at the project root:**

```json
{
"name": "agent-one",
"build": "Container",
"entrypoint": "main.py",
"codeLocation": "app/agent-one/",
"buildContextPath": ".",
"customDockerBuildArgs": { "AGENT_NAME": "agent-one" }
},
{
"name": "agent-two",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Separate issue with this example regardless of the deploy plumbing: dockerfile is validated as a filename (no path separators — see src/schema/schemas/agent-env.ts and getDockerfilePath in src/lib/constants.ts), and getDockerfilePath joins it onto codeLocation. So the implied "single Dockerfile at the project root" setup isn't achievable today — each agent's codeLocation would still need its own Dockerfile file (even if it's just a one-liner that delegates).

Either (a) extend dockerfile to accept a path relative to the build context (and resolve it accordingly), or (b) update this section to clarify that each codeLocation must still contain a Dockerfile (which can simply be a thin wrapper / symlink) and adjust the example accordingly.

"build": "Container",
"entrypoint": "main.py",
"codeLocation": "app/agent-two/",
"buildContextPath": ".",
"customDockerBuildArgs": { "AGENT_NAME": "agent-two" }
}
```

The shared `Dockerfile` can then branch on the build arg:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Tied to the deploy issue: this example will not work end-to-end with agentcore deploy until the CDK construct side is updated. If we go with the "land construct first" path that's fine; otherwise this doc section should be reworded to make clear this is local-dev-only, or removed.


```dockerfile
ARG AGENT_NAME
COPY app/${AGENT_NAME}/ ./app/
```

**When to use `buildContextPath`:** use it when your `Dockerfile` needs to `COPY` files that live outside of
`codeLocation` (e.g. shared libraries at the project root). Without it, Docker only sees the `codeLocation` directory as
its build context.

> **A `.dockerignore` is generated at the build-context root.** When you set `buildContextPath`, the whole of that
> directory is the build context — for `buildContextPath: "."` that's your entire project. If there's no `.dockerignore`
> at that root, `agentcore` creates one (excluding `.env`, `.env.*`, `.git/`, `.venv/`, `node_modules/`, `agentcore/`)
> so secrets and build junk stay out of the image and the CodeBuild upload. It's an ordinary file — commit it, and edit
> it to include anything you intentionally want in the context (e.g. a non-secret `.env.production`, by removing that
> line). Both local `agentcore dev` / `agentcore package` and `agentcore deploy` honor the same root `.dockerignore`, so
> what's excluded locally is exactly what's excluded from the CodeBuild upload.

**When to use `customDockerBuildArgs`:** use it to parameterise a shared `Dockerfile` so each agent produces a different
image (different entry point, bundled code, etc.) without duplicating the file.

## Local Development

```bash
Expand Down
46 changes: 46 additions & 0 deletions src/cli/operations/deploy/__tests__/preflight-container.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { validateContainerAgents } from '../preflight.js';
import { existsSync, readFileSync } from 'node:fs';
import { afterEach, describe, expect, it, vi } from 'vitest';

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

vi.mock('node:fs', () => ({
existsSync: vi.fn(),
readFileSync: vi.fn(),
Expand All @@ -21,6 +23,7 @@ vi.mock('../../../../lib', () => ({
const repoRoot = p.dirname(configBaseDir);
return p.resolve(repoRoot, codeLocation);
}),
ensureBuildContextDockerignore: ensureMock,
// Stub other exports that the module may pull in
ConfigIO: vi.fn(),
requireConfigRoot: vi.fn(),
Expand Down Expand Up @@ -135,6 +138,49 @@ describe('validateContainerAgents', () => {
expect(() => validateContainerAgents(spec, CONFIG_ROOT)).toThrow(/Dockerfile\.gpu not found/);
});

it('resolves the Dockerfile against buildContextPath when set', () => {
mockedExistsSync.mockReturnValue(true);
mockValidDockerfile();

const spec = makeSpec([
{ name: 'mono', build: 'Container', codeLocation: dir('agents/mono'), buildContextPath: dir('.') },
]);

expect(() => validateContainerAgents(spec, CONFIG_ROOT)).not.toThrow();
// The Dockerfile is resolved from the build context (repo root), not the agent's codeLocation.
const calledPath = mockedExistsSync.mock.calls[0]?.[0] as string;
expect(calledPath).not.toContain('agents/mono');
});

it('generates a build-context .dockerignore (and logs) when buildContextPath is set and none exists', () => {
mockedExistsSync.mockReturnValue(true);
mockValidDockerfile();
// Simulate "created" — return the path so the preflight logs it.
ensureMock.mockReturnValue('/project/.dockerignore');
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);

const spec = makeSpec([
{ name: 'mono', build: 'Container', codeLocation: dir('agents/mono'), buildContextPath: dir('.') },
]);

expect(() => validateContainerAgents(spec, CONFIG_ROOT)).not.toThrow();
// Ensured against the resolved build context (repo root), not the agent's codeLocation.
const ensuredPath = ensureMock.mock.calls[0]?.[0] as string;
expect(ensuredPath).not.toContain('agents/mono');
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('created'));
warnSpy.mockRestore();
});

it('does not touch .dockerignore when buildContextPath is unset', () => {
mockedExistsSync.mockReturnValue(true);
mockValidDockerfile();

const spec = makeSpec([{ name: 'plain', build: 'Container', codeLocation: dir('agents/plain') }]);

validateContainerAgents(spec, CONFIG_ROOT);
expect(ensureMock).not.toHaveBeenCalled();
});

it('warns when Dockerfile uses deprecated bookworm base image', () => {
mockedExistsSync.mockReturnValue(true);
mockedReadFileSync.mockReturnValue(
Expand Down
29 changes: 26 additions & 3 deletions src/cli/operations/deploy/preflight.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { ConfigIO, DOCKERFILE_NAME, getDockerfilePath, requireConfigRoot, resolveCodeLocation } from '../../../lib';
import {
ConfigIO,
DOCKERFILE_NAME,
ensureBuildContextDockerignore,
getDockerfilePath,
requireConfigRoot,
resolveCodeLocation,
} from '../../../lib';
import { StaleCdkConstructError, ValidationError } from '../../../lib/errors/types';
import type { AgentCoreProjectSpec, AwsDeploymentTarget } from '../../../schema';
import { validateAwsCredentials } from '../../aws/account';
Expand Down Expand Up @@ -198,8 +205,10 @@ export function validateContainerAgents(projectSpec: AgentCoreProjectSpec, confi
const errors: string[] = [];
for (const agent of projectSpec.runtimes || []) {
if (agent.build === 'Container') {
const codeLocation = resolveCodeLocation(agent.codeLocation, configRoot);
const dockerfilePath = getDockerfilePath(codeLocation, agent.dockerfile);
// Resolve the Dockerfile against the build context (buildContextPath ?? codeLocation), matching
// how ContainerSourceAsset uploads the context and how the CodeBuild buildspec resolves it.
const buildContext = resolveCodeLocation(agent.buildContextPath ?? agent.codeLocation, configRoot);
const dockerfilePath = getDockerfilePath(buildContext, agent.dockerfile);

if (!existsSync(dockerfilePath)) {
errors.push(
Expand All @@ -208,6 +217,20 @@ export function validateContainerAgents(projectSpec: AgentCoreProjectSpec, confi
} else {
warnDeprecatedBaseImage(dockerfilePath, agent.name);
}
// buildContextPath widens the docker build context (e.g. the whole repo). Ensure a
// .dockerignore exists at that root so secrets/junk (.env, .git, agentcore/) are never baked
// into the image or uploaded to CodeBuild. Both local and deploy honor this same file; it is
// created only when absent (never overwrites the user's).
if (agent.buildContextPath) {
const created = ensureBuildContextDockerignore(buildContext);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[Blocker — correctness] Preflight masks its own friendly error and writes into the repo on a failing deploy.

ensureBuildContextDockerignore guards only the .dockerignore file's existence, never the directory. With buildContextPath: './typo-dir' (missing), the loop first pushes a clean "Dockerfile not found" into errors[], but this call then writeFileSyncs into the missing dir and throws raw ENOENT before the aggregated throw — so the user sees a cryptic fs error instead of the actionable one. When the dir exists but the Dockerfile is missing, it still leaves a stray .dockerignore in the repo even though the deploy aborts (dirtying CI/git checkouts).

Note the inconsistency with ContainerPackager.pack(), which does the Dockerfile existsSync check after this write; preflight does it before. Fix both by guarding the dir and deferring the write until validation passes.

if (created) {
console.warn(
`Agent "${agent.name}": created ${created} with default secret exclusions because buildContextPath is ` +
`set (the entire context is sent to Docker/CodeBuild). Review and commit it; edit to include any ` +
`intentionally-bundled files.`
);
}
}
}
}
if (errors.length > 0) {
Expand Down
71 changes: 71 additions & 0 deletions src/cli/operations/dev/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,77 @@ describe('getDevConfig', () => {
expect(config).not.toBeNull();
expect(config?.dockerfile).toBe('Dockerfile.gpu');
});

it('threads buildContextPath from Container agent spec to DevConfig (resolved absolute)', () => {
const project: AgentCoreProjectSpec = {
name: 'TestProject',
version: 1,
managedBy: 'CDK' as const,
runtimes: [
{
name: 'ContainerAgent',
build: 'Container',
runtimeVersion: 'PYTHON_3_12',
entrypoint: filePath('main.py'),
codeLocation: dirPath('./agents/container'),
protocol: 'HTTP',
buildContextPath: dirPath('.'),
},
],
memories: [],
knowledgeBases: [],
credentials: [],
evaluators: [],
onlineEvalConfigs: [],
agentCoreGateways: [],
policyEngines: [],
configBundles: [],
abTests: [],
harnesses: [],
datasets: [],
payments: [],
};

const config = getDevConfig(workingDir, project, '/test/project/agentcore');
expect(config).not.toBeNull();
// resolveCodeDirectory('.', '/test/project/agentcore') => dirname('/test/project/agentcore') + '/' + '.' => '/test/project'
expect(config?.buildContextPath).toBe('/test/project');
});

it('threads customDockerBuildArgs from Container agent spec to DevConfig', () => {
const project: AgentCoreProjectSpec = {
name: 'TestProject',
version: 1,
managedBy: 'CDK' as const,
runtimes: [
{
name: 'ContainerAgent',
build: 'Container',
runtimeVersion: 'PYTHON_3_12',
entrypoint: filePath('main.py'),
codeLocation: dirPath('./agents/container'),
protocol: 'HTTP',
customDockerBuildArgs: { AGENT_NAME: 'myagent' },
},
],
memories: [],
knowledgeBases: [],
credentials: [],
evaluators: [],
onlineEvalConfigs: [],
agentCoreGateways: [],
policyEngines: [],
configBundles: [],
abTests: [],
harnesses: [],
datasets: [],
payments: [],
};

const config = getDevConfig(workingDir, project, '/test/project/agentcore');
expect(config).not.toBeNull();
expect(config?.customDockerBuildArgs).toEqual({ AGENT_NAME: 'myagent' });
});
});

describe('getAgentPort', () => {
Expand Down
24 changes: 24 additions & 0 deletions src/cli/operations/dev/__tests__/container-dev-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { DevConfig } from '../config';
import { ContainerDevServer } from '../container-dev-server';
import type { DevServerCallbacks, DevServerOptions } from '../dev-server';
import { EventEmitter } from 'events';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const mockSpawnSync = vi.fn();
Expand Down Expand Up @@ -226,6 +227,29 @@ describe('ContainerDevServer', () => {
expect(buildArgs[tagIdx + 1]).toBe('agentcore-dev-testagent');
});

it('forwards customDockerBuildArgs and uses buildContextPath as the context + Dockerfile root', async () => {
mockSuccessfulPrepare();

const monoConfig: DevConfig = {
...defaultConfig,
directory: '/project/app/agent-one',
buildContextPath: '/project',
customDockerBuildArgs: { AGENT_NAME: 'one' },
};
const server = new ContainerDevServer(monoConfig, defaultOptions);
await server.start();

const buildArgs = mockSpawn.mock.calls[0]![1] as string[];
// Build args are forwarded as --build-arg KEY=VALUE.
expect(buildArgs).toContain('--build-arg');
expect(buildArgs).toContain('AGENT_NAME=one');
// The positional build context (last arg) is buildContextPath, not codeLocation.
expect(buildArgs[buildArgs.length - 1]).toBe('/project');
// The Dockerfile (-f) is resolved against the build context, not codeLocation.
const fIdx = buildArgs.indexOf('-f');
expect(buildArgs[fIdx + 1]).toBe(join('/project', 'Dockerfile'));
});

it('streams build output lines at system level in real-time', async () => {
mockDetectContainerRuntime.mockResolvedValue({
runtime: { runtime: 'docker', binary: 'docker', version: 'Docker 24.0' },
Expand Down
9 changes: 9 additions & 0 deletions src/cli/operations/dev/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ export interface DevConfig {
buildType: BuildType;
protocol: ProtocolMode;
dockerfile?: string;
/** Resolved absolute path to use as the Docker build context. Defaults to `directory`. */
buildContextPath?: string;
/** Custom `--build-arg` key/value pairs forwarded to `docker build`. */
customDockerBuildArgs?: Record<string, string>;
}

interface DevSupportResult {
Expand Down Expand Up @@ -139,6 +143,11 @@ export function getDevConfig(
buildType: targetAgent.build,
protocol: targetAgent.protocol ?? 'HTTP',
dockerfile: targetAgent.dockerfile,
buildContextPath:
configRoot && targetAgent.buildContextPath
? resolveCodeDirectory(targetAgent.buildContextPath, configRoot)
: undefined,
customDockerBuildArgs: targetAgent.customDockerBuildArgs,
};
}

Expand Down
Loading
Loading