Skip to content

Commit 385ec4d

Browse files
authored
fix(docker): apply docker-host-path-prefix to all compose service mounts (#3218)
The agent and iptables-init containers coordinate via a shared bind-mounted init-signal directory at /tmp/awf-init. The iptables-init container writes ready/output.log there after running setup-iptables.sh, and the agent's entrypoint waits for those files before continuing. buildAgentVolumes() applies dockerHostPathPrefix to its mount sources so the agent's /tmp/awf-init bind is daemon-resolvable on split runner/Docker daemon filesystems (e.g. ARC + DinD). buildIptablesInitService() did not, so once --docker-host-path-prefix was set the two containers bound to two different daemon-side directories. The init container could complete successfully and the agent would still time out after 30s with 'No init container output log found' because its bind target stayed empty. The same gap existed in the squid, api-proxy, and cli-proxy service builders: their bind-mount sources (squid logs, SSL cert/key/db, api-proxy logs, cli-proxy logs, optional DIFC CA cert) were never run through the prefix translation, so on ARC/DinD their logs would land in daemon-local directories and optional file mounts could fail when Docker auto-creates a directory at the unstaged source path. Extract normalize/translate/applyHostPathPrefixToVolumes into a shared host-path-prefix module and call applyHostPathPrefixToVolumes() at the end of every service builder's volume list construction. agent-volumes.ts delegates to the shared helper and re-exports the helpers for backwards compatibility. doh-proxy has no bind mounts and is unchanged. Add a parameterized symmetric invariant test that walks every bind mount on every compose service and asserts the prefix is applied uniformly when set (and skipped otherwise), so any future service builder is protected against the same class of asymmetric translation bug.
1 parent e9e5e92 commit 385ec4d

8 files changed

Lines changed: 216 additions & 58 deletions

src/compose-generator.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ export function generateDockerCompose(
147147
environment,
148148
networkConfig,
149149
initSignalDir,
150+
dockerHostPathPrefix: config.dockerHostPathPrefix,
150151
});
151152

152153
// ── Assemble base services ─────────────────────────────────────────────────

src/services/agent-service.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,100 @@ describe('agent service', () => {
132132
expect(initService.restart).toBe('no');
133133
});
134134

