Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pkg/constants/version_constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ const DefaultGitHubMCPServerVersion Version = "v1.5.0"
//
// The first recompile regenerates all lock files using the new version; the second recompile
// refreshes the container SHA pins that were resolved during the first pass.
const DefaultFirewallVersion Version = "v0.27.20"
const DefaultFirewallVersion Version = "v0.27.22"

// AWFExcludeEnvMinVersion is the minimum AWF version that supports the --exclude-env flag.
// Workflows pinning an older AWF version must not emit --exclude-env flags or the run will fail.
Expand Down
14 changes: 14 additions & 0 deletions pkg/workflow/awf_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,20 @@ fi`,
awfArcDindHomePathExpr, awfArcDindHomePathExpr,
awfArcDindRootPathExpr+"/sandbox/agent", awfArcDindRootPathExpr+"/sandbox/agent",
)
// Pre-create the rw mount source directories. AWF validates that mount source
// paths exist before starting containers, so these must be created on the host
// before the AWF invocation. The parent ${RUNNER_TEMP}/gh-aw/ already exists
// (created by actions/setup), but the subdirectories may not.
arcDindDockerHostProbe += fmt.Sprintf("\nmkdir -p \"%s\" \"%s\"",
awfArcDindHomePathExpr,
awfArcDindRootPathExpr+"/sandbox/agent",
)
// Copy prompt files to daemon-visible path. On ARC/DinD, /tmp/gh-aw/ is NOT
// accessible to the Docker daemon. The activation job writes prompts to
// /tmp/gh-aw/aw-prompts/, so we copy them to ${RUNNER_TEMP}/gh-aw/aw-prompts/.
arcDindDockerHostProbe += fmt.Sprintf("\nif [ -d /tmp/gh-aw/aw-prompts ]; then cp -a /tmp/gh-aw/aw-prompts \"%s/aw-prompts\"; fi",
awfArcDindRootPathExpr,
)
}

// Generate a JSON config file and reference it via --config "${RUNNER_TEMP}/gh-aw/awf-config.json".
Expand Down
28 changes: 28 additions & 0 deletions pkg/workflow/awf_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2068,3 +2068,31 @@ func TestGetAWFCommandPrefixNetworkIsolation(t *testing.T) {
assert.Equal(t, "custom-awf", cmd, "Custom command should take precedence over sudo rootless mode")
})
}

func TestBuildAWFCommand_ArcDindPreCreatesMountDirs(t *testing.T) {
config := AWFCommandConfig{
EngineName: "copilot",
EngineCommand: "copilot run",
LogFile: "/tmp/log.txt",
PathSetup: "export PATH=/usr/bin:$PATH",
WorkflowData: &WorkflowData{
Name: "Test",
AI: "copilot",
MarkdownContent: "test",
RunnerConfig: &RunnerConfig{Topology: RunnerTopologyArcDind},
SandboxConfig: &SandboxConfig{
Agent: &AgentSandboxConfig{ID: "awf"},
},
},
}

command := BuildAWFCommand(config)

// Verify mount source directories are pre-created before AWF invocation
assert.Contains(t, command, `mkdir -p "${RUNNER_TEMP}/gh-aw/home" "${RUNNER_TEMP}/gh-aw/sandbox/agent"`,
"should pre-create rw mount source directories for arc-dind")

// Verify the mounts themselves are present
assert.Contains(t, command, `--mount "${RUNNER_TEMP}/gh-aw/home:${RUNNER_TEMP}/gh-aw/home:rw"`)
assert.Contains(t, command, `--mount "${RUNNER_TEMP}/gh-aw/sandbox/agent:${RUNNER_TEMP}/gh-aw/sandbox/agent:rw"`)
}
43 changes: 36 additions & 7 deletions pkg/workflow/compiler_yaml_main_job.go
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,26 @@ func (c *Compiler) generateRuntimeAndWorkspaceSetupSteps(yaml *strings.Builder,

}

// ARC/DinD: ensure Node.js is at a daemon-visible path.
// On ARC runners, setup-node may find a pre-cached node at the original tool cache
// (e.g. /home/runner/_work/_tool/node/...) which is NOT under RUNNER_TEMP and therefore
// not bind-mounted into the AWF container. This step copies node to the redirected
// tool cache if needed and sets GH_AW_NODE_BIN for the AWF entrypoint.
if isArcDindTopology(data) {
yaml.WriteString(" - name: Ensure Node.js is at daemon-visible path\n")
yaml.WriteString(" run: |\n")
yaml.WriteString(" NODE_BIN=\"$(command -v node)\"\n")
yaml.WriteString(" NODE_PREFIX=\"$(dirname \"$(dirname \"$NODE_BIN\")\")\"\n")
yaml.WriteString(" TOOL_DEST=\"${RUNNER_TEMP}/gh-aw/tool-cache/node\"\n")
yaml.WriteString(" if [[ \"$NODE_PREFIX\" != \"${RUNNER_TEMP}\"/* ]]; then\n")
yaml.WriteString(" echo \"Node at $NODE_PREFIX is not under RUNNER_TEMP, copying to $TOOL_DEST\"\n")
yaml.WriteString(" mkdir -p \"$TOOL_DEST\"\n")
yaml.WriteString(" cp -a \"$NODE_PREFIX\"/. \"$TOOL_DEST\"/\n")
yaml.WriteString(" echo \"${TOOL_DEST}/bin\" >> \"$GITHUB_PATH\"\n")
yaml.WriteString(" echo \"GH_AW_NODE_BIN=${TOOL_DEST}/bin/node\" >> \"$GITHUB_ENV\"\n")
yaml.WriteString(" fi\n")
}

// Create /tmp/gh-aw/ base directory for all temporary files
// This must be created before custom steps so they can use the temp directory
yaml.WriteString(" - name: Create gh-aw temp directory\n")
Expand Down Expand Up @@ -668,13 +688,22 @@ func (c *Compiler) collectArtifactPaths(data *WorkflowData, engine CodingAgentEn
// Include firewall audit/observability logs in the unified agent artifact
// so all agent job outputs ship as a single artifact (AWF v0.25.0+).
if isFirewallEnabled(data) {
paths = append(paths, constants.AWFConfigFilePath)
paths = append(paths, constants.AWFProxyLogsDir+"/")
paths = append(paths, constants.AWFAuditDir+"/")
// Include the AWF /reflect payload persisted by the agent harness.
// Co-located under /tmp/gh-aw/sandbox/firewall/ so the existing
// chmod -R a+rX step covers its permissions before upload.
paths = append(paths, constants.AWFReflectFilePath)
if isArcDindTopology(data) {
// On ARC/DinD, logs are under ${{ runner.temp }}/gh-aw (daemon-visible path).
// Use ${{ runner.temp }} because `with:` blocks expand Actions expressions, not shell vars.
paths = append(paths, "${{ runner.temp }}/gh-aw/awf-config.json")
paths = append(paths, "${{ runner.temp }}/gh-aw/sandbox/firewall/logs/")
paths = append(paths, "${{ runner.temp }}/gh-aw/sandbox/firewall/audit/")
paths = append(paths, "${{ runner.temp }}/gh-aw/sandbox/firewall/awf-reflect.json")
} else {
paths = append(paths, constants.AWFConfigFilePath)
paths = append(paths, constants.AWFProxyLogsDir+"/")
paths = append(paths, constants.AWFAuditDir+"/")
// Include the AWF /reflect payload persisted by the agent harness.
// Co-located under /tmp/gh-aw/sandbox/firewall/ so the existing
// chmod -R a+rX step covers its permissions before upload.
paths = append(paths, constants.AWFReflectFilePath)
}
}

return paths
Expand Down
9 changes: 7 additions & 2 deletions pkg/workflow/copilot_engine_execution.go
Original file line number Diff line number Diff line change
Expand Up @@ -365,12 +365,17 @@ func (e *CopilotEngine) buildCopilotBaseCommand(workflowData *WorkflowData, copi
if workflowData.EngineConfig != nil && workflowData.EngineConfig.CopilotSDK {
return e.buildCopilotSDKCommand(execPrefix, copilotArgs)
}
// On ARC/DinD, /tmp/gh-aw is not daemon-visible; prompts are copied to ${RUNNER_TEMP}/gh-aw/
promptFilePath := "/tmp/gh-aw/aw-prompts/prompt.txt"
if isArcDindTopology(workflowData) {
promptFilePath = "${RUNNER_TEMP}/gh-aw/aw-prompts/prompt.txt"
}
if isFirewallEnabled(workflowData) {
// Sandbox mode: add workspace dir and pass prompt file path directly
return fmt.Sprintf(`%s %s --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt`, execPrefix, shellJoinArgs(copilotArgs)), ""
return fmt.Sprintf(`%s %s --add-dir "${GITHUB_WORKSPACE}" --prompt-file %s`, execPrefix, shellJoinArgs(copilotArgs), promptFilePath), ""
}
// Non-sandbox mode: pass prompt file path directly
return fmt.Sprintf(`%s %s --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt`, execPrefix, shellJoinArgs(copilotArgs)), ""
return fmt.Sprintf(`%s %s --prompt-file %s`, execPrefix, shellJoinArgs(copilotArgs), promptFilePath), ""
}

func (e *CopilotEngine) buildCopilotSDKCommand(execPrefix string, copilotArgs []string) (string, string) {
Expand Down
17 changes: 17 additions & 0 deletions pkg/workflow/copilot_engine_installation.go
Original file line number Diff line number Diff line change
Expand Up @@ -313,3 +313,20 @@ func generateAWFInstallationStep(version string, agentConfig *AgentSandboxConfig

return GitHubActionStep(stepLines)
}

// generateDockerComposeInstallStep creates a step that installs the Docker Compose
// CLI plugin. ARC/DinD runners may not have Docker Compose pre-installed, but AWF
// requires it to orchestrate the squid-proxy, agent, and api-proxy containers.
func generateDockerComposeInstallStep() GitHubActionStep {
return GitHubActionStep([]string{
" - name: Install Docker Compose plugin",
" run: |",
` export DOCKER_CONFIG="${DOCKER_CONFIG:-$HOME/.docker}"`,
` mkdir -p "$DOCKER_CONFIG/cli-plugins"`,
` arch="$(uname -m)"`,
` case "$arch" in x86_64|amd64) arch="x86_64" ;; aarch64|arm64) arch="aarch64" ;; *) echo "Unsupported architecture for docker compose plugin: $arch" >&2; exit 1 ;; esac`,
` curl -fsSL "https://github.com/docker/compose/releases/download/v2.36.2/docker-compose-linux-$arch" -o "$DOCKER_CONFIG/cli-plugins/docker-compose"`,
` chmod +x "$DOCKER_CONFIG/cli-plugins/docker-compose"`,
` docker compose version`,
})
}
13 changes: 13 additions & 0 deletions pkg/workflow/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,19 @@ func collectDockerImages(tools map[string]any, workflowData *WorkflowData, actio
dockerLog.Printf("Added AWF cli-proxy sidecar container: %s", cliProxyImage)
}
}

// Add build-tools sysroot image for ARC/DinD topology.
// AWF uses this as an init container to populate the sysroot volume with
// system binaries (gcc, make, libraries) that are invisible on the DinD daemon's FS.
if isArcDindTopology(workflowData) {
buildToolsImage := constants.DefaultFirewallRegistry + "/build-tools:" + awfImageTag
if !setutil.Contains(imageSet, buildToolsImage) {
images = append(images, buildToolsImage)
imageSet[buildToolsImage] = struct {
}{}
dockerLog.Printf("Added AWF build-tools sysroot container for arc-dind: %s", buildToolsImage)
}
Comment on lines +135 to +145

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid observation. The embedded action_pins.json is synced from .github/aw/actions-lock.json via make sync-action-pins, which is populated during the gh aw update / release cycle. The build-tools image has never been included in that cycle because it's a new image type — the existing pinned versions only cover agent, squid, api-proxy, cli-proxy, and agent-act. The release workflow (release.md) validates that all container images referenced by compiled workflows have SHA pins before releasing, so the first release that includes this will require the pins to be present in actions-lock.json. Adding the digest pins requires registry access to fetch them — that needs to happen as part of the gh aw update pin-refresh process, which is the appropriate path for all firewall images. Flagging this as a prerequisite for the first release that ships arc-dind support.

}
Comment on lines +135 to +146

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added TestCollectDockerImages_BuildToolsForArcDind in pkg/workflow/docker_build_tools_test.go (commit $(git rev-parse --short HEAD)). It covers four cases: image included when arc-dind + firewall enabled, excluded without arc-dind topology, excluded when firewall is disabled, and correct tag used with a custom AWF version.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added TestCollectDockerImages_BuildToolsForArcDind in pkg/workflow/docker_build_tools_test.go (18d8420). Covers: image included when arc-dind + firewall enabled, excluded without arc-dind topology, excluded when firewall is disabled, and correct tag used with a custom AWF version.

}

// Collect sandbox.mcp container (MCP gateway)
Expand Down
91 changes: 91 additions & 0 deletions pkg/workflow/docker_build_tools_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
//go:build !integration

package workflow

import (
"slices"
"strings"
"testing"

"github.com/github/gh-aw/pkg/constants"
)

func TestCollectDockerImages_BuildToolsForArcDind(t *testing.T) {
// Use a version without "v" prefix — getAWFImageTag strips it
awfImageTag := "0.27.13"

t.Run("includes build-tools image when topology is arc-dind and firewall enabled", func(t *testing.T) {
workflowData := &WorkflowData{
AI: "claude",
NetworkPermissions: &NetworkPermissions{
Firewall: &FirewallConfig{
Enabled: true,
Version: awfImageTag,
},
},
RunnerConfig: &RunnerConfig{Topology: RunnerTopologyArcDind},
}

images := collectDockerImages(nil, workflowData, ActionModeRelease)

buildToolsImage := constants.DefaultFirewallRegistry + "/build-tools:" + awfImageTag
if !slices.Contains(images, buildToolsImage) {
t.Errorf("Expected build-tools image %q in collected images for arc-dind, got: %v", buildToolsImage, images)
}
})

t.Run("excludes build-tools image when topology is not arc-dind", func(t *testing.T) {
workflowData := &WorkflowData{
AI: "claude",
NetworkPermissions: &NetworkPermissions{
Firewall: &FirewallConfig{
Enabled: true,
Version: awfImageTag,
},
},
}

images := collectDockerImages(nil, workflowData, ActionModeRelease)

buildToolsImage := constants.DefaultFirewallRegistry + "/build-tools:" + awfImageTag
if slices.Contains(images, buildToolsImage) {
t.Errorf("Did not expect build-tools image %q in collected images without arc-dind topology, got: %v", buildToolsImage, images)
}
})

t.Run("excludes build-tools image when firewall is disabled even with arc-dind topology", func(t *testing.T) {
workflowData := &WorkflowData{
AI: "claude",
RunnerConfig: &RunnerConfig{Topology: RunnerTopologyArcDind},
}

images := collectDockerImages(nil, workflowData, ActionModeRelease)

for _, img := range images {
if strings.Contains(img, "/build-tools:") {
t.Errorf("Did not expect any build-tools image when firewall is disabled, got: %v", images)
}
}
})

t.Run("build-tools image uses the correct AWF image tag", func(t *testing.T) {
customTag := "0.27.5"
workflowData := &WorkflowData{
AI: "copilot",
NetworkPermissions: &NetworkPermissions{
Firewall: &FirewallConfig{
Enabled: true,
Version: customTag,
},
},
RunnerConfig: &RunnerConfig{Topology: RunnerTopologyArcDind},
}

images := collectDockerImages(nil, workflowData, ActionModeRelease)

expectedImage := constants.DefaultFirewallRegistry + "/build-tools:" + customTag
if !slices.Contains(images, expectedImage) {
t.Errorf("Expected build-tools image %q with custom tag, got: %v", expectedImage, images)
}
})
}
21 changes: 16 additions & 5 deletions pkg/workflow/engine_firewall_support.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,11 +97,15 @@ func (c *Compiler) checkFirewallDisable(networkPermissions *NetworkPermissions)
}

// generateSquidLogsUploadStep creates a GitHub Actions step to upload Squid logs as artifact.
func generateSquidLogsUploadStep(workflowName string) GitHubActionStep {
func generateSquidLogsUploadStep(workflowName string, workflowData *WorkflowData) GitHubActionStep {
sanitizedName := strings.ToLower(SanitizeWorkflowName(workflowName))
artifactName := "firewall-logs-" + sanitizedName
// Firewall logs are now at a known location in the sandbox folder structure
// Firewall logs location: /tmp/gh-aw on standard runners, ${{ runner.temp }}/gh-aw on ARC/DinD.
// Use ${{ runner.temp }} (Actions expression) because `with:` blocks don't expand shell vars.
firewallLogsDir := constants.AWFProxyLogsDir + "/"
if isArcDindTopology(workflowData) {
firewallLogsDir = "${{ runner.temp }}/gh-aw/sandbox/firewall/logs/"
}

stepLines := []string{
" - name: Upload Firewall Logs",
Expand All @@ -119,16 +123,23 @@ func generateSquidLogsUploadStep(workflowName string) GitHubActionStep {

// generateFirewallLogParsingStep creates a GitHub Actions step to parse firewall logs and create step summary.
func generateFirewallLogParsingStep(workflowName string, workflowData *WorkflowData) GitHubActionStep {
// Firewall logs are at a known location in the sandbox folder structure
// Firewall logs are at a known location in the sandbox folder structure.
// On ARC/DinD, /tmp/gh-aw is not daemon-visible so logs land under runner.temp/gh-aw.
firewallLogsDir := constants.AWFProxyLogsDir
// For env: blocks, use ${{ runner.temp }} (Actions expression) since shell vars aren't expanded there.
firewallLogsDirEnv := constants.AWFProxyLogsDir
if isArcDindTopology(workflowData) {
firewallLogsDir = "${RUNNER_TEMP}/gh-aw/sandbox/firewall/logs"
firewallLogsDirEnv = "${{ runner.temp }}/gh-aw/sandbox/firewall/logs"
}
firewallDir := path.Dir(firewallLogsDir)

stepLines := []string{
" - name: Print firewall logs",
" if: always()",
" continue-on-error: true",
" env:",
" AWF_LOGS_DIR: " + firewallLogsDir,
" AWF_LOGS_DIR: " + firewallLogsDirEnv,
" run: |",
}

Expand Down Expand Up @@ -164,7 +175,7 @@ func defaultGetSquidLogsSteps(workflowData *WorkflowData, debugLog *logger.Logge
if isFirewallEnabled(workflowData) {
debugLog.Printf("Adding Squid logs upload and parsing steps for workflow: %s", workflowData.Name)

squidLogsUpload := generateSquidLogsUploadStep(workflowData.Name)
squidLogsUpload := generateSquidLogsUploadStep(workflowData.Name, workflowData)
steps = append(steps, squidLogsUpload)

// Add firewall log parsing step to create step summary
Expand Down
20 changes: 20 additions & 0 deletions pkg/workflow/nodejs.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,12 +157,32 @@ func BuildNpmEngineInstallStepsWithAWF(npmSteps []GitHubActionStep, workflowData
if len(awfInstall) > 0 {
steps = append(steps, awfInstall)
}

// Install Docker Compose plugin for ARC/DinD runners where it may not be pre-installed.
if isArcDindTopology(workflowData) {
steps = append(steps, generateDockerComposeInstallStep())
}
}

if len(npmSteps) > 1 {
steps = append(steps, npmSteps[1:]...) // CLI installation and subsequent steps
}

// Copy Copilot CLI to daemon-visible path for ARC/DinD.
// The install script puts copilot at /usr/local/bin/copilot which is inside the
// sysroot image — not the runner's filesystem. On ARC/DinD, the AWF command
// references ${RUNNER_TEMP}/gh-aw/bin/copilot which is daemon-visible.
if isFirewallEnabled(workflowData) && isArcDindTopology(workflowData) {
copyStep := GitHubActionStep([]string{
" - name: Copy Copilot CLI to daemon-visible path",
" run: |",
" mkdir -p \"${RUNNER_TEMP}/gh-aw/bin\"",
" cp /usr/local/bin/copilot \"${RUNNER_TEMP}/gh-aw/bin/copilot\"",
" chmod +x \"${RUNNER_TEMP}/gh-aw/bin/copilot\"",
})
steps = append(steps, copyStep)
}

return steps
}

Expand Down
Loading
Loading