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
16 changes: 15 additions & 1 deletion containers/agent/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -637,6 +637,20 @@ run_chroot_command() {
fi
fi

# When chroot.binariesSourcePath is configured, the firewall mounts the runner-bin
# overlay at /host/tmp/awf-runner-bin (inside chroot: /tmp/awf-runner-bin).
# Prepend it to AWF_HOST_PATH so the chrooted PATH resolves runner-installed binaries
# (e.g. copilot, claude) even when they are absent from /usr/local/bin in the staged
# host filesystem. This is the primary mechanism for ARC/DinD runners where the
# runner-installed binaries live on the runner's /tmp emptyDir, not in /usr.
if [ -d /host/tmp/awf-runner-bin ]; then
case ":${AWF_HOST_PATH:-$PATH}:" in
*":/tmp/awf-runner-bin:"*) ;;
*) export AWF_HOST_PATH="/tmp/awf-runner-bin:${AWF_HOST_PATH:-$PATH}" ;;
esac
echo "[entrypoint] Runner binaries overlay detected at /tmp/awf-runner-bin; prepended to PATH"
fi

# Copy AWF CA certificate to chroot-accessible path for ssl-bump TLS trust.
# NODE_EXTRA_CA_CERTS points to /usr/local/share/ca-certificates/awf-ca.crt which
# is a Docker volume mount on the container's overlay filesystem. After chroot /host,
Expand Down Expand Up @@ -1031,7 +1045,7 @@ AWFEOF
printf 'if ! command -v -- %s >/dev/null 2>&1; then\n' "${AWF_PREFLIGHT_BINARY}" >> "/host${SCRIPT_FILE}"
printf ' echo "[entrypoint][ERROR] Required binary '"'"'%s'"'"' is not available inside AWF chroot." >&2\n' "${AWF_PREFLIGHT_BINARY}" >> "/host${SCRIPT_FILE}"
printf ' echo "[entrypoint][ERROR] Ensure '"'"'%s'"'"' is installed on the runner and present in a PATH directory bind-mounted into /host." >&2\n' "${AWF_PREFLIGHT_BINARY}" >> "/host${SCRIPT_FILE}"
printf ' echo "[entrypoint][ERROR] Standard bind-mounted PATH directories: /usr/local/bin, /usr/bin, /bin, /opt." >&2\n' >> "/host${SCRIPT_FILE}"
printf ' echo "[entrypoint][ERROR] Standard bind-mounted PATH directories: /tmp/awf-runner-bin, /usr/local/bin, /usr/bin, /bin, /opt." >&2\n' >> "/host${SCRIPT_FILE}"
printf ' exit 127\n' >> "/host${SCRIPT_FILE}"
printf 'fi\n' >> "/host${SCRIPT_FILE}"
else
Expand Down
36 changes: 33 additions & 3 deletions src/services/agent-volumes-mounts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ describe('agent service', () => {

expect(volumes).toContain('/daemon-root/tmp:/tmp:rw');
expect(volumes).toContain('/daemon-root/usr:/host/usr:ro');
expect(volumes).toContain('/daemon-root/tmp/gh-aw/runner-bin:/host/usr/local/bin:ro');
expect(volumes).toContain('/daemon-root/tmp/gh-aw/runner-bin:/host/tmp/awf-runner-bin: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');
Expand All @@ -103,7 +103,7 @@ describe('agent service', () => {
expect(volumes).not.toContain('/daemon-root/sys:/host/sys:ro');
});

it('should mount chroot binaries source path over /host/usr/local/bin', () => {
it('should mount chroot binaries source path at /host/tmp/awf-runner-bin', () => {
const configWithBinariesOverlay = {
...getConfig(),
chrootBinariesSourcePath: '/tmp/gh-aw/runner-bin',
Expand All @@ -112,7 +112,10 @@ describe('agent service', () => {
const volumes = result.services.agent.volumes as string[];

expect(volumes).toContain('/usr:/host/usr:ro');
expect(volumes).toContain('/tmp/gh-aw/runner-bin:/host/usr/local/bin:ro');
// Binaries overlay uses /host/tmp/awf-runner-bin (not /host/usr/local/bin) so that
// Docker can always create the mount-point directory inside the writable /host/tmp.
expect(volumes).toContain('/tmp/gh-aw/runner-bin:/host/tmp/awf-runner-bin:ro');
expect(volumes).not.toContain('/tmp/gh-aw/runner-bin:/host/usr/local/bin:ro');
});

it('should normalize trailing slash in dockerHostPathPrefix', () => {
Expand All @@ -126,6 +129,33 @@ describe('agent service', () => {
expect(volumes).toContain('/daemon-root/tmp:/tmp:rw');
});

it('should mount binaries overlay at /host/tmp/awf-runner-bin when binariesSourcePath equals dockerHostPathPrefix', () => {
// Regression test for ARC/DinD collision: when binariesSourcePath equals
// dockerHostPathPrefix (both /tmp/gh-aw), the old target /host/usr/local/bin
// could not be created by Docker because /host/usr was already mounted read-only.
// The new target /host/tmp/awf-runner-bin sits under the writable /host/tmp mount
// so Docker can always create the subdirectory mount-point.
const sharedPrefix = '/tmp/gh-aw';
const configCollision = {
...getConfig(),
dockerHostPathPrefix: sharedPrefix,
chrootBinariesSourcePath: sharedPrefix, // same value as prefix — the problematic case
};
const result = generateDockerCompose(configCollision, mockNetworkConfig);
const volumes = result.services.agent.volumes as string[];

// /host/usr is mounted read-only from the staged prefix
expect(volumes).toContain(`${sharedPrefix}/usr:/host/usr:ro`);

// Binaries overlay is at /host/tmp/awf-runner-bin — NOT nested under /host/usr:ro
// The source equals the prefix so applyHostPathPrefixToVolumes leaves it un-prefixed
expect(volumes).toContain(`${sharedPrefix}:/host/tmp/awf-runner-bin:ro`);

// Must NOT produce the old colliding mount that Docker could not set up
expect(volumes).not.toContain(`${sharedPrefix}:/host/usr/local/bin:ro`);
expect(volumes.some((v: string) => v.includes('/host/usr/local/bin'))).toBe(false);
});

it('should auto-stage the ARC/DinD manual bootstrap files under a shared /tmp docker-host-path-prefix', () => {
const originalPath = process.env.PATH;
const sharedTmpPrefix = fs.mkdtempSync(path.join('/tmp', 'gh-aw-'));
Expand Down
10 changes: 9 additions & 1 deletion src/services/agent-volumes/system-mounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,15 @@ export function buildSystemMounts(workspaceDir: string, chrootBinariesSourcePath

const normalizedBinariesPath = normalizeChrootBinariesSourcePath(chrootBinariesSourcePath);
if (normalizedBinariesPath) {
mounts.push(`${normalizedBinariesPath}:/host/usr/local/bin:ro`);
// Mount under /host/tmp/awf-runner-bin (not /host/usr/local/bin) so Docker can
// always create the mount-point directory. /host/usr is mounted read-only, so
// Docker cannot mkdir /host/usr/local/bin after that parent mount is applied —
// which fails in DinD/ARC setups where the staged /usr tree lacks local/bin.
// /host/tmp is mounted read-write (/tmp:/host/tmp:rw), so subdirectory creation
// always succeeds regardless of the host's staged /tmp content.
// entrypoint.sh detects /host/tmp/awf-runner-bin and adds /tmp/awf-runner-bin
// to the chroot PATH automatically.
mounts.push(`${normalizedBinariesPath}:/host/tmp/awf-runner-bin:ro`);
}

return mounts;
Expand Down
Loading