Skip to content

Commit c96f0ce

Browse files
authored
Merge 72ff1cb into 218a001
2 parents 218a001 + 72ff1cb commit c96f0ce

7 files changed

Lines changed: 345 additions & 32 deletions

File tree

docs/environment.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,66 @@ When enabled, the library logs:
186186

187187
**Note:** Debug output goes to stderr and does not interfere with command stdout. See `containers/agent/one-shot-token/README.md` for complete documentation.
188188

189+
## Workflow-Scope Docker-in-Docker (`DOCKER_HOST`)
190+
191+
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.
192+
193+
### What happens
194+
195+
AWF's container orchestration (Squid proxy, agent, iptables-init) must run on the **local** Docker daemon so that:
196+
- bind mounts from the runner host filesystem work correctly,
197+
- AWF's fixed subnet (`172.30.0.0/24`) and iptables DNAT rules are created in the right network namespace, and
198+
- port binding expectations between containers are satisfied.
199+
200+
When `DOCKER_HOST` is set to a TCP address, AWF:
201+
202+
1. **Emits a warning** (not an error) informing you that the local socket will be used for AWF's own containers.
203+
2. **Clears `DOCKER_HOST`** for all `docker` / `docker compose` calls it makes internally, so they target the local daemon.
204+
3. **Forwards the original `DOCKER_HOST`** into the agent container's environment, so Docker commands run *by the agent* still reach the DinD daemon.
205+
206+
### Example workflow structure
207+
208+
```yaml
209+
jobs:
210+
build:
211+
runs-on: ubuntu-latest
212+
services:
213+
dind:
214+
image: docker:dind
215+
options: --privileged
216+
ports:
217+
- 2375:2375
218+
env:
219+
DOCKER_HOST: tcp://localhost:2375
220+
steps:
221+
- uses: actions/checkout@v4
222+
- name: Run agent with AWF
223+
run: |
224+
# AWF warns about DOCKER_HOST but proceeds with local socket for its own containers.
225+
# The agent can run `docker build` / `docker run` and they will reach the DinD daemon
226+
# via the forwarded DOCKER_HOST inside the container.
227+
awf --allow-domains registry-1.docker.io,ghcr.io -- docker build -t myapp .
228+
```
229+
230+
### Explicit socket override
231+
232+
If your local Docker daemon is at a non-standard Unix socket path, use `--docker-host`:
233+
234+
```bash
235+
awf --docker-host unix:///run/user/1000/docker.sock \
236+
--allow-domains github.com \
237+
-- agent-command
238+
```
239+
240+
This overrides the socket used for AWF's own operations without affecting the agent's `DOCKER_HOST`.
241+
242+
### Limitation
243+
244+
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:
245+
246+
- **`--enable-host-access`** — allows the agent to reach `host.docker.internal` and set `DOCKER_HOST=tcp://host.docker.internal:2375` inside the agent.
247+
- **`--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).
248+
189249
## Troubleshooting
190250

191251
**Variable not accessible:** Use `sudo -E` or pass explicitly with `--env VAR="$VAR"`

src/cli.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
preserveIptablesAudit,
1717
fastKillAgentContainer,
1818
collectDiagnosticLogs,
19+
setAwfDockerHost,
1920
} from './docker-manager';
2021
import {
2122
ensureFirewallNetwork,
@@ -1370,6 +1371,12 @@ program
13701371
'Use local images without pulling from registry (requires pre-downloaded images)',
13711372
false
13721373
)
1374+
.option(
1375+
'--docker-host <socket>',
1376+
'Docker socket for AWF\'s own containers (default: auto-detect from DOCKER_HOST env).\n' +
1377+
' Use when Docker is at a non-standard path.\n' +
1378+
' Example: unix:///run/user/1000/docker.sock'
1379+
)
13731380

13741381
// -- Container Configuration --
13751382
.option(
@@ -1602,12 +1609,14 @@ program
16021609

16031610
logger.setLevel(logLevel);
16041611

1605-
// Fail fast when DOCKER_HOST points at an external daemon (e.g. workflow-scope DinD).
1606-
// AWF's network isolation depends on direct access to the local Docker socket.
1612+
// When DOCKER_HOST points at an external TCP daemon (e.g. workflow-scope DinD),
1613+
// AWF redirects its own docker calls to the local socket automatically.
1614+
// The original DOCKER_HOST value is forwarded into the agent container so the
1615+
// agent workload can still reach the DinD daemon.
16071616
const dockerHostCheck = checkDockerHost();
16081617
if (!dockerHostCheck.valid) {
1609-
logger.error(`❌ ${dockerHostCheck.error}`);
1610-
process.exit(1);
1618+
logger.warn('⚠️ External DOCKER_HOST detected. AWF will redirect its own Docker calls to the local socket.');
1619+
logger.warn(' The original DOCKER_HOST (and related Docker client env vars) are forwarded into the agent container.');
16111620
}
16121621

16131622
// Parse domains from both --allow-domains flag and --allow-domains-file
@@ -1909,8 +1918,18 @@ program
19091918
difcProxyCaCert: options.difcProxyCaCert,
19101919
githubToken: process.env.GITHUB_TOKEN || process.env.GH_TOKEN,
19111920
diagnosticLogs: options.diagnosticLogs || false,
1921+
awfDockerHost: options.dockerHost,
19121922
};
19131923

1924+
// Apply --docker-host override for AWF's own container operations.
1925+
// This must be called before startContainers/stopContainers/runAgentCommand.
1926+
if (config.awfDockerHost && !config.awfDockerHost.startsWith('unix://')) {
1927+
logger.error(`❌ --docker-host must be a unix:// socket URI, got: ${config.awfDockerHost}`);
1928+
logger.error(' Example: --docker-host unix:///run/user/1000/docker.sock');
1929+
process.exit(1);
1930+
}
1931+
setAwfDockerHost(config.awfDockerHost);
1932+
19141933
// Parse and validate --agent-timeout
19151934
applyAgentTimeout(options.agentTimeout, config, logger);
19161935

