From d82b045ea7154b899290ec6ba274143d4537cf5e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Apr 2026 18:58:52 +0000 Subject: [PATCH 1/4] Initial plan From fc457b1cd37d0887162f3b213d9a6716a5e07686 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Apr 2026 19:12:07 +0000 Subject: [PATCH 2/4] fix: handle workflow-scope DinD (DOCKER_HOST) in AWF container startup When DOCKER_HOST is set to a TCP address (e.g. DinD sidecar), AWF now: - Warns instead of failing - Clears DOCKER_HOST for its own docker CLI invocations so they target the local socket - Forwards the original DOCKER_HOST into the agent container so the agent workload can still reach the DinD daemon Also adds --docker-host flag for explicit socket override and documents the DinD interaction model in docs/environment.md. Agent-Logs-Url: https://github.com/github/gh-aw-firewall/sessions/5c58cf57-cf73-4f3c-89b7-67261abd40d5 --- docs/environment.md | 62 +++++++++++++++ src/cli.ts | 23 +++++- src/docker-manager.test.ts | 150 ++++++++++++++++++++++++++++++++++--- src/docker-manager.ts | 79 +++++++++++++++++-- src/types.ts | 22 ++++++ 5 files changed, 314 insertions(+), 22 deletions(-) diff --git a/docs/environment.md b/docs/environment.md index adaede55..7db0c782 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -186,6 +186,68 @@ When enabled, the library logs: **Note:** Debug output goes to stderr and does not interfere with command stdout. See `containers/agent/one-shot-token/README.md` for complete documentation. +## Workflow-Scope Docker-in-Docker (`DOCKER_HOST`) + +When a GitHub Actions workflow enables Docker-in-Docker (DinD) at the **workflow scope** — for example by starting a `docker:dind` service container and setting `DOCKER_HOST: tcp://localhost:2375` in the runner's environment — AWF handles the conflict automatically. + +### What happens + +AWF's container orchestration (Squid proxy, agent, iptables-init) must run on the **local** Docker daemon so that: +- bind mounts from the runner host filesystem work correctly, +- AWF's fixed subnet (`172.30.0.0/24`) and iptables DNAT rules are created in the right network namespace, and +- port binding expectations between containers are satisfied. + +When `DOCKER_HOST` is set to a TCP address, AWF: + +1. **Emits a warning** (not an error) informing you that the local socket will be used for AWF's own containers. +2. **Clears `DOCKER_HOST`** for all `docker` / `docker compose` calls it makes internally, so they target the local daemon. +3. **Forwards the original `DOCKER_HOST`** into the agent container's environment, so Docker commands run *by the agent* still reach the DinD daemon. + +### Example workflow structure + +```yaml +jobs: + build: + runs-on: ubuntu-latest + services: + dind: + image: docker:dind + options: --privileged + ports: + - 2375:2375 + env: + DOCKER_HOST: tcp://localhost:2375 + steps: + - uses: actions/checkout@v4 + - name: Run agent with AWF + run: | + # AWF warns about DOCKER_HOST but proceeds with local socket for its own containers. + # The agent can run `docker build` / `docker run` and they will reach the DinD daemon + # via the forwarded DOCKER_HOST inside the container. + awf --allow-domains registry-1.docker.io,ghcr.io -- docker build -t myapp . +``` + +### Explicit socket override + +If your local Docker daemon is at a non-standard Unix socket path, use `--docker-host`: + +```bash +awf --docker-host unix:///run/user/1000/docker.sock \ + --allow-domains github.com \ + -- agent-command +``` + +This overrides the socket used for AWF's own operations without affecting the agent's `DOCKER_HOST`. + +### Limitation + +The DinD TCP address (e.g., `tcp://localhost:2375`) typically refers to the runner's localhost from outside the container. From *inside* the agent container, `localhost` resolves to the container's own loopback interface, not the host's. To make docker commands inside the agent reach the DinD daemon you need one of: + +- **`--enable-host-access`** — allows the agent to reach `host.docker.internal` and set `DOCKER_HOST=tcp://host.docker.internal:2375` inside the agent. +- **`--enable-dind`** — mounts the local Docker socket (`/var/run/docker.sock`) directly into the agent container (only works when using the local daemon, not a remote DinD TCP socket). + + + ## Troubleshooting **Variable not accessible:** Use `sudo -E` or pass explicitly with `--env VAR="$VAR"` diff --git a/src/cli.ts b/src/cli.ts index af9a95a1..2cc015a1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -16,6 +16,7 @@ import { preserveIptablesAudit, fastKillAgentContainer, collectDiagnosticLogs, + setAwfDockerHost, } from './docker-manager'; import { ensureFirewallNetwork, @@ -1370,6 +1371,12 @@ program 'Use local images without pulling from registry (requires pre-downloaded images)', false ) + .option( + '--docker-host ', + 'Docker socket for AWF\'s own containers (default: auto-detect from DOCKER_HOST env).\n' + + ' Use when Docker is at a non-standard path.\n' + + ' Example: unix:///run/user/1000/docker.sock' + ) // -- Container Configuration -- .option( @@ -1602,12 +1609,15 @@ program logger.setLevel(logLevel); - // Fail fast when DOCKER_HOST points at an external daemon (e.g. workflow-scope DinD). - // AWF's network isolation depends on direct access to the local Docker socket. + // When DOCKER_HOST points at an external TCP daemon (e.g. workflow-scope DinD), + // AWF redirects its own docker calls to the local socket automatically. + // The original DOCKER_HOST value is forwarded into the agent container so the + // agent workload can still reach the DinD daemon. const dockerHostCheck = checkDockerHost(); if (!dockerHostCheck.valid) { - logger.error(`❌ ${dockerHostCheck.error}`); - process.exit(1); + logger.warn(`⚠️ ${dockerHostCheck.error}`); + logger.warn(' AWF will use the local Docker socket for its own containers.'); + logger.warn(' The original DOCKER_HOST is forwarded into the agent container.'); } // Parse domains from both --allow-domains flag and --allow-domains-file @@ -1909,8 +1919,13 @@ program difcProxyCaCert: options.difcProxyCaCert, githubToken: process.env.GITHUB_TOKEN || process.env.GH_TOKEN, diagnosticLogs: options.diagnosticLogs || false, + awfDockerHost: options.dockerHost, }; + // Apply --docker-host override for AWF's own container operations. + // This must be called before startContainers/stopContainers/runAgentCommand. + setAwfDockerHost(config.awfDockerHost); + // Parse and validate --agent-timeout applyAgentTimeout(options.agentTimeout, config, logger); diff --git a/src/docker-manager.test.ts b/src/docker-manager.test.ts index cfad8c2e..290187c1 100644 --- a/src/docker-manager.test.ts +++ b/src/docker-manager.test.ts @@ -1,4 +1,4 @@ -import { generateDockerCompose, subnetsOverlap, writeConfigs, startContainers, stopContainers, fastKillAgentContainer, isAgentExternallyKilled, resetAgentExternallyKilled, AGENT_CONTAINER_NAME, cleanup, runAgentCommand, validateIdNotInSystemRange, getSafeHostUid, getSafeHostGid, getRealUserHome, extractGhHostFromServerUrl, readGitHubPathEntries, mergeGitHubPathEntries, readEnvFile, MIN_REGULAR_UID, ACT_PRESET_BASE_IMAGE, stripScheme, collectDiagnosticLogs } from './docker-manager'; +import { generateDockerCompose, subnetsOverlap, writeConfigs, startContainers, stopContainers, fastKillAgentContainer, isAgentExternallyKilled, resetAgentExternallyKilled, AGENT_CONTAINER_NAME, cleanup, runAgentCommand, validateIdNotInSystemRange, getSafeHostUid, getSafeHostGid, getRealUserHome, extractGhHostFromServerUrl, readGitHubPathEntries, mergeGitHubPathEntries, readEnvFile, MIN_REGULAR_UID, ACT_PRESET_BASE_IMAGE, stripScheme, collectDiagnosticLogs, setAwfDockerHost } from './docker-manager'; import { WrapperConfig } from './types'; import * as fs from 'fs'; import * as path from 'path'; @@ -1386,6 +1386,56 @@ describe('docker-manager', () => { } }); + it('should forward DOCKER_HOST into agent container when set (TCP address)', () => { + const originalDockerHost = process.env.DOCKER_HOST; + process.env.DOCKER_HOST = 'tcp://localhost:2375'; + + try { + const result = generateDockerCompose(mockConfig, mockNetworkConfig); + const env = result.services.agent.environment as Record; + // Agent must receive the original DOCKER_HOST so it can reach the DinD daemon + expect(env.DOCKER_HOST).toBe('tcp://localhost:2375'); + } finally { + if (originalDockerHost !== undefined) { + process.env.DOCKER_HOST = originalDockerHost; + } else { + delete process.env.DOCKER_HOST; + } + } + }); + + it('should forward DOCKER_HOST into agent container when set (unix socket)', () => { + const originalDockerHost = process.env.DOCKER_HOST; + process.env.DOCKER_HOST = 'unix:///var/run/docker.sock'; + + try { + const result = generateDockerCompose(mockConfig, mockNetworkConfig); + const env = result.services.agent.environment as Record; + expect(env.DOCKER_HOST).toBe('unix:///var/run/docker.sock'); + } finally { + if (originalDockerHost !== undefined) { + process.env.DOCKER_HOST = originalDockerHost; + } else { + delete process.env.DOCKER_HOST; + } + } + }); + + it('should not set DOCKER_HOST in agent container when not in host environment', () => { + const originalDockerHost = process.env.DOCKER_HOST; + delete process.env.DOCKER_HOST; + + try { + const result = generateDockerCompose(mockConfig, mockNetworkConfig); + const env = result.services.agent.environment as Record; + expect(env.DOCKER_HOST).toBeUndefined(); + } finally { + if (originalDockerHost !== undefined) { + process.env.DOCKER_HOST = originalDockerHost; + } + } + }); + it('should add additional environment variables from config', () => { const configWithEnv = { ...mockConfig, @@ -3477,7 +3527,7 @@ describe('docker-manager', () => { expect(mockExecaFn).toHaveBeenCalledWith( 'docker', ['rm', '-f', 'awf-squid', 'awf-agent', 'awf-iptables-init', 'awf-api-proxy', 'awf-cli-proxy'], - { reject: false } + expect.objectContaining({ reject: false }) ); }); @@ -3493,7 +3543,7 @@ describe('docker-manager', () => { expect(mockExecaFn).toHaveBeenCalledWith( 'docker', ['compose', 'up', '-d'], - { cwd: testDir, stdout: process.stderr, stderr: 'inherit' } + expect.objectContaining({ cwd: testDir, stdout: process.stderr, stderr: 'inherit' }) ); }); @@ -3506,7 +3556,7 @@ describe('docker-manager', () => { expect(mockExecaFn).toHaveBeenCalledWith( 'docker', ['compose', 'up', '-d'], - { cwd: testDir, stdout: process.stderr, stderr: 'inherit' } + expect.objectContaining({ cwd: testDir, stdout: process.stderr, stderr: 'inherit' }) ); }); @@ -3519,7 +3569,7 @@ describe('docker-manager', () => { expect(mockExecaFn).toHaveBeenCalledWith( 'docker', ['compose', 'up', '-d', '--pull', 'never'], - { cwd: testDir, stdout: process.stderr, stderr: 'inherit' } + expect.objectContaining({ cwd: testDir, stdout: process.stderr, stderr: 'inherit' }) ); }); @@ -3532,7 +3582,7 @@ describe('docker-manager', () => { expect(mockExecaFn).toHaveBeenCalledWith( 'docker', ['compose', 'up', '-d'], - { cwd: testDir, stdout: process.stderr, stderr: 'inherit' } + expect.objectContaining({ cwd: testDir, stdout: process.stderr, stderr: 'inherit' }) ); }); @@ -3587,7 +3637,7 @@ describe('docker-manager', () => { expect(mockExecaFn).toHaveBeenCalledWith( 'docker', ['compose', 'down', '-v', '-t', '1'], - { cwd: testDir, stdout: process.stderr, stderr: 'inherit' } + expect.objectContaining({ cwd: testDir, stdout: process.stderr, stderr: 'inherit' }) ); }); @@ -3612,7 +3662,7 @@ describe('docker-manager', () => { expect(mockExecaFn).toHaveBeenCalledWith( 'docker', ['stop', '-t', '3', AGENT_CONTAINER_NAME], - { reject: false, timeout: 8000 } + expect.objectContaining({ reject: false, timeout: 8000 }) ); }); @@ -3624,7 +3674,7 @@ describe('docker-manager', () => { expect(mockExecaFn).toHaveBeenCalledWith( 'docker', ['stop', '-t', '5', AGENT_CONTAINER_NAME], - { reject: false, timeout: 10000 } + expect.objectContaining({ reject: false, timeout: 10000 }) ); }); @@ -3650,6 +3700,86 @@ describe('docker-manager', () => { }); }); + describe('setAwfDockerHost / getLocalDockerEnv (DOCKER_HOST isolation)', () => { + const originalDockerHost = process.env.DOCKER_HOST; + + afterEach(() => { + // Restore env and reset override after each test + if (originalDockerHost === undefined) { + delete process.env.DOCKER_HOST; + } else { + process.env.DOCKER_HOST = originalDockerHost; + } + setAwfDockerHost(undefined); + jest.clearAllMocks(); + }); + + it('docker compose up should NOT forward a TCP DOCKER_HOST to the docker CLI', async () => { + process.env.DOCKER_HOST = 'tcp://localhost:2375'; + const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-test-')); + try { + mockExecaFn.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as any); // docker rm + mockExecaFn.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as any); // docker compose up + + await startContainers(testDir, ['github.com']); + + const composeCalls = mockExecaFn.mock.calls.filter( + (call: any[]) => call[1]?.[0] === 'compose' + ); + expect(composeCalls.length).toBeGreaterThan(0); + const composeEnv = composeCalls[0][2]?.env as Record | undefined; + // DOCKER_HOST must be absent from the env passed to docker compose + expect(composeEnv).toBeDefined(); + expect(composeEnv!.DOCKER_HOST).toBeUndefined(); + } finally { + fs.rmSync(testDir, { recursive: true, force: true }); + } + }); + + it('docker compose up should keep a unix:// DOCKER_HOST in the env', async () => { + process.env.DOCKER_HOST = 'unix:///var/run/docker.sock'; + const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-test-')); + try { + mockExecaFn.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as any); // docker rm + mockExecaFn.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as any); // docker compose up + + await startContainers(testDir, ['github.com']); + + const composeCalls = mockExecaFn.mock.calls.filter( + (call: any[]) => call[1]?.[0] === 'compose' + ); + expect(composeCalls.length).toBeGreaterThan(0); + const composeEnv = composeCalls[0][2]?.env as Record | undefined; + expect(composeEnv).toBeDefined(); + expect(composeEnv!.DOCKER_HOST).toBe('unix:///var/run/docker.sock'); + } finally { + fs.rmSync(testDir, { recursive: true, force: true }); + } + }); + + it('setAwfDockerHost should override DOCKER_HOST for AWF operations', async () => { + process.env.DOCKER_HOST = 'tcp://localhost:2375'; // Would normally be cleared + setAwfDockerHost('unix:///run/user/1000/docker.sock'); + const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-test-')); + try { + mockExecaFn.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as any); // docker rm + mockExecaFn.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as any); // docker compose up + + await startContainers(testDir, ['github.com']); + + const composeCalls = mockExecaFn.mock.calls.filter( + (call: any[]) => call[1]?.[0] === 'compose' + ); + expect(composeCalls.length).toBeGreaterThan(0); + const composeEnv = composeCalls[0][2]?.env as Record | undefined; + expect(composeEnv).toBeDefined(); + expect(composeEnv!.DOCKER_HOST).toBe('unix:///run/user/1000/docker.sock'); + } finally { + fs.rmSync(testDir, { recursive: true, force: true }); + } + }); + }); + describe('runAgentCommand', () => { let testDir: string; @@ -3808,7 +3938,7 @@ describe('docker-manager', () => { expect(result.exitCode).toBe(124); // Verify docker stop was called - expect(mockExecaFn).toHaveBeenCalledWith('docker', ['stop', '-t', '10', 'awf-agent'], { reject: false }); + expect(mockExecaFn).toHaveBeenCalledWith('docker', ['stop', '-t', '10', 'awf-agent'], expect.objectContaining({ reject: false })); jest.useRealTimers(); }); diff --git a/src/docker-manager.ts b/src/docker-manager.ts index 94edf330..923be1ec 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -30,6 +30,59 @@ const CLI_PROXY_CONTAINER_NAME = 'awf-cli-proxy'; */ let agentExternallyKilled = false; +/** + * Optional override for the Docker host used by AWF's own container operations. + * Set via setAwfDockerHost() from the CLI --docker-host flag. + * When undefined, AWF auto-selects the local socket (see getLocalDockerEnv). + */ +let awfDockerHostOverride: string | undefined; + +/** + * Sets the Docker host to use for AWF's own container operations. + * + * When set, overrides DOCKER_HOST for all docker CLI calls made by AWF + * (compose up/down, docker wait, docker logs, etc.). + * + * When not set, AWF auto-detects: + * - unix:// DOCKER_HOST values are kept as-is (local socket). + * - TCP DOCKER_HOST values (e.g. DinD) are cleared so docker falls back + * to the system default socket. + * + * @internal Called from cli.ts when --docker-host flag is provided. + */ +export function setAwfDockerHost(host: string | undefined): void { + awfDockerHostOverride = host; +} + +/** + * Returns an environment object suitable for AWF's own docker CLI calls. + * + * When DOCKER_HOST is set to an external TCP daemon (e.g. a workflow-scope + * DinD sidecar), it is removed so docker/docker-compose use the local Unix + * socket instead. When --docker-host was provided via the CLI, that value + * is used regardless of the environment. + * + * The original DOCKER_HOST value is NOT removed from the agent container's + * environment — see generateDockerCompose for the passthrough logic. + */ +function getLocalDockerEnv(): NodeJS.ProcessEnv { + const env = { ...process.env }; + + if (awfDockerHostOverride !== undefined) { + // Explicit CLI override — always use this socket for AWF operations + env.DOCKER_HOST = awfDockerHostOverride; + } else { + const dockerHost = env.DOCKER_HOST; + if (dockerHost && !dockerHost.startsWith('unix://')) { + // Non-unix DOCKER_HOST (e.g. tcp://localhost:2375 from a DinD sidecar). + // Clear it so AWF's docker commands target the local daemon, not the DinD one. + delete env.DOCKER_HOST; + } + } + + return env; +} + // When bundled with esbuild, this global is replaced at build time with the // JSON content of containers/agent/seccomp-profile.json. In normal (tsc) // builds the identifier remains undeclared, so the typeof check below is safe. @@ -270,7 +323,7 @@ export function readEnvFile(filePath: string): Record { async function getExistingDockerSubnets(): Promise { try { // Get all network IDs - const { stdout: networkIds } = await execa('docker', ['network', 'ls', '-q']); + const { stdout: networkIds } = await execa('docker', ['network', 'ls', '-q'], { env: getLocalDockerEnv() }); if (!networkIds.trim()) { return []; } @@ -281,7 +334,7 @@ async function getExistingDockerSubnets(): Promise { 'inspect', '--format={{range .IPAM.Config}}{{.Subnet}} {{end}}', ...networkIds.trim().split('\n'), - ]); + ], { env: getLocalDockerEnv() }); // Parse subnets from output (format: "172.17.0.0/16 172.18.0.0/16 ") const subnets = stdout @@ -775,6 +828,11 @@ export function generateDockerCompose( if (process.env.ACTIONS_ID_TOKEN_REQUEST_URL) environment.ACTIONS_ID_TOKEN_REQUEST_URL = process.env.ACTIONS_ID_TOKEN_REQUEST_URL; if (process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN) environment.ACTIONS_ID_TOKEN_REQUEST_TOKEN = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; + // Forward DOCKER_HOST so the agent workload can reach a DinD daemon or custom Docker socket. + // AWF itself uses the local socket (see getLocalDockerEnv), but the agent container should + // inherit the original value so docker commands inside the agent work as expected. + if (process.env.DOCKER_HOST) environment.DOCKER_HOST = process.env.DOCKER_HOST; + } // Always derive GH_HOST from GITHUB_SERVER_URL to prevent proxy-rewritten values @@ -2189,6 +2247,7 @@ export async function startContainers(workDir: string, allowedDomains: string[], try { await execa('docker', ['rm', '-f', SQUID_CONTAINER_NAME, AGENT_CONTAINER_NAME, IPTABLES_INIT_CONTAINER_NAME, API_PROXY_CONTAINER_NAME, CLI_PROXY_CONTAINER_NAME], { reject: false, + env: getLocalDockerEnv(), }); } catch { // Ignore errors if containers don't exist @@ -2211,6 +2270,7 @@ export async function startContainers(workDir: string, allowedDomains: string[], cwd: workDir, stdout: process.stderr, stderr: 'inherit', + env: getLocalDockerEnv(), }); logger.success('Containers started successfully'); } catch (error) { @@ -2283,6 +2343,7 @@ export async function runAgentCommand(workDir: string, allowedDomains: string[], const logsProcess = execa('docker', ['logs', '-f', AGENT_CONTAINER_NAME], { stdio: 'inherit', reject: false, + env: getLocalDockerEnv(), }); let exitCode: number; @@ -2292,7 +2353,7 @@ export async function runAgentCommand(workDir: string, allowedDomains: string[], logger.info(`Agent timeout: ${agentTimeoutMinutes} minutes`); // Race docker wait against a timeout - const waitPromise = execa('docker', ['wait', AGENT_CONTAINER_NAME]).then(result => ({ + const waitPromise = execa('docker', ['wait', AGENT_CONTAINER_NAME], { env: getLocalDockerEnv() }).then(result => ({ type: 'completed' as const, exitCodeStr: result.stdout, })); @@ -2307,7 +2368,7 @@ export async function runAgentCommand(workDir: string, allowedDomains: string[], if (raceResult.type === 'timeout') { logger.warn(`Agent command timed out after ${agentTimeoutMinutes} minutes, stopping container...`); // Stop the container gracefully (10 second grace period before SIGKILL) - await execa('docker', ['stop', '-t', '10', AGENT_CONTAINER_NAME], { reject: false }); + await execa('docker', ['stop', '-t', '10', AGENT_CONTAINER_NAME], { reject: false, env: getLocalDockerEnv() }); exitCode = 124; // Standard timeout exit code (same as coreutils timeout) } else { // Clear the timeout timer so it doesn't keep the event loop alive @@ -2316,7 +2377,7 @@ export async function runAgentCommand(workDir: string, allowedDomains: string[], } } else { // No timeout - wait indefinitely - const { stdout: exitCodeStr } = await execa('docker', ['wait', AGENT_CONTAINER_NAME]); + const { stdout: exitCodeStr } = await execa('docker', ['wait', AGENT_CONTAINER_NAME], { env: getLocalDockerEnv() }); exitCode = parseInt(exitCodeStr.trim(), 10); } @@ -2400,6 +2461,7 @@ export async function fastKillAgentContainer(stopTimeoutSeconds = 3): Promise { for (const container of containers) { // Collect stdout+stderr from docker logs try { - const result = await execa('docker', ['logs', container], { reject: false }); + const result = await execa('docker', ['logs', container], { reject: false, env: getLocalDockerEnv() }); if (result.exitCode === 0) { const combined = [result.stdout, result.stderr].filter(Boolean).join('\n').trim(); if (combined) { @@ -2539,7 +2601,7 @@ export async function collectDiagnosticLogs(workDir: string): Promise { const result = await execa( 'docker', ['inspect', '--format', '{{.State.ExitCode}} {{.State.Error}}', container], - { reject: false } + { reject: false, env: getLocalDockerEnv() } ); const state = result.stdout.trim(); if (state) { @@ -2554,7 +2616,7 @@ export async function collectDiagnosticLogs(workDir: string): Promise { const result = await execa( 'docker', ['inspect', '--format', '{{json .Mounts}}', container], - { reject: false } + { reject: false, env: getLocalDockerEnv() } ); const mounts = result.stdout.trim(); if (mounts && mounts !== 'null') { @@ -2598,6 +2660,7 @@ export async function stopContainers(workDir: string, keepContainers: boolean): cwd: workDir, stdout: process.stderr, stderr: 'inherit', + env: getLocalDockerEnv(), }); logger.success('Containers stopped successfully'); } catch (error) { diff --git a/src/types.ts b/src/types.ts index a3ae7d35..f6be49ef 100644 --- a/src/types.ts +++ b/src/types.ts @@ -564,6 +564,28 @@ export interface WrapperConfig { */ enableDind?: boolean; + /** + * Docker host (socket) to use for AWF's own container operations + * + * When set, overrides the `DOCKER_HOST` environment variable for all + * docker CLI calls made by AWF itself (compose up/down, docker wait, etc.). + * + * Use this when you need to point AWF at a specific local Unix socket that + * is not the system default (`/var/run/docker.sock`). + * + * When not set, AWF auto-detects the Docker host: + * - If `DOCKER_HOST` is a Unix socket, it is used as-is. + * - If `DOCKER_HOST` is a TCP address (e.g. a DinD daemon), AWF clears it + * and falls back to the system default socket. + * + * The original `DOCKER_HOST` value (if any) is always forwarded into the + * agent container so the agent workload can still reach the DinD daemon. + * + * @example 'unix:///var/run/docker.sock' + * @example 'unix:///run/user/1000/docker.sock' + */ + awfDockerHost?: string; + /** * URL patterns to allow for HTTPS traffic (requires sslBump: true) * From f5c2cb90b175beb5919fe714084bb0caecb51c8a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Apr 2026 19:14:04 +0000 Subject: [PATCH 3/4] docs: improve clarity of DOCKER_HOST comments per review feedback Agent-Logs-Url: https://github.com/github/gh-aw-firewall/sessions/5c58cf57-cf73-4f3c-89b7-67261abd40d5 --- docs/environment.md | 2 +- src/types.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/environment.md b/docs/environment.md index 7db0c782..1191db66 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -241,7 +241,7 @@ This overrides the socket used for AWF's own operations without affecting the ag ### Limitation -The DinD TCP address (e.g., `tcp://localhost:2375`) typically refers to the runner's localhost from outside the container. From *inside* the agent container, `localhost` resolves to the container's own loopback interface, not the host's. To make docker commands inside the agent reach the DinD daemon you need one of: +The DinD TCP address (e.g., `tcp://localhost:2375`) typically refers to the runner host's localhost interface. From *inside* the agent container, `localhost` resolves to the container's own loopback interface, not the host's. To make docker commands inside the agent reach the DinD daemon you need one of: - **`--enable-host-access`** — allows the agent to reach `host.docker.internal` and set `DOCKER_HOST=tcp://host.docker.internal:2375` inside the agent. - **`--enable-dind`** — mounts the local Docker socket (`/var/run/docker.sock`) directly into the agent container (only works when using the local daemon, not a remote DinD TCP socket). diff --git a/src/types.ts b/src/types.ts index f6be49ef..6ae22ba4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -575,8 +575,8 @@ export interface WrapperConfig { * * When not set, AWF auto-detects the Docker host: * - If `DOCKER_HOST` is a Unix socket, it is used as-is. - * - If `DOCKER_HOST` is a TCP address (e.g. a DinD daemon), AWF clears it - * and falls back to the system default socket. + * - If `DOCKER_HOST` is a TCP address (e.g. a Docker-in-Docker (DinD) daemon), + * AWF clears it and falls back to the system default socket. * * The original `DOCKER_HOST` value (if any) is always forwarded into the * agent container so the agent workload can still reach the DinD daemon. From 8f748ecbc6d778fb2b252bad2e5f2a0119364f9a Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Sun, 12 Apr 2026 12:52:44 -0700 Subject: [PATCH 4/4] fix: address review feedback on DinD configuration - Export getLocalDockerEnv() and use it in host-iptables.ts for all docker CLI calls (network inspect/create/rm) so they target the local daemon when DOCKER_HOST points at an external TCP daemon - Forward full set of Docker client env vars into agent container (DOCKER_TLS_VERIFY, DOCKER_CERT_PATH, DOCKER_CONTEXT, etc.) not just DOCKER_HOST, so TLS-authenticated DinD daemons work - Update warning message to describe auto-redirect behavior instead of reusing old fatal error text - Validate --docker-host flag is a unix:// URI; reject TCP/SSH - Remove extra blank line before Troubleshooting in docs/environment - Update host-iptables tests for new env option in docker calls Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/environment.md | 2 -- src/cli.ts | 10 +++++++--- src/docker-manager.ts | 23 ++++++++++++++++++----- src/host-iptables.test.ts | 15 ++++++++++----- src/host-iptables.ts | 11 ++++++----- 5 files changed, 41 insertions(+), 20 deletions(-) diff --git a/docs/environment.md b/docs/environment.md index 1191db66..03432151 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -246,8 +246,6 @@ The DinD TCP address (e.g., `tcp://localhost:2375`) typically refers to the runn - **`--enable-host-access`** — allows the agent to reach `host.docker.internal` and set `DOCKER_HOST=tcp://host.docker.internal:2375` inside the agent. - **`--enable-dind`** — mounts the local Docker socket (`/var/run/docker.sock`) directly into the agent container (only works when using the local daemon, not a remote DinD TCP socket). - - ## Troubleshooting **Variable not accessible:** Use `sudo -E` or pass explicitly with `--env VAR="$VAR"` diff --git a/src/cli.ts b/src/cli.ts index 2cc015a1..a7b35332 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1615,9 +1615,8 @@ program // agent workload can still reach the DinD daemon. const dockerHostCheck = checkDockerHost(); if (!dockerHostCheck.valid) { - logger.warn(`⚠️ ${dockerHostCheck.error}`); - logger.warn(' AWF will use the local Docker socket for its own containers.'); - logger.warn(' The original DOCKER_HOST is forwarded into the agent container.'); + logger.warn('⚠️ External DOCKER_HOST detected. AWF will redirect its own Docker calls to the local socket.'); + logger.warn(' The original DOCKER_HOST (and related Docker client env vars) are forwarded into the agent container.'); } // Parse domains from both --allow-domains flag and --allow-domains-file @@ -1924,6 +1923,11 @@ program // Apply --docker-host override for AWF's own container operations. // This must be called before startContainers/stopContainers/runAgentCommand. + if (config.awfDockerHost && !config.awfDockerHost.startsWith('unix://')) { + logger.error(`❌ --docker-host must be a unix:// socket URI, got: ${config.awfDockerHost}`); + logger.error(' Example: --docker-host unix:///run/user/1000/docker.sock'); + process.exit(1); + } setAwfDockerHost(config.awfDockerHost); // Parse and validate --agent-timeout diff --git a/src/docker-manager.ts b/src/docker-manager.ts index 923be1ec..79235005 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -65,7 +65,7 @@ export function setAwfDockerHost(host: string | undefined): void { * The original DOCKER_HOST value is NOT removed from the agent container's * environment — see generateDockerCompose for the passthrough logic. */ -function getLocalDockerEnv(): NodeJS.ProcessEnv { +export function getLocalDockerEnv(): NodeJS.ProcessEnv { const env = { ...process.env }; if (awfDockerHostOverride !== undefined) { @@ -828,10 +828,23 @@ export function generateDockerCompose( if (process.env.ACTIONS_ID_TOKEN_REQUEST_URL) environment.ACTIONS_ID_TOKEN_REQUEST_URL = process.env.ACTIONS_ID_TOKEN_REQUEST_URL; if (process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN) environment.ACTIONS_ID_TOKEN_REQUEST_TOKEN = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; - // Forward DOCKER_HOST so the agent workload can reach a DinD daemon or custom Docker socket. - // AWF itself uses the local socket (see getLocalDockerEnv), but the agent container should - // inherit the original value so docker commands inside the agent work as expected. - if (process.env.DOCKER_HOST) environment.DOCKER_HOST = process.env.DOCKER_HOST; + // Forward Docker client environment so the agent workload can reach the same DinD daemon, + // custom Docker socket, or TCP endpoint as the parent process. DOCKER_HOST alone is not + // sufficient for TLS/authenticated daemons; the companion Docker client variables must also + // be preserved so docker commands inside the agent work as expected. + const dockerClientEnvVars = [ + 'DOCKER_HOST', + 'DOCKER_TLS', + 'DOCKER_TLS_VERIFY', + 'DOCKER_CERT_PATH', + 'DOCKER_CONTEXT', + 'DOCKER_CONFIG', + 'DOCKER_API_VERSION', + 'DOCKER_DEFAULT_PLATFORM', + ] as const; + for (const dockerEnvVar of dockerClientEnvVars) { + if (process.env[dockerEnvVar]) environment[dockerEnvVar] = process.env[dockerEnvVar]!; + } } diff --git a/src/host-iptables.test.ts b/src/host-iptables.test.ts index 0370b0ad..85c67c77 100644 --- a/src/host-iptables.test.ts +++ b/src/host-iptables.test.ts @@ -5,6 +5,11 @@ import execa from 'execa'; jest.mock('execa'); const mockedExeca = execa as jest.MockedFunction; +// Mock getLocalDockerEnv to return a predictable env for assertions +jest.mock('./docker-manager', () => ({ + getLocalDockerEnv: () => process.env, +})); + // Mock logger to avoid console output during tests jest.mock('./logger', () => ({ logger: { @@ -41,8 +46,8 @@ describe('host-iptables', () => { }); // Should only check if network exists, not create it - expect(mockedExeca).toHaveBeenCalledWith('docker', ['network', 'inspect', 'awf-net']); - expect(mockedExeca).not.toHaveBeenCalledWith('docker', expect.arrayContaining(['network', 'create'])); + expect(mockedExeca).toHaveBeenCalledWith('docker', ['network', 'inspect', 'awf-net'], { env: expect.any(Object) }); + expect(mockedExeca).not.toHaveBeenCalledWith('docker', expect.arrayContaining(['network', 'create']), expect.anything()); }); it('should create network when it does not exist', async () => { @@ -65,7 +70,7 @@ describe('host-iptables', () => { proxyIp: '172.30.0.30', }); - expect(mockedExeca).toHaveBeenCalledWith('docker', ['network', 'inspect', 'awf-net']); + expect(mockedExeca).toHaveBeenCalledWith('docker', ['network', 'inspect', 'awf-net'], { env: expect.any(Object) }); expect(mockedExeca).toHaveBeenCalledWith('docker', [ 'network', 'create', @@ -74,7 +79,7 @@ describe('host-iptables', () => { '172.30.0.0/24', '--opt', 'com.docker.network.bridge.name=fw-bridge', - ]); + ], { env: expect.any(Object) }); }); }); @@ -1101,7 +1106,7 @@ describe('host-iptables', () => { await cleanupFirewallNetwork(); - expect(mockedExeca).toHaveBeenCalledWith('docker', ['network', 'rm', 'awf-net'], { reject: false }); + expect(mockedExeca).toHaveBeenCalledWith('docker', ['network', 'rm', 'awf-net'], { reject: false, env: expect.any(Object) }); }); it('should not throw on errors (best-effort cleanup)', async () => { diff --git a/src/host-iptables.ts b/src/host-iptables.ts index ec50925e..2f8c86c4 100644 --- a/src/host-iptables.ts +++ b/src/host-iptables.ts @@ -2,6 +2,7 @@ import execa from 'execa'; import { logger } from './logger'; import { API_PROXY_PORTS } from './types'; import { DEFAULT_DNS_SERVERS } from './dns-resolver'; +import { getLocalDockerEnv } from './docker-manager'; const NETWORK_NAME = 'awf-net'; const CHAIN_NAME = 'FW_WRAPPER'; @@ -71,7 +72,7 @@ async function getNetworkBridgeName(): Promise { NETWORK_NAME, '-f', '{{index .Options "com.docker.network.bridge.name"}}', - ]); + ], { env: getLocalDockerEnv() }); const bridgeName = stdout.trim(); return bridgeName || null; } catch (error) { @@ -89,7 +90,7 @@ export async function getDockerBridgeGateway(): Promise { const { stdout } = await execa('docker', [ 'network', 'inspect', 'bridge', '-f', '{{(index .IPAM.Config 0).Gateway}}', - ]); + ], { env: getLocalDockerEnv() }); const gateway = stdout.trim(); if (!gateway) return null; // Validate IPv4 format before using in iptables rules @@ -173,7 +174,7 @@ export async function ensureFirewallNetwork(): Promise<{ // Check if network already exists let networkExists = false; try { - await execa('docker', ['network', 'inspect', NETWORK_NAME]); + await execa('docker', ['network', 'inspect', NETWORK_NAME], { env: getLocalDockerEnv() }); networkExists = true; logger.debug(`Network '${NETWORK_NAME}' already exists`); } catch { @@ -191,7 +192,7 @@ export async function ensureFirewallNetwork(): Promise<{ NETWORK_SUBNET, '--opt', 'com.docker.network.bridge.name=fw-bridge', - ]); + ], { env: getLocalDockerEnv() }); logger.success(`Created network '${NETWORK_NAME}' with bridge 'fw-bridge'`); } @@ -693,7 +694,7 @@ export async function cleanupFirewallNetwork(): Promise { logger.debug(`Removing firewall network '${NETWORK_NAME}'...`); try { - await execa('docker', ['network', 'rm', NETWORK_NAME], { reject: false }); + await execa('docker', ['network', 'rm', NETWORK_NAME], { reject: false, env: getLocalDockerEnv() }); logger.debug('Firewall network removed'); } catch (error) { logger.debug('Error removing firewall network:', error);