From 74b1b5056a400af2d8ccd2b30fa0c9ffad92e911 Mon Sep 17 00:00:00 2001 From: Edouard Courty Date: Mon, 18 May 2026 16:53:38 +0200 Subject: [PATCH 1/5] feat(issue-1236): enhance docker build with additional build configuration - allow buildContextPath argument to change build context path - allow custom docker build arguments --- docs/configuration.md | 38 ++++---- docs/container-builds.md | 45 ++++++++++ .../operations/dev/__tests__/config.test.ts | 65 ++++++++++++++ src/cli/operations/dev/config.ts | 9 ++ .../operations/dev/container-dev-server.ts | 7 +- src/lib/packaging/__tests__/container.test.ts | 51 +++++++++++ src/lib/packaging/container.ts | 9 +- src/schema/llm-compacted/agentcore.ts | 2 + .../schemas/__tests__/agent-env.test.ts | 90 +++++++++++++++++++ src/schema/schemas/agent-env.ts | 26 ++++++ 10 files changed, 322 insertions(+), 20 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 6e42c101a..105ea243b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -175,24 +175,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 diff --git a/docs/container-builds.md b/docs/container-builds.md index 61d65bcde..d57b00432 100644 --- a/docs/container-builds.md +++ b/docs/container-builds.md @@ -68,6 +68,51 @@ 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 positional `docker build` argument. | +| `customDockerBuildArgs` | Key/value pairs forwarded as `--build-arg` flags, allowing a shared Dockerfile to branch per agent. | + +**Example — two agents, one 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", + "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: + +```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. + +**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 diff --git a/src/cli/operations/dev/__tests__/config.test.ts b/src/cli/operations/dev/__tests__/config.test.ts index 844ab437c..d539d41fd 100644 --- a/src/cli/operations/dev/__tests__/config.test.ts +++ b/src/cli/operations/dev/__tests__/config.test.ts @@ -438,6 +438,71 @@ 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: [], + credentials: [], + evaluators: [], + onlineEvalConfigs: [], + agentCoreGateways: [], + policyEngines: [], + configBundles: [], + abTests: [], + httpGateways: [], + }; + + 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: [], + credentials: [], + evaluators: [], + onlineEvalConfigs: [], + agentCoreGateways: [], + policyEngines: [], + configBundles: [], + abTests: [], + httpGateways: [], + }; + + const config = getDevConfig(workingDir, project, '/test/project/agentcore'); + expect(config).not.toBeNull(); + expect(config?.customDockerBuildArgs).toEqual({ AGENT_NAME: 'myagent' }); + }); }); describe('getAgentPort', () => { diff --git a/src/cli/operations/dev/config.ts b/src/cli/operations/dev/config.ts index fd13637bb..b2374e513 100644 --- a/src/cli/operations/dev/config.ts +++ b/src/cli/operations/dev/config.ts @@ -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; } interface DevSupportResult { @@ -142,6 +146,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, }; } diff --git a/src/cli/operations/dev/container-dev-server.ts b/src/cli/operations/dev/container-dev-server.ts index 10ef21b3f..0057f7fb5 100644 --- a/src/cli/operations/dev/container-dev-server.ts +++ b/src/cli/operations/dev/container-dev-server.ts @@ -78,8 +78,13 @@ export class ContainerDevServer extends DevServer { // 4. Build the container image, streaming output in real-time onLog('system', `Building container image: ${this.imageName}...`); + const buildContext = this.config.buildContextPath ?? this.config.directory; + const buildArgFlags = Object.entries(this.config.customDockerBuildArgs ?? {}).flatMap(([k, v]) => [ + '--build-arg', + `${k}=${v}`, + ]); const exitCode = await this.streamBuild( - ['-t', this.imageName, '-f', dockerfilePath, ...getUvBuildArgs(), this.config.directory], + ['-t', this.imageName, '-f', dockerfilePath, ...getUvBuildArgs(), ...buildArgFlags, buildContext], onLog ); diff --git a/src/lib/packaging/__tests__/container.test.ts b/src/lib/packaging/__tests__/container.test.ts index cb5685dbf..f60a6ad2f 100644 --- a/src/lib/packaging/__tests__/container.test.ts +++ b/src/lib/packaging/__tests__/container.test.ts @@ -210,6 +210,57 @@ describe('ContainerPackager', () => { await expect(packager.pack(specWithDockerfile as any)).rejects.toThrow('Dockerfile.custom not found'); }); + it('uses buildContextPath as docker build context instead of codeLocation', async () => { + mockResolveCodeLocation.mockImplementation((path: string) => { + if (path === './src') return '/resolved/src'; + if (path === './context') return '/resolved/context'; + return path; + }); + mockExistsSync.mockReturnValue(true); + mockSpawnSync.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'which' && args[0] === 'docker') return { status: 0 }; + if (cmd === 'docker' && args[0] === '--version') return { status: 0 }; + if (cmd === 'docker' && args[0] === 'build') return { status: 0 }; + if (cmd === 'docker' && args[0] === 'image') return { status: 0, stdout: Buffer.from('1000') }; + return { status: 1 }; + }); + + const specWithContext = { ...baseSpec, buildContextPath: './context' }; + await packager.pack(specWithContext as any); + + const buildCall = mockSpawnSync.mock.calls.find( + (c: unknown[]) => c[0] === 'docker' && (c[1] as string[])[0] === 'build' + ); + expect(buildCall).toBeDefined(); + const buildArgs = buildCall![1] as string[]; + // Last arg should be the buildContextPath, not codeLocation + expect(buildArgs[buildArgs.length - 1]).toBe('/resolved/context'); + }); + + it('passes customDockerBuildArgs as --build-arg flags', async () => { + mockResolveCodeLocation.mockReturnValue('/resolved/src'); + mockExistsSync.mockReturnValue(true); + mockSpawnSync.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'which' && args[0] === 'docker') return { status: 0 }; + if (cmd === 'docker' && args[0] === '--version') return { status: 0 }; + if (cmd === 'docker' && args[0] === 'build') return { status: 0 }; + if (cmd === 'docker' && args[0] === 'image') return { status: 0, stdout: Buffer.from('1000') }; + return { status: 1 }; + }); + + const specWithBuildArgs = { ...baseSpec, customDockerBuildArgs: { AGENT_NAME: 'dummyagent', BUILD_ENV: 'prod' } }; + await packager.pack(specWithBuildArgs as any); + + const buildCall = mockSpawnSync.mock.calls.find( + (c: unknown[]) => c[0] === 'docker' && (c[1] as string[])[0] === 'build' + ); + expect(buildCall).toBeDefined(); + const buildArgs = buildCall![1] as string[]; + expect(buildArgs).toContain('--build-arg'); + expect(buildArgs).toContain('AGENT_NAME=dummyagent'); + expect(buildArgs).toContain('BUILD_ENV=prod'); + }); + it('detects podman runtime last', async () => { mockResolveCodeLocation.mockReturnValue('/resolved/src'); mockExistsSync.mockReturnValue(true); diff --git a/src/lib/packaging/container.ts b/src/lib/packaging/container.ts index a166a08f3..85416f270 100644 --- a/src/lib/packaging/container.ts +++ b/src/lib/packaging/container.ts @@ -36,6 +36,9 @@ export class ContainerPackager implements RuntimePackager { const configBaseDir = options.artifactDir ?? options.projectRoot ?? process.cwd(); const codeLocation = resolveCodeLocation(spec.codeLocation, configBaseDir); const dockerfilePath = getDockerfilePath(codeLocation, spec.dockerfile); + const buildContext = spec.buildContextPath + ? resolveCodeLocation(spec.buildContextPath, configBaseDir) + : codeLocation; // Preflight: Dockerfile must exist if (!existsSync(dockerfilePath)) { @@ -59,9 +62,13 @@ export class ContainerPackager implements RuntimePackager { // Build locally const imageName = `agentcore-package-${agentName}`; + const buildArgFlags = Object.entries(spec.customDockerBuildArgs ?? {}).flatMap(([k, v]) => [ + '--build-arg', + `${k}=${v}`, + ]); const buildResult = spawnSync( runtime, - ['build', '-t', imageName, '-f', dockerfilePath, ...getUvBuildArgs(), codeLocation], + ['build', '-t', imageName, '-f', dockerfilePath, ...getUvBuildArgs(), ...buildArgFlags, buildContext], { stdio: 'pipe', } diff --git a/src/schema/llm-compacted/agentcore.ts b/src/schema/llm-compacted/agentcore.ts index c86c14cb7..34448cf49 100644 --- a/src/schema/llm-compacted/agentcore.ts +++ b/src/schema/llm-compacted/agentcore.ts @@ -68,6 +68,8 @@ interface AgentEnvSpec { entrypoint: string; // @regex ^[a-zA-Z0-9_][a-zA-Z0-9_/.-]*\.(py|ts|js)(:[a-zA-Z_][a-zA-Z0-9_]*)?$ e.g. "main.py:handler" or "index.ts" codeLocation: string; // Directory path dockerfile?: string; // Custom Dockerfile name for Container builds (default: 'Dockerfile'). Must be a filename, not a path. + buildContextPath?: string; // Docker build context directory for Container builds. Replaces codeLocation as the positional `docker build` argument. + customDockerBuildArgs?: Record; // Key/value pairs forwarded as --build-arg flags. Container builds only. runtimeVersion?: RuntimeVersion; envVars?: EnvVar[]; networkMode?: NetworkMode; // default 'PUBLIC' diff --git a/src/schema/schemas/__tests__/agent-env.test.ts b/src/schema/schemas/__tests__/agent-env.test.ts index b5b0e55a2..0d0c4f982 100644 --- a/src/schema/schemas/__tests__/agent-env.test.ts +++ b/src/schema/schemas/__tests__/agent-env.test.ts @@ -458,6 +458,96 @@ describe('AgentEnvSpecSchema - dockerfile', () => { }); }); +describe('AgentEnvSpecSchema - buildContextPath', () => { + const validContainerAgent = { + name: 'ContainerAgent', + build: 'Container', + entrypoint: 'main.py', + codeLocation: './agents/container', + }; + + const validCodeZipAgent = { + name: 'CodeZipAgent', + build: 'CodeZip', + entrypoint: 'main.py:handler', + codeLocation: './agents/test', + runtimeVersion: 'PYTHON_3_12', + }; + + it('accepts Container agent with buildContextPath', () => { + const result = AgentEnvSpecSchema.safeParse({ ...validContainerAgent, buildContextPath: '.' }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.buildContextPath).toBe('.'); + } + }); + + it('accepts Container agent without buildContextPath (optional)', () => { + const result = AgentEnvSpecSchema.safeParse(validContainerAgent); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.buildContextPath).toBeUndefined(); + } + }); + + it('rejects buildContextPath on CodeZip builds', () => { + const result = AgentEnvSpecSchema.safeParse({ ...validCodeZipAgent, buildContextPath: '.' }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('only allowed for Container'))).toBe(true); + } + }); +}); + +describe('AgentEnvSpecSchema - customDockerBuildArgs', () => { + const validContainerAgent = { + name: 'ContainerAgent', + build: 'Container', + entrypoint: 'main.py', + codeLocation: './agents/container', + }; + + const validCodeZipAgent = { + name: 'CodeZipAgent', + build: 'CodeZip', + entrypoint: 'main.py:handler', + codeLocation: './agents/test', + runtimeVersion: 'PYTHON_3_12', + }; + + it('accepts Container agent with customDockerBuildArgs', () => { + const result = AgentEnvSpecSchema.safeParse({ + ...validContainerAgent, + customDockerBuildArgs: { AGENT_NAME: 'dummyagent', BUILD_ENV: 'prod' }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.customDockerBuildArgs).toEqual({ AGENT_NAME: 'dummyagent', BUILD_ENV: 'prod' }); + } + }); + + it('accepts Container agent without customDockerBuildArgs (optional)', () => { + const result = AgentEnvSpecSchema.safeParse(validContainerAgent); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.customDockerBuildArgs).toBeUndefined(); + } + }); + + it('rejects customDockerBuildArgs on CodeZip builds', () => { + const result = AgentEnvSpecSchema.safeParse({ ...validCodeZipAgent, customDockerBuildArgs: { KEY: 'val' } }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('only allowed for Container'))).toBe(true); + } + }); + + it('accepts empty customDockerBuildArgs object', () => { + const result = AgentEnvSpecSchema.safeParse({ ...validContainerAgent, customDockerBuildArgs: {} }); + expect(result.success).toBe(true); + }); +}); + describe('AgentEnvSpecSchema - lifecycleConfiguration', () => { const validAgent = { name: 'TestAgent', diff --git a/src/schema/schemas/agent-env.ts b/src/schema/schemas/agent-env.ts index 789109a38..f7ec3bdb6 100644 --- a/src/schema/schemas/agent-env.ts +++ b/src/schema/schemas/agent-env.ts @@ -232,6 +232,18 @@ export const AgentEnvSpecSchema = z .max(255) .regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/, 'Must be a filename (no path separators or traversal)') .optional(), + /** + * Docker build context directory for Container builds. + * When set, this path is used as the positional argument to `docker build` instead of `codeLocation`. + * Useful when a single Dockerfile is shared across multiple agents in a monorepo. + */ + buildContextPath: DirectoryPathSchema.optional(), + /** + * Custom build arguments passed to `docker build` as `--build-arg KEY=VALUE` flags. + * Useful for parameterising a shared Dockerfile per agent (e.g. `{ "AGENT_NAME": "myagent" }`). + * Container builds only. + */ + customDockerBuildArgs: z.record(z.string().min(1), z.string()).optional(), runtimeVersion: RuntimeVersionSchemaFromConstants.optional(), /** Environment variables to set on the runtime */ envVars: z.array(EnvVarSchema).optional(), @@ -296,6 +308,20 @@ export const AgentEnvSpecSchema = z path: ['dockerfile'], }); } + if (data.build !== 'Container' && data.buildContextPath) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'buildContextPath is only allowed for Container builds', + path: ['buildContextPath'], + }); + } + if (data.build !== 'Container' && data.customDockerBuildArgs) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'customDockerBuildArgs is only allowed for Container builds', + path: ['customDockerBuildArgs'], + }); + } }); export type AgentEnvSpec = z.infer; From a2c383c69ac958219064941e98c75ff5edf429e5 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Tue, 7 Jul 2026 22:10:29 +0000 Subject: [PATCH 2/5] fix(container): reject reserved build-arg keys; cover dev/package build-context resolution Address adversarial-review findings (paired with the @aws/agentcore-cdk change): - Reject customDockerBuildArgs keys reserved by the CodeBuild build environment (ECR_REGISTRY, IMAGE_URI, DOCKERFILE_PATH, BUILD_ARG_FLAGS, or CODEBUILD_*/AWS_* prefixes) so a config that builds locally can't fail only on `agentcore deploy`. Tests: - schema: reserved-key rejection. - packager: assert the Dockerfile (-f) resolves against the build context, not codeLocation. - dev server: assert customDockerBuildArgs are forwarded and buildContextPath is used as both the build context and the Dockerfile root. --- .../__tests__/container-dev-server.test.ts | 24 +++++++++++++++++++ src/lib/packaging/__tests__/container.test.ts | 4 ++++ .../schemas/__tests__/agent-env.test.ts | 17 +++++++++++++ src/schema/schemas/agent-env.ts | 22 +++++++++++++++++ 4 files changed, 67 insertions(+) diff --git a/src/cli/operations/dev/__tests__/container-dev-server.test.ts b/src/cli/operations/dev/__tests__/container-dev-server.test.ts index bfd374e91..aebf552b3 100644 --- a/src/cli/operations/dev/__tests__/container-dev-server.test.ts +++ b/src/cli/operations/dev/__tests__/container-dev-server.test.ts @@ -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(); @@ -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' }, diff --git a/src/lib/packaging/__tests__/container.test.ts b/src/lib/packaging/__tests__/container.test.ts index f60a6ad2f..4e7e10661 100644 --- a/src/lib/packaging/__tests__/container.test.ts +++ b/src/lib/packaging/__tests__/container.test.ts @@ -235,6 +235,10 @@ describe('ContainerPackager', () => { const buildArgs = buildCall![1] as string[]; // Last arg should be the buildContextPath, not codeLocation expect(buildArgs[buildArgs.length - 1]).toBe('/resolved/context'); + // The Dockerfile (-f) must be resolved against the build context, not codeLocation — otherwise the + // local build diverges from the deploy build (which resolves it against the uploaded context root). + const fIdx = buildArgs.indexOf('-f'); + expect(buildArgs[fIdx + 1]).toBe('/resolved/context/Dockerfile'); }); it('passes customDockerBuildArgs as --build-arg flags', async () => { diff --git a/src/schema/schemas/__tests__/agent-env.test.ts b/src/schema/schemas/__tests__/agent-env.test.ts index ec1da045e..abdacc0d8 100644 --- a/src/schema/schemas/__tests__/agent-env.test.ts +++ b/src/schema/schemas/__tests__/agent-env.test.ts @@ -642,6 +642,23 @@ describe('AgentEnvSpecSchema - customDockerBuildArgs', () => { const result = AgentEnvSpecSchema.safeParse({ ...validContainerAgent, customDockerBuildArgs: {} }); expect(result.success).toBe(true); }); + + it('rejects a key that is not a valid identifier', () => { + expect( + AgentEnvSpecSchema.safeParse({ ...validContainerAgent, customDockerBuildArgs: { 'has-dash': 'v' } }).success + ).toBe(false); + }); + + it.each(['IMAGE_URI', 'ECR_REGISTRY', 'DOCKERFILE_PATH', 'BUILD_ARG_FLAGS', 'AWS_DEFAULT_REGION', 'CODEBUILD_FOO'])( + 'rejects the build-environment-reserved key %s (would break only on deploy)', + key => { + const result = AgentEnvSpecSchema.safeParse({ ...validContainerAgent, customDockerBuildArgs: { [key]: 'v' } }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('reserved by the build environment'))).toBe(true); + } + } + ); }); describe('AgentEnvSpecSchema - lifecycleConfiguration', () => { diff --git a/src/schema/schemas/agent-env.ts b/src/schema/schemas/agent-env.ts index b7a6a841b..93d12251e 100644 --- a/src/schema/schemas/agent-env.ts +++ b/src/schema/schemas/agent-env.ts @@ -122,6 +122,19 @@ const BuildArgKeySchema = z 'Build arg names must be valid identifiers (letters, digits, underscores; not starting with a digit)' ); +/** + * Build-arg names reserved by the CodeBuild build environment. On deploy each build arg becomes a + * CodeBuild environment-variable override alongside the buildspec's own control vars; a collision + * would either be rejected by StartBuild or shadow a control var (wrong image tag / ECR region), so + * these are rejected up front so a config that builds locally can't fail only on deploy. `AWS_*` and + * `CODEBUILD_*` are reserved by CodeBuild itself. + */ +export const RESERVED_BUILD_ARG_KEYS = ['ECR_REGISTRY', 'IMAGE_URI', 'DOCKERFILE_PATH', 'BUILD_ARG_FLAGS']; + +export function isReservedBuildArgKey(key: string): boolean { + return RESERVED_BUILD_ARG_KEYS.includes(key) || key.startsWith('CODEBUILD_') || key.startsWith('AWS_'); +} + export const EnvVarSchema = z.object({ name: EnvVarNameSchema, value: z.string(), @@ -471,6 +484,15 @@ export const AgentEnvSpecSchema = z path: ['customDockerBuildArgs'], }); } + for (const key of Object.keys(data.customDockerBuildArgs ?? {})) { + if (isReservedBuildArgKey(key)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `customDockerBuildArgs key "${key}" is reserved by the build environment (must not be ${RESERVED_BUILD_ARG_KEYS.join(', ')}, or start with CODEBUILD_ or AWS_)`, + path: ['customDockerBuildArgs', key], + }); + } + } const fcs = data.filesystemConfigurations ?? []; if (fcs.length > 0) { const efsCount = fcs.filter(fc => 'efsAccessPoint' in fc).length; From ac7b7fa5bd6c2a918ca42afc181f955d7aadd643 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 8 Jul 2026 20:05:56 +0000 Subject: [PATCH 3/5] fix(container): refine build-arg guards; warn on missing build-context .dockerignore Address code-review findings (paired with @aws/agentcore-cdk): - Reserved build-arg keys: narrow the denylist to genuinely build-breaking names (PATH, HOME, IFS, LD_PRELOAD, LD_LIBRARY_PATH) plus the docker-client vars (DOCKER_HOST, DOCKER_CONFIG, DOCKER_TLS_VERIFY, DOCKER_CERT_PATH); stop over-rejecting common ARGs (USER, LANG, ...). - Constrain customDockerBuildArgs values (max 4096, no control chars) so a schema-valid config can't be rejected by CodeBuild only on deploy. - Deduplicate the --build-arg flag construction into getCustomBuildArgs() in build-args.ts (used by the package and dev build paths). - deploy preflight: warn when buildContextPath is set but the context root has no .dockerignore (secrets/junk would otherwise be sent to Docker/CodeBuild). Both local and deploy honor the same root .dockerignore. - docs + compacted schema (@regex/@max) updated. npm test (5836), typecheck, lint, build all green. --- docs/container-builds.md | 8 +++ .../__tests__/preflight-container.test.ts | 27 ++++++++ src/cli/operations/deploy/preflight.ts | 10 +++ .../operations/dev/container-dev-server.ts | 7 +-- src/lib/packaging/build-args.ts | 9 +++ src/lib/packaging/container.ts | 7 +-- src/schema/llm-compacted/agentcore.ts | 4 +- .../schemas/__tests__/agent-env.test.ts | 62 ++++++++++++++++--- src/schema/schemas/agent-env.ts | 46 ++++++++++++-- 9 files changed, 153 insertions(+), 27 deletions(-) diff --git a/docs/container-builds.md b/docs/container-builds.md index 96ca6e02f..e897ffb6d 100644 --- a/docs/container-builds.md +++ b/docs/container-builds.md @@ -138,6 +138,14 @@ COPY app/${AGENT_NAME}/ ./app/ `codeLocation` (e.g. shared libraries at the project root). Without it, Docker only sees the `codeLocation` directory as its build context. +> **Keep a `.dockerignore` 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. Add a `.dockerignore` at that root (e.g. +> excluding `.env`, `.env.*`, `.git/`, `.venv/`, `node_modules/`) to keep secrets and junk out of the image. Both local +> `agentcore dev` / `agentcore package` and `agentcore deploy` honor the same root `.dockerignore`, so what you exclude +> locally is exactly what's excluded from the CodeBuild upload. (The generated per-agent `.dockerignore` lives in +> `codeLocation`, which is no longer the context root once `buildContextPath` is set, so move or recreate it at the +> root.) + **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. diff --git a/src/cli/operations/deploy/__tests__/preflight-container.test.ts b/src/cli/operations/deploy/__tests__/preflight-container.test.ts index eeb5e3d5f..095ecbbec 100644 --- a/src/cli/operations/deploy/__tests__/preflight-container.test.ts +++ b/src/cli/operations/deploy/__tests__/preflight-container.test.ts @@ -149,6 +149,33 @@ describe('validateContainerAgents', () => { expect(calledPath).not.toContain('agents/mono'); }); + it('warns when buildContextPath is set but the context root has no .dockerignore', () => { + // Dockerfile exists, .dockerignore does not. + mockedExistsSync.mockImplementation((p: unknown) => !String(p).endsWith('.dockerignore')); + mockValidDockerfile(); + 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(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('no .dockerignore')); + warnSpy.mockRestore(); + }); + + it('does not warn about .dockerignore when buildContextPath is unset', () => { + mockedExistsSync.mockReturnValue(true); + mockValidDockerfile(); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + const spec = makeSpec([{ name: 'plain', build: 'Container', codeLocation: dir('agents/plain') }]); + + validateContainerAgents(spec, CONFIG_ROOT); + expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining('no .dockerignore')); + warnSpy.mockRestore(); + }); + it('warns when Dockerfile uses deprecated bookworm base image', () => { mockedExistsSync.mockReturnValue(true); mockedReadFileSync.mockReturnValue( diff --git a/src/cli/operations/deploy/preflight.ts b/src/cli/operations/deploy/preflight.ts index 57f8a79f6..aba89ac2b 100644 --- a/src/cli/operations/deploy/preflight.ts +++ b/src/cli/operations/deploy/preflight.ts @@ -210,6 +210,16 @@ export function validateContainerAgents(projectSpec: AgentCoreProjectSpec, confi } else { warnDeprecatedBaseImage(dockerfilePath, agent.name); } + // buildContextPath widens the docker build context (e.g. the whole repo). Without a + // .dockerignore at that root, secrets/junk (.env, .git) get baked into the image and uploaded + // to CodeBuild. Both local and deploy honor this .dockerignore, so recommend one when missing. + if (agent.buildContextPath && !existsSync(path.join(buildContext, '.dockerignore'))) { + console.warn( + `Warning: Agent "${agent.name}" sets buildContextPath but has no .dockerignore at ${buildContext}. ` + + `The entire build context is sent to Docker/CodeBuild — add a .dockerignore there (e.g. excluding ` + + `.env, .env.*, .git/) to keep secrets and junk out of the image.` + ); + } } } if (errors.length > 0) { diff --git a/src/cli/operations/dev/container-dev-server.ts b/src/cli/operations/dev/container-dev-server.ts index aa6184bda..cf841a9e6 100644 --- a/src/cli/operations/dev/container-dev-server.ts +++ b/src/cli/operations/dev/container-dev-server.ts @@ -1,5 +1,5 @@ import { CONTAINER_INTERNAL_PORT, DOCKERFILE_NAME, getDockerfilePath } from '../../../lib'; -import { getUvBuildArgs } from '../../../lib/packaging/build-args'; +import { getCustomBuildArgs, getUvBuildArgs } from '../../../lib/packaging/build-args'; import { detectContainerRuntime } from '../../external-requirements/detect'; import { DevServer, type LogLevel, type SpawnConfig } from './dev-server'; import { waitForServerReady } from './utils'; @@ -79,10 +79,7 @@ export class ContainerDevServer extends DevServer { // 4. Build the container image, streaming output in real-time onLog('system', `Building container image: ${this.imageName}...`); - const buildArgFlags = Object.entries(this.config.customDockerBuildArgs ?? {}).flatMap(([k, v]) => [ - '--build-arg', - `${k}=${v}`, - ]); + const buildArgFlags = getCustomBuildArgs(this.config.customDockerBuildArgs); const exitCode = await this.streamBuild( ['-t', this.imageName, '-f', dockerfilePath, ...getUvBuildArgs(), ...buildArgFlags, buildContext], onLog diff --git a/src/lib/packaging/build-args.ts b/src/lib/packaging/build-args.ts index eadc35875..5bee4c237 100644 --- a/src/lib/packaging/build-args.ts +++ b/src/lib/packaging/build-args.ts @@ -11,3 +11,12 @@ export function getUvBuildArgs(): string[] { if (config.uvIndex) args.push('--build-arg', `UV_INDEX=${config.uvIndex}`); return args; } + +/** + * Return Docker `--build-arg KEY=VALUE` flags for an agent's `customDockerBuildArgs`. + * Returns an empty array when there are none. Shared by the local `package` and `dev` build paths + * so the flag encoding stays in one place. + */ +export function getCustomBuildArgs(customDockerBuildArgs?: Record): string[] { + return Object.entries(customDockerBuildArgs ?? {}).flatMap(([key, value]) => ['--build-arg', `${key}=${value}`]); +} diff --git a/src/lib/packaging/container.ts b/src/lib/packaging/container.ts index fcdc0ff7c..c75c5fc7b 100644 --- a/src/lib/packaging/container.ts +++ b/src/lib/packaging/container.ts @@ -1,7 +1,7 @@ import type { AgentEnvSpec } from '../../schema'; import { CONTAINER_RUNTIMES, DOCKERFILE_NAME, ONE_GB, getDockerfilePath } from '../constants'; import { PackagingError } from '../errors/types'; -import { getUvBuildArgs } from './build-args'; +import { getCustomBuildArgs, getUvBuildArgs } from './build-args'; import { resolveCodeLocation } from './helpers'; import type { ArtifactResult, PackageOptions, RuntimePackager } from './types/packaging'; import { spawnSync } from 'child_process'; @@ -63,10 +63,7 @@ export class ContainerPackager implements RuntimePackager { // Build locally const imageName = `agentcore-package-${agentName}`; - const buildArgFlags = Object.entries(spec.customDockerBuildArgs ?? {}).flatMap(([k, v]) => [ - '--build-arg', - `${k}=${v}`, - ]); + const buildArgFlags = getCustomBuildArgs(spec.customDockerBuildArgs); const buildResult = spawnSync( runtime, ['build', '-t', imageName, '-f', dockerfilePath, ...getUvBuildArgs(), ...buildArgFlags, buildContext], diff --git a/src/schema/llm-compacted/agentcore.ts b/src/schema/llm-compacted/agentcore.ts index 46be9e490..05b9a9397 100644 --- a/src/schema/llm-compacted/agentcore.ts +++ b/src/schema/llm-compacted/agentcore.ts @@ -102,9 +102,9 @@ interface AgentEnvSpec { build: BuildType; entrypoint: string; // @regex ^[a-zA-Z0-9_][a-zA-Z0-9_/.-]*\.(py|ts|js)(:[a-zA-Z_][a-zA-Z0-9_]*)?$ e.g. "main.py:handler" or "index.ts" codeLocation: string; // Directory path - dockerfile?: string; // Dockerfile for Container builds, resolved relative to the build context (buildContextPath ?? codeLocation). Filename or relative subpath; no traversal. Default: 'Dockerfile'. + dockerfile?: string; // @regex ^[a-zA-Z0-9][a-zA-Z0-9._/-]*$ @max 255 Dockerfile for Container builds, resolved relative to the build context (buildContextPath ?? codeLocation). Filename or relative subpath; no traversal. Default: 'Dockerfile'. buildContextPath?: string; // Container only. Docker build context directory; replaces codeLocation as the `docker build` context (e.g. a monorepo root shared across agents). - customDockerBuildArgs?: Record; // Container only. Key/value pairs forwarded as --build-arg flags. Keys must be valid identifiers. + customDockerBuildArgs?: Record; // Container only. --build-arg flags. Keys @regex ^[A-Za-z_][A-Za-z0-9_]*$ (reserved build-env names rejected); values @max 4096, no control chars. runtimeVersion?: RuntimeVersion; envVars?: EnvVar[]; networkMode?: NetworkMode; // default 'PUBLIC' diff --git a/src/schema/schemas/__tests__/agent-env.test.ts b/src/schema/schemas/__tests__/agent-env.test.ts index abdacc0d8..c7c0c1c33 100644 --- a/src/schema/schemas/__tests__/agent-env.test.ts +++ b/src/schema/schemas/__tests__/agent-env.test.ts @@ -649,16 +649,60 @@ describe('AgentEnvSpecSchema - customDockerBuildArgs', () => { ).toBe(false); }); - it.each(['IMAGE_URI', 'ECR_REGISTRY', 'DOCKERFILE_PATH', 'BUILD_ARG_FLAGS', 'AWS_DEFAULT_REGION', 'CODEBUILD_FOO'])( - 'rejects the build-environment-reserved key %s (would break only on deploy)', - key => { - const result = AgentEnvSpecSchema.safeParse({ ...validContainerAgent, customDockerBuildArgs: { [key]: 'v' } }); - expect(result.success).toBe(false); - if (!result.success) { - expect(result.error.issues.some(i => i.message.includes('reserved by the build environment'))).toBe(true); - } + it.each([ + 'IMAGE_URI', + 'ECR_REGISTRY', + 'DOCKERFILE_PATH', + 'BUILD_ARG_FLAGS', + 'AWS_DEFAULT_REGION', + 'CODEBUILD_FOO', + 'PATH', + 'HOME', + 'IFS', + 'LD_PRELOAD', + 'LD_LIBRARY_PATH', + 'DOCKER_HOST', + 'DOCKER_CONFIG', + ])('rejects the build-environment-reserved key %s (would break only on deploy)', key => { + const result = AgentEnvSpecSchema.safeParse({ ...validContainerAgent, customDockerBuildArgs: { [key]: 'v' } }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('reserved by the build environment'))).toBe(true); + } + }); + + it.each(['USER', 'LANG', 'SHELL', 'TERM'])('accepts the common non-dangerous build-arg key %s', key => { + // Legitimate Dockerfile ARGs (e.g. `ARG USER` -> `useradd $USER`) must not be over-rejected. + const result = AgentEnvSpecSchema.safeParse({ ...validContainerAgent, customDockerBuildArgs: { [key]: 'v' } }); + expect(result.success).toBe(true); + }); + + it('accepts a normal value with spaces and symbols', () => { + const result = AgentEnvSpecSchema.safeParse({ + ...validContainerAgent, + customDockerBuildArgs: { GREETING: 'hello world = ok!' }, + }); + expect(result.success).toBe(true); + }); + + it('rejects a value containing a newline or control character', () => { + const result = AgentEnvSpecSchema.safeParse({ + ...validContainerAgent, + customDockerBuildArgs: { BAD: 'line1\nline2' }, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('control characters'))).toBe(true); } - ); + }); + + it('rejects a value longer than 4096 characters', () => { + const result = AgentEnvSpecSchema.safeParse({ + ...validContainerAgent, + customDockerBuildArgs: { BIG: 'x'.repeat(4097) }, + }); + expect(result.success).toBe(false); + }); }); describe('AgentEnvSpecSchema - lifecycleConfiguration', () => { diff --git a/src/schema/schemas/agent-env.ts b/src/schema/schemas/agent-env.ts index 93d12251e..3c7723a0f 100644 --- a/src/schema/schemas/agent-env.ts +++ b/src/schema/schemas/agent-env.ts @@ -124,17 +124,51 @@ const BuildArgKeySchema = z /** * Build-arg names reserved by the CodeBuild build environment. On deploy each build arg becomes a - * CodeBuild environment-variable override alongside the buildspec's own control vars; a collision - * would either be rejected by StartBuild or shadow a control var (wrong image tag / ECR region), so - * these are rejected up front so a config that builds locally can't fail only on deploy. `AWS_*` and - * `CODEBUILD_*` are reserved by CodeBuild itself. + * CodeBuild environment-variable override alongside the buildspec's own control vars, so a colliding + * name would break the deploy build — a wrong image tag / ECR region, a rejected StartBuild, or a + * clobbered build-container shell. We reject the buildspec control vars, the process-critical shell + * vars, and CodeBuild's own `AWS_*` / `CODEBUILD_*` namespaces. This is a best-effort denylist (the + * shell has other sensitive vars), but it covers the realistic footguns and keeps a config that + * builds locally from failing only on deploy. */ -export const RESERVED_BUILD_ARG_KEYS = ['ECR_REGISTRY', 'IMAGE_URI', 'DOCKERFILE_PATH', 'BUILD_ARG_FLAGS']; +export const RESERVED_BUILD_ARG_KEYS = [ + // Buildspec control vars (mirrored in @aws/agentcore-cdk's ContainerBuildProject / build handler). + 'ECR_REGISTRY', + 'IMAGE_URI', + 'DOCKERFILE_PATH', + 'BUILD_ARG_FLAGS', + // Overriding these in the build shell would break the buildspec's own commands (command lookup, + // word-splitting, dynamic linking) or redirect the docker client off the build daemon. Kept + // deliberately narrow to the genuinely-dangerous names so common ARGs (e.g. USER, LANG) stay usable. + 'PATH', + 'HOME', + 'IFS', + 'LD_PRELOAD', + 'LD_LIBRARY_PATH', + 'DOCKER_HOST', + 'DOCKER_CONFIG', + 'DOCKER_TLS_VERIFY', + 'DOCKER_CERT_PATH', +]; export function isReservedBuildArgKey(key: string): boolean { return RESERVED_BUILD_ARG_KEYS.includes(key) || key.startsWith('CODEBUILD_') || key.startsWith('AWS_'); } +/** + * Build-arg value. Bounded and control-char-free because on deploy each value becomes a CodeBuild + * `environmentVariablesOverride` PLAINTEXT entry; newlines/control chars or an over-long value can be + * rejected by StartBuild, so we reject them at schema time rather than let a local-only-valid config + * fail on deploy. + */ +const BuildArgValueSchema = z + .string() + .max(4096, 'Build arg values must be at most 4096 characters') + .refine( + v => ![...v].some(ch => ch.charCodeAt(0) < 0x20 || ch.charCodeAt(0) === 0x7f), + 'Build arg values must not contain control characters (including newlines)' + ); + export const EnvVarSchema = z.object({ name: EnvVarNameSchema, value: z.string(), @@ -377,7 +411,7 @@ export const AgentEnvSpecSchema = z * identifiers. Useful for parameterising a shared Dockerfile per agent (e.g. `{ "AGENT_NAME": "myagent" }`). * Container builds only. */ - customDockerBuildArgs: z.record(BuildArgKeySchema, z.string()).optional(), + customDockerBuildArgs: z.record(BuildArgKeySchema, BuildArgValueSchema).optional(), runtimeVersion: RuntimeVersionSchemaFromConstants.optional(), /** Environment variables to set on the runtime */ envVars: z.array(EnvVarSchema).optional(), From 3d0b8a8d0349db417af9375b1ff35ec5d2d3db8a Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 8 Jul 2026 20:57:34 +0000 Subject: [PATCH 4/5] feat(container): generate a build-context .dockerignore when buildContextPath is set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the secret-exposure gap without reintroducing the local/deploy divergence: when a Container agent sets buildContextPath and the build-context root has no .dockerignore, the CLI generates one (excluding .env, .env.*, .git/, node_modules/, .venv/, __pycache__/, agentcore/). Because it's a real file, both local `docker build` and the CodeBuild deploy path honor the same rules — secrets/junk stay out of the image and the S3 upload, consistently, with no forced-exclude false positives (users edit the file to include anything intentional). This mirrors the CodeZip packager's always-on secret guard. - New shared helper ensureBuildContextDockerignore(); never overwrites an existing file. - Wired into the package, dev, and deploy-preflight build paths (replaces the prior "no .dockerignore" warning with actually creating one + a log line). - docs updated; unit tests for the helper (create/no-op) and the preflight wiring. Validated live end to end: the generated .dockerignore trimmed the CodeBuild context (agentcore/, .git, .venv excluded) and the build succeeded. --- docs/container-builds.md | 14 +++---- .../__tests__/preflight-container.test.ts | 21 +++++++---- src/cli/operations/deploy/preflight.ts | 31 +++++++++++----- .../operations/dev/container-dev-server.ts | 12 ++++++ .../build-context-dockerignore.test.ts | 37 +++++++++++++++++++ .../packaging/build-context-dockerignore.ts | 37 +++++++++++++++++++ src/lib/packaging/container.ts | 6 +++ src/lib/packaging/index.ts | 1 + 8 files changed, 134 insertions(+), 25 deletions(-) create mode 100644 src/lib/packaging/__tests__/build-context-dockerignore.test.ts create mode 100644 src/lib/packaging/build-context-dockerignore.ts diff --git a/docs/container-builds.md b/docs/container-builds.md index e897ffb6d..426824787 100644 --- a/docs/container-builds.md +++ b/docs/container-builds.md @@ -138,13 +138,13 @@ COPY app/${AGENT_NAME}/ ./app/ `codeLocation` (e.g. shared libraries at the project root). Without it, Docker only sees the `codeLocation` directory as its build context. -> **Keep a `.dockerignore` 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. Add a `.dockerignore` at that root (e.g. -> excluding `.env`, `.env.*`, `.git/`, `.venv/`, `node_modules/`) to keep secrets and junk out of the image. Both local -> `agentcore dev` / `agentcore package` and `agentcore deploy` honor the same root `.dockerignore`, so what you exclude -> locally is exactly what's excluded from the CodeBuild upload. (The generated per-agent `.dockerignore` lives in -> `codeLocation`, which is no longer the context root once `buildContextPath` is set, so move or recreate it at the -> root.) +> **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. diff --git a/src/cli/operations/deploy/__tests__/preflight-container.test.ts b/src/cli/operations/deploy/__tests__/preflight-container.test.ts index 095ecbbec..f5901a90e 100644 --- a/src/cli/operations/deploy/__tests__/preflight-container.test.ts +++ b/src/cli/operations/deploy/__tests__/preflight-container.test.ts @@ -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(), @@ -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(), @@ -149,10 +152,11 @@ describe('validateContainerAgents', () => { expect(calledPath).not.toContain('agents/mono'); }); - it('warns when buildContextPath is set but the context root has no .dockerignore', () => { - // Dockerfile exists, .dockerignore does not. - mockedExistsSync.mockImplementation((p: unknown) => !String(p).endsWith('.dockerignore')); + 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([ @@ -160,20 +164,21 @@ describe('validateContainerAgents', () => { ]); expect(() => validateContainerAgents(spec, CONFIG_ROOT)).not.toThrow(); - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('no .dockerignore')); + // 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 warn about .dockerignore when buildContextPath is unset', () => { + it('does not touch .dockerignore when buildContextPath is unset', () => { mockedExistsSync.mockReturnValue(true); mockValidDockerfile(); - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); const spec = makeSpec([{ name: 'plain', build: 'Container', codeLocation: dir('agents/plain') }]); validateContainerAgents(spec, CONFIG_ROOT); - expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining('no .dockerignore')); - warnSpy.mockRestore(); + expect(ensureMock).not.toHaveBeenCalled(); }); it('warns when Dockerfile uses deprecated bookworm base image', () => { diff --git a/src/cli/operations/deploy/preflight.ts b/src/cli/operations/deploy/preflight.ts index aba89ac2b..442ee3cc3 100644 --- a/src/cli/operations/deploy/preflight.ts +++ b/src/cli/operations/deploy/preflight.ts @@ -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'; @@ -210,15 +217,19 @@ export function validateContainerAgents(projectSpec: AgentCoreProjectSpec, confi } else { warnDeprecatedBaseImage(dockerfilePath, agent.name); } - // buildContextPath widens the docker build context (e.g. the whole repo). Without a - // .dockerignore at that root, secrets/junk (.env, .git) get baked into the image and uploaded - // to CodeBuild. Both local and deploy honor this .dockerignore, so recommend one when missing. - if (agent.buildContextPath && !existsSync(path.join(buildContext, '.dockerignore'))) { - console.warn( - `Warning: Agent "${agent.name}" sets buildContextPath but has no .dockerignore at ${buildContext}. ` + - `The entire build context is sent to Docker/CodeBuild — add a .dockerignore there (e.g. excluding ` + - `.env, .env.*, .git/) to keep secrets and junk out of the image.` - ); + // 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); + 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.` + ); + } } } } diff --git a/src/cli/operations/dev/container-dev-server.ts b/src/cli/operations/dev/container-dev-server.ts index cf841a9e6..b6922020c 100644 --- a/src/cli/operations/dev/container-dev-server.ts +++ b/src/cli/operations/dev/container-dev-server.ts @@ -1,5 +1,6 @@ import { CONTAINER_INTERNAL_PORT, DOCKERFILE_NAME, getDockerfilePath } from '../../../lib'; import { getCustomBuildArgs, getUvBuildArgs } from '../../../lib/packaging/build-args'; +import { ensureBuildContextDockerignore } from '../../../lib/packaging/build-context-dockerignore'; import { detectContainerRuntime } from '../../external-requirements/detect'; import { DevServer, type LogLevel, type SpawnConfig } from './dev-server'; import { waitForServerReady } from './utils'; @@ -67,6 +68,17 @@ export class ContainerDevServer extends DevServer { // 2. Verify Dockerfile exists (resolved relative to the build context, matching deploy) const buildContext = this.config.buildContextPath ?? this.config.directory; + // When the context is widened via buildContextPath, ensure a .dockerignore keeps secrets/junk out + // of the local image — the same file the deploy (CodeBuild) path honors, so both stay consistent. + if (this.config.buildContextPath) { + const created = ensureBuildContextDockerignore(buildContext); + if (created) { + onLog( + 'system', + `Created ${created} with default secret exclusions (buildContextPath is set); review and commit it.` + ); + } + } const dockerfileName = this.config.dockerfile ?? DOCKERFILE_NAME; const dockerfilePath = getDockerfilePath(buildContext, this.config.dockerfile); if (!existsSync(dockerfilePath)) { diff --git a/src/lib/packaging/__tests__/build-context-dockerignore.test.ts b/src/lib/packaging/__tests__/build-context-dockerignore.test.ts new file mode 100644 index 000000000..f12ed6c63 --- /dev/null +++ b/src/lib/packaging/__tests__/build-context-dockerignore.test.ts @@ -0,0 +1,37 @@ +import { BUILD_CONTEXT_DOCKERIGNORE_TEMPLATE, ensureBuildContextDockerignore } from '../build-context-dockerignore.js'; +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; + +describe('ensureBuildContextDockerignore', () => { + const created: string[] = []; + afterEach(() => { + created.length = 0; + }); + + it('creates a .dockerignore with secret exclusions when none exists', () => { + const ctx = mkdtempSync(join(tmpdir(), 'bctx-')); + const result = ensureBuildContextDockerignore(ctx); + + expect(result).toBe(join(ctx, '.dockerignore')); + expect(existsSync(join(ctx, '.dockerignore'))).toBe(true); + const contents = readFileSync(join(ctx, '.dockerignore'), 'utf-8'); + expect(contents).toBe(BUILD_CONTEXT_DOCKERIGNORE_TEMPLATE); + // Covers the real secret/junk vectors. + for (const pattern of ['.env', '.env.*', '.git/', 'agentcore/']) { + expect(contents).toContain(pattern); + } + }); + + it('never overwrites an existing .dockerignore (returns null)', () => { + const ctx = mkdtempSync(join(tmpdir(), 'bctx-')); + writeFileSync(join(ctx, '.dockerignore'), '# user-owned\ncustom-pattern\n'); + + const result = ensureBuildContextDockerignore(ctx); + + expect(result).toBeNull(); + // The user's content is preserved untouched. + expect(readFileSync(join(ctx, '.dockerignore'), 'utf-8')).toBe('# user-owned\ncustom-pattern\n'); + }); +}); diff --git a/src/lib/packaging/build-context-dockerignore.ts b/src/lib/packaging/build-context-dockerignore.ts new file mode 100644 index 000000000..4861d8acf --- /dev/null +++ b/src/lib/packaging/build-context-dockerignore.ts @@ -0,0 +1,37 @@ +import { existsSync, writeFileSync } from 'fs'; +import { join } from 'path'; + +/** + * Default `.dockerignore` written at a Container agent's build-context root when `buildContextPath` + * is set and none exists. Keeps secrets and build junk out of the image and out of the CodeBuild + * upload. Both local `agentcore dev`/`package` and `agentcore deploy` honor this same file, so what + * is excluded locally is exactly what is excluded on deploy. + */ +export const BUILD_CONTEXT_DOCKERIGNORE_TEMPLATE = `# Generated by agentcore because this agent sets buildContextPath — the whole of this directory is the +# Docker build context, so secrets/junk here would otherwise be baked into the image and uploaded to +# CodeBuild. Both 'agentcore dev'/'package' and 'agentcore deploy' honor this file. Edit freely; to +# intentionally include a file listed here (e.g. a non-secret .env.production), remove its line. +.env +.env.* +.git/ +node_modules/ +.venv/ +__pycache__/ +agentcore/ +`; + +/** + * Ensure a `.dockerignore` exists at the resolved build-context root. No-op when one already exists + * (the user's file is never overwritten). Returns the path when it creates one, otherwise null. + * + * Called only when `buildContextPath` is set — that is when the context is widened beyond the agent's + * own `codeLocation` (e.g. to a monorepo root) and a stray root-level `.env`/`.git` would be swept in. + */ +export function ensureBuildContextDockerignore(resolvedBuildContext: string): string | null { + const dockerignorePath = join(resolvedBuildContext, '.dockerignore'); + if (existsSync(dockerignorePath)) { + return null; + } + writeFileSync(dockerignorePath, BUILD_CONTEXT_DOCKERIGNORE_TEMPLATE); + return dockerignorePath; +} diff --git a/src/lib/packaging/container.ts b/src/lib/packaging/container.ts index c75c5fc7b..038306d38 100644 --- a/src/lib/packaging/container.ts +++ b/src/lib/packaging/container.ts @@ -2,6 +2,7 @@ import type { AgentEnvSpec } from '../../schema'; import { CONTAINER_RUNTIMES, DOCKERFILE_NAME, ONE_GB, getDockerfilePath } from '../constants'; import { PackagingError } from '../errors/types'; import { getCustomBuildArgs, getUvBuildArgs } from './build-args'; +import { ensureBuildContextDockerignore } from './build-context-dockerignore'; import { resolveCodeLocation } from './helpers'; import type { ArtifactResult, PackageOptions, RuntimePackager } from './types/packaging'; import { spawnSync } from 'child_process'; @@ -38,6 +39,11 @@ export class ContainerPackager implements RuntimePackager { const buildContext = spec.buildContextPath ? resolveCodeLocation(spec.buildContextPath, configBaseDir) : codeLocation; + // When the context is widened via buildContextPath, ensure a .dockerignore keeps secrets/junk out + // of the local image — the same file the deploy (CodeBuild) path honors, so both stay consistent. + if (spec.buildContextPath) { + ensureBuildContextDockerignore(buildContext); + } // Dockerfile is resolved relative to the build context (matches the deploy/CodeBuild path). const dockerfilePath = getDockerfilePath(buildContext, spec.dockerfile); diff --git a/src/lib/packaging/index.ts b/src/lib/packaging/index.ts index 8c3067ca3..d0e6590c8 100644 --- a/src/lib/packaging/index.ts +++ b/src/lib/packaging/index.ts @@ -93,3 +93,4 @@ export type { } from './types/packaging'; export { resolveCodeLocation } from './helpers'; +export { ensureBuildContextDockerignore } from './build-context-dockerignore'; From 4bfd7fa04b182b05a8558b1f9e08460035d0f2a3 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 9 Jul 2026 04:59:22 +0000 Subject: [PATCH 5/5] fix(package): lower-case the container package image tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `agentcore package` built its local image tag as `agentcore-package-${agentName}` without lowercasing, so `docker build -t` rejected any capitalized agent name (e.g. the default `AgentOne`) with "repository name must be lowercase" — the package command was broken for essentially every default container agent. The dev server already lower-cases its tag; the packager now matches. Pre-existing bug (unrelated to buildContextPath/customDockerBuildArgs), surfaced while live-testing `agentcore package` on a container agent. Added a unit test with a mixed-case name. --- src/lib/packaging/__tests__/container.test.ts | 22 +++++++++++++++++++ src/lib/packaging/container.ts | 5 +++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/lib/packaging/__tests__/container.test.ts b/src/lib/packaging/__tests__/container.test.ts index 4e7e10661..015137226 100644 --- a/src/lib/packaging/__tests__/container.test.ts +++ b/src/lib/packaging/__tests__/container.test.ts @@ -121,6 +121,28 @@ describe('ContainerPackager', () => { expect(result.artifactPath).toBe('docker://agentcore-package-custom-agent'); }); + it('lower-cases the image tag for a mixed-case agent name (docker tags must be lowercase)', async () => { + mockResolveCodeLocation.mockReturnValue('/resolved/src'); + mockExistsSync.mockReturnValue(true); + mockSpawnSync.mockImplementation((cmd: string, args: string[]) => { + if (cmd === 'which' && args[0] === 'docker') return { status: 0 }; + if (cmd === 'docker' && args[0] === '--version') return { status: 0 }; + if (cmd === 'docker' && args[0] === 'build') return { status: 0 }; + if (cmd === 'docker' && args[0] === 'image') return { status: 0, stdout: Buffer.from('1000') }; + return { status: 1 }; + }); + + const result = await packager.pack({ ...baseSpec, name: 'AgentOne' } as any); + + // Docker rejects uppercase in image tags; the default agent name is PascalCase, so it must be lowered. + expect(result.artifactPath).toBe('docker://agentcore-package-agentone'); + const buildCall = mockSpawnSync.mock.calls.find( + (c: unknown[]) => c[0] === 'docker' && (c[1] as string[])[0] === 'build' + ); + const buildArgs = buildCall![1] as string[]; + expect(buildArgs[buildArgs.indexOf('-t') + 1]).toBe('agentcore-package-agentone'); + }); + it('uses options.artifactDir as configBaseDir', async () => { mockResolveCodeLocation.mockReturnValue('/artifact/dir/src'); mockExistsSync.mockReturnValue(true); diff --git a/src/lib/packaging/container.ts b/src/lib/packaging/container.ts index 038306d38..4ce32f65e 100644 --- a/src/lib/packaging/container.ts +++ b/src/lib/packaging/container.ts @@ -67,8 +67,9 @@ export class ContainerPackager implements RuntimePackager { }); } - // Build locally - const imageName = `agentcore-package-${agentName}`; + // Build locally. Docker image tags must be lowercase, but agent names allow uppercase + // (e.g. the default "AgentOne"), so lower-case the tag — matching the dev server. + const imageName = `agentcore-package-${agentName}`.toLowerCase(); const buildArgFlags = getCustomBuildArgs(spec.customDockerBuildArgs); const buildResult = spawnSync( runtime,