From 28c9251861ffab4297cefff4c47eee3a45522075 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Wed, 26 Aug 2026 12:59:08 -0700 Subject: [PATCH] Add apple-container sandbox runtime contract and fail-closed validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce `sandbox.agent.runtime: apple-container` as a recognised runtime profile for AWF's Apple Virtualization.framework workload preview (gh-aw-firewall#7764). This is the contract/validation layer only: it defines the declarative surface, emits the AWF config selectors, and fails closed on everything the compiler can know statically. Generated runtime setup, image provisioning, and MCP transport rewiring are deliberately left out. Runtime model and profile: - Add `AgentRuntimeAppleContainer` and its profile: network isolation on, rootless AWF invocation, no host access, no compiler-generated runtime installation. Docker is still required — AWF keeps Squid, the API proxy, the CLI proxy, and the MCP gateway in Docker Compose on the host, and only the agent workload moves to Apple Container. - Add the value to the frontmatter schema, regenerate autocomplete data and the generated frontmatter reference, and update the internal agent-runtime instructions so tools do not emit an invalid configuration. AWF config generation: - Emit BOTH required selectors as a unit: `container.containerRuntime: "apple-container"` and `appleContainer.previewEnabled: true`. Neither is ever emitted without the other. - Suppress `network.topologyAttach`, which AWF rejects for this runtime because externally owned peers are not published to macOS loopback and cannot be bridged into the NIC-less guest. - Suppress `--tty`, matching the docker-sbx and cloud-hypervisor handling. - Mirror the AWF source schema in the embedded `awf-config.schema.json`: the `apple-container` enum value, the `appleContainer` section (previewEnabled, cpus, memory, initImage, cliPath with AWF's exact types, defaults, and patterns), and the `container.images.appleInit` role. Version gating: - Add `AWFAppleContainerMinVersion` (v0.28.9) and `awfSupportsAppleContainer`. The constant is deliberately above `DefaultFirewallVersion` because gh-aw-firewall#7764 is not in any published release yet (v0.28.8 still has no `apple-container` enum value and no `appleContainer` section), so the runtime fails closed until a workflow explicitly pins a supporting AWF version. Runner validation: - Accept only an explicitly self-hosted bare-metal Apple Silicon label set: `runs-on: [self-hosted, macOS, ARM64]` (extra pool labels allowed). - Reject every GitHub-hosted `macos-*` label, including the `-xlarge` Apple Silicon images, because those runners are virtual machines without nested virtualization. - Reject runner groups without labels, GitHub Actions expressions, contradicting OS/arch labels, and an omitted runner, rather than guessing. Every rejection names the exact accepted syntax. - Keep the blanket macOS rejection for every other runner field (`runs-on-slim`, safe-output job runners) and for every other runtime. The exemption applies only to the agent job's own `runs-on` when this workflow's frontmatter selects apple-container, and `validateSandboxConfig` re-validates the merged runner so imports cannot bypass the host requirement. Feature compatibility: - Reject arc-dind topology, enclaves, `sandbox.agent.mounts`, `filesystem.allowWrite`, `ssl_bump`, Vertex AI credential isolation, and the raw AWF arguments AWF refuses under this runtime (`--legacy-security`, `--enable-host-access`, `--allow-host-ports`, `--dns-over-https`, `--topology-attach`, `--dind`, `--docker-host-path-prefix`, `--build-local`, `--agent-image`, `--sysroot-image`, `--chroot-binaries-source-path`, `--volume`, `--tty`, `--ssl-bump`, `--google-api-key`, and `--no-network-isolation`). - `allow-host-ports`, GitHub Actions `services:` port mappings, and `runtime-install` are already rejected by the shared profile rules. - Require the `appleInit` image role whenever a `sandbox.agent.images` manifest is present, add it to the image-tag digest contract, and exclude it from Docker predownload because Apple Container uses a separate image store. All existing runtimes and Linux-only behaviour are unchanged; `make recompile` produces no lock file drift. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../apple-container-runtime-contract.md | 18 + .github/aw/agent-runtime-instructions.md | 7 +- docs/public/editor/autocomplete-data.json | 7 +- .../docs/reference/frontmatter-full.md | 16 +- pkg/constants/version_constants.go | 17 + pkg/parser/schemas/main_workflow_schema.json | 10 +- pkg/workflow/apple_container.go | 344 ++++++++++ pkg/workflow/apple_container_test.go | 631 ++++++++++++++++++ pkg/workflow/awf_arc_dind.go | 5 + pkg/workflow/awf_command_builder.go | 3 +- pkg/workflow/awf_config.go | 44 +- pkg/workflow/awf_config_build.go | 22 + pkg/workflow/awf_feature_flags.go | 12 + pkg/workflow/docker.go | 8 + pkg/workflow/firewall.go | 24 +- pkg/workflow/runs_on_validation.go | 27 +- pkg/workflow/sandbox.go | 14 + pkg/workflow/sandbox_agent_images.go | 9 + pkg/workflow/sandbox_runtime_profile.go | 23 + pkg/workflow/sandbox_validation.go | 5 + pkg/workflow/schemas/awf-config.schema.json | 39 +- 21 files changed, 1266 insertions(+), 19 deletions(-) create mode 100644 .changeset/apple-container-runtime-contract.md create mode 100644 pkg/workflow/apple_container.go create mode 100644 pkg/workflow/apple_container_test.go diff --git a/.changeset/apple-container-runtime-contract.md b/.changeset/apple-container-runtime-contract.md new file mode 100644 index 00000000000..5d7357d7284 --- /dev/null +++ b/.changeset/apple-container-runtime-contract.md @@ -0,0 +1,18 @@ +--- +"gh-aw": minor +--- + +Add the compiler/runtime contract for the `apple-container` sandbox runtime (AWF's Apple Virtualization.framework workload preview, gh-aw-firewall#7764). + +`sandbox.agent.runtime: apple-container` is now a recognised runtime profile: network-isolated, rootless AWF invocation, no host access, and no compiler-generated runtime installation. When selected, the compiler emits **both** required AWF selectors — `container.containerRuntime: "apple-container"` and `appleContainer.previewEnabled: true` — and suppresses `network.topologyAttach`, which AWF rejects for this runtime because externally owned peers are not published to macOS loopback. Docker is still required: AWF keeps Squid, the API proxy, the CLI proxy, and the MCP gateway in Docker Compose on the host, and only the agent workload moves to Apple Container. + +The runtime is gated behind a new `AWFAppleContainerMinVersion` (v0.28.9) that is deliberately above the default AWF version, so a workflow must explicitly pin `sandbox.agent.version` (or `firewall.version`) to an AWF build that understands the runtime. The selector and the `appleContainer` section are emitted as a unit and never reach an older AWF. + +Compile-time validation fails closed on everything gh-aw can know: + +- **Runner.** `apple-container` requires a self-hosted bare-metal Apple Silicon host (Darwin arm64, macOS 26+, `kern.hv_support=1`), declared explicitly as `runs-on: [self-hosted, macOS, ARM64]` (extra pool labels allowed). GitHub-hosted `macos-*` labels — including the `-xlarge` Apple Silicon images — are rejected because they are virtual machines without nested virtualization. Runner groups, expressions, conflicting OS/arch labels, and an omitted `runs-on` are rejected rather than guessed. The blanket macOS rejection stays in place for every other runner field and for every other runtime. +- **Features.** `runner.topology: arc-dind`, enclaves, `sandbox.agent.mounts`, `filesystem.allowWrite`, `ssl_bump`, Vertex AI credential isolation, `allow-host-ports`, GitHub Actions `services:` port mappings, `runtime-install`, and the raw AWF arguments AWF refuses under this runtime (`--legacy-security`, `--enable-host-access`, `--dns-over-https`, `--topology-attach`, `--dind`, `--build-local`, `--agent-image`, `--sysroot-image`, `--volume`, `--tty`, `--ssl-bump`, and others) all produce actionable errors. + +A new `appleInit` role is added to the `sandbox.agent.images` manifest and to `container.images`, and is required whenever the runtime is selected: Apple Container maintains a separate image store, so it is excluded from Docker predownload. + +This layer intentionally does not generate runnable Apple Container setup — runtime installation, image provisioning, and MCP transport rewiring follow separately. diff --git a/.github/aw/agent-runtime-instructions.md b/.github/aw/agent-runtime-instructions.md index a680ac91919..24dc15a7d22 100644 --- a/.github/aw/agent-runtime-instructions.md +++ b/.github/aw/agent-runtime-instructions.md @@ -13,20 +13,25 @@ Use these instructions when creating or updating workflows that mention Docker, - Set `sandbox.agent.runtime: gvisor` only when the runner has a local Docker daemon and can install or already has `runsc`. - Set `sandbox.agent.runtime: docker-sbx` only when the runner supports KVM-backed microVMs. - Set `sandbox.agent.runtime: cloud-hypervisor` only for the preview microVM runtime on a GitHub-hosted Ubuntu x86_64 runner with `/dev/kvm`; prefer `docker-sbx` or `gvisor` when those host constraints are not guaranteed. +- Set `sandbox.agent.runtime: apple-container` only for the preview Apple Virtualization.framework microVM runtime on a self-hosted bare-metal Apple Silicon runner (Darwin arm64, macOS 26+, `kern.hv_support=1`). It also requires `runs-on: [self-hosted, macOS, ARM64]` and an AWF version that supports the preview; do not generate it otherwise. - Do not set `sandbox.agent.runtime: docker`; Docker is selected by omitting the field. - Do not set `sandbox.agent.runtime: sbx`; `sbx` is not a valid `sandbox.agent.runtime` value. - Set `runner.topology: arc-dind` for ARC or equivalent Kubernetes runners that use a Docker-in-Docker sidecar. This is a runner topology, not an agent runtime. ## Compatibility -- Do not combine `runner.topology: arc-dind` with `sandbox.agent.runtime: gvisor`, `sandbox.agent.runtime: docker-sbx`, or `sandbox.agent.runtime: cloud-hypervisor`. +- Do not combine `runner.topology: arc-dind` with `sandbox.agent.runtime: gvisor`, `sandbox.agent.runtime: docker-sbx`, `sandbox.agent.runtime: cloud-hypervisor`, or `sandbox.agent.runtime: apple-container`. - ARC DinD workflows must be rootless: do not add `sudo`, `apt-get install`, or other host package bootstrap steps. - Docker sbx requires KVM and normally does not work on ARC DinD because the sbx daemon must run on the runner host. - Cloud Hypervisor requires `RUNNER_ENVIRONMENT=github-hosted`, Ubuntu Linux x86_64, and `/dev/kvm`; it is not supported on self-hosted or ARC DinD runners. +- Apple Container is the inverse: it requires a self-hosted bare-metal Apple Silicon runner and is never valid on a GitHub-hosted `macos-*` label, because those runners are virtual machines without nested virtualization. +- Apple Container rejects host access, `allow-host-ports`, GitHub Actions `services:` port mappings, enclaves, volume mounts, `filesystem.allowWrite`, `ssl_bump`, Vertex AI credential isolation, and `runtime-install`. +- Apple Container keeps Docker for the AWF infrastructure containers; only the agent workload moves to the Apple Container runtime. ## `runtime-install` - `sandbox.agent.runtime-install` defaults to `true` for gVisor and Docker sbx provisioning. +- `sandbox.agent.runtime-install` is not valid with `cloud-hypervisor` or `apple-container`. - Set `runtime-install: false` only when the runner image or pod is pre-provisioned with the runtime and required daemon or policy. - When any imported workflow sets `runtime-install: false`, false wins during import merging. - With `runtime-install: false`, gh-aw skips generated runtime checks and setup, so the runner must already satisfy those prerequisites. diff --git a/docs/public/editor/autocomplete-data.json b/docs/public/editor/autocomplete-data.json index 2595a63da29..971b8382493 100644 --- a/docs/public/editor/autocomplete-data.json +++ b/docs/public/editor/autocomplete-data.json @@ -886,6 +886,11 @@ "type": "string", "desc": "Docker-in-Docker staging image required when raw AWF arguments enable directory or engine-binary pre-staging.", "leaf": true + }, + "appleInit": { + "type": "string", + "desc": "Apple Container guest init image carrying the capability relay.", + "leaf": true } } }, @@ -903,7 +908,7 @@ "runtime": { "type": "string", "desc": "Sandbox runtime profile for the agent container.", - "enum": ["docker", "docker-sudo-iptables", "gvisor", "docker-sbx", "cloud-hypervisor"], + "enum": ["docker", "docker-sudo-iptables", "gvisor", "docker-sbx", "cloud-hypervisor", "apple-container"], "leaf": true }, "runtime-install": { diff --git a/docs/src/content/docs/reference/frontmatter-full.md b/docs/src/content/docs/reference/frontmatter-full.md index ea8e2a04fd1..41c3966638b 100644 --- a/docs/src/content/docs/reference/frontmatter-full.md +++ b/docs/src/content/docs/reference/frontmatter-full.md @@ -2236,6 +2236,11 @@ sandbox: # (optional) dindStaging: "example-value" + # Apple Container guest init image carrying the capability relay. Required + # whenever sandbox.agent.runtime is 'apple-container'. + # (optional) + appleInit: "example-value" + # Enable or disable model fallback for unresolved model selections. Set to false # for BYOK Azure OpenAI deployments to prevent deployment-name rewriting. Supports # literal boolean or GitHub Actions expression. @@ -2263,9 +2268,14 @@ sandbox: # (requires DOCKER_PAT and DOCKER_USERNAME secrets and a KVM-capable runner; the # compiler handles the required privileged setup); 'cloud-hypervisor' runs the # agent in AWF's preview Cloud Hypervisor microVM runtime on GitHub-hosted Ubuntu - # x86_64, sized at 2 vCPUs and 4096 MiB. Omitting runtime is equivalent to - # 'docker'. gvisor, docker-sbx and cloud-hypervisor are incompatible with - # runner.topology: arc-dind. + # x86_64, sized at 2 vCPUs and 4096 MiB; 'apple-container' runs the agent in AWF's + # preview Apple Virtualization.framework microVM (gh-aw-firewall#7764) and + # requires a self-hosted bare-metal Apple Silicon runner (Darwin arm64, macOS 26+, + # kern.hv_support=1) declared as runs-on: [self-hosted, macOS, ARM64] — + # GitHub-hosted macos-* runners are rejected because nested virtualization is + # unavailable, and it needs an AWF version that supports the preview. Omitting + # runtime is equivalent to 'docker'. gvisor, docker-sbx, cloud-hypervisor and + # apple-container are incompatible with runner.topology: arc-dind. # (optional) runtime: "docker" diff --git a/pkg/constants/version_constants.go b/pkg/constants/version_constants.go index bd1b5706ca8..2a2c7f45c96 100644 --- a/pkg/constants/version_constants.go +++ b/pkg/constants/version_constants.go @@ -109,6 +109,23 @@ const AWFContainerRuntimeMinVersion Version = "v0.27.30" // cloud-hypervisor preview runtime and its release assets. const AWFCloudHypervisorMinVersion Version = "v0.28.1" +// AWFAppleContainerMinVersion is the minimum AWF version that supports the +// apple-container preview runtime (Apple Virtualization.framework workloads on +// self-hosted bare-metal Apple Silicon). +// +// The backend is added by gh-aw-firewall#7764, which is not part of any published +// release yet: the newest release at the time of writing (v0.28.8) still ships a +// container.containerRuntime enum without "apple-container" and has no +// appleContainer config section. v0.28.9 is therefore the earliest release that +// can contain it. +// +// This constant is deliberately ABOVE DefaultFirewallVersion so the runtime fails +// closed by default: a workflow must explicitly pin sandbox.agent.version (or +// firewall.version) to an AWF build that actually understands the runtime. If +// gh-aw-firewall#7764 lands in a later release than v0.28.9, bump this constant — +// never lower it, and never relax the gate to DefaultFirewallVersion. +const AWFAppleContainerMinVersion Version = "v0.28.9" + // AWFLegacySecurityMinVersion is the minimum AWF version that supports the // --legacy-security flag and unconditional API proxy (gh-aw-firewall#6207). // Workflows pinning an older AWF version must use the old --security-mode compat behavior. diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index e43e904cd9a..cd2f05facc4 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -3744,6 +3744,10 @@ "dindStaging": { "$ref": "#/$defs/awf_pinned_image", "description": "Docker-in-Docker staging image required when raw AWF arguments enable directory or engine-binary pre-staging." + }, + "appleInit": { + "$ref": "#/$defs/awf_pinned_image", + "description": "Apple Container guest init image carrying the capability relay. Required whenever sandbox.agent.runtime is 'apple-container'." } }, "examples": [ @@ -3766,10 +3770,10 @@ }, "runtime": { "type": "string", - "description": "Sandbox runtime profile for the agent container. Each value selects one supported security and topology profile: 'docker' (default) runs the agent under Docker with a rootless AWF and network isolation; 'docker-sudo-iptables' runs Docker with a privileged AWF, legacy iptables networking, and host/service access (required for allow-host-ports and GitHub Actions services: connectivity); 'gvisor' runs the agent under gVisor's runsc runtime with strict network isolation; 'docker-sbx' runs the agent inside a Docker sbx KVM microVM (requires DOCKER_PAT and DOCKER_USERNAME secrets and a KVM-capable runner; the compiler handles the required privileged setup); 'cloud-hypervisor' runs the agent in AWF's preview Cloud Hypervisor microVM runtime on GitHub-hosted Ubuntu x86_64, sized at 2 vCPUs and 4096 MiB. Omitting runtime is equivalent to 'docker'. gvisor, docker-sbx and cloud-hypervisor are incompatible with runner.topology: arc-dind.", - "enum": ["docker", "docker-sudo-iptables", "gvisor", "docker-sbx", "cloud-hypervisor"], + "description": "Sandbox runtime profile for the agent container. Each value selects one supported security and topology profile: 'docker' (default) runs the agent under Docker with a rootless AWF and network isolation; 'docker-sudo-iptables' runs Docker with a privileged AWF, legacy iptables networking, and host/service access (required for allow-host-ports and GitHub Actions services: connectivity); 'gvisor' runs the agent under gVisor's runsc runtime with strict network isolation; 'docker-sbx' runs the agent inside a Docker sbx KVM microVM (requires DOCKER_PAT and DOCKER_USERNAME secrets and a KVM-capable runner; the compiler handles the required privileged setup); 'cloud-hypervisor' runs the agent in AWF's preview Cloud Hypervisor microVM runtime on GitHub-hosted Ubuntu x86_64, sized at 2 vCPUs and 4096 MiB; 'apple-container' runs the agent in AWF's preview Apple Virtualization.framework microVM (gh-aw-firewall#7764) and requires a self-hosted bare-metal Apple Silicon runner (Darwin arm64, macOS 26+, kern.hv_support=1) declared as runs-on: [self-hosted, macOS, ARM64] \u2014 GitHub-hosted macos-* runners are rejected because nested virtualization is unavailable, and it needs an AWF version that supports the preview. Omitting runtime is equivalent to 'docker'. gvisor, docker-sbx, cloud-hypervisor and apple-container are incompatible with runner.topology: arc-dind.", + "enum": ["docker", "docker-sudo-iptables", "gvisor", "docker-sbx", "cloud-hypervisor", "apple-container"], "default": "docker", - "examples": ["docker", "docker-sudo-iptables", "gvisor", "docker-sbx", "cloud-hypervisor"] + "examples": ["docker", "docker-sudo-iptables", "gvisor", "docker-sbx", "cloud-hypervisor", "apple-container"] }, "runtime-install": { "type": "boolean", diff --git a/pkg/workflow/apple_container.go b/pkg/workflow/apple_container.go new file mode 100644 index 00000000000..e4d86184145 --- /dev/null +++ b/pkg/workflow/apple_container.go @@ -0,0 +1,344 @@ +// This file implements the compile-time contract for the apple-container sandbox +// runtime (AWF's Apple Virtualization.framework workload preview, +// gh-aw-firewall#7764). +// +// The runtime is only viable on a self-hosted bare-metal Apple Silicon runner +// (Darwin arm64, macOS 26+, kern.hv_support=1). GitHub-hosted macOS runners are +// themselves virtual machines with nested virtualisation unavailable, so they can +// never host the guest and must be rejected at compile time rather than failing +// late on the runner. +// +// gh-aw cannot inspect the runner, so it fails closed: only an explicitly +// self-hosted Apple Silicon label set is accepted. Anything the compiler cannot +// prove — a bare runner group, a GitHub Actions expression, a custom label, or a +// GitHub-hosted macos-* label — is an error that names the exact required syntax. + +package workflow + +import ( + "fmt" + "strings" + + "github.com/github/gh-aw/pkg/constants" + "github.com/github/gh-aw/pkg/logger" + "gopkg.in/yaml.v3" +) + +var appleContainerLog = logger.New("workflow:apple_container") + +// Runner labels that together identify a self-hosted bare-metal Apple Silicon +// runner. These are the default labels the GitHub Actions runner applies on +// Darwin arm64, so they are recognisable without guessing. +const ( + appleContainerSelfHostedLabel = "self-hosted" + appleContainerMacOSLabel = "macos" + appleContainerARM64Label = "arm64" +) + +// appleContainerRunnerExample is the exact runs-on syntax the compiler accepts. +// It is repeated in every error message so authors never have to guess. +const appleContainerRunnerExample = "runs-on: [self-hosted, macOS, ARM64]" + +// appleContainerRejectedArchLabels are architecture labels that contradict the +// native arm64 requirement. Rosetta translation is refused by AWF, so an x86_64 +// runner can never satisfy the runtime. +var appleContainerRejectedArchLabels = []string{"x64", "x86", "x86_64", "amd64"} + +// appleContainerRejectedOSLabels are OS labels that contradict the macOS +// requirement. +var appleContainerRejectedOSLabels = []string{"linux", "windows"} + +// runsOnLabelsFromYAMLSection extracts runner labels from a rendered runs-on YAML +// snippet such as the value stored in WorkflowData.RunsOn. +// +// It returns the labels and whether a runs-on value was present at all. Parsing +// failures are reported as "no labels" so callers fail closed. +func runsOnLabelsFromYAMLSection(section string) (labels []string, present bool) { + trimmed := strings.TrimSpace(section) + if trimmed == "" { + return nil, false + } + var parsed map[string]any + if err := yaml.Unmarshal([]byte(trimmed), &parsed); err != nil { + appleContainerLog.Printf("Failed to parse runs-on snippet: %v", err) + return nil, true + } + value, ok := parsed["runs-on"] + if !ok || value == nil { + return nil, false + } + return extractRunnerLabels(value), true +} + +// isGitHubHostedMacOSLabel reports whether a label selects a GitHub-hosted macOS +// image. Every GitHub-hosted macOS label is of the form "macos-" +// (macos-latest, macos-15, macos-26, macos-15-xlarge, ...). The bare "macOS" +// label is the self-hosted runner's OS label and is not GitHub-hosted. +func isGitHubHostedMacOSLabel(label string) bool { + return strings.HasPrefix(strings.ToLower(strings.TrimSpace(label)), "macos-") +} + +// hasRunnerLabel reports whether labels contains target (case-insensitive, after +// trimming surrounding whitespace). +func hasRunnerLabel(labels []string, target string) bool { + for _, label := range labels { + if strings.EqualFold(strings.TrimSpace(label), target) { + return true + } + } + return false +} + +// isAppleContainerRunnerLabelSet reports whether a label set explicitly proves a +// self-hosted bare-metal Apple Silicon runner. +// +// Additional custom labels are allowed (a dedicated pool label is encouraged), but +// no GitHub-hosted macos-* label and no contradicting OS/arch label may be present. +func isAppleContainerRunnerLabelSet(labels []string) bool { + if !hasRunnerLabel(labels, appleContainerSelfHostedLabel) || + !hasRunnerLabel(labels, appleContainerMacOSLabel) || + !hasRunnerLabel(labels, appleContainerARM64Label) { + return false + } + for _, label := range labels { + if isGitHubHostedMacOSLabel(label) { + return false + } + if githubActionsExpressionPattern.MatchString(label) { + return false + } + } + for _, rejected := range appleContainerRejectedArchLabels { + if hasRunnerLabel(labels, rejected) { + return false + } + } + for _, rejected := range appleContainerRejectedOSLabels { + if hasRunnerLabel(labels, rejected) { + return false + } + } + return true +} + +// appleContainerRunnerError builds the shared fail-closed runner error. +func appleContainerRunnerError(value, message string) error { + return NewValidationError( + "runs-on", + value, + message, + fmt.Sprintf( + "%s requires a self-hosted bare-metal Apple Silicon runner (Darwin arm64, macOS 26+, kern.hv_support=1). "+ + "GitHub-hosted macOS runners are virtual machines without nested virtualisation and can never host the guest.\n\n"+ + "Declare the runner explicitly:\n\n%s\n\n"+ + "Extra pool labels may be added (for example [self-hosted, macOS, ARM64, apple-container]), but the "+ + "self-hosted, macOS and ARM64 labels must all be present and no GitHub-hosted macos-* label may be used.\n\n"+ + "See: %s", + AgentRuntimeAppleContainer, appleContainerRunnerExample, constants.DocsSandboxURL), + ) +} + +// validateAppleContainerRunnerLabels validates a resolved runs-on label set for +// the apple-container runtime. present reports whether the workflow declared a +// runs-on value at all; an omitted runs-on defaults to ubuntu-latest and is +// therefore rejected. +func validateAppleContainerRunnerLabels(labels []string, present bool) error { + if !present { + return appleContainerRunnerError("(not set)", + fmt.Sprintf("sandbox.agent.runtime: %s requires an explicit runs-on runner declaration", AgentRuntimeAppleContainer)) + } + if len(labels) == 0 { + return appleContainerRunnerError("(no labels)", + fmt.Sprintf("runs-on must list explicit runner labels for sandbox.agent.runtime: %s; a runner group alone cannot prove a self-hosted Apple Silicon host", AgentRuntimeAppleContainer)) + } + + for _, label := range labels { + if isGitHubHostedMacOSLabel(label) { + return appleContainerRunnerError(label, + fmt.Sprintf("GitHub-hosted macOS runner '%s' cannot run sandbox.agent.runtime: %s", label, AgentRuntimeAppleContainer)) + } + } + for _, label := range labels { + if githubActionsExpressionPattern.MatchString(label) { + return appleContainerRunnerError(label, + fmt.Sprintf("runs-on expression '%s' cannot prove a self-hosted Apple Silicon host for sandbox.agent.runtime: %s", label, AgentRuntimeAppleContainer)) + } + } + for _, rejected := range appleContainerRejectedArchLabels { + if hasRunnerLabel(labels, rejected) { + return appleContainerRunnerError(strings.Join(labels, ", "), + fmt.Sprintf("runner label '%s' contradicts the native arm64 requirement of sandbox.agent.runtime: %s (Rosetta translation is refused)", rejected, AgentRuntimeAppleContainer)) + } + } + for _, rejected := range appleContainerRejectedOSLabels { + if hasRunnerLabel(labels, rejected) { + return appleContainerRunnerError(strings.Join(labels, ", "), + fmt.Sprintf("runner label '%s' contradicts the macOS requirement of sandbox.agent.runtime: %s", rejected, AgentRuntimeAppleContainer)) + } + } + + if !isAppleContainerRunnerLabelSet(labels) { + return appleContainerRunnerError(strings.Join(labels, ", "), + fmt.Sprintf("runs-on does not prove a self-hosted Apple Silicon host for sandbox.agent.runtime: %s", AgentRuntimeAppleContainer)) + } + + appleContainerLog.Printf("apple-container runner accepted: %s", strings.Join(labels, ", ")) + return nil +} + +// sandboxAgentRuntimeFromFrontmatter reads sandbox.agent.runtime from raw +// frontmatter. It returns an empty string when the path is absent or not a +// string, which keeps every non-apple-container workflow on the existing rules. +func sandboxAgentRuntimeFromFrontmatter(frontmatter map[string]any) string { + sandbox, ok := frontmatter["sandbox"].(map[string]any) + if !ok { + return "" + } + agent, ok := sandbox["agent"].(map[string]any) + if !ok { + return "" + } + runtime, ok := agent["runtime"].(string) + if !ok { + return "" + } + return strings.TrimSpace(runtime) +} + +// frontmatterSelectsAppleContainer reports whether raw frontmatter selects the +// apple-container sandbox runtime. +func frontmatterSelectsAppleContainer(frontmatter map[string]any) bool { + return sandboxAgentRuntimeFromFrontmatter(frontmatter) == string(AgentRuntimeAppleContainer) +} + +// appleContainerRejectedAWFArgs maps raw AWF arguments that apple-container +// refuses to the reason AWF gives. gh-aw surfaces these at compile time so the +// workflow never reaches a runner that will fail closed anyway. +var appleContainerRejectedAWFArgs = []struct { + arg string + reason string +}{ + {"--legacy-security", "the guest has no NIC, so host and container iptables rules govern nothing"}, + {"--enable-host-access", "only allowlisted AWF capability sockets cross the VM boundary"}, + {"--allow-host-ports", "only allowlisted AWF capability sockets cross the VM boundary"}, + {"--allow-host-service-ports", "only allowlisted AWF capability sockets cross the VM boundary"}, + {"--dns-over-https", "the guest resolves no names at all and reaches every destination through the Squid capability"}, + {"--topology-attach", "externally owned peers are not published to macOS loopback and cannot be bridged into the guest"}, + {"--dind", "the guest never receives a Docker socket"}, + {"--docker-host-path-prefix", "the guest never receives a Docker socket"}, + {"--build-local", "the agent image is pulled through the Apple Container image store, not Docker's"}, + {"--agent-image", "only the default agent image is published as native arm64 and Rosetta translation is refused"}, + {"--sysroot-image", "the guest runs the agent image root filesystem directly and does not use the chroot sysroot"}, + {"--chroot-binaries-source-path", "the guest runs the agent image root filesystem directly and does not use the chroot sysroot"}, + {"--volume", "only the workspace and AWF-owned run directories are exposed to the guest"}, + {"--tty", "the Apple Container preview does not allocate a pseudo-TTY"}, + {"--ssl-bump", "SSL bump requires a locally built Squid image and a guest trust store AWF does not manage here"}, + {"--google-api-key", "the Vertex provider port is not part of the capability transport allowlist"}, + {"--no-network-isolation", "the Apple Container preview requires strict network isolation"}, +} + +// appleContainerIncompatibility builds a consistent incompatibility error keyed on +// sandbox.agent.runtime, matching the docker-sbx/cloud-hypervisor error shape. +func appleContainerIncompatibility(reason, suggestion string) error { + return NewValidationError( + "sandbox.agent.runtime", + string(AgentRuntimeAppleContainer), + reason, + suggestion, + ) +} + +// validateAppleContainerCompatibility enforces the full apple-container contract: +// the AWF version gate, the runner requirement, and every workflow feature AWF +// refuses under this runtime. It is a no-op for all other runtimes. +func validateAppleContainerCompatibility(workflowData *WorkflowData, agentConfig *AgentSandboxConfig) error { + if agentConfig == nil || agentConfig.Disabled || agentConfig.Runtime != AgentRuntimeAppleContainer { + return nil + } + + // AWF version gate. This is checked first so an unsupported pin is reported + // before any feature-level detail. + firewallConfig := getFirewallConfig(workflowData) + if !awfSupportsAppleContainer(firewallConfig) { + effectiveVersion := string(constants.DefaultFirewallVersion) + if firewallConfig != nil && firewallConfig.Version != "" { + effectiveVersion = firewallConfig.Version + } + return appleContainerIncompatibility( + fmt.Sprintf("apple-container requires AWF %s or newer", constants.AWFAppleContainerMinVersion), + fmt.Sprintf("apple-container preview support (container.containerRuntime: apple-container plus appleContainer.previewEnabled) is only available in AWF %s+.\n\nThe effective AWF version is %s. Set sandbox.agent.version (or firewall.version) to %s or newer.", constants.AWFAppleContainerMinVersion, effectiveVersion, constants.AWFAppleContainerMinVersion), + ) + } + + // Runner requirement. validateRunsOn already checks the raw frontmatter; this + // re-checks the resolved workflow so directly constructed WorkflowData and any + // future runs-on source cannot bypass the host requirement. + if workflowData != nil { + labels, present := runsOnLabelsFromYAMLSection(workflowData.RunsOn) + if err := validateAppleContainerRunnerLabels(labels, present); err != nil { + return err + } + } + + if isArcDindTopology(workflowData) { + return appleContainerIncompatibility( + "apple-container is incompatible with runner.topology: arc-dind", + "apple-container requires a self-hosted bare-metal Apple Silicon runner with a local Unix-socket Docker daemon. "+ + "ARC DinD runners are Linux, use a split runner/daemon filesystem, and never give the guest a Docker socket. "+ + "Remove sandbox.agent.runtime: apple-container or change runner.topology.", + ) + } + + if workflowData != nil && len(workflowData.Enclaves) > 0 { + return appleContainerIncompatibility( + "apple-container is incompatible with enclaves", + "apple-container does not yet support the enclaves subsystem: the enclave MCP gateway is a Docker-network peer "+ + "that a NIC-less guest cannot reach. Remove the enclaves configuration, or change sandbox.agent.runtime.", + ) + } + + if len(agentConfig.Mounts) > 0 { + return appleContainerIncompatibility( + "apple-container is incompatible with sandbox.agent.mounts", + "apple-container exposes only the workspace and AWF-owned run directories to the guest; arbitrary volume mounts "+ + "are refused. Remove sandbox.agent.mounts, or change sandbox.agent.runtime.", + ) + } + + if agentConfig.Config != nil && agentConfig.Config.Filesystem != nil && len(agentConfig.Config.Filesystem.AllowWrite) > 0 { + return appleContainerIncompatibility( + "apple-container is incompatible with sandbox.agent.config.filesystem.allowWrite", + "AWF fails closed with \"filesystem.allowWrite is not yet supported by the apple-container runtime\". "+ + "Remove the allowWrite policy, or change sandbox.agent.runtime.", + ) + } + + if firewallConfig != nil && firewallConfig.SSLBump { + return appleContainerIncompatibility( + "apple-container is incompatible with firewall ssl_bump", + "SSL bump requires a locally built Squid image and a guest trust store that AWF does not manage for this runtime. "+ + "Remove ssl_bump (and allow_urls), or change sandbox.agent.runtime.", + ) + } + + if isGeminiVertexWIF(workflowData) { + return appleContainerIncompatibility( + "apple-container is incompatible with Google Vertex AI credential isolation", + "The Vertex provider port is not part of the apple-container capability transport allowlist. "+ + "Use a non-Vertex Gemini configuration, or change sandbox.agent.runtime.", + ) + } + + args := customAWFArgs(workflowData) + for _, rejected := range appleContainerRejectedAWFArgs { + if hasEnabledAWFArg(args, rejected.arg) { + return appleContainerIncompatibility( + "apple-container does not support the AWF argument "+rejected.arg, + fmt.Sprintf("AWF refuses %s under the apple-container runtime: %s.\n\nRemove the argument from sandbox.agent.args (or firewall.args), or change sandbox.agent.runtime.\n\nSee: %s", rejected.arg, rejected.reason, constants.DocsSandboxURL), + ) + } + } + + appleContainerLog.Print("apple-container runtime configured -- AWF version, runner, and feature compatibility checks passed") + return nil +} diff --git a/pkg/workflow/apple_container_test.go b/pkg/workflow/apple_container_test.go new file mode 100644 index 00000000000..0d0e7ae7977 --- /dev/null +++ b/pkg/workflow/apple_container_test.go @@ -0,0 +1,631 @@ +//go:build !integration + +package workflow + +import ( + "strings" + "testing" + + "github.com/github/gh-aw/pkg/constants" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// appleContainerRunsOn is the exact runs-on snippet the compiler accepts for the +// apple-container runtime, rendered the way extractTopLevelYAMLSection stores it. +const appleContainerRunsOn = "runs-on:\n - self-hosted\n - macOS\n - ARM64" + +// newAppleContainerWorkflow builds a minimal apple-container workflow that passes +// every check, so each test can mutate exactly one thing. +func newAppleContainerWorkflow() *WorkflowData { + return &WorkflowData{ + RunsOn: appleContainerRunsOn, + EngineConfig: &EngineConfig{ID: "copilot"}, + SandboxConfig: &SandboxConfig{Agent: &AgentSandboxConfig{ + ID: "awf", + Runtime: AgentRuntimeAppleContainer, + Version: string(constants.AWFAppleContainerMinVersion), + }}, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true, Version: string(constants.AWFAppleContainerMinVersion)}, + }, + Tools: map[string]any{"github": map[string]any{"mode": "remote"}}, + } +} + +func TestAppleContainerRuntimeIsSupported(t *testing.T) { + t.Parallel() + + assert.True(t, isSupportedAgentRuntime(AgentRuntimeAppleContainer)) + assert.Contains(t, supportedAgentRuntimeNames(), "apple-container") + assert.Equal(t, "apple-container", string(AgentRuntimeAppleContainer)) +} + +func TestAppleContainerRuntimeProfile(t *testing.T) { + t.Parallel() + + profile := resolveSandboxRuntimeProfile(&AgentSandboxConfig{Runtime: AgentRuntimeAppleContainer}) + assert.Equal(t, AgentRuntimeAppleContainer, profile.Runtime) + assert.True(t, profile.NetworkIsolation, "AWF requires strict network isolation for apple-container") + assert.False(t, profile.LegacySecurity, "apple-container rejects legacy iptables security") + assert.True(t, profile.Rootless, "AWF itself runs as the runner user") + assert.Equal(t, constants.AWFDefaultCommand.String(), profile.AWFCommand) + assert.False(t, profile.SupportsRuntimeInstall, "layer 1 generates no Apple Container provisioning steps") + assert.False(t, profile.SupportsHostAccess, "the guest has no NIC") +} + +func TestAppleContainerRuntimePredicate(t *testing.T) { + t.Parallel() + + assert.True(t, isAppleContainerRuntime(newAppleContainerWorkflow())) + assert.False(t, isAppleContainerRuntime(&WorkflowData{ + SandboxConfig: &SandboxConfig{Agent: &AgentSandboxConfig{Runtime: AgentRuntimeDocker}}, + })) + // A disabled sandbox never selects the runtime. + assert.False(t, isAppleContainerRuntime(&WorkflowData{ + SandboxConfig: &SandboxConfig{Agent: &AgentSandboxConfig{Runtime: AgentRuntimeAppleContainer, Disabled: true}}, + })) + assert.False(t, isAppleContainerRuntime(nil)) +} + +// ── AWF version gating ────────────────────────────────────────────────────── + +func TestAppleContainerMinVersionIsAboveDefault(t *testing.T) { + t.Parallel() + + // The runtime must fail closed on the default AWF version: gh-aw-firewall#7764 + // is not in any published release yet, so a workflow has to opt in explicitly. + assert.False(t, + versionAtLeast(string(constants.DefaultFirewallVersion), string(constants.DefaultFirewallVersion), string(constants.AWFAppleContainerMinVersion)), + "AWFAppleContainerMinVersion must stay above DefaultFirewallVersion until the backend ships") +} + +func TestAWFSupportsAppleContainerVersionGate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + version string + expected bool + }{ + {name: "default version is too old", version: "", expected: false}, + {name: "explicit older version", version: "v0.28.7", expected: false}, + {name: "one patch below minimum", version: "v0.28.8", expected: false}, + {name: "exact minimum", version: string(constants.AWFAppleContainerMinVersion), expected: true}, + {name: "newer minor", version: "v0.29.0", expected: true}, + {name: "latest", version: "latest", expected: true}, + {name: "non-semver branch name is conservative", version: "my-branch", expected: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := awfSupportsAppleContainer(&FirewallConfig{Enabled: true, Version: tt.version}) + assert.Equal(t, tt.expected, got) + }) + } +} + +func TestAppleContainerRejectedBelowMinVersion(t *testing.T) { + t.Parallel() + + workflowData := newAppleContainerWorkflow() + workflowData.SandboxConfig.Agent.Version = "v0.28.8" + workflowData.NetworkPermissions.Firewall.Version = "v0.28.8" + + err := validateSandboxConfig(workflowData) + require.Error(t, err) + require.ErrorContains(t, err, string(constants.AWFAppleContainerMinVersion)) + require.ErrorContains(t, err, "apple-container") +} + +func TestAppleContainerRejectedOnDefaultAWFVersion(t *testing.T) { + t.Parallel() + + workflowData := newAppleContainerWorkflow() + workflowData.SandboxConfig.Agent.Version = "" + workflowData.NetworkPermissions.Firewall.Version = "" + + err := validateSandboxConfig(workflowData) + require.Error(t, err, "the default AWF version must not silently accept apple-container") + require.ErrorContains(t, err, string(constants.AWFAppleContainerMinVersion)) +} + +// ── Emitted AWF config ────────────────────────────────────────────────────── + +func buildAppleContainerAWFConfigJSON(t *testing.T, workflowData *WorkflowData) string { + t.Helper() + workflowData.SandboxConfig = applySandboxDefaults(workflowData.SandboxConfig, workflowData.EngineConfig) + jsonStr, err := BuildAWFConfigJSON(AWFCommandConfig{ + EngineName: "copilot", + AllowedDomains: "github.com", + WorkflowData: workflowData, + }) + require.NoError(t, err) + return jsonStr +} + +func TestAppleContainerAWFConfigEmitsBothSelectors(t *testing.T) { + t.Parallel() + + jsonStr := buildAppleContainerAWFConfigJSON(t, newAppleContainerWorkflow()) + + assert.Contains(t, jsonStr, `"containerRuntime":"apple-container"`, + "AWF selects the backend through container.containerRuntime") + assert.Contains(t, jsonStr, `"appleContainer":{"previewEnabled":true}`, + "AWF also requires the explicit appleContainer.previewEnabled opt-in") +} + +func TestAppleContainerAWFConfigVersionGated(t *testing.T) { + t.Parallel() + + workflowData := newAppleContainerWorkflow() + workflowData.SandboxConfig.Agent.Version = "v0.28.8" + workflowData.NetworkPermissions.Firewall.Version = "v0.28.8" + + jsonStr := buildAppleContainerAWFConfigJSON(t, workflowData) + + assert.NotContains(t, jsonStr, "apple-container", + "the containerRuntime enum value must never reach an AWF that does not know it") + assert.NotContains(t, jsonStr, "appleContainer", + "the appleContainer section must never reach an AWF that does not know it") +} + +func TestAppleContainerAWFConfigOmitsTopologyAttach(t *testing.T) { + t.Parallel() + + jsonStr := buildAppleContainerAWFConfigJSON(t, newAppleContainerWorkflow()) + + // AWF fails closed on any non-empty topologyAttach for this runtime. + assert.NotContains(t, jsonStr, "topologyAttach") + assert.NotContains(t, jsonStr, "awmg-mcpg") + assert.Contains(t, jsonStr, `"isolation":true`) +} + +func TestAppleContainerTopologyAttachListIsEmpty(t *testing.T) { + t.Parallel() + + assert.Empty(t, buildAWFTopologyAttachList(newAppleContainerWorkflow())) + // Other runtimes keep the existing behaviour. + assert.Equal(t, []string{"awmg-mcpg"}, buildAWFTopologyAttachList(&WorkflowData{ + SandboxConfig: &SandboxConfig{Agent: &AgentSandboxConfig{Runtime: AgentRuntimeDocker}}, + })) +} + +func TestAppleContainerContainerRuntimeString(t *testing.T) { + t.Parallel() + + assert.Equal(t, "apple-container", getAgentContainerRuntime(newAppleContainerWorkflow())) + // Existing runtimes are unchanged. + assert.Equal(t, "gvisor", getAgentContainerRuntime(&WorkflowData{ + SandboxConfig: &SandboxConfig{Agent: &AgentSandboxConfig{Runtime: AgentRuntimeGVisor}}, + })) + assert.Empty(t, getAgentContainerRuntime(&WorkflowData{ + SandboxConfig: &SandboxConfig{Agent: &AgentSandboxConfig{Runtime: AgentRuntimeCloudHypervisor}}, + })) +} + +func TestAppleContainerSuppressesTTY(t *testing.T) { + t.Parallel() + + args := appendTTYAndContainerRuntimeArgs( + AWFCommandConfig{UsesTTY: true, WorkflowData: newAppleContainerWorkflow()}, + &FirewallConfig{Enabled: true, Version: string(constants.AWFAppleContainerMinVersion)}, + ) + assert.NotContains(t, args, "--tty") + assert.NotContains(t, args, "--container-runtime", + "apple-container is selected through the config file, not a CLI flag") +} + +// ── Embedded AWF config schema ────────────────────────────────────────────── + +func TestValidateAWFConfigJSON_AllowsAppleContainer(t *testing.T) { + t.Parallel() + + err := validateAWFConfigJSON(`{"container":{"containerRuntime":"apple-container"},"appleContainer":{"previewEnabled":true}}`) + require.NoError(t, err) +} + +func TestValidateAWFConfigJSON_AppleContainerOptionalFields(t *testing.T) { + t.Parallel() + + err := validateAWFConfigJSON(`{"appleContainer":{"previewEnabled":true,"cpus":8,"memory":"16G","cliPath":"/usr/local/bin/container"}}`) + require.NoError(t, err) +} + +func TestValidateAWFConfigJSON_RejectsInvalidAppleContainerFields(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config string + }{ + {name: "unknown property", config: `{"appleContainer":{"previewEnabled":true,"vcpus":4}}`}, + {name: "cpus below minimum", config: `{"appleContainer":{"previewEnabled":true,"cpus":0}}`}, + {name: "cpus not an integer", config: `{"appleContainer":{"previewEnabled":true,"cpus":"4"}}`}, + {name: "memory wrong shape", config: `{"appleContainer":{"previewEnabled":true,"memory":"8 gigabytes"}}`}, + {name: "init image not digest pinned", config: `{"appleContainer":{"previewEnabled":true,"initImage":"ghcr.io/github/gh-aw-firewall/apple-init:v1"}}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + require.Error(t, validateAWFConfigJSON(tt.config)) + }) + } +} + +func TestValidateAWFConfigJSON_AllowsAppleInitImageRole(t *testing.T) { + t.Parallel() + + err := validateAWFConfigJSON(`{"container":{"images":{"appleInit":"ghcr.io/github/gh-aw-firewall/apple-init:v0.28.9@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}}}`) + require.NoError(t, err) +} + +// ── container.images appleInit role ───────────────────────────────────────── + +func TestAppleContainerRequiresAppleInitImageRole(t *testing.T) { + t.Parallel() + + assert.True(t, isKnownAWFImageRole(awfImageRoleAppleInit)) + assert.Contains(t, requiredAWFImageRoles(newAppleContainerWorkflow()), awfImageRoleAppleInit) + assert.NotContains(t, requiredAWFImageRoles(&WorkflowData{ + SandboxConfig: &SandboxConfig{Agent: &AgentSandboxConfig{Runtime: AgentRuntimeDocker}}, + }), awfImageRoleAppleInit) +} + +func TestAppleContainerManifestMustPinAppleInit(t *testing.T) { + t.Parallel() + + const pinned = "ghcr.io/github/gh-aw-firewall/%s:v0.28.9@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + + workflowData := newAppleContainerWorkflow() + workflowData.SandboxConfig.Agent.Images = map[string]string{ + awfImageRoleSquid: strings.ReplaceAll(pinned, "%s", "squid"), + awfImageRoleAgent: strings.ReplaceAll(pinned, "%s", "agent"), + awfImageRoleAPIProxy: strings.ReplaceAll(pinned, "%s", "api-proxy"), + } + + err := validateSandboxConfig(workflowData) + require.Error(t, err) + require.ErrorContains(t, err, awfImageRoleAppleInit) + + workflowData.SandboxConfig.Agent.Images[awfImageRoleAppleInit] = strings.ReplaceAll(pinned, "%s", "apple-init") + require.NoError(t, validateSandboxConfig(workflowData)) +} + +// ── Runner acceptance / rejection matrix ──────────────────────────────────── + +func TestAppleContainerRunnerMatrix(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + labels []string + present bool + wantErr bool + errContains string + }{ + {name: "canonical self-hosted Apple Silicon", labels: []string{"self-hosted", "macOS", "ARM64"}}, + {name: "lowercase labels", labels: []string{"self-hosted", "macos", "arm64"}}, + {name: "extra dedicated pool label", labels: []string{"self-hosted", "macOS", "ARM64", "apple-container"}}, + {name: "different label order", labels: []string{"ARM64", "macOS", "self-hosted"}}, + + {name: "github-hosted macos-latest", labels: []string{"macos-latest"}, wantErr: true, errContains: "macos-latest"}, + {name: "github-hosted macos-26", labels: []string{"macos-26"}, wantErr: true, errContains: "macos-26"}, + {name: "github-hosted macos-15-xlarge", labels: []string{"macos-15-xlarge"}, wantErr: true, errContains: "macos-15-xlarge"}, + {name: "github-hosted label mixed with self-hosted", labels: []string{"self-hosted", "macos-26", "ARM64"}, wantErr: true, errContains: "macos-26"}, + {name: "bare macOS without self-hosted", labels: []string{"macOS", "ARM64"}, wantErr: true}, + {name: "self-hosted macOS without arm64", labels: []string{"self-hosted", "macOS"}, wantErr: true}, + {name: "self-hosted arm64 without macOS", labels: []string{"self-hosted", "ARM64"}, wantErr: true}, + {name: "x64 architecture", labels: []string{"self-hosted", "macOS", "x64"}, wantErr: true, errContains: "x64"}, + {name: "conflicting arm64 and x64", labels: []string{"self-hosted", "macOS", "ARM64", "x64"}, wantErr: true, errContains: "x64"}, + {name: "linux label", labels: []string{"self-hosted", "linux", "ARM64", "macOS"}, wantErr: true, errContains: "linux"}, + {name: "ubuntu-latest", labels: []string{"ubuntu-latest"}, wantErr: true}, + {name: "expression", labels: []string{"${{ vars.RUNNER }}"}, wantErr: true, errContains: "cannot prove"}, + {name: "custom label alone", labels: []string{"self-hosted", "my-mac-pool"}, wantErr: true}, + {name: "runner group only", labels: nil, present: true, wantErr: true, errContains: "runner group"}, + {name: "runs-on omitted", labels: nil, wantErr: true, errContains: "explicit runs-on"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + present := tt.present || len(tt.labels) > 0 + err := validateAppleContainerRunnerLabels(tt.labels, present) + if !tt.wantErr { + require.NoError(t, err) + return + } + require.Error(t, err) + if tt.errContains != "" { + require.ErrorContains(t, err, tt.errContains) + } + // Every rejection must name the exact accepted syntax. + require.ErrorContains(t, err, appleContainerRunnerExample) + }) + } +} + +func TestAppleContainerRunnerValidatedFromWorkflowData(t *testing.T) { + t.Parallel() + + workflowData := newAppleContainerWorkflow() + workflowData.RunsOn = "runs-on: macos-26" + + err := validateSandboxConfig(workflowData) + require.Error(t, err) + require.ErrorContains(t, err, "macos-26") +} + +func TestAppleContainerRunnerDefaultUbuntuRejected(t *testing.T) { + t.Parallel() + + workflowData := newAppleContainerWorkflow() + workflowData.RunsOn = "runs-on: ubuntu-latest" + + require.Error(t, validateSandboxConfig(workflowData)) +} + +func TestRunsOnLabelsFromYAMLSection(t *testing.T) { + t.Parallel() + + labels, present := runsOnLabelsFromYAMLSection(appleContainerRunsOn) + require.True(t, present) + assert.Equal(t, []string{"self-hosted", "macOS", "ARM64"}, labels) + + labels, present = runsOnLabelsFromYAMLSection("runs-on: ubuntu-latest") + require.True(t, present) + assert.Equal(t, []string{"ubuntu-latest"}, labels) + + labels, present = runsOnLabelsFromYAMLSection("runs-on:\n group: my-group") + require.True(t, present) + assert.Empty(t, labels) + + _, present = runsOnLabelsFromYAMLSection("") + assert.False(t, present) +} + +// TestRunsOnValidationKeepsMacOSBanForOtherRuntimes guards the exemption: only the +// agent job's own runs-on is allowed to be macOS, and only for apple-container. +func TestRunsOnValidationMacOSExemptionIsScoped(t *testing.T) { + t.Parallel() + + appleFrontmatter := func() map[string]any { + return map[string]any{ + "sandbox": map[string]any{ + "agent": map[string]any{"runtime": "apple-container"}, + }, + "runs-on": []any{"self-hosted", "macOS", "ARM64"}, + } + } + + t.Run("accepts the agent runner", func(t *testing.T) { + t.Parallel() + require.NoError(t, validateRunsOn(appleFrontmatter(), "test.md")) + }) + + t.Run("still rejects macOS for runs-on-slim", func(t *testing.T) { + t.Parallel() + frontmatter := appleFrontmatter() + frontmatter["runs-on-slim"] = "macos-latest" + err := validateRunsOn(frontmatter, "test.md") + require.Error(t, err) + require.ErrorContains(t, err, "runs-on-slim") + }) + + t.Run("still rejects macOS for safe-outputs runners", func(t *testing.T) { + t.Parallel() + frontmatter := appleFrontmatter() + frontmatter["safe-outputs"] = map[string]any{"runs-on": "macos-latest"} + err := validateRunsOn(frontmatter, "test.md") + require.Error(t, err) + require.ErrorContains(t, err, "safe-outputs.runs-on") + }) + + t.Run("still rejects macOS without apple-container", func(t *testing.T) { + t.Parallel() + err := validateRunsOn(map[string]any{"runs-on": []any{"self-hosted", "macOS", "ARM64"}}, "test.md") + require.Error(t, err) + require.ErrorContains(t, err, macOSRunnerFAQURL) + }) + + t.Run("rejects github-hosted macOS with apple-container", func(t *testing.T) { + t.Parallel() + frontmatter := appleFrontmatter() + frontmatter["runs-on"] = "macos-26" + err := validateRunsOn(frontmatter, "test.md") + require.Error(t, err) + require.ErrorContains(t, err, "macos-26") + }) + + // runs-on can arrive from an import, which this validator cannot see. Deferring + // is safe because validateSandboxConfig re-validates the merged runner. + t.Run("defers an omitted runs-on to the post-merge check", func(t *testing.T) { + t.Parallel() + frontmatter := appleFrontmatter() + delete(frontmatter, "runs-on") + require.NoError(t, validateRunsOn(frontmatter, "test.md")) + }) +} + +// ── Incompatible feature matrix ───────────────────────────────────────────── + +func TestAppleContainerIncompatibleFeatures(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + mutate func(*WorkflowData) + errContains string + }{ + { + name: "arc-dind topology", + mutate: func(w *WorkflowData) { w.RunnerConfig = &RunnerConfig{Topology: RunnerTopologyArcDind} }, + errContains: "arc-dind", + }, + { + name: "enclaves", + mutate: func(w *WorkflowData) { w.Enclaves = []*EnclaveConfig{{Script: &ScriptEnclaveConfig{}}} }, + errContains: "enclaves", + }, + { + name: "volume mounts", + mutate: func(w *WorkflowData) { w.SandboxConfig.Agent.Mounts = []string{"/host:/guest:ro"} }, + errContains: "mounts", + }, + { + name: "filesystem allowWrite", + mutate: func(w *WorkflowData) { + w.SandboxConfig.Agent.Config = &SandboxRuntimeConfig{ + Filesystem: &SRTFilesystemConfig{AllowWrite: []string{"/workspace"}}, + } + }, + errContains: "allowWrite", + }, + { + name: "ssl bump", + mutate: func(w *WorkflowData) { w.NetworkPermissions.Firewall.SSLBump = true }, + errContains: "ssl_bump", + }, + { + name: "legacy security argument", + mutate: func(w *WorkflowData) { w.SandboxConfig.Agent.Args = []string{"--legacy-security"} }, + errContains: "--legacy-security", + }, + { + name: "host access argument", + mutate: func(w *WorkflowData) { w.SandboxConfig.Agent.Args = []string{"--enable-host-access"} }, + errContains: "--enable-host-access", + }, + { + name: "dns over https argument", + mutate: func(w *WorkflowData) { w.SandboxConfig.Agent.Args = []string{"--dns-over-https"} }, + errContains: "--dns-over-https", + }, + { + name: "topology attach argument", + mutate: func(w *WorkflowData) { w.SandboxConfig.Agent.Args = []string{"--topology-attach=awmg-mcpg"} }, + errContains: "--topology-attach", + }, + { + name: "build local argument", + mutate: func(w *WorkflowData) { w.SandboxConfig.Agent.Args = []string{"--build-local"} }, + errContains: "--build-local", + }, + { + name: "custom agent image argument", + mutate: func(w *WorkflowData) { w.SandboxConfig.Agent.Args = []string{"--agent-image=example/agent:act"} }, + errContains: "--agent-image", + }, + { + name: "sysroot image argument", + mutate: func(w *WorkflowData) { w.SandboxConfig.Agent.Args = []string{"--sysroot-image=example/sysroot"} }, + errContains: "--sysroot-image", + }, + { + name: "chroot binaries source path argument", + mutate: func(w *WorkflowData) { w.SandboxConfig.Agent.Args = []string{"--chroot-binaries-source-path=/opt/bin"} }, + errContains: "--chroot-binaries-source-path", + }, + { + name: "volume argument", + mutate: func(w *WorkflowData) { w.SandboxConfig.Agent.Args = []string{"--volume=/host:/guest"} }, + errContains: "--volume", + }, + { + name: "tty argument", + mutate: func(w *WorkflowData) { w.SandboxConfig.Agent.Args = []string{"--tty"} }, + errContains: "--tty", + }, + { + name: "dind argument", + mutate: func(w *WorkflowData) { w.SandboxConfig.Agent.Args = []string{"--dind"} }, + errContains: "--dind", + }, + { + name: "google api key argument", + mutate: func(w *WorkflowData) { w.SandboxConfig.Agent.Args = []string{"--google-api-key"} }, + errContains: "--google-api-key", + }, + { + name: "firewall args are checked too", + mutate: func(w *WorkflowData) { w.NetworkPermissions.Firewall.Args = []string{"--ssl-bump"} }, + errContains: "--ssl-bump", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + workflowData := newAppleContainerWorkflow() + tt.mutate(workflowData) + + err := validateSandboxConfig(workflowData) + require.Error(t, err) + require.ErrorContains(t, err, tt.errContains) + require.ErrorContains(t, err, "apple-container") + }) + } +} + +func TestAppleContainerRejectsHostPortsAndRuntimeInstall(t *testing.T) { + t.Parallel() + + t.Run("allow-host-ports", func(t *testing.T) { + t.Parallel() + workflowData := newAppleContainerWorkflow() + workflowData.SandboxConfig.Agent.AllowHostPorts = []int{9000} + err := validateSandboxConfig(workflowData) + require.Error(t, err) + require.ErrorContains(t, err, "allow-host-ports") + }) + + t.Run("services with published ports", func(t *testing.T) { + t.Parallel() + workflowData := newAppleContainerWorkflow() + workflowData.ServicePortExpressions = "5432:5432" + err := validateSandboxConfig(workflowData) + require.Error(t, err) + require.ErrorContains(t, err, "services") + }) + + t.Run("runtime-install", func(t *testing.T) { + t.Parallel() + workflowData := newAppleContainerWorkflow() + runtimeInstall := true + workflowData.SandboxConfig.Agent.RuntimeInstall = &runtimeInstall + err := validateSandboxConfig(workflowData) + require.Error(t, err) + require.ErrorContains(t, err, "runtime-install") + }) +} + +// TestAppleContainerBaselineValidates is the positive control for the matrix above. +func TestAppleContainerBaselineValidates(t *testing.T) { + t.Parallel() + + require.NoError(t, validateSandboxConfig(newAppleContainerWorkflow())) +} + +// TestAppleContainerLeavesLinuxRuntimesUnchanged guards against collateral damage +// to the existing runtime profiles. +func TestAppleContainerLeavesLinuxRuntimesUnchanged(t *testing.T) { + t.Parallel() + + for _, runtime := range []AgentRuntime{AgentRuntimeDocker, AgentRuntimeDockerSudoIptables, AgentRuntimeGVisor} { + t.Run(string(runtime), func(t *testing.T) { + t.Parallel() + workflowData := &WorkflowData{ + RunsOn: "runs-on: ubuntu-latest", + EngineConfig: &EngineConfig{ID: "copilot"}, + SandboxConfig: &SandboxConfig{Agent: &AgentSandboxConfig{ID: "awf", Runtime: runtime}}, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + Tools: map[string]any{"github": map[string]any{"mode": "remote"}}, + } + require.NoError(t, validateSandboxConfig(workflowData)) + }) + } +} diff --git a/pkg/workflow/awf_arc_dind.go b/pkg/workflow/awf_arc_dind.go index 238613c6619..6f0068d1c81 100644 --- a/pkg/workflow/awf_arc_dind.go +++ b/pkg/workflow/awf_arc_dind.go @@ -52,6 +52,11 @@ func buildAWFImageTagWithDigests(imageTag string, workflowData *WorkflowData) st if isArcDindTopology(workflowData) { specs = append(specs, digestSpec{name: "build-tools", image: constants.DefaultFirewallRegistry + "/build-tools:" + imageTag}) } + // Apple Container additionally needs the guest init image pinned: AWF resolves + // it from the same registry/tag contract as the agent image. + if isAppleContainerRuntime(workflowData) { + specs = append(specs, digestSpec{name: "apple-init", image: constants.DefaultFirewallRegistry + "/apple-init:" + imageTag}) + } parts := []string{imageTag} var missing []string diff --git a/pkg/workflow/awf_command_builder.go b/pkg/workflow/awf_command_builder.go index 7b249f713d0..7b445de10cc 100644 --- a/pkg/workflow/awf_command_builder.go +++ b/pkg/workflow/awf_command_builder.go @@ -401,7 +401,8 @@ func BuildAWFArgs(config AWFCommandConfig) []string { func appendTTYAndContainerRuntimeArgs(config AWFCommandConfig, firewallConfig *FirewallConfig) []string { var awfArgs []string - if config.UsesTTY && !isDockerSbxRuntime(config.WorkflowData) && !isCloudHypervisorRuntime(config.WorkflowData) { + if config.UsesTTY && !isDockerSbxRuntime(config.WorkflowData) && !isCloudHypervisorRuntime(config.WorkflowData) && + !isAppleContainerRuntime(config.WorkflowData) { awfArgs = append(awfArgs, "--tty") } if isDockerSbxRuntime(config.WorkflowData) && awfSupportsContainerRuntime(firewallConfig) { diff --git a/pkg/workflow/awf_config.go b/pkg/workflow/awf_config.go index 4855eb5af90..9c2d6a9786b 100644 --- a/pkg/workflow/awf_config.go +++ b/pkg/workflow/awf_config.go @@ -92,6 +92,11 @@ type AWFConfigFile struct { // Container contains container execution configuration. Container *AWFContainerConfig `json:"container,omitempty"` + // AppleContainer configures the Apple Container (Virtualization.framework) + // workload preview. AWF only resolves the backend when this section sets + // previewEnabled together with container.containerRuntime: "apple-container". + AppleContainer *AWFAppleContainerConfig `json:"appleContainer,omitempty"` + // Logging contains logging and diagnostics configuration. Logging *AWFLoggingConfig `json:"logging,omitempty"` @@ -261,9 +266,11 @@ type AWFContainerConfig struct { // Maps to: --docker-host-path-prefix DockerHostPathPrefix string `json:"dockerHostPathPrefix,omitempty"` - // ContainerRuntime specifies the OCI runtime for the agent container. - // "gvisor" enables gVisor's runsc runtime for additional kernel-level isolation. + // ContainerRuntime specifies the runtime for the agent workload. + // "gvisor" enables gVisor's runsc runtime for additional kernel-level isolation; // AWF translates "gvisor" → "runsc" internally. + // "apple-container" selects the Apple Virtualization.framework workload preview + // and additionally requires appleContainer.previewEnabled. ContainerRuntime string `json:"containerRuntime,omitempty"` // Images is the closed manifest of digest-pinned AWF infrastructure images, @@ -274,6 +281,39 @@ type AWFContainerConfig struct { Images map[string]string `json:"images,omitempty"` } +// AWFAppleContainerConfig is the "appleContainer" section of the AWF config file. +// +// It mirrors AWF's AppleContainerOptions (gh-aw-firewall#7764). AWF fails closed +// unless PreviewEnabled is true AND container.containerRuntime is "apple-container"; +// neither signal alone selects the backend. +// +// Sizing (cpus/memory), the init image, and the CLI path are intentionally not +// exposed as gh-aw frontmatter in this contract layer: the compiler emits only the +// two required selector fields and lets AWF apply its documented defaults +// (cpus: 4, memory: "8G", registry-derived digest-pinned init image, CLI on PATH). +type AWFAppleContainerConfig struct { + // PreviewEnabled is the explicit opt-in for the Apple Container + // workload-execution preview. AWF refuses to resolve the backend without it. + // This is emitted as an explicit `false`-able field with no omitempty so the + // opt-in is always visible in the generated config. + PreviewEnabled bool `json:"previewEnabled"` + + // CPUs is the guest virtual CPU count. AWF defaults to 4 when omitted. + CPUs int `json:"cpus,omitempty"` + + // Memory is the guest memory size, an integer with an optional K/M/G/T/P + // suffix. AWF defaults to "8G" when omitted. + Memory string `json:"memory,omitempty"` + + // InitImage is the digest-pinned AWF Apple init image carrying the guest + // capability relay. AWF derives it from the registry/tag when omitted. + InitImage string `json:"initImage,omitempty"` + + // CliPath is the absolute path to the Apple "container" CLI when it is not on + // PATH. + CliPath string `json:"cliPath,omitempty"` +} + // AWFLoggingConfig is the "logging" section of the AWF config file. // It maps to logging and diagnostics CLI flags. type AWFLoggingConfig struct { diff --git a/pkg/workflow/awf_config_build.go b/pkg/workflow/awf_config_build.go index ed973797f51..bd77b025278 100644 --- a/pkg/workflow/awf_config_build.go +++ b/pkg/workflow/awf_config_build.go @@ -299,6 +299,18 @@ func BuildAWFConfigJSON(config AWFCommandConfig) (string, error) { } agentRuntime = "" } + // The apple-container enum value and the appleContainer section were added + // together in gh-aw-firewall#7764. Older AWF binaries reject both, so they are + // gated as one unit: the selector is never emitted without the opt-in, and the + // opt-in is never emitted without the selector. + if agentRuntime == string(AgentRuntimeAppleContainer) && !awfSupportsAppleContainer(firewallConfig) { + awfConfigLog.Printf("Skipping containerRuntime apple-container: AWF version %q requires at least %s (gh-aw-firewall#7764)", getAWFImageTag(firewallConfig), constants.AWFAppleContainerMinVersion) + agentRuntime = "" + } + if agentRuntime == string(AgentRuntimeAppleContainer) { + awfConfig.AppleContainer = &AWFAppleContainerConfig{PreviewEnabled: true} + awfConfigLog.Print("Apple Container section: previewEnabled=true") + } if awfImageTag != "" || isArcDindTopology(config.WorkflowData) || agentRuntime != "" || agentTimeout > 0 || len(containerImages) > 0 { container := &AWFContainerConfig{ ImageTag: awfImageTag, @@ -392,7 +404,17 @@ func resolveAWFContainerAgentTimeoutMinutes(workflowData *WorkflowData) int { // The list always includes the MCP gateway and conditionally includes the // host-started CLI proxy sidecar when gh-proxy mode is active. Cloud Hypervisor // omits the CLI proxy until its control peer supports the proxy's TCP port. +// +// Apple Container attaches nothing: AWF fails closed on any non-empty +// topologyAttach for that runtime because externally owned peers are not +// published to macOS loopback and cannot be bridged into the NIC-less guest. +// Infrastructure the guest may reach is instead published as an explicit +// capability socket by AWF itself. Rewiring the gh-aw MCP gateway onto that +// transport is the follow-up stack layer's job. func buildAWFTopologyAttachList(workflowData *WorkflowData) []string { + if isAppleContainerRuntime(workflowData) { + return nil + } targets := []string{"awmg-mcpg"} if !isCloudHypervisorRuntime(workflowData) && isCliProxyNeeded(workflowData) { targets = append(targets, "awmg-cli-proxy") diff --git a/pkg/workflow/awf_feature_flags.go b/pkg/workflow/awf_feature_flags.go index 356a0e9d88b..414986f4862 100644 --- a/pkg/workflow/awf_feature_flags.go +++ b/pkg/workflow/awf_feature_flags.go @@ -65,6 +65,18 @@ func awfSupportsCloudHypervisor(firewallConfig *FirewallConfig) bool { return awfVersionAtLeast(firewallConfig, constants.AWFCloudHypervisorMinVersion) } +// awfSupportsAppleContainer returns true when the effective AWF version supports the +// apple-container preview runtime: container.containerRuntime: "apple-container", +// the appleContainer config section, and the container.images appleInit role +// (gh-aw-firewall#7764). +// +// The compiler must never emit any of those fields to an older AWF: strict config +// validation there rejects the unknown containerRuntime enum value, the unknown +// top-level appleContainer property, and the unknown appleInit image role. +func awfSupportsAppleContainer(firewallConfig *FirewallConfig) bool { + return awfVersionAtLeast(firewallConfig, constants.AWFAppleContainerMinVersion) +} + // awfSupportsLegacySecurity returns true when the effective AWF version supports the // --legacy-security flag (v0.27.32+). Older versions default to legacy mode and do not // recognize this flag. diff --git a/pkg/workflow/docker.go b/pkg/workflow/docker.go index fad721b9a42..101766d8b39 100644 --- a/pkg/workflow/docker.go +++ b/pkg/workflow/docker.go @@ -95,6 +95,14 @@ func collectDockerImages(tools map[string]any, workflowData *WorkflowData, actio // every AWF consumer is predownloaded under --skip-pull. A closed manifest // is authoritative: its literal references bypass aw.json container_pins. for _, role := range requiredAWFImageRoles(workflowData) { + // Apple Container keeps a separate image store that `docker pull` cannot + // populate, so the appleInit role is deliberately excluded from Docker + // predownload. Runtime-aware provisioning for it lands in the follow-up + // Apple Container stack layer. + if role == awfImageRoleAppleInit { + dockerLog.Print("Skipping AWF appleInit container: Apple Container uses a separate image store") + continue + } image := defaultAWFImageForRole(role, awfImageTag) if sandboxImages != nil { image = sandboxImages[role] diff --git a/pkg/workflow/firewall.go b/pkg/workflow/firewall.go index d319a717711..98abf90aad9 100644 --- a/pkg/workflow/firewall.go +++ b/pkg/workflow/firewall.go @@ -124,14 +124,20 @@ func getAgentContainerRuntime(workflowData *WorkflowData) string { if agentConfig == nil || agentConfig.Disabled { return "" } - // Only gVisor is an OCI runtime that AWF passes through as - // container.containerRuntime. The docker/docker-sudo-iptables profiles use the + // gVisor is an OCI runtime that AWF passes through as + // container.containerRuntime. apple-container is not an OCI runtime, but AWF + // selects the backend through the same config field (there is no dedicated CLI + // flag for the selector). The docker/docker-sudo-iptables profiles use the // default Docker runtime, and docker-sbx/cloud-hypervisor pass // --container-runtime via CLI flags in BuildAWFArgs instead. - if agentConfig.Runtime != AgentRuntimeGVisor { + switch agentConfig.Runtime { + case AgentRuntimeGVisor: + return string(AgentRuntimeGVisor) + case AgentRuntimeAppleContainer: + return string(AgentRuntimeAppleContainer) + default: return "" } - return string(AgentRuntimeGVisor) } // isGVisorRuntime returns true when the agent container should use gVisor (runsc). @@ -163,6 +169,16 @@ func isCloudHypervisorRuntime(workflowData *WorkflowData) bool { return agentConfig.Runtime == AgentRuntimeCloudHypervisor } +// isAppleContainerRuntime returns true when the agent should run inside an Apple +// Virtualization.framework microVM (preview, gh-aw-firewall#7764). +func isAppleContainerRuntime(workflowData *WorkflowData) bool { + agentConfig := getAgentConfig(workflowData) + if agentConfig == nil || agentConfig.Disabled { + return false + } + return agentConfig.Runtime == AgentRuntimeAppleContainer +} + // declaresIgnoredFilesystemAllowWrite returns true when a workflow declares // sandbox.agent.config.filesystem.allowWrite on a runtime where the compiler drops // it (see awfEmitsFilesystemAllowWrite). Only explicit opt-ins are reported: the diff --git a/pkg/workflow/runs_on_validation.go b/pkg/workflow/runs_on_validation.go index 2404d104ac9..7a7741bb366 100644 --- a/pkg/workflow/runs_on_validation.go +++ b/pkg/workflow/runs_on_validation.go @@ -70,8 +70,32 @@ func validateRunsOn(frontmatter map[string]any, markdownPath string) error { } } + // The apple-container sandbox runtime is the one case where a macOS runner is + // required rather than forbidden, and only for the agent job's own runs-on. + // Every other runner field (runs-on-slim and the safe-output job runners) keeps + // the blanket macOS rejection because those jobs still need Linux containers. + // + // Only this workflow's own frontmatter is visible here; runs-on and the sandbox + // runtime can also arrive from an import. That is safe in both directions: + // validateSandboxConfig re-validates the merged runner for every apple-container + // workflow, so an omitted local runs-on is deferred rather than rejected here, + // and a runtime that arrives only from an import still gets its host requirement + // enforced after merging. + appleContainer := frontmatterSelectsAppleContainer(frontmatter) + for _, field := range runsOnFields { labels := extractRunnerLabels(field.value) + if appleContainer && field.name == "runs-on" { + if isEmptyRunsOnValue(field.value) { + // Defer to the post-merge check, which can see an imported runs-on. + continue + } + if err := validateAppleContainerRunnerLabels(labels, true); err != nil { + runsOnValidationLog.Printf("apple-container runs-on validation failed: %v", err) + return err + } + continue + } for _, label := range labels { lower := strings.ToLower(label) if strings.HasPrefix(lower, "macos-") || strings.EqualFold(lower, "macos") { @@ -79,8 +103,9 @@ func validateRunsOn(frontmatter map[string]any, markdownPath string) error { fmt.Sprintf("%s includes unsupported runner '%s'.\n\n"+ "Agentic workflows require Linux containers and container jobs. Use a Linux runner label or runner-group configuration instead.\n\n"+ "Example: runs-on: [self-hosted, linux, x64]\n\n"+ + "The only exception is sandbox.agent.runtime: apple-container, which requires %s declared in this workflow's own frontmatter.\n\n"+ "See %s for details.", - field.name, label, macOSRunnerFAQURL), nil) + field.name, label, appleContainerRunnerExample, macOSRunnerFAQURL), nil) } } } diff --git a/pkg/workflow/sandbox.go b/pkg/workflow/sandbox.go index 25c323a0dae..ef2ef2f2e76 100644 --- a/pkg/workflow/sandbox.go +++ b/pkg/workflow/sandbox.go @@ -77,6 +77,20 @@ const ( // AgentRuntimeCloudHypervisor runs the agent inside a Cloud Hypervisor microVM // using AWF's preview cloud-hypervisor runtime mode. AgentRuntimeCloudHypervisor AgentRuntime = "cloud-hypervisor" + + // AgentRuntimeAppleContainer runs the agent inside an Apple + // Virtualization.framework microVM using AWF's preview apple-container runtime + // mode (gh-aw-firewall#7764). + // + // The guest has no NIC and reaches AWF infrastructure exclusively through + // published Unix-socket capabilities. AWF keeps Squid, the API proxy, the CLI + // proxy, and the MCP gateway in Docker Compose on the host, so Docker is still + // required; only the agent workload moves to Apple Container. + // + // Supported host: self-hosted bare-metal Apple Silicon, macOS 26+, native + // arm64, kern.hv_support=1. GitHub-hosted macOS runners are VMs without nested + // virtualisation and are rejected at compile time. + AgentRuntimeAppleContainer AgentRuntime = "apple-container" ) // AgentSandboxConfig represents the agent sandbox configuration diff --git a/pkg/workflow/sandbox_agent_images.go b/pkg/workflow/sandbox_agent_images.go index 8d6532f89e7..997b2b45a40 100644 --- a/pkg/workflow/sandbox_agent_images.go +++ b/pkg/workflow/sandbox_agent_images.go @@ -39,6 +39,7 @@ const ( awfImageRoleEnclaveAgent = "enclaveAgent" awfImageRoleEnclaveMcpServer = "enclaveMcpServer" awfImageRoleDindStaging = "dindStaging" + awfImageRoleAppleInit = "appleInit" ) // awfImageRoles lists the supported container.images roles in documentation order. @@ -53,6 +54,7 @@ var awfImageRoles = []string{ awfImageRoleEnclaveAgent, awfImageRoleEnclaveMcpServer, awfImageRoleDindStaging, + awfImageRoleAppleInit, } // awfPinnedImagePattern is AWF's canonical digestPinnedImage grammar. It follows @@ -79,6 +81,11 @@ func isKnownAWFImageRole(role string) bool { func requiredAWFImageRoles(workflowData *WorkflowData) []string { required := []string{awfImageRoleSquid, awfImageRoleAgent, awfImageRoleAPIProxy} args := customAWFArgs(workflowData) + // Apple Container boots the guest from a dedicated init image that carries the + // capability relay. AWF fails closed when a manifest omits it. + if isAppleContainerRuntime(workflowData) { + required = append(required, awfImageRoleAppleInit) + } if isCliProxyNeeded(workflowData) || hasEnabledAWFArg(args, "--difc-proxy-host") { required = append(required, awfImageRoleCliProxy) } @@ -143,6 +150,8 @@ func defaultAWFImageForRole(role, imageTag string) string { return constants.DefaultFirewallRegistry + "/enclave-mcp-server:" + imageTag case awfImageRoleDindStaging: return constants.DefaultFirewallRegistry + "/agent:latest" + case awfImageRoleAppleInit: + return constants.DefaultFirewallRegistry + "/apple-init:" + imageTag default: return "" } diff --git a/pkg/workflow/sandbox_runtime_profile.go b/pkg/workflow/sandbox_runtime_profile.go index e30c370552e..3d32e60deac 100644 --- a/pkg/workflow/sandbox_runtime_profile.go +++ b/pkg/workflow/sandbox_runtime_profile.go @@ -81,6 +81,28 @@ var sandboxRuntimeProfiles = map[AgentRuntime]sandboxRuntimeProfile{ // legacy-security or host-access flags are implied by the sudo prefix. AWFCommand: constants.AWFCloudHypervisorCommand, }, + AgentRuntimeAppleContainer: { + Runtime: AgentRuntimeAppleContainer, + // AWF requires strict --network-isolation for apple-container and refuses to + // start without it: the guest has no NIC and reaches Squid, the API proxy, + // the CLI proxy, and the MCP gateway only through published capability + // sockets. + NetworkIsolation: true, + // Apple Virtualization.framework is driven through the unprivileged + // `container` CLI, so AWF itself runs as the runner user. Docker remains + // required for the Compose-based infrastructure containers; "rootless" here + // describes the AWF invocation, not the absence of Docker. + Rootless: true, + AWFCommand: constants.AWFDefaultCommand.String(), + // The Apple Container CLI and the guest init image are provisioned by the + // self-hosted runner image, not by compiler-generated steps. Layer 2 of this + // stack adds the generated setup; until then runtime-install has nothing to + // generate and is rejected rather than silently ignored. + SupportsRuntimeInstall: false, + // The guest has no NIC, so no host port or GitHub Actions services: mapping + // can ever reach it. + SupportsHostAccess: false, + }, } // supportedAgentRuntimes lists the runtime values accepted in frontmatter, in @@ -91,6 +113,7 @@ var supportedAgentRuntimes = []AgentRuntime{ AgentRuntimeGVisor, AgentRuntimeDockerSbx, AgentRuntimeCloudHypervisor, + AgentRuntimeAppleContainer, } // supportedAgentRuntimeNames returns the supported runtime values as strings. diff --git a/pkg/workflow/sandbox_validation.go b/pkg/workflow/sandbox_validation.go index 2703d4119c0..89c9d21a338 100644 --- a/pkg/workflow/sandbox_validation.go +++ b/pkg/workflow/sandbox_validation.go @@ -261,6 +261,11 @@ func validateSandboxConfig(workflowData *WorkflowData) error { sandboxValidationLog.Print("cloud-hypervisor runtime configured -- topology, AWF version, and feature compatibility checks passed") } + // Validate apple-container runtime compatibility (host, AWF version, features). + if err := validateAppleContainerCompatibility(workflowData, agentConfig); err != nil { + return err + } + // Validate config structure if provided (deprecated - was only for SRT) if sandboxConfig.Config != nil { // Config is no longer used - SRT removed diff --git a/pkg/workflow/schemas/awf-config.schema.json b/pkg/workflow/schemas/awf-config.schema.json index 95a4aa71b2a..6b873476cc5 100644 --- a/pkg/workflow/schemas/awf-config.schema.json +++ b/pkg/workflow/schemas/awf-config.schema.json @@ -601,7 +601,8 @@ "enclaveScript": { "$ref": "#/$defs/pinnedImageReference" }, "enclaveAgent": { "$ref": "#/$defs/pinnedImageReference" }, "enclaveMcpServer": { "$ref": "#/$defs/pinnedImageReference" }, - "dindStaging": { "$ref": "#/$defs/pinnedImageReference" } + "dindStaging": { "$ref": "#/$defs/pinnedImageReference" }, + "appleInit": { "$ref": "#/$defs/pinnedImageReference" } } }, "tty": { @@ -630,8 +631,40 @@ }, "containerRuntime": { "type": "string", - "enum": ["gvisor", "sbx"], - "description": "Container runtime for the agent container. \"gvisor\" runs the agent under gVisor's runsc runtime (OCI runtime, compose-based). \"sbx\" runs the agent inside a Docker sbx microVM with hypervisor isolation; infrastructure containers (squid-proxy, api-proxy) stay in Docker Compose on the host and the sbx proxy chains upstream through AWF's Squid for domain filtering. Only the agent uses the custom runtime; infrastructure containers always use the default runc runtime." + "enum": ["gvisor", "sbx", "apple-container"], + "description": "Container runtime for the agent container. \"gvisor\" runs the agent under gVisor's runsc runtime (OCI runtime, compose-based). \"sbx\" runs the agent inside a Docker sbx microVM with hypervisor isolation; infrastructure containers (squid-proxy, api-proxy) stay in Docker Compose on the host and the sbx proxy chains upstream through AWF's Squid for domain filtering. \"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) and additionally requires appleContainer.previewEnabled. Only the agent uses the custom runtime; infrastructure containers always use the default runc runtime." + } + } + }, + "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/pinnedImageReference", + "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." } } },