diff --git a/containers/agent/entrypoint.sh b/containers/agent/entrypoint.sh
index 187c30e57..2c68a3d78 100644
--- a/containers/agent/entrypoint.sh
+++ b/containers/agent/entrypoint.sh
@@ -333,9 +333,10 @@ if [ -n "$HTTP_PROXY" ]; then
# Maven proxy config (~/.m2/settings.xml)
# Only create if the file does not already exist, to avoid clobbering user-provided settings
- mkdir -p "${JVM_HOME_PREFIX}/.m2"
- if [ ! -f "${JVM_HOME_PREFIX}/.m2/settings.xml" ]; then
- cat > "${JVM_HOME_PREFIX}/.m2/settings.xml" << MAVEN_EOF
+ if ! mkdir -p "${JVM_HOME_PREFIX}/.m2" 2>/dev/null || [ ! -w "${JVM_HOME_PREFIX}/.m2" ]; then
+ echo "[entrypoint] ⚠ Cannot write ${JVM_HOME_PREFIX}/.m2 (read-only home); skipping Maven proxy config"
+ elif [ ! -f "${JVM_HOME_PREFIX}/.m2/settings.xml" ]; then
+ if cat > "${JVM_HOME_PREFIX}/.m2/settings.xml" << MAVEN_EOF
@@ -355,8 +356,12 @@ if [ -n "$HTTP_PROXY" ]; then
MAVEN_EOF
- chown awfuser:awfuser "${JVM_HOME_PREFIX}/.m2/settings.xml" 2>/dev/null || true
- echo "[entrypoint] ✓ Created Maven proxy config (${JVM_HOME_PREFIX}/.m2/settings.xml)"
+ then
+ chown awfuser:awfuser "${JVM_HOME_PREFIX}/.m2/settings.xml" 2>/dev/null || true
+ echo "[entrypoint] ✓ Created Maven proxy config (${JVM_HOME_PREFIX}/.m2/settings.xml)"
+ else
+ echo "[entrypoint] ⚠ Failed to write ${JVM_HOME_PREFIX}/.m2/settings.xml; skipping Maven proxy config"
+ fi
else
echo "[entrypoint] ✓ Maven settings.xml already exists, skipping proxy config creation"
fi
@@ -364,16 +369,21 @@ MAVEN_EOF
# Gradle proxy config (~/.gradle/gradle.properties)
# Only create if the file does not already exist, to avoid clobbering user-provided settings
# (e.g., org.gradle.jvmargs, build cache settings, private repo credentials)
- mkdir -p "${JVM_HOME_PREFIX}/.gradle"
- if [ ! -f "${JVM_HOME_PREFIX}/.gradle/gradle.properties" ]; then
- cat > "${JVM_HOME_PREFIX}/.gradle/gradle.properties" << GRADLE_EOF
+ if ! mkdir -p "${JVM_HOME_PREFIX}/.gradle" 2>/dev/null || [ ! -w "${JVM_HOME_PREFIX}/.gradle" ]; then
+ echo "[entrypoint] ⚠ Cannot write ${JVM_HOME_PREFIX}/.gradle (read-only home); skipping Gradle proxy config"
+ elif [ ! -f "${JVM_HOME_PREFIX}/.gradle/gradle.properties" ]; then
+ if cat > "${JVM_HOME_PREFIX}/.gradle/gradle.properties" << GRADLE_EOF
systemProp.http.proxyHost=${PROXY_HOST}
systemProp.http.proxyPort=${PROXY_PORT}
systemProp.https.proxyHost=${PROXY_HOST}
systemProp.https.proxyPort=${PROXY_PORT}
GRADLE_EOF
- chown awfuser:awfuser "${JVM_HOME_PREFIX}/.gradle/gradle.properties" 2>/dev/null || true
- echo "[entrypoint] ✓ Created Gradle proxy config (${JVM_HOME_PREFIX}/.gradle/gradle.properties)"
+ then
+ chown awfuser:awfuser "${JVM_HOME_PREFIX}/.gradle/gradle.properties" 2>/dev/null || true
+ echo "[entrypoint] ✓ Created Gradle proxy config (${JVM_HOME_PREFIX}/.gradle/gradle.properties)"
+ else
+ echo "[entrypoint] ⚠ Failed to write ${JVM_HOME_PREFIX}/.gradle/gradle.properties; skipping Gradle proxy config"
+ fi
else
echo "[entrypoint] ✓ Gradle gradle.properties already exists, skipping proxy config creation"
fi
diff --git a/docs/arc-dind.md b/docs/arc-dind.md
index 39d63d2cf..cd52802c3 100644
--- a/docs/arc-dind.md
+++ b/docs/arc-dind.md
@@ -83,6 +83,31 @@ Language SDKs (Go, Node, Java, .NET) are NOT baked into the sysroot image. They
- run: echo "RUNNER_TOOL_CACHE=/tmp/gh-aw/tool-cache" >> "$GITHUB_ENV"
```
+## Writable home under sysroot staging
+
+Sysroot staging drops agent bind mounts whose sources the DinD daemon cannot
+resolve, including AWF's own `${workDir}-chroot-home` volume for `/host$HOME`.
+An explicitly supplied mount is exempt from that filter: if the caller passes
+`--mount :$HOME:rw` (the gh-aw compiler does this for
+`${RUNNER_TEMP}/gh-aw/home`), the resulting `/host$HOME` mount is kept, because
+the caller vouches for the source being visible to the daemon. The exemption
+matches on both source and target, so AWF's own mounts to the same target stay
+subject to the filter.
+
+A writable `/host$HOME` matters for two reasons:
+
+- the `/dev/null` credential-hiding overlays are mounted under `/host$HOME`, and
+ runc cannot create those mountpoints under a read-only parent;
+- `entrypoint.sh` pre-seeds JVM build tool proxy config (`~/.m2`, `~/.gradle`)
+ under the chroot home.
+
+If no writable `/host$HOME` survives the filter, AWF logs a warning and skips
+the `/host$HOME` credential overlays instead of failing container creation — the
+overlays at the un-prefixed `$HOME` path (on the agent's own rootfs) are still
+applied, but credential files under the chroot home are not masked for that run.
+The entrypoint likewise warns and skips JVM proxy pre-seeding rather than
+aborting.
+
## What AWF handles automatically
- Split-filesystem probing for `--docker-host-path-prefix`
diff --git a/src/compose-generator.test.ts b/src/compose-generator.test.ts
index 1c4ad642d..4fc05e5f9 100644
--- a/src/compose-generator.test.ts
+++ b/src/compose-generator.test.ts
@@ -610,7 +610,7 @@ describe('generateDockerCompose', () => {
generateDockerCompose(config, mockNetworkConfig);
- expect(warnSpy).not.toHaveBeenCalled();
+ expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining('under /opt'));
warnSpy.mockRestore();
});
@@ -650,7 +650,9 @@ describe('generateDockerCompose', () => {
expect(homeTargets).toContain(`/host${workspaceDir}`);
expect(homeTargets.some(target => target.startsWith(`/host${effectiveHomeForFilter}/.`))).toBe(false);
- // Home root mounts (including trailing slash source) should be dropped.
+ // An explicitly supplied home-root mount (including trailing slash source)
+ // survives the filter: the caller vouches for its daemon visibility, and a
+ // writable /host$HOME is required by the credential overlays and entrypoint.
const effectiveHome = getRealUserHome();
const configWithHomeRootMount = {
...config,
@@ -661,7 +663,28 @@ describe('generateDockerCompose', () => {
const target = v.split(':')[1];
return target === `/host${effectiveHome}` || target === `/host${effectiveHome}/`;
});
- expect(homeRootMounts).toHaveLength(0);
+ expect(homeRootMounts).toEqual([`${effectiveHome}/:/host${effectiveHome}:rw`]);
+
+ // The chroot-home volume sourced from workDir is still dropped.
+ expect(
+ (resultWithHomeRootMount.services.agent.volumes as string[]).some(v =>
+ v.startsWith('/tmp/awf-12345-chroot-home'),
+ ),
+ ).toBe(false);
+
+ // Credential overlays under /host$HOME are kept when a writable home survives.
+ expect(
+ (resultWithHomeRootMount.services.agent.volumes as string[]).some(
+ v => v.startsWith('/dev/null:') && v.split(':')[1].startsWith(`/host${effectiveHome}/`),
+ ),
+ ).toBe(true);
+
+ // Without such a mount, those overlays are skipped (no writable parent exists).
+ expect(
+ volumes.some(
+ v => v.startsWith('/dev/null:') && v.split(':')[1].startsWith(`/host${effectiveHome}/`),
+ ),
+ ).toBe(false);
// Should still have /tmp:/tmp, /sys, /dev, sysroot volume
expect(volumes).toContain('/tmp:/tmp:rw');
diff --git a/src/services/agent-volumes/workspace-mounts.test.ts b/src/services/agent-volumes/workspace-mounts.test.ts
index 70eb01624..e8a260f9e 100644
--- a/src/services/agent-volumes/workspace-mounts.test.ts
+++ b/src/services/agent-volumes/workspace-mounts.test.ts
@@ -189,4 +189,9 @@ describe('buildCustomVolumeMounts', () => {
const result = buildCustomVolumeMounts(['/a:/b', '/c:/d:ro', 'named']);
expect(result).toEqual(['/a:/host/b', '/c:/host/d:ro', 'named']);
});
+
+ it('does not double-prefix targets that already start with /host', () => {
+ const result = buildCustomVolumeMounts(['/data:/host/data:ro', '/root:/host']);
+ expect(result).toEqual(['/data:/host/data:ro', '/root:/host']);
+ });
});
diff --git a/src/services/agent-volumes/workspace-mounts.ts b/src/services/agent-volumes/workspace-mounts.ts
index 2abd9de72..4254fd21b 100644
--- a/src/services/agent-volumes/workspace-mounts.ts
+++ b/src/services/agent-volumes/workspace-mounts.ts
@@ -92,12 +92,19 @@ function isExecutableFile(candidate: string): boolean {
export function buildCustomVolumeMounts(
volumeMounts?: string[],
dockerHostPathPrefix?: string,
+ options: { quiet?: boolean } = {},
): string[] {
if (!volumeMounts || volumeMounts.length === 0) {
return [];
}
- logger.debug(`Adding ${volumeMounts.length} custom volume mount(s)`);
+ // `quiet` is used by callers that only re-derive the transformed specs for
+ // comparison (e.g. the sysroot volume filter) and must not log them twice.
+ const debug = (message: string) => {
+ if (!options.quiet) logger.debug(message);
+ };
+
+ debug(`Adding ${volumeMounts.length} custom volume mount(s)`);
// Custom mount sources always use the runner's filesystem view. Translate
// them even when a source already starts with the daemon-side prefix; this
@@ -114,11 +121,17 @@ export function buildCustomVolumeMounts(
const hostPath = parts[0];
const containerPath = parts[1];
const mode = parts[2] || '';
- const chrootContainerPath = `/host${containerPath}`;
+ // Targets that already carry the chroot prefix (some callers emit both an
+ // un-prefixed and a `/host`-prefixed mount) must not be prefixed again,
+ // otherwise they land at `/host/host/…` and mount nothing meaningful.
+ const chrootContainerPath =
+ containerPath === '/host' || containerPath.startsWith('/host/')
+ ? containerPath
+ : `/host${containerPath}`;
const transformedMount = mode
? `${hostPath}:${chrootContainerPath}:${mode}`
: `${hostPath}:${chrootContainerPath}`;
- logger.debug(`Adding custom volume mount: ${volumeMounts[index]} -> ${transformedMount} (chroot-adjusted)`);
+ debug(`Adding custom volume mount: ${volumeMounts[index]} -> ${transformedMount} (chroot-adjusted)`);
return transformedMount;
}
diff --git a/src/services/optional-services.test.ts b/src/services/optional-services.test.ts
index 3c55179ea..41281c640 100644
--- a/src/services/optional-services.test.ts
+++ b/src/services/optional-services.test.ts
@@ -63,6 +63,7 @@ describe('optional-services helpers', () => {
const config: WrapperConfig = {
...baseConfig,
workDir: '/tmp/awf-work',
+ volumeMounts: ['/home/runner:/home/runner:rw'],
};
const filtered = testHelpers.filterAgentVolumesForSysroot(
@@ -81,11 +82,90 @@ describe('optional-services helpers', () => {
);
expect(filtered).toEqual([
+ '/home/runner:/host/home/runner:rw',
'/home/runner/_work/_temp/gh-aw:/host/home/runner/_work/_temp/gh-aw:rw',
'/tmp:/tmp:rw',
'/dev/null:/host/home/runner/.npmrc:ro',
'bad-volume-entry',
]);
});
+
+ it('drops the chroot-home volume but keeps an explicitly mounted writable home', () => {
+ const config: WrapperConfig = {
+ ...baseConfig,
+ workDir: '/tmp/awf-work',
+ volumeMounts: ['/home/runner/_work/_temp/gh-aw/home:/home/runner/_work/_temp/gh-aw/home:rw'],
+ };
+ const home = '/home/runner/_work/_temp/gh-aw/home';
+
+ const filtered = testHelpers.filterAgentVolumesForSysroot(
+ [
+ `/tmp/awf-work-chroot-home:/host${home}:rw`,
+ `${home}:/host${home}:rw`,
+ `/dev/null:/host${home}/.npmrc:ro`,
+ `/dev/null:${home}/.npmrc:ro`,
+ ],
+ config,
+ home,
+ );
+
+ expect(filtered).toEqual([
+ `${home}:/host${home}:rw`,
+ `/dev/null:/host${home}/.npmrc:ro`,
+ `/dev/null:${home}/.npmrc:ro`,
+ ]);
+ });
+
+ it('does not exempt AWF home mounts that merely share a target with an explicit mount', () => {
+ const config: WrapperConfig = {
+ ...baseConfig,
+ workDir: '/tmp/awf-work',
+ volumeMounts: [
+ '/daemon/cache:/home/runner/.cache:rw',
+ '/home/runner/_work/_temp/gh-aw/home:/home/runner:rw',
+ ],
+ };
+
+ const filtered = testHelpers.filterAgentVolumesForSysroot(
+ [
+ '/home/runner/.cache:/host/home/runner/.cache:rw',
+ '/daemon/cache:/host/home/runner/.cache:rw',
+ '/home/runner/_work/_temp/gh-aw/home:/host/home/runner:rw',
+ ],
+ config,
+ '/home/runner',
+ );
+
+ // AWF's own $HOME/.cache bind (runner-side source) is still dropped even
+ // though an explicit mount targets the same path.
+ expect(filtered).toEqual([
+ '/daemon/cache:/host/home/runner/.cache:rw',
+ '/home/runner/_work/_temp/gh-aw/home:/host/home/runner:rw',
+ ]);
+ });
+
+ it('skips /host$HOME credential overlays when no writable /host$HOME survives', () => {
+ const config: WrapperConfig = {
+ ...baseConfig,
+ workDir: '/tmp/awf-work',
+ };
+ const home = '/home/runner/_work/_temp/gh-aw/home';
+
+ const filtered = testHelpers.filterAgentVolumesForSysroot(
+ [
+ `/tmp/awf-work-chroot-home:/host${home}:rw`,
+ `/dev/null:/host${home}/.npmrc:ro`,
+ `/dev/null:${home}/.npmrc:ro`,
+ '/tmp:/tmp:rw',
+ ],
+ config,
+ home,
+ );
+
+ expect(filtered).toEqual([
+ `/dev/null:${home}/.npmrc:ro`,
+ '/tmp:/tmp:rw',
+ ]);
+ });
});
});
diff --git a/src/services/optional-services.ts b/src/services/optional-services.ts
index 8efeded2c..ae3f7eb6f 100644
--- a/src/services/optional-services.ts
+++ b/src/services/optional-services.ts
@@ -9,6 +9,8 @@ import { buildEnclaveMcpService } from './enclave-mcp-service';
import { buildSysrootStageService, isSysrootEnabled } from './sysroot-service';
import { resolveDockerHostGateway } from './host-gateway';
import { runtimeUsesIptables } from '../container-runtime';
+import { applyHostPathPrefixToVolumes } from './host-path-prefix';
+import { buildCustomVolumeMounts } from './agent-volumes/workspace-mounts';
import { NetworkConfig, ImageBuildConfig } from './squid-service';
interface AssembleOptionalServicesParams {
@@ -70,8 +72,14 @@ function filterAgentVolumesForSysroot(
]);
const normalizedWorkDirPrefix = config.workDir.replace(/\/+$/, '');
const hostHomeMountPrefix = `/host${effectiveHome}`;
-
- return agentVolumes.filter(volume => {
+ // Source:target pairs of explicitly supplied `--mount` specs. Their sources
+ // are chosen by the caller (the gh-aw compiler or the user), who asserts the
+ // Docker daemon can resolve them, so they must survive the sysroot filter even
+ // when they target the chroot home root. Matching on both source and target
+ // keeps AWF's own mounts to the same target subject to the filter.
+ const explicitMountSpecs = collectCustomMountSpecs(config);
+
+ const filtered = agentVolumes.filter(volume => {
const parts = volume.split(':');
if (parts.length < 2) return true; // Keep malformed entries unchanged
const source = parts[0];
@@ -95,7 +103,14 @@ function filterAgentVolumesForSysroot(
// Drop home dot-directory mounts (e.g. .cache, .config) — sysroot provides them.
// Keep workspace/work paths (e.g. _work/_temp/gh-aw) since those are user-supplied
// custom mounts or tool-cache mounts that the sysroot doesn't provide.
- if (source.startsWith(effectiveHome) && target.startsWith(hostHomeMountPrefix)) {
+ // Keep explicitly supplied `--mount` specs: the caller vouches for their
+ // daemon visibility, and a writable `/host$HOME` is required for the
+ // credential-hiding overlays and the agent entrypoint to work.
+ if (
+ source.startsWith(effectiveHome) &&
+ target.startsWith(hostHomeMountPrefix) &&
+ !explicitMountSpecs.has(mountSpecKey(source, target))
+ ) {
const normalizedSource = source.replace(/\/+$/, '') || '/';
const relPath = normalizedSource.slice(effectiveHome.length);
if (relPath.startsWith('/.') || relPath === '') return false;
@@ -103,6 +118,74 @@ function filterAgentVolumesForSysroot(
return true;
});
+
+ return dropUnbackedHostHomeOverlays(filtered, hostHomeMountPrefix);
+}
+
+/**
+ * Collects `source:target` keys for the bind mounts produced from explicitly
+ * supplied `--mount` specs, transformed exactly as `buildAgentVolumes` does
+ * (`buildCustomVolumeMounts` prefixes targets with `/host`, then the host path
+ * prefix is applied). Keying on both ends means AWF's own mounts to the same
+ * target are still subject to the sysroot filter.
+ */
+function collectCustomMountSpecs(config: WrapperConfig): Set {
+ const specs = new Set();
+ const transformed = applyHostPathPrefixToVolumes(
+ buildCustomVolumeMounts(config.volumeMounts, config.dockerHostPathPrefix, { quiet: true }),
+ config.dockerHostPathPrefix,
+ );
+
+ for (const mount of transformed) {
+ const parts = mount.split(':');
+ if (parts.length < 2) continue;
+ if (!parts[0] || !parts[1]) continue;
+ specs.add(mountSpecKey(parts[0], parts[1]));
+ }
+ return specs;
+}
+
+function mountSpecKey(source: string, target: string): string {
+ const normalize = (value: string) => value.replace(/\/+$/, '') || '/';
+ return `${normalize(source)}:${normalize(target)}`;
+}
+
+/**
+ * Removes `/dev/null` credential overlays under `/host$HOME` when no writable
+ * mount backs that path. Without a writable parent, runc cannot create the
+ * mountpoint and the agent container fails to start. The equivalent overlays
+ * at the un-prefixed `$HOME` path (on the container's own rootfs) are kept.
+ */
+function dropUnbackedHostHomeOverlays(volumes: string[], hostHomeMountPrefix: string): string[] {
+ const hasWritableHostHome = volumes.some(volume => {
+ const parts = volume.split(':');
+ if (parts.length < 2) return false;
+ if (parts[0] === '/dev/null') return false;
+ const target = (parts[1] || '').replace(/\/+$/, '');
+ const mode = parts[2] || 'rw';
+ return target === hostHomeMountPrefix && mode !== 'ro';
+ });
+
+ if (hasWritableHostHome) return volumes;
+
+ const overlayPrefix = `${hostHomeMountPrefix}/`;
+ const kept = volumes.filter(volume => {
+ const parts = volume.split(':');
+ if (parts.length < 2) return true;
+ return !(parts[0] === '/dev/null' && (parts[1] || '').startsWith(overlayPrefix));
+ });
+
+ const dropped = volumes.length - kept.length;
+ if (dropped > 0) {
+ logger.warn(
+ `No writable ${hostHomeMountPrefix} mount survived the sysroot filter; skipping ${dropped} ` +
+ 'credential-hiding overlay(s) under that path (the container could not start otherwise). ' +
+ 'Credential files under the chroot home are NOT masked for this run — pass a writable ' +
+ `--mount :${hostHomeMountPrefix.replace(/^\/host/, '')}:rw to restore masking.`,
+ );
+ }
+
+ return kept;
}
function assembleSysrootService(
diff --git a/tests/entrypoint-phase-functions.test.sh b/tests/entrypoint-phase-functions.test.sh
index 0ec91d0d6..b40439d99 100755
--- a/tests/entrypoint-phase-functions.test.sh
+++ b/tests/entrypoint-phase-functions.test.sh
@@ -214,6 +214,49 @@ else
fail "run_chroot_command() does not clean up copied system CA bundles"
fi
+# configure_jvm_proxy must not abort the entrypoint (set -e) when $HOME is
+# read-only, including when .m2/.gradle already exist but cannot be written.
+run_configure_jvm_proxy_readonly_home_fixture() {
+ local tmp_dir
+ tmp_dir="$(mktemp -d)"
+ local fake_home="${tmp_dir}/home"
+ mkdir -p "${fake_home}/.m2" "${fake_home}/.gradle"
+ chmod 555 "${fake_home}/.m2" "${fake_home}/.gradle" "${fake_home}"
+
+ # Run in a separate bash process: `set -e` is ignored inside a subshell that
+ # is part of an `if` condition, which would mask the abort this test guards.
+ env -u JAVA_TOOL_OPTIONS \
+ HOME="${fake_home}" \
+ AWF_CHROOT_ENABLED="false" \
+ HTTP_PROXY="http://172.30.0.10:3128" \
+ SQUID_PROXY_HOST="172.30.0.10" \
+ SQUID_PROXY_PORT="3128" \
+ bash -c '
+ set -e
+ # The BASH_SOURCE guard keeps main() from running when sourced.
+ . "$1"
+ configure_jvm_proxy > /dev/null
+ [ ! -f "${HOME}/.m2/settings.xml" ]
+ [ ! -f "${HOME}/.gradle/gradle.properties" ]
+ case "${JAVA_TOOL_OPTIONS}" in
+ *-Dhttps.proxyHost=172.30.0.10*) ;;
+ *) exit 1 ;;
+ esac
+ ' _ "${ENTRYPOINT}" 2>/dev/null
+ local result=$?
+ chmod -R u+w "${fake_home}" 2>/dev/null || true
+ rm -rf "${tmp_dir}"
+ return "${result}"
+}
+
+if [ "$(id -u)" -eq 0 ]; then
+ pass "configure_jvm_proxy() read-only home check skipped (running as root)"
+elif run_configure_jvm_proxy_readonly_home_fixture; then
+ pass "configure_jvm_proxy() survives an existing but read-only .m2/.gradle"
+else
+ fail "configure_jvm_proxy() aborts when .m2/.gradle exist on a read-only home"
+fi
+
echo ""
echo "Results: ${PASS} passed, ${FAIL} failed"