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
9 changes: 7 additions & 2 deletions containers/agent/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -461,9 +461,14 @@ if [ "${AWF_CHROOT_ENABLED}" = "true" ]; then
# always resolves to the parent shell's exe, causing runtime failures.
# Security: This procfs is container-scoped (only shows container processes, not host).
# SYS_ADMIN capability (required for mount) is dropped before user code runs.
# SECURITY: hidepid=2 prevents awfuser from reading other processes' /proc/[pid]/environ,
# which is critical because PID 1 (entrypoint) may briefly hold auth tokens before
# unset_sensitive_tokens() clears them. Without hidepid=2, the agent could race to read
# /proc/1/environ and extract credentials. Also blocks access via /dev/fd → /proc/self/fd
# symlink traversal paths.
mkdir -p /host/proc
if mount -t proc -o nosuid,nodev,noexec proc /host/proc; then
echo "[entrypoint] Mounted procfs at /host/proc (nosuid,nodev,noexec)"
if mount -t proc -o nosuid,nodev,noexec,hidepid=2 proc /host/proc; then
echo "[entrypoint] Mounted procfs at /host/proc (nosuid,nodev,noexec,hidepid=2)"
else
echo "[entrypoint][ERROR] Failed to mount procfs at /host/proc"
echo "[entrypoint][ERROR] This is required for Java, .NET, and other runtimes that read /proc/self/exe"
Expand Down
1 change: 1 addition & 0 deletions docs/awf-config-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ the corresponding CLI flag.
- `container.agentImage` → `--agent-image`
- `container.tty` → `--tty`
- `container.dockerHost` → `--docker-host`
- `container.dockerHostPathPrefix` → `--docker-host-path-prefix`
- `environment.envFile` → `--env-file`
- `environment.envAll` → `--env-all`
- `environment.excludeEnv[]` → `--exclude-env` *(repeatable)*
Expand Down
4 changes: 4 additions & 0 deletions docs/awf-config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,10 @@
"dockerHost": {
"type": "string",
"description": "Docker daemon socket or host to connect to (e.g. \"unix:///var/run/docker.sock\")."
},
"dockerHostPathPrefix": {
"type": "string",
"description": "Prefix runner-visible bind-mount source paths for Docker daemon resolution (e.g. \"/host\" for split runner/daemon filesystems)."
}
}
},
Expand Down
4 changes: 4 additions & 0 deletions src/awf-config-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,10 @@
"dockerHost": {
"type": "string",
"description": "Docker daemon socket or host to connect to (e.g. \"unix:///var/run/docker.sock\")."
},
"dockerHostPathPrefix": {
"type": "string",
"description": "Prefix runner-visible bind-mount source paths for Docker daemon resolution (e.g. \"/host\" for split runner/daemon filesystems)."
}
}
},
Expand Down
18 changes: 18 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
parseAgentTimeout,
applyAgentTimeout,
checkDockerHost,
resolveDockerHostPathPrefix,
parseDnsServers,
parseDnsOverHttps,
processLocalhostKeyword,
Expand Down Expand Up @@ -116,6 +117,7 @@
parseMemoryLimit,
applyAgentTimeout,
checkDockerHost,
resolveDockerHostPathPrefix,
parseDnsServers,
parseDnsOverHttps,
processLocalhostKeyword,
Expand Down Expand Up @@ -198,9 +200,9 @@
const flags = helper.optionTerm(opt);
const optDesc = helper.optionDescription(opt);
const longFlag = opt.long?.replace(/^--/, '');
if (longFlag && optionGroupHeaders[longFlag]) {

Check warning on line 203 in src/cli.ts

View workflow job for this annotation

GitHub Actions / ESLint

Generic Object Injection Sink

Check warning on line 203 in src/cli.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Generic Object Injection Sink

Check warning on line 203 in src/cli.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Generic Object Injection Sink
output.push('');
output.push(` ${optionGroupHeaders[longFlag]}`);

Check warning on line 205 in src/cli.ts

View workflow job for this annotation

GitHub Actions / ESLint

Generic Object Injection Sink

Check warning on line 205 in src/cli.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Generic Object Injection Sink

Check warning on line 205 in src/cli.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Generic Object Injection Sink
}
output.push(formatItem(flags, optDesc, termWidth, itemIndent + 2, itemSep, helpWidth));
}
Expand Down Expand Up @@ -298,6 +300,12 @@
' Use when Docker is at a non-standard path.\n' +
' Example: unix:///run/user/1000/docker.sock'
)
.option(
'--docker-host-path-prefix <prefix>',
'Prefix bind-mount source paths so Docker daemon can resolve runner filesystem paths.\n' +
' Useful for split runner/daemon filesystems (e.g. ARC DinD).\n' +
' Example: /host'
)