src/docker-manager.test.ts

Lines changed: 140 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
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';
1+
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';
22
import { WrapperConfig } from './types';
33
import * as fs from 'fs';
44
import * as path from 'path';
@@ -1386,6 +1386,56 @@ describe('docker-manager', () => {
13861386
}
13871387
});
13881388

1389+
it('should forward DOCKER_HOST into agent container when set (TCP address)', () => {
1390+
const originalDockerHost = process.env.DOCKER_HOST;
1391+
process.env.DOCKER_HOST = 'tcp://localhost:2375';
1392+
1393+
try {
1394+
const result = generateDockerCompose(mockConfig, mockNetworkConfig);
1395+
const env = result.services.agent.environment as Record<string, string>;
1396+
// Agent must receive the original DOCKER_HOST so it can reach the DinD daemon
1397+
expect(env.DOCKER_HOST).toBe('tcp://localhost:2375');
1398+
} finally {
1399+
if (originalDockerHost !== undefined) {
1400+
process.env.DOCKER_HOST = originalDockerHost;
1401+
} else {
1402+
delete process.env.DOCKER_HOST;
1403+
}
1404+
}
1405+
});
1406+
1407+
it('should forward DOCKER_HOST into agent container when set (unix socket)', () => {
1408+
const originalDockerHost = process.env.DOCKER_HOST;
1409+
process.env.DOCKER_HOST = 'unix:///var/run/docker.sock';
1410+
1411+
try {
1412+
const result = generateDockerCompose(mockConfig, mockNetworkConfig);
1413+
const env = result.services.agent.environment as Record<string, string>;
1414+
expect(env.DOCKER_HOST).toBe('unix:///var/run/docker.sock');
1415+
} finally {
1416+
if (originalDockerHost !== undefined) {
1417+
process.env.DOCKER_HOST = originalDockerHost;
1418+
} else {
1419+
delete process.env.DOCKER_HOST;
1420+
}
1421+
}
1422+
});
1423+
1424+
it('should not set DOCKER_HOST in agent container when not in host environment', () => {
1425+
const originalDockerHost = process.env.DOCKER_HOST;
1426+
delete process.env.DOCKER_HOST;
1427+
1428+
try {
1429+
const result = generateDockerCompose(mockConfig, mockNetworkConfig);
1430+
const env = result.services.agent.environment as Record<string, string>;
1431+
expect(env.DOCKER_HOST).toBeUndefined();
1432+
} finally {
1433+
if (originalDockerHost !== undefined) {
1434+
process.env.DOCKER_HOST = originalDockerHost;
1435+
}
1436+
}
1437+
});
1438+
13891439
it('should add additional environment variables from config', () => {
13901440
const configWithEnv = {
13911441
...mockConfig,
@@ -3489,7 +3539,7 @@ describe('docker-manager', () => {
34893539
expect(mockExecaFn).toHaveBeenCalledWith(
34903540
'docker',
34913541
['rm', '-f', 'awf-squid', 'awf-agent', 'awf-iptables-init', 'awf-api-proxy', 'awf-cli-proxy'],
3492-
{ reject: false }
3542+
expect.objectContaining({ reject: false })
34933543
);
34943544
});
34953545

@@ -3505,7 +3555,7 @@ describe('docker-manager', () => {
35053555
expect(mockExecaFn).toHaveBeenCalledWith(
35063556
'docker',
35073557
['compose', 'up', '-d'],
3508-
{ cwd: testDir, stdout: process.stderr, stderr: 'inherit' }
3558+
expect.objectContaining({ cwd: testDir, stdout: process.stderr, stderr: 'inherit' })
35093559
);
35103560
});
35113561

@@ -3518,7 +3568,7 @@ describe('docker-manager', () => {
35183568
expect(mockExecaFn).toHaveBeenCalledWith(
35193569
'docker',
35203570
['compose', 'up', '-d'],
3521-
{ cwd: testDir, stdout: process.stderr, stderr: 'inherit' }
3571+
expect.objectContaining({ cwd: testDir, stdout: process.stderr, stderr: 'inherit' })
35223572
);
35233573
});
35243574

@@ -3531,7 +3581,7 @@ describe('docker-manager', () => {
35313581
expect(mockExecaFn).toHaveBeenCalledWith(
35323582
'docker',
35333583
['compose', 'up', '-d', '--pull', 'never'],
3534-
{ cwd: testDir, stdout: process.stderr, stderr: 'inherit' }
3584+
expect.objectContaining({ cwd: testDir, stdout: process.stderr, stderr: 'inherit' })
35353585
);
35363586
});
35373587

@@ -3544,7 +3594,7 @@ describe('docker-manager', () => {
35443594
expect(mockExecaFn).toHaveBeenCalledWith(
35453595
'docker',
35463596
['compose', 'up', '-d'],
3547-
{ cwd: testDir, stdout: process.stderr, stderr: 'inherit' }
3597+
expect.objectContaining({ cwd: testDir, stdout: process.stderr, stderr: 'inherit' })
35483598
);
35493599
});
35503600

@@ -3599,7 +3649,7 @@ describe('docker-manager', () => {
35993649
expect(mockExecaFn).toHaveBeenCalledWith(
36003650
'docker',
36013651
['compose', 'down', '-v', '-t', '1'],
3602-
{ cwd: testDir, stdout: process.stderr, stderr: 'inherit' }
3652+
expect.objectContaining({ cwd: testDir, stdout: process.stderr, stderr: 'inherit' })
36033653
);
36043654
});
36053655

@@ -3624,7 +3674,7 @@ describe('docker-manager', () => {
36243674
expect(mockExecaFn).toHaveBeenCalledWith(
36253675
'docker',
36263676
['stop', '-t', '3', AGENT_CONTAINER_NAME],
3627-
{ reject: false, timeout: 8000 }
3677+
expect.objectContaining({ reject: false, timeout: 8000 })
36283678
);
36293679
});
36303680

@@ -3636,7 +3686,7 @@ describe('docker-manager', () => {
36363686
expect(mockExecaFn).toHaveBeenCalledWith(
36373687
'docker',
36383688
['stop', '-t', '5', AGENT_CONTAINER_NAME],
3639-
{ reject: false, timeout: 10000 }
3689+
expect.objectContaining({ reject: false, timeout: 10000 })
36403690
);
36413691
});
36423692

@@ -3662,6 +3712,86 @@ describe('docker-manager', () => {
36623712
});
36633713
});
36643714

3715+
describe('setAwfDockerHost / getLocalDockerEnv (DOCKER_HOST isolation)', () => {
3716+
const originalDockerHost = process.env.DOCKER_HOST;
3717+
3718+
afterEach(() => {
3719+
// Restore env and reset override after each test
3720+
if (originalDockerHost === undefined) {
3721+
delete process.env.DOCKER_HOST;
3722+
} else {
3723+
process.env.DOCKER_HOST = originalDockerHost;
3724+
}
3725+
setAwfDockerHost(undefined);
3726+
jest.clearAllMocks();
3727+
});
3728+
3729+
it('docker compose up should NOT forward a TCP DOCKER_HOST to the docker CLI', async () => {
3730+
process.env.DOCKER_HOST = 'tcp://localhost:2375';
3731+
const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-test-'));
3732+
try {
3733+
mockExecaFn.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as any); // docker rm
3734+
mockExecaFn.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as any); // docker compose up
3735+
3736+
await startContainers(testDir, ['github.com']);
3737+
3738+
const composeCalls = mockExecaFn.mock.calls.filter(
3739+
(call: any[]) => call[1]?.[0] === 'compose'
3740+
);
3741+
expect(composeCalls.length).toBeGreaterThan(0);
3742+
const composeEnv = composeCalls[0][2]?.env as Record<string, string | undefined> | undefined;
3743+
// DOCKER_HOST must be absent from the env passed to docker compose
3744+
expect(composeEnv).toBeDefined();
3745+
expect(composeEnv!.DOCKER_HOST).toBeUndefined();
3746+
} finally {
3747+
fs.rmSync(testDir, { recursive: true, force: true });
3748+
}
3749+
});
3750+
3751+
it('docker compose up should keep a unix:// DOCKER_HOST in the env', async () => {
3752+
process.env.DOCKER_HOST = 'unix:///var/run/docker.sock';
3753+
const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-test-'));
3754+
try {
3755+
mockExecaFn.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as any); // docker rm
3756+
mockExecaFn.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as any); // docker compose up
3757+
3758+
await startContainers(testDir, ['github.com']);
3759+
3760+
const composeCalls = mockExecaFn.mock.calls.filter(
3761+
(call: any[]) => call[1]?.[0] === 'compose'
3762+
);
3763+
expect(composeCalls.length).toBeGreaterThan(0);
3764+
const composeEnv = composeCalls[0][2]?.env as Record<string, string | undefined> | undefined;
3765+
expect(composeEnv).toBeDefined();
3766+
expect(composeEnv!.DOCKER_HOST).toBe('unix:///var/run/docker.sock');
3767+
} finally {
3768+
fs.rmSync(testDir, { recursive: true, force: true });
3769+
}
3770+
});
3771+
3772+
it('setAwfDockerHost should override DOCKER_HOST for AWF operations', async () => {
3773+
process.env.DOCKER_HOST = 'tcp://localhost:2375'; // Would normally be cleared
3774+
setAwfDockerHost('unix:///run/user/1000/docker.sock');
3775+
const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-test-'));
3776+
try {
3777+
mockExecaFn.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as any); // docker rm
3778+
mockExecaFn.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as any); // docker compose up
3779+
3780+
await startContainers(testDir, ['github.com']);
3781+
3782+
const composeCalls = mockExecaFn.mock.calls.filter(
3783+
(call: any[]) => call[1]?.[0] === 'compose'
3784+
);
3785+
expect(composeCalls.length).toBeGreaterThan(0);
3786+
const composeEnv = composeCalls[0][2]?.env as Record<string, string | undefined> | undefined;
3787+
expect(composeEnv).toBeDefined();
3788+
expect(composeEnv!.DOCKER_HOST).toBe('unix:///run/user/1000/docker.sock');
3789+
} finally {
3790+
fs.rmSync(testDir, { recursive: true, force: true });
3791+
}
3792+
});
3793+
});
3794+
36653795
describe('runAgentCommand', () => {
36663796
let testDir: string;
36673797

@@ -3820,7 +3950,7 @@ describe('docker-manager', () => {
38203950

38213951
expect(result.exitCode).toBe(124);
38223952
// Verify docker stop was called
3823-
expect(mockExecaFn).toHaveBeenCalledWith('docker', ['stop', '-t', '10', 'awf-agent'], { reject: false });
3953+
expect(mockExecaFn).toHaveBeenCalledWith('docker', ['stop', '-t', '10', 'awf-agent'], expect.objectContaining({ reject: false }));
38243954

38253955
jest.useRealTimers();
38263956
});

0 commit comments

Comments
 (0)