Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions docs/environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,66 @@ 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 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).

## Troubleshooting

**Variable not accessible:** Use `sudo -E` or pass explicitly with `--env VAR="$VAR"`
Expand Down
27 changes: 23 additions & 4 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
preserveIptablesAudit,
fastKillAgentContainer,
collectDiagnosticLogs,
setAwfDockerHost,
} from './docker-manager';
import {
ensureFirewallNetwork,
Expand Down Expand Up @@ -1370,6 +1371,12 @@ program
'Use local images without pulling from registry (requires pre-downloaded images)',
false
)
.option(
'--docker-host <socket>',
'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'
)
Comment on lines +1374 to +1379
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

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

--docker-host is described as a Unix socket override, but the value is not validated before being applied via setAwfDockerHost(). If a user passes a TCP/SSH DOCKER_HOST (or an invalid string), AWF will route its container orchestration back to an external daemon and likely fail in the same way this PR is trying to avoid. Consider validating that --docker-host is a unix:// socket URI (and erroring otherwise), or explicitly documenting/handling supported schemes.

Copilot uses AI. Check for mistakes.

// -- Container Configuration --
.option(
Expand Down Expand Up @@ -1602,12 +1609,14 @@ 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) {
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

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

The new DOCKER_HOST handling only affects docker CLI calls made via src/docker-manager.ts (using getLocalDockerEnv), but the workflow runs ensureFirewallNetwork / setupHostIptables before startContainers, and those functions in src/host-iptables.ts invoke docker ... via execa without overriding env. With workflow-scope DOCKER_HOST=tcp://..., those network/inspect/create calls will still be routed to the DinD daemon, so host iptables rules and awf-net inspection/bridge lookup can be applied to the wrong daemon/namespace and AWF startup can still fail. Consider applying the same local-daemon env isolation to all docker CLI call sites (host-iptables, logs utilities, predownload, etc.), e.g. by exporting a shared helper and passing { env: getLocalDockerEnv() } consistently.

Suggested change
if (!dockerHostCheck.valid) {
if (!dockerHostCheck.valid) {
// Apply the AWF-local docker host at the process level before any later
// workflow step shells out to `docker`, so inherited env targets the local daemon.
setAwfDockerHost();

Copilot uses AI. Check for mistakes.
logger.error(`❌ ${dockerHostCheck.error}`);
process.exit(1);
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
Expand Down Expand Up @@ -1909,8 +1918,18 @@ 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.
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
applyAgentTimeout(options.agentTimeout, config, logger);

Expand Down
150 changes: 140 additions & 10 deletions src/docker-manager.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<string, string>;
// 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<string, string>;
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<string, string>;
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,
Expand Down Expand Up @@ -3489,7 +3539,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 })
);
});

Expand All @@ -3505,7 +3555,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' })
);
});

Expand All @@ -3518,7 +3568,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' })
);
});

Expand All @@ -3531,7 +3581,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' })
);
});

Expand All @@ -3544,7 +3594,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' })
);
});

Expand Down Expand Up @@ -3599,7 +3649,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' })
);
});

Expand All @@ -3624,7 +3674,7 @@ describe('docker-manager', () => {
expect(mockExecaFn).toHaveBeenCalledWith(
'docker',
['stop', '-t', '3', AGENT_CONTAINER_NAME],
{ reject: false, timeout: 8000 }
expect.objectContaining({ reject: false, timeout: 8000 })
);
});

Expand All @@ -3636,7 +3686,7 @@ describe('docker-manager', () => {
expect(mockExecaFn).toHaveBeenCalledWith(
'docker',
['stop', '-t', '5', AGENT_CONTAINER_NAME],
{ reject: false, timeout: 10000 }
expect.objectContaining({ reject: false, timeout: 10000 })
);
});

Expand All @@ -3662,6 +3712,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<string, string | undefined> | 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<string, string | undefined> | 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<string, string | undefined> | 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;

Expand Down Expand Up @@ -3820,7 +3950,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();
});
Expand Down
Loading
Loading