// -- Container Configuration --
.option(
Expand Down Expand Up @@ -605,6 +613,10 @@
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.');
}
const dockerHostPathPrefixResolution = resolveDockerHostPathPrefix(dockerHostCheck, options.dockerHostPathPrefix);
if (!dockerHostCheck.valid && !dockerHostPathPrefixResolution.dockerHostPathPrefix) {
logger.warn('⚠️ If your Docker daemon uses a split runner/daemon filesystem, set --docker-host-path-prefix (for example: /host).');
}

// Parse domains from both --allow-domains flag and --allow-domains-file
let allowedDomains: string[] = [];
Expand Down Expand Up @@ -747,7 +759,7 @@

// Validate --env-file path if provided
if (options.envFile) {
if (!fs.existsSync(options.envFile)) {

Check warning on line 762 in src/cli.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found existsSync from package "fs" with non literal argument at index 0

Check warning on line 762 in src/cli.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found existsSync from package "fs" with non literal argument at index 0

Check warning on line 762 in src/cli.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found existsSync from package "fs" with non literal argument at index 0
logger.error(`--env-file: file not found: ${options.envFile}`);
process.exit(1);
}
Expand Down Expand Up @@ -958,6 +970,7 @@
diagnosticLogs: options.diagnosticLogs || false,
awfDockerHost: options.dockerHost,
upstreamProxy,
dockerHostPathPrefix: dockerHostPathPrefixResolution.dockerHostPathPrefix,
};

// Apply --docker-host override for AWF's own container operations.
Expand All @@ -967,6 +980,11 @@
logger.error(' Example: --docker-host unix:///run/user/1000/docker.sock');
process.exit(1);
}
if (config.dockerHostPathPrefix && !config.dockerHostPathPrefix.startsWith('/')) {
logger.error(`❌ --docker-host-path-prefix must be an absolute path, got: ${config.dockerHostPathPrefix}`);
logger.error(' Example: --docker-host-path-prefix /host');
process.exit(1);
}
setAwfDockerHost(config.awfDockerHost);

