diff --git a/actions/setup/sh/cloud_hypervisor_host_preflight.sh b/actions/setup/sh/cloud_hypervisor_host_preflight.sh new file mode 100755 index 00000000000..f4c43a096a8 --- /dev/null +++ b/actions/setup/sh/cloud_hypervisor_host_preflight.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set +o histexpand + +# cloud_hypervisor_host_preflight.sh - Validate runner eligibility for AWF's +# preview cloud-hypervisor runtime. +# +# Supported scope is intentionally narrow: +# - GitHub-hosted runners only +# - Ubuntu Linux x86_64 only +# - /dev/kvm must be present + +set -euo pipefail + +echo "::group::cloud-hypervisor host preflight" + +if [[ "${RUNNER_ENVIRONMENT:-}" != "github-hosted" ]]; then + echo "::error::cloud-hypervisor preview is supported only on GitHub-hosted runners." + exit 1 +fi + +if [[ "${RUNNER_OS:-}" != "Linux" ]]; then + echo "::error::cloud-hypervisor preview requires Linux runners." + exit 1 +fi + +if [[ "${RUNNER_ARCH:-}" != "X64" ]]; then + echo "::error::cloud-hypervisor preview requires x86_64 (RUNNER_ARCH=X64) runners." + exit 1 +fi + +if [[ "${ImageOS:-}" != ubuntu* ]]; then + echo "::error::cloud-hypervisor preview requires GitHub-hosted Ubuntu images (ImageOS starts with 'ubuntu')." + exit 1 +fi + +if ! test -e /dev/kvm; then + echo "::error::/dev/kvm is missing. cloud-hypervisor preview requires KVM-capable GitHub-hosted Ubuntu x86_64 runners." + exit 1 +fi + +echo "runner is eligible for cloud-hypervisor preview" +echo "::endgroup::" diff --git a/actions/setup/sh/cloud_hypervisor_setup_bundle.sh b/actions/setup/sh/cloud_hypervisor_setup_bundle.sh new file mode 100755 index 00000000000..078359c2167 --- /dev/null +++ b/actions/setup/sh/cloud_hypervisor_setup_bundle.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +set +o histexpand + +# cloud_hypervisor_setup_bundle.sh - Download, verify, and unpack AWF's +# cloud-hypervisor guest bundle for the requested AWF version. +# +# Outputs (GITHUB_OUTPUT): +# binary_path, kernel_path, rootfs_path, supervisor_path +# binary_sha256, kernel_sha256, rootfs_sha256, supervisor_sha256 + +set -euo pipefail + +if [[ -z "${GH_AW_AWF_VERSION:-}" ]]; then + echo "::error::GH_AW_AWF_VERSION is required" + exit 1 +fi + +version="${GH_AW_AWF_VERSION}" +if [[ "${version}" != v* ]]; then + version="v${version}" +fi + +asset_base_url="https://github.com/github/gh-aw-firewall/releases/download/${version}" +asset_name="cloud-hypervisor-test-x86_64.tar.gz" +checksums_name="cloud-hypervisor-test-x86_64.SHA256SUMS" +manifest_name="cloud-hypervisor-test-x86_64.manifest.json" + +bundle_root="${RUNNER_TEMP}/gh-aw/cloud-hypervisor/${version}" +extract_dir="${bundle_root}/bundle" +mkdir -p "${bundle_root}" "${extract_dir}" + +echo "::group::Download cloud-hypervisor bundle (${version})" +curl -fsSL -o "${bundle_root}/${asset_name}" "${asset_base_url}/${asset_name}" +curl -fsSL -o "${bundle_root}/${checksums_name}" "${asset_base_url}/${checksums_name}" +curl -fsSL -o "${bundle_root}/${manifest_name}" "${asset_base_url}/${manifest_name}" +echo "downloaded release assets" +echo "::endgroup::" + +echo "::group::Extract cloud-hypervisor bundle" +tar -xzf "${bundle_root}/${asset_name}" -C "${extract_dir}" +echo "bundle extracted to ${extract_dir}" +echo "::endgroup::" + +sha_file="${bundle_root}/${checksums_name}" + +resolve_path() { + local rel="$1" + if [[ -z "${rel}" ]]; then + return 1 + fi + + local cleaned="${rel#./}" + local candidate + for candidate in \ + "${extract_dir}/${cleaned}" \ + "${bundle_root}/${cleaned}"; do + if [[ -f "${candidate}" ]]; then + realpath "${candidate}" + return 0 + fi + done + + local found + found="$(find "${extract_dir}" -type f -name "$(basename "${cleaned}")" | head -n1 || true)" + if [[ -n "${found}" ]]; then + realpath "${found}" + return 0 + fi + + return 1 +} + +lookup_sha256() { + local rel="$1" + local full="$2" + local candidate + for candidate in "${rel#./}" "$(basename "${rel#./}")" "${full#${bundle_root}/}" "${full#${extract_dir}/}"; do + local sum + sum="$(awk -v target="${candidate}" '{sub(/^\.\//, "", $2); if ($2==target) {print $1; exit}}' "${sha_file}")" + if [[ -n "${sum}" ]]; then + echo "${sum}" + return 0 + fi + done + return 1 +} + +verify_sha256() { + local expected="$1" + local file="$2" + local actual + actual="$(sha256sum "${file}" | awk '{print $1}')" + if [[ "${actual}" != "${expected}" ]]; then + echo "::error::checksum verification failed for ${file}" + exit 1 + fi +} + +# Artifact names are fixed by the gh-aw-firewall cloud-hypervisor release contract. +binary_rel="cloud-hypervisor" +kernel_rel="vmlinux.bin" +rootfs_rel="rootfs.ext4" +supervisor_rel="awf-supervisor" + +binary_path="$(resolve_path "${binary_rel}" || true)" +kernel_path="$(resolve_path "${kernel_rel}" || true)" +rootfs_path="$(resolve_path "${rootfs_rel}" || true)" +supervisor_path="$(resolve_path "${supervisor_rel}" || true)" + +if [[ -z "${binary_path}" || -z "${kernel_path}" || -z "${rootfs_path}" || -z "${supervisor_path}" ]]; then + echo "::error::failed to resolve one or more cloud-hypervisor artifact files after extraction" + exit 1 +fi + +binary_sha256="$(lookup_sha256 "${binary_rel}" "${binary_path}" || true)" +kernel_sha256="$(lookup_sha256 "${kernel_rel}" "${kernel_path}" || true)" +rootfs_sha256="$(lookup_sha256 "${rootfs_rel}" "${rootfs_path}" || true)" +supervisor_sha256="$(lookup_sha256 "${supervisor_rel}" "${supervisor_path}" || true)" + +if [[ -z "${binary_sha256}" || -z "${kernel_sha256}" || -z "${rootfs_sha256}" || -z "${supervisor_sha256}" ]]; then + echo "::error::failed to resolve one or more cloud-hypervisor SHA256 digests from ${checksums_name}" + exit 1 +fi + +echo "::group::Verify cloud-hypervisor bundle checksums" +verify_sha256 "${binary_sha256}" "${binary_path}" +verify_sha256 "${kernel_sha256}" "${kernel_path}" +verify_sha256 "${rootfs_sha256}" "${rootfs_path}" +verify_sha256 "${supervisor_sha256}" "${supervisor_path}" +echo "bundle checksums verified" +echo "::endgroup::" + +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + { + echo "binary_path=${binary_path}" + echo "kernel_path=${kernel_path}" + echo "rootfs_path=${rootfs_path}" + echo "supervisor_path=${supervisor_path}" + echo "binary_sha256=${binary_sha256}" + echo "kernel_sha256=${kernel_sha256}" + echo "rootfs_sha256=${rootfs_sha256}" + echo "supervisor_sha256=${supervisor_sha256}" + } >> "${GITHUB_OUTPUT}" +fi +if [[ -n "${GITHUB_ENV:-}" ]]; then + { + echo "GH_AW_CLOUD_HYPERVISOR_BINARY=${binary_path}" + echo "GH_AW_CLOUD_HYPERVISOR_KERNEL=${kernel_path}" + echo "GH_AW_CLOUD_HYPERVISOR_ROOTFS=${rootfs_path}" + echo "GH_AW_CLOUD_HYPERVISOR_SUPERVISOR=${supervisor_path}" + echo "GH_AW_CLOUD_HYPERVISOR_BINARY_SHA256=${binary_sha256}" + echo "GH_AW_CLOUD_HYPERVISOR_KERNEL_SHA256=${kernel_sha256}" + echo "GH_AW_CLOUD_HYPERVISOR_ROOTFS_SHA256=${rootfs_sha256}" + echo "GH_AW_CLOUD_HYPERVISOR_SUPERVISOR_SHA256=${supervisor_sha256}" + } >> "${GITHUB_ENV}" +fi + +echo "cloud-hypervisor bundle prepared" diff --git a/docs/public/editor/autocomplete-data.json b/docs/public/editor/autocomplete-data.json index 7ab59c9529f..e55bbdd8fda 100644 --- a/docs/public/editor/autocomplete-data.json +++ b/docs/public/editor/autocomplete-data.json @@ -800,8 +800,8 @@ }, "runtime": { "type": "string", - "desc": "Container runtime for the agent container.", - "enum": ["gvisor", "docker-sbx"], + "desc": "Container runtime for the agent container. cloud-hypervisor is preview-only and limited to GitHub-hosted Ubuntu x86_64 runners with /dev/kvm.", + "enum": ["gvisor", "docker-sbx", "cloud-hypervisor"], "leaf": true }, "config": { diff --git a/docs/src/content/docs/introduction/architecture.mdx b/docs/src/content/docs/introduction/architecture.mdx index 8e745e6a9ce..33614d94e82 100644 --- a/docs/src/content/docs/introduction/architecture.mdx +++ b/docs/src/content/docs/introduction/architecture.mdx @@ -189,6 +189,7 @@ Runner topology determines where the Docker daemon and workspace live. Agent run | ARC or another split-daemon DinD runner | Docker (default) | `runner.topology: arc-dind` stages the sysroot and workspace for the sidecar daemon. The privileged DinD sidecar creates the isolated network; the runner container remains unprivileged and does not need `NET_ADMIN`. | | Compatible Linux runner | gVisor (`sandbox.agent.runtime: gvisor`) | The agent container runs under `runsc`, which interposes a user-space kernel between the agent and the host kernel. | | KVM-capable Linux runner | Docker sbx (`sandbox.agent.runtime: docker-sbx`) | The agent runs inside a hardware-virtualized microVM while the firewall, API proxy, MCP Gateway, and MCP servers remain in host-side containers. | +| GitHub-hosted Ubuntu x86_64 KVM runner | Cloud Hypervisor (preview) (`sandbox.agent.runtime: cloud-hypervisor`) | The agent runs inside AWF's preview Cloud Hypervisor microVM runtime with release-asset checksum verification and digest-pinned runtime flags. | > [!IMPORTANT] > gVisor and Docker sbx are incompatible with `runner.topology: arc-dind`. Installing gVisor invokes `sudo` to register `runsc`, but the agent remains in the default rootless AWF mode with `sandbox.agent.sudo: false`. Docker sbx requires `sandbox.agent.sudo: true`, KVM access, and the `DOCKER_USERNAME` and `DOCKER_PAT` secrets. The compiler rejects incompatible runtime, topology, sudo, and AWF-version combinations; generated Docker sbx workflows fail fast at run time when KVM access or required secrets are unavailable. diff --git a/docs/src/content/docs/reference/agent-runtimes.md b/docs/src/content/docs/reference/agent-runtimes.md index c087d40ce22..07d8e11f73b 100644 --- a/docs/src/content/docs/reference/agent-runtimes.md +++ b/docs/src/content/docs/reference/agent-runtimes.md @@ -1,11 +1,11 @@ --- title: Agent Runtime Selection -description: Choose and configure Docker, gVisor, Docker sbx, or ARC DinD for an agentic workflow, with runner requirements and troubleshooting guidance. +description: Choose and configure Docker, gVisor, Docker sbx, Cloud Hypervisor, or ARC DinD for an agentic workflow, with runner requirements and troubleshooting guidance. sidebar: order: 1340 --- -Agentic workflows use AWF (Agent Workflow Firewall) to run the agent in an isolated environment. The environment can use the runner's standard Docker runtime, gVisor, or Docker sbx. ARC DinD is a runner topology that changes how the standard Docker environment is reached; it is not another value of `sandbox.agent.runtime`. +Agentic workflows use AWF (Agent Workflow Firewall) to run the agent in an isolated environment. The environment can use the runner's standard Docker runtime, gVisor, Docker sbx, or preview Cloud Hypervisor mode. ARC DinD is a runner topology that changes how the standard Docker environment is reached; it is not another value of `sandbox.agent.runtime`. Use this page when selecting a runtime, writing workflow frontmatter, provisioning a runner, or diagnosing a runtime setup failure. @@ -15,7 +15,7 @@ These similarly named fields control different layers: | Field | Purpose | Values covered here | | --- | --- | --- | -| `sandbox.agent.runtime` | Selects the isolation backend for the main agent | `gvisor`, `docker-sbx`, or omitted for Docker | +| `sandbox.agent.runtime` | Selects the isolation backend for the main agent | `gvisor`, `docker-sbx`, `cloud-hypervisor`, or omitted for Docker | | `sandbox.agent.runtime-install` | Controls whether gh-aw installs and prepares gVisor or Docker sbx | `true` by default; `false` for a pre-provisioned runner | | `runner.topology` | Describes how the runner reaches Docker | `arc-dind`, or omitted for a local Docker daemon | | `tools.github.bounded-queries.runtime` | Selects the backend for bounded-query scripts only | `docker`, `gvisor`, `sbx` | @@ -31,14 +31,16 @@ These similarly named fields control different layers: | Docker | Linux namespaces, cgroups, and the host kernel | Linux and a usable Docker daemon | Fastest and most compatible, but the agent shares the host kernel | | gVisor | A `runsc` user-space kernel between the agent and host kernel | Local Docker daemon, `sudo`, systemd, and access to gVisor downloads | Stronger kernel isolation with syscall compatibility and performance overhead | | Docker sbx | A KVM-backed microVM for the agent | KVM, nested virtualization, `sudo`, apt, Docker Hub credentials, and local Docker | Strongest boundary here, but has the most setup cost and platform constraints | +| Cloud Hypervisor (preview) | A KVM-backed microVM for the agent | GitHub-hosted Ubuntu x86_64 runner with `/dev/kvm` and AWF release asset download access | Preview-only path with strict host requirements and release-asset provisioning | | ARC DinD | Standard Docker agent container in a DinD sidecar | ARC or equivalent Kubernetes runner with a privileged DinD sidecar and shared work volume | Supports Kubernetes runner fleets, but adds split-filesystem and daemon-connectivity complexity | Apply this selection order: 1. Use **ARC DinD** when the runner is an ARC pod or another Kubernetes runner whose Docker daemon is a DinD sidecar. Do not combine it with gVisor or Docker sbx. 2. Otherwise, use **Docker sbx** when the user requires a hardware-virtualized boundary and the runner exposes working KVM. -3. Otherwise, use **gVisor** when untrusted agent code warrants a smaller host-kernel attack surface and the workload is compatible with `runsc`. -4. Use the default **Docker** runtime when compatibility, startup time, or runner portability is more important than an additional kernel or VM boundary. +3. Use **Cloud Hypervisor (preview)** only when the runtime must be Cloud Hypervisor and the runner is GitHub-hosted Ubuntu x86_64 with `/dev/kvm`. +4. Otherwise, use **gVisor** when untrusted agent code warrants a smaller host-kernel attack surface and the workload is compatible with `runsc`. +5. Use the default **Docker** runtime when compatibility, startup time, or runner portability is more important than an additional kernel or VM boundary. If the user's requirement is unclear, prefer Docker. Do not select a stronger runtime until the runner prerequisites are known to be available. @@ -266,6 +268,34 @@ It has the highest cold-start cost, consumes more memory and disk, requires Dock **The CLI is missing inside the microVM:** Upgrade gh-aw and recompile. Docker sbx requires engine CLIs to be staged under `${RUNNER_TEMP}/gh-aw/engine-cli`, which is visible to the microVM. +## Cloud Hypervisor (preview) + +Cloud Hypervisor runs the agent in AWF's preview microVM runtime: + +```aw wrap +--- +on: issues +sandbox: + agent: + id: awf + runtime: cloud-hypervisor +--- + +Investigate this issue. +``` + +Preview scope is intentionally narrow: + +- GitHub-hosted runners only (`RUNNER_ENVIRONMENT=github-hosted`). +- Ubuntu Linux x86_64 only (`RUNNER_OS=Linux`, `RUNNER_ARCH=X64`, `ImageOS=ubuntu*`). +- `/dev/kvm` must be present. +- `runner.topology: arc-dind` is not supported. + +The compiler emits host preflight and release-asset provisioning steps before AWF runs. Provisioning downloads `cloud-hypervisor-test-x86_64.tar.gz`, `SHA256SUMS`, and `manifest.json` from the pinned `gh-aw-firewall` release, verifies checksums, and feeds AWF digest-pinned flags for the Cloud Hypervisor binary, kernel, rootfs, and supervisor. + +> [!IMPORTANT] +> This runtime is preview-only. Keep expectations aligned with AWF preview support and prefer Docker sbx or gVisor when Cloud Hypervisor host constraints are not guaranteed. + ## ARC with Docker-in-Docker ARC DinD describes a split-daemon runner: the GitHub Actions runner is one container and Docker runs in a privileged sidecar. The agent still uses standard Docker, so omit `sandbox.agent.runtime`. diff --git a/docs/src/content/docs/reference/frontmatter-full.md b/docs/src/content/docs/reference/frontmatter-full.md index e913ab1c48c..d6e88caa9d5 100644 --- a/docs/src/content/docs/reference/frontmatter-full.md +++ b/docs/src/content/docs/reference/frontmatter-full.md @@ -2131,7 +2131,9 @@ sandbox: # gVisor's runsc runtime for additional kernel-level isolation. Use 'docker-sbx' # to run the agent inside a Docker sbx microVM with KVM hypervisor-level isolation # — requires sandbox.agent.sudo: true, DOCKER_PAT and DOCKER_USERNAME secrets, and - # a KVM-capable runner. Incompatible with runner.topology: arc-dind. + # a KVM-capable runner. Use 'cloud-hypervisor' for AWF's preview Cloud Hypervisor + # microVM runtime (GitHub-hosted Ubuntu x86_64 + /dev/kvm only). Incompatible with + # runner.topology: arc-dind. # (optional) runtime: "gvisor" diff --git a/docs/src/content/docs/reference/glossary.md b/docs/src/content/docs/reference/glossary.md index d9d6949896f..8253c617cf5 100644 --- a/docs/src/content/docs/reference/glossary.md +++ b/docs/src/content/docs/reference/glossary.md @@ -1476,6 +1476,7 @@ A `sandbox.agent` field that selects the container runtime used to execute the A - `gvisor` — Runs the agent container under [gVisor](#gvisor-runsc) (`runsc`) for kernel-level isolation. Best for workflows processing untrusted input. - `docker-sbx` — Runs the agent inside a [docker-sbx](#docker-sbx) KVM-isolated microVM while keeping infrastructure containers on the host. +- `cloud-hypervisor` — Runs the agent inside AWF's preview Cloud Hypervisor microVM runtime (GitHub-hosted Ubuntu x86_64 with `/dev/kvm` only). When omitted, the default Docker runtime is used. See [Sandbox Configuration](/gh-aw/reference/sandbox/). @@ -1493,6 +1494,10 @@ A container runtime from Google that interposes a user-space kernel between the A KVM-hardware-virtualized microVM runtime. When `sandbox.agent.runtime: docker-sbx` is set, the AI agent runs inside a hardware-isolated microVM while infrastructure containers (MCP servers, gateway, etc.) remain on the host. Provides stronger isolation than gVisor for workloads that require full hardware-virtualization boundaries. gh-aw automatically refreshes Docker Hub OAuth credentials immediately before agent execution to prevent token expiry errors. See [Sandbox Configuration](/gh-aw/reference/sandbox/). +### cloud-hypervisor + +A preview KVM-hardware-virtualized microVM runtime in AWF. When `sandbox.agent.runtime: cloud-hypervisor` is set, gh-aw emits host eligibility checks and digest-pinned release-asset provisioning for the Cloud Hypervisor binary, kernel, rootfs, and supervisor bundle. Support is intentionally limited to GitHub-hosted Ubuntu x86_64 runners with `/dev/kvm`. See [Sandbox Configuration](/gh-aw/reference/sandbox/). + ### Strict Mode Enhanced validation mode enforcing additional security checks and best practices. Enabled via `strict: true` in frontmatter or `--strict` flag when compiling. diff --git a/pkg/cli/audit.go b/pkg/cli/audit.go index 2aa32fbad7c..184d36e59ac 100644 --- a/pkg/cli/audit.go +++ b/pkg/cli/audit.go @@ -115,7 +115,7 @@ func registerAuditCommandFlags(cmd *cobra.Command) { cmd.Flags().Bool("stdin", false, "Read workflow run IDs or URLs from stdin (one per line) instead of positional arguments") cmd.Flags().String("experiment", "", "Filter to runs that include this experiment name") cmd.Flags().String("variant", "", "Filter to runs with a specific variant value (requires --experiment)") - cmd.Flags().String("runtime", "", "Filter to runs using a specific sandbox agent runtime (e.g., gvisor, docker-sbx)") + cmd.Flags().String("runtime", "", "Filter to runs using a specific sandbox agent runtime (e.g., gvisor, docker-sbx, cloud-hypervisor)") cmd.Flags().Bool("evals", false, "Filter to runs containing evals results (evals.jsonl); automatically downloads the usage artifact (which includes evals) when --artifacts is narrowed") RegisterDirFlagCompletion(cmd, "output") } diff --git a/pkg/cli/audit_test.go b/pkg/cli/audit_test.go index 82b41a3fbf6..b2533f1457b 100644 --- a/pkg/cli/audit_test.go +++ b/pkg/cli/audit_test.go @@ -1240,6 +1240,10 @@ func TestAuditCommandInvalidRuntimeIsRejected(t *testing.T) { require.ErrorContains(t, err, "invalid runtime value", "error message should explain the invalid value") } +func TestValidateLogsRuntimeAllowsCloudHypervisor(t *testing.T) { + require.NoError(t, validateLogsRuntime(string(workflow.AgentRuntimeCloudHypervisor))) +} + // TestShouldSkipAuditRun_Runtime verifies that shouldSkipAuditRun's runtime // filter matches the shared matchRuntimeFilter contract used by the logs // orchestrator: matching runtime is not skipped, non-matching or missing diff --git a/pkg/cli/logs_command.go b/pkg/cli/logs_command.go index 727ad178708..a76600f433d 100644 --- a/pkg/cli/logs_command.go +++ b/pkg/cli/logs_command.go @@ -319,7 +319,7 @@ func validateLogsRuntime(runtime string) error { return nil } logsCommandLog.Printf("Validating runtime parameter: %s", runtime) - validRuntimes := []string{string(workflow.AgentRuntimeGVisor), string(workflow.AgentRuntimeDockerSbx)} + validRuntimes := []string{string(workflow.AgentRuntimeGVisor), string(workflow.AgentRuntimeDockerSbx), string(workflow.AgentRuntimeCloudHypervisor)} if slices.Contains(validRuntimes, runtime) { return nil } @@ -391,7 +391,7 @@ func addLogsCommandFlags(logsCmd *cobra.Command, validArtifactSets string) { logsCmd.Flags().String("end-date", "", "Filter runs created before this date (YYYY-MM-DD or delta like -1d, -1w, -1mo)") addOutputFlag(logsCmd, defaultLogsOutputDir) addEngineFilterFlag(logsCmd) - logsCmd.Flags().String("runtime", "", "Filter to runs using a specific sandbox agent runtime (e.g., gvisor, docker-sbx)") + logsCmd.Flags().String("runtime", "", "Filter to runs using a specific sandbox agent runtime (e.g., gvisor, docker-sbx, cloud-hypervisor)") logsCmd.Flags().String("ref", "", "Filter runs by branch or tag name (e.g., main, v1.0.0)") logsCmd.Flags().Int64("before-run-id", 0, "Filter runs with database ID before this value (exclusive)") logsCmd.Flags().Int64("after-run-id", 0, "Filter runs with database ID after this value (exclusive)") diff --git a/pkg/cli/logs_models.go b/pkg/cli/logs_models.go index 023f2cafcb6..5672dd5686a 100644 --- a/pkg/cli/logs_models.go +++ b/pkg/cli/logs_models.go @@ -329,7 +329,7 @@ type AwInfo struct { Staged bool `json:"staged"` AwfVersion string `json:"awf_version,omitempty"` // AWF firewall version (new name) FirewallVersion string `json:"firewall_version,omitempty"` // AWF firewall version (old name, for backward compatibility) - AgentRuntime string `json:"agent_runtime,omitempty"` // sandbox.agent.runtime value (e.g., "gvisor", "docker-sbx"); empty when unset + AgentRuntime string `json:"agent_runtime,omitempty"` // sandbox.agent.runtime value (e.g., "gvisor", "docker-sbx", "cloud-hypervisor"); empty when unset Steps AwInfoSteps `json:"steps,omitzero"` // Steps metadata CreatedAt string `json:"created_at"` Context *AwContext `json:"context,omitempty"` // aw_context data passed via workflow_dispatch inputs diff --git a/pkg/cli/logs_orchestrator_filters.go b/pkg/cli/logs_orchestrator_filters.go index 27c38a63a87..bd6b4086234 100644 --- a/pkg/cli/logs_orchestrator_filters.go +++ b/pkg/cli/logs_orchestrator_filters.go @@ -40,7 +40,7 @@ func matchEngineFilter(awInfo *AwInfo, awInfoErr error, filterEngine string) (bo } // matchRuntimeFilter checks whether the run recorded in awInfo matches the -// requested sandbox agent runtime filter string (e.g., "gvisor", "docker-sbx"). +// requested sandbox agent runtime filter string (e.g., "gvisor", "docker-sbx", "cloud-hypervisor"). // It returns (matches, detectedRuntime). detectedRuntime is "" when awInfo is // unavailable or carries no agent_runtime. func matchRuntimeFilter(awInfo *AwInfo, awInfoErr error, filterRuntime string) (bool, string) { diff --git a/pkg/constants/version_constants.go b/pkg/constants/version_constants.go index b4290a9d382..c22861d1985 100644 --- a/pkg/constants/version_constants.go +++ b/pkg/constants/version_constants.go @@ -105,6 +105,10 @@ const AWFArcDindMinVersion Version = "v0.27.20" // containerRuntime field in the container config (gh-aw-firewall#6093). const AWFContainerRuntimeMinVersion Version = "v0.27.30" +// AWFCloudHypervisorMinVersion is the minimum AWF version that supports the +// cloud-hypervisor preview runtime and its release assets. +const AWFCloudHypervisorMinVersion Version = "v0.28.0" + // 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 40cfdfc8fee..894d7d8cdda 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -3586,9 +3586,9 @@ }, "runtime": { "type": "string", - "description": "Container runtime for the agent container. Use 'gvisor' to run the agent under gVisor's runsc runtime for additional kernel-level isolation. Use 'docker-sbx' to run the agent inside a Docker sbx microVM with KVM hypervisor-level isolation \u2014 when runtime-install is left enabled, it requires sandbox.agent.sudo: true, DOCKER_PAT and DOCKER_USERNAME secrets, and a KVM-capable runner. Incompatible with runner.topology: arc-dind.", - "enum": ["gvisor", "docker-sbx"], - "examples": ["gvisor", "docker-sbx"] + "description": "Container runtime for the agent container. Use 'gvisor' to run the agent under gVisor's runsc runtime for additional kernel-level isolation. Use 'docker-sbx' to run the agent inside a Docker sbx microVM with KVM hypervisor-level isolation \u2014 when runtime-install is left enabled, it requires sandbox.agent.sudo: true, DOCKER_PAT and DOCKER_USERNAME secrets, and a KVM-capable runner. Use 'cloud-hypervisor' to run the agent in AWF's preview Cloud Hypervisor microVM runtime (GitHub-hosted Ubuntu x86_64 + /dev/kvm only). Incompatible with runner.topology: arc-dind.", + "enum": ["gvisor", "docker-sbx", "cloud-hypervisor"], + "examples": ["gvisor", "docker-sbx", "cloud-hypervisor"] }, "runtime-install": { "type": "boolean", diff --git a/pkg/workflow/awf_command_builder.go b/pkg/workflow/awf_command_builder.go index 37269964993..e7efa007c5e 100644 --- a/pkg/workflow/awf_command_builder.go +++ b/pkg/workflow/awf_command_builder.go @@ -36,6 +36,7 @@ func BuildAWFCommand(config AWFCommandConfig) string { // expansion is not suppressed by single-quoting. awfArgs := BuildAWFArgs(config) firewallConfig := getFirewallConfig(config.WorkflowData) + isCloudHypervisor := isCloudHypervisorRuntime(config.WorkflowData) // Auto-detect ARC/DinD split daemon topology at runtime: probe DOCKER_HOST for a // tcp:// scheme and pass it through to AWF via --docker-host. @@ -85,15 +86,22 @@ fi`, awfToolCacheMountVarName, ) toolCacheMountRef := fmt.Sprintf("${%s:+--mount \"$%s\"}", awfToolCacheMountVarName, awfToolCacheMountVarName) + if isCloudHypervisor { + toolCacheMountProbe = "" + toolCacheMountRef = "" + } // Build the expandable args string for args that need shell variable expansion. // These MUST be appended as raw (unescaped) strings because single-quoting would // prevent the runner's shell from expanding ${GITHUB_WORKSPACE} and ${RUNNER_TEMP}. ghAwDir := constants.GhAwRootDirShell - expandableArgs := fmt.Sprintf( - `--container-workdir "${GITHUB_WORKSPACE}" --mount "%s:%s:ro" --mount "%s:/host%s:ro"`, - ghAwDir, ghAwDir, ghAwDir, ghAwDir, - ) + expandableArgs := `--container-workdir "${GITHUB_WORKSPACE}"` + if !isCloudHypervisor { + expandableArgs += fmt.Sprintf( + ` --mount "%s:%s:ro" --mount "%s:/host%s:ro"`, + ghAwDir, ghAwDir, ghAwDir, ghAwDir, + ) + } if isArcDind { expandableArgs += fmt.Sprintf( ` --mount "%s:%s:rw" --mount "%s:%s:rw"`, @@ -231,7 +239,7 @@ fi`, // so the model can copy files there from inside the container. The parent ${RUNNER_TEMP}/gh-aw // is mounted :ro above; this child mount overrides access for the staging subdirectory only. // The staging directory must already exist on the host (created in Generate Safe Outputs Config step). - if config.WorkflowData != nil && config.WorkflowData.SafeOutputs != nil && config.WorkflowData.SafeOutputs.UploadArtifact != nil { + if !isCloudHypervisor && config.WorkflowData != nil && config.WorkflowData.SafeOutputs != nil && config.WorkflowData.SafeOutputs.UploadArtifact != nil { stagingDir := SafeOutputsUploadArtifactsDir expandableArgs += fmt.Sprintf(` --mount "%s:%s:rw"`, stagingDir, stagingDir) awfHelpersLog.Print("Added read-write mount for upload_artifact staging directory") @@ -251,6 +259,16 @@ fi`, } else if config.WorkflowData != nil && config.WorkflowData.ServicePortExpressions != "" { awfHelpersLog.Print("Skipping --allow-host-service-ports: requires legacy-security mode") } + if isCloudHypervisorRuntime(config.WorkflowData) { + expandableArgs += ` --cloud-hypervisor-binary "${GH_AW_CLOUD_HYPERVISOR_BINARY}"` + + ` --cloud-hypervisor-kernel "${GH_AW_CLOUD_HYPERVISOR_KERNEL}"` + + ` --cloud-hypervisor-rootfs "${GH_AW_CLOUD_HYPERVISOR_ROOTFS}"` + + ` --cloud-hypervisor-supervisor "${GH_AW_CLOUD_HYPERVISOR_SUPERVISOR}"` + + ` --cloud-hypervisor-binary-sha256 "${GH_AW_CLOUD_HYPERVISOR_BINARY_SHA256}"` + + ` --cloud-hypervisor-kernel-sha256 "${GH_AW_CLOUD_HYPERVISOR_KERNEL_SHA256}"` + + ` --cloud-hypervisor-rootfs-sha256 "${GH_AW_CLOUD_HYPERVISOR_ROOTFS_SHA256}"` + + ` --cloud-hypervisor-supervisor-sha256 "${GH_AW_CLOUD_HYPERVISOR_SUPERVISOR_SHA256}"` + } engineCommand := config.EngineCommand if isArcDind { @@ -434,7 +452,7 @@ func BuildAWFArgs(config AWFCommandConfig) []string { // Add TTY flag if needed (Claude requires this), except for docker-sbx where // sbx exec --tty can terminate long-running Claude sessions prematurely. - if config.UsesTTY && !isDockerSbxRuntime(config.WorkflowData) { + if config.UsesTTY && !isDockerSbxRuntime(config.WorkflowData) && !isCloudHypervisorRuntime(config.WorkflowData) { awfArgs = append(awfArgs, "--tty") } @@ -447,6 +465,16 @@ func BuildAWFArgs(config AWFCommandConfig) []string { } else if isDockerSbxRuntime(config.WorkflowData) { awfHelpersLog.Printf("Skipping --container-runtime sbx: AWF version %q is older than required minimum %s", getAWFImageTag(firewallConfig), constants.AWFContainerRuntimeMinVersion) } + if isCloudHypervisorRuntime(config.WorkflowData) && awfSupportsCloudHypervisor(firewallConfig) { + awfArgs = append( + awfArgs, + "--container-runtime", "cloud-hypervisor", + "--cloud-hypervisor-preview", + ) + awfHelpersLog.Print("Added cloud-hypervisor runtime arguments") + } else if isCloudHypervisorRuntime(config.WorkflowData) { + awfHelpersLog.Printf("Skipping cloud-hypervisor runtime flags: AWF version %q is older than required minimum %s", getAWFImageTag(firewallConfig), constants.AWFCloudHypervisorMinVersion) + } // Pass all environment variables to the container, but exclude every variable whose // step-env value comes from a GitHub Actions secret. AWF's API proxy (--enable-api-proxy) @@ -482,10 +510,12 @@ func BuildAWFArgs(config AWFCommandConfig) []string { // read-write access is guaranteed for every sandbox runtime (chroot, gVisor, // docker-sbx), not just topologies where /tmp/gh-aw happens to be writable by // default via the host filesystem. - awfArgs = append(awfArgs, "--mount", constants.DefaultTmpGhAwMount) + if !isCloudHypervisorRuntime(config.WorkflowData) { + awfArgs = append(awfArgs, "--mount", constants.DefaultTmpGhAwMount) + } // Add custom mounts from agent config if specified - if agentConfig != nil && len(agentConfig.Mounts) > 0 { + if !isCloudHypervisorRuntime(config.WorkflowData) && agentConfig != nil && len(agentConfig.Mounts) > 0 { // Sort mounts for consistent output sortedMounts := make([]string, len(agentConfig.Mounts)) copy(sortedMounts, agentConfig.Mounts) diff --git a/pkg/workflow/awf_config.go b/pkg/workflow/awf_config.go index 3639d22b694..aff1973bc70 100644 --- a/pkg/workflow/awf_config.go +++ b/pkg/workflow/awf_config.go @@ -508,11 +508,14 @@ func BuildAWFConfigJSON(config AWFCommandConfig) (string, error) { awfConfig.Network = &AWFNetworkConfig{} } awfConfig.Network.Isolation = true - awfConfig.Network.TopologyAttach = buildAWFTopologyAttachList(config.WorkflowData) + if !isCloudHypervisorRuntime(config.WorkflowData) { + awfConfig.Network.TopologyAttach = buildAWFTopologyAttachList(config.WorkflowData) + } awfConfigLog.Printf("Network section: isolation enabled with %d topology attachments", len(awfConfig.Network.TopologyAttach)) } - // docker-sbx: the sbx microVM resolves host services via host.docker.internal + // Docker sbx microVMs resolve host services via + // host.docker.internal // (the Docker bridge gateway, 172.17.0.1). Allow this domain so AWF's network // policy permits connections from the microVM to the api-proxy, MCP gateway, and // Squid proxy that are all published on the host bridge. @@ -523,7 +526,7 @@ func BuildAWFConfigJSON(config AWFCommandConfig) (string, error) { const hostDockerInternal = "host.docker.internal" if !slices.Contains(awfConfig.Network.AllowDomains, hostDockerInternal) { awfConfig.Network.AllowDomains = append(awfConfig.Network.AllowDomains, hostDockerInternal) - awfConfigLog.Printf("Network section: added %s for docker-sbx microVM routing", hostDockerInternal) + awfConfigLog.Printf("Network section: added %s for microVM runtime routing", hostDockerInternal) } } @@ -691,7 +694,7 @@ func BuildAWFConfigJSON(config AWFCommandConfig) (string, error) { awfImageTag := buildAWFImageTagWithDigests(getAWFImageTag(firewallConfig), config.WorkflowData) agentRuntime := getAgentContainerRuntime(config.WorkflowData) agentTimeout := 0 - if isDockerSbxRuntime(config.WorkflowData) { + if isDockerSbxRuntime(config.WorkflowData) || isCloudHypervisorRuntime(config.WorkflowData) { agentTimeout = resolveAWFContainerAgentTimeoutMinutes(config.WorkflowData) } // containerRuntime is only emitted when the effective AWF version supports it. diff --git a/pkg/workflow/awf_feature_flags.go b/pkg/workflow/awf_feature_flags.go index 4f4a4ccd175..44a535122e3 100644 --- a/pkg/workflow/awf_feature_flags.go +++ b/pkg/workflow/awf_feature_flags.go @@ -59,6 +59,12 @@ func awfSupportsContainerRuntime(firewallConfig *FirewallConfig) bool { return awfVersionAtLeast(firewallConfig, constants.AWFContainerRuntimeMinVersion) } +// awfSupportsCloudHypervisor returns true when the effective AWF version supports +// the cloud-hypervisor preview runtime and its required CLI flags. +func awfSupportsCloudHypervisor(firewallConfig *FirewallConfig) bool { + return awfVersionAtLeast(firewallConfig, constants.AWFCloudHypervisorMinVersion) +} + // 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/claude_engine.go b/pkg/workflow/claude_engine.go index ffda9fbca9b..84ab60bbcf7 100644 --- a/pkg/workflow/claude_engine.go +++ b/pkg/workflow/claude_engine.go @@ -132,7 +132,7 @@ func (e *ClaudeEngine) GetInstallationSteps(workflowData *WorkflowData) []GitHub CooldownEnabled: false, }, ) - if isDockerSbxRuntime(workflowData) { + if isDockerSbxRuntime(workflowData) || isCloudHypervisorRuntime(workflowData) { npmSteps = append(npmSteps, GenerateDockerSbxNpmCLIInstallStep( "@anthropic-ai/claude-code", version, diff --git a/pkg/workflow/cloud_hypervisor_install.go b/pkg/workflow/cloud_hypervisor_install.go new file mode 100644 index 00000000000..d425a99ae4f --- /dev/null +++ b/pkg/workflow/cloud_hypervisor_install.go @@ -0,0 +1,27 @@ +// This file generates GitHub Actions steps required to prepare AWF's preview +// cloud-hypervisor microVM runtime for sandbox.agent.runtime: cloud-hypervisor. + +package workflow + +import "github.com/github/gh-aw/pkg/logger" + +var cloudHypervisorInstallLog = logger.New("workflow:cloud_hypervisor_install") + +func generateCloudHypervisorHostPreflightStep() GitHubActionStep { + cloudHypervisorInstallLog.Print("Generating cloud-hypervisor host eligibility preflight step") + return GitHubActionStep([]string{ + " - name: Check host eligibility for cloud-hypervisor", + ` run: bash "${RUNNER_TEMP}/gh-aw/actions/cloud_hypervisor_host_preflight.sh"`, + }) +} + +func generateCloudHypervisorBundleSetupStep(awfVersion string) GitHubActionStep { + cloudHypervisorInstallLog.Printf("Generating cloud-hypervisor bundle setup step for AWF version %q", awfVersion) + return GitHubActionStep([]string{ + " - name: Download and verify cloud-hypervisor bundle", + " id: cloud-hypervisor-bundle", + " env:", + " GH_AW_AWF_VERSION: " + awfVersion, + ` run: bash "${RUNNER_TEMP}/gh-aw/actions/cloud_hypervisor_setup_bundle.sh"`, + }) +} diff --git a/pkg/workflow/cloud_hypervisor_test.go b/pkg/workflow/cloud_hypervisor_test.go new file mode 100644 index 00000000000..7cbccb957bd --- /dev/null +++ b/pkg/workflow/cloud_hypervisor_test.go @@ -0,0 +1,241 @@ +//go:build !integration + +package workflow + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/github/gh-aw/pkg/constants" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGenerateCloudHypervisorSetupSteps(t *testing.T) { + t.Run("host preflight step", func(t *testing.T) { + step := generateCloudHypervisorHostPreflightStep() + require.NotEmpty(t, step) + content := strings.Join(step, "\n") + assert.Contains(t, content, "Check host eligibility for cloud-hypervisor") + assert.Contains(t, content, "cloud_hypervisor_host_preflight.sh") + }) + + t.Run("bundle setup step", func(t *testing.T) { + step := generateCloudHypervisorBundleSetupStep("v0.28.0") + require.NotEmpty(t, step) + content := strings.Join(step, "\n") + assert.Contains(t, content, "Download and verify cloud-hypervisor bundle") + assert.Contains(t, content, "id: cloud-hypervisor-bundle") + assert.Contains(t, content, "GH_AW_AWF_VERSION: v0.28.0") + assert.Contains(t, content, "cloud_hypervisor_setup_bundle.sh") + }) +} + +func TestCloudHypervisorInstallStepOrderInBuildNpmEngineInstallStepsWithAWF(t *testing.T) { + workflowData := &WorkflowData{ + SandboxConfig: &SandboxConfig{Agent: &AgentSandboxConfig{ID: "awf", Runtime: AgentRuntimeCloudHypervisor}}, + NetworkPermissions: &NetworkPermissions{Firewall: &FirewallConfig{Enabled: true}}, + } + + steps := BuildNpmEngineInstallStepsWithAWF(nil, workflowData) + require.NotEmpty(t, steps) + + preflightIdx := -1 + bundleIdx := -1 + awfIdx := -1 + for i, step := range steps { + content := strings.Join(step, "\n") + switch { + case strings.Contains(content, "Check host eligibility for cloud-hypervisor"): + preflightIdx = i + case strings.Contains(content, "Download and verify cloud-hypervisor bundle"): + bundleIdx = i + case strings.Contains(content, "install_awf_binary.sh"): + awfIdx = i + } + } + + require.NotEqual(t, -1, preflightIdx) + require.NotEqual(t, -1, bundleIdx) + require.NotEqual(t, -1, awfIdx) + assert.Less(t, preflightIdx, bundleIdx) + assert.Less(t, bundleIdx, awfIdx) +} + +func TestCloudHypervisorAWFArgs(t *testing.T) { + config := AWFCommandConfig{ + EngineName: "copilot", + WorkflowData: &WorkflowData{ + EngineConfig: &EngineConfig{ID: "copilot"}, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true, Version: string(constants.AWFCloudHypervisorMinVersion)}, + }, + SandboxConfig: &SandboxConfig{Agent: &AgentSandboxConfig{ID: "awf", Runtime: AgentRuntimeCloudHypervisor}}, + }, + } + + args := strings.Join(BuildAWFArgs(config), " ") + assert.Contains(t, args, "--container-runtime cloud-hypervisor") + assert.Contains(t, args, "--cloud-hypervisor-preview") + assert.NotContains(t, args, "${{ steps.cloud-hypervisor-bundle.outputs.") + assert.NotContains(t, args, "--mount") +} + +func TestCloudHypervisorAWFCommandOmitsUnsupportedMountsAndTTY(t *testing.T) { + config := AWFCommandConfig{ + EngineName: "claude", + UsesTTY: true, + WorkflowData: &WorkflowData{ + EngineConfig: &EngineConfig{ID: "claude"}, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true, Version: string(constants.AWFCloudHypervisorMinVersion)}, + }, + SandboxConfig: &SandboxConfig{Agent: &AgentSandboxConfig{ + ID: "awf", + Runtime: AgentRuntimeCloudHypervisor, + Mounts: []string{"/tmp/custom:/tmp/custom"}, + }}, + }, + } + + command := BuildAWFCommand(config) + assert.NotContains(t, command, "--mount") + assert.NotContains(t, command, "--tty") +} + +func TestCloudHypervisorAWFConfigJSON(t *testing.T) { + config := AWFCommandConfig{ + EngineName: "copilot", + AllowedDomains: "github.com", + WorkflowData: &WorkflowData{ + EngineConfig: &EngineConfig{ID: "copilot"}, + TimeoutMinutes: "timeout-minutes: 30", + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + SandboxConfig: &SandboxConfig{Agent: &AgentSandboxConfig{ID: "awf", Runtime: AgentRuntimeCloudHypervisor}}, + }, + } + + jsonStr, err := BuildAWFConfigJSON(config) + require.NoError(t, err) + assert.NotContains(t, jsonStr, `"containerRuntime"`) + assert.NotContains(t, jsonStr, "host.docker.internal") + assert.Contains(t, jsonStr, `"isolation":true`) + assert.NotContains(t, jsonStr, `"topologyAttach"`) + assert.Contains(t, jsonStr, `"agentTimeout":30`) +} + +func TestCloudHypervisorValidationArcDindIncompatible(t *testing.T) { + workflowData := &WorkflowData{ + SandboxConfig: &SandboxConfig{Agent: &AgentSandboxConfig{ID: "awf", Runtime: AgentRuntimeCloudHypervisor}}, + RunnerConfig: &RunnerConfig{Topology: RunnerTopologyArcDind}, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + Tools: map[string]any{"github": map[string]any{"mode": "remote"}}, + } + + err := validateSandboxConfig(workflowData) + require.Error(t, err) + require.ErrorContains(t, err, "arc-dind") + require.ErrorContains(t, err, "cloud-hypervisor") +} + +func TestCloudHypervisorValidationRequiresPreviewVersion(t *testing.T) { + workflowData := &WorkflowData{ + SandboxConfig: &SandboxConfig{Agent: &AgentSandboxConfig{ + ID: "awf", + Runtime: AgentRuntimeCloudHypervisor, + Version: string(constants.AWFCloudHypervisorMinVersion), + }}, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + Tools: map[string]any{"github": map[string]any{"mode": "remote"}}, + } + + require.NoError(t, validateSandboxConfig(workflowData)) + + workflowData.SandboxConfig.Agent.Version = "v0.27.44" + err := validateSandboxConfig(workflowData) + require.Error(t, err) + require.ErrorContains(t, err, string(constants.AWFCloudHypervisorMinVersion)) +} + +func TestCloudHypervisorFrontmatterExtraction(t *testing.T) { + workflowsDir := t.TempDir() + + markdown := `--- +on: + workflow_dispatch: +engine: copilot +strict: false +sandbox: + agent: + id: awf + runtime: cloud-hypervisor + version: v0.28.0 +--- + +# Test cloud-hypervisor Runtime +` + + testFile := filepath.Join(workflowsDir, "test-cloud-hypervisor.md") + err := os.WriteFile(testFile, []byte(markdown), 0o644) + require.NoError(t, err) + + compiler := NewCompiler() + err = compiler.CompileWorkflow(testFile) + require.NoError(t, err) + + lockContent, err := os.ReadFile(filepath.Join(workflowsDir, "test-cloud-hypervisor.lock.yml")) + require.NoError(t, err) + lockStr := string(lockContent) + + assert.Contains(t, lockStr, "Check host eligibility for cloud-hypervisor") + assert.Contains(t, lockStr, "Download and verify cloud-hypervisor bundle") + assert.Contains(t, lockStr, "GH_AW_AWF_VERSION: v0.28.0") + assert.Contains(t, lockStr, "--container-runtime cloud-hypervisor") + assert.Contains(t, lockStr, "--cloud-hypervisor-preview") + assert.Contains(t, lockStr, "--cloud-hypervisor-kernel \"${GH_AW_CLOUD_HYPERVISOR_KERNEL}\"") + assert.Contains(t, lockStr, "--cloud-hypervisor-supervisor-sha256 \"${GH_AW_CLOUD_HYPERVISOR_SUPERVISOR_SHA256}\"") +} + +func TestIsCloudHypervisorRuntime(t *testing.T) { + assert.False(t, isCloudHypervisorRuntime(nil)) + assert.False(t, isCloudHypervisorRuntime(&WorkflowData{})) + assert.True(t, isCloudHypervisorRuntime(&WorkflowData{SandboxConfig: &SandboxConfig{Agent: &AgentSandboxConfig{Runtime: AgentRuntimeCloudHypervisor}}})) +} + +func TestCloudHypervisorShellScriptContent(t *testing.T) { + wd, err := os.Getwd() + require.NoError(t, err) + shDir := filepath.Join(wd, "..", "..", "actions", "setup", "sh") + + tests := []struct { + script string + contains []string + }{ + { + script: "cloud_hypervisor_host_preflight.sh", + contains: []string{"RUNNER_ENVIRONMENT", "github-hosted", "ImageOS", "/dev/kvm", "cloud-hypervisor preview"}, + }, + { + script: "cloud_hypervisor_setup_bundle.sh", + contains: []string{"cloud-hypervisor-test-x86_64.tar.gz", "cloud-hypervisor-test-x86_64.SHA256SUMS", "cloud-hypervisor-test-x86_64.manifest.json", "vmlinux.bin", "rootfs.ext4", "awf-supervisor", "binary_path=", "binary_sha256="}, + }, + } + + for _, tc := range tests { + t.Run(tc.script, func(t *testing.T) { + content, err := os.ReadFile(filepath.Join(shDir, tc.script)) + require.NoError(t, err) + for _, expected := range tc.contains { + assert.Contains(t, string(content), expected) + } + }) + } +} diff --git a/pkg/workflow/codex_engine.go b/pkg/workflow/codex_engine.go index 392dce8b24d..50126849644 100644 --- a/pkg/workflow/codex_engine.go +++ b/pkg/workflow/codex_engine.go @@ -125,7 +125,7 @@ func (e *CodexEngine) GetInstallationSteps(workflowData *WorkflowData) []GitHubA "codex", workflowData, ) - if isDockerSbxRuntime(workflowData) { + if isDockerSbxRuntime(workflowData) || isCloudHypervisorRuntime(workflowData) { version := string(constants.DefaultCodexVersion) if workflowData.EngineConfig != nil && workflowData.EngineConfig.Version != "" { version = workflowData.EngineConfig.Version @@ -164,6 +164,10 @@ func (e *CodexEngine) GetInstallationSteps(workflowData *WorkflowData) []GitHubA steps = append(steps, generateDockerSbxPreFlightStep()) } } + if isCloudHypervisorRuntime(workflowData) { + steps = append(steps, generateCloudHypervisorHostPreflightStep()) + steps = append(steps, generateCloudHypervisorBundleSetupStep(getAWFVersionForSetup(workflowData))) + } // Install AWF binary (or skip if custom command is specified) awfInstall := generateAWFInstallationStep(awfVersion, agentConfig) diff --git a/pkg/workflow/compiler_yaml_step_lifecycle.go b/pkg/workflow/compiler_yaml_step_lifecycle.go index 378761602ac..4c02b0005ee 100644 --- a/pkg/workflow/compiler_yaml_step_lifecycle.go +++ b/pkg/workflow/compiler_yaml_step_lifecycle.go @@ -136,7 +136,7 @@ func (c *Compiler) generateCreateAwInfo(yaml *strings.Builder, data *WorkflowDat firewallType = "squid" } - // Sandbox agent runtime (e.g., "gvisor", "docker-sbx"), stored in aw_info.json + // Sandbox agent runtime (e.g., "gvisor", "docker-sbx", "cloud-hypervisor"), stored in aw_info.json // for observability and used by the logs/audit --runtime filter. agentRuntime := "" if data.SandboxConfig != nil && data.SandboxConfig.Agent != nil { diff --git a/pkg/workflow/firewall.go b/pkg/workflow/firewall.go index 0d179496b95..19cf4d033d4 100644 --- a/pkg/workflow/firewall.go +++ b/pkg/workflow/firewall.go @@ -117,15 +117,16 @@ func getAgentConfig(workflowData *WorkflowData) *AgentSandboxConfig { // getAgentContainerRuntime returns the container runtime string for the AWF config, // or an empty string if no custom runtime is configured. -// docker-sbx is excluded because it is not an OCI runtime; it passes -// --container-runtime sbx as a CLI flag in BuildAWFArgs instead. +// docker-sbx and cloud-hypervisor are excluded because they are not OCI runtimes; +// they pass --container-runtime via CLI flags in BuildAWFArgs instead. func getAgentContainerRuntime(workflowData *WorkflowData) string { agentConfig := getAgentConfig(workflowData) if agentConfig == nil || agentConfig.Disabled { return "" } - // docker-sbx is not an OCI runtime and must not appear in container.containerRuntime. - if agentConfig.Runtime == AgentRuntimeDockerSbx { + // docker-sbx and cloud-hypervisor are not OCI runtimes and must not appear in + // container.containerRuntime. + if agentConfig.Runtime == AgentRuntimeDockerSbx || agentConfig.Runtime == AgentRuntimeCloudHypervisor { return "" } return string(agentConfig.Runtime) @@ -150,6 +151,16 @@ func isDockerSbxRuntime(workflowData *WorkflowData) bool { return agentConfig.Runtime == AgentRuntimeDockerSbx } +// isCloudHypervisorRuntime returns true when the agent should run inside a Cloud +// Hypervisor microVM (preview). +func isCloudHypervisorRuntime(workflowData *WorkflowData) bool { + agentConfig := getAgentConfig(workflowData) + if agentConfig == nil || agentConfig.Disabled { + return false + } + return agentConfig.Runtime == AgentRuntimeCloudHypervisor +} + // isRuntimeInstallEnabled returns true when runtime installation steps should be // generated (the default). Returns false only when sandbox.agent.runtime-install is // explicitly set to false AND a runtime (gVisor or docker-sbx) is configured. @@ -174,9 +185,10 @@ func isAWFNetworkIsolationEnabled(workflowData *WorkflowData) bool { if agentConfig == nil || agentConfig.Disabled { return false } - // docker-sbx always uses network isolation regardless of the sudo setting. + // docker-sbx and cloud-hypervisor always use network isolation regardless of + // the sudo setting. // The sudo flag is only for the install steps, not for network enforcement. - if agentConfig.Runtime == AgentRuntimeDockerSbx { + if agentConfig.Runtime == AgentRuntimeDockerSbx || agentConfig.Runtime == AgentRuntimeCloudHypervisor { return true } return agentConfig.NetworkIsolation diff --git a/pkg/workflow/mcp_setup_gateway.go b/pkg/workflow/mcp_setup_gateway.go index 45bb770fdae..944742e37b5 100644 --- a/pkg/workflow/mcp_setup_gateway.go +++ b/pkg/workflow/mcp_setup_gateway.go @@ -148,7 +148,7 @@ func resolveMCPGatewayValues(workflowData *WorkflowData, gatewayConfig *MCPGatew if workflowData.SandboxConfig.Agent != nil && workflowData.SandboxConfig.Agent.Disabled { domain = "localhost" } else if isDockerSbxRuntime(workflowData) { - // docker-sbx microVM reaches host-published services via host.docker.internal + // Docker sbx microVMs reach host-published services via host.docker.internal // (the Docker bridge gateway). Use this as the MCP gateway domain so that the // CLI wrapper scripts generated inside the microVM point to the correct host. domain = "host.docker.internal" @@ -204,7 +204,7 @@ func writeMCPGatewayExports(yaml *strings.Builder, opts writeMCPGatewayExportsOp // When MCP_GATEWAY_DOMAIN is host.docker.internal (only reachable from containers), // or when network isolation is active (gateway on bridge; host reaches it via the // published 127.0.0.1 port), use localhost instead; otherwise inherit the domain. - // Exception: for docker-sbx, the CLI wrappers run INSIDE the microVM, so they must + // Exception: for microVM runtimes, the CLI wrappers run INSIDE the microVM, so they must // also use host.docker.internal (not localhost) to reach the published gateway port. // Exception: for Gemini under network isolation, use the topology hostname (awmg-mcpg) // instead of localhost. The Gemini CLI honors HTTP_PROXY but ignores NO_PROXY, so @@ -317,7 +317,7 @@ func buildMCPGatewayContainerCommand(opts buildMCPGatewayContainerCommandOptions if isAWFNetworkIsolationEnabled(workflowData) { containerCmd.WriteString(" --network bridge") if isDockerSbxRuntime(workflowData) { - // docker-sbx: publish to 0.0.0.0 so the microVM can reach the gateway via + // Docker sbx microVMs: publish to 0.0.0.0 so the guest can reach the gateway via // host.docker.internal (the Docker bridge gateway, 172.17.0.1). containerCmd.WriteString(" -p 0.0.0.0:${MCP_GATEWAY_PORT}:${MCP_GATEWAY_PORT}") } else { diff --git a/pkg/workflow/nodejs.go b/pkg/workflow/nodejs.go index 23e1fd15d93..3b422acd0a1 100644 --- a/pkg/workflow/nodejs.go +++ b/pkg/workflow/nodejs.go @@ -159,6 +159,10 @@ func BuildNpmEngineInstallStepsWithAWF(npmSteps []GitHubActionStep, workflowData steps = append(steps, generateDockerSbxPreFlightStep()) } } + if isCloudHypervisorRuntime(workflowData) { + steps = append(steps, generateCloudHypervisorHostPreflightStep()) + steps = append(steps, generateCloudHypervisorBundleSetupStep(getAWFVersionForSetup(workflowData))) + } awfInstall := generateAWFInstallationStep(awfVersion, agentConfig) if len(awfInstall) > 0 { @@ -229,7 +233,8 @@ func GetNpmBinPathSetup() string { } // GenerateDockerSbxNpmCLIInstallStep installs an npm CLI into a runner path that is -// visible inside the docker-sbx microVM, then creates a stable bin/ symlink from +// visible inside microVM runtimes (docker-sbx/cloud-hypervisor), then creates a +// stable bin/ symlink from // ${RUNNER_TEMP}/gh-aw/engine-cli/bin/ to the package's node_modules/.bin entry. func GenerateDockerSbxNpmCLIInstallStep(packageName, version, stepName, commandName string, runInstallScripts bool, cooldownEnabled bool) GitHubActionStep { ignoreScriptsFlag := "--ignore-scripts " @@ -271,9 +276,9 @@ func GenerateDockerSbxNpmCLIInstallStep(packageName, version, stepName, commandN } // GetDockerSbxNpmCLIPathSetup returns the PATH export needed for npm CLIs that were -// staged into ${RUNNER_TEMP}/gh-aw/engine-cli/bin for docker-sbx microVM runs. +// staged into ${RUNNER_TEMP}/gh-aw/engine-cli/bin for microVM runs. func GetDockerSbxNpmCLIPathSetup(workflowData *WorkflowData) string { - if !isDockerSbxRuntime(workflowData) { + if !isDockerSbxRuntime(workflowData) && !isCloudHypervisorRuntime(workflowData) { return "" } return `export PATH="${RUNNER_TEMP}/gh-aw/engine-cli/bin:$PATH"` diff --git a/pkg/workflow/sandbox.go b/pkg/workflow/sandbox.go index 81e11ea2111..25c46986c3e 100644 --- a/pkg/workflow/sandbox.go +++ b/pkg/workflow/sandbox.go @@ -58,6 +58,10 @@ const ( // api-proxy, MCP gateway) remain on the host in Docker Compose. // Requires sudo: true and a KVM-capable runner with DOCKER_PAT / DOCKER_USERNAME secrets. AgentRuntimeDockerSbx AgentRuntime = "docker-sbx" + + // AgentRuntimeCloudHypervisor runs the agent inside a Cloud Hypervisor microVM + // using AWF's preview cloud-hypervisor runtime mode. + AgentRuntimeCloudHypervisor AgentRuntime = "cloud-hypervisor" ) // AgentSandboxConfig represents the agent sandbox configuration diff --git a/pkg/workflow/sandbox_validation.go b/pkg/workflow/sandbox_validation.go index 21a4d39882a..dd9bc8191a8 100644 --- a/pkg/workflow/sandbox_validation.go +++ b/pkg/workflow/sandbox_validation.go @@ -191,6 +191,39 @@ func validateSandboxConfig(workflowData *WorkflowData) error { sandboxValidationLog.Print("docker-sbx runtime configured -- topology, sudo, and AWF version checks passed") } + // Validate cloud-hypervisor runtime compatibility + if agentConfig != nil && agentConfig.Runtime == AgentRuntimeCloudHypervisor { + if isArcDindTopology(workflowData) { + return NewValidationError( + "sandbox.agent.runtime", + string(AgentRuntimeCloudHypervisor), + "cloud-hypervisor is incompatible with runner.topology: arc-dind", + "cloud-hypervisor requires KVM and is only supported on GitHub-hosted Ubuntu x86_64 runners. "+ + "ARC DinD runners do not provide that runtime environment. Remove sandbox.agent.runtime: cloud-hypervisor or change runner.topology.", + ) + } + + firewallConfig := getFirewallConfig(workflowData) + var configuredVersion string + if firewallConfig != nil { + configuredVersion = firewallConfig.Version + } + if !versionAtLeast(configuredVersion, string(constants.DefaultFirewallVersion), string(constants.AWFCloudHypervisorMinVersion)) { + effectiveVersion := configuredVersion + if effectiveVersion == "" { + effectiveVersion = string(constants.DefaultFirewallVersion) + } + return NewValidationError( + "sandbox.agent.runtime", + string(AgentRuntimeCloudHypervisor), + fmt.Sprintf("cloud-hypervisor requires AWF %s or newer", constants.AWFCloudHypervisorMinVersion), + fmt.Sprintf("cloud-hypervisor preview support is only available in AWF %s+.\n\nThe effective AWF version is %s. Set firewall.version or sandbox.agent.version to %s or newer.", constants.AWFCloudHypervisorMinVersion, effectiveVersion, constants.AWFCloudHypervisorMinVersion), + ) + } + + sandboxValidationLog.Print("cloud-hypervisor runtime configured -- topology and AWF version checks passed") + } + // Validate config structure if provided (deprecated - was only for SRT) if sandboxConfig.Config != nil { // Config is no longer used - SRT removed