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"