diff --git a/containers/agent/entrypoint.sh b/containers/agent/entrypoint.sh index 1690ec524..7213ebce0 100644 --- a/containers/agent/entrypoint.sh +++ b/containers/agent/entrypoint.sh @@ -707,12 +707,78 @@ if [ "${AWF_CHROOT_ENABLED}" = "true" ]; then # Find the user name on the host system by UID # This allows us to run as the same user inside the chroot HOST_USER_UID="${AWF_USER_UID:-1000}" + HOST_USER_GID="${AWF_USER_GID:-${HOST_USER_UID}}" HOST_USER=$(chroot /host getent passwd "${HOST_USER_UID}" 2>/dev/null | cut -d: -f1 || echo "") + CAPSH_IDENTITY_ARGS="" + CHROOT_HOME_OVERRIDE="" if [ -z "${HOST_USER}" ]; then - # Fall back to 'nobody' if user not found by UID - HOST_USER="nobody" - echo "[entrypoint][WARN] Could not find user with UID ${HOST_USER_UID} on host, using ${HOST_USER}" + # User not found in chroot's /etc/passwd (common on ARC-DinD Alpine daemons). + # Synthesize minimal identity files so the agent can resolve its own UID/GID. + HOST_USER="runner" + echo "[entrypoint] User with UID ${HOST_USER_UID} not found in chroot — synthesizing identity files" + + # Determine the user's home directory (default to /home/runner) + SYNTH_HOME="${AWF_HOST_HOME:-/home/${HOST_USER}}" + + # Synthesize /etc/passwd entry if missing + if ! grep -q "^[^:]*:[^:]*:${HOST_USER_UID}:" /host/etc/passwd 2>/dev/null; then + PASSWD_ENTRY="${HOST_USER}:x:${HOST_USER_UID}:${HOST_USER_GID}:GitHub Actions Runner:${SYNTH_HOME}:/bin/bash" + # Append to existing file or create new one + if [ -f /host/etc/passwd ]; then + if echo "${PASSWD_ENTRY}" >> /host/etc/passwd 2>/dev/null; then + echo "[entrypoint] Appended ${HOST_USER} (UID ${HOST_USER_UID}) to /host/etc/passwd" + else + echo "[entrypoint][WARN] Could not write to /host/etc/passwd — identity resolution may fail" + fi + else + # Create minimal passwd with root and the runner user + if printf '%s\n' "root:x:0:0:root:/root:/bin/bash" "nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin" "${PASSWD_ENTRY}" > /host/etc/passwd 2>/dev/null; then + chmod 644 /host/etc/passwd 2>/dev/null + echo "[entrypoint] Created /host/etc/passwd with ${HOST_USER} (UID ${HOST_USER_UID})" + else + echo "[entrypoint][WARN] Could not create /host/etc/passwd — identity resolution may fail" + fi + fi + fi + + # Synthesize /etc/group entry if missing + if ! grep -q "^[^:]*:[^:]*:${HOST_USER_GID}:" /host/etc/group 2>/dev/null; then + GROUP_ENTRY="${HOST_USER}:x:${HOST_USER_GID}:" + if [ -f /host/etc/group ]; then + if echo "${GROUP_ENTRY}" >> /host/etc/group 2>/dev/null; then + echo "[entrypoint] Appended group ${HOST_USER} (GID ${HOST_USER_GID}) to /host/etc/group" + else + echo "[entrypoint][WARN] Could not write to /host/etc/group" + fi + else + if printf '%s\n' "root:x:0:" "nobody:x:65534:" "${GROUP_ENTRY}" > /host/etc/group 2>/dev/null; then + chmod 644 /host/etc/group 2>/dev/null + echo "[entrypoint] Created /host/etc/group with group ${HOST_USER} (GID ${HOST_USER_GID})" + else + echo "[entrypoint][WARN] Could not create /host/etc/group" + fi + fi + fi + + # Synthesize /etc/hosts if it doesn't exist (DinD Alpine daemon may not have one) + if [ ! -f /host/etc/hosts ]; then + if printf '%s\n' "127.0.0.1 localhost" "::1 localhost ip6-localhost ip6-loopback" > /host/etc/hosts 2>/dev/null; then + chmod 644 /host/etc/hosts 2>/dev/null + echo "[entrypoint] Created minimal /host/etc/hosts" + fi + fi + + HOST_USER=$(chroot /host getent passwd "${HOST_USER_UID}" 2>/dev/null | cut -d: -f1 || echo "") + if [ -n "${HOST_USER}" ]; then + CAPSH_IDENTITY_ARGS="--user=${HOST_USER}" + echo "[entrypoint] Running as synthesized host user: ${HOST_USER} (UID: ${HOST_USER_UID})" + else + CAPSH_IDENTITY_ARGS="--gid=${HOST_USER_GID} --uid=${HOST_USER_UID} --groups=${HOST_USER_GID}" + CHROOT_HOME_OVERRIDE="${SYNTH_HOME}" + echo "[entrypoint][WARN] Proceeding with numeric UID/GID fallback (${HOST_USER_UID}:${HOST_USER_GID})" + fi else + CAPSH_IDENTITY_ARGS="--user=${HOST_USER}" echo "[entrypoint] Running as host user: ${HOST_USER} (UID: ${HOST_USER_UID})" fi @@ -974,7 +1040,8 @@ AWFEOF cd '${CHROOT_WORKDIR}' 2>/dev/null || cd / trap '${CLEANUP_CMD}' EXIT ${LD_PRELOAD_CMD} - exec capsh --drop=${CAPS_TO_DROP} --user=${HOST_USER} -- -c 'exec ${SCRIPT_FILE}' + if [ -n '${CHROOT_HOME_OVERRIDE}' ]; then export HOME='${CHROOT_HOME_OVERRIDE}'; fi + exec capsh --drop=${CAPS_TO_DROP} ${CAPSH_IDENTITY_ARGS} -- -c 'exec ${SCRIPT_FILE}' " else # Original behavior - run in container filesystem diff --git a/docs/chroot-mode.md b/docs/chroot-mode.md index 9e351081e..1b489d001 100644 --- a/docs/chroot-mode.md +++ b/docs/chroot-mode.md @@ -302,7 +302,7 @@ sudo mv /etc/resolv.conf.awf-backup-* /etc/resolv.conf | glibc-based host userspace | Required for chroot execution chain (`capsh` + `bash`) | | `capsh` | Must be installed on host (usually in `libcap2-bin` package) | | `/bin/bash` | Must exist and be executable on host | -| User by UID | Host user must exist in `/etc/passwd` | +| User by UID | Host user should exist in `/etc/passwd` (auto-synthesized in DinD mode if missing) | | Docker | Standard Docker requirement | | sudo | Required for iptables manipulation | @@ -339,6 +339,18 @@ sudo dnf install libcap **Fix**: Run AWF on a glibc-based daemon host (for example Ubuntu/Debian/RHEL-family). +### DinD Identity Synthesis + +In ARC (Actions Runner Controller) environments using the DinD (Docker-in-Docker) sidecar pattern, the Docker daemon's filesystem is separate from the runner's. This means `/etc/passwd` and `/etc/group` may not exist or may not contain the runner's UID/GID. + +AWF handles this automatically at two layers: + +1. **Mount staging** (`etc-mounts.ts`): When `--docker-host-path-prefix` uses a `/tmp/...` prefix (the DinD staging path) and `/etc/passwd` or `/etc/group` cannot be staged from the runner, AWF synthesizes minimal identity files containing `root` and a `runner` entry matching the host UID/GID. If staging succeeds but the staged files are missing the runner UID/GID, AWF supplements them before mounting. + +2. **Runtime fallback** (`entrypoint.sh`): If `getent passwd $UID` fails inside the chroot (user not found), the entrypoint attempts to synthesize `/etc/passwd` and `/etc/group` entries. If those mounts are read-only, it falls back to running with numeric `UID:GID` directly. + +No configuration is required — synthesis is triggered automatically when user lookup fails. + ### Error: Working directory does not exist ``` diff --git a/src/services/agent-volumes/etc-mounts.test.ts b/src/services/agent-volumes/etc-mounts.test.ts new file mode 100644 index 000000000..6b5c69e01 --- /dev/null +++ b/src/services/agent-volumes/etc-mounts.test.ts @@ -0,0 +1,102 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { buildEtcMounts } from './etc-mounts'; +import { WrapperConfig } from '../../types'; +import * as hostIdentity from '../../host-identity'; + +function createMinimalConfig(overrides: Partial = {}): WrapperConfig { + return { + allowDomains: 'example.com', + agentCommand: 'echo test', + workDir: '/tmp/awf-test', + ...overrides, + } as WrapperConfig; +} + +describe('buildEtcMounts', () => { + describe('non-DinD mode', () => { + it('mounts /etc/passwd and /etc/group directly', () => { + const config = createMinimalConfig({ dockerHostPathPrefix: undefined }); + const mounts = buildEtcMounts(config); + expect(mounts).toContain('/etc/passwd:/host/etc/passwd:ro'); + expect(mounts).toContain('/etc/group:/host/etc/group:ro'); + }); + + it('includes standard /etc mounts', () => { + const config = createMinimalConfig({ dockerHostPathPrefix: undefined }); + const mounts = buildEtcMounts(config); + expect(mounts).toContain('/etc/ssl:/host/etc/ssl:ro'); + expect(mounts).toContain('/etc/ca-certificates:/host/etc/ca-certificates:ro'); + expect(mounts).toContain('/etc/nsswitch.conf:/host/etc/nsswitch.conf:ro'); + }); + }); + + describe('DinD mode with dockerHostPathPrefix', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-etc-mounts-')); + }); + + afterEach(() => { + jest.restoreAllMocks(); + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('stages /etc/passwd when it exists on the runner', () => { + const config = createMinimalConfig({ + dockerHostPathPrefix: '/tmp/awf-dind-prefix', + workDir: tmpDir, + }); + const mounts = buildEtcMounts(config); + // Should have passwd and group mounts (either staged or synthesized) + const passwdMount = mounts.find(m => m.includes('/host/etc/passwd')); + expect(passwdMount).toBeDefined(); + expect(passwdMount!.startsWith('/etc/passwd:')).toBe(false); + expect(passwdMount).toContain(':ro'); + }); + + it('produces passwd and group mounts in DinD mode', () => { + const workDir = path.join(tmpDir, 'work'); + fs.mkdirSync(workDir, { recursive: true }); + const config = createMinimalConfig({ + dockerHostPathPrefix: '/tmp/awf-dind-prefix', + workDir, + }); + + const mounts = buildEtcMounts(config); + + const passwdMount = mounts.find(m => m.includes('/host/etc/passwd')); + const groupMount = mounts.find(m => m.includes('/host/etc/group')); + expect(passwdMount).toBeDefined(); + expect(groupMount).toBeDefined(); + + // In DinD mode, the mount source is a staged file path (not bare /etc/passwd) + const passwdPath = passwdMount!.split(':')[0]; + expect(fs.existsSync(passwdPath)).toBe(true); + + const groupPath = groupMount!.split(':')[0]; + expect(fs.existsSync(groupPath)).toBe(true); + }); + + it('supplements staged passwd/group files when UID/GID are missing', () => { + const uid = '424242'; + const gid = '434343'; + jest.spyOn(hostIdentity, 'getSafeHostUid').mockReturnValue(uid); + jest.spyOn(hostIdentity, 'getSafeHostGid').mockReturnValue(gid); + + const config = createMinimalConfig({ + dockerHostPathPrefix: '/tmp/awf-dind-prefix', + workDir: tmpDir, + }); + + const mounts = buildEtcMounts(config); + const passwdPath = mounts.find(m => m.includes('/host/etc/passwd'))!.split(':')[0]; + const groupPath = mounts.find(m => m.includes('/host/etc/group'))!.split(':')[0]; + + expect(fs.readFileSync(passwdPath, 'utf8')).toContain(`runner:x:${uid}:${gid}:`); + expect(fs.readFileSync(groupPath, 'utf8')).toContain(`runner:x:${gid}:`); + }); + }); +}); diff --git a/src/services/agent-volumes/etc-mounts.ts b/src/services/agent-volumes/etc-mounts.ts index fcf32ee16..7911b8fbe 100644 --- a/src/services/agent-volumes/etc-mounts.ts +++ b/src/services/agent-volumes/etc-mounts.ts @@ -1,5 +1,44 @@ +import * as fs from 'fs'; +import * as path from 'path'; import { WrapperConfig } from '../../types'; -import { shouldUseDockerHostStaging, stageHostFile } from './docker-host-staging'; +import { shouldUseDockerHostStaging, stageHostFile, getDockerHostStageRoot } from './docker-host-staging'; +import { getSafeHostUid, getSafeHostGid } from '../../host-identity'; + +/** + * Synthesize a minimal /etc/passwd or /etc/group file in the staging directory. + * Used when the runner doesn't have these files (e.g., minimal ARC-DinD containers). + */ +function synthesizeIdentityFile(config: WrapperConfig, relPath: string, content: string): string | undefined { + try { + const stageRoot = getDockerHostStageRoot(config); + const tempDir = fs.mkdtempSync(path.join(stageRoot, 'identity-')); + const targetPath = path.join(tempDir, path.basename(relPath)); + fs.writeFileSync(targetPath, content, { mode: 0o644, flag: 'wx' }); + return targetPath; + } catch { + return undefined; + } +} + +function readFileContent(filePath: string): string | undefined { + try { + return fs.readFileSync(filePath, 'utf8'); + } catch { + return undefined; + } +} + +function fileHasPasswdUid(content: string, uid: string): boolean { + return new RegExp(`^[^:]*:[^:]*:${uid}:`, 'm').test(content); +} + +function fileHasGroupGid(content: string, gid: string): boolean { + return new RegExp(`^[^:]*:[^:]*:${gid}:`, 'm').test(content); +} + +function withTrailingNewline(content: string): string { + return content.endsWith('\n') ? content : `${content}\n`; +} export function buildEtcMounts(config: WrapperConfig): string[] { const mounts: string[] = [ @@ -16,10 +55,45 @@ export function buildEtcMounts(config: WrapperConfig): string[] { return mounts; } - const stagedPasswdPath = stageHostFile(config, '/etc/passwd', 'etc/passwd'); - const stagedGroupPath = stageHostFile(config, '/etc/group', 'etc/group'); - mounts.push(`${stagedPasswdPath || '/etc/passwd'}:/host/etc/passwd:ro`); - mounts.push(`${stagedGroupPath || '/etc/group'}:/host/etc/group:ro`); + // In DinD mode, stage /etc/passwd and /etc/group from the runner. + // If the runner doesn't have these files (minimal ARC containers), synthesize minimal ones. + const uid = getSafeHostUid(); + const gid = getSafeHostGid(); + + let passwdPath = stageHostFile(config, '/etc/passwd', 'etc/passwd'); + const passwdEntry = `runner:x:${uid}:${gid}:GitHub Actions Runner:/home/runner:/bin/bash`; + if (!passwdPath) { + const minimalPasswd = [ + 'root:x:0:0:root:/root:/bin/bash', + 'nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin', + passwdEntry, + ].join('\n') + '\n'; + passwdPath = synthesizeIdentityFile(config, 'etc/passwd', minimalPasswd); + } else { + const stagedPasswdContent = readFileContent(passwdPath); + if (stagedPasswdContent && !fileHasPasswdUid(stagedPasswdContent, uid)) { + passwdPath = synthesizeIdentityFile(config, 'etc/passwd', `${withTrailingNewline(stagedPasswdContent)}${passwdEntry}\n`) || passwdPath; + } + } + + let groupPath = stageHostFile(config, '/etc/group', 'etc/group'); + const groupEntry = `runner:x:${gid}:`; + if (!groupPath) { + const minimalGroup = [ + 'root:x:0:', + 'nobody:x:65534:', + groupEntry, + ].join('\n') + '\n'; + groupPath = synthesizeIdentityFile(config, 'etc/group', minimalGroup); + } else { + const stagedGroupContent = readFileContent(groupPath); + if (stagedGroupContent && !fileHasGroupGid(stagedGroupContent, gid)) { + groupPath = synthesizeIdentityFile(config, 'etc/group', `${withTrailingNewline(stagedGroupContent)}${groupEntry}\n`) || groupPath; + } + } + + mounts.push(`${passwdPath || '/etc/passwd'}:/host/etc/passwd:ro`); + mounts.push(`${groupPath || '/etc/group'}:/host/etc/group:ro`); return mounts; }