From bbe96715e14a41f84fbc6a6441918f80eb9a98c7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Jun 2026 23:21:58 +0000 Subject: [PATCH 1/3] Initial plan From d2f4c5534a928a3c0db65aed91f3302e08bb19ba Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Jun 2026 23:33:29 +0000 Subject: [PATCH 2/3] feat: add ARC-DinD sysroot topology support --- .github/workflows/release.yml | 64 ++++++++++++++++++- containers/build-tools/Dockerfile | 27 ++++++++ docs/arc-dind.md | 21 ++++++ docs/awf-config.schema.json | 18 ++++++ src/awf-config-schema.json | 18 ++++++ src/commands/build-config.ts | 2 + .../validators/network-options.test.ts | 26 ++++++++ src/commands/validators/network-options.ts | 12 ++++ src/compose-generator.test.ts | 26 ++++++++ src/compose-generator.ts | 28 ++++++++ src/config-file-mapping.test.ts | 11 ++++ src/config-file-validation.test.ts | 15 +++++ src/config-file.ts | 4 ++ src/config-mapper.ts | 2 + src/schema.test.ts | 7 ++ src/services/agent-volumes/system-mounts.ts | 26 +++++--- src/services/agent-volumes/volume-builder.ts | 3 +- src/services/sysroot-service.test.ts | 53 +++++++++++++++ src/services/sysroot-service.ts | 28 ++++++++ src/types/runner-options.ts | 18 ++++++ src/types/wrapper-config.ts | 4 +- 21 files changed, 401 insertions(+), 12 deletions(-) create mode 100644 containers/build-tools/Dockerfile create mode 100644 src/services/sysroot-service.test.ts create mode 100644 src/services/sysroot-service.ts create mode 100644 src/types/runner-options.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 93398f040..441c53fac 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -348,6 +348,68 @@ jobs: --type spdxjson \ ghcr.io/${{ github.repository }}/cli-proxy@${{ steps.build_cli_proxy.outputs.digest }} + build-build-tools: + name: Build Build-Tools Image + runs-on: ubuntu-latest + needs: bump-version + outputs: + digest: ${{ steps.build_build_tools.outputs.digest }} + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4 + with: + ref: ${{ needs.bump-version.outputs.version }} + + - name: Log in to GitHub Container Registry + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + + - name: Set up QEMU + uses: docker/setup-qemu-action@49b3bc8e6bdd4a60e6116a5414239cba5943d3cf # v3.2.0 + with: + platforms: arm64 + + - name: Install cosign + uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 # v3.5.0 + + - name: Build and push Build-Tools image + id: build_build_tools + uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 + with: + context: ./containers/build-tools + push: true + platforms: linux/amd64,linux/arm64 + tags: | + ghcr.io/${{ github.repository }}/build-tools:${{ needs.bump-version.outputs.version_number }} + ghcr.io/${{ github.repository }}/build-tools:latest + cache-from: type=gha,scope=build-tools + cache-to: type=gha,mode=max,scope=build-tools + + - name: Sign Build-Tools image with cosign + run: | + cosign sign --yes \ + ghcr.io/${{ github.repository }}/build-tools@${{ steps.build_build_tools.outputs.digest }} + + - name: Generate SBOM for Build-Tools image + uses: anchore/sbom-action@28d71544de8eaf1b958d335707167c5f783590ad # v0.22.2 + with: + image: ghcr.io/${{ github.repository }}/build-tools@${{ steps.build_build_tools.outputs.digest }} + format: spdx-json + output-file: build-tools-sbom.spdx.json + + - name: Attest SBOM for Build-Tools image + run: | + cosign attest --yes \ + --predicate build-tools-sbom.spdx.json \ + --type spdxjson \ + ghcr.io/${{ github.repository }}/build-tools@${{ steps.build_build_tools.outputs.digest }} + # Build agent-act image with catthehacker/ubuntu:act-24.04 base for GitHub Actions parity # amd64-only: catthehacker/ubuntu:act-24.04 does not publish arm64 manifests build-agent-act: @@ -427,7 +489,7 @@ jobs: release: name: Create Release runs-on: ubuntu-latest - needs: [bump-version, build-squid, build-agent, build-api-proxy, build-cli-proxy, build-agent-act] + needs: [bump-version, build-squid, build-agent, build-api-proxy, build-cli-proxy, build-build-tools, build-agent-act] steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4 diff --git a/containers/build-tools/Dockerfile b/containers/build-tools/Dockerfile new file mode 100644 index 000000000..8aa03584d --- /dev/null +++ b/containers/build-tools/Dockerfile @@ -0,0 +1,27 @@ +FROM ubuntu:22.04 + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y --no-install-recommends \ + autoconf \ + bash \ + binutils \ + build-essential \ + ca-certificates \ + cmake \ + coreutils \ + curl \ + gh \ + git \ + gnupg \ + gosu \ + jq \ + libc6-dev \ + libcap2-bin \ + libicu-dev \ + libssl-dev \ + make \ + pkg-config \ + wget \ + zlib1g-dev \ + && rm -rf /var/lib/apt/lists/* diff --git a/docs/arc-dind.md b/docs/arc-dind.md index d6b63f767..e452f1318 100644 --- a/docs/arc-dind.md +++ b/docs/arc-dind.md @@ -37,6 +37,10 @@ AWF supports ARC runners where the runner filesystem and Docker daemon filesyste "path": "/usr/local/bin/copilot", "targetPath": "/usr/local/bin/copilot" } + }, + "runner": { + "topology": "arc-dind", + "sysrootImage": "ghcr.io/github/gh-aw-firewall/build-tools:latest" } } ``` @@ -49,6 +53,23 @@ AWF supports ARC runners where the runner filesystem and Docker daemon filesyste - `dind.stageEngineBinary`: copies an engine binary from the runner path into daemon-visible filesystem before compose startup. - `dind.stagingImage`: image used for short-lived staging containers. - `dind.workDir`: target root for DinD pre-staged directory tree (`/tmp/gh-aw` default). +- `runner.topology: "arc-dind"`: enables sysroot staging (`sysroot-stage` init service + `sysroot` volume mounted on agent at `/host:ro`). +- `runner.sysrootImage`: optional override for the sysroot image used by `runner.topology=arc-dind`. + +## Build-tools sysroot image + +When `runner.topology` is `arc-dind`, AWF starts a one-shot `sysroot-stage` service that copies +the filesystem from `ghcr.io/github/gh-aw-firewall/build-tools:latest` into a named `sysroot` +volume. The agent mounts that volume at `/host:ro`. + +This image pre-installs root-required system build dependencies (for example gcc/make/cmake, +libssl-dev/libc6-dev/libicu-dev, capsh/gosu/gh) so ARC workflow steps can stay non-root. + +## Tool cache path guidance for ARC + +If `RUNNER_TOOL_CACHE` points under `/opt` (for example `/opt/hostedtoolcache`) AWF logs a warning +in `runner.topology=arc-dind` mode because `/opt` is commonly not visible from the DinD daemon +filesystem. Prefer a shared runner/daemon path under `/tmp/gh-aw` when possible. ## Auto-detection of split filesystem setups diff --git a/docs/awf-config.schema.json b/docs/awf-config.schema.json index b4bba6de8..18519ad61 100644 --- a/docs/awf-config.schema.json +++ b/docs/awf-config.schema.json @@ -777,6 +777,24 @@ "description": "The GitHub deployment type. 'github.com' = GitHub.com (default), 'ghes' = GitHub Enterprise Server (on-premises), 'ghec' = GitHub Enterprise Cloud (*.ghe.com tenants), 'ghec-self-hosted' = GHEC with self-hosted runners." } } + }, + "runner": { + "type": "object", + "description": "Runner topology and sysroot staging settings.", + "additionalProperties": false, + "properties": { + "topology": { + "type": "string", + "enum": [ + "arc-dind" + ], + "description": "Runner topology mode. Set to \"arc-dind\" to enable sysroot-stage init service and /host sysroot volume mount for split runner/daemon filesystems." + }, + "sysrootImage": { + "type": "string", + "description": "Override sysroot image used by runner.topology=arc-dind. Defaults to \"ghcr.io/github/gh-aw-firewall/build-tools:latest\"." + } + } } }, "$defs": { diff --git a/src/awf-config-schema.json b/src/awf-config-schema.json index b4bba6de8..18519ad61 100644 --- a/src/awf-config-schema.json +++ b/src/awf-config-schema.json @@ -777,6 +777,24 @@ "description": "The GitHub deployment type. 'github.com' = GitHub.com (default), 'ghes' = GitHub Enterprise Server (on-premises), 'ghec' = GitHub Enterprise Cloud (*.ghe.com tenants), 'ghec-self-hosted' = GHEC with self-hosted runners." } } + }, + "runner": { + "type": "object", + "description": "Runner topology and sysroot staging settings.", + "additionalProperties": false, + "properties": { + "topology": { + "type": "string", + "enum": [ + "arc-dind" + ], + "description": "Runner topology mode. Set to \"arc-dind\" to enable sysroot-stage init service and /host sysroot volume mount for split runner/daemon filesystems." + }, + "sysrootImage": { + "type": "string", + "description": "Override sysroot image used by runner.topology=arc-dind. Defaults to \"ghcr.io/github/gh-aw-firewall/build-tools:latest\"." + } + } } }, "$defs": { diff --git a/src/commands/build-config.ts b/src/commands/build-config.ts index abc842564..e45e9bf7f 100644 --- a/src/commands/build-config.ts +++ b/src/commands/build-config.ts @@ -227,6 +227,8 @@ export function buildConfig(inputs: BuildConfigInputs): WrapperConfig { chrootBinariesSourcePath: options.chrootBinariesSourcePath as string | undefined, chrootIdentity, dind, + runnerTopology: options.runnerTopology as 'arc-dind' | undefined, + runnerSysrootImage: options.runnerSysrootImage as string | undefined, }; } diff --git a/src/commands/validators/network-options.test.ts b/src/commands/validators/network-options.test.ts index 723606b5d..f9ef9ef3b 100644 --- a/src/commands/validators/network-options.test.ts +++ b/src/commands/validators/network-options.test.ts @@ -60,9 +60,17 @@ function makeDefaultMocks() { } describe('validateNetworkOptions', () => { + const savedRunnerToolCache = process.env.RUNNER_TOOL_CACHE; + beforeEach(() => { jest.clearAllMocks(); makeDefaultMocks(); + delete process.env.RUNNER_TOOL_CACHE; + }); + + afterAll(() => { + if (savedRunnerToolCache === undefined) delete process.env.RUNNER_TOOL_CACHE; + else process.env.RUNNER_TOOL_CACHE = savedRunnerToolCache; }); describe('happy path', () => { @@ -261,4 +269,22 @@ describe('validateNetworkOptions', () => { expect(result.dnsOverHttps).toBe('https://1.1.1.1/dns-query'); }); }); + + describe('arc-dind RUNNER_TOOL_CACHE warnings', () => { + it('warns when RUNNER_TOOL_CACHE is under /opt in arc-dind topology', () => { + process.env.RUNNER_TOOL_CACHE = '/opt/hostedtoolcache'; + validateNetworkOptions({ runnerTopology: 'arc-dind' }); + + const warnCalls = (logger.warn as jest.Mock).mock.calls.map((c: string[]) => c[0]); + expect(warnCalls.some((m: string) => m.includes('RUNNER_TOOL_CACHE is under /opt'))).toBe(true); + }); + + it('does not warn when topology is not arc-dind', () => { + process.env.RUNNER_TOOL_CACHE = '/opt/hostedtoolcache'; + validateNetworkOptions({}); + + const warnCalls = (logger.warn as jest.Mock).mock.calls.map((c: string[]) => c[0]); + expect(warnCalls.some((m: string) => m.includes('RUNNER_TOOL_CACHE is under /opt'))).toBe(false); + }); + }); }); diff --git a/src/commands/validators/network-options.ts b/src/commands/validators/network-options.ts index 0f97e23a2..55d30430a 100644 --- a/src/commands/validators/network-options.ts +++ b/src/commands/validators/network-options.ts @@ -78,6 +78,18 @@ export function validateNetworkOptions(options: Record): Networ ); } + if (options.runnerTopology === 'arc-dind') { + const runnerToolCache = process.env.RUNNER_TOOL_CACHE?.trim(); + if (runnerToolCache === '/opt' || runnerToolCache?.startsWith('/opt/')) { + logger.warn( + '⚠️ RUNNER_TOOL_CACHE is under /opt, which is typically invisible to DinD daemons in ARC.', + ); + logger.warn( + ' Prefer a runner-visible shared path (for example under /tmp/gh-aw) for tool-cache mounts.', + ); + } + } + // --- Domain resolution -------------------------------------------------- // Resolve allowed and blocked domains (parse, merge, validate) diff --git a/src/compose-generator.test.ts b/src/compose-generator.test.ts index db1852773..ba22ef983 100644 --- a/src/compose-generator.test.ts +++ b/src/compose-generator.test.ts @@ -312,4 +312,30 @@ describe('generateDockerCompose', () => { expect(result.services.agent.environment?.AWF_NETWORK_ISOLATION).toBeUndefined(); }); }); + + describe('runner.topology arc-dind', () => { + it('adds sysroot-stage service and sysroot volume', () => { + const result = generateDockerCompose( + { ...mockConfig, runnerTopology: 'arc-dind' }, + mockNetworkConfig + ); + + expect(result.services['sysroot-stage']).toBeDefined(); + expect(result.services['sysroot-stage'].image).toBe('ghcr.io/github/gh-aw-firewall/build-tools:latest'); + expect(result.volumes?.sysroot).toEqual({}); + }); + + it('mounts sysroot on agent /host and depends on sysroot-stage completion', () => { + const result = generateDockerCompose( + { ...mockConfig, runnerTopology: 'arc-dind' }, + mockNetworkConfig + ); + + expect(result.services.agent.volumes).toContain('sysroot:/host:ro'); + const dependsOn = result.services.agent.depends_on as { [key: string]: { condition: string } }; + expect(dependsOn['sysroot-stage']).toEqual({ + condition: 'service_completed_successfully', + }); + }); + }); }); diff --git a/src/compose-generator.ts b/src/compose-generator.ts index 723f233bc..1b58118cd 100644 --- a/src/compose-generator.ts +++ b/src/compose-generator.ts @@ -11,6 +11,7 @@ import { buildAgentEnvironment, buildAgentVolumes, buildAgentService, buildIptab import { buildApiProxyService } from './services/api-proxy-service'; import { buildDohProxyService } from './services/doh-proxy-service'; import { buildCliProxyService } from './services/cli-proxy-service'; +import { buildSysrootStageService, isSysrootTopologyEnabled } from './services/sysroot-service'; import { TOPOLOGY_NETWORK_NAME } from './topology'; /** @@ -139,9 +140,22 @@ export function generateDockerCompose( // ── Assemble base services ───────────────────────────────────────────────── + const sysrootEnabled = isSysrootTopologyEnabled(config); + if (sysrootEnabled) { + agentService.volumes = [`sysroot:/host:ro`, ...(agentService.volumes || [])]; + agentService.depends_on['sysroot-stage'] = { + condition: 'service_completed_successfully', + }; + } + const services: Record = { 'squid-proxy': squidService, 'agent': agentService, + ...(sysrootEnabled + ? { + 'sysroot-stage': buildSysrootStageService(config, imageConfig), + } + : {}), }; if (!networkIsolation) { @@ -242,6 +256,13 @@ export function generateDockerCompose( driver: 'bridge', }, }, + ...(sysrootEnabled + ? { + volumes: { + sysroot: {}, + }, + } + : {}), }; return composeResult; @@ -254,6 +275,13 @@ export function generateDockerCompose( external: true, }, }, + ...(sysrootEnabled + ? { + volumes: { + sysroot: {}, + }, + } + : {}), }; return composeResult; diff --git a/src/config-file-mapping.test.ts b/src/config-file-mapping.test.ts index 79aa046c0..813f67c5d 100644 --- a/src/config-file-mapping.test.ts +++ b/src/config-file-mapping.test.ts @@ -496,4 +496,15 @@ describe('mapAwfFileConfigToCliOptions', () => { const result = mapAwfFileConfigToCliOptions({}); expect(result.platformType).toBeUndefined(); }); + + it('maps runner topology fields', () => { + const result = mapAwfFileConfigToCliOptions({ + runner: { + topology: 'arc-dind', + sysrootImage: 'ghcr.io/github/gh-aw-firewall/build-tools:latest', + }, + }); + expect(result.runnerTopology).toBe('arc-dind'); + expect(result.runnerSysrootImage).toBe('ghcr.io/github/gh-aw-firewall/build-tools:latest'); + }); }); diff --git a/src/config-file-validation.test.ts b/src/config-file-validation.test.ts index e7c451fa2..5b1a3fd99 100644 --- a/src/config-file-validation.test.ts +++ b/src/config-file-validation.test.ts @@ -399,6 +399,21 @@ describe('validateAwfFileConfig', () => { expect(errors).toContain('config.container.unknown is not supported'); }); + it('rejects non-object runner', () => { + const errors = validateAwfFileConfig({ runner: 'invalid' }); + expect(errors).toContain('config.runner must be an object'); + }); + + it('rejects invalid runner field types', () => { + expect(validateAwfFileConfig({ runner: { topology: 'invalid' } })).toContain('config.runner.topology must be one of: arc-dind'); + expect(validateAwfFileConfig({ runner: { sysrootImage: 123 } })).toContain('config.runner.sysrootImage must be a string'); + }); + + it('rejects unknown runner keys', () => { + const errors = validateAwfFileConfig({ runner: { unknown: true } }); + expect(errors).toContain('config.runner.unknown is not supported'); + }); + it('rejects non-object chroot', () => { const errors = validateAwfFileConfig({ chroot: 'invalid' }); expect(errors).toContain('config.chroot must be an object'); diff --git a/src/config-file.ts b/src/config-file.ts index 2fd5f9f60..ce557dd81 100644 --- a/src/config-file.ts +++ b/src/config-file.ts @@ -152,6 +152,10 @@ export interface AwfFileConfig { platform?: { type?: 'github.com' | 'ghes' | 'ghec' | 'ghec-self-hosted'; }; + runner?: { + topology?: 'arc-dind'; + sysrootImage?: string; + }; } /** diff --git a/src/config-mapper.ts b/src/config-mapper.ts index a6fc34283..69fb3402e 100644 --- a/src/config-mapper.ts +++ b/src/config-mapper.ts @@ -132,5 +132,7 @@ export function mapAwfFileConfigToCliOptions(config: AwfFileConfig): Record { expect(validate({ container: { runnerToolCachePath: 123 } })).toBe(false); }); + it('accepts runner.topology and runner.sysrootImage', () => { + expect(validate({ runner: { topology: 'arc-dind' } })).toBe(true); + expect(validate({ runner: { topology: 'invalid' } })).toBe(false); + expect(validate({ runner: { sysrootImage: 'ghcr.io/github/gh-aw-firewall/build-tools:latest' } })).toBe(true); + expect(validate({ runner: { sysrootImage: 123 } })).toBe(false); + }); + it('validates chroot.identity fields', () => { expect(validate({ chroot: { identity: { home: '/tmp/gh-aw/home', user: 'runner', uid: 1001, gid: 1001 } } })).toBe(true); expect(validate({ chroot: { identity: { uid: 1.2 } } })).toBe(false); diff --git a/src/services/agent-volumes/system-mounts.ts b/src/services/agent-volumes/system-mounts.ts index 8c3198c49..e93e609de 100644 --- a/src/services/agent-volumes/system-mounts.ts +++ b/src/services/agent-volumes/system-mounts.ts @@ -10,16 +10,24 @@ function normalizeChrootBinariesSourcePath(chrootBinariesSourcePath?: string): s return normalized === '/' ? undefined : normalized; } -export function buildSystemMounts(workspaceDir: string, chrootBinariesSourcePath?: string): string[] { +export function buildSystemMounts( + workspaceDir: string, + chrootBinariesSourcePath?: string, + useSysroot = false +): string[] { const mounts = [ - '/usr:/host/usr:ro', - '/bin:/host/bin:ro', - '/sbin:/host/sbin:ro', - '/lib:/host/lib:ro', - '/lib64:/host/lib64:ro', - '/opt:/host/opt:ro', - '/sys:/host/sys:ro', - '/dev:/host/dev:ro', + ...(useSysroot + ? [] + : [ + '/usr:/host/usr:ro', + '/bin:/host/bin:ro', + '/sbin:/host/sbin:ro', + '/lib:/host/lib:ro', + '/lib64:/host/lib64:ro', + '/opt:/host/opt:ro', + '/sys:/host/sys:ro', + '/dev:/host/dev:ro', + ]), `${workspaceDir}:/host${workspaceDir}:rw`, '/tmp:/host/tmp:rw', ]; diff --git a/src/services/agent-volumes/volume-builder.ts b/src/services/agent-volumes/volume-builder.ts index 696e9105f..e74bea9be 100644 --- a/src/services/agent-volumes/volume-builder.ts +++ b/src/services/agent-volumes/volume-builder.ts @@ -38,7 +38,8 @@ export function buildAgentVolumes(params: AgentVolumesParams): string[] { logger.debug('Using selective path mounts for security'); - agentVolumes.push(...buildSystemMounts(workspaceDir, config.chrootBinariesSourcePath)); + const useSysroot = config.runnerTopology === 'arc-dind'; + agentVolumes.push(...buildSystemMounts(workspaceDir, config.chrootBinariesSourcePath, useSysroot)); agentVolumes.push(...buildHomeMounts({ config, effectiveHome, agentLogsPath, sessionStatePath })); agentVolumes.push(...buildEtcMounts(config)); agentVolumes.push(generateHostsFileMount(config)); diff --git a/src/services/sysroot-service.test.ts b/src/services/sysroot-service.test.ts new file mode 100644 index 000000000..e378a8b6b --- /dev/null +++ b/src/services/sysroot-service.test.ts @@ -0,0 +1,53 @@ +import { parseImageTag } from '../image-tag'; +import { WrapperConfig } from '../types'; +import { + buildSysrootStageService, + DEFAULT_SYSROOT_IMAGE, + isSysrootTopologyEnabled, + resolveSysrootImage, +} from './sysroot-service'; + +const baseConfig: WrapperConfig = { + allowedDomains: ['github.com'], + agentCommand: 'echo hi', + logLevel: 'info', + keepContainers: false, + workDir: '/tmp/awf-test', + imageRegistry: 'ghcr.io/github/gh-aw-firewall', + imageTag: 'latest', + buildLocal: false, +}; + +const imageConfig = { + useGHCR: true, + registry: 'ghcr.io/github/gh-aw-firewall', + parsedTag: parseImageTag('latest'), + projectRoot: '/tmp/project', +}; + +describe('sysroot-service', () => { + it('enables sysroot topology only for arc-dind', () => { + expect(isSysrootTopologyEnabled(baseConfig)).toBe(false); + expect(isSysrootTopologyEnabled({ ...baseConfig, runnerTopology: 'arc-dind' })).toBe(true); + }); + + it('resolves default build-tools image from registry/tag', () => { + expect(resolveSysrootImage({ ...baseConfig, runnerTopology: 'arc-dind' }, imageConfig)) + .toBe(DEFAULT_SYSROOT_IMAGE); + }); + + it('prefers explicit runnerSysrootImage override', () => { + expect(resolveSysrootImage({ + ...baseConfig, + runnerTopology: 'arc-dind', + runnerSysrootImage: 'ghcr.io/custom/build-tools:v1', + }, imageConfig)).toBe('ghcr.io/custom/build-tools:v1'); + }); + + it('builds a sysroot-stage one-shot service', () => { + const service = buildSysrootStageService({ ...baseConfig, runnerTopology: 'arc-dind' }, imageConfig); + expect(service.image).toBe(DEFAULT_SYSROOT_IMAGE); + expect(service.volumes).toEqual(['sysroot:/sysroot']); + expect(service.restart).toBe('no'); + }); +}); diff --git a/src/services/sysroot-service.ts b/src/services/sysroot-service.ts new file mode 100644 index 000000000..1e0206fcc --- /dev/null +++ b/src/services/sysroot-service.ts @@ -0,0 +1,28 @@ +import { WrapperConfig } from '../types'; +import { ImageBuildConfig } from './squid-service'; + +export const DEFAULT_SYSROOT_IMAGE = 'ghcr.io/github/gh-aw-firewall/build-tools:latest'; + +export function isSysrootTopologyEnabled(config: WrapperConfig): boolean { + return config.runnerTopology === 'arc-dind'; +} + +export function resolveSysrootImage(config: WrapperConfig, imageConfig: ImageBuildConfig): string { + if (config.runnerSysrootImage) { + return config.runnerSysrootImage; + } + return `${imageConfig.registry}/build-tools:${imageConfig.parsedTag.tag}`; +} + +export function buildSysrootStageService(config: WrapperConfig, imageConfig: ImageBuildConfig): any { + return { + image: resolveSysrootImage(config, imageConfig), + volumes: ['sysroot:/sysroot'], + command: [ + '/bin/bash', + '-lc', + "set -euo pipefail; rm -rf /sysroot/.awf-tmp; mkdir -p /sysroot/.awf-tmp; tar -C / --exclude='./sysroot' -cf - . | tar -C /sysroot -xf -", + ], + restart: 'no', + }; +} diff --git a/src/types/runner-options.ts b/src/types/runner-options.ts new file mode 100644 index 000000000..4020754d6 --- /dev/null +++ b/src/types/runner-options.ts @@ -0,0 +1,18 @@ +/** + * Runner topology configuration options. + */ +export interface RunnerOptions { + /** + * Runner topology mode for AWF compose generation. + * + * `arc-dind` enables sysroot staging for split runner/daemon filesystems. + */ + runnerTopology?: 'arc-dind'; + + /** + * Sysroot image used by arc-dind topology to stage build tools into /host. + * + * @default 'ghcr.io/github/gh-aw-firewall/build-tools:latest' + */ + runnerSysrootImage?: string; +} diff --git a/src/types/wrapper-config.ts b/src/types/wrapper-config.ts index ad7ce952b..b5ef3fd06 100644 --- a/src/types/wrapper-config.ts +++ b/src/types/wrapper-config.ts @@ -14,6 +14,7 @@ import type { CliProxyOptions } from './cli-proxy-options'; import type { RateLimitOptions } from './rate-limit-options'; import type { RuntimeOptions } from './runtime-options'; import type { PlatformOptions } from './platform-options'; +import type { RunnerOptions } from './runner-options'; export type WrapperConfig = ContainerImageOptions @@ -24,4 +25,5 @@ export type WrapperConfig = & CliProxyOptions & RateLimitOptions & RuntimeOptions - & PlatformOptions; + & PlatformOptions + & RunnerOptions; From 4d8f33f319c939d00fd7f5da9ee107f2035ce148 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Mon, 29 Jun 2026 17:23:42 -0700 Subject: [PATCH 3/3] fix: address Copilot review feedback on sysroot staging - Mount sysroot volume at /host:rw (agent entrypoint writes resolv.conf) - Keep /sys and /dev bind mounts in sysroot mode (live kernel VFS needed) - Clarify docs: default sysroot image derives from --image-registry/--image-tag - Update docs from :ro to :rw to match implementation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/arc-dind.md | 9 +++++---- src/compose-generator.test.ts | 12 ++++++------ src/compose-generator.ts | 12 ++++++------ src/etc-mounts-branches.test.ts | 8 ++++++++ src/services/agent-volumes/system-mounts.ts | 5 ++++- 5 files changed, 29 insertions(+), 17 deletions(-) diff --git a/docs/arc-dind.md b/docs/arc-dind.md index 47f9e7c81..5b8c5082b 100644 --- a/docs/arc-dind.md +++ b/docs/arc-dind.md @@ -52,7 +52,7 @@ services: depends_on: sysroot-stage: { condition: service_completed_successfully } volumes: - - sysroot:/host:ro + - sysroot:/host:rw - /tmp/gh-aw/tool-cache:/host/tmp/gh-aw/tool-cache:ro volumes: @@ -136,14 +136,15 @@ For fine-grained control (or when not using `runner.topology`): - `dind.stageEngineBinary`: copies an engine binary from the runner path into daemon-visible filesystem before compose startup. - `dind.stagingImage`: image used for short-lived staging containers. - `dind.workDir`: target root for DinD pre-staged directory tree (`/tmp/gh-aw` default). -- `runner.topology: "arc-dind"`: enables sysroot staging (`sysroot-stage` init service + `sysroot` volume mounted on agent at `/host:ro`). +- `runner.topology: "arc-dind"`: enables sysroot staging (`sysroot-stage` init service + `sysroot` volume mounted on agent at `/host:rw`). - `runner.sysrootImage`: optional override for the sysroot image used by `runner.topology=arc-dind`. ## Build-tools sysroot image When `runner.topology` is `arc-dind`, AWF starts a one-shot `sysroot-stage` service that copies -the filesystem from `ghcr.io/github/gh-aw-firewall/build-tools:latest` into a named `sysroot` -volume. The agent mounts that volume at `/host:ro`. +the filesystem from a build-tools image derived from the same `--image-registry` and `--image-tag` +settings as the other AWF containers (unless `runner.sysrootImage` overrides it) into a named +`sysroot` volume. The agent mounts that volume at `/host:rw`. This image pre-installs root-required system build dependencies (for example gcc/make/cmake, libssl-dev/libc6-dev/libicu-dev, capsh/gosu/gh) so ARC workflow steps can stay non-root. diff --git a/src/compose-generator.test.ts b/src/compose-generator.test.ts index 3cf5ef403..ff8a54eee 100644 --- a/src/compose-generator.test.ts +++ b/src/compose-generator.test.ts @@ -354,11 +354,11 @@ describe('generateDockerCompose', () => { expect(result.volumes!.sysroot).toEqual({}); }); - it('adds sysroot:/host:ro to agent volumes', () => { + it('adds sysroot:/host:rw to agent volumes', () => { const config = { ...mockConfig, runnerTopology: 'arc-dind' as const }; const result = generateDockerCompose(config, mockNetworkConfig); - expect(result.services.agent.volumes).toContain('sysroot:/host:ro'); + expect(result.services.agent.volumes).toContain('sysroot:/host:rw'); }); it('does not retain base-system bind mounts that shadow sysroot', () => { @@ -375,16 +375,16 @@ describe('generateDockerCompose', () => { expect(volumes).not.toContain('/lib:/host/lib:ro'); expect(volumes).not.toContain('/lib64:/host/lib64:ro'); expect(volumes).not.toContain('/opt:/host/opt:ro'); - expect(volumes).not.toContain('/sys:/host/sys:ro'); - expect(volumes).not.toContain('/dev:/host/dev:ro'); + expect(volumes).toContain('/sys:/host/sys:ro'); + expect(volumes).toContain('/dev:/host/dev:ro'); expect(volumes.some(v => v.includes(':/host/usr:ro'))).toBe(false); expect(volumes.some(v => v.includes(':/host/bin:ro'))).toBe(false); expect(volumes.some(v => v.includes(':/host/sbin:ro'))).toBe(false); expect(volumes.some(v => v.includes(':/host/lib:ro'))).toBe(false); expect(volumes.some(v => v.includes(':/host/lib64:ro'))).toBe(false); expect(volumes.some(v => v.includes(':/host/opt:ro'))).toBe(false); - expect(volumes.some(v => v.includes(':/host/sys:ro'))).toBe(false); - expect(volumes.some(v => v.includes(':/host/dev:ro'))).toBe(false); + expect(volumes.filter(v => v.endsWith(':/host/sys:ro'))).toEqual(['/sys:/host/sys:ro']); + expect(volumes.filter(v => v.endsWith(':/host/dev:ro'))).toEqual(['/dev:/host/dev:ro']); }); it('does not declare sysroot volume when topology is standard', () => { diff --git a/src/compose-generator.ts b/src/compose-generator.ts index 870ef2dc9..a3a8ab864 100644 --- a/src/compose-generator.ts +++ b/src/compose-generator.ts @@ -124,8 +124,6 @@ export function generateDockerCompose( '/host/lib', '/host/lib64', '/host/opt', - '/host/sys', - '/host/dev', ]); const filteredVolumes = agentVolumes.filter(volume => { const target = volume.split(':')[1]; @@ -263,15 +261,17 @@ export function generateDockerCompose( // ── Final compose result ─────────────────────────────────────────────────── // When sysroot staging is active, declare the named volume and mount it - // on the agent at /host (replacing the per-directory system bind mounts). + // on the agent at /host (replacing the per-directory userspace bind mounts, + // while /sys and /dev remain live host mounts). const namedVolumes: Record | undefined = sysrootActive ? { sysroot: {} } : undefined; if (sysrootActive) { - // The sysroot named volume provides /host content (system binaries, libs, etc.) - // via the sysroot-stage init container instead of per-directory bind mounts. - agentVolumes.push('sysroot:/host:ro'); + // The sysroot named volume provides most /host content (system binaries, + // libs, etc.) via the sysroot-stage init container instead of per-directory + // userspace bind mounts. + agentVolumes.push('sysroot:/host:rw'); } if (networkIsolation) { diff --git a/src/etc-mounts-branches.test.ts b/src/etc-mounts-branches.test.ts index 5cc81fbde..dfd917850 100644 --- a/src/etc-mounts-branches.test.ts +++ b/src/etc-mounts-branches.test.ts @@ -286,6 +286,14 @@ describe('system-mounts branch coverage', () => { expect(mounts).toContain('/custom/tools:/host/tmp/awf-runner-bin:ro'); }); + it('keeps live /sys and /dev mounts when sysroot mode is enabled', () => { + const mounts = buildSystemMounts('/workspace', undefined, true); + expect(mounts).toContain('/sys:/host/sys:ro'); + expect(mounts).toContain('/dev:/host/dev:ro'); + expect(mounts).not.toContain('/usr:/host/usr:ro'); + expect(mounts).not.toContain('/bin:/host/bin:ro'); + }); + it('excludes runner-bin mount when chrootBinariesSourcePath is whitespace-only', () => { const mounts = buildSystemMounts('/workspace', ' '); expect(mounts.every(m => !m.includes('awf-runner-bin'))).toBe(true); diff --git a/src/services/agent-volumes/system-mounts.ts b/src/services/agent-volumes/system-mounts.ts index e93e609de..c13a6abe4 100644 --- a/src/services/agent-volumes/system-mounts.ts +++ b/src/services/agent-volumes/system-mounts.ts @@ -17,7 +17,10 @@ export function buildSystemMounts( ): string[] { const mounts = [ ...(useSysroot - ? [] + ? [ + '/sys:/host/sys:ro', + '/dev:/host/dev:ro', + ] : [ '/usr:/host/usr:ro', '/bin:/host/bin:ro',