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." } } },