diff --git a/docs-site/src/content/docs/reference/cli-reference.md b/docs-site/src/content/docs/reference/cli-reference.md index d5e0db215..f2d0eb400 100644 --- a/docs-site/src/content/docs/reference/cli-reference.md +++ b/docs-site/src/content/docs/reference/cli-reference.md @@ -32,6 +32,7 @@ awf [options] -- | `--build-local` | flag | `false` | Build containers locally instead of pulling from registry | | `--image-registry ` | string | `ghcr.io/github/gh-aw-firewall` | Container image registry | | `--image-tag ` | string | `latest` | Container image tag | +| `--skip-pull` | flag | `false` | Use local images without pulling from registry | | `-e, --env ` | string | `[]` | Environment variable (repeatable) | | `--env-all` | flag | `false` | Pass all host environment variables | | `-v, --mount ` | string | `[]` | Volume mount (repeatable) | @@ -181,6 +182,31 @@ Custom container image registry URL. Container image tag to use. +### `--skip-pull` + +Use local images without pulling from the registry. This is useful for: + +- **Air-gapped environments** where registry access is unavailable +- **CI systems with pre-warmed image caches** to avoid unnecessary network calls +- **Local development** when images are already cached + +```bash +# Pre-pull images first +docker pull ghcr.io/github/gh-aw-firewall/squid:latest +docker pull ghcr.io/github/gh-aw-firewall/agent:latest + +# Use with --skip-pull to avoid re-pulling +sudo awf --skip-pull --allow-domains github.com -- curl https://api.github.com +``` + +:::caution[Image Verification] +When using `--skip-pull`, you are responsible for verifying image authenticity. The firewall cannot verify that locally cached images haven't been tampered with. See [Image Verification](/gh-aw-firewall/docs/image-verification/) for cosign verification instructions. +::: + +:::note[Incompatible with --build-local] +The `--skip-pull` flag cannot be used with `--build-local` since building images requires pulling base images from the registry. +::: + ### `-e, --env ` Pass environment variable to container. Can be specified multiple times. diff --git a/src/cli-workflow.ts b/src/cli-workflow.ts index 4551d3284..62ad1511f 100644 --- a/src/cli-workflow.ts +++ b/src/cli-workflow.ts @@ -4,7 +4,7 @@ export interface WorkflowDependencies { ensureFirewallNetwork: () => Promise<{ squidIp: string }>; setupHostIptables: (squidIp: string, port: number, dnsServers: string[]) => Promise; writeConfigs: (config: WrapperConfig) => Promise; - startContainers: (workDir: string, allowedDomains: string[], proxyLogsDir?: string) => Promise; + startContainers: (workDir: string, allowedDomains: string[], proxyLogsDir?: string, skipPull?: boolean) => Promise; runAgentCommand: ( workDir: string, allowedDomains: string[], @@ -51,7 +51,7 @@ export async function runMainWorkflow( await dependencies.writeConfigs(config); // Step 2: Start containers - await dependencies.startContainers(config.workDir, config.allowedDomains, config.proxyLogsDir); + await dependencies.startContainers(config.workDir, config.allowedDomains, config.proxyLogsDir, config.skipPull); onContainersStarted?.(); // Step 3: Wait for agent to complete diff --git a/src/cli.test.ts b/src/cli.test.ts index ea697c903..c3a3d435b 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1,5 +1,5 @@ import { Command } from 'commander'; -import { parseEnvironmentVariables, parseDomains, parseDomainsFile, escapeShellArg, joinShellArgs, parseVolumeMounts, isValidIPv4, isValidIPv6, parseDnsServers, validateAgentImage, isAgentImagePreset, AGENT_IMAGE_PRESETS, processAgentImageOption } from './cli'; +import { parseEnvironmentVariables, parseDomains, parseDomainsFile, escapeShellArg, joinShellArgs, parseVolumeMounts, isValidIPv4, isValidIPv6, parseDnsServers, validateAgentImage, isAgentImagePreset, AGENT_IMAGE_PRESETS, processAgentImageOption, validateSkipPullWithBuildLocal } from './cli'; import { redactSecrets } from './redact-secrets'; import * as fs from 'fs'; import * as path from 'path'; @@ -666,6 +666,7 @@ describe('cli', () => { expect(result.invalidMount).toBe('invalid-mount'); } }); + }); describe('IPv4 validation', () => { @@ -1140,4 +1141,48 @@ describe('cli', () => { }); }); }); + + describe('validateSkipPullWithBuildLocal', () => { + it('should return valid when both flags are false', () => { + const result = validateSkipPullWithBuildLocal(false, false); + expect(result.valid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('should return valid when both flags are undefined', () => { + const result = validateSkipPullWithBuildLocal(undefined, undefined); + expect(result.valid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('should return valid when only skipPull is true', () => { + const result = validateSkipPullWithBuildLocal(true, false); + expect(result.valid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('should return valid when only buildLocal is true', () => { + const result = validateSkipPullWithBuildLocal(false, true); + expect(result.valid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('should return invalid when both skipPull and buildLocal are true', () => { + const result = validateSkipPullWithBuildLocal(true, true); + expect(result.valid).toBe(false); + expect(result.error).toContain('--skip-pull cannot be used with --build-local'); + }); + + it('should return valid when skipPull is true and buildLocal is undefined', () => { + const result = validateSkipPullWithBuildLocal(true, undefined); + expect(result.valid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('should return valid when skipPull is undefined and buildLocal is true', () => { + const result = validateSkipPullWithBuildLocal(undefined, true); + expect(result.valid).toBe(true); + expect(result.error).toBeUndefined(); + }); + }); }); diff --git a/src/cli.ts b/src/cli.ts index 2ddc7aec3..1ce5a28eb 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -243,6 +243,35 @@ export function processAgentImageOption( }; } +/** + * Result of validating flag combinations + */ +export interface FlagValidationResult { + /** Whether the validation passed */ + valid: boolean; + /** Error message if validation failed */ + error?: string; +} + +/** + * Validates that --skip-pull is not used with --build-local + * @param skipPull - Whether --skip-pull flag was provided + * @param buildLocal - Whether --build-local flag was provided + * @returns FlagValidationResult with validation status and error message + */ +export function validateSkipPullWithBuildLocal( + skipPull: boolean | undefined, + buildLocal: boolean | undefined +): FlagValidationResult { + if (skipPull && buildLocal) { + return { + valid: false, + error: '--skip-pull cannot be used with --build-local. Building images requires pulling base images from the registry.', + }; + } + return { valid: true }; +} + /** * Parses and validates DNS servers from a comma-separated string * @param input - Comma-separated DNS server string (e.g., "8.8.8.8,1.1.1.1") @@ -507,6 +536,11 @@ program 'Container image tag', 'latest' ) + .option( + '--skip-pull', + 'Use local images without pulling from registry (requires images to be pre-downloaded)', + false + ) .option( '-e, --env ', 'Additional environment variables to pass to container (can be specified multiple times)', @@ -788,6 +822,7 @@ program tty: options.tty || false, workDir: options.workDir, buildLocal: options.buildLocal, + skipPull: options.skipPull, agentImage, imageRegistry: options.imageRegistry, imageTag: options.imageTag, @@ -816,6 +851,13 @@ program process.exit(1); } + // Error if --skip-pull is used with --build-local (incompatible flags) + const skipPullValidation = validateSkipPullWithBuildLocal(config.skipPull, config.buildLocal); + if (!skipPullValidation.valid) { + logger.error(`❌ ${skipPullValidation.error}`); + process.exit(1); + } + // Warn if --enable-host-access is used with host.docker.internal in allowed domains if (config.enableHostAccess) { const hasHostDomain = allowedDomains.some(d => diff --git a/src/docker-manager.test.ts b/src/docker-manager.test.ts index 04bd49167..541a7d69b 100644 --- a/src/docker-manager.test.ts +++ b/src/docker-manager.test.ts @@ -623,6 +623,47 @@ describe('docker-manager', () => { expect(environment.AWF_CHROOT_ENABLED).toBe('true'); }); + it('should pass GOROOT, CARGO_HOME, JAVA_HOME to container when enableChroot is true and env vars are set', () => { + const originalGoroot = process.env.GOROOT; + const originalCargoHome = process.env.CARGO_HOME; + const originalJavaHome = process.env.JAVA_HOME; + + process.env.GOROOT = '/usr/local/go'; + process.env.CARGO_HOME = '/home/user/.cargo'; + process.env.JAVA_HOME = '/usr/lib/jvm/java-17'; + + try { + const configWithChroot = { + ...mockConfig, + enableChroot: true + }; + const result = generateDockerCompose(configWithChroot, mockNetworkConfig); + const agent = result.services.agent; + const environment = agent.environment as Record; + + expect(environment.AWF_GOROOT).toBe('/usr/local/go'); + expect(environment.AWF_CARGO_HOME).toBe('/home/user/.cargo'); + expect(environment.AWF_JAVA_HOME).toBe('/usr/lib/jvm/java-17'); + } finally { + // Restore original values + if (originalGoroot !== undefined) { + process.env.GOROOT = originalGoroot; + } else { + delete process.env.GOROOT; + } + if (originalCargoHome !== undefined) { + process.env.CARGO_HOME = originalCargoHome; + } else { + delete process.env.CARGO_HOME; + } + if (originalJavaHome !== undefined) { + process.env.JAVA_HOME = originalJavaHome; + } else { + delete process.env.JAVA_HOME; + } + } + }); + it('should not set AWF_CHROOT_ENABLED when enableChroot is false', () => { const result = generateDockerCompose(mockConfig, mockNetworkConfig); const agent = result.services.agent; @@ -932,6 +973,24 @@ describe('docker-manager', () => { }); }); + describe('allowHostPorts option', () => { + it('should set AWF_ALLOW_HOST_PORTS when allowHostPorts is specified', () => { + const config = { ...mockConfig, enableHostAccess: true, allowHostPorts: '8080,3000' }; + const result = generateDockerCompose(config, mockNetworkConfig); + const env = result.services.agent.environment as Record; + + expect(env.AWF_ALLOW_HOST_PORTS).toBe('8080,3000'); + }); + + it('should NOT set AWF_ALLOW_HOST_PORTS when allowHostPorts is undefined', () => { + const config = { ...mockConfig, enableHostAccess: true }; + const result = generateDockerCompose(config, mockNetworkConfig); + const env = result.services.agent.environment as Record; + + expect(env.AWF_ALLOW_HOST_PORTS).toBeUndefined(); + }); + }); + it('should override environment variables with additionalEnv', () => { const originalEnv = process.env.GITHUB_TOKEN; process.env.GITHUB_TOKEN = 'original_token'; @@ -1246,6 +1305,22 @@ describe('docker-manager', () => { ); }); + it('should continue when removing existing containers fails', async () => { + // First call (docker rm) throws an error, but we should continue + mockExecaFn.mockRejectedValueOnce(new Error('No such container')); + // Second call (docker compose up) succeeds + mockExecaFn.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as any); + + await startContainers(testDir, ['github.com']); + + // Should still call docker compose up even if rm failed + expect(mockExecaFn).toHaveBeenCalledWith( + 'docker', + ['compose', 'up', '-d'], + { cwd: testDir, stdio: 'inherit' } + ); + }); + it('should run docker compose up', async () => { mockExecaFn.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as any); mockExecaFn.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as any); @@ -1259,6 +1334,32 @@ describe('docker-manager', () => { ); }); + it('should run docker compose up with --pull never when skipPull is true', async () => { + mockExecaFn.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as any); + mockExecaFn.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as any); + + await startContainers(testDir, ['github.com'], undefined, true); + + expect(mockExecaFn).toHaveBeenCalledWith( + 'docker', + ['compose', 'up', '-d', '--pull', 'never'], + { cwd: testDir, stdio: 'inherit' } + ); + }); + + it('should run docker compose up without --pull never when skipPull is false', async () => { + mockExecaFn.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as any); + mockExecaFn.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as any); + + await startContainers(testDir, ['github.com'], undefined, false); + + expect(mockExecaFn).toHaveBeenCalledWith( + 'docker', + ['compose', 'up', '-d'], + { cwd: testDir, stdio: 'inherit' } + ); + }); + it('should handle docker compose failure', async () => { mockExecaFn.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as any); mockExecaFn.mockRejectedValueOnce(new Error('Docker compose failed')); diff --git a/src/docker-manager.ts b/src/docker-manager.ts index be53af78e..ef1602c96 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -800,8 +800,12 @@ async function checkSquidLogs(workDir: string, proxyLogsDir?: string): Promise<{ /** * Starts Docker Compose services + * @param workDir - Working directory containing Docker Compose config + * @param allowedDomains - List of allowed domains for error reporting + * @param proxyLogsDir - Optional custom directory for proxy logs + * @param skipPull - If true, use local images without pulling from registry */ -export async function startContainers(workDir: string, allowedDomains: string[], proxyLogsDir?: string): Promise { +export async function startContainers(workDir: string, allowedDomains: string[], proxyLogsDir?: string, skipPull?: boolean): Promise { logger.info('Starting containers...'); // Force remove any existing containers with these names to avoid conflicts @@ -817,7 +821,12 @@ export async function startContainers(workDir: string, allowedDomains: string[], } try { - await execa('docker', ['compose', 'up', '-d'], { + const composeArgs = ['compose', 'up', '-d']; + if (skipPull) { + composeArgs.push('--pull', 'never'); + logger.debug('Using --pull never (skip-pull mode)'); + } + await execa('docker', composeArgs, { cwd: workDir, stdio: 'inherit', }); diff --git a/src/types.ts b/src/types.ts index 75fd9828c..a1e1f726f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -138,15 +138,28 @@ export interface WrapperConfig { /** * Whether to build container images locally instead of pulling from registry - * + * * When true, Docker images are built from local Dockerfiles in containers/squid * and containers/agent directories. When false (default), images are pulled * from the configured registry. - * + * * @default false */ buildLocal?: boolean; + /** + * Whether to skip pulling images from the registry + * + * When true, Docker Compose will use locally available images without + * attempting to pull from the registry. This is useful when images are + * pre-downloaded or in air-gapped environments. + * + * If the required images are not available locally, container startup will fail. + * + * @default false + */ + skipPull?: boolean; + /** * Agent container image preset or custom base image *