diff --git a/.changeset/minor-docker-sbx-runtime-support.md b/.changeset/minor-docker-sbx-runtime-support.md new file mode 100644 index 00000000000..a073aac61d1 --- /dev/null +++ b/.changeset/minor-docker-sbx-runtime-support.md @@ -0,0 +1,24 @@ +--- +"gh-aw": minor +--- + +Add `sandbox.agent.runtime: docker-sbx` frontmatter field for Docker sbx microVM runtime support. + +When set to `docker-sbx`, the compiler: + +1. Emits fail-fast steps that check KVM availability and Docker Hub secrets (`DOCKER_PAT`, `DOCKER_USERNAME`) before any installation begins +2. Emits a `docker-sbx` installation step that adds the Docker apt repository and installs the `docker-sbx` package +3. Emits an auth and daemon step that starts the `sbx` daemon, authenticates with Docker Hub, resets and re-initialises the allow-all policy, and pre-pulls the `docker/sandbox-templates:shell-docker` image +4. Emits a pre-flight smoke test that creates a throwaway sandbox, runs `uname -a`, and cleans up — confirming the sbx stack is functional before committing to the expensive AWF setup +5. Passes `--container-runtime sbx` to the AWF CLI invocation +6. Adds `host.docker.internal` to `network.allowDomains` so the sbx microVM can reach the api-proxy, MCP gateway, and Squid proxy via the Docker bridge +7. Binds the MCP gateway to `0.0.0.0` (instead of `127.0.0.1`) so the microVM can reach it via `host.docker.internal` +8. Sets `MCP_GATEWAY_HOST_DOMAIN=host.docker.internal` so CLI wrapper scripts generated inside the microVM point to the correct gateway URL + +Compile-time validation rejects incompatible combinations: + +- `sandbox.agent.runtime: docker-sbx` + `runner.topology: arc-dind` — sbx requires KVM; ARC DinD runners typically lack nested virtualisation +- `sandbox.agent.runtime: docker-sbx` without `sandbox.agent.sudo: true` — the sbx install step requires root access +- `sandbox.agent.runtime: docker-sbx` with an effective AWF version older than `v0.28.0` — `awf --container-runtime sbx` requires AWF support + +The `sudo: true` deprecation warning/error is suppressed when `runtime: docker-sbx` is set because sbx fundamentally requires root for installation. Despite requiring `sudo: true`, network isolation is always enabled (`network.isolation: true`) — the sudo flag is only for the install steps, not for network enforcement. diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 365625e8393..93313a4ae4d 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -3463,9 +3463,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. Requires sandbox.agent.sudo: true (root access is needed to install and register runsc). Incompatible with runner.topology: arc-dind.", - "enum": ["gvisor"], - "examples": ["gvisor"] + "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 — 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"] }, "config": { "type": "object", diff --git a/pkg/workflow/awf_config.go b/pkg/workflow/awf_config.go index 7fb449d27b3..dec3cb29a6e 100644 --- a/pkg/workflow/awf_config.go +++ b/pkg/workflow/awf_config.go @@ -67,6 +67,7 @@ import ( _ "embed" "encoding/json" "fmt" + "slices" "strings" "sync" @@ -425,6 +426,21 @@ func BuildAWFConfigJSON(config AWFCommandConfig) (string, error) { 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 + // (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. + if isDockerSbxRuntime(config.WorkflowData) { + if awfConfig.Network == nil { + awfConfig.Network = &AWFNetworkConfig{} + } + 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) + } + } + if platformType := extractPlatformType(config.WorkflowData); platformType != "" { awfConfig.Platform = &AWFPlatformConfig{Type: platformType} awfConfigLog.Printf("Platform section: type=%s", platformType) diff --git a/pkg/workflow/awf_helpers.go b/pkg/workflow/awf_helpers.go index 9ef888205eb..4f48bb34c8b 100644 --- a/pkg/workflow/awf_helpers.go +++ b/pkg/workflow/awf_helpers.go @@ -612,6 +612,16 @@ func BuildAWFArgs(config AWFCommandConfig) []string { awfArgs = append(awfArgs, "--tty") } + // docker-sbx: tell AWF to launch the agent inside a Docker sbx microVM instead + // of as a standard Docker Compose service. Guard on the effective AWF version so + // older binaries do not receive an unknown flag. + if isDockerSbxRuntime(config.WorkflowData) && awfSupportsContainerRuntime(firewallConfig) { + awfArgs = append(awfArgs, "--container-runtime", "sbx") + awfHelpersLog.Print("Added --container-runtime sbx for docker-sbx microVM runtime") + } else if isDockerSbxRuntime(config.WorkflowData) { + awfHelpersLog.Printf("Skipping --container-runtime sbx: AWF version %q is older than required minimum %s", getAWFImageTag(firewallConfig), constants.AWFContainerRuntimeMinVersion) + } + // 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) // handles authentication for these tokens transparently, so the container does not need diff --git a/pkg/workflow/codex_engine.go b/pkg/workflow/codex_engine.go index 2b05f87743c..be962046f2c 100644 --- a/pkg/workflow/codex_engine.go +++ b/pkg/workflow/codex_engine.go @@ -137,6 +137,15 @@ func (e *CodexEngine) GetInstallationSteps(workflowData *WorkflowData) []GitHubA steps = append(steps, generateGVisorInstallStep()) } + // docker-sbx must be installed, authenticated, and smoke-tested BEFORE AWF. + if isDockerSbxRuntime(workflowData) { + steps = append(steps, generateDockerSbxKVMCheckStep()) + steps = append(steps, generateDockerSbxSecretsCheckStep()) + steps = append(steps, generateDockerSbxInstallStep()) + steps = append(steps, generateDockerSbxAuthAndDaemonStep()) + steps = append(steps, generateDockerSbxPreFlightStep()) + } + // Install AWF binary (or skip if custom command is specified) awfInstall := generateAWFInstallationStep(awfVersion, agentConfig) if len(awfInstall) > 0 { diff --git a/pkg/workflow/docker_sbx_install.go b/pkg/workflow/docker_sbx_install.go new file mode 100644 index 00000000000..b775bc74320 --- /dev/null +++ b/pkg/workflow/docker_sbx_install.go @@ -0,0 +1,154 @@ +// This file generates the GitHub Actions steps required to install, authenticate, +// and pre-flight-test the Docker sbx microVM runtime for sandbox.agent.runtime: docker-sbx. +// +// The steps emitted are (in order): +// 1. KVM availability check – fails fast when nested virtualisation is absent. +// 2. Docker Hub secrets check – fails fast when DOCKER_PAT / DOCKER_USERNAME are missing. +// 3. sbx installation – adds the Docker apt repo and installs the docker-sbx package. +// 4. sbx auth & daemon – authenticates with Docker Hub, starts the daemon, resets and +// re-initialises the allow-all policy, then pre-pulls the template image. +// 5. sbx pre-flight smoke – creates a throwaway sandbox, execs a command, then removes it. +// +// All five steps must be injected BEFORE the AWF installation step so the sbx runtime +// is available when AWF starts the agent inside a microVM. + +package workflow + +// generateDockerSbxKVMCheckStep creates a fail-fast step that verifies the runner +// has KVM support before spending time on sbx installation. +func generateDockerSbxKVMCheckStep() GitHubActionStep { + return GitHubActionStep([]string{ + " - name: Check KVM availability for docker-sbx", + " run: |", + " set -euo pipefail", + ` echo "::group::KVM availability check"`, + ` if ! lsmod | grep -q kvm; then`, + ` echo "::error::KVM kernel module is not loaded. docker-sbx requires a KVM-capable runner with nested virtualisation enabled."`, + ` exit 1`, + ` fi`, + ` if ! test -e /dev/kvm; then`, + ` echo "::error::/dev/kvm is missing. docker-sbx requires the KVM device to be present on the runner."`, + ` exit 1`, + ` fi`, + ` echo "KVM is available and /dev/kvm is present ✅"`, + ` echo "::endgroup::"`, + }) +} + +// generateDockerSbxSecretsCheckStep creates a fail-fast step that verifies the +// DOCKER_PAT and DOCKER_USERNAME secrets are present before attempting sbx install. +func generateDockerSbxSecretsCheckStep() GitHubActionStep { + return GitHubActionStep([]string{ + " - name: Check Docker Hub secrets for docker-sbx", + " env:", + " DOCKER_PAT_VAL: ${{ secrets.DOCKER_PAT }}", + " DOCKER_USERNAME_VAL: ${{ secrets.DOCKER_USERNAME }}", + " run: |", + " set -euo pipefail", + ` echo "::group::Docker Hub secrets check"`, + ` if [[ -z "${DOCKER_PAT_VAL}" ]]; then`, + ` echo "::error::secrets.DOCKER_PAT is empty. docker-sbx requires a Docker Hub personal access token to pull the sandbox template image. Add a DOCKER_PAT secret to your repository."`, + ` exit 1`, + ` fi`, + ` if [[ -z "${DOCKER_USERNAME_VAL}" ]]; then`, + ` echo "::error::secrets.DOCKER_USERNAME is empty. docker-sbx requires a Docker Hub username. Add a DOCKER_USERNAME secret to your repository."`, + ` exit 1`, + ` fi`, + ` echo "Docker Hub secrets are present ✅"`, + ` echo "::endgroup::"`, + }) +} + +// generateDockerSbxInstallStep creates a GitHub Actions step that installs the +// docker-sbx package via the official Docker apt repository. +func generateDockerSbxInstallStep() GitHubActionStep { + return GitHubActionStep([]string{ + " - name: Install docker-sbx", + " run: |", + " set -euo pipefail", + ` echo "::group::Install docker-sbx"`, + ` # Add Docker apt repo without installing Docker Engine (already present).`, + ` curl -fsSL https://get.docker.com | sudo REPO_ONLY=1 sh`, + ` sudo apt-get install -y docker-sbx`, + ` sbx version`, + ` # Fix KVM permissions so the runner user can create microVMs.`, + ` sudo chmod 666 /dev/kvm`, + ` echo "docker-sbx installed successfully ✅"`, + ` echo "::endgroup::"`, + }) +} + +// generateDockerSbxAuthAndDaemonStep creates a step that: +// 1. Starts the sbx daemon. +// 2. Authenticates with Docker Hub (both docker and sbx CLIs). +// 3. Resets and re-initialises the sbx policy (required for mount policy). +// 4. Restarts the daemon and re-authenticates. +// 5. Pre-pulls the sandbox template image. +func generateDockerSbxAuthAndDaemonStep() GitHubActionStep { + return GitHubActionStep([]string{ + " - name: Start docker-sbx daemon and authenticate", + " env:", + " DOCKER_PAT_VAL: ${{ secrets.DOCKER_PAT }}", + " DOCKER_USERNAME_VAL: ${{ secrets.DOCKER_USERNAME }}", + " run: |", + " set -euo pipefail", + ` export DOCKER_CONFIG="$(mktemp -d)"`, + ` trap 'rm -rf "${DOCKER_CONFIG}"' EXIT`, + ` echo "::group::Start sbx daemon"`, + ` nohup sbx daemon start > /tmp/sbx-daemon.log 2>&1 &`, + ` # Poll until daemon is running (up to 10 s).`, + ` for i in $(seq 1 10); do`, + ` if sbx daemon status 2>/dev/null | grep -q -i running; then`, + ` echo "sbx daemon is running"`, + ` break`, + ` fi`, + ` sleep 1`, + ` done`, + ` echo "::endgroup::"`, + ` echo "::group::Authenticate with Docker Hub"`, + ` printf '%s' "${DOCKER_PAT_VAL}" | docker login --username "${DOCKER_USERNAME_VAL}" --password-stdin`, + ` printf '%s' "${DOCKER_PAT_VAL}" | sbx login --username "${DOCKER_USERNAME_VAL}" --password-stdin`, + ` echo "::endgroup::"`, + ` echo "::group::Reset and initialise sbx policy"`, + ` sbx daemon stop || true`, + ` sbx policy reset --force || true`, + ` sbx policy init allow-all`, + ` nohup sbx daemon start > /tmp/sbx-daemon.log 2>&1 &`, + ` for i in $(seq 1 10); do`, + ` if sbx daemon status 2>/dev/null | grep -q -i running; then`, + ` echo "sbx daemon restarted"`, + ` break`, + ` fi`, + ` sleep 1`, + ` done`, + ` printf '%s' "${DOCKER_PAT_VAL}" | docker login --username "${DOCKER_USERNAME_VAL}" --password-stdin`, + ` printf '%s' "${DOCKER_PAT_VAL}" | sbx login --username "${DOCKER_USERNAME_VAL}" --password-stdin`, + ` echo "::endgroup::"`, + ` echo "::group::Pre-pull sandbox template image"`, + ` docker pull docker/sandbox-templates:shell-docker`, + ` echo "Template image ready ✅"`, + ` echo "::endgroup::"`, + }) +} + +// generateDockerSbxPreFlightStep creates a step that verifies the sbx stack works +// end-to-end before the MCP gateway and AWF container setup begins. +func generateDockerSbxPreFlightStep() GitHubActionStep { + return GitHubActionStep([]string{ + " - name: docker-sbx pre-flight smoke test", + " run: |", + " set -euo pipefail", + ` echo "::group::docker-sbx pre-flight smoke test"`, + ` sandbox_name="test-sandbox-direct"`, + ` cleanup() {`, + ` sbx stop "${sandbox_name}" >/dev/null 2>&1 || true`, + ` sbx rm --force "${sandbox_name}" >/dev/null 2>&1 || true`, + ` }`, + ` trap cleanup EXIT`, + ` echo "y" | sbx create shell --name "${sandbox_name}" "${GITHUB_WORKSPACE}"`, + ` sbx exec "${sandbox_name}" uname -a`, + ` sbx stop "${sandbox_name}"`, + ` echo "✅ sbx ready"`, + ` echo "::endgroup::"`, + }) +} diff --git a/pkg/workflow/docker_sbx_test.go b/pkg/workflow/docker_sbx_test.go new file mode 100644 index 00000000000..812888368e6 --- /dev/null +++ b/pkg/workflow/docker_sbx_test.go @@ -0,0 +1,527 @@ +//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" +) + +// TestGenerateDockerSbxInstallSteps verifies that all four docker-sbx install step +// generators produce non-empty output with the expected key content. +func TestGenerateDockerSbxInstallSteps(t *testing.T) { + t.Run("KVM check step", func(t *testing.T) { + step := generateDockerSbxKVMCheckStep() + require.NotEmpty(t, step, "KVM check step must not be empty") + content := strings.Join(step, "\n") + assert.Contains(t, content, "kvm", "must check for KVM availability") + assert.Contains(t, content, "test -e /dev/kvm", "must check /dev/kvm exists") + assert.Contains(t, content, "exit 1", "must fail with exit 1 when KVM is absent") + }) + + t.Run("secrets check step", func(t *testing.T) { + step := generateDockerSbxSecretsCheckStep() + require.NotEmpty(t, step, "secrets check step must not be empty") + content := strings.Join(step, "\n") + assert.Contains(t, content, "DOCKER_PAT", "must check DOCKER_PAT secret") + assert.Contains(t, content, "DOCKER_USERNAME", "must check DOCKER_USERNAME secret") + assert.Contains(t, content, "secrets.DOCKER_PAT", "must reference secrets.DOCKER_PAT") + assert.Contains(t, content, "secrets.DOCKER_USERNAME", "must reference secrets.DOCKER_USERNAME") + assert.Contains(t, content, "exit 1", "must fail with exit 1 when secrets are missing") + }) + + t.Run("install step", func(t *testing.T) { + step := generateDockerSbxInstallStep() + require.NotEmpty(t, step, "install step must not be empty") + content := strings.Join(step, "\n") + assert.Contains(t, content, "docker-sbx", "must install docker-sbx package") + assert.Contains(t, content, "sbx version", "must verify sbx is installed") + assert.Contains(t, content, "chmod 666 /dev/kvm", "must fix KVM permissions") + assert.Contains(t, content, "get.docker.com", "must add Docker apt repo") + }) + + t.Run("auth and daemon step", func(t *testing.T) { + step := generateDockerSbxAuthAndDaemonStep() + require.NotEmpty(t, step, "auth and daemon step must not be empty") + content := strings.Join(step, "\n") + assert.Contains(t, content, "sbx daemon start", "must start sbx daemon") + assert.Contains(t, content, "docker login", "must authenticate with Docker") + assert.Contains(t, content, "sbx login", "must authenticate sbx with Docker Hub") + assert.Contains(t, content, "sbx policy reset", "must reset sbx policy") + assert.Contains(t, content, "sbx policy init allow-all", "must init sbx allow-all policy") + assert.Contains(t, content, "docker/sandbox-templates:shell-docker", "must pre-pull template image") + assert.Contains(t, content, `export DOCKER_CONFIG="$(mktemp -d)"`, "must isolate Docker auth in a temporary config") + assert.Contains(t, content, `trap 'rm -rf "${DOCKER_CONFIG}"' EXIT`, "must clean up temporary Docker auth on exit") + // Secrets must be passed via env, not inline in the run: block + assert.Contains(t, content, "DOCKER_PAT_VAL: ${{ secrets.DOCKER_PAT }}", "must use env for DOCKER_PAT") + assert.Contains(t, content, "DOCKER_USERNAME_VAL: ${{ secrets.DOCKER_USERNAME }}", "must use env for DOCKER_USERNAME") + // The run: section must use env var references (${DOCKER_PAT_VAL}) not raw secret expressions. + // Extract the run: body to verify secret expressions don't appear in shell commands. + parts := strings.SplitN(content, "run: |", 2) + require.Len(t, parts, 2, "step must have a run: section") + runBody := parts[1] + assert.NotContains(t, runBody, "${{ secrets.DOCKER_PAT }}", + "raw secrets.DOCKER_PAT expression must not appear in shell commands") + assert.NotContains(t, runBody, "${{ secrets.DOCKER_USERNAME }}", + "raw secrets.DOCKER_USERNAME expression must not appear in shell commands") + }) + + t.Run("pre-flight step", func(t *testing.T) { + step := generateDockerSbxPreFlightStep() + require.NotEmpty(t, step, "pre-flight step must not be empty") + content := strings.Join(step, "\n") + assert.Contains(t, content, "sbx create", "must create a test sandbox") + assert.Contains(t, content, "test-sandbox-direct", "must use a named test sandbox") + assert.Contains(t, content, "sbx exec", "must exec a command in the sandbox") + assert.Contains(t, content, "uname -a", "must run uname -a as smoke test") + assert.Contains(t, content, "trap cleanup EXIT", "must register cleanup for smoke-test failures") + assert.Contains(t, content, "sbx stop", "must stop the test sandbox") + assert.Contains(t, content, "sbx rm", "must remove the test sandbox") + assert.Contains(t, content, "✅ sbx ready", "must confirm readiness") + }) +} + +// TestDockerSbxInstallStepOrderInBuildNpmEngineInstallStepsWithAWF verifies that all +// docker-sbx pre-flight steps are emitted BEFORE the AWF install step. +func TestDockerSbxInstallStepOrderInBuildNpmEngineInstallStepsWithAWF(t *testing.T) { + workflowData := &WorkflowData{ + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + ID: "awf", + Runtime: AgentRuntimeDockerSbx, + NetworkIsolation: false, // will be overridden by isDockerSbxRuntime + SudoExplicitlyEnabled: true, + }, + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + } + + steps := BuildNpmEngineInstallStepsWithAWF(nil, workflowData) + require.NotEmpty(t, steps, "must generate installation steps") + + // Locate the key steps by their content. + kvmIdx := -1 + secretsIdx := -1 + installIdx := -1 + authIdx := -1 + preflightIdx := -1 + awfIdx := -1 + for i, step := range steps { + content := strings.Join(step, "\n") + switch { + case strings.Contains(content, "Check KVM availability"): + kvmIdx = i + case strings.Contains(content, "Check Docker Hub secrets"): + secretsIdx = i + case strings.Contains(content, "Install docker-sbx"): + installIdx = i + case strings.Contains(content, "Start docker-sbx daemon"): + authIdx = i + case strings.Contains(content, "pre-flight smoke test"): + preflightIdx = i + case strings.Contains(content, "install_awf_binary.sh"): + awfIdx = i + } + } + + require.NotEqual(t, -1, kvmIdx, "KVM check step must be present") + require.NotEqual(t, -1, secretsIdx, "secrets check step must be present") + require.NotEqual(t, -1, installIdx, "docker-sbx install step must be present") + require.NotEqual(t, -1, authIdx, "auth and daemon step must be present") + require.NotEqual(t, -1, preflightIdx, "pre-flight step must be present") + require.NotEqual(t, -1, awfIdx, "AWF install step must be present") + + // All docker-sbx steps must precede the AWF install step. + assert.Less(t, kvmIdx, awfIdx, "KVM check step must come before AWF install") + assert.Less(t, secretsIdx, awfIdx, "secrets check step must come before AWF install") + assert.Less(t, installIdx, awfIdx, "docker-sbx install step must come before AWF install") + assert.Less(t, authIdx, awfIdx, "auth and daemon step must come before AWF install") + assert.Less(t, preflightIdx, awfIdx, "pre-flight step must come before AWF install") + // And they must be in the correct logical order relative to each other. + assert.Less(t, kvmIdx, secretsIdx, "KVM check must come before secrets check") + assert.Less(t, secretsIdx, installIdx, "secrets check must come before install") + assert.Less(t, installIdx, authIdx, "install must come before auth/daemon") + assert.Less(t, authIdx, preflightIdx, "auth/daemon must come before pre-flight") +} + +// TestDockerSbxAWFArgs verifies that --container-runtime sbx is added to AWF args +// when docker-sbx runtime is configured. +func TestDockerSbxAWFArgs(t *testing.T) { + config := AWFCommandConfig{ + EngineName: "copilot", + WorkflowData: &WorkflowData{ + EngineConfig: &EngineConfig{ID: "copilot"}, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true, Version: string(constants.AWFContainerRuntimeMinVersion)}, + }, + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + ID: "awf", + Runtime: AgentRuntimeDockerSbx, + SudoExplicitlyEnabled: true, + }, + }, + }, + } + + args := BuildAWFArgs(config) + + // Must include --container-runtime sbx + found := false + for i, arg := range args { + if arg == "--container-runtime" && i+1 < len(args) && args[i+1] == "sbx" { + found = true + break + } + } + assert.True(t, found, "AWF args must include --container-runtime sbx for docker-sbx runtime") +} + +// TestDockerSbxAWFArgsAbsentByDefault verifies that --container-runtime sbx is NOT +// added when no runtime is configured. +func TestDockerSbxAWFArgsAbsentByDefault(t *testing.T) { + config := AWFCommandConfig{ + EngineName: "copilot", + WorkflowData: &WorkflowData{ + EngineConfig: &EngineConfig{ID: "copilot"}, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + ID: "awf", + }, + }, + }, + } + + args := BuildAWFArgs(config) + argStr := strings.Join(args, " ") + assert.NotContains(t, argStr, "--container-runtime", "AWF args must not include --container-runtime when no runtime is set") +} + +// TestDockerSbxAWFArgsVersionGated verifies that --container-runtime sbx is omitted when +// the effective AWF version predates AWFContainerRuntimeMinVersion. +func TestDockerSbxAWFArgsVersionGated(t *testing.T) { + config := AWFCommandConfig{ + EngineName: "copilot", + WorkflowData: &WorkflowData{ + EngineConfig: &EngineConfig{ID: "copilot"}, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + ID: "awf", + Runtime: AgentRuntimeDockerSbx, + SudoExplicitlyEnabled: true, + }, + }, + }, + } + + args := BuildAWFArgs(config) + assert.NotContains(t, strings.Join(args, " "), "--container-runtime", + "AWF args must omit --container-runtime when the AWF version is too old") +} + +// TestDockerSbxAWFConfigJSON verifies that the AWF config JSON for docker-sbx does NOT +// include containerRuntime (docker-sbx is not an OCI runtime) but DOES include +// host.docker.internal in allowDomains and sets network.isolation: true. +func TestDockerSbxAWFConfigJSON(t *testing.T) { + config := AWFCommandConfig{ + EngineName: "copilot", + AllowedDomains: "github.com", + WorkflowData: &WorkflowData{ + EngineConfig: &EngineConfig{ID: "copilot"}, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + ID: "awf", + Runtime: AgentRuntimeDockerSbx, + SudoExplicitlyEnabled: true, + }, + }, + }, + } + + jsonStr, err := BuildAWFConfigJSON(config) + require.NoError(t, err) + + // docker-sbx is not an OCI runtime — containerRuntime must NOT appear in JSON. + assert.NotContains(t, jsonStr, `"containerRuntime"`, + "docker-sbx must not set container.containerRuntime in AWF config JSON") + + // host.docker.internal must be in network.allowDomains so the microVM can + // reach host-published services (api-proxy, MCP gateway, Squid proxy). + assert.Contains(t, jsonStr, "host.docker.internal", + "AWF config JSON must include host.docker.internal in network.allowDomains for docker-sbx") + + // network.isolation must be true for docker-sbx. + assert.Contains(t, jsonStr, `"isolation":true`, + "AWF config JSON must have network.isolation: true for docker-sbx") +} + +// TestDockerSbxNetworkIsolationAlwaysTrue verifies that isAWFNetworkIsolationEnabled +// returns true for docker-sbx even when sudo: true sets NetworkIsolation=false. +func TestDockerSbxNetworkIsolationAlwaysTrue(t *testing.T) { + workflowData := &WorkflowData{ + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + ID: "awf", + Runtime: AgentRuntimeDockerSbx, + NetworkIsolation: false, // sudo: true sets this to false normally + SudoExplicitlyEnabled: true, + }, + }, + } + + assert.True(t, isAWFNetworkIsolationEnabled(workflowData), + "docker-sbx must always use network isolation regardless of sudo setting") +} + +// TestDockerSbxContainerRuntimeEmpty verifies that getAgentContainerRuntime returns +// an empty string for docker-sbx (it is not an OCI runtime). +func TestDockerSbxContainerRuntimeEmpty(t *testing.T) { + workflowData := &WorkflowData{ + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + ID: "awf", + Runtime: AgentRuntimeDockerSbx, + }, + }, + } + + assert.Empty(t, getAgentContainerRuntime(workflowData), + "docker-sbx must return empty string from getAgentContainerRuntime") +} + +// TestDockerSbxValidation_ArcDindIncompatible verifies that docker-sbx + arc-dind is +// a compile-time error. +func TestDockerSbxValidation_ArcDindIncompatible(t *testing.T) { + workflowData := &WorkflowData{ + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + ID: "awf", + Runtime: AgentRuntimeDockerSbx, + SudoExplicitlyEnabled: true, + }, + }, + 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, "docker-sbx + arc-dind must produce a compile-time error") + assert.Contains(t, err.Error(), "arc-dind", "error must mention arc-dind") + assert.Contains(t, err.Error(), "docker-sbx", "error must mention docker-sbx") +} + +// TestDockerSbxValidation_SudoFalseRejected verifies that docker-sbx without +// sudo: true is a compile-time error. +func TestDockerSbxValidation_SudoFalseRejected(t *testing.T) { + workflowData := &WorkflowData{ + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + ID: "awf", + Runtime: AgentRuntimeDockerSbx, + NetworkIsolation: true, // sudo: false + SudoExplicitlyEnabled: false, // sudo not explicitly set + }, + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + Tools: map[string]any{"github": map[string]any{"mode": "remote"}}, + } + + err := validateSandboxConfig(workflowData) + require.Error(t, err, "docker-sbx without sudo: true must produce a compile-time error") + assert.Contains(t, err.Error(), "sudo: true", "error must mention sudo: true") + assert.Contains(t, err.Error(), "docker-sbx", "error must mention docker-sbx") +} + +// TestDockerSbxValidation_DefaultVersionRejected verifies that docker-sbx is rejected +// when the effective AWF version predates --container-runtime support. +func TestDockerSbxValidation_DefaultVersionRejected(t *testing.T) { + workflowData := &WorkflowData{ + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + ID: "awf", + Runtime: AgentRuntimeDockerSbx, + NetworkIsolation: false, // sudo: true → NetworkIsolation=false (overridden by isDockerSbxRuntime) + SudoExplicitlyEnabled: true, + }, + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + Tools: map[string]any{"github": map[string]any{"mode": "remote"}}, + } + + err := validateSandboxConfig(workflowData) + require.Error(t, err, "docker-sbx with the default AWF version must fail validation") + assert.Contains(t, err.Error(), string(constants.AWFContainerRuntimeMinVersion)) +} + +// TestDockerSbxValidation_MinVersionSatisfied verifies that docker-sbx passes validation +// when the effective AWF version supports --container-runtime. +func TestDockerSbxValidation_MinVersionSatisfied(t *testing.T) { + workflowData := &WorkflowData{ + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + ID: "awf", + Runtime: AgentRuntimeDockerSbx, + NetworkIsolation: false, + SudoExplicitlyEnabled: true, + Version: string(constants.AWFContainerRuntimeMinVersion), + }, + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{Enabled: true}, + }, + Tools: map[string]any{"github": map[string]any{"mode": "remote"}}, + } + + err := validateSandboxConfig(workflowData) + assert.NoError(t, err, "docker-sbx with a supported AWF version must pass validation") +} + +// TestDockerSbxStrictModeSudoSuppressed verifies that sandbox.agent.sudo: true combined +// with runtime: docker-sbx does NOT produce a strict-mode error (sudo is required for +// docker-sbx install and the deprecation warning is suppressed). +func TestDockerSbxStrictModeSudoSuppressed(t *testing.T) { + sandboxConfig := &SandboxConfig{ + Agent: &AgentSandboxConfig{ + ID: "awf", + Runtime: AgentRuntimeDockerSbx, + NetworkIsolation: false, + SudoExplicitlyEnabled: true, + }, + } + + compiler := NewCompiler() + compiler.strictMode = true + + err := compiler.validateStrictSandboxCustomization(sandboxConfig) + assert.NoError(t, err, "sudo:true + runtime:docker-sbx must NOT produce a strict-mode error") +} + +// TestIsDockerSbxRuntime verifies the isDockerSbxRuntime helper. +func TestIsDockerSbxRuntime(t *testing.T) { + t.Run("returns false for nil workflow data", func(t *testing.T) { + assert.False(t, isDockerSbxRuntime(nil)) + }) + + t.Run("returns false when no sandbox config", func(t *testing.T) { + assert.False(t, isDockerSbxRuntime(&WorkflowData{})) + }) + + t.Run("returns false when runtime is not docker-sbx", func(t *testing.T) { + assert.False(t, isDockerSbxRuntime(&WorkflowData{ + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ID: "awf"}, + }, + })) + }) + + t.Run("returns false when agent is disabled", func(t *testing.T) { + assert.False(t, isDockerSbxRuntime(&WorkflowData{ + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + ID: "awf", + Runtime: AgentRuntimeDockerSbx, + Disabled: true, + }, + }, + })) + }) + + t.Run("returns true when runtime is docker-sbx", func(t *testing.T) { + assert.True(t, isDockerSbxRuntime(&WorkflowData{ + SandboxConfig: &SandboxConfig{ + Agent: &AgentSandboxConfig{ + ID: "awf", + Runtime: AgentRuntimeDockerSbx, + }, + }, + })) + }) +} + +// TestDockerSbxFrontmatterExtraction verifies end-to-end that a workflow with +// sandbox.agent.runtime: docker-sbx compiles correctly and produces the expected output. +func TestDockerSbxFrontmatterExtraction(t *testing.T) { + workflowsDir := t.TempDir() + + markdown := `--- +on: + workflow_dispatch: +engine: copilot +strict: false +network: + allowed: + - "example.com" +sandbox: + agent: + id: awf + runtime: docker-sbx + version: v0.28.0 + sudo: true +--- + +# Test docker-sbx Runtime +` + + testFile := filepath.Join(workflowsDir, "test-docker-sbx.md") + err := os.WriteFile(testFile, []byte(markdown), 0644) + require.NoError(t, err) + + compiler := NewCompiler() + err = compiler.CompileWorkflow(testFile) + require.NoError(t, err, "compilation with runtime: docker-sbx must succeed") + + lockContent, err := os.ReadFile(filepath.Join(workflowsDir, "test-docker-sbx.lock.yml")) + require.NoError(t, err) + lockStr := string(lockContent) + + // KVM check step must be present. + assert.Contains(t, lockStr, "Check KVM availability", "compiled workflow must include KVM availability check") + // Secrets check step must be present. + assert.Contains(t, lockStr, "Check Docker Hub secrets", "compiled workflow must include Docker Hub secrets check") + // docker-sbx install step must be present. + assert.Contains(t, lockStr, "Install docker-sbx", "compiled workflow must include docker-sbx install step") + // Auth and daemon step must be present. + assert.Contains(t, lockStr, "Start docker-sbx daemon", "compiled workflow must include sbx daemon step") + // Pre-flight step must be present. + assert.Contains(t, lockStr, "pre-flight smoke test", "compiled workflow must include pre-flight step") + // AWF install step must also be present. + assert.Contains(t, lockStr, "Install AWF binary", "compiled workflow must include AWF install step") + + // All docker-sbx steps must appear before AWF install step. + kvmPos := strings.Index(lockStr, "Check KVM availability") + awfPos := strings.Index(lockStr, "Install AWF binary") + assert.Less(t, kvmPos, awfPos, "KVM check step must precede AWF install step") + + // containerRuntime must NOT appear (docker-sbx is not an OCI runtime). + assert.NotContains(t, lockStr, `"containerRuntime"`, "containerRuntime must not appear for docker-sbx") + + // host.docker.internal must be in the allowed domains. + assert.Contains(t, lockStr, "host.docker.internal", "host.docker.internal must be in allowed domains") + + // --container-runtime sbx must appear in the AWF invocation. + assert.Contains(t, lockStr, "--container-runtime sbx", "AWF invocation must include --container-runtime sbx") +} diff --git a/pkg/workflow/firewall.go b/pkg/workflow/firewall.go index 9cc4fed7b23..e330917cbcf 100644 --- a/pkg/workflow/firewall.go +++ b/pkg/workflow/firewall.go @@ -117,11 +117,17 @@ 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. 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 { + return "" + } return string(agentConfig.Runtime) } @@ -134,11 +140,26 @@ func isGVisorRuntime(workflowData *WorkflowData) bool { return agentConfig.Runtime == AgentRuntimeGVisor } +// isDockerSbxRuntime returns true when the agent should run inside a Docker sbx +// microVM (KVM-based hypervisor isolation). +func isDockerSbxRuntime(workflowData *WorkflowData) bool { + agentConfig := getAgentConfig(workflowData) + if agentConfig == nil || agentConfig.Disabled { + return false + } + return agentConfig.Runtime == AgentRuntimeDockerSbx +} + func isAWFNetworkIsolationEnabled(workflowData *WorkflowData) bool { agentConfig := getAgentConfig(workflowData) if agentConfig == nil || agentConfig.Disabled { return false } + // docker-sbx always uses network isolation regardless of the sudo setting. + // The sudo flag is only for the install steps, not for network enforcement. + if agentConfig.Runtime == AgentRuntimeDockerSbx { + return true + } return agentConfig.NetworkIsolation } diff --git a/pkg/workflow/mcp_setup_generator.go b/pkg/workflow/mcp_setup_generator.go index 4c63158b4f4..444b12a03d5 100644 --- a/pkg/workflow/mcp_setup_generator.go +++ b/pkg/workflow/mcp_setup_generator.go @@ -591,6 +591,11 @@ func resolveMCPGatewayValues(workflowData *WorkflowData, gatewayConfig *MCPGatew if domain == "" { 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 + // (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" } else if isAWFNetworkIsolationEnabled(workflowData) { domain = "awmg-mcpg" } else { @@ -641,8 +646,12 @@ 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 + // also use host.docker.internal (not localhost) to reach the published gateway port. hostDomain := domain - if domain == "host.docker.internal" || isAWFNetworkIsolationEnabled(workflowData) { + if isDockerSbxRuntime(workflowData) { + hostDomain = "host.docker.internal" + } else if domain == "host.docker.internal" || isAWFNetworkIsolationEnabled(workflowData) { hostDomain = "localhost" } yaml.WriteString(" export MCP_GATEWAY_HOST_DOMAIN=\"" + hostDomain + "\"\n") @@ -719,9 +728,15 @@ func buildMCPGatewayContainerCommand(opts buildMCPGatewayContainerCommandOptions containerCmd.WriteString("docker run -i --rm") if isAWFNetworkIsolationEnabled(workflowData) { containerCmd.WriteString(" --network bridge") - // Publish the gateway port to the host so host-side clients (e.g. Gemini CLI) - // can reach the gateway at localhost:${MCP_GATEWAY_PORT}. - containerCmd.WriteString(" -p 127.0.0.1:${MCP_GATEWAY_PORT}:${MCP_GATEWAY_PORT}") + if isDockerSbxRuntime(workflowData) { + // docker-sbx: publish to 0.0.0.0 so the microVM 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 { + // Publish the gateway port to the host so host-side clients (e.g. Gemini CLI) + // can reach the gateway at localhost:${MCP_GATEWAY_PORT}. + containerCmd.WriteString(" -p 127.0.0.1:${MCP_GATEWAY_PORT}:${MCP_GATEWAY_PORT}") + } } else { containerCmd.WriteString(" --network host") } diff --git a/pkg/workflow/mcp_setup_generator_test.go b/pkg/workflow/mcp_setup_generator_test.go index 674e8b9d231..b4a76388287 100644 --- a/pkg/workflow/mcp_setup_generator_test.go +++ b/pkg/workflow/mcp_setup_generator_test.go @@ -617,6 +617,53 @@ tools: "MCP gateway host domain should be localhost in network isolation mode so host-side clients can connect") } +// TestMCPGatewayDockerCommandUsesDockerSbxGatewayRouting verifies that docker-sbx workflows +// publish the gateway on 0.0.0.0 and export host.docker.internal for both container-side and +// microVM-side clients. +func TestMCPGatewayDockerCommandUsesDockerSbxGatewayRouting(t *testing.T) { + frontmatter := `--- +on: workflow_dispatch +engine: copilot +sandbox: + agent: + runtime: docker-sbx + version: v0.28.0 + sudo: true +tools: + github: + mode: remote + toolsets: [repos] +--- + +# Test Docker SBX MCP Routing +` + + compiler := NewCompiler() + + tmpDir := t.TempDir() + inputFile := filepath.Join(tmpDir, "test.md") + + err := os.WriteFile(inputFile, []byte(frontmatter), 0644) + require.NoError(t, err, "Failed to write test input file") + + err = compiler.CompileWorkflow(inputFile) + require.NoError(t, err, "Compilation should succeed") + + outputFile := stringutil.MarkdownToLockFile(inputFile) + content, err := os.ReadFile(outputFile) + require.NoError(t, err, "Failed to read output file") + yamlStr := string(content) + + require.Contains(t, yamlStr, `docker run -i --rm --network bridge`, + "Docker command should use bridge networking in docker-sbx mode") + require.Contains(t, yamlStr, `-p 0.0.0.0:`, + "Docker command should publish the gateway to 0.0.0.0 for docker-sbx") + require.Contains(t, yamlStr, `export MCP_GATEWAY_DOMAIN="host.docker.internal"`, + "MCP gateway domain should use host.docker.internal in docker-sbx mode") + require.Contains(t, yamlStr, `export MCP_GATEWAY_HOST_DOMAIN="host.docker.internal"`, + "MCP gateway host domain should use host.docker.internal in docker-sbx mode") +} + // TestMCPGatewayDockerCommandAddsHostGatewayForMCPScriptsInBridgeMode verifies that when // mcp-scripts are configured in network-isolation (bridge) mode, the gateway container command // includes --add-host host.docker.internal:host-gateway so the gateway can reach the diff --git a/pkg/workflow/nodejs.go b/pkg/workflow/nodejs.go index bc04bb4904c..ae6a504ac30 100644 --- a/pkg/workflow/nodejs.go +++ b/pkg/workflow/nodejs.go @@ -159,6 +159,16 @@ func BuildNpmEngineInstallStepsWithAWF(npmSteps []GitHubActionStep, workflowData steps = append(steps, generateGVisorInstallStep()) } + // docker-sbx must be installed, authenticated, and smoke-tested BEFORE AWF + // starts so the microVM runtime is ready when AWF launches the agent. + if isDockerSbxRuntime(workflowData) { + steps = append(steps, generateDockerSbxKVMCheckStep()) + steps = append(steps, generateDockerSbxSecretsCheckStep()) + steps = append(steps, generateDockerSbxInstallStep()) + steps = append(steps, generateDockerSbxAuthAndDaemonStep()) + steps = append(steps, generateDockerSbxPreFlightStep()) + } + awfInstall := generateAWFInstallationStep(awfVersion, agentConfig) if len(awfInstall) > 0 { steps = append(steps, awfInstall) diff --git a/pkg/workflow/sandbox.go b/pkg/workflow/sandbox.go index 51a6ec0c937..113f8995c56 100644 --- a/pkg/workflow/sandbox.go +++ b/pkg/workflow/sandbox.go @@ -52,6 +52,12 @@ const ( // AgentRuntimeGVisor runs the agent container under gVisor's runsc runtime for // additional kernel-level isolation. Requires root access for installation. AgentRuntimeGVisor AgentRuntime = "gvisor" + + // AgentRuntimeDockerSbx runs the agent inside a Docker sbx microVM with + // hypervisor-level isolation (KVM). Infrastructure containers (Squid proxy, + // 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" ) // AgentSandboxConfig represents the agent sandbox configuration diff --git a/pkg/workflow/sandbox_validation.go b/pkg/workflow/sandbox_validation.go index 54f421ef03a..a45d57cc970 100644 --- a/pkg/workflow/sandbox_validation.go +++ b/pkg/workflow/sandbox_validation.go @@ -125,6 +125,53 @@ func validateSandboxConfig(workflowData *WorkflowData) error { sandboxValidationLog.Print("gVisor runtime configured -- topology check passed") } + // Validate docker-sbx runtime compatibility + if agentConfig != nil && agentConfig.Runtime == AgentRuntimeDockerSbx { + // docker-sbx is incompatible with ARC/DinD topology: sbx requires KVM which is + // not available on ARC DinD runners that typically lack nested virtualisation. + if isArcDindTopology(workflowData) { + return NewValidationError( + "sandbox.agent.runtime", + string(AgentRuntimeDockerSbx), + "docker-sbx is incompatible with runner.topology: arc-dind", + "docker-sbx requires KVM (nested virtualisation) which is typically unavailable "+ + "on ARC DinD runners. Remove sandbox.agent.runtime: docker-sbx or change runner.topology.", + ) + } + + // docker-sbx install step requires root access; sudo: true is mandatory. + if !agentConfig.SudoExplicitlyEnabled { + return NewValidationError( + "sandbox.agent.runtime", + string(AgentRuntimeDockerSbx), + "docker-sbx requires sandbox.agent.sudo: true", + "The docker-sbx install step needs root access to install docker-sbx and fix KVM "+ + "device permissions. Add 'sudo: true' to your sandbox.agent configuration:\n\n"+ + "sandbox:\n agent:\n id: awf\n runtime: docker-sbx\n sudo: true", + ) + } + + firewallConfig := getFirewallConfig(workflowData) + var configuredVersion string + if firewallConfig != nil { + configuredVersion = firewallConfig.Version + } + if !versionAtLeast(configuredVersion, string(constants.DefaultFirewallVersion), string(constants.AWFContainerRuntimeMinVersion)) { + effectiveVersion := configuredVersion + if effectiveVersion == "" { + effectiveVersion = string(constants.DefaultFirewallVersion) + } + return NewValidationError( + "sandbox.agent.runtime", + string(AgentRuntimeDockerSbx), + fmt.Sprintf("docker-sbx requires AWF %s or newer", constants.AWFContainerRuntimeMinVersion), + fmt.Sprintf("docker-sbx emits 'awf --container-runtime sbx', which is only supported in AWF %s+.\n\nThe effective AWF version is %s. Set firewall.version or sandbox.agent.version to %s or newer.", constants.AWFContainerRuntimeMinVersion, effectiveVersion, constants.AWFContainerRuntimeMinVersion), + ) + } + + sandboxValidationLog.Print("docker-sbx runtime configured -- topology, sudo, 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 diff --git a/pkg/workflow/strict_mode_sandbox_validation.go b/pkg/workflow/strict_mode_sandbox_validation.go index 22ccb3407d0..5af4da2bc45 100644 --- a/pkg/workflow/strict_mode_sandbox_validation.go +++ b/pkg/workflow/strict_mode_sandbox_validation.go @@ -45,7 +45,9 @@ func (c *Compiler) validateStrictSandboxCustomization(sandboxConfig *SandboxConf if agent := sandboxConfig.Agent; agent != nil { // sandbox.agent.sudo: true is deprecated regardless of strict mode. // It is an error in strict mode and a warning otherwise. - if agent.SudoExplicitlyEnabled { + // Exception: docker-sbx fundamentally requires sudo for its install step, so + // the deprecation message is suppressed — sudo: true is mandatory for that runtime. + if agent.SudoExplicitlyEnabled && agent.Runtime != AgentRuntimeDockerSbx { const sudoTrueMsg = "sandbox.agent.sudo: true re-enables host-access (sudo) mode. " + "The default is now sudo: false (network isolation). " + "Remove 'sudo: true' to use the secure default. " +