// Parse and validate --agent-timeout
Expand Down Expand Up @@ -1081,11 +1099,11 @@
if (typeof candidate !== 'string' || candidate.trim() === '') continue;
try {
const envFilePath = path.isAbsolute(candidate) ? candidate : path.resolve(process.cwd(), candidate);
const envFileContents = fs.readFileSync(envFilePath, 'utf8');

Check warning on line 1102 in src/cli.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found readFileSync from package "fs" with non literal argument at index 0

Check warning on line 1102 in src/cli.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found readFileSync from package "fs" with non literal argument at index 0

Check warning on line 1102 in src/cli.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found readFileSync from package "fs" with non literal argument at index 0
for (const line of envFileContents.split(/\r?\n/)) {
const trimmedLine = line.trim();
if (!trimmedLine || trimmedLine.startsWith('#')) continue;
if (/^(?:export\s+)?COPILOT_MODEL\s*=/.test(trimmedLine)) {

Check warning on line 1106 in src/cli.ts

View workflow job for this annotation

GitHub Actions / ESLint

Unsafe Regular Expression

Check warning on line 1106 in src/cli.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Unsafe Regular Expression

Check warning on line 1106 in src/cli.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Unsafe Regular Expression
return true;
}
}
Expand All @@ -1112,7 +1130,7 @@
const redactedConfig: Record<string, unknown> = {};
for (const [key, value] of Object.entries(config)) {
if (key === 'openaiApiKey' || key === 'anthropicApiKey' || key === 'copilotGithubToken' || key === 'copilotApiKey' || key === 'geminiApiKey') continue;
redactedConfig[key] = key === 'agentCommand' ? redactSecrets(value as string) : value;

Check warning on line 1133 in src/cli.ts

View workflow job for this annotation

GitHub Actions / ESLint

Generic Object Injection Sink

Check warning on line 1133 in src/cli.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Generic Object Injection Sink

Check warning on line 1133 in src/cli.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Generic Object Injection Sink
}
logger.debug('Configuration:', JSON.stringify(redactedConfig, null, 2));
logger.info(`Allowed domains: ${allowedDomains.join(', ')}`);
Expand Down
7 changes: 7 additions & 0 deletions src/config-file.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,11 @@ describe('config-file', () => {
expect(errors).toContain('config.container.dockerHost must be a string');
});

it('rejects non-string container.dockerHostPathPrefix', () => {
const errors = validateAwfFileConfig({ container: { dockerHostPathPrefix: 123 } });
expect(errors).toContain('config.container.dockerHostPathPrefix must be a string');
});

it('rejects unknown container keys', () => {
const errors = validateAwfFileConfig({ container: { unknown: true } });
expect(errors).toContain('config.container.unknown is not supported');
Expand Down Expand Up @@ -645,6 +650,7 @@ describe('config-file', () => {
agentImage: 'custom:latest',
tty: true,
dockerHost: 'unix:///var/run/docker.sock',
dockerHostPathPrefix: '/host',
},
});

Expand All @@ -658,6 +664,7 @@ describe('config-file', () => {
expect(result.agentImage).toBe('custom:latest');
expect(result.tty).toBe(true);
expect(result.dockerHost).toBe('unix:///var/run/docker.sock');
expect(result.dockerHostPathPrefix).toBe('/host');
});

it('maps environment fields', () => {
Expand Down
2 changes: 2 additions & 0 deletions src/config-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export interface AwfFileConfig {
agentImage?: string;
tty?: boolean;
dockerHost?: string;
dockerHostPathPrefix?: string;
};
environment?: {
envFile?: string;
Expand Down Expand Up @@ -194,6 +195,7 @@ export function mapAwfFileConfigToCliOptions(config: AwfFileConfig): Record<stri
agentImage: config.container?.agentImage,
tty: config.container?.tty,
dockerHost: config.container?.dockerHost,
dockerHostPathPrefix: config.container?.dockerHostPathPrefix,

envFile: config.environment?.envFile,
envAll: config.environment?.envAll,
Expand Down
18 changes: 18 additions & 0 deletions src/option-parsers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
applyAgentTimeout,
collectRulesetFile,
checkDockerHost,
resolveDockerHostPathPrefix,
formatItem,
} from './option-parsers';
import * as fs from 'fs';
Expand Down Expand Up @@ -1117,6 +1118,23 @@ describe('checkDockerHost', () => {
});
});

describe('resolveDockerHostPathPrefix', () => {
it('returns explicit prefix when provided', () => {
const result = resolveDockerHostPathPrefix({ valid: false, error: 'external DOCKER_HOST' }, '/daemon-root');
expect(result).toEqual({ dockerHostPathPrefix: '/daemon-root', autoApplied: false });
});

it('does not auto-apply a prefix for external DOCKER_HOST when none is provided', () => {
const result = resolveDockerHostPathPrefix({ valid: false, error: 'external DOCKER_HOST' }, undefined);
expect(result).toEqual({ dockerHostPathPrefix: undefined, autoApplied: false });
});

it('returns undefined when DOCKER_HOST is local and no prefix is provided', () => {
const result = resolveDockerHostPathPrefix({ valid: true }, undefined);
expect(result).toEqual({ dockerHostPathPrefix: undefined, autoApplied: false });
});
});

describe('formatItem', () => {
it('should format item with description on same line when term fits', () => {
const result = formatItem('-v', 'verbose output', 20, 2, 2, 80);
Expand Down
18 changes: 18 additions & 0 deletions src/option-parsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,24 @@ export function checkDockerHost(
};
}

/**
* Resolves the effective Docker host path prefix for bind mount translation.
*
* If an explicit prefix is provided, it wins. Otherwise, no prefix is applied.
*/
export function resolveDockerHostPathPrefix(
_dockerHostCheck: { valid: true } | { valid: false; error: string },
explicitPrefix: string | undefined
): { dockerHostPathPrefix?: string; autoApplied: boolean } {
const trimmedExplicitPrefix = explicitPrefix?.trim();

if (trimmedExplicitPrefix) {
return { dockerHostPathPrefix: trimmedExplicitPrefix, autoApplied: false };
}

return { dockerHostPathPrefix: undefined, autoApplied: false };
}
Comment on lines +308 to +324

/**
* 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")
Expand Down
1 change: 1 addition & 0 deletions src/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ describe('awf-config.schema.json', () => {
agentImage: 'ghcr.io/actions/actions-runner:latest',
tty: false,
dockerHost: 'unix:///var/run/docker.sock',
dockerHostPathPrefix: '/host',
},
environment: {
envFile: '.env',
Expand Down
35 changes: 35 additions & 0 deletions src/services/agent-volumes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,41 @@ describe('agent service', () => {
expect(volumes.some((v: string) => v.includes('agent-logs'))).toBe(true);
});

it('should apply dockerHostPathPrefix to bind-mount source paths', () => {
const configWithPrefix = {
...mockConfig,
dockerHostPathPrefix: '/daemon-root',
volumeMounts: ['/workspace:/workspace:ro'],
};
const result = generateDockerCompose(configWithPrefix, mockNetworkConfig);
const volumes = result.services.agent.volumes as string[];

expect(volumes).toContain('/daemon-root/tmp:/tmp:rw');
expect(volumes).toContain('/daemon-root/usr:/host/usr:ro');
expect(volumes).toContain('/daemon-root/etc/passwd:/host/etc/passwd:ro');
expect(volumes).toContain('/daemon-root/workspace:/host/workspace:ro');
expect(volumes).toContain('/dev/null:/host/var/run/docker.sock:ro');
expect(volumes).toContain('/dev/null:/host/run/docker.sock:ro');
expect(volumes.some((v: string) => v.startsWith(`/daemon-root${mockConfig.workDir}/chroot-`) && v.endsWith(':/host/etc/hosts:ro'))).toBe(true);

// Kernel virtual filesystems should NOT be prefixed — they are daemon-local
expect(volumes).toContain('/dev:/host/dev:ro');
expect(volumes).toContain('/sys:/host/sys:ro');
expect(volumes).not.toContain('/daemon-root/dev:/host/dev:ro');
expect(volumes).not.toContain('/daemon-root/sys:/host/sys:ro');
});

it('should normalize trailing slash in dockerHostPathPrefix', () => {
const configWithPrefix = {
...mockConfig,
dockerHostPathPrefix: '/daemon-root/',
};
const result = generateDockerCompose(configWithPrefix, mockNetworkConfig);
const volumes = result.services.agent.volumes as string[];

expect(volumes).toContain('/daemon-root/tmp:/tmp:rw');
});

it('should use selective mounts when no custom mounts specified', () => {
const result = generateDockerCompose(mockConfig, mockNetworkConfig);
const agent = result.services.agent;
Expand Down
48 changes: 48 additions & 0 deletions src/services/agent-volumes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,47 @@ interface AgentVolumesParams {
initSignalDir: string;
}

function normalizeDockerHostPathPrefix(prefix: string): string {
const trimmed = prefix.trim();
if (!trimmed) return '';
const withLeadingSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
const withoutTrailingSlash = withLeadingSlash.replace(/\/+$/, '');
return withoutTrailingSlash || '/';
}

function translateBindMountHostPath(mount: string, dockerHostPathPrefix: string): string {
const parts = mount.split(':');
if (parts.length < 2 || parts.length > 3) {
return mount;
}

const [hostPath, containerPath, mode] = parts;
if (!hostPath.startsWith('/')) {
return mount;
}

// Skip kernel virtual filesystems — /dev, /sys, and /proc are provided by the
// Docker daemon's own kernel, not staged runner paths. Prefixing them would look
// for non-existent directories under the runner root.
// SECURITY: /dev/null must be preserved for credential-hiding overlays.
// /proc is not bind-mounted (it's a fresh procfs via mount -t proc in entrypoint.sh
// with hidepid=2), but is included defensively to prevent accidental exposure of
// /proc/*/environ which contains auth credentials.
if (hostPath === '/dev/null' || hostPath.startsWith('/dev') || hostPath.startsWith('/sys') || hostPath.startsWith('/proc')) {
return mount;
}

if (dockerHostPathPrefix === '/') {
return mount;
}

const translatedHostPath = hostPath === '/'
? dockerHostPathPrefix
: `${dockerHostPathPrefix}${hostPath}`;

return mode ? `${translatedHostPath}:${containerPath}:${mode}` : `${translatedHostPath}:${containerPath}`;
Comment on lines +28 to +58
}

const DEFAULT_DOCKER_SOCKET_PATH = '/var/run/docker.sock';

function resolveDockerSocketPath(config: WrapperConfig): string {
Expand Down Expand Up @@ -437,5 +478,12 @@ export function buildAgentVolumes(params: AgentVolumesParams): string[] {

logger.debug(`Hidden ${chrootCredentialFiles.length} credential file(s) at /host paths`);

if (config.dockerHostPathPrefix) {
const dockerHostPathPrefix = normalizeDockerHostPathPrefix(config.dockerHostPathPrefix);
if (dockerHostPathPrefix) {
return agentVolumes.map(mount => translateBindMountHostPath(mount, dockerHostPathPrefix));
}
}

return agentVolumes;
}
11 changes: 11 additions & 0 deletions src/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,17 @@ export interface WrapperConfig {
*/
awfDockerHost?: string;

/**
* Prefix runner-visible bind-mount source paths for Docker daemon resolution
*
* Use this when the Docker daemon runs in a different filesystem namespace
* than the AWF process (for example, ARC + DinD sidecar setups). AWF will
* prepend this prefix to bind-mount source paths before generating compose.
*
* @example '/host'
*/
dockerHostPathPrefix?: string;

/**
* URL patterns to allow for HTTPS traffic (requires sslBump: true)
*
Expand Down
Loading