135+
it('should mount init-signal dir without translation when dockerHostPathPrefix is unset', () => {
136+
const result = generateDockerCompose(mockConfig, mockNetworkConfig);
137+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
138+
const initService = result.services['iptables-init'] as any;
139+
const volumes = initService.volumes as string[];
140+
141+
// Source path is the runner-side init-signal dir, container path is /tmp/awf-init
142+
expect(volumes).toContain(`${mockConfig.workDir}/init-signal:/tmp/awf-init:rw`);
143+
});
144+
145+
it('should apply dockerHostPathPrefix to the iptables-init init-signal volume', () => {
146+
// Regression: when --docker-host-path-prefix is set (e.g. ARC + DinD), the agent
147+
// container's init-signal mount source is prefixed via translateBindMountHostPath.
148+
// The iptables-init container's mount source must be prefixed identically — otherwise
149+
// the two containers bind to different daemon-side directories and the agent times
150+
// out with "No init container output log found" because the ready file written by
151+
// setup-iptables.sh lands in a different bind-mount target.
152+
const configWithPrefix = {
153+
...mockConfig,
154+
dockerHostPathPrefix: '/host',
155+
};
156+
const result = generateDockerCompose(configWithPrefix, mockNetworkConfig);
157+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
158+
const initService = result.services['iptables-init'] as any;
159+
const initVolumes = initService.volumes as string[];
160+
const agentVolumes = result.services.agent.volumes as string[];
161+
162+
const expectedSource = `/host${mockConfig.workDir}/init-signal`;
163+
expect(initVolumes).toContain(`${expectedSource}:/tmp/awf-init:rw`);
164+
165+
// The agent must mount the SAME daemon-side source so they share the ready file.
166+
expect(agentVolumes).toContain(`${expectedSource}:/tmp/awf-init:rw`);
167+
});
168+
169+
it('should normalize trailing slash in dockerHostPathPrefix for iptables-init mount', () => {
170+
const configWithPrefix = {
171+
...mockConfig,
172+
dockerHostPathPrefix: '/host/',
173+
};
174+
const result = generateDockerCompose(configWithPrefix, mockNetworkConfig);
175+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
176+
const initService = result.services['iptables-init'] as any;
177+
const initVolumes = initService.volumes as string[];
178+
179+
expect(initVolumes).toContain(`/host${mockConfig.workDir}/init-signal:/tmp/awf-init:rw`);
180+
});
181+
182+
// Symmetric invariant: every absolute, non-kernel-virtual bind-mount source on every
183+
// service must be prefixed when dockerHostPathPrefix is set. This catches the original
184+
// class of bug (asymmetric translation between services that share a daemon-side dir)
185+
// for any future service builder, not just iptables-init.
186+
describe.each([
187+
{ name: 'unset', prefix: undefined as string | undefined, expectPrefixed: false },
188+
{ name: 'empty', prefix: '', expectPrefixed: false },
189+
{ name: 'whitespace', prefix: ' ', expectPrefixed: false },
190+
{ name: '/host', prefix: '/host', expectPrefixed: true },
191+
{ name: '/host/ (trailing slash)', prefix: '/host/', expectPrefixed: true },
192+
])('symmetric prefix translation across compose services (dockerHostPathPrefix=$name)', ({ prefix, expectPrefixed }) => {
193+
it('every absolute, non-kernel-virtual bind-mount source is prefixed consistently', () => {
194+
const cfg = {
195+
...mockConfig,
196+
dockerHostPathPrefix: prefix,
197+
// Exercise sibling services that also build bind mounts.
198+
enableApiProxy: true,
199+
difcProxyHost: 'proxy.example.com:18443',
200+
difcProxyCaCert: '/etc/ssl/ca.crt',
201+
};
202+
const result = generateDockerCompose(cfg, mockNetworkConfig);
203+
204+
const allVolumes: string[] = [];
205+
for (const [, svc] of Object.entries(result.services)) {
206+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
207+
const volumes = (svc as any).volumes as string[] | undefined;
208+
if (Array.isArray(volumes)) allVolumes.push(...volumes);
209+
}
210+
211+
// Sanity: at least one workDir-derived mount on every service we touched
212+
expect(allVolumes.some(v => v.includes(mockConfig.workDir))).toBe(true);
213+
214+
for (const mount of allVolumes) {
215+
const [src] = mount.split(':');
216+
// Skip relative sources (named volumes) and the kernel virtual / /dev/null exemptions
217+
if (!src.startsWith('/')) continue;
218+
if (src === '/dev/null' || src.startsWith('/dev') || src.startsWith('/sys') || src.startsWith('/proc')) continue;
219+
220+
if (expectPrefixed) {
221+
expect(src).toMatch(/^\/host(\/|$)/);
222+
} else {
223+
expect(src).not.toMatch(/^\/host(\/|$)/);
224+
}
225+
}
226+
});
227+
});
228+
135229
it('should apply container hardening measures', () => {
136230
const result = generateDockerCompose(mockConfig, mockNetworkConfig);
137231
const agent = result.services.agent;

src/services/agent-service.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { NetworkConfig, ImageBuildConfig } from './squid-service';
1515
// Re-export functions for backwards compatibility
1616
export { buildAgentEnvironment } from './agent-environment';
1717
export { buildAgentVolumes } from './agent-volumes';
18+
import { applyHostPathPrefixToVolumes } from './host-path-prefix';
1819

1920
// ─── Agent Service ────────────────────────────────────────────────────────────
2021

@@ -206,6 +207,12 @@ interface IptablesInitServiceParams {
206207
environment: Record<string, string>;
207208
networkConfig: NetworkConfig;
208209
initSignalDir: string;
210+
// When the Docker daemon resolves bind-mount sources from a different filesystem
211+
// view than the runner (e.g. ARC + DinD), translate the init-signal mount source
212+
// through the same prefix used for agent volumes. Without this, the agent and
213+
// iptables-init containers land on two different daemon-side directories and the
214+
// ready/output.log handshake silently fails ("No init container output log found").
215+
dockerHostPathPrefix?: string;
209216
}
210217

211218
/**
@@ -214,7 +221,16 @@ interface IptablesInitServiceParams {
214221
* without ever granting NET_ADMIN to the agent itself.
215222
*/
216223
export function buildIptablesInitService(params: IptablesInitServiceParams): any {
217-
const { agentService, environment, networkConfig, initSignalDir } = params;
224+
const { agentService, environment, networkConfig, initSignalDir, dockerHostPathPrefix } = params;
225+
226+
// The init-signal mount must use the same source path that the agent container uses,
227+
// otherwise the two containers bind to different daemon-side directories and the
228+
// ready-file handshake fails. buildAgentVolumes() applies dockerHostPathPrefix to its
229+
// mounts, so do the same here via the shared helper.
230+
const [initSignalMount] = applyHostPathPrefixToVolumes(
231+
[`${initSignalDir}:/tmp/awf-init:rw`],
232+
dockerHostPathPrefix,
233+
);
218234

219235
// SECURITY: iptables init container - sets up NAT rules in a separate container
220236
// that shares the agent's network namespace but NEVER gives NET_ADMIN to the agent.
@@ -225,7 +241,7 @@ export function buildIptablesInitService(params: IptablesInitServiceParams): any
225241
network_mode: 'service:agent',
226242
// Only mount the init signal volume and the iptables setup script
227243
volumes: [
228-
`${initSignalDir}:/tmp/awf-init:rw`,
244+
initSignalMount,
229245
],
230246
environment: {
231247
// Pass through environment variables needed by setup-iptables.sh

src/services/agent-volumes.ts

Lines changed: 14 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,19 @@ import execa from 'execa';
44
import { SslConfig } from '../host-env';
55
import { logger } from '../logger';
66
import { WrapperConfig } from '../types';
7+
import {
8+
normalizeDockerHostPathPrefix,
9+
translateBindMountHostPath,
10+
applyHostPathPrefixToVolumes,
11+
} from './host-path-prefix';
12+
13+
// Re-export for backwards compatibility — call sites that previously imported
14+
// these helpers from agent-volumes.ts continue to work.
15+
export {
16+
normalizeDockerHostPathPrefix,
17+
translateBindMountHostPath,
18+
applyHostPathPrefixToVolumes,
19+
};
720

821
// ─── Agent Volumes ────────────────────────────────────────────────────────────
922

@@ -18,47 +31,6 @@ interface AgentVolumesParams {
1831
initSignalDir: string;
1932
}
2033

21-
function normalizeDockerHostPathPrefix(prefix: string): string {
22-
const trimmed = prefix.trim();
23-
if (!trimmed) return '';
24-
const withLeadingSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
25-
const withoutTrailingSlash = withLeadingSlash.replace(/\/+$/, '');
26-
return withoutTrailingSlash || '/';
27-
}
28-
29-
function translateBindMountHostPath(mount: string, dockerHostPathPrefix: string): string {
30-
const parts = mount.split(':');
31-
if (parts.length < 2 || parts.length > 3) {
32-
return mount;
33-
}
34-
35-
const [hostPath, containerPath, mode] = parts;
36-
if (!hostPath.startsWith('/')) {
37-
return mount;
38-
}
39-
40-
// Skip kernel virtual filesystems — /dev, /sys, and /proc are provided by the
41-
// Docker daemon's own kernel, not staged runner paths. Prefixing them would look
42-
// for non-existent directories under the runner root.
43-
// SECURITY: /dev/null must be preserved for credential-hiding overlays.
44-
// /proc is not bind-mounted (it's a fresh procfs via mount -t proc in entrypoint.sh
45-
// with hidepid=2), but is included defensively to prevent accidental exposure of
46-
// /proc/*/environ which contains auth credentials.
47-
if (hostPath === '/dev/null' || hostPath.startsWith('/dev') || hostPath.startsWith('/sys') || hostPath.startsWith('/proc')) {
48-
return mount;
49-
}
50-
51-
if (dockerHostPathPrefix === '/') {
52-
return mount;
53-
}
54-
55-
const translatedHostPath = hostPath === '/'
56-
? dockerHostPathPrefix
57-
: `${dockerHostPathPrefix}${hostPath}`;
58-
59-
return mode ? `${translatedHostPath}:${containerPath}:${mode}` : `${translatedHostPath}:${containerPath}`;
60-
}
61-
6234
const DEFAULT_DOCKER_SOCKET_PATH = '/var/run/docker.sock';
6335

6436
function resolveDockerSocketPath(config: WrapperConfig): string {
@@ -491,10 +463,7 @@ export function buildAgentVolumes(params: AgentVolumesParams): string[] {
491463
logger.debug(`Hidden ${chrootCredentialFiles.length} credential file(s) at /host paths`);
492464

493465
if (config.dockerHostPathPrefix) {
494-
const dockerHostPathPrefix = normalizeDockerHostPathPrefix(config.dockerHostPathPrefix);
495-
if (dockerHostPathPrefix) {
496-
return agentVolumes.map(mount => translateBindMountHostPath(mount, dockerHostPathPrefix));
497-
}
466+
return applyHostPathPrefixToVolumes(agentVolumes, config.dockerHostPathPrefix);
498467
}
499468

500469
return agentVolumes;

src/services/api-proxy-service.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { WrapperConfig, API_PROXY_PORTS, API_PROXY_HEALTH_PORT } from '../types'
1111
import { pickEnvVars } from '../env-utils';
1212
import { COPILOT_PLACEHOLDER_TOKEN } from '../constants/placeholders';
1313
import { NetworkConfig, ImageBuildConfig } from './squid-service';
14+
import { applyHostPathPrefixToVolumes } from './host-path-prefix';
1415

1516
interface ApiProxyBuildResult {
1617
/** The api-proxy service definition to add to Docker Compose services. */
@@ -70,10 +71,13 @@ export function buildApiProxyService(params: ApiProxyServiceParams): ApiProxyBui
7071
ipv4_address: networkConfig.proxyIp,
7172
},
7273
},
73-
volumes: [
74-
// Mount log directory for api-proxy logs
75-
`${apiProxyLogsPath}:/var/log/api-proxy:rw`,
76-
],
74+
volumes: applyHostPathPrefixToVolumes(
75+
[
76+
// Mount log directory for api-proxy logs
77+
`${apiProxyLogsPath}:/var/log/api-proxy:rw`,
78+
],
79+
config.dockerHostPathPrefix,
80+
),
7781
environment: {
7882
// Pass API keys securely to sidecar (not visible to agent)
7983
...(config.openaiApiKey && { OPENAI_API_KEY: config.openaiApiKey }),

src/services/cli-proxy-service.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { buildRuntimeImageRef } from '../image-tag';
44
import { logger } from '../logger';
55
import { WrapperConfig, CLI_PROXY_PORT } from '../types';
66
import { NetworkConfig, ImageBuildConfig } from './squid-service';
7+
import { applyHostPathPrefixToVolumes } from './host-path-prefix';
78

89
interface CliProxyBuildResult {
910
/** The cli-proxy service definition to add to Docker Compose services. */
@@ -52,12 +53,15 @@ export function buildCliProxyService(params: CliProxyServiceParams): CliProxyBui
5253
},
5354
// Enable host.docker.internal resolution for connecting to host DIFC proxy
5455
extra_hosts: ['host.docker.internal:host-gateway'],
55-
volumes: [
56-
// Log directory for HTTP server logs
57-
`${cliProxyLogsPath}:/var/log/cli-proxy:rw`,
58-
// Mount host CA cert for TLS verification
59-
...(config.difcProxyCaCert ? [`${config.difcProxyCaCert}:/tmp/proxy-tls/ca.crt:ro`] : []),
60-
],
56+
volumes: applyHostPathPrefixToVolumes(
57+
[
58+
// Log directory for HTTP server logs
59+
`${cliProxyLogsPath}:/var/log/cli-proxy:rw`,
60+
// Mount host CA cert for TLS verification
61+
...(config.difcProxyCaCert ? [`${config.difcProxyCaCert}:/tmp/proxy-tls/ca.crt:ro`] : []),
62+
],
63+
config.dockerHostPathPrefix,
64+
),
6165
environment: {
6266
// External DIFC proxy connection info for tcp-tunnel.js
6367
AWF_DIFC_PROXY_HOST: difcProxyHost,

src/services/host-path-prefix.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// Helpers for rewriting Docker bind-mount source paths so the daemon can
2+
// resolve them on split runner/Docker daemon filesystems (e.g. ARC + DinD).
3+
//
4+
// When the runner process and the Docker daemon do not share the same root
5+
// filesystem, bind-mount sources resolved on the runner side are not visible
6+
// to the daemon. The user can stage the runner filesystem (or part of it)
7+
// under a known location inside the daemon (commonly /host) and pass
8+
// `--docker-host-path-prefix /host` so AWF rewrites every bind-mount source
9+
// from `/foo` to `/host/foo` before handing the compose file to docker.
10+
//
11+
// These helpers are shared by all service builders (agent, iptables-init,
12+
// squid, api-proxy, cli-proxy) so the rewrite is symmetric across services
13+
// that share daemon-side directories.
14+
15+
export function normalizeDockerHostPathPrefix(prefix: string): string {
16+
const trimmed = prefix.trim();
17+
if (!trimmed) return '';
18+
const withLeadingSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
19+
const withoutTrailingSlash = withLeadingSlash.replace(/\/+$/, '');
20+
return withoutTrailingSlash || '/';
21+
}
22+
23+
export function translateBindMountHostPath(mount: string, dockerHostPathPrefix: string): string {
24+
const parts = mount.split(':');
25+
if (parts.length < 2 || parts.length > 3) {
26+
return mount;
27+
}
28+
29+
const [hostPath, containerPath, mode] = parts;
30+
if (!hostPath.startsWith('/')) {
31+
return mount;
32+
}
33+
34+
// Skip kernel virtual filesystems — /dev, /sys, and /proc are provided by the
35+
// Docker daemon's own kernel, not staged runner paths. Prefixing them would look
36+
// for non-existent directories under the runner root.
37+
// SECURITY: /dev/null must be preserved for credential-hiding overlays.
38+
// /proc is not bind-mounted (it's a fresh procfs via mount -t proc in entrypoint.sh
39+
// with hidepid=2), but is included defensively to prevent accidental exposure of
40+
// /proc/*/environ which contains auth credentials.
41+
if (hostPath === '/dev/null' || hostPath.startsWith('/dev') || hostPath.startsWith('/sys') || hostPath.startsWith('/proc')) {
42+
return mount;
43+
}
44+
45+
if (dockerHostPathPrefix === '/') {
46+
return mount;
47+
}
48+
49+
const translatedHostPath = hostPath === '/'
50+
? dockerHostPathPrefix
51+
: `${dockerHostPathPrefix}${hostPath}`;
52+
53+
return mode ? `${translatedHostPath}:${containerPath}:${mode}` : `${translatedHostPath}:${containerPath}`;
54+
}
55+
56+
// Applies dockerHostPathPrefix translation to every bind mount in the list.
57+
// Returns the input unchanged when no prefix is set or the prefix normalises
58+
// to an empty string. Service builders call this at the end of their volume
59+
// list construction so the rewrite is consistent across the compose stack.
60+
export function applyHostPathPrefixToVolumes(volumes: string[], dockerHostPathPrefix: string | undefined): string[] {
61+
if (!dockerHostPathPrefix) return volumes;
62+
const normalized = normalizeDockerHostPathPrefix(dockerHostPathPrefix);
63+
if (!normalized) return volumes;
64+
return volumes.map(mount => translateBindMountHostPath(mount, normalized));
65+
}

src/services/squid-service.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { SslConfig, SQUID_PORT, SQUID_CONTAINER_NAME } from '../host-env';
33
import { parseImageTag, buildRuntimeImageRef } from '../image-tag';
44
import { logger } from '../logger';
55
import { WrapperConfig } from '../types';
6+
import { applyHostPathPrefixToVolumes } from './host-path-prefix';
67

78
/** Network configuration passed to service builders */
89
export interface NetworkConfig {
@@ -56,6 +57,10 @@ export function buildSquidService(params: SquidServiceParams): any {
5657
squidVolumes.push(`${sslConfig.sslDbPath}:/var/spool/squid_ssl_db:rw`);
5758
}
5859

60+
// Apply --docker-host-path-prefix to all bind-mount sources so the daemon
61+
// can resolve them on split runner/Docker daemon filesystems (e.g. ARC + DinD).
62+
const translatedSquidVolumes = applyHostPathPrefixToVolumes(squidVolumes, config.dockerHostPathPrefix);
63+
5964
// Squid service configuration
6065
const squidService: any = {
6166
container_name: SQUID_CONTAINER_NAME,
@@ -64,7 +69,7 @@ export function buildSquidService(params: SquidServiceParams): any {
6469
ipv4_address: networkConfig.squidIp,
6570
},
6671
},
67-
volumes: squidVolumes,
72+
volumes: translatedSquidVolumes,
6873
healthcheck: {
6974
test: ['CMD', 'nc', '-z', 'localhost', '3128'],
7075
interval: '1s',

0 commit comments

Comments
 (0)