diff --git a/docs/configuration.md b/docs/configuration.md index bac57bc00..7f81358e6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 diff --git a/docs/container-builds.md b/docs/container-builds.md index 1f34ea64e..426824787 100644 --- a/docs/container-builds.md +++ b/docs/container-builds.md @@ -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", + "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. + +> **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 diff --git a/src/cli/operations/deploy/__tests__/preflight-container.test.ts b/src/cli/operations/deploy/__tests__/preflight-container.test.ts index ff5f5c3c9..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(), @@ -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( diff --git a/src/cli/operations/deploy/preflight.ts b/src/cli/operations/deploy/preflight.ts index bf307b87f..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'; @@ -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( @@ -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); + 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) { diff --git a/src/cli/operations/dev/__tests__/config.test.ts b/src/cli/operations/dev/__tests__/config.test.ts index 6dbc25885..38b096451 100644 --- a/src/cli/operations/dev/__tests__/config.test.ts +++ b/src/cli/operations/dev/__tests__/config.test.ts @@ -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', () => { 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/cli/operations/dev/config.ts b/src/cli/operations/dev/config.ts index 37cd4c48d..0d5a358e7 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 { @@ -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, }; } diff --git a/src/cli/operations/dev/container-dev-server.ts b/src/cli/operations/dev/container-dev-server.ts index 5fb21ee29..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 { getUvBuildArgs } from '../../../lib/packaging/build-args'; +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'; @@ -65,9 +66,21 @@ export class ContainerDevServer extends DevServer { } this.runtimeBinary = runtime.binary; - // 2. Verify Dockerfile exists + // 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(this.config.directory, this.config.dockerfile); + const dockerfilePath = getDockerfilePath(buildContext, this.config.dockerfile); if (!existsSync(dockerfilePath)) { onLog('error', `${dockerfileName} not found at ${dockerfilePath}. Container agents require a Dockerfile.`); return false; @@ -78,8 +91,9 @@ export class ContainerDevServer extends DevServer { // 4. Build the container image, streaming output in real-time onLog('system', `Building container image: ${this.imageName}...`); + const buildArgFlags = getCustomBuildArgs(this.config.customDockerBuildArgs); 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/__tests__/constants.test.ts b/src/lib/__tests__/constants.test.ts index 21b952b69..e4796ab96 100644 --- a/src/lib/__tests__/constants.test.ts +++ b/src/lib/__tests__/constants.test.ts @@ -25,23 +25,31 @@ describe('getDockerfilePath', () => { expect(getDockerfilePath('/app/code')).toBe(join('/app/code', 'Dockerfile')); }); - it('returns custom dockerfile name joined to code location', () => { + it('returns custom dockerfile name joined to the build context', () => { expect(getDockerfilePath('/app/code', 'Dockerfile.gpu')).toBe(join('/app/code', 'Dockerfile.gpu')); }); - it('rejects forward slash in dockerfile name', () => { - expect(() => getDockerfilePath('/app/code', '../Dockerfile')).toThrow(/Invalid dockerfile name/); + it('allows a relative subpath within the build context', () => { + expect(getDockerfilePath('/app/code', 'path/to/Dockerfile')).toBe(join('/app/code', 'path/to/Dockerfile')); }); - it('rejects backslash in dockerfile name', () => { - expect(() => getDockerfilePath('/app/code', 'Dockerfile\\..\\secret')).toThrow(/Invalid dockerfile name/); + it('rejects an absolute dockerfile path', () => { + expect(() => getDockerfilePath('/app/code', '/etc/Dockerfile')).toThrow(/Invalid dockerfile path/); }); - it('rejects dot-dot traversal in dockerfile name', () => { - expect(() => getDockerfilePath('/app/code', '..')).toThrow(/Invalid dockerfile name/); + it('rejects leading dot-dot traversal', () => { + expect(() => getDockerfilePath('/app/code', '../Dockerfile')).toThrow(/Invalid dockerfile path/); }); - it('rejects path/to/Dockerfile', () => { - expect(() => getDockerfilePath('/app/code', 'path/to/Dockerfile')).toThrow(/Invalid dockerfile name/); + it('rejects a dot-dot traversal segment mid-path', () => { + expect(() => getDockerfilePath('/app/code', 'a/../secret')).toThrow(/Invalid dockerfile path/); + }); + + it('rejects backslash in dockerfile path', () => { + expect(() => getDockerfilePath('/app/code', 'Dockerfile\\..\\secret')).toThrow(/Invalid dockerfile path/); + }); + + it('rejects a bare dot-dot', () => { + expect(() => getDockerfilePath('/app/code', '..')).toThrow(/Invalid dockerfile path/); }); }); diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 5ef7b5c34..6b42ae5a2 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -51,14 +51,20 @@ export type ContainerRuntime = 'docker' | 'podman' | 'finch'; export const CONTAINER_RUNTIMES: ContainerRuntime[] = ['docker', 'podman', 'finch']; /** - * Get the Dockerfile path for a given code location. - * @param codeLocation - Directory containing the Dockerfile - * @param dockerfile - Custom Dockerfile name (default: 'Dockerfile') + * Resolve the Dockerfile path against the Docker build context. + * @param buildContext - The build context directory (`buildContextPath` when set, else `codeLocation`) + * @param dockerfile - Dockerfile name or relative subpath within the context (default: 'Dockerfile') + * + * `dockerfile` may be a filename ('Dockerfile') or a forward-slash relative subpath + * ('docker/Dockerfile'); absolute paths, backslashes, and `..` traversal are rejected so it can + * never escape the build context. */ -export function getDockerfilePath(codeLocation: string, dockerfile?: string): string { +export function getDockerfilePath(buildContext: string, dockerfile?: string): string { const name = dockerfile ?? DOCKERFILE_NAME; - if (name.includes('/') || name.includes('\\') || name.includes('..')) { - throw new Error(`Invalid dockerfile name: must be a filename without path separators or traversal`); + if (name.startsWith('/') || name.includes('\\') || name.split('/').includes('..')) { + throw new Error( + `Invalid dockerfile path: must be a relative path within the build context (no leading slash, backslash, or ".." traversal)` + ); } - return join(codeLocation, name); + return join(buildContext, name); } 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/__tests__/container.test.ts b/src/lib/packaging/__tests__/container.test.ts index cb5685dbf..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); @@ -210,6 +232,61 @@ 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'); + // 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 () => { + 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/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/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 fc9400a86..4ce32f65e 100644 --- a/src/lib/packaging/container.ts +++ b/src/lib/packaging/container.ts @@ -1,7 +1,8 @@ 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 { ensureBuildContextDockerignore } from './build-context-dockerignore'; import { resolveCodeLocation } from './helpers'; import type { ArtifactResult, PackageOptions, RuntimePackager } from './types/packaging'; import { spawnSync } from 'child_process'; @@ -35,7 +36,16 @@ export class ContainerPackager implements RuntimePackager { const agentName = options.agentName ?? spec.name; 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; + // 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); // Preflight: Dockerfile must exist if (!existsSync(dockerfilePath)) { @@ -57,11 +67,13 @@ 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, - ['build', '-t', imageName, '-f', dockerfilePath, ...getUvBuildArgs(), codeLocation], + ['build', '-t', imageName, '-f', dockerfilePath, ...getUvBuildArgs(), ...buildArgFlags, buildContext], { stdio: 'pipe', } 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'; diff --git a/src/schema/llm-compacted/agentcore.ts b/src/schema/llm-compacted/agentcore.ts index 13e2ba719..05b9a9397 100644 --- a/src/schema/llm-compacted/agentcore.ts +++ b/src/schema/llm-compacted/agentcore.ts @@ -102,7 +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; // Custom Dockerfile name for Container builds (default: 'Dockerfile'). Must be a filename, not a path. + 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. --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 57fc01657..c7c0c1c33 100644 --- a/src/schema/schemas/__tests__/agent-env.test.ts +++ b/src/schema/schemas/__tests__/agent-env.test.ts @@ -506,6 +506,12 @@ describe('AgentEnvSpecSchema - dockerfile', () => { ); }); + it('accepts a relative subpath (resolved against the build context)', () => { + expect(AgentEnvSpecSchema.safeParse({ ...validContainerAgent, dockerfile: 'docker/Dockerfile' }).success).toBe( + true + ); + }); + it('rejects dockerfile on CodeZip builds', () => { const result = AgentEnvSpecSchema.safeParse({ ...validCodeZipAgent, dockerfile: 'Dockerfile.custom' }); expect(result.success).toBe(false); @@ -514,11 +520,10 @@ describe('AgentEnvSpecSchema - dockerfile', () => { } }); - it('rejects path traversal or path separator in dockerfile', () => { + it('rejects path traversal and absolute paths in dockerfile', () => { expect(AgentEnvSpecSchema.safeParse({ ...validContainerAgent, dockerfile: '../Dockerfile' }).success).toBe(false); - expect(AgentEnvSpecSchema.safeParse({ ...validContainerAgent, dockerfile: 'path/to/Dockerfile' }).success).toBe( - false - ); + expect(AgentEnvSpecSchema.safeParse({ ...validContainerAgent, dockerfile: 'a/../secret' }).success).toBe(false); + expect(AgentEnvSpecSchema.safeParse({ ...validContainerAgent, dockerfile: '/etc/Dockerfile' }).success).toBe(false); }); it('rejects empty string dockerfile', () => { @@ -549,6 +554,157 @@ 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); + }); + + 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', + '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', () => { const validAgent = { name: 'TestAgent', diff --git a/src/schema/schemas/agent-env.ts b/src/schema/schemas/agent-env.ts index e5cd74395..3c7723a0f 100644 --- a/src/schema/schemas/agent-env.ts +++ b/src/schema/schemas/agent-env.ts @@ -94,6 +94,81 @@ export const EntrypointSchema = z const DirectoryPathSchema = z.string().min(1) as unknown as z.ZodType; +/** + * Dockerfile location for Container builds, resolved relative to the Docker build context + * (`buildContextPath` when set, otherwise `codeLocation`). Accepts a filename ('Dockerfile', + * 'Dockerfile.gpu') or a forward-slash relative subpath ('docker/Dockerfile'). Absolute paths + * and '..' traversal are rejected so the Dockerfile can never escape the build context. + */ +const DockerfilePathSchema = z + .string() + .min(1) + .max(255) + .regex( + /^[a-zA-Z0-9][a-zA-Z0-9._/-]*$/, + 'Must be a relative path (a filename or forward-slash subpath) with no leading slash or backslash' + ) + .refine(p => !p.split('/').includes('..'), 'Must not contain path traversal ("..")'); + +/** + * Build-arg name. Must be a valid identifier because on deploy each arg is forwarded to CodeBuild as + * an environment variable (values are inherited from the environment, never interpolated onto the + * shell command line) and consumed by Dockerfile `ARG` directives, which are themselves identifiers. + */ +const BuildArgKeySchema = z + .string() + .regex( + /^[A-Za-z_][A-Za-z0-9_]*$/, + '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, 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 = [ + // 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(), @@ -319,13 +394,24 @@ export const AgentEnvSpecSchema = z build: BuildTypeSchema, entrypoint: EntrypointSchema, codeLocation: DirectoryPathSchema, - /** Custom Dockerfile name for Container builds. Must be a filename, not a path. Default: 'Dockerfile' */ - dockerfile: z - .string() - .min(1) - .max(255) - .regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/, 'Must be a filename (no path separators or traversal)') - .optional(), + /** + * Dockerfile for Container builds, resolved relative to the build context (`buildContextPath` + * when set, otherwise `codeLocation`). A filename or a relative subpath; no traversal. Default: 'Dockerfile'. + */ + dockerfile: DockerfilePathSchema.optional(), + /** + * Docker build context directory for Container builds. When set, this directory (instead of + * `codeLocation`) is used as the `docker build` context and as the root the Dockerfile path is + * resolved against, so a single Dockerfile can be shared across multiple agents in a monorepo + * (e.g. so it can COPY files that live outside `codeLocation`). Container builds only. + */ + buildContextPath: DirectoryPathSchema.optional(), + /** + * Custom build arguments passed to `docker build` as `--build-arg` flags. Keys must be valid + * identifiers. Useful for parameterising a shared Dockerfile per agent (e.g. `{ "AGENT_NAME": "myagent" }`). + * Container builds only. + */ + customDockerBuildArgs: z.record(BuildArgKeySchema, BuildArgValueSchema).optional(), runtimeVersion: RuntimeVersionSchemaFromConstants.optional(), /** Environment variables to set on the runtime */ envVars: z.array(EnvVarSchema).optional(), @@ -418,6 +504,29 @@ 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'], + }); + } + 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;