diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 53670d528..a7b3457d6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -350,6 +350,129 @@ jobs: --type spdxjson \ ghcr.io/${{ github.repository }}/cli-proxy@${{ steps.build_cli_proxy.outputs.digest }} + # Build the AWF Apple Container init image (Apple's vminit with the AWF guest + # capability relay installed at /sbin/vminitd). + # + # Gated on the `APPLE_VMINIT_IMAGE` repository variable, which must hold a + # digest-pinned reference to Apple's own vminit image for the supported + # `container` CLI range. There is deliberately no default: a floating base + # would let Apple's init move underneath a shim that hard-codes where that + # init lives. When the variable is unset the job is skipped and no + # apple-init digest is published — and because the Apple Container backend + # refuses to launch without a digest-pinned init image, the runtime fails + # closed at preflight rather than running with an unknown guest init. + build-apple-init: + name: Build Apple Container Init Image + runs-on: ubuntu-latest + needs: bump-version + if: vars.APPLE_VMINIT_IMAGE != '' + outputs: + digest: ${{ steps.build_apple_init.outputs.digest }} + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4 + with: + ref: ${{ needs.bump-version.outputs.version }} + + - name: Validate the Apple base image is digest-pinned + env: + AWF_VMINIT_IMAGE: ${{ vars.APPLE_VMINIT_IMAGE }} + run: | + set -euo pipefail + case "$AWF_VMINIT_IMAGE" in + *@sha256:*) echo "Apple vminit base: $AWF_VMINIT_IMAGE" ;; + *) echo "::error::APPLE_VMINIT_IMAGE must be digest-pinned"; exit 1 ;; + esac + + - name: Read the transport contract constants + id: contract + run: | + set -euo pipefail + src=src/apple-container/transport-capabilities.ts + min=$(sed -n "s/^export const APPLE_CONTAINER_TRANSPORT_MIN_CLI_VERSION = '\\([^']*\\)';$/\\1/p" "$src") + max=$(sed -n "s/^export const APPLE_CONTAINER_TRANSPORT_MAX_CLI_VERSION_EXCLUSIVE = '\\([^']*\\)';$/\\1/p" "$src") + contract=$(sed -n 's/^export const APPLE_CONTAINER_TRANSPORT_CONTRACT_VERSION = \([0-9]*\);$/\1/p' "$src") + test -n "$min" && test -n "$max" && test -n "$contract" + echo "min=$min" >> "$GITHUB_OUTPUT" + echo "max=$max" >> "$GITHUB_OUTPUT" + echo "contract=$contract" >> "$GITHUB_OUTPUT" + + - 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 + + # arm64 only: Apple Container guests are native arm64 and Rosetta + # translation is never used, so an amd64 variant would be a silently + # unusable artifact rather than a useful one. + - name: Build and push Apple init image + id: build_apple_init + uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 + with: + context: . + file: ./containers/apple-init/Dockerfile + push: true + platforms: linux/arm64 + build-args: | + AWF_VMINIT_IMAGE=${{ vars.APPLE_VMINIT_IMAGE }} + AWF_CLI_MIN_VERSION=${{ steps.contract.outputs.min }} + AWF_CLI_MAX_VERSION_EXCLUSIVE=${{ steps.contract.outputs.max }} + AWF_TRANSPORT_CONTRACT_VERSION=${{ steps.contract.outputs.contract }} + tags: | + ghcr.io/${{ github.repository }}/apple-init:${{ needs.bump-version.outputs.version_number }} + ghcr.io/${{ github.repository }}/apple-init:latest + cache-from: type=gha,scope=apple-init + cache-to: type=gha,mode=max,scope=apple-init + + - name: Verify the published init image layout + run: | + set -euo pipefail + ref="ghcr.io/${{ github.repository }}/apple-init@${{ steps.build_apple_init.outputs.digest }}" + # Extracting the image is the only way to prove the relocation + # happened; the image has no shell, so it cannot be probed by running it. + id=$(docker create --platform linux/arm64 "$ref") + trap 'docker rm -f "$id" >/dev/null 2>&1 || true' EXIT + docker export "$id" > init.tar + tar -tf init.tar | grep -qx 'sbin/vminitd' + tar -tf init.tar | grep -qx 'sbin/vminitd.apple' + mkdir -p extracted + tar -xf init.tar -C extracted sbin/vminitd + file extracted/sbin/vminitd | tee /dev/stderr | \ + grep -q 'ELF 64-bit LSB executable, ARM aarch64' + file extracted/sbin/vminitd | grep -q 'statically linked' + + - name: Sign Apple init image with cosign + run: | + cosign sign --yes \ + ghcr.io/${{ github.repository }}/apple-init@${{ steps.build_apple_init.outputs.digest }} + + - name: Generate SBOM for Apple init image + uses: anchore/sbom-action@28d71544de8eaf1b958d335707167c5f783590ad # v0.22.2 + with: + image: ghcr.io/${{ github.repository }}/apple-init@${{ steps.build_apple_init.outputs.digest }} + format: spdx-json + output-file: apple-init-sbom.spdx.json + + - name: Attest SBOM for Apple init image + run: | + cosign attest --yes \ + --predicate apple-init-sbom.spdx.json \ + --type spdxjson \ + ghcr.io/${{ github.repository }}/apple-init@${{ steps.build_apple_init.outputs.digest }} + # Build the unified enclave images from containers/enclave/Dockerfile. build-enclaves: name: Build Enclave Images @@ -721,7 +844,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, build-build-tools, build-enclaves, build-gh-aw-node, build-cloud-hypervisor-test-artifacts] + needs: [bump-version, build-squid, build-agent, build-api-proxy, build-cli-proxy, build-agent-act, build-build-tools, build-enclaves, build-gh-aw-node, build-cloud-hypervisor-test-artifacts, build-apple-init] steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4 @@ -830,6 +953,8 @@ jobs: release/cloud-hypervisor-test-x86_64.sbom.spdx.json - name: Generate containers list + env: + APPLE_INIT_DIGEST: ${{ needs['build-apple-init'].outputs.digest }} run: | mkdir -p release printf '%s\n' \ @@ -843,6 +968,17 @@ jobs: "ghcr.io/${{ github.repository }}/enclave-mcp-server@${{ needs['build-enclaves'].outputs.enclave_mcp_server_digest }}" \ "ghcr.io/${{ github.repository }}/gh-aw-node@${{ needs['build-gh-aw-node'].outputs.digest }}" \ > release/containers.txt + # The Apple Container init image is only built when APPLE_VMINIT_IMAGE + # is configured. Its absence is not silently equivalent to "use latest": + # the Apple Container backend refuses any init image that is not + # digest-pinned, so a release without this line simply cannot select + # that preview runtime. + if [ -n "${APPLE_INIT_DIGEST:-}" ]; then + echo "ghcr.io/${{ github.repository }}/apple-init@${APPLE_INIT_DIGEST}" \ + >> release/containers.txt + else + echo "No apple-init digest; the Apple Container preview runtime is not selectable in this release." + fi echo "Generated containers.txt:" cat release/containers.txt diff --git a/.github/workflows/smoke-apple-container.yml b/.github/workflows/smoke-apple-container.yml new file mode 100644 index 000000000..604df240f --- /dev/null +++ b/.github/workflows/smoke-apple-container.yml @@ -0,0 +1,357 @@ +name: Apple Container Live Smoke (self-hosted) + +# Live end-to-end validation of the Apple Container preview runtime. +# +# This is the ONLY place the runtime can actually be exercised. Apple Container +# requires Virtualization.framework, and every GitHub-hosted macOS runner is +# itself virtualized and reports `kern.hv_support=0` — AWF's preflight fails +# closed there rather than falling back, by design. So this workflow is gated on +# a self-hosted, bare-metal Apple Silicon runner and never runs by default. +# +# Because it is the repository's only self-hosted job and this repository is +# public, it is `workflow_dispatch`-only and takes an explicit `ref`. There is +# deliberately NO `pull_request` trigger, not even a label-gated one: a label +# gate does not stop an attacker pushing new commits between the label and the +# checkout (`refs/pull/N/merge` is resolved when the job starts, not when the +# label was applied), and `npm ci` would then execute lifecycle scripts from an +# unreviewed `package-lock.json` on a persistent bare-metal host — from which +# the runner registration token and every later run's secrets are reachable. +# Running this against a fork PR therefore requires a maintainer to first +# review the code and then dispatch it by SHA. +# +# Three gates, all required: +# 1. `workflow_dispatch` with a maintainer-supplied `ref` (use a full commit +# SHA for anything not already on a protected branch). +# 2. The `apple-container-live-smoke` environment, so the run additionally +# needs whatever reviewers/wait timer that environment is configured with. +# 3. A runner carrying the `self-hosted`, `macOS`, `ARM64`, and +# `apple-container` labels. The last is what distinguishes bare metal from +# a hosted macOS VM; it must only be applied to a host where +# `kern.hv_support=1`. +# +# What it proves, in order: the host is eligible; egress is confined to the +# allowlist; every bypass path a NIC-less guest might attempt is unreachable; +# credentials stay on the host side of the VM boundary; writes land only where +# intended; host secrets are absent; and teardown leaves no VM and no live +# capability socket behind. + +on: + workflow_dispatch: + inputs: + ref: + description: >- + Commit SHA (preferred) or ref to check out and run. Code at this ref + executes on a persistent bare-metal host, so review it first. + required: true + type: string + image_tag: + description: >- + Digest-carrying --image-tag for the agent and apple-init images + (e.g. "0.30.0,agent=sha256:...,apple-init=sha256:..."). Required + because the runtime refuses a floating image reference. + required: true + type: string + +permissions: + contents: read + +concurrency: + # Not cancel-in-progress: a cancelled run must still reach its cleanup + # assertions, and two concurrent runs would collide on the fixed loopback + # ports and the shared `awf-net` Docker network. + group: apple-container-live-smoke + cancel-in-progress: false + +env: + ALLOWED_DOMAIN: api.github.com + BLOCKED_DOMAIN: example.com + # RFC 5737 documentation address: routable-looking, guaranteed not ours. + DIRECT_PUBLIC_IP: 203.0.113.10 + METADATA_IP: 169.254.169.254 + +jobs: + live-smoke: + name: Live smoke (bare-metal Apple Silicon) + runs-on: [self-hosted, macOS, ARM64, apple-container] + # Configure this environment with required reviewers so a dispatch cannot + # start executing on the bare-metal host without a second pair of eyes. + environment: apple-container-live-smoke + timeout-minutes: 60 + steps: + # Checks out exactly the dispatched ref. `persist-credentials: false` + # keeps the job's token out of the checkout's git config, so code that + # runs later in the job cannot reuse it. + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4 + with: + ref: ${{ inputs.ref }} + persist-credentials: false + + # ── Host contract ────────────────────────────────────────────────────── + - name: Assert the runner is eligible bare metal + run: | + set -euo pipefail + test "$(uname -s)" = "Darwin" + test "$(uname -m)" = "arm64" + product=$(sw_vers -productVersion) + echo "macOS $product" + major=${product%%.*} + if [ "$major" -lt 26 ]; then + echo "::error::Apple Container requires macOS 26 or newer; found $product" + exit 1 + fi + hv=$(sysctl -n kern.hv_support) + if [ "$hv" != "1" ]; then + echo "::error::kern.hv_support=$hv — this is not bare metal. The" + echo "::error::'apple-container' runner label must never be applied to a hosted macOS VM." + exit 1 + fi + + - name: Assert the container CLI is installed and healthy + run: | + set -euo pipefail + container --version + container system status --format json + + - name: Set up Node.js + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version: '20' + cache: npm + + - name: Install dependencies and build + run: | + npm ci + npm run build + + - name: Prepare the smoke workspace + id: workspace + run: | + set -euo pipefail + root="${RUNNER_TEMP}/apple-smoke" + rm -rf "$root" + mkdir -p "$root/workspace" "$root/gh-aw" + echo "root=$root" >> "$GITHUB_OUTPUT" + + # ── Egress: the allowlist is the whole surface ───────────────────────── + - name: Allowed HTTPS succeeds through the Squid capability + env: + IMAGE_TAG: ${{ inputs.image_tag }} + run: | + set -euo pipefail + ./dist/cli.js \ + --container-runtime apple-container --apple-container-preview \ + --image-tag "$IMAGE_TAG" \ + --allow-domains "$ALLOWED_DOMAIN" \ + --work-dir "${{ steps.workspace.outputs.root }}/run-allowed" \ + -- "curl -fsS --max-time 30 -o /dev/null -w '%{http_code}\n' https://$ALLOWED_DOMAIN/zen" + + - name: Every bypass path a NIC-less guest could try is unreachable + env: + IMAGE_TAG: ${{ inputs.image_tag }} + run: | + set -euo pipefail + + # Each probe must FAIL. `--network none` means the guest has zero + # NICs, so these are unreachable by construction rather than by rule — + # which is exactly the property being verified. + probes=( + "disallowed-domain:curl -fsS --max-time 15 https://$BLOCKED_DOMAIN/" + "direct-public-ip:curl -fsS --max-time 15 http://$DIRECT_PUBLIC_IP/" + "cloud-metadata:curl -fsS --max-time 15 http://$METADATA_IP/latest/meta-data/" + "plain-dns:getent hosts $BLOCKED_DOMAIN" + "doh:curl -fsS --max-time 15 -H 'accept: application/dns-json' 'https://1.1.1.1/dns-query?name=example.com'" + "ipv6:curl -fsS --max-time 15 -6 'http://[2606:4700:4700::1111]/'" + "raw-socket:ping -c 1 -W 5 $DIRECT_PUBLIC_IP" + "unproxied-tcp:bash -c 'exec 3<>/dev/tcp/$DIRECT_PUBLIC_IP/443'" + ) + + for probe in "${probes[@]}"; do + name=${probe%%:*} + command=${probe#*:} + echo "::group::probe $name" + set +e + ./dist/cli.js \ + --container-runtime apple-container --apple-container-preview \ + --image-tag "$IMAGE_TAG" \ + --allow-domains "$ALLOWED_DOMAIN" \ + --work-dir "${{ steps.workspace.outputs.root }}/run-$name" \ + -- "$command" + status=$? + set -e + echo "::endgroup::" + if [ "$status" -eq 0 ]; then + echo "::error::Bypass probe '$name' SUCCEEDED; the guest reached something it must not." + exit 1 + fi + echo "probe $name correctly failed with exit $status" + done + + # ── Credentials stay on the host side of the boundary ───────────────── + - name: The API proxy works and the guest holds no credential + env: + IMAGE_TAG: ${{ inputs.image_tag }} + ANTHROPIC_API_KEY: ${{ secrets.SMOKE_ANTHROPIC_API_KEY }} + # A non-secret prefix of the same key, so the guest can search its own + # surface for credential material without the full secret ever being + # an argument or an environment value inside the VM. Configure it as a + # repository variable alongside the secret. + AWF_SMOKE_SECRET_PREFIX: ${{ vars.SMOKE_ANTHROPIC_KEY_PREFIX }} + run: | + set -euo pipefail + # The guest must see an unauthenticated loopback endpoint and never the + # real key: the sidecar injects it on the host side. The probe dumps + # its own environment, argv, and every readable file under $HOME and + # the workspace, then greps for the secret. + ./dist/cli.js \ + --container-runtime apple-container --apple-container-preview \ + --enable-api-proxy \ + --image-tag "$IMAGE_TAG" \ + --allow-domains "$ALLOWED_DOMAIN,api.anthropic.com" \ + --work-dir "${{ steps.workspace.outputs.root }}/run-credentials" \ + --env "AWF_SMOKE_SECRET_PREFIX=$AWF_SMOKE_SECRET_PREFIX" \ + -- 'set -e + test -n "$ANTHROPIC_BASE_URL" + case "$ANTHROPIC_BASE_URL" in http://127.0.0.1:*) ;; *) echo "unexpected base url: $ANTHROPIC_BASE_URL"; exit 1;; esac + curl -fsS --max-time 20 -o /dev/null -w "api-proxy %{http_code}\n" "$ANTHROPIC_BASE_URL/v1/models" + { env; cat /proc/self/cmdline | tr "\0" " "; echo; } > /tmp/guest-surface.txt + grep -RIl "$AWF_SMOKE_SECRET_PREFIX" /tmp/guest-surface.txt "$HOME" 2>/dev/null && { echo "credential visible in guest"; exit 1; } + echo "no credential material visible in the guest"' + + # ── Filesystem: writes land only where intended ─────────────────────── + - name: Workspace and safe-output writes succeed; everything else does not + env: + IMAGE_TAG: ${{ inputs.image_tag }} + run: | + set -euo pipefail + workspace="${{ steps.workspace.outputs.root }}/workspace" + GITHUB_WORKSPACE="$workspace" ./dist/cli.js \ + --container-runtime apple-container --apple-container-preview \ + --image-tag "$IMAGE_TAG" \ + --allow-domains "$ALLOWED_DOMAIN" \ + --work-dir "${{ steps.workspace.outputs.root }}/run-fs" \ + -- 'set -e + echo written > "$PWD/awf-smoke-output.txt" + echo written > "$HOME/awf-smoke-home.txt" + echo written > /tmp/awf-smoke-tmp.txt + # The guest root filesystem is mounted read-only. + if echo bad > /usr/local/bin/awf-smoke-rootfs 2>/dev/null; then + echo "rootfs is writable"; exit 1 + fi + if echo bad > /etc/awf-smoke-etc 2>/dev/null; then + echo "/etc is writable"; exit 1 + fi + echo "filesystem policy holds"' + test -f "$workspace/awf-smoke-output.txt" + + - name: Host secrets are absent from the guest by construction + env: + IMAGE_TAG: ${{ inputs.image_tag }} + run: | + set -euo pipefail + # Nothing is "hidden" here — these paths were simply never mounted, so + # the guest cannot see them regardless of what it does. + ./dist/cli.js \ + --container-runtime apple-container --apple-container-preview \ + --image-tag "$IMAGE_TAG" \ + --allow-domains "$ALLOWED_DOMAIN" \ + --work-dir "${{ steps.workspace.outputs.root }}/run-secrets" \ + -- 'set -e + for path in /Users "$HOME/.ssh" "$HOME/.aws" "$HOME/.docker/config.json" \ + /var/run/docker.sock /Library/Keychains; do + if [ -e "$path" ]; then echo "unexpectedly visible: $path"; exit 1; fi + done + echo "no host credential path is visible"' + + # ── Teardown ────────────────────────────────────────────────────────── + - name: A timed-out run is killed and leaves nothing behind + env: + IMAGE_TAG: ${{ inputs.image_tag }} + run: | + set -euo pipefail + before=$(container list --all --format json) + set +e + ./dist/cli.js \ + --container-runtime apple-container --apple-container-preview \ + --image-tag "$IMAGE_TAG" \ + --allow-domains "$ALLOWED_DOMAIN" \ + --agent-timeout 1 \ + --work-dir "${{ steps.workspace.outputs.root }}/run-timeout" \ + -- 'sleep 600' + status=$? + set -e + if [ "$status" -ne 124 ]; then + echo "::error::Expected the coreutils timeout exit code 124, got $status" + exit 1 + fi + # No VM and no capability socket may survive the timeout. The + # capability sockets live under /tmp (see + # APPLE_CONTAINER_TRANSPORT_BASE_DIRECTORY), not under the work + # directory, because macOS caps sun_path at 104 bytes. + container list --all --format json | grep -q 'awf-agent-' && { + echo "::error::An agent VM survived the timeout"; exit 1; } + if find /tmp -maxdepth 2 -name '*.sock' -path '*/awf-apple-*' 2>/dev/null | grep -q .; then + echo "::error::A capability socket survived the timeout" + find /tmp -maxdepth 2 -name '*.sock' -path '*/awf-apple-*' + exit 1 + fi + echo "timeout teardown is clean (was: $(echo "$before" | wc -c) bytes of prior state)" + + - name: A cancelled run is killed and leaves nothing behind + env: + IMAGE_TAG: ${{ inputs.image_tag }} + run: | + set -euo pipefail + ./dist/cli.js \ + --container-runtime apple-container --apple-container-preview \ + --image-tag "$IMAGE_TAG" \ + --allow-domains "$ALLOWED_DOMAIN" \ + --work-dir "${{ steps.workspace.outputs.root }}/run-cancel" \ + -- 'sleep 600' & + cli_pid=$! + # Give the run long enough to boot the VM and bind every relay, so the + # cancellation exercises real teardown rather than an early abort. + sleep 90 + kill -INT "$cli_pid" + wait "$cli_pid" || true + container list --all --format json | grep -q 'awf-agent-' && { + echo "::error::An agent VM survived cancellation"; exit 1; } + if find /tmp -maxdepth 2 -name '*.sock' -path '*/awf-apple-*' 2>/dev/null | grep -q .; then + echo "::error::A capability socket survived cancellation" + find /tmp -maxdepth 2 -name '*.sock' -path '*/awf-apple-*' + exit 1 + fi + echo "cancellation teardown is clean" + + - name: Collect diagnostics + if: always() + run: | + set +e + container list --all --format json + container system logs --last 30m | tail -n 400 + docker ps -a + find "${{ steps.workspace.outputs.root }}" -name 'transport-*.json' -exec cat {} + + find /tmp -maxdepth 2 -path '*/awf-apple-*' -name 'transport-summary.json' -exec cat {} + + + - name: Reclaim the runner + if: always() + run: | + set +e + # A self-hosted runner is reused, so leftovers are not merely untidy: + # a surviving VM or published port would collide with the next run. + for id in $(container list --all --format json | grep -o '"id"[^,]*' | grep -o 'awf-agent-[^"]*'); do + container kill "$id" + container delete --force "$id" + done + docker compose -f "${{ steps.workspace.outputs.root }}"/*/docker-compose.yml down -v 2>/dev/null + rm -rf "${{ steps.workspace.outputs.root }}" + rm -rf /tmp/awf-apple-* + + - name: Upload diagnostics + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: apple-container-live-smoke-diagnostics + path: ${{ steps.workspace.outputs.root }}/**/diagnostics/** + if-no-files-found: ignore + retention-days: 7 diff --git a/.github/workflows/test-apple-container.yml b/.github/workflows/test-apple-container.yml index 41f287275..8768a9845 100644 --- a/.github/workflows/test-apple-container.yml +++ b/.github/workflows/test-apple-container.yml @@ -1,14 +1,19 @@ -name: Apple Container Transport +name: Apple Container -# Layer 2 of the Apple Container backend stack: the host-side capability relay -# and the guest init shim. Neither is wired into a user-visible runtime yet, so -# there is nothing to exercise end to end here — and there is no GitHub-hosted -# macOS runner that could, because Apple Container needs -# Virtualization.framework and hosted macOS reports `kern.hv_support=0`. +# Hosted CI coverage for the Apple Container backend. # -# What this workflow can prove without a live VM is exactly what the design -# depends on: the guest binary compiles for Linux arm64, its relay and init -# logic behave, and the compiled-in host/guest contract halves agree. +# Nothing here launches a VM, and nothing here can: Apple Container needs +# Virtualization.framework, and GitHub-hosted macOS runners are themselves +# virtualized and report `kern.hv_support=0`. Live end-to-end validation runs on +# a self-hosted bare-metal Apple Silicon runner via +# `.github/workflows/smoke-apple-container.yml`. +# +# What this workflow proves without a live VM is the part the design depends on: +# the guest binary compiles for Linux arm64 and its relay logic behaves, the +# compiled-in host/guest contract halves agree, the init image build encodes the +# same contract, and the host-side runtime selection, compatibility matrix, +# infrastructure publication, mounts, environment, and lifecycle ordering are +# all what they claim to be. on: workflow_dispatch: @@ -18,12 +23,15 @@ on: - '.github/workflows/test-apple-container.yml' - 'guest/apple-container-init/**' - 'src/apple-container/**' + - 'src/apple-container-runtime-backend.ts' + - 'containers/apple-init/**' + - 'scripts/build-apple-init-image.sh' permissions: contents: read concurrency: - group: apple-container-transport-${{ github.ref }} + group: apple-container-${{ github.ref }} cancel-in-progress: true jobs: @@ -76,8 +84,8 @@ jobs: grep -q 'ELF 64-bit LSB executable, ARM aarch64' file awf-apple-guest-init | grep -q 'statically linked' - host-transport: - name: Host transport unit tests + host-runtime: + name: Host runtime unit tests runs-on: ubuntu-24.04 timeout-minutes: 15 steps: @@ -100,7 +108,72 @@ jobs: run: npx eslint src/apple-container # transport-contract-sync.test.ts parses guest/apple-container-init/ - # contract.go, so a divergence between the host and guest halves of the - # contract fails here instead of inside a VM nobody can reproduce. + # contract.go, and init-image-contract.test.ts parses + # containers/apple-init/Dockerfile, so a divergence between the host half, + # the guest half, and the shipped init image fails here instead of inside a + # VM nobody can reproduce. - name: Run Apple Container unit tests - run: npx jest src/apple-container --testTimeout=15000 + run: | + npx jest \ + src/apple-container \ + src/apple-container-runtime-backend.test.ts \ + src/apple-container-runtime-selection.test.ts \ + --testTimeout=15000 + + - name: Verify the generated JSON Schema is in sync + run: | + npm run generate:schema + git diff --exit-code docs/awf-config.schema.json src/awf-config-schema.json + + init-image: + name: Init image build (Linux arm64) + runs-on: ubuntu-24.04 + timeout-minutes: 20 + # Requires a digest-pinned reference to Apple's own vminit image. There is + # deliberately no default, so forks and PRs from forks skip this job rather + # than building against a floating base. + if: vars.APPLE_VMINIT_IMAGE != '' + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4 + + - 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: Build the AWF Apple init image + env: + AWF_VMINIT_IMAGE: ${{ vars.APPLE_VMINIT_IMAGE }} + run: ./scripts/build-apple-init-image.sh awf-apple-init:ci + + # The image has no shell, so the only way to prove the relocation happened + # is to export and inspect it. + - name: Verify Apple's init was relocated and the shim installed + run: | + set -euo pipefail + id=$(docker create --platform linux/arm64 awf-apple-init:ci) + trap 'docker rm -f "$id" >/dev/null 2>&1 || true' EXIT + docker export "$id" > init.tar + tar -tf init.tar | grep -qx 'sbin/vminitd' + tar -tf init.tar | grep -qx 'sbin/vminitd.apple' + mkdir -p extracted + tar -xf init.tar -C extracted sbin/vminitd + file extracted/sbin/vminitd | tee /dev/stderr | \ + grep -q 'ELF 64-bit LSB executable, ARM aarch64' + file extracted/sbin/vminitd | grep -q 'statically linked' + + - name: Verify the recorded contract labels match the host half + run: | + set -euo pipefail + src=src/apple-container/transport-capabilities.ts + min=$(sed -n "s/^export const APPLE_CONTAINER_TRANSPORT_MIN_CLI_VERSION = '\\([^']*\\)';$/\\1/p" "$src") + max=$(sed -n "s/^export const APPLE_CONTAINER_TRANSPORT_MAX_CLI_VERSION_EXCLUSIVE = '\\([^']*\\)';$/\\1/p" "$src") + labels=$(docker image inspect awf-apple-init:ci --format '{{json .Config.Labels}}') + echo "$labels" + echo "$labels" | grep -q "\"io.github.gh-aw-firewall.apple-init.cli-min-version\":\"$min\"" + echo "$labels" | grep -q "\"io.github.gh-aw-firewall.apple-init.cli-max-version-exclusive\":\"$max\"" + diff --git a/CLAUDE.md b/CLAUDE.md index 77003cb08..c2d2f2a9e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,6 +48,8 @@ The system is orchestrated by `src/cli.ts` and managed by `src/docker-manager.ts - **[docs/INTEGRATION-TESTS.md](docs/INTEGRATION-TESTS.md)** - Integration test coverage guide with gap analysis - **[docs/enclaves-architecture.md](docs/enclaves-architecture.md)** - Unified enclave architecture, MCP gateway handoff, and coverage notes - **[docs/cloud-hypervisor-foundation.md](docs/cloud-hypervisor-foundation.md)** - Cloud Hypervisor v53.0 microVM backend (preview): REST API client, secure launcher (network-namespace join + privilege drop + Landlock/seccomp in place of a jailer), manager/backend, GitHub-hosted Ubuntu x86_64 KVM runners only +- **[docs/apple-container-runtime.md](docs/apple-container-runtime.md)** - Apple Container microVM backend (preview): `--container-runtime apple-container`, self-hosted bare-metal Apple Silicon macOS 26+ only, `--network none` guest reached solely through the published-socket capability transport, infrastructure-only Docker Compose with loopback-scoped port publication +- **[docs/apple-container-transport.md](docs/apple-container-transport.md)** - Host/guest capability transport design and threat model for the Apple Container backend ## Development Workflow diff --git a/README.md b/README.md index 0d651ea1b..79ba41d35 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ See [GitHub Actions](docs/github_actions.md) for advanced setup and `awf logs su - [Auth Doctor Updater workflow](.github/workflows/auth-doctor-updater.md) — daily/manual audit that opens bounded PRs with evidence-backed authentication and API-proxy documentation corrections - [Image verification](docs/image-verification.md) — cosign signature verification - [Cloud Hypervisor integration (preview)](docs/cloud-hypervisor-foundation.md) — Cloud Hypervisor v53.0 microVM backend: explicit opt-in, GitHub-hosted Ubuntu x86_64 KVM runners only, operator-managed artifacts with mandatory SHA-256 digests, Landlock/seccomp-confined launcher in place of a jailer, fail-closed egress, mandatory API proxy credential isolation +- [Apple Container runtime (preview)](docs/apple-container-runtime.md) — Apple Virtualization.framework microVM backend: explicit opt-in, self-hosted bare-metal Apple Silicon macOS 26+ only (GitHub-hosted macOS fails preflight), NIC-less guest whose only egress is a closed allowlist of published capability sockets, digest-pinned native arm64 images, supported/unsupported feature matrix ## Development diff --git a/action.yml b/action.yml index 90345731a..00a146869 100644 --- a/action.yml +++ b/action.yml @@ -27,19 +27,46 @@ runs: using: 'composite' steps: - name: Validate runner OS and architecture + id: platform shell: bash run: | - if [ "$RUNNER_OS" != "Linux" ]; then - echo "::error::This action only supports Linux runners. Current OS: $RUNNER_OS" - exit 1 - fi + set -euo pipefail - # Validate architecture (only x64 is supported) + # Two supported install targets: + # Linux x64 — the default AWF runtime (Docker + Squid + agent container). + # macOS arm64 — installs the CLI only, for the Apple Container preview + # runtime. That runtime additionally requires Apple Silicon bare metal + # on macOS 26+ with kern.hv_support=1 and the `container` CLI + # installed and running. GitHub-hosted macOS runners are virtualized + # and report kern.hv_support=0, so `awf` fails preflight there rather + # than falling back to another runtime. Installing the binary here is + # therefore NOT a claim that hosted macOS can run the firewall. ARCH=$(uname -m) - if [ "$ARCH" != "x86_64" ] && [ "$ARCH" != "amd64" ]; then - echo "::error::This action only supports x64 architecture. Current architecture: $ARCH" - exit 1 - fi + case "$RUNNER_OS" in + Linux) + if [ "$ARCH" != "x86_64" ] && [ "$ARCH" != "amd64" ]; then + echo "::error::AWF supports x64 Linux runners. Current architecture: $ARCH" + exit 1 + fi + BINARY_NAME="awf-linux-x64" + BINARY_FORMAT="ELF.*executable" + ;; + macOS) + if [ "$ARCH" != "arm64" ] && [ "$ARCH" != "aarch64" ]; then + echo "::error::AWF supports arm64 macOS runners only. Current architecture: $ARCH" + exit 1 + fi + BINARY_NAME="awf-darwin-arm64" + BINARY_FORMAT="Mach-O 64-bit executable arm64" + ;; + *) + echo "::error::AWF supports Linux and macOS runners. Current OS: $RUNNER_OS" + exit 1 + ;; + esac + + echo "binary_name=$BINARY_NAME" >> "$GITHUB_OUTPUT" + echo "binary_format=$BINARY_FORMAT" >> "$GITHUB_OUTPUT" - name: Install awf id: install @@ -47,11 +74,12 @@ runs: env: INPUT_VERSION: ${{ inputs.version }} GITHUB_TOKEN: ${{ github.token }} + BINARY_NAME: ${{ steps.platform.outputs.binary_name }} + BINARY_FORMAT: ${{ steps.platform.outputs.binary_format }} run: | set -euo pipefail REPO="github/gh-aw-firewall" - BINARY_NAME="awf-linux-x64" INSTALL_DIR="${RUNNER_TEMP}/awf-bin" # Build auth headers for GitHub API (Bearer is the recommended format for GITHUB_TOKEN) @@ -143,6 +171,7 @@ runs: ENCLAVE_SCRIPT_DIGEST="$(extract_digest enclave-script || true)" ENCLAVE_AGENT_DIGEST="$(extract_digest enclave-agent || true)" ENCLAVE_MCP_SERVER_DIGEST="$(extract_digest enclave-mcp-server || true)" + APPLE_INIT_DIGEST="$(extract_digest apple-init || true)" [ -n "${SQUID_DIGEST:-}" ] && DIGEST_ENTRIES+=("squid=${SQUID_DIGEST}") [ -n "${AGENT_DIGEST:-}" ] && DIGEST_ENTRIES+=("agent=${AGENT_DIGEST}") @@ -152,6 +181,7 @@ runs: [ -n "${ENCLAVE_SCRIPT_DIGEST:-}" ] && DIGEST_ENTRIES+=("enclave-script=${ENCLAVE_SCRIPT_DIGEST}") [ -n "${ENCLAVE_AGENT_DIGEST:-}" ] && DIGEST_ENTRIES+=("enclave-agent=${ENCLAVE_AGENT_DIGEST}") [ -n "${ENCLAVE_MCP_SERVER_DIGEST:-}" ] && DIGEST_ENTRIES+=("enclave-mcp-server=${ENCLAVE_MCP_SERVER_DIGEST}") + [ -n "${APPLE_INIT_DIGEST:-}" ] && DIGEST_ENTRIES+=("apple-init=${APPLE_INIT_DIGEST}") if [ "${#DIGEST_ENTRIES[@]}" -gt 0 ]; then DIGEST_CSV="$(IFS=,; echo "${DIGEST_ENTRIES[*]}")" @@ -190,7 +220,12 @@ runs: # Normalize to lowercase for comparison EXPECTED_SUM=$(echo "$EXPECTED_SUM" | tr '[:upper:]' '[:lower:]') - ACTUAL_SUM=$(sha256sum "$INSTALL_DIR/awf" | awk '{print $1}' | tr '[:upper:]' '[:lower:]') + if command -v sha256sum >/dev/null 2>&1; then + ACTUAL_SUM=$(sha256sum "$INSTALL_DIR/awf" | awk '{print $1}' | tr '[:upper:]' '[:lower:]') + else + # macOS ships `shasum`, not `sha256sum`. + ACTUAL_SUM=$(shasum -a 256 "$INSTALL_DIR/awf" | awk '{print $1}' | tr '[:upper:]' '[:lower:]') + fi if [ "$EXPECTED_SUM" != "$ACTUAL_SUM" ]; then echo "::error::Checksum verification failed!" @@ -201,9 +236,11 @@ runs: echo "Checksum verification passed ✓" - # Verify it's a valid ELF executable - if ! file "$INSTALL_DIR/awf" | grep -q "ELF.*executable"; then - echo "::error::Downloaded file is not a valid Linux executable" + # Verify the binary really is a native executable for this runner, so a + # mismatched or truncated download fails here rather than at first use. + if ! file "$INSTALL_DIR/awf" | grep -q "$BINARY_FORMAT"; then + echo "::error::Downloaded file is not a valid $RUNNER_OS executable (expected: $BINARY_FORMAT)" + file "$INSTALL_DIR/awf" exit 1 fi @@ -219,8 +256,12 @@ runs: echo "Successfully installed awf ${VERSION} to $INSTALL_DIR" echo "awf is now available in PATH for subsequent steps" + # Linux only. On macOS the Apple Container preview runtime pulls the agent + # and init images through Apple Container's own image store, which is + # independent of Docker's — a Docker pre-pull here would not populate it, + # and AWF verifies presence rather than assuming it. - name: Pull Docker images - if: ${{ inputs.pull-images == 'true' }} + if: ${{ inputs.pull-images == 'true' && runner.os == 'Linux' }} shell: bash env: IMAGE_TAG: ${{ steps.install.outputs.image_tag }} diff --git a/containers/apple-init/Dockerfile b/containers/apple-init/Dockerfile new file mode 100644 index 000000000..d9c28188a --- /dev/null +++ b/containers/apple-init/Dockerfile @@ -0,0 +1,108 @@ +# AWF Apple Container init image. +# +# Apple's containerization runtime executes `/sbin/vminitd` from a *separate* +# init image (`container run --init-image ...`), not from the workload image. +# That is the only place AWF can install code that runs inside a NIC-less guest +# before the workload starts, so it is where the layer-2 capability relay lives. +# +# The transformation is deliberately minimal and auditable: +# +# 1. Take Apple's own `vminit` image, unmodified and digest-pinned. +# 2. Move Apple's `/sbin/vminitd` aside to `/sbin/vminitd.apple`. +# 3. Install the AWF shim at `/sbin/vminitd`. +# +# The shim binds every capability's loopback listener and then `syscall.Exec`s +# `/sbin/vminitd.apple`, so Apple's real init keeps PID 1 and its reaping, +# signal, and lifecycle semantics are exactly what Apple shipped. Nothing else +# in the image is added, removed, or rewritten. +# +# Version coupling: the shim execs Apple's init from a fixed path inside this +# image, so the image is only valid for the `container` CLI range the host half +# validates (`APPLE_CONTAINER_TRANSPORT_MIN_CLI_VERSION` .. +# `APPLE_CONTAINER_TRANSPORT_MAX_CLI_VERSION_EXCLUSIVE`). Both bounds and the +# transport contract version are recorded as labels so a built image can be +# checked against the CLI it will run under without unpacking it. +# +# Reproducibility: every base image is digest-pinned, the shim module has no +# third-party dependencies, and the build uses `-trimpath -buildvcs=false` with +# cgo disabled — the same flags as `guest/apple-container-init/build.sh`. That +# script remains the local/CI determinism harness (it pins its own Go toolchain +# and verifies a stable digest); this Dockerfile is the release artifact path +# and uses the repository's shared pinned Go image, so the two produce +# functionally identical binaries from identical sources rather than +# byte-identical ones across differing toolchains. +# +# Build with `scripts/build-apple-init-image.sh`, which enforces the +# digest-pinned `AWF_VMINIT_IMAGE` requirement before invoking Docker. + +# Apple's unmodified init image. MUST be a digest-pinned reference: a floating +# tag would let the base image move underneath a shim that hard-codes where +# Apple's init lives, turning a silent mismatch into a guest with no +# capabilities. Enforced by the assemble stage below. +ARG AWF_VMINIT_IMAGE + +# Supported Apple `container` CLI range this init image is validated against. +# Kept in sync with src/apple-container/transport-capabilities.ts. +ARG AWF_CLI_MIN_VERSION=0.4.0 +ARG AWF_CLI_MAX_VERSION_EXCLUSIVE=1.0.0 +ARG AWF_TRANSPORT_CONTRACT_VERSION=1 + +FROM golang:1.26.6-alpine@sha256:af8d6740070b8906d12eae1c3e3ea0957fb63f492051ea05e354c38ef9fe88df AS toolchain + +# ── Guest relay shim ───────────────────────────────────────────────────────── +FROM toolchain AS shim-build +WORKDIR /src +# Sources only; the module declares no third-party requirements, so there is no +# download step and nothing outside this repository enters the binary. +COPY guest/apple-container-init/go.mod ./ +COPY guest/apple-container-init/*.go ./ +# GOARCH is hard-coded rather than taken from TARGETARCH: an Apple Container +# guest is always native arm64 and Rosetta translation is never used, so an +# amd64 build would be a silently wrong artifact rather than a useful one. +RUN set -eux; \ + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 \ + go build -trimpath -buildvcs=false -ldflags='-s -w' -o /out/vminitd .; \ + test -s /out/vminitd + +FROM ${AWF_VMINIT_IMAGE} AS vminit + +# ── Assemble ───────────────────────────────────────────────────────────────── +FROM toolchain AS assemble +ARG AWF_VMINIT_IMAGE +COPY --from=vminit / /rootfs +COPY --from=shim-build /out/vminitd /shim/vminitd +RUN set -eux; \ + case "${AWF_VMINIT_IMAGE}" in \ + *@sha256:*) : ;; \ + *) echo "AWF_VMINIT_IMAGE must be digest-pinned (got '${AWF_VMINIT_IMAGE}')" >&2; exit 1 ;; \ + esac; \ + # Apple's init must be present and executable, and must not already have + # been relocated: rebuilding on top of an AWF init image would install the + # shim over itself and lose Apple's binary entirely. + test -x /rootfs/sbin/vminitd; \ + test ! -e /rootfs/sbin/vminitd.apple; \ + mv /rootfs/sbin/vminitd /rootfs/sbin/vminitd.apple; \ + install -m 0755 /shim/vminitd /rootfs/sbin/vminitd; \ + test -x /rootfs/sbin/vminitd; \ + test -x /rootfs/sbin/vminitd.apple + +# ── Final image ────────────────────────────────────────────────────────────── +# +# `scratch` plus a single flattened copy: the init image is a small, purpose- +# built filesystem (Apple's init and its minimal support files), and flattening +# keeps the published artifact a faithful, inspectable snapshot of exactly what +# the assemble stage produced. +FROM scratch +ARG AWF_VMINIT_IMAGE +ARG AWF_CLI_MIN_VERSION +ARG AWF_CLI_MAX_VERSION_EXCLUSIVE +ARG AWF_TRANSPORT_CONTRACT_VERSION +COPY --from=assemble /rootfs/ / +LABEL org.opencontainers.image.title="AWF Apple Container init" +LABEL org.opencontainers.image.description="Apple vminit with the AWF capability relay shim installed at /sbin/vminitd" +LABEL org.opencontainers.image.source="https://github.com/github/gh-aw-firewall" +LABEL io.github.gh-aw-firewall.apple-init.base="${AWF_VMINIT_IMAGE}" +LABEL io.github.gh-aw-firewall.apple-init.contract-version="${AWF_TRANSPORT_CONTRACT_VERSION}" +LABEL io.github.gh-aw-firewall.apple-init.cli-min-version="${AWF_CLI_MIN_VERSION}" +LABEL io.github.gh-aw-firewall.apple-init.cli-max-version-exclusive="${AWF_CLI_MAX_VERSION_EXCLUSIVE}" +ENTRYPOINT ["/sbin/vminitd"] diff --git a/docs/apple-container-runtime.md b/docs/apple-container-runtime.md new file mode 100644 index 000000000..5ee49ab6c --- /dev/null +++ b/docs/apple-container-runtime.md @@ -0,0 +1,296 @@ +# Apple Container runtime (preview) + +`--container-runtime apple-container` runs the AWF agent inside an Apple +Virtualization.framework VM launched by Apple's [`container`][apple-container] +CLI, instead of in a Docker container. AWF's infrastructure — Squid, the API +proxy sidecar, the CLI proxy — keeps running under Docker Compose exactly as it +does for every other runtime; only the agent crosses the hypervisor boundary. + +> **Preview.** Requires an explicit `--apple-container-preview` opt-in and a +> self-hosted bare-metal Apple Silicon runner. Unit- and contract-tested in +> hosted CI; end-to-end behaviour is validated only by the self-hosted live +> smoke workflow described below. + +## Why this runtime is shaped the way it is + +The agent VM is launched with `--network none`, so it has **zero network +interfaces**. That single flag is the confinement: + +- Direct IP egress, DNS, DNS-over-HTTPS, IPv6, raw sockets, the + `169.254.169.254` metadata address, and every host network path are + unreachable *by construction*, not by rule. There is no firewall to + misconfigure and no rule to race, because there is no interface to send on. +- Apple Container has no daemon-side forced-proxy setting, so `HTTP_PROXY` is + advisory here — which does not matter, because a request that ignores the + proxy has nowhere to go. + +Omitting `--network` would attach Apple's default vmnet network and hand the +agent unfiltered egress, so AWF always emits the flag explicitly and asserts it +again immediately before the VM is created. + +A NIC-less guest still has to reach AWF's own services. The only transport Apple +Container offers such a VM is `--publish-socket`, which exposes one host Unix +socket at one guest path. AWF relays a **closed allowlist** of capabilities over +it — see [apple-container-transport.md](apple-container-transport.md) for the +transport's own design and threat model. + +``` + macOS host │ VM boundary │ guest (Linux arm64) + + Docker Compose │ │ + squid-proxy ──publish──▶ 127.0.0.1:3128 ◀─┤ AWF relay │ + api-proxy ──publish──▶ 127.0.0.1:1000x ◀┤ ↕ Unix ├──▶ 127.0.0.1:3128 ──▶ agent + cli-proxy ──publish──▶ 127.0.0.1:11000 ◀┤ sockets │ 127.0.0.1:1000x + │ │ 127.0.0.1:11000 +``` + +## Requirements + +| Requirement | Why | +| --- | --- | +| **Self-hosted, bare-metal Apple Silicon runner** | Virtualization.framework needs real hardware virtualization. | +| **macOS 26 or newer** | The `container` CLI's supported baseline. | +| **`kern.hv_support=1`** | Proves the host can actually create a VM. | +| **Apple `container` CLI ≥ 0.4.0, < 1.0.0** | `--publish-socket` and `--init-image` are load-bearing and only guaranteed from 0.4.0. The upper bound is exclusive because a major release may move Apple's own init inside the init image, which would silently break the guest handoff. | +| **`container system start` healthy** | The service must be running before AWF will launch anything. | +| **Local Unix-socket Docker daemon** | AWF's infrastructure still runs under Docker Compose, and its ports must be publishable to macOS loopback. | +| **Digest-pinned `agent` and `apple-init` images** | Apple Container maintains its own image store, independent of Docker's — see [Images](#images). | + +### GitHub-hosted macOS runners do not work + +GitHub-hosted macOS runners are themselves virtual machines and report +`kern.hv_support=0`. AWF's preflight fails with a `hypervisor` cause code and +**does not fall back** to another runtime — a silent downgrade would run the +workload under a different, unstated isolation model. + +`actions/setup-awf` will install the `awf-darwin-arm64` binary on a macOS arm64 +runner, but installing the CLI is not a claim that the runtime will start there. +On a hosted runner it will fail preflight. + +## Setup + +```bash +# One-time, on the self-hosted runner: +brew install --cask container # or install from https://github.com/apple/container +container system start +container system status +``` + +The `container` service must be running as the same user the Actions runner runs +as; it is per-user, not system-wide. Keep it running across jobs +(`container system start` is idempotent) rather than starting and stopping it +per run. + +For a self-hosted Actions runner, apply an `apple-container` label **only** to +hosts where `sysctl -n kern.hv_support` prints `1`. The live smoke workflow +re-checks this and fails if the label is on a virtualized host. + +## Selecting the runtime + +```bash +sudo awf \ + --container-runtime apple-container \ + --apple-container-preview \ + --image-tag "0.30.0,agent=sha256:...,apple-init=sha256:..." \ + --allow-domains api.github.com \ + --enable-api-proxy \ + -- 'npx @github/copilot --prompt "..."' +``` + +Or in `awf.config.yaml`: + +```yaml +container: + containerRuntime: apple-container + images: + agent: ghcr.io/github/gh-aw-firewall/agent:0.30.0@sha256:... + appleInit: ghcr.io/github/gh-aw-firewall/apple-init:0.30.0@sha256:... + squid: ghcr.io/github/gh-aw-firewall/squid:0.30.0@sha256:... + apiProxy: ghcr.io/github/gh-aw-firewall/api-proxy:0.30.0@sha256:... +appleContainer: + previewEnabled: true + cpus: 4 + memory: 8G +``` + +### Options + +| Flag | Config | Default | Notes | +| --- | --- | --- | --- | +| `--apple-container-preview` | `appleContainer.previewEnabled` | `false` | Required. Without it the backend refuses to resolve. | +| `--apple-container-cpus` | `appleContainer.cpus` | `4` | Guest vCPUs. | +| `--apple-container-memory` | `appleContainer.memory` | `8G` | Integer with an optional `K`/`M`/`G`/`T`/`P` suffix. | +| `--apple-container-init-image` | `appleContainer.initImage` | derived from registry/tag | Must be digest-pinned. | +| `--apple-container-cli` | `appleContainer.cliPath` | `container` on `PATH` | Absolute path when the CLI is not on `PATH`. | + +## Supported and unsupported + +| Feature | Status | +| --- | --- | +| Domain allowlisting via Squid | ✅ The guest's only egress path. | +| API proxy credential isolation (OpenAI, Anthropic, Copilot, Gemini) | ✅ Unauthenticated from the guest; the real key stays in the host sidecar. | +| CLI proxy / DIFC safe outputs | ✅ Bridged as a capability when `--difc-proxy-host` is set. | +| Workspace and `${RUNNER_TEMP}/gh-aw` writes | ✅ Mounted read-write at their own absolute host paths. | +| Agent timeout, signals, exit codes | ✅ Exit codes propagate verbatim; a timeout kills the VM and reports `124`. | +| Diagnostics and `--keep-containers` | ✅ See [Diagnostics](#diagnostics-and-preservation). | +| **Google Vertex AI** | ❌ The Vertex provider port is not in the transport allowlist. Rejected at validation rather than silently losing its endpoint. | +| **Enclaves** | ❌ The enclave MCP gateway is a Docker-network peer that has not been proven reachable from a NIC-less guest. | +| **`--topology-attach`** | ❌ Externally owned peers are not published to macOS loopback, so they cannot be bridged. | +| **Docker-in-Docker / ARC split filesystems** | ❌ The guest never receives a Docker socket. | +| **`--enable-host-access`, `--allow-host-ports`** | ❌ Only allowlisted capability sockets cross the boundary. | +| **`--legacy-security`** | ❌ Host and container iptables rules govern nothing here. | +| **DNS-over-HTTPS** | ❌ The guest resolves no names at all. | +| **`filesystem.allowWrite`** | ❌ Not yet implemented for this runtime. | +| **`--volume`, `--ssl-bump`, `--tty`, `--build-local`** | ❌ See the error messages, each of which names the reason. | +| **`agentImage: act` / custom base images** | ❌ Not published as native arm64, and Rosetta translation is never used. | + +Every unsupported combination is refused during option validation or preflight, +before any container, VM, or socket exists. Nothing degrades silently. + +## Images + +Apple Container keeps its **own image store**, independent of Docker's. Two +consequences: + +1. A Docker pre-pull (including `actions/setup-awf` with `pull-images: true`) + does not populate it. AWF pulls the agent and init images through + `container image pull --platform linux/arm64`. +2. `--skip-pull` is honoured by *verification*, not by assumption: AWF confirms + each image is present in Apple's store and fails with the exact + `container image pull` command to run if it is not. + +Both references must be digest-pinned. A floating tag would let the registry +decide what runs inside the VM between the operator's decision and the launch, +with no daemon-side content trust to fall back on. + +The **`apple-init`** image is Apple's own `vminit` with `/sbin/vminitd` moved to +`/sbin/vminitd.apple` and the AWF capability relay installed in its place. It is +built by [`containers/apple-init/Dockerfile`](../containers/apple-init/Dockerfile) +via [`scripts/build-apple-init-image.sh`](../scripts/build-apple-init-image.sh), +and carries labels recording the Apple base image, the transport contract +version, and the supported `container` CLI range. + +Publishing it requires an `APPLE_VMINIT_IMAGE` repository variable holding a +digest-pinned reference to Apple's `vminit`. When that variable is unset the +release job is skipped and no `apple-init` digest is published — which means the +runtime simply cannot be selected from that release, rather than falling back to +an unknown init. + +## Filesystem + +The guest runs the agent image's own root filesystem **read-only** and receives a +short, explicit list of writable host directories: + +| Guest path | Host source | Mode | +| --- | --- | --- | +| *(workspace path, unchanged)* | `$GITHUB_WORKSPACE` | rw | +| *(gh-aw path, unchanged)* | `${RUNNER_TEMP}/gh-aw`, when it exists | rw | +| `/awf/home` | `/apple-container/home` | rw | +| `/tmp` | `/apple-container/tmp` | rw | +| `/awf/home/.copilot/logs` | the run's agent log directory | rw | +| `/awf/home/.copilot/session-state` | the run's session-state directory | rw | + +The workspace and `gh-aw` directories are mounted at their *own* absolute paths +because gh-aw passes absolute runner paths through both its environment and its +command line. + +The capability sockets themselves live in a run-scoped `0700` directory under +`/tmp` (`/tmp/awf-apple-`), **not** under the work directory. macOS caps +a Unix socket path at 104 bytes, and a realistic runner work directory +(`${RUNNER_TEMP}/awf-/...`) exceeds that budget, which would make the +relay unbindable. `/tmp` resolving to the 13-byte `/private/tmp` leaves ample +room; its world-writable sticky permissions are not a weakness because the +directory is created with a non-recursive `mkdir` (a pre-existing path is a hard +failure, never a reuse), forced to `0700`, and verified for type, ownership, +mode, and self-resolution before any socket is bound — and binding never +unlinks, so a squatted path fails closed. + +Nothing else is mounted. There is no `/host` chroot, no sysroot, no `/etc` +cherry-picking, and no Docker socket. Host credential stores — `~/.ssh`, +`~/.aws`, `~/.docker`, the login keychain — are absent because they were never +mounted, not because they were shadowed. This is deliberately unlike the Docker +path's credential-hiding overlays, which have to temporarily mask live host +files; there is nothing to mask when the mount was never made. + +The workload runs as the host `uid:gid`, so files it writes into the workspace +are owned by the runner user, and `$HOME` points at a run-scoped directory that +same uid owns. + +### Entrypoint + +The agent image's own entrypoint is Docker-specific end to end: it waits on the +`awf-iptables-init` ready file, remaps `awfuser`, rewrites `/etc/resolv.conf`, +chroots into `/host`, and drops `SYS_CHROOT`/`SYS_ADMIN`. None of that exists — +or is needed — in a VM whose isolation *is* the VM. AWF overrides the entrypoint +to `/bin/bash -lc ` rather than adapting it, so no half-applicable +Docker assumption runs. + +## Threat model + +| Property | How it holds | +| --- | --- | +| No unfiltered egress | `--network none` on every launch. Layer 1 defaults to it, the transport merge re-asserts it, and the backend checks it once more immediately before `container create`. Omission is treated as a security bug, not a missing default. | +| Only AWF capabilities cross the boundary | A frozen allowlist with no extension point. A socket publication the plan did not authorise — including `/var/run/docker.sock` — is refused. | +| Relays cannot be repointed | Upstreams must be loopback or private **IP literals**. Hostnames are rejected, so no DNS answer can move a capability, and link-local (including the metadata address), multicast, and public addresses are refused. | +| Infrastructure is not exposed to the network | Every port is published to `127.0.0.1` only, replacing (not supplementing) the wildcard mapping the Docker topology uses. A collision on those ports is reported by name before Compose starts. | +| No dangerous capabilities | `NET_ADMIN`, `NET_RAW`, `SYS_ADMIN`, `SYS_MODULE`, `SYS_RAWIO` are dropped unconditionally; adding any of them, or `ALL`, is refused rather than overridden. | +| Native arm64 only | The architecture is fixed at `arm64` and asserted before launch. Rosetta translation is never requested. | +| Credentials stay host-side | API proxy capabilities are unauthenticated from the guest's point of view. The real key is injected by the host sidecar and never enters guest env, argv, files, or logs; the guest environment builder re-asserts this and refuses to launch otherwise. | +| No silent fallback | An ineligible host, an unsupported CLI, an occupied port, an unpinned image, or a failed transport aborts the run with an actionable error. | + +## Diagnostics and preservation + +Apple Container is a VM-per-container runtime, so a failed agent has log surfaces +a Docker mental model does not cover. AWF collects, into +`/apple-container` or `/diagnostics/apple-container`: + +- `container-boot.log` — kernel and init output, where a VM that never reached + the entrypoint fails. +- `container-stdio.log` — the init process's stdio. +- `system.log` — the host service log, where the daemon records why a VM was + refused. +- `container-inspect.json`, `containers.json`, `system-status.json`. +- `transport-stats.json` — per-capability counters (ids, guest ports, upstreams, + byte and connection totals). The relay retains no payload, so this cannot + carry credential material. + +Every capture is bounded and `--follow` is never used, so collection always +terminates. + +`--keep-containers` preserves the VM (inspect with `container inspect`, remove +with `container delete`) and the run directory, and writes a transport summary +to `/tmp/awf-apple-/transport-summary.json` (whose path is logged, +because it lives outside the work directory) — but **every capability socket is +still unlinked first**. A preserved run never leaves a live path into AWF's +credential-injecting sidecar. + +## Cleanup + +Normal teardown quiesces the guest before removing the transport, so a running +workload never observes its capabilities disappearing mid-request: + +1. `container stop` (10s grace), escalating to `container kill` if it does not + exit. +2. `container delete --force`. +3. Transport shutdown: every relay closed, every socket unlinked, the socket + directory under `/tmp` removed non-recursively. +4. Docker Compose infrastructure torn down by the existing cleanup path. + +A `--agent-timeout` expiry kills the VM explicitly — killing the attached +`container start` client alone would leave the guest running — and reports exit +code `124`. `SIGINT`/`SIGTERM` route through the same teardown. + +## Validation status + +| Layer | Coverage | +| --- | --- | +| Runtime selection, compatibility matrix, Compose publication, mounts, environment, credential filtering, image semantics, lifecycle ordering, rollback, timeout, diagnostics | Unit tests, hosted CI (`.github/workflows/test-apple-container.yml`). | +| Guest relay and init shim | Go unit tests plus a deterministic Linux arm64 build, hosted CI. | +| Host/guest contract agreement | `transport-contract-sync.test.ts` parses `contract.go`; `init-image-contract.test.ts` parses the init Dockerfile. | +| Init image layout and labels | Built and inspected in CI when `APPLE_VMINIT_IMAGE` is configured. | +| **Live VM behaviour** | `.github/workflows/smoke-apple-container.yml`, self-hosted bare metal only, `workflow_dispatch` by reviewed SHA into the protected `apple-container-live-smoke` environment. It has no pull request trigger: a public repository must not execute unreviewed code on a persistent self-hosted host. **Not yet exercised in this repository's CI.** | + +Treat anything in the last row as unvalidated on real hardware until that +workflow has run green on a bare-metal runner. + +[apple-container]: https://github.com/apple/container diff --git a/docs/apple-container-transport.md b/docs/apple-container-transport.md index b9c2db60c..cbc2c527d 100644 --- a/docs/apple-container-transport.md +++ b/docs/apple-container-transport.md @@ -1,9 +1,9 @@ -# Apple Container capability transport (internal) +# Apple Container capability transport -> **Internal design note.** Nothing described here is reachable from the CLI, -> the config schema, or the runtime resolver. This is layer 2 of the Apple -> Container backend stack; the selectable runtime lands in a later layer. There -> is intentionally no user-facing documentation yet. +> **Design note.** This describes the transport itself — the mechanism by which +> a NIC-less guest reaches AWF's services. For the user-facing runtime +> (requirements, setup, options, supported and unsupported features, cleanup, +> diagnostics), see [apple-container-runtime.md](apple-container-runtime.md). ## Problem diff --git a/docs/awf-config-spec.md b/docs/awf-config-spec.md index e2ab04ed5..b288ad437 100644 --- a/docs/awf-config-spec.md +++ b/docs/awf-config-spec.md @@ -77,6 +77,7 @@ following top-level properties. All are OPTIONAL: | `security` | object | Security and isolation settings | | `container` | object | Container and Docker settings | | `cloudHypervisor` | object | Cloud Hypervisor v53.0 microVM preview settings (see §4.2) | +| `appleContainer` | object | Apple Container microVM preview settings (see §4.3) | | `chroot` | object | Chroot execution overrides for split-filesystem ARC/DinD runners | | `dind` | object | Bootstrap helpers for ARC/DinD split runner/daemon filesystems | | `runner` | object | Runner topology declaration (standard vs. ARC/DinD) | @@ -112,8 +113,8 @@ a writable home MUST have the backing host directory `$GITHUB_WORKSPACE/.awf-home` created before AWF starts and MUST then list the guest path `/workspace/.awf-home` in `allowWrite`; otherwise planning fails because the path does not exist within a writable export. AWF rejects -`filesystem.allowWrite` with the sbx runtime and with Docker-in-Docker agent -execution. +`filesystem.allowWrite` with the sbx runtime, with the Apple Container runtime, +and with Docker-in-Docker agent execution. ### 4.2 Cloud Hypervisor microVM preview @@ -148,6 +149,53 @@ assets, but are not production defaults and are never auto-downloaded. See [docs/cloud-hypervisor-foundation.md](./cloud-hypervisor-foundation.md#part-14--ci-workflow) for the complete CI workflow specification and troubleshooting reference. +### 4.3 Apple Container microVM preview + +The `appleContainer` surface configures the Apple Virtualization.framework +workload runtime and requires explicit `--apple-container-preview` opt-in plus +`container.containerRuntime: "apple-container"` to execute a workload. The +supported host target is a self-hosted **bare-metal Apple Silicon** runner on +macOS 26 or newer with `kern.hv_support=1`, and an Apple `container` CLI in the +range `>=0.4.0 <1.0.0`. GitHub-hosted macOS runners are themselves virtualized +and report `kern.hv_support=0`; they are rejected by +[`src/apple-container/host-facts.ts`](../src/apple-container/host-facts.ts) +with no fallback to another runtime. + +The agent VM is launched with `--network none`, so it has zero network +interfaces. Direct IP egress, DNS, DNS-over-HTTPS, IPv6, raw sockets, the cloud +metadata address, and every host network path are unreachable by construction. +Infrastructure (Squid, the API proxy sidecar, the CLI proxy) continues to run +under Docker Compose; AWF publishes exactly the required ports to macOS loopback +and bridges them into the guest through the closed capability allowlist in +[`src/apple-container/transport-capabilities.ts`](../src/apple-container/transport-capabilities.ts). + +Both the agent image and the AWF `apple-init` image MUST be digest-pinned: +Apple Container maintains an image store independent of Docker's, so a Docker +pre-pull does not populate it and `--skip-pull` is honoured by verification +rather than by assumption. + +Configurations that cannot be enforced under this topology are rejected during +option validation rather than ignored: Docker-in-Docker and ARC split +filesystems, `--legacy-security`, host access and host ports, enclaves, +`--topology-attach`, DNS-over-HTTPS, `filesystem.allowWrite`, additional volume +mounts, `--tty`, `--ssl-bump`, the chroot sysroot, non-default agent images, +`--build-local`, and Google Vertex AI (whose provider port is deliberately +outside the capability allowlist). See +[docs/apple-container-runtime.md](./apple-container-runtime.md) for the full +supported/unsupported matrix, and +[docs/apple-container-transport.md](./apple-container-transport.md) for the +transport's own threat model. + +Live end-to-end validation runs only on a self-hosted bare-metal Apple Silicon +runner via +[`smoke-apple-container.yml`](../.github/workflows/smoke-apple-container.yml), +which is `workflow_dispatch`-only against a maintainer-reviewed SHA and runs in +a protected environment — it has no pull request trigger, because a public +repository must not execute unreviewed code on a persistent self-hosted host. +Hosted CI +(`test-apple-container.yml`) covers the host-side units, the guest binary, and +the compiled-in host/guest contract, but cannot boot a VM. + ## 5. CLI Mapping *This section is normative.* @@ -240,7 +288,7 @@ AWF settings MAY be supplied via config files, including stdin (`--config -`). - `container.enableDind` → `--enable-dind` - `container.workDir` → `--work-dir` - `container.containerWorkDir` → `--container-workdir` -- `container.images` → *(config-only; a closed compiler-authorized manifest of literal, registry-qualified `tag@sha256:` OCI references. Supported keys are `squid`, `agent`, `apiProxy`, `cliProxy`, `buildTools`, `dohProxy`, `enclaveScript`, `enclaveAgent`, `enclaveMcpServer`, and `dindStaging`. Every image AWF runs — including consumers outside Docker Compose such as DinD staging, `awf predownload --config`, and rootless artifact repair — resolves through this manifest, and the effective per-role references are recorded in `image-manifest.json`. AWF rejects missing enabled roles and never falls back to the official registry. It cannot be combined with controls that would select a different image: `container.imageRegistry`, `container.imageTag`, `container.agentImage`, `container.buildLocal`, `security.sslBump` (requires a locally built Squid image), `runner.sysrootImage`, `dind.stagingImage`, or per-enclave image overrides. Registry credentials are intentionally not configured by AWF; use a pre-authenticated Docker daemon.)* +- `container.images` → *(config-only; a closed compiler-authorized manifest of literal, registry-qualified `tag@sha256:` OCI references. Supported keys are `squid`, `agent`, `apiProxy`, `cliProxy`, `buildTools`, `dohProxy`, `enclaveScript`, `enclaveAgent`, `enclaveMcpServer`, `dindStaging`, and `appleInit` (the Apple Container guest init image; see §4.3). Every image AWF runs — including consumers outside Docker Compose such as DinD staging, `awf predownload --config`, and rootless artifact repair — resolves through this manifest, and the effective per-role references are recorded in `image-manifest.json`. AWF rejects missing enabled roles and never falls back to the official registry. It cannot be combined with controls that would select a different image: `container.imageRegistry`, `container.imageTag`, `container.agentImage`, `container.buildLocal`, `security.sslBump` (requires a locally built Squid image), `runner.sysrootImage`, `dind.stagingImage`, or per-enclave image overrides. Registry credentials are intentionally not configured by AWF; use a pre-authenticated Docker daemon.)* - `container.imageRegistry` → `--image-registry` - `container.imageTag` → `--image-tag` - `container.skipPull` → `--skip-pull` @@ -266,6 +314,11 @@ AWF settings MAY be supplied via config files, including stdin (`--config -`). - `cloudHypervisor.sha256.kernel` → `--cloud-hypervisor-kernel-sha256` - `cloudHypervisor.sha256.rootfs` → `--cloud-hypervisor-rootfs-sha256` - `cloudHypervisor.sha256.supervisor` → `--cloud-hypervisor-supervisor-sha256` +- `appleContainer.previewEnabled` → `--apple-container-preview` *(requires `container.containerRuntime: "apple-container"` and a self-hosted bare-metal Apple Silicon macOS 26+ runner to execute a workload)* +- `appleContainer.cpus` → `--apple-container-cpus` +- `appleContainer.memory` → `--apple-container-memory` +- `appleContainer.initImage` → `--apple-container-init-image` +- `appleContainer.cliPath` → `--apple-container-cli` - `chroot.binariesSourcePath` → *(config-only; mounts a runner-side binaries directory at `/tmp/awf-runner-bin` inside chroot mode and prepends it to `PATH`)* - `chroot.identity.home` → *(config-only; forwarded as `AWF_CHROOT_IDENTITY_HOME` and applied after chroot pivot)* - `chroot.identity.user` → *(config-only; forwarded as `AWF_CHROOT_IDENTITY_USER` and applied to `USER`/`LOGNAME` after chroot pivot)* diff --git a/docs/awf-config.schema.json b/docs/awf-config.schema.json index d33764c42..5bca89f51 100644 --- a/docs/awf-config.schema.json +++ b/docs/awf-config.schema.json @@ -650,6 +650,9 @@ }, "dindStaging": { "$ref": "#/$defs/digestPinnedImage" + }, + "appleInit": { + "$ref": "#/$defs/digestPinnedImage" } } }, @@ -702,9 +705,10 @@ "enum": [ "gvisor", "sbx", - "cloud-hypervisor" + "cloud-hypervisor", + "apple-container" ], - "description": "Runtime for the primary agent. \"gvisor\" uses runsc in Docker Compose. \"sbx\" uses a Docker sbx microVM. \"cloud-hypervisor\" selects the explicit Linux/KVM Cloud Hypervisor v53.0 workload preview (GitHub-hosted Ubuntu x86_64 KVM runners only). Infrastructure containers always use the default runc runtime." + "description": "Runtime for the primary agent. \"gvisor\" uses runsc in Docker Compose. \"sbx\" uses a Docker sbx microVM. \"cloud-hypervisor\" selects the explicit Linux/KVM Cloud Hypervisor v53.0 workload preview (GitHub-hosted Ubuntu x86_64 KVM runners only). \"apple-container\" selects the Apple Virtualization.framework workload preview (self-hosted bare-metal Apple Silicon macOS 26+ runners only; the guest runs with no NIC and reaches AWF infrastructure exclusively through published capability sockets). Infrastructure containers always use the default runc runtime." } }, "allOf": [ @@ -811,6 +815,38 @@ } } }, + "appleContainer": { + "type": "object", + "description": "Apple Container microVM preview configuration. Requires container.containerRuntime: \"apple-container\" and previewEnabled to execute workloads; supported only on self-hosted bare-metal Apple Silicon runners on macOS 26+ with kern.hv_support=1. GitHub-hosted macOS runners fail preflight and are never silently downgraded to another runtime.", + "additionalProperties": false, + "properties": { + "previewEnabled": { + "type": "boolean", + "default": false, + "description": "Enable the Apple Container workload-execution preview. Requires container.containerRuntime: \"apple-container\" and a self-hosted bare-metal Apple Silicon macOS 26+ runner." + }, + "cpus": { + "type": "integer", + "minimum": 1, + "default": 4, + "description": "Number of guest virtual CPUs." + }, + "memory": { + "type": "string", + "pattern": "^[1-9][0-9]*[KMGTP]?$", + "default": "8G", + "description": "Guest memory as an integer with an optional K/M/G/T/P suffix, e.g. \"8G\"." + }, + "initImage": { + "$ref": "#/$defs/digestPinnedImage", + "description": "Digest-pinned AWF Apple init image carrying the guest capability relay. Defaults to the registry/tag-derived apple-init reference, which must itself be digest-pinned." + }, + "cliPath": { + "type": "string", + "description": "Absolute path to the Apple \"container\" CLI when it is not on PATH." + } + } + }, "chroot": { "type": "object", "description": "Chroot execution overrides for split-filesystem ARC/DinD runners.", diff --git a/scripts/build-apple-init-image.sh b/scripts/build-apple-init-image.sh new file mode 100755 index 000000000..057ccf533 --- /dev/null +++ b/scripts/build-apple-init-image.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# Builds the AWF Apple Container init image (linux/arm64). +# +# The image is Apple's own `vminit` with `/sbin/vminitd` relocated to +# `/sbin/vminitd.apple` and the AWF capability-relay shim installed in its +# place. See containers/apple-init/Dockerfile for why that is the only safe +# transformation and why the base must be digest-pinned. +# +# Usage: +# AWF_VMINIT_IMAGE=ghcr.io/apple/containerization/vminit:X.Y.Z@sha256:<64-hex> \ +# scripts/build-apple-init-image.sh [tag ...] +# +# Environment: +# AWF_VMINIT_IMAGE (required) digest-pinned Apple vminit reference +# PUSH set to "true" to push the built tags +# PLATFORM override the build platform (default linux/arm64) +set -euo pipefail + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +PLATFORM=${PLATFORM:-linux/arm64} + +if [ -z "${AWF_VMINIT_IMAGE:-}" ]; then + echo "AWF_VMINIT_IMAGE is required (digest-pinned Apple vminit reference)" >&2 + exit 1 +fi + +# Refused here as well as inside the Dockerfile so a mistake is caught before a +# build starts, and so the failure names the variable the operator controls. +case "$AWF_VMINIT_IMAGE" in + *@sha256:*) : ;; + *) + echo "AWF_VMINIT_IMAGE must be digest-pinned: got '$AWF_VMINIT_IMAGE'" >&2 + echo "A floating tag would let Apple's init move underneath a shim that" >&2 + echo "hard-codes where that init lives." >&2 + exit 1 + ;; +esac + +if [ "$PLATFORM" != "linux/arm64" ]; then + echo "Apple Container guests are native arm64 only; refusing PLATFORM=$PLATFORM" >&2 + exit 1 +fi + +# Keep the CLI range in the image labels in sync with the compiled-in host half, +# so a drift is a build failure rather than an unbootable guest. +read_ts_const() { + local name="$1" + sed -n "s/^export const ${name} = '\\([^']*\\)';\$/\\1/p" \ + "$ROOT/src/apple-container/transport-capabilities.ts" | head -n 1 +} +CLI_MIN=$(read_ts_const APPLE_CONTAINER_TRANSPORT_MIN_CLI_VERSION) +CLI_MAX=$(read_ts_const APPLE_CONTAINER_TRANSPORT_MAX_CLI_VERSION_EXCLUSIVE) +CONTRACT_VERSION=$(sed -n \ + 's/^export const APPLE_CONTAINER_TRANSPORT_CONTRACT_VERSION = \([0-9]*\);$/\1/p' \ + "$ROOT/src/apple-container/transport-capabilities.ts" | head -n 1) + +if [ -z "$CLI_MIN" ] || [ -z "$CLI_MAX" ] || [ -z "$CONTRACT_VERSION" ]; then + echo "Could not read the transport contract constants from" >&2 + echo "src/apple-container/transport-capabilities.ts" >&2 + exit 1 +fi + +TAGS=("$@") +if [ ${#TAGS[@]} -eq 0 ]; then + TAGS=("awf-apple-init:dev") +fi + +ARGS=( + buildx build + --platform "$PLATFORM" + --file "$ROOT/containers/apple-init/Dockerfile" + --build-arg "AWF_VMINIT_IMAGE=$AWF_VMINIT_IMAGE" + --build-arg "AWF_CLI_MIN_VERSION=$CLI_MIN" + --build-arg "AWF_CLI_MAX_VERSION_EXCLUSIVE=$CLI_MAX" + --build-arg "AWF_TRANSPORT_CONTRACT_VERSION=$CONTRACT_VERSION" +) +for tag in "${TAGS[@]}"; do + ARGS+=(--tag "$tag") +done +if [ "${PUSH:-}" = "true" ]; then + ARGS+=(--push) +else + ARGS+=(--load) +fi +ARGS+=("$ROOT") + +echo "Building AWF Apple init image from $AWF_VMINIT_IMAGE" +echo " contract=v$CONTRACT_VERSION cli>=$CLI_MIN <$CLI_MAX platform=$PLATFORM" +exec docker "${ARGS[@]}" diff --git a/src/apple-container-runtime-backend.test.ts b/src/apple-container-runtime-backend.test.ts new file mode 100644 index 000000000..fba54f5f5 --- /dev/null +++ b/src/apple-container-runtime-backend.test.ts @@ -0,0 +1,541 @@ +import { + APPLE_CONTAINER_TIMEOUT_EXIT_CODE, + AppleContainerRuntimeBackend, + appleContainerRuntimeTestHelpers, + type AppleContainerRuntimeBackendDependencies, +} from './apple-container-runtime-backend'; +import type { AppleContainerCliResult } from './apple-container/cli'; +import type { AppleContainerDiagnostics } from './apple-container/diagnostics'; +import type { AppleContainerRunSpec } from './apple-container/run-args'; +import type { WrapperConfig } from './types'; + +const WORK_DIR = '/tmp/awf-apple-backend-test'; +const WORKSPACE = '/Users/runner/work/repo/repo'; +const AGENT_IMAGE = 'ghcr.io/github/gh-aw-firewall/agent:1.0.0@sha256:' + 'a'.repeat(64); +const INIT_IMAGE = 'ghcr.io/github/gh-aw-firewall/apple-init:1.0.0@sha256:' + 'b'.repeat(64); + +function config(overrides: Partial = {}): WrapperConfig { + return { + allowedDomains: ['github.com'], + agentCommand: 'true', + logLevel: 'info', + workDir: WORK_DIR, + containerRuntime: 'apple-container', + networkIsolation: true, + enableApiProxy: true, + appleContainer: { previewEnabled: true, cpus: 4, memory: '8G' }, + ...overrides, + } as unknown as WrapperConfig; +} + +function cliResult(overrides: Partial = {}): AppleContainerCliResult { + return { + argv: ['container', 'start'], + exitCode: 0, + signal: null, + stdout: '', + stderr: '', + timedOut: false, + ...overrides, + }; +} + +interface Harness { + backend: AppleContainerRuntimeBackend; + dependencies: AppleContainerRuntimeBackendDependencies; + order: string[]; + lifecycle: { + create: jest.Mock; + startAttached: jest.Mock; + stop: jest.Mock; + kill: jest.Mock; + remove: jest.Mock; + pullImage: jest.Mock; + cli: { run: jest.Mock; binary: string }; + }; + transport: { + applyTo: jest.Mock; + stop: jest.Mock; + stats: jest.Mock; + verify: jest.Mock; + }; +} + +function harness( + wrapper: WrapperConfig = config(), + overrides: Partial = {}, +): Harness { + const order: string[] = []; + const lifecycle = { + create: jest.fn(async () => { + order.push('container-create'); + return 'container-abc'; + }), + startAttached: jest.fn(async () => cliResult()), + stop: jest.fn(async () => cliResult()), + kill: jest.fn(async () => cliResult()), + remove: jest.fn(async () => { + order.push('container-remove'); + return cliResult(); + }), + pullImage: jest.fn(async () => { + order.push('image-pull'); + return cliResult(); + }), + cli: { run: jest.fn(async () => cliResult()), binary: 'container' }, + }; + const transport = { + directory: { path: '/tmp/awf-apple-abcdef0123456789', runId: 'abcdef0123456789' }, + applyTo: jest.fn((spec: AppleContainerRunSpec) => ({ + ...spec, + network: { kind: 'none' as const }, + capDrop: ['NET_RAW'], + initImage: INIT_IMAGE, + socketMounts: [{ hostPath: '/tmp/s/squid.sock', containerPath: '/run/awf/x/squid.sock' }], + })), + stop: jest.fn(async () => { + order.push('transport-stop'); + }), + stats: jest.fn(() => ({ squid: { connections: 1 } })), + verify: jest.fn(async () => undefined), + }; + + const dependencies: AppleContainerRuntimeBackendDependencies = { + startInfrastructure: jest.fn(async () => { + order.push('compose-infrastructure'); + }), + preflight: jest.fn(async () => { + order.push('preflight'); + return { + facts: { + platform: 'darwin' as NodeJS.Platform, + arch: 'arm64', + macosProductVersion: '26.1.0', + hypervisorSupported: true, + }, + cliBinary: 'container', + cliVersion: '0.4.2', + }; + }), + createLifecycle: jest.fn(() => lifecycle as never), + startTransport: jest.fn(async () => { + order.push('transport-start'); + return transport as never; + }), + collectDiagnostics: jest.fn(async (): Promise => ({ + captures: [{ name: 'system.log', argv: ['container'], ok: true, content: 'ok' }], + })), + findPortConflicts: jest.fn(async () => { + order.push('port-probe'); + return []; + }), + imagePresent: jest.fn(async () => true), + ensureDirectory: jest.fn(async () => { + order.push('run-directories'); + }), + writeDiagnostics: jest.fn(async () => undefined), + identity: () => ({ uid: '501', gid: '20' }), + workspaceDir: () => WORKSPACE, + ghAwStateDir: () => undefined, + resolveImages: jest.fn(() => ({ agent: AGENT_IMAGE, init: INIT_IMAGE })), + transportBaseDirectory: () => '/tmp', + logger: { debug: jest.fn(), info: jest.fn(), warn: jest.fn() }, + ...overrides, + }; + + return { + backend: new AppleContainerRuntimeBackend(wrapper, dependencies), + dependencies, + order, + lifecycle, + transport, + }; +} + +async function start(h: Harness): Promise { + await h.backend.start(WORK_DIR, ['github.com'], undefined, false); +} + +describe('preflight', () => { + it('rejects an unsupported container CLI version before anything is created', async () => { + const h = harness(config(), { + preflight: jest.fn(async () => ({ + facts: { + platform: 'darwin' as NodeJS.Platform, + arch: 'arm64', + macosProductVersion: '26.1.0', + hypervisorSupported: true, + }, + cliBinary: 'container', + cliVersion: '1.2.0', + })), + }); + await expect(h.backend.preflight()).rejects.toThrow('has not been validated against'); + expect(h.dependencies.startInfrastructure).not.toHaveBeenCalled(); + }); + + it('rejects an occupied loopback port and names every conflict', async () => { + const h = harness(config(), { + findPortConflicts: jest.fn(async () => [ + { service: 'squid-proxy' as const, containerPort: 3128, hostPort: 3128, capability: 'squid' as const }, + { service: 'api-proxy' as const, containerPort: 10001, hostPort: 10001, capability: 'api-proxy-anthropic' as const }, + ]), + }); + await expect(h.backend.preflight()).rejects.toThrow(/3128 \(squid\).*10001 \(api-proxy-anthropic\)/s); + }); + + it('rejects an unsupported configuration before probing the host', async () => { + const h = harness(config({ enableDind: true })); + await expect(h.backend.preflight()).rejects.toThrow('Docker-in-Docker'); + expect(h.dependencies.preflight).not.toHaveBeenCalled(); + }); +}); + +describe('start', () => { + it('runs the lifecycle in the fail-closed order', async () => { + const h = harness(); + await start(h); + expect(h.order).toEqual([ + 'preflight', + 'port-probe', + 'compose-infrastructure', + 'run-directories', + 'run-directories', + 'run-directories', + 'run-directories', + 'run-directories', + 'run-directories', + 'run-directories', + 'image-pull', + 'image-pull', + 'transport-start', + 'container-create', + ]); + }); + + it('starts the transport before the container is ever created', async () => { + const h = harness(); + await start(h); + expect(h.order.indexOf('transport-start')).toBeLessThan(h.order.indexOf('container-create')); + }); + + it('requests every capability the configuration implies', async () => { + const h = harness(config({ enableApiProxy: true, difcProxyHost: 'https://difc:18443' })); + await start(h); + const options = (h.dependencies.startTransport as jest.Mock).mock.calls[0][0]; + expect(options.capabilities.map((entry: { id: string }) => entry.id)).toEqual([ + 'squid', + 'api-proxy-openai', + 'api-proxy-anthropic', + 'api-proxy-copilot', + 'api-proxy-gemini', + 'cli-proxy', + ]); + expect(options.initImage).toBe(INIT_IMAGE); + }); + + it('merges the transport plan into the run spec before creating the container', async () => { + const h = harness(); + await start(h); + expect(h.transport.applyTo).toHaveBeenCalledTimes(1); + const created = h.lifecycle.create.mock.calls[0][0] as AppleContainerRunSpec; + expect(created.network).toEqual({ kind: 'none' }); + expect(created.initImage).toBe(INIT_IMAGE); + }); + + it('pulls both images for native arm64', async () => { + const h = harness(); + await start(h); + expect(h.lifecycle.pullImage).toHaveBeenCalledWith(AGENT_IMAGE, { platform: 'linux/arm64' }); + expect(h.lifecycle.pullImage).toHaveBeenCalledWith(INIT_IMAGE, { platform: 'linux/arm64' }); + }); + + it('verifies image presence rather than assuming a Docker pre-pull under --skip-pull', async () => { + const h = harness(); + await h.backend.start(WORK_DIR, ['github.com'], undefined, true); + expect(h.lifecycle.pullImage).not.toHaveBeenCalled(); + expect(h.dependencies.imagePresent).toHaveBeenCalledTimes(2); + }); + + it('fails closed when --skip-pull is used and the image store is empty', async () => { + const h = harness(config(), { imagePresent: jest.fn(async () => false) }); + await expect(h.backend.start(WORK_DIR, ['github.com'], undefined, true)) + .rejects.toThrow("independent of Docker's"); + }); + + it('keeps every capability socket path inside the macOS sun_path budget', async () => { + // A run work directory on a real runner is long enough that rooting the + // socket directory there would exceed the 104-byte sun_path cap and make + // the relay unbindable, so the base is deliberately short and independent + // of workDir. + const h = harness(); + await start(h); + const { baseDirectory } = (h.dependencies.startTransport as jest.Mock).mock.calls[0][0]; + const longestSocket = `${baseDirectory}/awf-apple-${'a'.repeat(16)}/api-proxy-anthropic.sock`; + expect(Buffer.byteLength(longestSocket)).toBeLessThanOrEqual(103); + expect(baseDirectory.startsWith(WORK_DIR)).toBe(false); + }); + + it('rolls the transport back when container creation fails', async () => { + const h = harness(); + h.lifecycle.create.mockRejectedValueOnce(new Error('create exploded')); + await expect(start(h)).rejects.toThrow('create exploded'); + expect(h.transport.stop).toHaveBeenCalledWith({ preserveDiagnostics: false }); + }); + + it('does not create a container when the transport cannot start', async () => { + const h = harness(config(), { + startTransport: jest.fn(async () => { + throw new Error('relay bind failed'); + }), + }); + await expect(start(h)).rejects.toThrow('relay bind failed'); + expect(h.lifecycle.create).not.toHaveBeenCalled(); + }); + + it('refuses a run spec that lost its isolated network', async () => { + const h = harness(); + h.transport.applyTo.mockImplementationOnce((spec: AppleContainerRunSpec) => ({ + ...spec, + network: { kind: 'attach' as const, networks: ['bridge'] }, + })); + await expect(start(h)).rejects.toThrow('--network none'); + expect(h.lifecycle.create).not.toHaveBeenCalled(); + }); + + it('refuses a run spec that regained a capability', async () => { + const h = harness(); + h.transport.applyTo.mockImplementationOnce((spec: AppleContainerRunSpec) => ({ + ...spec, + network: { kind: 'none' as const }, + capAdd: ['NET_RAW'], + })); + await expect(start(h)).rejects.toThrow('added capabilities: NET_RAW'); + }); +}); + +describe('exec', () => { + it('refuses to run before start completed', async () => { + const h = harness(); + await expect(h.backend.exec(WORK_DIR, [], undefined, undefined)) + .rejects.toThrow('is not ready'); + }); + + it('propagates the guest exit code verbatim', async () => { + const h = harness(); + await start(h); + h.lifecycle.startAttached.mockResolvedValueOnce(cliResult({ exitCode: 42 })); + await expect(h.backend.exec(WORK_DIR, [], undefined, undefined)) + .resolves.toEqual({ exitCode: 42 }); + }); + + it('propagates a fatal-signal exit code verbatim', async () => { + const h = harness(); + await start(h); + h.lifecycle.startAttached.mockResolvedValueOnce(cliResult({ exitCode: 143, signal: 'SIGTERM' })); + await expect(h.backend.exec(WORK_DIR, [], undefined, undefined)) + .resolves.toEqual({ exitCode: 143 }); + }); + + it('passes the agent timeout through to the attached start', async () => { + const h = harness(); + await start(h); + await h.backend.exec(WORK_DIR, [], undefined, 5); + expect(h.lifecycle.startAttached).toHaveBeenCalledWith('container-abc', { + interactive: true, + timeoutMs: 300_000, + }); + }); + + it('kills the VM and reports 124 when the timeout fires', async () => { + const h = harness(); + await start(h); + h.lifecycle.startAttached.mockResolvedValueOnce(cliResult({ exitCode: 143, timedOut: true })); + await expect(h.backend.exec(WORK_DIR, [], undefined, 1)) + .resolves.toEqual({ exitCode: APPLE_CONTAINER_TIMEOUT_EXIT_CODE }); + expect(h.lifecycle.kill).toHaveBeenCalledWith('container-abc'); + }); + + it('still reports the timeout exit code when the kill itself fails', async () => { + const h = harness(); + await start(h); + h.lifecycle.startAttached.mockResolvedValueOnce(cliResult({ exitCode: 143, timedOut: true })); + h.lifecycle.kill.mockRejectedValueOnce(new Error('already gone')); + await expect(h.backend.exec(WORK_DIR, [], undefined, 1)) + .resolves.toEqual({ exitCode: APPLE_CONTAINER_TIMEOUT_EXIT_CODE }); + }); +}); + +describe('stop and preserve', () => { + it('quiesces the guest before tearing the transport down', async () => { + const h = harness(); + await start(h); + await h.backend.stop(); + expect(h.lifecycle.stop).toHaveBeenCalledWith('container-abc', { timeoutSeconds: 10 }); + expect(h.order.indexOf('container-remove')).toBeLessThan(h.order.indexOf('transport-stop')); + }); + + it('is idempotent', async () => { + const h = harness(); + await start(h); + await h.backend.stop(); + await h.backend.stop(); + expect(h.lifecycle.remove).toHaveBeenCalledTimes(1); + }); + + it('escalates to a kill when a stop fails', async () => { + const h = harness(); + await start(h); + h.lifecycle.stop.mockRejectedValueOnce(new Error('stuck')); + await h.backend.stop(); + expect(h.lifecycle.kill).toHaveBeenCalledWith('container-abc'); + }); + + it('keeps the container but still unlinks every capability socket on preserve', async () => { + const h = harness(); + await start(h); + await h.backend.preserve(); + expect(h.lifecycle.remove).not.toHaveBeenCalled(); + expect(h.transport.stop).toHaveBeenCalledWith({ preserveDiagnostics: true }); + }); + + it('surfaces a teardown failure rather than swallowing it', async () => { + const h = harness(); + await start(h); + h.transport.stop.mockRejectedValueOnce(new Error('socket busy')); + await expect(h.backend.stop()).rejects.toThrow('transport shutdown: socket busy'); + }); +}); + +describe('collectDiagnostics', () => { + it('captures Apple boot/stdio/system logs plus transport counters exactly once', async () => { + const h = harness(); + await start(h); + await h.backend.collectDiagnostics(); + await h.backend.collectDiagnostics(); + expect(h.dependencies.collectDiagnostics).toHaveBeenCalledTimes(1); + expect(h.dependencies.collectDiagnostics) + .toHaveBeenCalledWith(h.lifecycle.cli, { containerId: 'container-abc' }); + expect(h.transport.stats).toHaveBeenCalled(); + const [, written] = (h.dependencies.writeDiagnostics as jest.Mock).mock.calls[0]; + expect(written.captures.map((capture: { name: string }) => capture.name)) + .toEqual(['system.log', 'transport-stats.json']); + }); + + it('never persists the guest environment from container inspect', async () => { + const h = harness(config(), { + collectDiagnostics: jest.fn(async () => ({ + captures: [{ + name: 'container-inspect.json', + argv: ['container', 'inspect', 'container-abc'], + ok: true, + content: JSON.stringify({ + configuration: { initProcess: { environment: ['SECRET=sk-real-secret'] } }, + }), + }], + })), + }); + await start(h); + await h.backend.collectDiagnostics(); + const [, written] = (h.dependencies.writeDiagnostics as jest.Mock).mock.calls[0]; + expect(JSON.stringify(written)).not.toContain('sk-real-secret'); + expect(JSON.stringify(written)).toContain('SECRET'); + }); + + it('does nothing when no CLI was ever created', async () => { + const h = harness(); + await h.backend.collectDiagnostics(); + expect(h.dependencies.collectDiagnostics).not.toHaveBeenCalled(); + }); +}); + +describe('diagnostics redaction', () => { + const { redactAppleContainerCapture } = appleContainerRuntimeTestHelpers; + + const inspect = (content: string) => redactAppleContainerCapture({ + name: 'container-inspect.json', + argv: ['container', 'inspect', 'x'], + ok: true, + content, + }); + + it('replaces the guest environment with variable names only', () => { + const result = inspect(JSON.stringify([{ + configuration: { + initProcess: { + environment: ['ANTHROPIC_API_KEY=sk-real-secret', 'HOME=/awf/home'], + executable: '/bin/bash', + }, + }, + }])); + expect(result.content).not.toContain('sk-real-secret'); + expect(result.content).toContain('ANTHROPIC_API_KEY'); + expect(result.content).toContain('HOME'); + expect(result.content).toContain('/bin/bash'); + }); + + it('redacts an object-shaped environment too', () => { + const result = inspect(JSON.stringify({ env: { GITHUB_TOKEN: 'ghp_real' } })); + expect(result.content).not.toContain('ghp_real'); + expect(result.content).toContain('GITHUB_TOKEN'); + }); + + it('redacts an environment block at any nesting depth', () => { + const result = inspect(JSON.stringify({ a: { b: [{ Environment: ['X=secret'] }] } })); + expect(result.content).not.toContain('secret'); + }); + + it('drops an unparseable inspect capture rather than persisting it', () => { + const result = inspect('not json ANTHROPIC_API_KEY=sk-real-secret'); + expect(result.ok).toBe(false); + expect(result.content).not.toContain('sk-real-secret'); + expect(result.content).toContain('withheld'); + }); + + it('leaves other captures untouched', () => { + const capture = { + name: 'container-boot.log', + argv: ['container', 'logs'], + ok: true, + content: 'boot output', + }; + expect(redactAppleContainerCapture(capture)).toBe(capture); + }); +}); + +describe('defaultResolveImages', () => { + const { defaultResolveImages } = appleContainerRuntimeTestHelpers; + const options = { previewEnabled: true, cpus: 4, memory: '8G' }; + + it('refuses a floating agent tag', () => { + expect(() => defaultResolveImages( + config({ imageTag: '1.0.0' }) as never, + options, + )).toThrow('digest-pinned agent image'); + }); + + it('accepts a digest-pinned manifest', () => { + const resolved = defaultResolveImages( + config({ + images: { agent: AGENT_IMAGE, appleInit: INIT_IMAGE } as WrapperConfig['images'], + }) as never, + options, + ); + expect(resolved).toEqual({ agent: AGENT_IMAGE, init: INIT_IMAGE }); + }); + + it('refuses a floating explicit init image', () => { + expect(() => defaultResolveImages( + config({ images: { agent: AGENT_IMAGE } as WrapperConfig['images'] }) as never, + { ...options, initImage: 'ghcr.io/github/gh-aw-firewall/apple-init:latest' }, + )).toThrow('digest-pinned apple-init image'); + }); +}); + +describe('appleContainerName', () => { + it('is unique per run so a leftover VM cannot be adopted', () => { + const { appleContainerName } = appleContainerRuntimeTestHelpers; + const first = appleContainerName(); + expect(first).toMatch(/^awf-agent-\d+-\d+$/); + }); +}); diff --git a/src/apple-container-runtime-backend.ts b/src/apple-container-runtime-backend.ts new file mode 100644 index 000000000..112deb2ab --- /dev/null +++ b/src/apple-container-runtime-backend.ts @@ -0,0 +1,768 @@ +/** + * Apple Container external agent runtime backend. + * + * Docker Compose keeps owning AWF's infrastructure (Squid, the API proxy, the + * CLI proxy); only the agent crosses the hypervisor boundary, into an Apple + * Virtualization.framework VM launched by the `container` CLI. Three properties + * define the shape of this file, and every ordering decision follows from them: + * + * 1. **The VM has no NIC.** Every launch emits `--network none` — layer 1 + * defaults to it, layer 2 re-asserts it, and layer 1 always emits the flag + * explicitly because omitting `--network` attaches Apple's default vmnet + * network. There is therefore no in-guest firewall to configure and no + * iptables-init container: egress exists only where a capability socket was + * published. + * 2. **The transport is mandatory.** Proxy environment variables are advisory + * and Apple Container has no forced-proxy daemon setting, so a guest whose + * relay failed would simply have no egress rather than an unfiltered one. + * That is safe, but useless, so transport startup and end-to-end verification + * are fatal on failure and always precede agent launch. + * 3. **Nothing falls back.** An ineligible host, an unsupported `container` CLI, + * an occupied loopback port, or a failed image pull aborts the run. There is + * no silent degradation to Docker. + * + * Startup order (each step's failure rolls back everything before it): + * + * ``` + * compatibility → Apple host/CLI/service preflight → transport CLI version + * → loopback port availability → Compose infrastructure (infra services only) + * → run directories → image pull (agent + init, native arm64) + * → transport start + verify → container create + * ``` + */ + +import * as fsSync from 'fs'; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +import type { WorkflowDependencies } from './cli-workflow'; +import type { ExternalAgentRuntimeBackend } from './external-runtime-backend'; +import { getSafeHostGid, getSafeHostUid } from './host-identity'; +import { logger } from './logger'; +import { resolveRuntimeImageFor } from './image-resolver'; +import { resolveLogPaths } from './log-paths'; +import type { AppleContainerOptions, WrapperConfig } from './types'; +import { + appleContainerRunDirectories, + buildAppleContainerAgentSpec, + type AppleContainerRunDirectories, +} from './apple-container/agent-run-spec'; +import { AppleContainerCli, type AppleContainerCliOptions } from './apple-container/cli'; +import { + collectAppleContainerDiagnostics, + type AppleContainerDiagnostics, +} from './apple-container/diagnostics'; +import { + appleContainerLoopbackPortConflicts, + planAppleContainerInfrastructure, + type AppleContainerInfrastructurePlan, +} from './apple-container/infrastructure-endpoints'; +import { AppleContainerLifecycle } from './apple-container/lifecycle'; +import { + runAppleContainerPreflight, + type AppleContainerPreflightResult, +} from './apple-container/preflight'; +import type { AppleContainerRunSpec } from './apple-container/run-args'; +import { + APPLE_CONTAINER_RUNTIME, + assertAppleContainerRuntimeCompatibility, + requireAppleContainerConfig, +} from './apple-container/runtime-validation'; +import { assertAppleContainerTransportCliVersion } from './apple-container/transport-capabilities'; +import { + startAppleContainerTransport, + type AppleContainerTransport, +} from './apple-container/transport-manager'; +import { assertAppleContainerImageReference } from './apple-container/validation'; + +export { + assertAppleContainerPreSecurityCompatibility, + assertAppleContainerRuntimeCompatibility, + assertAppleContainerSelection, +} from './apple-container/runtime-validation'; + +/** Exit code for an agent cut off by `--agent-timeout` (coreutils convention). */ +export const APPLE_CONTAINER_TIMEOUT_EXIT_CODE = 124; + +/** Grace period for `container stop` before the CLI escalates to a kill. */ +const APPLE_CONTAINER_STOP_TIMEOUT_SECONDS = 10; + +/** Guest platform. Native arm64 only; Rosetta translation is never requested. */ +const APPLE_CONTAINER_PLATFORM = 'linux/arm64'; + +/** + * Base directory for the run-scoped capability socket directory. + * + * Deliberately *not* `workDir`. macOS caps `sun_path` at 104 bytes, and a + * realistic runner work directory blows that budget: on a self-hosted runner + * `${RUNNER_TEMP}/awf-/apple-container/awf-apple-/ + * api-proxy-anthropic.sock` is well over 110 bytes, so the socket could not be + * bound at all. `os.tmpdir()` is not reliably better — an Actions runner's + * `TMPDIR` is typically a ~49-byte `/var/folders/...` path, which leaves only a + * couple of spare bytes. + * + * `/tmp` resolves to `/private/tmp` on macOS (13 bytes), which leaves ample + * room. Its world-writable sticky permissions are not a weakness here because + * `createAppleContainerSocketDirectory` is built for exactly this setting: the + * run directory is created with a non-recursive `mkdir` (so a pre-existing or + * planted path is a hard failure, never a reuse), forced to `0700`, and then + * verified for real-directory type, ownership, mode, and self-resolution before + * any socket is bound — and binding never unlinks, so a squatted socket path + * fails closed rather than being taken over. + */ +const APPLE_CONTAINER_TRANSPORT_BASE_DIRECTORY = '/tmp'; + +interface AppleContainerBackendLogger { + debug(message: string, ...args: unknown[]): void; + info(message: string, ...args: unknown[]): void; + warn(message: string, ...args: unknown[]): void; +} + +/** The two images a launch needs, both native arm64. */ +export interface AppleContainerImages { + readonly agent: string; + readonly init: string; +} + +/** @internal Exposed only for unit tests — not part of the public API. */ +// ts-prune-ignore-next +export interface AppleContainerRuntimeBackendDependencies { + startInfrastructure: WorkflowDependencies['startContainers']; + preflight(options: AppleContainerCliOptions): Promise; + createLifecycle(options: AppleContainerCliOptions): AppleContainerLifecycle; + startTransport: typeof startAppleContainerTransport; + collectDiagnostics: typeof collectAppleContainerDiagnostics; + findPortConflicts: typeof appleContainerLoopbackPortConflicts; + /** Confirms an image is already present in Apple Container's own image store. */ + imagePresent(lifecycle: AppleContainerLifecycle, reference: string): Promise; + ensureDirectory(directory: string): Promise; + writeDiagnostics(directory: string, diagnostics: AppleContainerDiagnostics): Promise; + identity(): { uid: string; gid: string }; + workspaceDir(): string; + ghAwStateDir(): string | undefined; + resolveImages(config: WrapperConfig, appleContainer: AppleContainerOptions): AppleContainerImages; + /** Short base directory for the capability sockets; see the constant's comment. */ + transportBaseDirectory(): string; + logger: AppleContainerBackendLogger; +} + +/** + * Resolves the digest-pinned, native arm64 image pair. + * + * Both references must be digest-pinned. A floating tag would let the registry + * decide what runs inside the VM between the operator's decision and the launch, + * and — unlike the Docker path, where a mutable tag at least stays inside the + * daemon's content trust story — Apple Container maintains a *separate* image + * store, so a tag resolved here has no relationship to anything Docker pulled. + */ +function defaultResolveImages( + config: WrapperConfig, + appleContainer: AppleContainerOptions, +): AppleContainerImages { + const agent = resolveRuntimeImageFor(config, 'agent'); + const init = appleContainer.initImage ?? resolveRuntimeImageFor(config, 'apple-init'); + return { + agent: assertDigestPinned(assertAppleContainerImageReference(agent), 'agent'), + init: assertDigestPinned(assertAppleContainerImageReference(init), 'apple-init'), + }; +} + +function assertDigestPinned(reference: string, role: string): string { + if (!/@sha256:[a-f0-9]{64}$/.test(reference)) { + throw new Error( + `Apple Container requires a digest-pinned ${role} image; got "${reference}". ` + + 'Supply container.images or an --image-tag carrying the digest, because Apple ' + + "Container's image store is independent of Docker's and cannot inherit a pre-pull.", + ); + } + return reference; +} + +async function defaultImagePresent( + lifecycle: AppleContainerLifecycle, + reference: string, +): Promise { + const result = await lifecycle.cli.run([ + 'image', + 'inspect', + assertAppleContainerImageReference(reference), + ]); + return result.exitCode === 0; +} + +async function defaultWriteDiagnostics( + directory: string, + diagnostics: AppleContainerDiagnostics, +): Promise { + await fs.mkdir(directory, { recursive: true }); + for (const capture of diagnostics.captures) { + // A successful JSON capture is written verbatim so it stays machine- + // readable; everything else (and any failed capture, whose body is an + // error message rather than JSON) carries a provenance header. + const body = capture.ok && capture.name.endsWith('.json') + ? `${capture.content}\n` + : `# ${capture.argv.join(' ')}\n# ok=${capture.ok}\n\n${capture.content}\n`; + await fs.writeFile(path.join(directory, capture.name), body, { mode: 0o600 }); + } +} + +function defaultDependencies( + startInfrastructure: WorkflowDependencies['startContainers'], +): AppleContainerRuntimeBackendDependencies { + return { + startInfrastructure, + preflight: (options) => runAppleContainerPreflight({ cli: options }), + createLifecycle: (options) => new AppleContainerLifecycle(new AppleContainerCli(options)), + startTransport: startAppleContainerTransport, + collectDiagnostics: collectAppleContainerDiagnostics, + findPortConflicts: appleContainerLoopbackPortConflicts, + imagePresent: defaultImagePresent, + ensureDirectory: async (directory) => { + await fs.mkdir(directory, { recursive: true, mode: 0o700 }); + }, + writeDiagnostics: defaultWriteDiagnostics, + identity: () => ({ uid: getSafeHostUid(), gid: getSafeHostGid() }), + workspaceDir: () => process.env.GITHUB_WORKSPACE || process.cwd(), + ghAwStateDir: () => { + const runnerTemp = process.env.RUNNER_TEMP; + if (!runnerTemp) return undefined; + const candidate = path.join(runnerTemp, 'gh-aw'); + // Only mounted when the runner actually created it. Creating it here + // would hand the guest a writable directory the workflow never asked for. + return fsSync.existsSync(candidate) ? candidate : undefined; + }, + resolveImages: defaultResolveImages, + transportBaseDirectory: () => APPLE_CONTAINER_TRANSPORT_BASE_DIRECTORY, + logger, + }; +} + +/** + * Stateful adapter for the Apple Container preview runtime. + * + * @internal Production code obtains instances via + * {@link createAppleContainerRuntimeBackend}; the class is exported only so + * unit tests can construct it with injected dependencies. + */ +// ts-prune-ignore-next +export class AppleContainerRuntimeBackend implements ExternalAgentRuntimeBackend { + readonly runtime = APPLE_CONTAINER_RUNTIME; + + private preflightResult: AppleContainerPreflightResult | undefined; + private lifecycle: AppleContainerLifecycle | undefined; + private transport: AppleContainerTransport | undefined; + private infrastructurePlan: AppleContainerInfrastructurePlan | undefined; + private directories: AppleContainerRunDirectories | undefined; + private spec: AppleContainerRunSpec | undefined; + private containerId: string | undefined; + private diagnosticsCollected = false; + private preservedTransportDirectory: string | undefined; + private stopped = false; + private stopping: Promise | undefined; + + constructor( + private readonly config: WrapperConfig, + private readonly dependencies: AppleContainerRuntimeBackendDependencies, + ) {} + + async preflight(): Promise { + const appleContainer = requireAppleContainerConfig(this.config); + assertAppleContainerRuntimeCompatibility(this.config, appleContainer); + + const result = await this.dependencies.preflight(this.cliOptions(appleContainer)); + // The init image relocates Apple's real `vminitd`, so a CLI outside the + // validated window could boot a guest whose init layout the shim does not + // match — which would surface as a VM with no capabilities rather than as a + // failure. Refuse before anything is created. + assertAppleContainerTransportCliVersion(result.cliVersion); + this.preflightResult = result; + + const plan = planAppleContainerInfrastructure(this.config); + this.infrastructurePlan = plan; + const conflicts = await this.dependencies.findPortConflicts(plan); + if (conflicts.length > 0) { + throw new Error( + 'Apple Container requires these macOS loopback ports for its capability relays, but ' + + `something is already listening on them: ${ + conflicts.map((entry) => `${entry.hostPort} (${entry.capability})`).join(', ') + }. Stop the conflicting process or the previous AWF run and retry.`, + ); + } + + this.dependencies.logger.info( + `[apple-container] runtime=${APPLE_CONTAINER_RUNTIME} maturity=preview fallback=disabled ` + + `cli=${result.cliVersion} macos=${result.facts.macosProductVersion} arch=${result.facts.arch}`, + ); + } + + readonly start: WorkflowDependencies['startContainers'] = async ( + workDir, + allowedDomains, + proxyLogsDir, + skipPull, + onNetworkReady, + onInfrastructureReady, + ) => { + const appleContainer = requireAppleContainerConfig(this.config); + let stage = 'preflight'; + try { + await this.preflight(); + const plan = this.infrastructurePlan!; + + stage = 'compose-infrastructure'; + // Compose generates infrastructure services only for this runtime + // (`runtimeUsesComposeAgent` is false), so no agent or iptables-init + // container is created and the required ports are published to macOS + // loopback by `applyAppleContainerLoopbackPublishing`. + await this.dependencies.startInfrastructure( + workDir, + allowedDomains, + proxyLogsDir, + skipPull, + onNetworkReady, + onInfrastructureReady, + ); + + stage = 'run-directories'; + this.directories = appleContainerRunDirectories(workDir); + await this.prepareDirectories(this.directories); + + stage = 'image-pull'; + this.lifecycle = this.dependencies.createLifecycle(this.cliOptions(appleContainer)); + const images = this.dependencies.resolveImages(this.config, appleContainer); + await this.ensureImages(images, skipPull === true); + + stage = 'transport'; + this.transport = await this.dependencies.startTransport({ + capabilities: plan.capabilities, + initImage: images.init, + baseDirectory: this.dependencies.transportBaseDirectory(), + }); + this.dependencies.logger.info( + `[apple-container] capability transport ready: ${ + plan.capabilities.map((capability) => capability.id).join(', ') + }`, + ); + + stage = 'container-create'; + // Resolved once: the default implementation stats the filesystem, and a + // directory that appeared between two calls would produce a spec whose + // mounts disagree with the check that authorised them. + const ghAwStateDir = this.dependencies.ghAwStateDir(); + const spec = this.transport.applyTo(buildAppleContainerAgentSpec({ + config: this.config, + directories: this.directories, + workspaceDir: this.dependencies.workspaceDir(), + ...(ghAwStateDir !== undefined ? { ghAwStateDir } : {}), + image: images.agent, + name: appleContainerName(), + cpus: appleContainer.cpus, + memory: appleContainer.memory, + identity: this.dependencies.identity(), + })); + assertIsolatedSpec(spec); + this.spec = spec; + this.containerId = await this.lifecycle.create(spec); + this.dependencies.logger.info( + `[apple-container] stage=ready container=${this.containerId}`, + ); + } catch (error) { + this.dependencies.logger.warn( + `[apple-container] stage=${stage} status=failed: ${describe(error)}`, + ); + // Partial startup must not leave a VM or a live capability socket behind; + // the original error is always what propagates. + await this.rollback(); + throw error; + } + }; + + readonly exec: WorkflowDependencies['runAgentCommand'] = async ( + _workDir, + _allowedDomains, + _proxyLogsDir, + agentTimeoutMinutes, + ) => { + const lifecycle = this.lifecycle; + const containerId = this.containerId; + if (!lifecycle || !containerId || !this.transport) { + throw new Error('Apple Container agent VM is not ready'); + } + + const timeoutMs = agentTimeoutMinutes === undefined + ? undefined + : agentTimeoutMinutes * 60_000; + this.dependencies.logger.info( + `[apple-container] Launching agent in ${containerId} ` + + `(timeout: ${agentTimeoutMinutes ?? 'none'} min)`, + ); + + const result = await lifecycle.startAttached(containerId, { + interactive: true, + ...(timeoutMs === undefined ? {} : { timeoutMs }), + }); + + if (result.timedOut) { + // execa killed the attached `container start` client, not the VM. Kill the + // guest explicitly so the run cannot outlive its own timeout. + this.dependencies.logger.warn( + `[apple-container] Agent exceeded --agent-timeout; killing ${containerId}`, + ); + try { + await lifecycle.kill(containerId); + } catch (error) { + this.dependencies.logger.warn( + `[apple-container] Could not kill ${containerId} after timeout: ${describe(error)}`, + ); + } + return { exitCode: APPLE_CONTAINER_TIMEOUT_EXIT_CODE }; + } + + this.dependencies.logger.info( + `[apple-container] Agent command exited with code ${result.exitCode}` + + (result.signal ? ` (${result.signal})` : ''), + ); + return { exitCode: result.exitCode }; + }; + + async collectDiagnostics(): Promise { + if (this.diagnosticsCollected || !this.lifecycle) return; + this.diagnosticsCollected = true; + + const directory = this.config.auditDir + ? path.join(this.config.auditDir, 'apple-container') + : path.join(this.config.workDir, 'diagnostics', 'apple-container'); + + const diagnostics = await this.dependencies.collectDiagnostics(this.lifecycle.cli, { + ...(this.containerId !== undefined ? { containerId: this.containerId } : {}), + }); + + const captures = diagnostics.captures.map(redactAppleContainerCapture); + if (this.transport) { + // Counters only: ids, guest ports, upstreams, and byte/connection totals. + // The relay retains no payload, so nothing here can carry credential + // material out of the API proxy sidecar. + captures.push({ + name: 'transport-stats.json', + argv: [], + ok: true, + content: JSON.stringify(this.transport.stats(), null, 2), + }); + } + await this.dependencies.writeDiagnostics(directory, { captures }); + this.dependencies.logger.info(`[apple-container] Diagnostics written to ${directory}`); + } + + async stop(): Promise { + if (this.stopped) return; + if (this.stopping) return this.stopping; + this.stopping = this.runStop(false); + try { + await this.stopping; + this.stopped = true; + } finally { + this.stopping = undefined; + } + } + + async preserve(): Promise { + if (this.stopped) return; + if (this.stopping) return this.stopping; + this.stopping = this.runStop(true); + try { + await this.stopping; + this.stopped = true; + if (this.directories) { + this.dependencies.logger.info( + `[apple-container] Preserved run directory: ${this.directories.root}`, + ); + } + if (this.containerId) { + this.dependencies.logger.info( + `[apple-container] Preserved container: ${this.containerId} ` + + '(inspect with "container inspect"; remove with "container delete")', + ); + } + if (this.preservedTransportDirectory) { + // Named explicitly because it lives outside workDir (see + // APPLE_CONTAINER_TRANSPORT_BASE_DIRECTORY) and would otherwise be + // missed during triage. Its sockets are already unlinked; only the + // summary remains. + this.dependencies.logger.info( + `[apple-container] Preserved transport summary: ` + + `${this.preservedTransportDirectory}/transport-summary.json`, + ); + } + } finally { + this.stopping = undefined; + } + } + + /** + * Teardown. + * + * The guest is quiesced before the transport is torn down so a still-running + * workload never observes its capabilities disappearing mid-request. Under + * `preserve` the container is stopped but kept for inspection, while the + * transport still unlinks every socket — a preserved run must never leave an + * active path into AWF's credential-injecting sidecar. + */ + private async runStop(preserve: boolean): Promise { + const failures: string[] = []; + + if (this.lifecycle && this.containerId) { + try { + await this.lifecycle.stop(this.containerId, { + timeoutSeconds: APPLE_CONTAINER_STOP_TIMEOUT_SECONDS, + }); + } catch (error) { + // Already-exited is the common case here and is not an error worth + // surfacing; a genuinely stuck VM is escalated by the kill below. + this.dependencies.logger.debug( + `[apple-container] stop(${this.containerId}) reported: ${describe(error)}`, + ); + try { + await this.lifecycle.kill(this.containerId); + } catch { + // The container may already be gone; removal below is authoritative. + } + } + if (!preserve) { + try { + await this.lifecycle.remove(this.containerId, { force: true }); + this.containerId = undefined; + } catch (error) { + failures.push(`container removal: ${describe(error)}`); + } + } + } + + if (this.transport) { + if (preserve) { + this.preservedTransportDirectory = this.transport.directory.path; + } + try { + await this.transport.stop({ preserveDiagnostics: preserve }); + } catch (error) { + failures.push(`transport shutdown: ${describe(error)}`); + } + this.transport = undefined; + } + + if (failures.length > 0) { + throw new Error(`Apple Container teardown failed: ${failures.join('; ')}`); + } + } + + /** Best-effort undo of a partial `start()`; never masks the original error. */ + private async rollback(): Promise { + try { + await this.runStop(false); + } catch (error) { + this.dependencies.logger.warn( + `[apple-container] rollback did not fully complete: ${describe(error)}`, + ); + } + this.stopped = true; + } + + private cliOptions(appleContainer: AppleContainerOptions): AppleContainerCliOptions { + return appleContainer.cliPath ? { binary: appleContainer.cliPath } : {}; + } + + /** + * Creates the run-scoped host directories, plus the `.copilot` mountpoints the + * nested log mounts land on. A missing mountpoint inside a virtiofs share is a + * boot failure, so they are created here rather than discovered later. + */ + private async prepareDirectories(directories: AppleContainerRunDirectories): Promise { + const logPaths = resolveLogPaths(this.config); + for (const directory of [ + directories.root, + directories.home, + directories.tmp, + directories.homeCopilotLogs, + directories.homeCopilotSessionState, + logPaths.agentLogs, + logPaths.sessionState, + ]) { + await this.dependencies.ensureDirectory(directory); + } + } + + /** + * Populates Apple Container's image store. + * + * `--skip-pull` cannot be honoured by assumption: Apple Container keeps its + * own image store, so a Docker pre-pull (including the one `setup-awf` does) + * leaves it empty. Presence is therefore *verified*, and a missing image is an + * error naming the exact `container image pull` command to run. + */ + private async ensureImages(images: AppleContainerImages, skipPull: boolean): Promise { + const lifecycle = this.lifecycle!; + for (const [role, reference] of Object.entries(images) as [keyof AppleContainerImages, string][]) { + if (skipPull) { + if (!await this.dependencies.imagePresent(lifecycle, reference)) { + throw new Error( + `--skip-pull was requested but the ${role} image ${reference} is not in Apple ` + + "Container's image store, which is independent of Docker's. Run " + + `"container image pull --platform ${APPLE_CONTAINER_PLATFORM} ${reference}" first.`, + ); + } + this.dependencies.logger.debug( + `[apple-container] ${role} image already present: ${reference}`, + ); + continue; + } + this.dependencies.logger.info(`[apple-container] Pulling ${role} image ${reference}`); + await lifecycle.pullImage(reference, { platform: APPLE_CONTAINER_PLATFORM }); + } + } +} + +/** + * Keys whose value is an environment block in `container inspect` output. + * + * Apple Container serialises the guest's full environment under + * `initProcess.environment`. Persisting that verbatim would be a real leak: + * `artifact-preservation` widens the whole audit directory to `a+rX` and CI + * routinely uploads it, and while `applySecurityMode` forces the API proxy on + * (so provider keys are placeholders), a workflow can still legitimately place + * its own secrets in the agent environment. Both existing runtimes already + * refuse to capture env — `diagnostic-collector.ts` documents its Docker + * capture as "no env vars", and the Cloud Hypervisor collector captures only + * logs, counters, and the network plan — so this keeps the contract rather than + * quietly widening it. + */ +const ENVIRONMENT_KEYS = new Set(['environment', 'env']); + +/** + * Replaces every environment block with its variable *names*. + * + * Names are what triage actually needs ("was `ANTHROPIC_BASE_URL` set?"), and + * they carry no secret material. Accepts both the array-of-`KEY=VALUE` and the + * object shapes so a future CLI change cannot slip values through by switching + * representation. + */ +function redactEnvironmentBlocks(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(redactEnvironmentBlocks); + } + if (value === null || typeof value !== 'object') { + return value; + } + const result: Record = {}; + for (const [key, nested] of Object.entries(value as Record)) { + if (!ENVIRONMENT_KEYS.has(key.toLowerCase())) { + result[key] = redactEnvironmentBlocks(nested); + continue; + } + result[key] = `[REDACTED: ${environmentNames(nested).join(', ')}]`; + } + return result; +} + +function environmentNames(value: unknown): readonly string[] { + if (Array.isArray(value)) { + return value + .filter((entry): entry is string => typeof entry === 'string') + .map((entry) => entry.split('=', 1)[0]); + } + if (value !== null && typeof value === 'object') { + return Object.keys(value as Record); + } + return []; +} + +/** + * Redacts a single capture before it is persisted. + * + * Only the container inspect capture can carry an environment block. A capture + * whose JSON cannot be parsed is replaced entirely rather than written through: + * an unparseable body is exactly the case where a value could survive + * unredacted, so it fails closed and says so. + */ +function redactAppleContainerCapture( + capture: AppleContainerDiagnostics['captures'][number], +): AppleContainerDiagnostics['captures'][number] { + if (capture.name !== 'container-inspect.json' || !capture.ok) { + return capture; + } + try { + const parsed: unknown = JSON.parse(capture.content); + return { + ...capture, + content: JSON.stringify(redactEnvironmentBlocks(parsed), null, 2), + }; + } catch { + return { + ...capture, + ok: false, + content: + '(withheld) "container inspect" output could not be parsed as JSON, so its guest ' + + 'environment block could not be redacted; the capture is dropped rather than persisted.', + }; + } +} + +/** + * Last-line assertion before a container is created. + * + * Layer 1 defaults to `--network none` and layer 2's merge re-emits it, so this + * can only fail if one of those invariants regressed. It is checked anyway + * because the failure mode — a VM silently attached to Apple's default vmnet + * network, with full unfiltered egress — is exactly the one that would not + * announce itself. + */ +function assertIsolatedSpec(spec: AppleContainerRunSpec): void { + if (!spec.network || spec.network.kind !== 'none') { + throw new Error( + 'Apple Container refuses to create an agent VM without --network none; omitting it ' + + 'attaches the default vmnet network and gives the agent unfiltered egress', + ); + } + if (spec.capAdd && spec.capAdd.length > 0) { + throw new Error( + `Apple Container refuses to create an agent VM with added capabilities: ${spec.capAdd.join(', ')}`, + ); + } + if ((spec.arch ?? 'arm64') !== 'arm64') { + throw new Error( + `Apple Container supports native arm64 guests only; got ${spec.arch}. Rosetta translation ` + + 'is never used.', + ); + } +} + +/** + * Run-scoped container name. + * + * Distinct per run so a leftover VM from a previous run cannot be adopted, and + * so `container list` attributes a VM to the process that made it. + */ +function appleContainerName(): string { + return `awf-agent-${process.pid}-${Date.now()}`; +} + +function describe(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export function createAppleContainerRuntimeBackend( + config: WrapperConfig, + startInfrastructure: WorkflowDependencies['startContainers'], +): AppleContainerRuntimeBackend { + return new AppleContainerRuntimeBackend(config, defaultDependencies(startInfrastructure)); +} + +/** @internal Exposed only for focused default-dependency tests. */ +// ts-prune-ignore-next +export const appleContainerRuntimeTestHelpers = { + APPLE_CONTAINER_TRANSPORT_BASE_DIRECTORY, + redactAppleContainerCapture, + defaultDependencies, + defaultResolveImages, + defaultImagePresent, + assertIsolatedSpec, + appleContainerName, +}; diff --git a/src/apple-container-runtime-selection.test.ts b/src/apple-container-runtime-selection.test.ts new file mode 100644 index 000000000..5f36fc1ff --- /dev/null +++ b/src/apple-container-runtime-selection.test.ts @@ -0,0 +1,171 @@ +/** + * Runtime selection surface for the Apple Container preview: the registry + * entry, the backend resolver, option parsing, and the infrastructure-only + * Compose output. + * + * These are the seams where a mistake is silent rather than loud — a runtime + * that falls through to Compose, an opt-in that is not enforced, or a port + * published on every interface — so each is asserted directly. + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { generateDockerCompose } from './compose-generator'; +import { + runtimeNeedsStaticDns, + runtimeUsesComposeAgent, + runtimeUsesIptables, + resolveDockerRuntime, +} from './container-runtime'; +import { resolveExternalRuntimeBackend } from './external-runtime-backend-resolver'; +import { APPLE_CONTAINER_RUNTIME } from './apple-container/runtime-validation'; +import { apiProxyPorts, SQUID_PORT } from './config/network-policy'; +import type { WrapperConfig } from './types'; + +const AGENT_IMAGE = 'ghcr.io/github/gh-aw-firewall/agent:1.0.0@sha256:' + 'a'.repeat(64); +const INIT_IMAGE = 'ghcr.io/github/gh-aw-firewall/apple-init:1.0.0@sha256:' + 'b'.repeat(64); + +let workDir: string; + +beforeAll(() => { + workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-apple-selection-')); +}); + +afterAll(() => { + fs.rmSync(workDir, { recursive: true, force: true }); +}); + +function config(overrides: Partial = {}): WrapperConfig { + return { + allowedDomains: ['github.com'], + agentCommand: 'true', + logLevel: 'info', + workDir, + containerRuntime: APPLE_CONTAINER_RUNTIME, + networkIsolation: true, + appleContainer: { previewEnabled: true, cpus: 4, memory: '8G' }, + ...overrides, + } as unknown as WrapperConfig; +} + +const networkConfig = { + subnet: '172.30.0.0/24', + squidIp: '172.30.0.10', + agentIp: '172.30.0.20', + proxyIp: '172.30.0.30', +}; + +describe('runtime registry', () => { + it('treats apple-container as an external microVM, not a Compose agent', () => { + expect(runtimeUsesComposeAgent(APPLE_CONTAINER_RUNTIME)).toBe(false); + }); + + it('declares no Docker OCI runtime for it', () => { + expect(resolveDockerRuntime(APPLE_CONTAINER_RUNTIME)).toBeUndefined(); + }); + + it('does not attempt static DNS or host-netns iptables for a NIC-less guest', () => { + expect(runtimeNeedsStaticDns(APPLE_CONTAINER_RUNTIME)).toBe(false); + expect(runtimeUsesIptables(APPLE_CONTAINER_RUNTIME)).toBe(false); + }); + + it('leaves the other runtimes unchanged', () => { + expect(runtimeUsesComposeAgent('gvisor')).toBe(true); + expect(runtimeUsesComposeAgent('sbx')).toBe(false); + expect(runtimeUsesComposeAgent('cloud-hypervisor')).toBe(false); + expect(resolveDockerRuntime('gvisor')).toBe('runsc'); + expect(runtimeUsesIptables('gvisor')).toBe(false); + expect(runtimeUsesIptables(undefined)).toBe(true); + }); +}); + +describe('resolveExternalRuntimeBackend', () => { + const startInfrastructure = jest.fn(); + + it('resolves the Apple Container backend when the preview is enabled', () => { + const backend = resolveExternalRuntimeBackend(config(), startInfrastructure); + expect(backend?.runtime).toBe(APPLE_CONTAINER_RUNTIME); + }); + + it('refuses to resolve without the explicit preview opt-in', () => { + expect(() => resolveExternalRuntimeBackend( + config({ appleContainer: { previewEnabled: false, cpus: 4, memory: '8G' } }), + startInfrastructure, + )).toThrow('--apple-container-preview'); + }); + + it('refuses to resolve with no Apple Container configuration at all', () => { + expect(() => resolveExternalRuntimeBackend( + config({ appleContainer: undefined }), + startInfrastructure, + )).toThrow('--apple-container-preview'); + }); + + it('still returns undefined for Compose runtimes', () => { + expect(resolveExternalRuntimeBackend( + config({ containerRuntime: 'gvisor', appleContainer: undefined }), + startInfrastructure, + )).toBeUndefined(); + }); +}); + +describe('infrastructure-only Compose generation', () => { + it('omits the agent and the iptables-init container', () => { + const compose = generateDockerCompose(config({ images: { agent: AGENT_IMAGE, appleInit: INIT_IMAGE, squid: AGENT_IMAGE } as WrapperConfig['images'] }), networkConfig); + expect(compose.services.agent).toBeUndefined(); + expect(compose.services['iptables-init']).toBeUndefined(); + expect(compose.services['squid-proxy']).toBeDefined(); + }); + + it('publishes Squid to loopback only, replacing the wildcard mapping', () => { + const compose = generateDockerCompose(config(), networkConfig); + expect((compose.services['squid-proxy'] as { ports: string[] }).ports) + .toEqual([`127.0.0.1:${SQUID_PORT}:${SQUID_PORT}`]); + }); + + it('publishes the four allowlisted provider ports to loopback and never Vertex', () => { + const compose = generateDockerCompose(config({ enableApiProxy: true }), networkConfig); + const ports = (compose.services['api-proxy'] as { ports: string[] }).ports; + const expected = apiProxyPorts(); + expect(ports).toEqual([ + `127.0.0.1:${expected.openai}:${expected.openai}`, + `127.0.0.1:${expected.anthropic}:${expected.anthropic}`, + `127.0.0.1:${expected.copilot}:${expected.copilot}`, + `127.0.0.1:${expected.gemini}:${expected.gemini}`, + ]); + expect(ports.some((entry) => entry.includes(String(expected.vertex)))).toBe(false); + }); + + it('binds nothing to a wildcard address', () => { + const compose = generateDockerCompose(config({ enableApiProxy: true }), networkConfig); + for (const service of Object.values(compose.services)) { + for (const port of (service as { ports?: string[] }).ports ?? []) { + expect(port.startsWith('127.0.0.1:')).toBe(true); + } + } + }); + + it('attaches publishing services to the external bridge in isolation mode', () => { + const compose = generateDockerCompose(config({ enableApiProxy: true }), networkConfig); + const squid = compose.services['squid-proxy'] as { networks: Record }; + const proxy = compose.services['api-proxy'] as { networks: Record }; + expect(Object.keys(squid.networks)).toContain('awf-ext'); + expect(Object.keys(proxy.networks)).toContain('awf-ext'); + }); + + it('leaves the sbx wildcard publication path untouched', () => { + const compose = generateDockerCompose( + config({ + containerRuntime: 'sbx', + appleContainer: undefined, + enableApiProxy: true, + }), + networkConfig, + ); + const ports = (compose.services['api-proxy'] as { ports: string[] }).ports; + const expected = apiProxyPorts(); + expect(ports).toContain(`${expected.openai}:${expected.openai}`); + }); +}); diff --git a/src/apple-container/agent-run-spec.test.ts b/src/apple-container/agent-run-spec.test.ts new file mode 100644 index 000000000..b74f6195e --- /dev/null +++ b/src/apple-container/agent-run-spec.test.ts @@ -0,0 +1,277 @@ +import * as path from 'path'; + +import { + APPLE_CONTAINER_ENTRYPOINT, + APPLE_CONTAINER_GUEST_HOME, + APPLE_CONTAINER_GUEST_TMP, + appleContainerRunDirectories, + buildAppleContainerAgentSpec, + buildAppleContainerGuestEnvironment, + buildAppleContainerMounts, +} from './agent-run-spec'; +import { apiProxyPorts, SQUID_PORT } from '../config/network-policy'; +import { buildAppleContainerRunArgs } from './run-args'; +import { + applyAppleContainerTransportToRunSpec, + planAppleContainerTransport, +} from './transport-plan'; +import type { AppleContainerSocketDirectoryHandle } from './transport-socket-dir'; +import { planAppleContainerInfrastructure } from './infrastructure-endpoints'; +import type { WrapperConfig } from '../types'; + +const WORK_DIR = '/tmp/awf-apple-test'; +const WORKSPACE = '/Users/runner/work/repo/repo'; +const IMAGE = 'ghcr.io/github/gh-aw-firewall/agent:1.0.0@sha256:' + 'a'.repeat(64); +const INIT_IMAGE = 'ghcr.io/github/gh-aw-firewall/apple-init:1.0.0@sha256:' + 'b'.repeat(64); + +function config(overrides: Partial = {}): WrapperConfig { + return { + allowedDomains: ['github.com'], + agentCommand: 'npx @github/copilot --prompt "hi"', + logLevel: 'info', + workDir: WORK_DIR, + containerRuntime: 'apple-container', + networkIsolation: true, + appleContainer: { previewEnabled: true, cpus: 4, memory: '8G' }, + ...overrides, + } as unknown as WrapperConfig; +} + +const directories = appleContainerRunDirectories(WORK_DIR); + +describe('appleContainerRunDirectories', () => { + it('keeps every writable host directory inside the run work directory', () => { + expect(directories.root).toBe(path.join(WORK_DIR, 'apple-container')); + expect(directories.home).toBe(path.join(directories.root, 'home')); + expect(directories.tmp).toBe(path.join(directories.root, 'tmp')); + expect(directories.homeCopilotLogs).toBe(path.join(directories.home, '.copilot', 'logs')); + expect(directories.homeCopilotSessionState) + .toBe(path.join(directories.home, '.copilot', 'session-state')); + }); +}); + +describe('buildAppleContainerMounts', () => { + it('exposes only the workspace and AWF-owned run directories', () => { + const mounts = buildAppleContainerMounts({ + config: config(), + directories, + workspaceDir: WORKSPACE, + }); + expect(mounts.map((mount) => mount.target)).toEqual([ + APPLE_CONTAINER_GUEST_TMP, + APPLE_CONTAINER_GUEST_HOME, + WORKSPACE, + `${APPLE_CONTAINER_GUEST_HOME}/.copilot/logs`, + `${APPLE_CONTAINER_GUEST_HOME}/.copilot/session-state`, + ]); + }); + + it('never mounts host credential stores, the home directory, or a Docker socket', () => { + const mounts = buildAppleContainerMounts({ + config: config(), + directories, + workspaceDir: WORKSPACE, + ghAwStateDir: '/Users/runner/work/_temp/gh-aw', + }); + const sources = mounts.map((mount) => mount.source); + for (const forbidden of [ + '/', + '/etc', + '/usr', + '/var/run/docker.sock', + '/Users/runner/.ssh', + '/Users/runner/.aws', + '/Users/runner/.docker', + '/Users/runner', + ]) { + expect(sources).not.toContain(forbidden); + } + }); + + it('mounts the gh-aw state directory at its own host path', () => { + const ghAwStateDir = '/Users/runner/work/_temp/gh-aw'; + const mounts = buildAppleContainerMounts({ + config: config(), + directories, + workspaceDir: WORKSPACE, + ghAwStateDir, + }); + expect(mounts).toContainEqual({ source: ghAwStateDir, target: ghAwStateDir, readOnly: false }); + }); + + it('does not double-mount a gh-aw directory nested inside the workspace', () => { + const nested = path.join(WORKSPACE, 'gh-aw'); + const mounts = buildAppleContainerMounts({ + config: config(), + directories, + workspaceDir: WORKSPACE, + ghAwStateDir: nested, + }); + expect(mounts.filter((mount) => mount.target === nested)).toHaveLength(0); + }); +}); + +describe('buildAppleContainerGuestEnvironment', () => { + it('points every endpoint at guest loopback', () => { + const environment = buildAppleContainerGuestEnvironment({ + config: config({ enableApiProxy: true, anthropicApiKey: 'sk-secret-value' }), + workspaceDir: WORKSPACE, + }); + expect(environment.HTTPS_PROXY).toBe(`http://127.0.0.1:${SQUID_PORT}`); + expect(environment.SQUID_PROXY_HOST).toBe('127.0.0.1'); + expect(environment.ANTHROPIC_BASE_URL).toBe(`http://127.0.0.1:${apiProxyPorts().anthropic}`); + }); + + it('never carries a real provider credential into the guest', () => { + expect(() => buildAppleContainerGuestEnvironment({ + config: config({ + enableApiProxy: true, + additionalEnv: { LEAKED: 'sk-secret-value' }, + anthropicApiKey: 'sk-secret-value', + }), + workspaceDir: WORKSPACE, + })).toThrow('Refusing to pass a real provider credential'); + }); + + it('sets HOME and the XDG paths to the writable run-scoped mount', () => { + const environment = buildAppleContainerGuestEnvironment({ + config: config(), + workspaceDir: WORKSPACE, + }); + expect(environment.HOME).toBe(APPLE_CONTAINER_GUEST_HOME); + expect(environment.XDG_CACHE_HOME).toBe(`${APPLE_CONTAINER_GUEST_HOME}/.cache`); + expect(environment.TMPDIR).toBe(APPLE_CONTAINER_GUEST_TMP); + }); + + it('leaves NO_PROXY to the transport plan instead of asserting a Docker-shaped one', () => { + const environment = buildAppleContainerGuestEnvironment({ + config: config({ enableApiProxy: true }), + workspaceDir: WORKSPACE, + }); + expect(environment.NO_PROXY).toBeUndefined(); + expect(environment.no_proxy).toBeUndefined(); + expect(environment.AWF_INIT_SIGNAL_DIR).toBeUndefined(); + }); + + it('fails closed on a value that cannot survive a single argv token', () => { + expect(() => buildAppleContainerGuestEnvironment({ + config: config({ additionalEnv: { MULTILINE: 'one\ntwo' } }), + workspaceDir: WORKSPACE, + })).toThrow('MULTILINE'); + }); +}); + +describe('buildAppleContainerAgentSpec', () => { + const spec = () => buildAppleContainerAgentSpec({ + config: config({ enableApiProxy: true }), + directories, + workspaceDir: WORKSPACE, + image: IMAGE, + name: 'awf-agent-1-2', + cpus: 4, + memory: '8G', + identity: { uid: '501', gid: '20' }, + }); + + it('runs as the host identity so workspace writes are owned by the runner user', () => { + expect(spec().user).toBe('501:20'); + }); + + it('bypasses the Docker-specific agent entrypoint', () => { + const built = spec(); + expect(built.entrypoint).toBe(APPLE_CONTAINER_ENTRYPOINT); + expect(built.args).toEqual(['-lc', 'npx @github/copilot --prompt "hi"']); + }); + + it('mounts the guest root filesystem read-only and requests no TTY', () => { + expect(spec().readOnlyRootfs).toBe(true); + expect(spec().tty).toBe(false); + }); + + it('adds no capability and no network of its own', () => { + expect(spec().capAdd).toBeUndefined(); + expect(spec().network).toBeUndefined(); + }); + + it('honours an explicit container working directory', () => { + const built = buildAppleContainerAgentSpec({ + config: config({ containerWorkDir: '/workspace/sub' }), + directories, + workspaceDir: WORKSPACE, + image: IMAGE, + name: 'awf-agent-1-2', + cpus: 2, + memory: '4G', + identity: { uid: '501', gid: '20' }, + }); + expect(built.workdir).toBe('/workspace/sub'); + }); +}); + +describe('integration with the layer-2 transport plan', () => { + const directoryHandle: AppleContainerSocketDirectoryHandle = { + path: '/tmp/awf-apple/t', + runId: 'abcdef01', + }; + + function merged(overrides: Partial = {}) { + const wrapper = config({ enableApiProxy: true, ...overrides }); + const plan = planAppleContainerTransport({ + directory: directoryHandle, + capabilities: planAppleContainerInfrastructure(wrapper).capabilities, + initImage: INIT_IMAGE, + }); + return applyAppleContainerTransportToRunSpec( + buildAppleContainerAgentSpec({ + config: wrapper, + directories, + workspaceDir: WORKSPACE, + image: IMAGE, + name: 'awf-agent-1-2', + cpus: 4, + memory: '8G', + identity: { uid: '501', gid: '20' }, + }), + plan, + ); + } + + it('produces a spec the transport accepts without an endpoint conflict', () => { + expect(() => merged()).not.toThrow(); + }); + + it('always emits --network none in the final argv', () => { + const argv = buildAppleContainerRunArgs(merged(), 'create'); + const index = argv.indexOf('--network'); + expect(index).toBeGreaterThanOrEqual(0); + expect(argv[index + 1]).toBe('none'); + }); + + it('emits native arm64 and never requests a translated architecture', () => { + const argv = buildAppleContainerRunArgs(merged(), 'create'); + expect(argv[argv.indexOf('--arch') + 1]).toBe('arm64'); + }); + + it('drops NET_RAW and the other escape-primitive capabilities', () => { + const spec = merged(); + expect(spec.capDrop).toEqual( + expect.arrayContaining(['NET_ADMIN', 'NET_RAW', 'SYS_ADMIN', 'SYS_MODULE', 'SYS_RAWIO']), + ); + expect(spec.capAdd ?? []).toEqual([]); + }); + + it('publishes only allowlisted capability sockets', () => { + const spec = merged(); + for (const mount of spec.socketMounts ?? []) { + expect(mount.containerPath.startsWith('/run/awf/transport/v1/')).toBe(true); + expect(mount.hostPath.startsWith(directoryHandle.path)).toBe(true); + } + expect( + (spec.socketMounts ?? []).some((mount) => mount.hostPath.includes('docker.sock')), + ).toBe(false); + }); + + it('pins the init image supplied by the plan', () => { + expect(merged().initImage).toBe(INIT_IMAGE); + }); +}); diff --git a/src/apple-container/agent-run-spec.ts b/src/apple-container/agent-run-spec.ts new file mode 100644 index 000000000..54c12e035 --- /dev/null +++ b/src/apple-container/agent-run-spec.ts @@ -0,0 +1,312 @@ +/** + * Builds the Apple Container agent run spec: what the guest can see, who it + * runs as, and what it is told about its endpoints. + * + * Everything here is *policy*; layer 1 turns the result into argv and layer 2 + * merges in the capability transport. The three concerns this module owns: + * + * **Filesystem.** Apple Container is not the Docker topology: there is no + * chroot at `/host`, no sysroot, no `/etc` cherry-picking, and no Docker socket. + * The guest runs the agent image's own root filesystem read-only and receives a + * short, explicit list of writable host directories — the workspace, the + * `gh-aw` state directory when one exists, and two run-scoped directories AWF + * creates for `/tmp` and `$HOME`. Nothing else is mounted, so host credentials + * (`~/.ssh`, `~/.aws`, `~/.docker`, the login keychain) are absent by + * construction rather than by exclusion. That is deliberately unlike the Docker + * path's credential-hiding overlays, which have to temporarily shadow live host + * files; there is nothing to shadow when the mount was never made. + * + * **Identity.** The workload runs as the host uid:gid so files it writes into + * the workspace are owned by the runner user, and `$HOME` points at a + * run-scoped directory that same uid owns. + * + * **Environment.** The guest's endpoints are all `127.0.0.1`, served by the + * layer-2 guest relay. Reusing AWF's own environment builders with a loopback + * "network" yields exactly the endpoints the transport plan publishes, so the + * plan's conflict check passes on identical values rather than being bypassed. + * Real provider credentials never appear: they stay in the host-side API proxy + * sidecar, and {@link buildAppleContainerGuestEnvironment} re-asserts that + * before the values can reach argv. + */ + +import * as path from 'path'; + +import { NETWORK_SUBNET } from '../config/network-policy'; +import { getSafeHostGid, getSafeHostUid } from '../host-identity'; +import { resolveLogPaths } from '../log-paths'; +import { buildGuestEnvironment } from '../microvm/guest-environment'; +import type { WrapperConfig } from '../types'; +import { apiProxyPorts } from '../config/network-policy'; +import { APPLE_CONTAINER_LOOPBACK_HOST } from './infrastructure-endpoints'; +import type { AppleContainerBindMount, AppleContainerRunSpec } from './run-args'; +import { assertAppleContainerEnvValue } from './validation'; + +/** Writable `$HOME` for the workload, backed by a run-scoped host directory. */ +export const APPLE_CONTAINER_GUEST_HOME = '/awf/home'; + +/** Writable `/tmp`, backed by a run-scoped host directory. */ +export const APPLE_CONTAINER_GUEST_TMP = '/tmp'; + +/** Guest working directory when the config does not name one. */ +export const APPLE_CONTAINER_DEFAULT_WORKDIR = '/workspace'; + +/** + * Entrypoint override. + * + * The agent image's own entrypoint is Docker-specific end to end: it waits on + * the `awf-iptables-init` ready file, remaps `awfuser`, rewrites + * `/etc/resolv.conf`, chroots into `/host`, and drops `SYS_CHROOT`/`SYS_ADMIN`. + * None of that exists — or is needed — in a VM whose isolation is the VM. It is + * bypassed rather than adapted so no half-applicable Docker assumption runs. + */ +export const APPLE_CONTAINER_ENTRYPOINT = '/bin/bash'; + +/** Host-side run-scoped directories the backend creates before launch. */ +export interface AppleContainerRunDirectories { + readonly root: string; + readonly home: string; + readonly tmp: string; + /** Mountpoint for the agent log directory, inside {@link home}. */ + readonly homeCopilotLogs: string; + /** Mountpoint for the agent session-state directory, inside {@link home}. */ + readonly homeCopilotSessionState: string; +} + +/** + * Derives the run-scoped host directory layout. + * + * The `.copilot` mountpoints live inside the guest home directory because the + * agent CLI writes there unconditionally. They are created on the host so the + * nested virtiofs mounts have a real mountpoint to land on; a missing + * mountpoint inside a virtiofs share is a boot-time failure, not a fallback. + */ +export function appleContainerRunDirectories(workDir: string): AppleContainerRunDirectories { + const root = path.join(workDir, 'apple-container'); + const home = path.join(root, 'home'); + return { + root, + home, + tmp: path.join(root, 'tmp'), + homeCopilotLogs: path.join(home, '.copilot', 'logs'), + homeCopilotSessionState: path.join(home, '.copilot', 'session-state'), + }; +} + +export interface AppleContainerMountPlanInput { + readonly config: WrapperConfig; + readonly directories: AppleContainerRunDirectories; + readonly workspaceDir: string; + /** `${RUNNER_TEMP}/gh-aw`, when the runner created one. */ + readonly ghAwStateDir?: string; +} + +/** + * Builds the complete bind-mount list. + * + * Host paths are mounted at their *own* absolute path wherever the workload may + * see that path in its arguments or environment (the workspace, the `gh-aw` + * state directory), because gh-aw passes absolute runner paths through both. + */ +export function buildAppleContainerMounts( + input: AppleContainerMountPlanInput, +): readonly AppleContainerBindMount[] { + const { config, directories, workspaceDir, ghAwStateDir } = input; + const logPaths = resolveLogPaths(config); + + const mounts: AppleContainerBindMount[] = [ + { source: directories.tmp, target: APPLE_CONTAINER_GUEST_TMP, readOnly: false }, + { source: directories.home, target: APPLE_CONTAINER_GUEST_HOME, readOnly: false }, + { source: workspaceDir, target: workspaceDir, readOnly: false }, + { + source: logPaths.agentLogs, + target: `${APPLE_CONTAINER_GUEST_HOME}/.copilot/logs`, + readOnly: false, + }, + { + source: logPaths.sessionState, + target: `${APPLE_CONTAINER_GUEST_HOME}/.copilot/session-state`, + readOnly: false, + }, + ]; + + // Safe outputs and tool state. Mounted at the identical host path so the + // absolute paths gh-aw injects (GH_AW_SAFE_OUTPUTS and friends) resolve. + if (ghAwStateDir && ghAwStateDir !== workspaceDir && !isWithin(ghAwStateDir, workspaceDir)) { + mounts.push({ source: ghAwStateDir, target: ghAwStateDir, readOnly: false }); + } + + return Object.freeze(mounts.map((mount) => Object.freeze(mount))); +} + +function isWithin(candidate: string, parent: string): boolean { + const relative = path.relative(parent, candidate); + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); +} + +export interface AppleContainerGuestEnvironmentInput { + readonly config: WrapperConfig; + readonly workspaceDir: string; +} + +/** + * Environment variables the transport plan owns. + * + * `applyAppleContainerTransportToRunSpec` refuses a spec that sets any of these + * to a *different* value, which is the correct behaviour — it means nothing can + * quietly repoint the workload at a non-transport endpoint. `NO_PROXY` is the + * one AWF's builder legitimately computes differently (it lists Docker service + * names the guest cannot resolve), so it is dropped here and the plan's value + * is used instead of being fought over. + */ +const TRANSPORT_OWNED_ENV = ['NO_PROXY', 'no_proxy'] as const; + +/** + * Environment variables that describe the Docker topology and mean nothing — + * or something misleading — inside a NIC-less VM. + */ +const DOCKER_TOPOLOGY_ENV = [ + // The iptables-init handshake directory does not exist; leaving it set would + // make a future guest-side check wait on a file that is never written. + 'AWF_INIT_SIGNAL_DIR', +] as const; + +/** + * Builds the guest environment. + * + * @throws when a value cannot be represented as a single `--env KEY=VALUE` argv + * token, or when a real provider credential would cross the VM boundary. + */ +export function buildAppleContainerGuestEnvironment( + input: AppleContainerGuestEnvironmentInput, +): Record { + const { config, workspaceDir } = input; + + const environment = buildGuestEnvironment({ + config, + networkConfig: { + subnet: NETWORK_SUBNET, + // Every endpoint is loopback inside the guest: the layer-2 relay listens + // on 127.0.0.1 at the same port the sidecar uses on the host, so AWF's + // own builders produce transport-identical URLs. + squidIp: APPLE_CONTAINER_LOOPBACK_HOST, + agentIp: APPLE_CONTAINER_LOOPBACK_HOST, + ...(config.enableApiProxy ? { proxyIp: APPLE_CONTAINER_LOOPBACK_HOST } : {}), + ...(config.difcProxyHost ? { cliProxyIp: APPLE_CONTAINER_LOOPBACK_HOST } : {}), + }, + home: APPLE_CONTAINER_GUEST_HOME, + workspace: workspaceDir, + runtimeName: 'apple-container', + runtimeDisplayName: 'Apple Container', + }); + + for (const name of [...TRANSPORT_OWNED_ENV, ...DOCKER_TOPOLOGY_ENV]) { + delete environment[name]; + } + + // Caches and config default to `$HOME`, which is the run-scoped writable + // mount. Without these, tools fall back to paths on the read-only rootfs. + environment.XDG_CACHE_HOME = `${APPLE_CONTAINER_GUEST_HOME}/.cache`; + environment.XDG_CONFIG_HOME = `${APPLE_CONTAINER_GUEST_HOME}/.config`; + environment.XDG_DATA_HOME = `${APPLE_CONTAINER_GUEST_HOME}/.local/share`; + environment.TMPDIR = APPLE_CONTAINER_GUEST_TMP; + + assertVertexEndpointAbsent(environment); + assertArgvRepresentable(environment); + return environment; +} + +/** + * The Vertex provider port is not in the capability allowlist, so an endpoint + * pointing at it would be a black hole inside the guest. `runtime-validation` + * rejects a Vertex configuration up front; this is the defence in depth that + * makes a future regression in that guard fail loudly here instead of silently + * producing a dead endpoint. + */ +function assertVertexEndpointAbsent(environment: Readonly>): void { + const vertexEndpoint = `:${apiProxyPorts().vertex}`; + for (const [name, value] of Object.entries(environment)) { + if (value.includes(APPLE_CONTAINER_LOOPBACK_HOST) && value.includes(vertexEndpoint)) { + throw new Error( + `Apple Container guest variable ${name} points at the Vertex API proxy port, which the ` + + 'capability transport does not bridge; remove the Vertex configuration', + ); + } + } +} + +/** + * Fails closed on values that cannot survive argv. + * + * `--env` is a single token, so a newline or NUL would either be rejected by + * the CLI or silently truncate. Dropping the variable instead would hand the + * workload a subtly different environment than the operator configured, so the + * run is refused with the offending names named. + */ +function assertArgvRepresentable(environment: Readonly>): void { + const invalid: string[] = []; + for (const [name, value] of Object.entries(environment)) { + try { + assertAppleContainerEnvValue(name, value); + } catch { + invalid.push(name); + } + } + if (invalid.length > 0) { + throw new Error( + `Apple Container cannot pass ${invalid.length} environment variable(s) containing NUL or ` + + `newlines through "container run --env": ${invalid.join(', ')}. ` + + 'Remove them with --exclude-env or set a single-line value.', + ); + } +} + +export interface AppleContainerAgentSpecInput { + readonly config: WrapperConfig; + readonly directories: AppleContainerRunDirectories; + readonly workspaceDir: string; + readonly ghAwStateDir?: string; + readonly image: string; + readonly name: string; + readonly cpus: number; + readonly memory: string; + readonly identity?: { readonly uid: string; readonly gid: string }; +} + +/** + * Assembles the layer-1 run spec for the agent. + * + * The transport plan is merged in afterwards by `transport.applyTo(spec)`, which + * adds the published sockets, endpoint environment, cap drops, and init image, + * and re-asserts `--network none`. Nothing here sets a network or a capability, + * so the merge has nothing to fight with. + */ +export function buildAppleContainerAgentSpec( + input: AppleContainerAgentSpecInput, +): AppleContainerRunSpec { + const { config, directories, workspaceDir, ghAwStateDir, image, name, cpus, memory } = input; + const identity = input.identity ?? { uid: getSafeHostUid(), gid: getSafeHostGid() }; + + return { + image, + name, + cpus, + memory, + user: `${identity.uid}:${identity.gid}`, + workdir: config.containerWorkDir || workspaceDir || APPLE_CONTAINER_DEFAULT_WORKDIR, + entrypoint: APPLE_CONTAINER_ENTRYPOINT, + // `-l` gives the workload the image's login profile (PATH additions for + // node, gh, and the agent CLIs), matching what the Docker entrypoint's + // `bash -lc` invocation provides. + args: ['-lc', config.agentCommand], + env: buildAppleContainerGuestEnvironment({ config, workspaceDir }), + mounts: buildAppleContainerMounts({ config, directories, workspaceDir, ghAwStateDir }), + readOnlyRootfs: true, + // No TTY: `runtime-validation` rejects `--tty`, and requesting a PTY in CI + // corrupts captured output. + tty: false, + // stdin is attached so an interactive agent CLI reading from stdin behaves + // as it does under Docker; a closed stdin yields immediate EOF either way. + interactive: true, + removeOnExit: false, + }; +} diff --git a/src/apple-container/infrastructure-endpoints.test.ts b/src/apple-container/infrastructure-endpoints.test.ts new file mode 100644 index 000000000..6bb1fbed3 --- /dev/null +++ b/src/apple-container/infrastructure-endpoints.test.ts @@ -0,0 +1,161 @@ +import { + APPLE_CONTAINER_LOOPBACK_HOST, + appleContainerLoopbackPortConflicts, + appleContainerPortMapping, + applyAppleContainerLoopbackPublishing, + planAppleContainerInfrastructure, +} from './infrastructure-endpoints'; +import { apiProxyPorts, CLI_PROXY_PORT, SQUID_PORT } from '../config/network-policy'; +import { APPLE_CONTAINER_TRANSPORT_CAPABILITIES } from './transport-capabilities'; +import type { WrapperConfig } from '../types'; + +function config(overrides: Partial = {}): WrapperConfig { + return { + allowedDomains: ['github.com'], + agentCommand: 'true', + logLevel: 'info', + workDir: '/tmp/awf-test', + containerRuntime: 'apple-container', + networkIsolation: true, + ...overrides, + } as unknown as WrapperConfig; +} + +describe('planAppleContainerInfrastructure', () => { + it('always includes Squid, the guest\'s only egress path', () => { + const plan = planAppleContainerInfrastructure(config()); + expect(plan.capabilities.map((entry) => entry.id)).toEqual(['squid']); + expect(plan.publications).toEqual([ + { + service: 'squid-proxy', + containerPort: SQUID_PORT, + hostPort: SQUID_PORT, + capability: 'squid', + }, + ]); + }); + + it('adds the four allowlisted provider ports when the API proxy is enabled', () => { + const plan = planAppleContainerInfrastructure(config({ enableApiProxy: true })); + expect(plan.capabilities.map((entry) => entry.id)).toEqual([ + 'squid', + 'api-proxy-openai', + 'api-proxy-anthropic', + 'api-proxy-copilot', + 'api-proxy-gemini', + ]); + expect(plan.services).toEqual(['squid-proxy', 'api-proxy']); + }); + + it('never publishes the Vertex provider port', () => { + const plan = planAppleContainerInfrastructure(config({ enableApiProxy: true })); + const vertex = apiProxyPorts().vertex; + expect(plan.publications.some((entry) => entry.containerPort === vertex)).toBe(false); + expect(plan.capabilities.some((entry) => entry.upstream.port === vertex)).toBe(false); + }); + + it('adds the CLI proxy only when a DIFC proxy host is configured', () => { + expect(planAppleContainerInfrastructure(config()).capabilities.map((e) => e.id)) + .not.toContain('cli-proxy'); + const plan = planAppleContainerInfrastructure(config({ difcProxyHost: 'https://difc:18443' })); + expect(plan.capabilities.map((entry) => entry.id)).toContain('cli-proxy'); + expect(plan.publications).toContainEqual({ + service: 'cli-proxy', + containerPort: CLI_PROXY_PORT, + hostPort: CLI_PROXY_PORT, + capability: 'cli-proxy', + }); + }); + + it('only ever names capabilities that exist in the layer-2 allowlist', () => { + const allowed = new Set(APPLE_CONTAINER_TRANSPORT_CAPABILITIES.map((entry) => entry.id)); + const plan = planAppleContainerInfrastructure( + config({ enableApiProxy: true, difcProxyHost: 'https://difc:18443' }), + ); + for (const capability of plan.capabilities) { + expect(allowed.has(capability.id as never)).toBe(true); + } + }); + + it('dials only loopback IP literals, never a hostname', () => { + const plan = planAppleContainerInfrastructure( + config({ enableApiProxy: true, difcProxyHost: 'https://difc:18443' }), + ); + for (const capability of plan.capabilities) { + expect(capability.upstream.host).toBe(APPLE_CONTAINER_LOOPBACK_HOST); + } + }); +}); + +describe('appleContainerPortMapping', () => { + it('binds the publication to loopback only', () => { + const [publication] = planAppleContainerInfrastructure(config()).publications; + expect(appleContainerPortMapping(publication)).toBe(`127.0.0.1:${SQUID_PORT}:${SQUID_PORT}`); + }); +}); + +describe('applyAppleContainerLoopbackPublishing', () => { + it('replaces a wildcard publication rather than adding beside it', () => { + const services: Record = { + 'squid-proxy': { ports: [`${SQUID_PORT}:${SQUID_PORT}`] }, + }; + applyAppleContainerLoopbackPublishing(services, planAppleContainerInfrastructure(config())); + expect((services['squid-proxy'] as { ports: string[] }).ports) + .toEqual([`127.0.0.1:${SQUID_PORT}:${SQUID_PORT}`]); + }); + + it('groups every provider port onto the api-proxy service', () => { + const services: Record = { + 'squid-proxy': {}, + 'api-proxy': {}, + }; + applyAppleContainerLoopbackPublishing( + services, + planAppleContainerInfrastructure(config({ enableApiProxy: true })), + ); + const ports = apiProxyPorts(); + expect((services['api-proxy'] as { ports: string[] }).ports).toEqual([ + `127.0.0.1:${ports.openai}:${ports.openai}`, + `127.0.0.1:${ports.anthropic}:${ports.anthropic}`, + `127.0.0.1:${ports.copilot}:${ports.copilot}`, + `127.0.0.1:${ports.gemini}:${ports.gemini}`, + ]); + }); + + it('fails closed when a required service was not generated', () => { + expect(() => applyAppleContainerLoopbackPublishing( + {}, + planAppleContainerInfrastructure(config()), + )).toThrow('requires the "squid-proxy" Compose service'); + }); +}); + +describe('appleContainerLoopbackPortConflicts', () => { + it('reports nothing when every port is free', async () => { + const plan = planAppleContainerInfrastructure(config({ enableApiProxy: true })); + const conflicts = await appleContainerLoopbackPortConflicts(plan, { + isPortInUse: async () => false, + }); + expect(conflicts).toEqual([]); + }); + + it('reports every occupied port so the operator sees the whole conflict', async () => { + const plan = planAppleContainerInfrastructure(config({ enableApiProxy: true })); + const conflicts = await appleContainerLoopbackPortConflicts(plan, { + isPortInUse: async (port) => port === SQUID_PORT || port === apiProxyPorts().copilot, + }); + expect(conflicts.map((entry) => entry.capability)).toEqual(['squid', 'api-proxy-copilot']); + }); + + it('probes the real loopback address and finds a free port free', async () => { + const plan = planAppleContainerInfrastructure(config()); + // Uses the default probe against a port nothing is listening on; a live + // ECONNREFUSED must be reported as "free" rather than as a conflict. + const conflicts = await appleContainerLoopbackPortConflicts( + { ...plan, publications: [{ ...plan.publications[0], hostPort: 1, containerPort: 1 }] }, + undefined, + 250, + ); + expect(conflicts).toEqual([]); + }); +}); diff --git a/src/apple-container/infrastructure-endpoints.ts b/src/apple-container/infrastructure-endpoints.ts new file mode 100644 index 000000000..b27b8380d --- /dev/null +++ b/src/apple-container/infrastructure-endpoints.ts @@ -0,0 +1,201 @@ +/** + * Bridges AWF's Docker Compose infrastructure to the Apple Container guest. + * + * The agent VM has no NIC, and on macOS the Compose sidecars live inside the + * Docker Desktop VM, so their `172.30.0.x` addresses are unreachable from the + * macOS host as well. Two hops are therefore required, and this module owns the + * first one: + * + * ``` + * sidecar container --(docker publish 127.0.0.1:P)--> macOS loopback + * macOS loopback --(layer-2 relay + --publish-socket)--> guest 127.0.0.1:P + * ``` + * + * Three properties matter: + * + * 1. **Loopback only.** Every publication is bound to `127.0.0.1` explicitly. + * Docker's default (`"3128:3128"`) binds `0.0.0.0`, which would expose an + * open forward proxy and unauthenticated credential-injecting endpoints to + * the entire network the runner sits on. This module rewrites those, it does + * not merely add to them. + * 2. **Exactly the required ports.** The publication set is derived from the + * same configuration that derives the capability set, so a port is published + * if and only if a capability relays to it. In particular the Vertex + * provider port is never published, because no capability can carry it. + * 3. **Validated before use.** {@link appleContainerLoopbackPortConflicts} + * proves the fixed ports are free before Compose is started, so a port that + * is already in use fails with an actionable message instead of producing a + * relay that silently fronts somebody else's listener. + */ + +import * as net from 'net'; + +import { apiProxyPorts, CLI_PROXY_PORT, SQUID_PORT } from '../config/network-policy'; +import type { WrapperConfig } from '../types'; +import type { AppleContainerCapabilityId } from './transport-capabilities'; +import type { AppleContainerTransportCapabilityRequest } from './transport-plan'; + +/** + * Address every AWF infrastructure port is published on, and the address every + * relay dials. A literal, so the relay never resolves a name. + */ +export const APPLE_CONTAINER_LOOPBACK_HOST = '127.0.0.1'; + +/** Compose service key whose published ports back a capability. */ +export type AppleContainerInfrastructureService = 'squid-proxy' | 'api-proxy' | 'cli-proxy'; + +export interface AppleContainerPortPublication { + readonly service: AppleContainerInfrastructureService; + /** Port inside the sidecar container. */ + readonly containerPort: number; + /** Port on macOS loopback. Fixed to `containerPort` (see module comment). */ + readonly hostPort: number; + readonly capability: AppleContainerCapabilityId; +} + +export interface AppleContainerInfrastructurePlan { + readonly publications: readonly AppleContainerPortPublication[]; + readonly capabilities: readonly AppleContainerTransportCapabilityRequest[]; + /** Compose services that must publish at least one loopback port. */ + readonly services: readonly AppleContainerInfrastructureService[]; +} + +/** Provider ports the transport allowlist covers, in capability order. */ +function apiProxyPublications(): readonly Omit[] { + const ports = apiProxyPorts(); + return [ + { service: 'api-proxy', containerPort: ports.openai, capability: 'api-proxy-openai' }, + { service: 'api-proxy', containerPort: ports.anthropic, capability: 'api-proxy-anthropic' }, + { service: 'api-proxy', containerPort: ports.copilot, capability: 'api-proxy-copilot' }, + { service: 'api-proxy', containerPort: ports.gemini, capability: 'api-proxy-gemini' }, + // `ports.vertex` (10004) is deliberately absent: there is no Vertex entry in + // APPLE_CONTAINER_TRANSPORT_CAPABILITIES, so it could not be relayed even if + // it were published. runtime-validation rejects a Vertex configuration up + // front rather than letting it reach here and silently lose its endpoint. + ]; +} + +/** + * Derives the infrastructure publication and capability sets from the config. + * + * Squid is unconditional: it is the guest's only egress path, and layer 2 + * refuses a plan without it. + */ +export function planAppleContainerInfrastructure( + config: WrapperConfig, +): AppleContainerInfrastructurePlan { + const planned: Omit[] = [ + { service: 'squid-proxy', containerPort: SQUID_PORT, capability: 'squid' }, + ]; + + if (config.enableApiProxy) { + planned.push(...apiProxyPublications()); + } + if (config.difcProxyHost) { + planned.push({ + service: 'cli-proxy', + containerPort: CLI_PROXY_PORT, + capability: 'cli-proxy', + }); + } + + const publications = planned.map((entry) => Object.freeze({ ...entry, hostPort: entry.containerPort })); + const services = [...new Set(publications.map((entry) => entry.service))]; + + return Object.freeze({ + publications: Object.freeze(publications), + capabilities: Object.freeze(publications.map((entry) => Object.freeze({ + id: entry.capability, + upstream: Object.freeze({ host: APPLE_CONTAINER_LOOPBACK_HOST, port: entry.hostPort }), + }))), + services: Object.freeze(services), + }); +} + +/** Compose `ports:` entry that binds the publication to loopback only. */ +export function appleContainerPortMapping(publication: AppleContainerPortPublication): string { + return `${APPLE_CONTAINER_LOOPBACK_HOST}:${publication.hostPort}:${publication.containerPort}`; +} + +interface ComposeServiceLike { + ports?: unknown; + [key: string]: unknown; +} + +/** + * Replaces each backing service's `ports` with the loopback-only publication + * set for this run. + * + * Replacement rather than merging is the point: `buildSquidService` publishes + * `3128:3128` on all interfaces for the Docker topology, and leaving that entry + * in place would keep an open forward proxy listening on every host interface + * while the loopback entry sat harmlessly beside it. + * + * @throws when a service the plan needs is missing from the Compose output, + * which would otherwise surface as an unreachable capability inside the VM. + */ +export function applyAppleContainerLoopbackPublishing( + services: Record, + plan: AppleContainerInfrastructurePlan, +): void { + for (const service of plan.services) { + const target = services[service] as ComposeServiceLike | undefined; + if (!target || typeof target !== 'object') { + throw new Error( + `Apple Container infrastructure requires the "${service}" Compose service, which was ` + + 'not generated for this configuration', + ); + } + target.ports = plan.publications + .filter((publication) => publication.service === service) + .map(appleContainerPortMapping); + } +} + +/** Probes one loopback TCP port for an existing listener. */ +async function isPortInUse(port: number, timeoutMs: number): Promise { + return new Promise((resolve) => { + const socket = net.connect({ host: APPLE_CONTAINER_LOOPBACK_HOST, port }); + let settled = false; + const finish = (inUse: boolean): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + socket.destroy(); + resolve(inUse); + }; + const timer = setTimeout(() => finish(false), timeoutMs); + timer.unref?.(); + // A completed connect means something is already listening. Any error — + // ECONNREFUSED being the expected one — means the port is free for Docker + // to bind. Treating a timeout as "free" is the safe direction here: Docker + // itself fails loudly if the bind is actually taken. + socket.once('connect', () => finish(true)); + socket.once('error', () => finish(false)); + }); +} + +export interface AppleContainerPortProbeDependencies { + isPortInUse(port: number, timeoutMs: number): Promise; +} + +/** + * Returns the planned host ports that already have a listener. + * + * Called before Compose starts so a collision (a second concurrent AWF run, a + * stray local Squid) is reported as a named conflict rather than as a Docker + * bind error buried in Compose output. + */ +export async function appleContainerLoopbackPortConflicts( + plan: AppleContainerInfrastructurePlan, + dependencies: AppleContainerPortProbeDependencies = { isPortInUse }, + timeoutMs = 500, +): Promise { + const conflicts: AppleContainerPortPublication[] = []; + for (const publication of plan.publications) { + if (await dependencies.isPortInUse(publication.hostPort, timeoutMs)) { + conflicts.push(publication); + } + } + return conflicts; +} diff --git a/src/apple-container/init-image-contract.test.ts b/src/apple-container/init-image-contract.test.ts new file mode 100644 index 000000000..14157f5b5 --- /dev/null +++ b/src/apple-container/init-image-contract.test.ts @@ -0,0 +1,112 @@ +/** + * Contract check for the AWF Apple init image build. + * + * The init image is where the guest half of the transport actually ships, and + * it encodes three things that must agree with the compiled-in host contract: + * where Apple's real `vminitd` is relocated to, where the shim is installed, + * and which `container` CLI range the result is valid for. A drift in any of + * them would not fail a build — it would produce a VM that boots without + * capabilities, or one whose init cannot be found at all, which is exactly the + * failure mode nobody can reproduce locally. So it is asserted here. + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +import { + APPLE_CONTAINER_INIT_ENTRYPOINT, + APPLE_CONTAINER_TRANSPORT_CONTRACT_VERSION, + APPLE_CONTAINER_TRANSPORT_MAX_CLI_VERSION_EXCLUSIVE, + APPLE_CONTAINER_TRANSPORT_MIN_CLI_VERSION, + APPLE_CONTAINER_VMINITD_PATH, +} from './transport-capabilities'; + +const repoRoot = path.resolve(__dirname, '..', '..'); +const dockerfile = fs.readFileSync( + path.join(repoRoot, 'containers', 'apple-init', 'Dockerfile'), + 'utf8', +); +const buildScript = fs.readFileSync( + path.join(repoRoot, 'scripts', 'build-apple-init-image.sh'), + 'utf8', +); + +function argDefault(name: string): string | undefined { + const match = new RegExp(`^ARG ${name}=(.+)$`, 'm').exec(dockerfile); + return match?.[1].trim(); +} + +describe('Apple init image Dockerfile', () => { + it('relocates Apple\'s init to the path the shim execs', () => { + expect(dockerfile).toContain(`mv /rootfs${APPLE_CONTAINER_INIT_ENTRYPOINT} /rootfs${APPLE_CONTAINER_VMINITD_PATH}`); + }); + + it('installs the shim where Apple\'s runtime executes init from', () => { + expect(dockerfile).toContain(`install -m 0755 /shim/vminitd /rootfs${APPLE_CONTAINER_INIT_ENTRYPOINT}`); + expect(dockerfile).toContain(`ENTRYPOINT ["${APPLE_CONTAINER_INIT_ENTRYPOINT}"]`); + }); + + it('records the same CLI range the host half enforces', () => { + expect(argDefault('AWF_CLI_MIN_VERSION')).toBe(APPLE_CONTAINER_TRANSPORT_MIN_CLI_VERSION); + expect(argDefault('AWF_CLI_MAX_VERSION_EXCLUSIVE')) + .toBe(APPLE_CONTAINER_TRANSPORT_MAX_CLI_VERSION_EXCLUSIVE); + expect(argDefault('AWF_TRANSPORT_CONTRACT_VERSION')) + .toBe(String(APPLE_CONTAINER_TRANSPORT_CONTRACT_VERSION)); + }); + + it('exposes the coupling as labels so a built image can be checked without unpacking it', () => { + for (const label of [ + 'io.github.gh-aw-firewall.apple-init.base', + 'io.github.gh-aw-firewall.apple-init.contract-version', + 'io.github.gh-aw-firewall.apple-init.cli-min-version', + 'io.github.gh-aw-firewall.apple-init.cli-max-version-exclusive', + ]) { + expect(dockerfile).toContain(`LABEL ${label}=`); + } + }); + + it('has no default for the Apple base image, so a build cannot silently float', () => { + expect(dockerfile).toMatch(/^ARG AWF_VMINIT_IMAGE$/m); + expect(argDefault('AWF_VMINIT_IMAGE')).toBeUndefined(); + }); + + it('refuses a base image that is not digest-pinned', () => { + expect(dockerfile).toContain('AWF_VMINIT_IMAGE must be digest-pinned'); + expect(dockerfile).toContain('*@sha256:*)'); + }); + + it('refuses to relocate twice, which would lose Apple\'s init entirely', () => { + expect(dockerfile).toContain(`test ! -e /rootfs${APPLE_CONTAINER_VMINITD_PATH}`); + }); + + it('builds the shim as a static native arm64 Linux binary', () => { + expect(dockerfile).toContain('CGO_ENABLED=0 GOOS=linux GOARCH=arm64'); + expect(dockerfile).toContain('-trimpath -buildvcs=false'); + expect(dockerfile).not.toContain('GOARCH=amd64'); + }); + + it('pins every base image it builds from by digest', () => { + const bases = [...dockerfile.matchAll(/^FROM (\S+)/gm)].map((match) => match[1]); + for (const base of bases) { + const isStageReference = ['scratch', 'toolchain', '${AWF_VMINIT_IMAGE}'].includes(base); + if (isStageReference) continue; + expect(base).toMatch(/@sha256:[a-f0-9]{64}$/); + } + }); +}); + +describe('build-apple-init-image.sh', () => { + it('refuses a non-arm64 platform', () => { + expect(buildScript).toContain('refusing PLATFORM='); + }); + + it('refuses a base image that is not digest-pinned before invoking Docker', () => { + expect(buildScript).toContain('AWF_VMINIT_IMAGE must be digest-pinned'); + }); + + it('sources the CLI range from the host contract rather than duplicating it', () => { + expect(buildScript).toContain('src/apple-container/transport-capabilities.ts'); + expect(buildScript).toContain('APPLE_CONTAINER_TRANSPORT_MIN_CLI_VERSION'); + expect(buildScript).toContain('APPLE_CONTAINER_TRANSPORT_MAX_CLI_VERSION_EXCLUSIVE'); + }); +}); diff --git a/src/apple-container/runtime-validation.test.ts b/src/apple-container/runtime-validation.test.ts new file mode 100644 index 000000000..6773aa036 --- /dev/null +++ b/src/apple-container/runtime-validation.test.ts @@ -0,0 +1,175 @@ +import { + APPLE_CONTAINER_MAX_TIMEOUT_MS, + APPLE_CONTAINER_RUNTIME, + assertAppleContainerPreSecurityCompatibility, + assertAppleContainerRuntimeCompatibility, + assertAppleContainerSelection, + requireAppleContainerConfig, +} from './runtime-validation'; +import type { AppleContainerOptions, WrapperConfig } from '../types'; + +import { getLocalDockerEnv } from '../docker-host'; + +jest.mock('../docker-host', () => ({ + getLocalDockerEnv: jest.fn(() => ({})), +})); + +const mockedGetLocalDockerEnv = getLocalDockerEnv as jest.MockedFunction< + typeof getLocalDockerEnv +>; + +const appleContainer: AppleContainerOptions = { + previewEnabled: true, + cpus: 4, + memory: '8G', +}; + +function config(overrides: Partial = {}): WrapperConfig { + return { + allowedDomains: ['github.com'], + agentCommand: 'true', + logLevel: 'info', + workDir: '/tmp/awf-test', + containerRuntime: APPLE_CONTAINER_RUNTIME, + networkIsolation: true, + enableApiProxy: true, + appleContainer, + ...overrides, + } as unknown as WrapperConfig; +} + +beforeEach(() => { + mockedGetLocalDockerEnv.mockReturnValue({}); +}); + +describe('assertAppleContainerSelection', () => { + it('rejects Apple Container options on another runtime', () => { + expect(() => assertAppleContainerSelection(config({ containerRuntime: 'gvisor' }))) + .toThrow('require --container-runtime apple-container'); + }); + + it('accepts a config with no Apple Container options at all', () => { + expect(() => assertAppleContainerSelection( + config({ containerRuntime: 'gvisor', appleContainer: undefined }), + )).not.toThrow(); + }); + + it('accepts Apple Container options on the Apple Container runtime', () => { + expect(() => assertAppleContainerSelection(config())).not.toThrow(); + }); +}); + +describe('assertAppleContainerPreSecurityCompatibility', () => { + it('accepts the supported baseline', () => { + expect(() => assertAppleContainerPreSecurityCompatibility(config())).not.toThrow(); + }); + + it.each([ + ['networkIsolation disabled', { networkIsolation: false }, 'cannot disable --network-isolation'], + ['legacy security', { legacySecurity: true }, 'does not support --legacy-security'], + ['Docker-in-Docker', { enableDind: true }, 'Docker-in-Docker'], + ['split filesystem prefix', { dockerHostPathPrefix: '/host' }, 'split runner/daemon'], + ['ARC DinD topology', { runnerTopology: 'arc-dind' }, 'Docker-in-Docker'], + ['host access', { enableHostAccess: true }, 'does not support host access'], + ['host ports', { allowHostPorts: '8080' }, 'does not support host access'], + ['host service ports', { allowHostServicePorts: '9000' }, 'does not support host access'], + ['DNS-over-HTTPS', { dnsOverHttps: true }, 'DNS-over-HTTPS'], + ['topology peers', { topologyAttach: ['awmg-mcpg'] }, '--topology-attach'], + ])('rejects %s', (_label, overrides, message) => { + expect(() => assertAppleContainerPreSecurityCompatibility( + config(overrides as Partial), + )).toThrow(message); + }); + + it('rejects enclaves until the MCP gateway is proven reachable from a NIC-less guest', () => { + expect(() => assertAppleContainerPreSecurityCompatibility( + config({ enclaves: { enabled: true } as WrapperConfig['enclaves'] }), + )).toThrow('does not yet support enclaves'); + }); + + it('rejects a non-Unix Docker host', () => { + expect(() => assertAppleContainerPreSecurityCompatibility( + config({ awfDockerHost: 'tcp://127.0.0.1:2375' }), + )).toThrow('local Unix-socket Docker daemon'); + }); + + it('rejects a non-Unix DOCKER_HOST inherited from the environment', () => { + mockedGetLocalDockerEnv.mockReturnValue({ DOCKER_HOST: 'tcp://localhost:2375' }); + expect(() => assertAppleContainerPreSecurityCompatibility(config())) + .toThrow('local Unix-socket Docker daemon'); + }); + + it('accepts a Unix-socket Docker host', () => { + expect(() => assertAppleContainerPreSecurityCompatibility( + config({ awfDockerHost: 'unix:///var/run/docker.sock' }), + )).not.toThrow(); + }); +}); + +describe('assertAppleContainerRuntimeCompatibility', () => { + it('accepts the supported baseline', () => { + expect(() => assertAppleContainerRuntimeCompatibility(config())).not.toThrow(); + }); + + it('requires the explicit preview opt-in', () => { + expect(() => assertAppleContainerRuntimeCompatibility(config({ + appleContainer: { ...appleContainer, previewEnabled: false }, + }))).toThrow('--apple-container-preview'); + }); + + it('requires strict network isolation', () => { + expect(() => assertAppleContainerRuntimeCompatibility(config({ networkIsolation: undefined }))) + .toThrow('requires strict --network-isolation'); + }); + + it.each([ + ['the act agent image preset', { agentImage: 'act' }, 'only the default agent image'], + ['a custom agent base image', { agentImage: 'ubuntu:24.04' }, 'only the default agent image'], + ['--build-local', { buildLocal: true }, '--build-local'], + ['filesystem.allowWrite', { filesystemAllowWrite: ['/opt'] }, 'filesystem.allowWrite'], + ['extra volume mounts', { volumeMounts: ['/a:/a'] }, 'does not support --volume'], + ['tty', { tty: true }, 'does not support --tty'], + ['ssl bump', { sslBump: true }, '--ssl-bump'], + ['a sysroot image', { sysrootImage: 'ghcr.io/x/y:1' }, 'chroot sysroot'], + ['chroot binaries', { chrootBinariesSourcePath: '/opt/bin' }, 'chroot sysroot'], + ])('rejects %s', (_label, overrides, message) => { + expect(() => assertAppleContainerRuntimeCompatibility( + config(overrides as Partial), + )).toThrow(message); + }); + + it('rejects Vertex because its provider port is outside the capability allowlist', () => { + expect(() => assertAppleContainerRuntimeCompatibility(config({ googleApiKey: 'k' }))) + .toThrow('Vertex'); + }); + + it('rejects an agent timeout beyond the supported bound', () => { + const overLimit = APPLE_CONTAINER_MAX_TIMEOUT_MS / 60_000 + 1; + expect(() => assertAppleContainerRuntimeCompatibility(config({ agentTimeout: overLimit }))) + .toThrow('--agent-timeout values up to'); + }); + + it('accepts an agent timeout at the supported bound', () => { + const atLimit = APPLE_CONTAINER_MAX_TIMEOUT_MS / 60_000; + expect(() => assertAppleContainerRuntimeCompatibility(config({ agentTimeout: atLimit }))) + .not.toThrow(); + }); + + it('still applies the pre-security guards', () => { + expect(() => assertAppleContainerRuntimeCompatibility(config({ enableDind: true }))) + .toThrow('Docker-in-Docker'); + }); +}); + +describe('requireAppleContainerConfig', () => { + it('returns the runtime options when the runtime is selected', () => { + expect(requireAppleContainerConfig(config())).toBe(appleContainer); + }); + + it('throws when the backend is reached without Apple Container configuration', () => { + expect(() => requireAppleContainerConfig(config({ appleContainer: undefined }))) + .toThrow('resolved without Apple Container runtime configuration'); + expect(() => requireAppleContainerConfig(config({ containerRuntime: 'sbx' }))) + .toThrow('resolved without Apple Container runtime configuration'); + }); +}); diff --git a/src/apple-container/runtime-validation.ts b/src/apple-container/runtime-validation.ts new file mode 100644 index 000000000..713805511 --- /dev/null +++ b/src/apple-container/runtime-validation.ts @@ -0,0 +1,201 @@ +/** + * Fail-closed compatibility guards for the Apple Container preview runtime. + * + * The Apple Container backend is structurally different from every other AWF + * runtime in three ways that make an explicit, exhaustive compatibility matrix + * mandatory rather than nice to have: + * + * 1. **The guest has no NIC.** `--network none` is the confinement, so every + * feature that assumes the agent shares a Docker network — iptables egress + * control, host access, DoH, topology peers, Docker-in-Docker — has no + * implementation here. Silently ignoring such a flag would present a + * security control the runtime is not applying. + * 2. **The capability set is closed.** Only the layer-2 allowlist can cross the + * VM boundary, and Vertex (port 10004) is deliberately not in it. A + * configuration that needs a capability outside the allowlist must be + * refused, not routed somewhere else. + * 3. **The host contract is narrow.** Self-hosted bare-metal Apple Silicon on + * macOS 26+ with `kern.hv_support=1`. GitHub-hosted macOS reports + * `kern.hv_support=0`, and there is no fallback: an ineligible host fails + * preflight instead of quietly running under a weaker runtime. + * + * Nothing here probes the host — that is {@link runAppleContainerPreflight}'s + * job, which runs later in the backend. This module only reasons about the + * assembled {@link WrapperConfig}, so an unsupported combination is rejected + * before any container, VM, or socket exists. + */ + +import { getLocalDockerEnv } from '../docker-host'; +import type { AppleContainerOptions, WrapperConfig } from '../types'; + +/** User-facing runtime name that selects this backend. */ +export const APPLE_CONTAINER_RUNTIME = 'apple-container'; + +/** + * Longest `--agent-timeout` the backend accepts, in milliseconds. + * + * Matches the Cloud Hypervisor preview bound (24h). A larger value would be + * accepted by `container run` but is well past any plausible CI budget and + * would keep a VM and its capability sockets alive indefinitely. + */ +export const APPLE_CONTAINER_MAX_TIMEOUT_MS = 86_400_000; + +/** + * Rejects Apple Container options attached to a different runtime. + * + * Runs before security-mode resolution so `--apple-container-*` on, say, a + * gVisor run is a hard error rather than a silently inert flag. + */ +export function assertAppleContainerSelection(config: WrapperConfig): void { + if (config.appleContainer && config.containerRuntime !== APPLE_CONTAINER_RUNTIME) { + throw new Error( + `Apple Container options require --container-runtime ${APPLE_CONTAINER_RUNTIME}`, + ); + } +} + +/** + * Guards that must hold *before* `applySecurityMode` mutates the config. + * + * Security mode can turn `--legacy-security` into a populated iptables + * configuration, at which point "the user asked for legacy security" is no + * longer distinguishable from "AWF defaulted to it". Checking here keeps the + * error message pointed at the flag the operator actually passed. + */ +export function assertAppleContainerPreSecurityCompatibility(config: WrapperConfig): void { + if (config.networkIsolation === false) { + throw new Error('Apple Container preview cannot disable --network-isolation'); + } + if (config.legacySecurity) { + throw new Error( + 'Apple Container preview does not support --legacy-security; the guest has no NIC, so ' + + 'host and container iptables rules govern nothing', + ); + } + if (config.enableDind || config.dockerHostPathPrefix || config.runnerTopology === 'arc-dind') { + throw new Error( + 'Apple Container preview does not support Docker-in-Docker or split runner/daemon ' + + 'filesystems; the guest never receives a Docker socket', + ); + } + if (config.enableHostAccess || config.allowHostPorts || config.allowHostServicePorts) { + throw new Error( + 'Apple Container preview does not support host access; only allowlisted AWF capability ' + + 'sockets cross the VM boundary', + ); + } + if (config.dnsOverHttps) { + throw new Error( + 'Apple Container preview does not support DNS-over-HTTPS; the guest resolves no names ' + + 'at all and reaches every destination through the Squid capability', + ); + } + if (config.enclaves?.enabled) { + throw new Error( + 'Apple Container preview does not yet support enclaves; the enclave MCP gateway is a ' + + 'Docker-network peer that has not been proven reachable from a NIC-less guest', + ); + } + if (config.topologyAttach && config.topologyAttach.length > 0) { + throw new Error( + 'Apple Container preview does not support --topology-attach; externally owned peers are ' + + 'not published to macOS loopback and therefore cannot be bridged into the guest', + ); + } + const dockerHost = config.awfDockerHost ?? getLocalDockerEnv().DOCKER_HOST; + if (dockerHost && !dockerHost.startsWith('unix://')) { + throw new Error( + 'Apple Container preview requires a local Unix-socket Docker daemon so infrastructure ' + + 'ports are published to macOS loopback', + ); + } +} + +/** + * Full compatibility check for a fully assembled config. + * + * @throws with an actionable message on any unsupported combination. + */ +export function assertAppleContainerRuntimeCompatibility( + config: WrapperConfig, + appleContainer = requireAppleContainerConfig(config), +): void { + if (!appleContainer.previewEnabled) { + throw new Error( + 'Apple Container workload execution requires explicit --apple-container-preview opt-in', + ); + } + if (!config.networkIsolation) { + throw new Error('Apple Container preview requires strict --network-isolation security'); + } + assertAppleContainerPreSecurityCompatibility(config); + + if (config.agentImage && config.agentImage !== 'default') { + throw new Error( + 'Apple Container preview supports only the default agent image; the "act" preset and ' + + 'custom base images are not published as native arm64 and Rosetta translation is refused', + ); + } + if (config.buildLocal) { + throw new Error( + 'Apple Container preview cannot use --build-local; the agent image is pulled through the ' + + "Apple Container image store, not Docker's", + ); + } + if (config.filesystemAllowWrite !== undefined) { + throw new Error( + `filesystem.allowWrite is not yet supported by the ${APPLE_CONTAINER_RUNTIME} runtime`, + ); + } + if (config.volumeMounts?.length) { + throw new Error( + 'Apple Container preview does not support --volume; only the workspace and AWF-owned ' + + 'run directories are exposed to the guest', + ); + } + if (config.tty) { + throw new Error('Apple Container preview does not support --tty'); + } + if (config.sslBump) { + throw new Error( + 'Apple Container preview does not support --ssl-bump; it requires a locally built Squid ' + + 'image and a guest trust store AWF does not manage here', + ); + } + if (config.sysrootImage || config.chrootBinariesSourcePath) { + throw new Error( + 'Apple Container preview does not use the chroot sysroot; the guest runs the agent image ' + + 'root filesystem directly', + ); + } + if (config.googleApiKey) { + throw new Error( + 'Apple Container preview does not support Google Vertex AI credential isolation; the ' + + 'Vertex provider port is not part of the capability transport allowlist', + ); + } + if ( + config.agentTimeout !== undefined && + config.agentTimeout * 60_000 > APPLE_CONTAINER_MAX_TIMEOUT_MS + ) { + throw new Error( + `Apple Container preview supports --agent-timeout values up to ` + + `${APPLE_CONTAINER_MAX_TIMEOUT_MS / 60_000} minutes`, + ); + } +} + +/** + * Narrows a config to one that actually selected this runtime. + * + * A backend reached without Apple Container configuration is a wiring bug, not + * a user error, so this throws rather than substituting defaults. + */ +export function requireAppleContainerConfig(config: WrapperConfig): AppleContainerOptions { + if (config.containerRuntime !== APPLE_CONTAINER_RUNTIME || !config.appleContainer) { + throw new Error( + 'Apple Container backend resolved without Apple Container runtime configuration', + ); + } + return config.appleContainer; +} diff --git a/src/awf-config-schema.json b/src/awf-config-schema.json index d33764c42..5bca89f51 100644 --- a/src/awf-config-schema.json +++ b/src/awf-config-schema.json @@ -650,6 +650,9 @@ }, "dindStaging": { "$ref": "#/$defs/digestPinnedImage" + }, + "appleInit": { + "$ref": "#/$defs/digestPinnedImage" } } }, @@ -702,9 +705,10 @@ "enum": [ "gvisor", "sbx", - "cloud-hypervisor" + "cloud-hypervisor", + "apple-container" ], - "description": "Runtime for the primary agent. \"gvisor\" uses runsc in Docker Compose. \"sbx\" uses a Docker sbx microVM. \"cloud-hypervisor\" selects the explicit Linux/KVM Cloud Hypervisor v53.0 workload preview (GitHub-hosted Ubuntu x86_64 KVM runners only). Infrastructure containers always use the default runc runtime." + "description": "Runtime for the primary agent. \"gvisor\" uses runsc in Docker Compose. \"sbx\" uses a Docker sbx microVM. \"cloud-hypervisor\" selects the explicit Linux/KVM Cloud Hypervisor v53.0 workload preview (GitHub-hosted Ubuntu x86_64 KVM runners only). \"apple-container\" selects the Apple Virtualization.framework workload preview (self-hosted bare-metal Apple Silicon macOS 26+ runners only; the guest runs with no NIC and reaches AWF infrastructure exclusively through published capability sockets). Infrastructure containers always use the default runc runtime." } }, "allOf": [ @@ -811,6 +815,38 @@ } } }, + "appleContainer": { + "type": "object", + "description": "Apple Container microVM preview configuration. Requires container.containerRuntime: \"apple-container\" and previewEnabled to execute workloads; supported only on self-hosted bare-metal Apple Silicon runners on macOS 26+ with kern.hv_support=1. GitHub-hosted macOS runners fail preflight and are never silently downgraded to another runtime.", + "additionalProperties": false, + "properties": { + "previewEnabled": { + "type": "boolean", + "default": false, + "description": "Enable the Apple Container workload-execution preview. Requires container.containerRuntime: \"apple-container\" and a self-hosted bare-metal Apple Silicon macOS 26+ runner." + }, + "cpus": { + "type": "integer", + "minimum": 1, + "default": 4, + "description": "Number of guest virtual CPUs." + }, + "memory": { + "type": "string", + "pattern": "^[1-9][0-9]*[KMGTP]?$", + "default": "8G", + "description": "Guest memory as an integer with an optional K/M/G/T/P suffix, e.g. \"8G\"." + }, + "initImage": { + "$ref": "#/$defs/digestPinnedImage", + "description": "Digest-pinned AWF Apple init image carrying the guest capability relay. Defaults to the registry/tag-derived apple-init reference, which must itself be digest-pinned." + }, + "cliPath": { + "type": "string", + "description": "Absolute path to the Apple \"container\" CLI when it is not on PATH." + } + } + }, "chroot": { "type": "object", "description": "Chroot execution overrides for split-filesystem ARC/DinD runners.", diff --git a/src/cli-options.ts b/src/cli-options.ts index bc8dae9e1..e5dc45147 100644 --- a/src/cli-options.ts +++ b/src/cli-options.ts @@ -3,7 +3,11 @@ import * as path from 'path'; import * as os from 'os'; import { version } from '../package.json'; import { collectRulesetFile, collectStringArray, formatItem } from './option-parsers'; -import { CLOUD_HYPERVISOR_RELEASE_VERSION } from './types/runtime-options'; +import { + APPLE_CONTAINER_DEFAULT_CPUS, + APPLE_CONTAINER_DEFAULT_MEMORY, + CLOUD_HYPERVISOR_RELEASE_VERSION, +} from './types/runtime-options'; // Option group markers used by the custom help formatter to insert section headers. // Each key is the long flag name of the first option in a group. @@ -12,6 +16,7 @@ const optionGroupHeaders: Record = { 'allow-domains': 'Domain Filtering:', 'build-local': 'Image Management:', 'cloud-hypervisor-preview': 'Cloud Hypervisor Preview (GitHub-hosted Ubuntu x86_64 KVM only):', + 'apple-container-preview': 'Apple Container Preview (self-hosted bare-metal Apple Silicon only):', 'env': 'Container Configuration:', 'dns-servers': 'Network & Security:', 'upstream-proxy': 'Network & Security:', @@ -178,6 +183,8 @@ program ' "sbx" — Docker sbx microVM with hypervisor isolation.\n' + ' "cloud-hypervisor" — explicit GitHub-hosted Ubuntu x86_64 KVM\n' + ' Cloud Hypervisor v53.0 preview.\n' + + ' "apple-container" — Apple Virtualization.framework VM on\n' + + ' self-hosted bare-metal Apple Silicon macOS 26+ (preview).\n' + ' Unknown values are passed through as raw Docker runtime names.' ) // -- Cloud Hypervisor Preview -- @@ -201,6 +208,28 @@ program .option('--cloud-hypervisor-rootfs-sha256 ', 'Expected SHA-256 digest of the guest rootfs.') .option('--cloud-hypervisor-supervisor-sha256 ', 'Expected SHA-256 digest of the AWF guest supervisor.') + // -- Apple Container Preview -- + .option( + '--apple-container-preview', + 'Enable the Apple Container workload-execution preview.\n' + + ' Self-hosted bare-metal Apple Silicon macOS 26+ runners only\n' + + ' (kern.hv_support=1). GitHub-hosted macOS fails preflight.', + false + ) + .option( + '--apple-container-cpus ', + `Guest virtual CPU count (default: ${APPLE_CONTAINER_DEFAULT_CPUS}).` + ) + .option( + '--apple-container-memory ', + `Guest memory, integer with an optional K/M/G/T/P suffix (default: ${APPLE_CONTAINER_DEFAULT_MEMORY}).` + ) + .option( + '--apple-container-init-image ', + 'Digest-pinned AWF Apple init image carrying the guest capability relay.' + ) + .option('--apple-container-cli ', 'Path to the Apple "container" CLI when it is not on PATH.') + // -- Container Configuration -- .option( '-e, --env ', diff --git a/src/commands/build-config.ts b/src/commands/build-config.ts index abdcedaec..57537316c 100644 --- a/src/commands/build-config.ts +++ b/src/commands/build-config.ts @@ -4,11 +4,14 @@ import { resolveApiCredentials } from './resolve-credentials'; import { normalizeEnclavesConfig } from '../parsers/enclave-parser'; import { logger } from '../logger'; import { + APPLE_CONTAINER_DEFAULT_CPUS, + APPLE_CONTAINER_DEFAULT_MEMORY, CLOUD_HYPERVISOR_DEFAULT_API_TIMEOUT_MS, CLOUD_HYPERVISOR_DEFAULT_BINARY, CLOUD_HYPERVISOR_DEFAULT_MEMORY_MIB, CLOUD_HYPERVISOR_DEFAULT_VCPU_COUNT, } from '../types/runtime-options'; +import { APPLE_CONTAINER_RUNTIME } from '../apple-container/runtime-validation'; /** * Resolves the effective `legacySecurity` value from CLI options. @@ -124,6 +127,7 @@ export function buildConfig(inputs: BuildConfigInputs): WrapperConfig { const chrootIdentity = buildChrootIdentity(options); const dind = buildDindConfig(options); const cloudHypervisor = buildCloudHypervisorConfig(options); + const appleContainer = buildAppleContainerConfig(options); const apiCredentials = resolveApiCredentials(options, { resolvedCopilotApiTarget, resolvedCopilotApiBasePath, @@ -228,6 +232,7 @@ export function buildConfig(inputs: BuildConfigInputs): WrapperConfig { chrootIdentity, dind, cloudHypervisor, + appleContainer, enclaves: normalizeEnclavesConfig( options.enclaves as AwfFileConfig['enclaves'] | undefined, ), @@ -313,6 +318,60 @@ function buildCloudHypervisorConfig( }; } +/** + * Builds the Apple Container preview runtime config. + * + * Returns `undefined` unless the runtime is selected or an + * `--apple-container-*` flag was passed, so the field stays absent (and + * `assertAppleContainerSelection` stays quiet) for every other runtime. + * Compatibility, opt-in, and host eligibility are enforced later by + * `src/apple-container/runtime-validation.ts` and the backend's preflight. + */ +function buildAppleContainerConfig( + options: Record, +): WrapperConfig['appleContainer'] { + const selected = options.containerRuntime === APPLE_CONTAINER_RUNTIME; + const configured = options.appleContainerPreview === true + || [ + 'appleContainerCpus', + 'appleContainerMemory', + 'appleContainerInitImage', + 'appleContainerCli', + ].some((key) => options[key] !== undefined); + if (!selected && !configured) return undefined; + + return { + previewEnabled: options.appleContainerPreview === true, + cpus: parsePositiveIntegerOption( + options.appleContainerCpus, + '--apple-container-cpus', + APPLE_CONTAINER_DEFAULT_CPUS, + ), + memory: parseAppleContainerMemory(options.appleContainerMemory), + initImage: options.appleContainerInitImage as string | undefined, + cliPath: options.appleContainerCli as string | undefined, + }; +} + +/** + * Validates `--apple-container-memory` at parse time. + * + * Layer 1 validates the same grammar before it reaches argv, but rejecting here + * means a typo fails during option validation with the flag named, rather than + * after the Compose infrastructure is already up. + */ +function parseAppleContainerMemory(value: unknown): string { + if (value === undefined) return APPLE_CONTAINER_DEFAULT_MEMORY; + const text = String(value); + if (!/^[1-9][0-9]*[KMGTP]?$/.test(text)) { + throw new Error( + '--apple-container-memory must be a positive integer with an optional K/M/G/T/P suffix; ' + + `got ${text}`, + ); + } + return text; +} + function buildChrootIdentity( options: Record ): WrapperConfig['chrootIdentity'] { diff --git a/src/commands/validators/config-assembly.ts b/src/commands/validators/config-assembly.ts index 1141e294a..11be10fa0 100644 --- a/src/commands/validators/config-assembly.ts +++ b/src/commands/validators/config-assembly.ts @@ -14,6 +14,12 @@ import { assertCloudHypervisorRuntimeCompatibility, assertCloudHypervisorSelection, } from '../../cloud-hypervisor/runtime-validation'; +import { + APPLE_CONTAINER_RUNTIME, + assertAppleContainerPreSecurityCompatibility, + assertAppleContainerRuntimeCompatibility, + assertAppleContainerSelection, +} from '../../apple-container/runtime-validation'; import { assertFilesystemWritePolicyCompatibility } from '../../filesystem-policy'; // --------------------------------------------------------------------------- @@ -78,6 +84,7 @@ export function assembleAndValidateConfig( validateInfrastructureOptions(config); try { assertCloudHypervisorSelection(config); + assertAppleContainerSelection(config); } catch (error) { logger.error(`❌ ${error instanceof Error ? error.message : String(error)}`); process.exit(1); @@ -90,6 +97,17 @@ export function assembleAndValidateConfig( process.exit(1); } } + // Runs before applySecurityMode for the same reason as the Cloud Hypervisor + // guard above: once security mode has resolved, "the operator passed + // --legacy-security" is no longer distinguishable from an AWF default. + if (config.containerRuntime === APPLE_CONTAINER_RUNTIME) { + try { + assertAppleContainerPreSecurityCompatibility(config); + } catch (error) { + logger.error(`❌ ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + } + } applySecurityMode(config); try { assertFilesystemWritePolicyCompatibility(config); @@ -105,6 +123,14 @@ export function assembleAndValidateConfig( process.exit(1); } } + if (config.containerRuntime === APPLE_CONTAINER_RUNTIME) { + try { + assertAppleContainerRuntimeCompatibility(config); + } catch (error) { + logger.error(`❌ ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + } + } applyAgentTimeout(options.agentTimeout as string | undefined, config, logger); applyRateLimitConfig(config, options); validateFeatureFlagCompatibility(config); diff --git a/src/compose-generator.ts b/src/compose-generator.ts index 92feb4499..cc20e9edf 100644 --- a/src/compose-generator.ts +++ b/src/compose-generator.ts @@ -24,6 +24,11 @@ import { ENCLAVE_MCP_CONTROL_NETWORK, } from './enclave/network'; import { buildInternalServiceHosts } from './services/internal-service-hosts'; +import { + applyAppleContainerLoopbackPublishing, + planAppleContainerInfrastructure, +} from './apple-container/infrastructure-endpoints'; +import { APPLE_CONTAINER_RUNTIME } from './apple-container/runtime-validation'; /** * Generates Docker Compose configuration @@ -168,7 +173,12 @@ export function generateDockerCompose( // In network-isolation mode the internal network blocks host→container traffic, // so we also attach api-proxy to the external bridge (`awf-ext`) — same as // Squid — so published ports are reachable from outside Docker. - if (!includeAgent && services['api-proxy']) { + // + // Apple Container is handled separately below: its guest has no NIC at all, so + // it needs *loopback-scoped* publication for a specific capability set rather + // than this broad host-wide publication. + const isAppleContainer = config.containerRuntime === APPLE_CONTAINER_RUNTIME; + if (!includeAgent && !isAppleContainer && services['api-proxy']) { const proxyService = services['api-proxy']; if (!proxyService.ports) { proxyService.ports = []; @@ -185,6 +195,29 @@ export function generateDockerCompose( } } + // ── Publish infra ports to macOS loopback for Apple Container ────────────── + // The Apple Container guest has zero NICs, and on macOS the sidecars run + // inside the Docker Desktop VM, so a capability relay on the macOS host is the + // only way in. Publication is loopback-scoped and limited to the exact ports + // backing an allowlisted capability, and it *replaces* any broader mapping a + // service builder emitted (Squid's default `3128:3128` binds 0.0.0.0). + if (isAppleContainer) { + const infrastructurePlan = planAppleContainerInfrastructure(config); + applyAppleContainerLoopbackPublishing(services, infrastructurePlan); + if (config.networkIsolation) { + // Same reason as the microVM branch above: the internal topology network + // blocks host→container traffic, so every publishing service must also sit + // on the external bridge for its loopback publication to be reachable. + for (const service of infrastructurePlan.services) { + const target = services[service]; + target.networks = { + ...(target.networks || {}), + [EXTERNAL_BRIDGE_NAME]: {}, + }; + } + } + } + // ── Assemble and return the compose result ───────────────────────────────── const compose = buildComposeNetworks({ diff --git a/src/config-file.ts b/src/config-file.ts index c225e6563..99c9ba64b 100644 --- a/src/config-file.ts +++ b/src/config-file.ts @@ -148,6 +148,19 @@ export interface AwfFileConfig { apiTimeoutMs?: number; sha256?: CloudHypervisorArtifactDigests; }; + /** + * Apple Container preview microVM runtime. + * Selectable via `container.containerRuntime: "apple-container"`, gated + * behind `previewEnabled`/`--apple-container-preview`. Supported only on + * self-hosted bare-metal Apple Silicon macOS 26+ runners. + */ + appleContainer?: { + previewEnabled?: boolean; + cpus?: number; + memory?: string; + initImage?: string; + cliPath?: string; + }; chroot?: { binariesSourcePath?: string; identity?: { diff --git a/src/config-mapper.ts b/src/config-mapper.ts index 280054641..53ab47510 100644 --- a/src/config-mapper.ts +++ b/src/config-mapper.ts @@ -130,6 +130,11 @@ export function mapAwfFileConfigToCliOptions(config: AwfFileConfig): Record> = { needsStaticDns: false, usesIptables: false, }, + // Apple Virtualization.framework VM launched by the `container` CLI. The + // guest has zero NICs (`--network none`), so it reaches AWF infrastructure + // only through the published-socket capability transport; Docker's embedded + // DNS and host-netns iptables are both structurally inapplicable. + 'apple-container': { + executionModel: 'microvm', + dockerRuntime: undefined, + needsStaticDns: false, + usesIptables: false, + }, }; /** diff --git a/src/external-runtime-backend-resolver.ts b/src/external-runtime-backend-resolver.ts index d0a1ff075..0b9e14123 100644 --- a/src/external-runtime-backend-resolver.ts +++ b/src/external-runtime-backend-resolver.ts @@ -3,6 +3,8 @@ import { runtimeUsesComposeAgent } from './container-runtime'; import type { ExternalAgentRuntimeBackend } from './external-runtime-backend'; import { createSbxRuntimeBackend } from './sbx-runtime-backend'; import { createCloudHypervisorRuntimeBackend } from './cloud-hypervisor-runtime-backend'; +import { createAppleContainerRuntimeBackend } from './apple-container-runtime-backend'; +import { APPLE_CONTAINER_RUNTIME } from './apple-container/runtime-validation'; import type { WrapperConfig } from './types'; interface ExternalRuntimeBackendFactoryContext { @@ -23,6 +25,8 @@ const EXTERNAL_RUNTIME_BACKENDS: ExternalRuntimeBackendRegistry = { createSbxRuntimeBackend(config, startInfrastructure), 'cloud-hypervisor': ({ config, startInfrastructure }) => createCloudHypervisorRuntimeBackend(config, startInfrastructure), + [APPLE_CONTAINER_RUNTIME]: ({ config, startInfrastructure }) => + createAppleContainerRuntimeBackend(config, startInfrastructure), }; /** @@ -46,6 +50,11 @@ export function resolveExternalRuntimeBackend( 'Cloud Hypervisor workload execution requires explicit --cloud-hypervisor-preview opt-in', ); } + if (runtime === APPLE_CONTAINER_RUNTIME && !config.appleContainer?.previewEnabled) { + throw new Error( + 'Apple Container workload execution requires explicit --apple-container-preview opt-in', + ); + } const factory = runtime ? registry[runtime] : undefined; if (!factory) { throw new Error(`No external agent runtime backend is registered for "${runtime}"`); diff --git a/src/image-resolver.ts b/src/image-resolver.ts index a466db3af..ab08cd7bc 100644 --- a/src/image-resolver.ts +++ b/src/image-resolver.ts @@ -3,7 +3,8 @@ import { buildRuntimeImageRef, parseImageTag, type ParsedImageTag } from './imag export type RuntimeImageName = | 'squid' | 'agent' | 'agent-act' | 'api-proxy' | 'cli-proxy' | 'build-tools' - | 'enclave-script' | 'enclave-agent' | 'enclave-mcp-server' | 'dind-staging' | 'doh-proxy'; + | 'enclave-script' | 'enclave-agent' | 'enclave-mcp-server' | 'dind-staging' | 'doh-proxy' + | 'apple-init'; type ManifestKey = keyof NonNullable; @@ -31,6 +32,7 @@ const MANIFEST_KEY: Record = { 'enclave-mcp-server': 'enclaveMcpServer', 'dind-staging': 'dindStaging', 'doh-proxy': 'dohProxy', + 'apple-init': 'appleInit', }; const DEFAULT_IMAGE_REGISTRY = 'ghcr.io/github/gh-aw-firewall'; diff --git a/src/image-tag.ts b/src/image-tag.ts index caa39b032..86cedf7b3 100644 --- a/src/image-tag.ts +++ b/src/image-tag.ts @@ -1,6 +1,6 @@ import path from 'path'; -const IMAGE_DIGEST_KEYS = ['squid', 'agent', 'agent-act', 'api-proxy', 'cli-proxy', 'build-tools', 'enclave-script', 'enclave-agent', 'enclave-mcp-server'] as const; +const IMAGE_DIGEST_KEYS = ['squid', 'agent', 'agent-act', 'api-proxy', 'cli-proxy', 'build-tools', 'enclave-script', 'enclave-agent', 'enclave-mcp-server', 'apple-init'] as const; type ImageDigestKey = typeof IMAGE_DIGEST_KEYS[number]; diff --git a/src/types/container-image-options.ts b/src/types/container-image-options.ts index 81e1ad541..a3bc531a7 100644 --- a/src/types/container-image-options.ts +++ b/src/types/container-image-options.ts @@ -9,7 +9,8 @@ export interface ContainerImageOptions { */ images?: Partial>; /** diff --git a/src/types/index.ts b/src/types/index.ts index c17abf9a9..87ae7ace7 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -20,6 +20,9 @@ export { CLOUD_HYPERVISOR_DEFAULT_VCPU_COUNT, CLOUD_HYPERVISOR_DEFAULT_MEMORY_MIB, CLOUD_HYPERVISOR_DEFAULT_API_TIMEOUT_MS, + type AppleContainerOptions, + APPLE_CONTAINER_DEFAULT_CPUS, + APPLE_CONTAINER_DEFAULT_MEMORY, } from './runtime-options'; export { type RateLimitConfig } from './rate-limit'; export { type FlagValidationResult } from './validation'; diff --git a/src/types/runtime-options.ts b/src/types/runtime-options.ts index a48a5e155..6e92ffe36 100644 --- a/src/types/runtime-options.ts +++ b/src/types/runtime-options.ts @@ -47,6 +47,46 @@ export interface CloudHypervisorOptions { sha256?: CloudHypervisorArtifactDigests; } +// ─── Apple Container (macOS arm64 microVM preview) ───────────────────────── +// +// Selectable via `--container-runtime apple-container`, gated behind explicit +// `--apple-container-preview` opt-in. The agent runs in an Apple +// Virtualization.framework VM with `--network none`, and reaches AWF's +// Docker Compose infrastructure exclusively through the layer-2 published-socket +// capability transport. Supported only on self-hosted bare-metal Apple Silicon +// macOS 26+ runners; GitHub-hosted macOS reports `kern.hv_support=0` and fails +// preflight rather than falling back. + +/** Guest vCPU count when `--apple-container-cpus` is not supplied. */ +export const APPLE_CONTAINER_DEFAULT_CPUS = 4; + +/** Guest memory when `--apple-container-memory` is not supplied. */ +export const APPLE_CONTAINER_DEFAULT_MEMORY = '8G'; + +/** + * Apple Container preview microVM runtime settings. + * + * Every field is inert unless `--container-runtime apple-container` is + * selected; {@link ../apple-container/runtime-validation} rejects the + * combination of Apple Container options with any other runtime. + */ +export interface AppleContainerOptions { + /** Explicit opt-in. Without it the runtime refuses to resolve a backend. */ + previewEnabled: boolean; + /** Guest vCPU count. */ + cpus: number; + /** Guest memory size, integer with an optional K/M/G/T/P suffix. */ + memory: string; + /** + * Complete, digest-pinned reference to the AWF Apple init image that carries + * the guest capability relay. Defaults to the manifest/registry-derived + * reference; an explicit value must still be digest-pinned. + */ + initImage?: string; + /** Path to the `container` CLI when it is not on `PATH`. */ + cliPath?: string; +} + export interface RuntimeOptions { /** * The command to execute inside the firewall container @@ -213,4 +253,13 @@ export interface RuntimeOptions { * GitHub-hosted Ubuntu x86_64 KVM runners. */ cloudHypervisor?: CloudHypervisorOptions; + + /** + * Apple Container preview microVM runtime settings. + * + * Selectable via `--container-runtime apple-container`, gated behind explicit + * `--apple-container-preview` opt-in. Supported only on self-hosted + * bare-metal Apple Silicon macOS 26+ runners. + */ + appleContainer?: AppleContainerOptions; }