diff --git a/.changeset/patch-support-awf-path.md b/.changeset/patch-support-awf-path.md new file mode 100644 index 0000000000..faf851cc9c --- /dev/null +++ b/.changeset/patch-support-awf-path.md @@ -0,0 +1,10 @@ +--- +"gh-aw": patch +--- + +Support custom AWF installation path in firewall configuration. Adds support for specifying a custom AWF binary path in the workflow frontmatter `network.firewall.path` so users can validate and use their own AWF binary instead of downloading releases from GitHub. + +When `path` is set, the `version` field is ignored and AWF download is skipped. + +Affected files: `pkg/workflow/firewall.go`, `pkg/parser/schemas/main_workflow_schema.json`, `pkg/workflow/frontmatter_extraction.go`, `pkg/workflow/copilot_engine.go`. + diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index 3143d98a14..f50fd1da33 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -1713,6 +1713,10 @@ "type": "string", "description": "AWF log level (default: info). Valid values: debug, info, warn, error", "enum": ["debug", "info", "warn", "error"] + }, + "path": { + "type": "string", + "description": "Custom path to AWF binary. When specified, skips downloading AWF from GitHub releases. Absolute paths (starting with /) are used as-is; other paths are resolved relative to GITHUB_WORKSPACE." } }, "additionalProperties": false diff --git a/pkg/workflow/copilot_engine.go b/pkg/workflow/copilot_engine.go index 6c383a5af3..671163b33b 100644 --- a/pkg/workflow/copilot_engine.go +++ b/pkg/workflow/copilot_engine.go @@ -119,16 +119,25 @@ func (e *CopilotEngine) GetInstallationSteps(workflowData *WorkflowData) []GitHu srtInstall := generateSRTInstallationStep() steps = append(steps, srtInstall) } else if isFirewallEnabled(workflowData) { - // Install AWF after Node.js setup but before Copilot CLI installation + // Install or validate AWF after Node.js setup but before Copilot CLI installation firewallConfig := getFirewallConfig(workflowData) - var awfVersion string - if firewallConfig != nil { - awfVersion = firewallConfig.Version - } - // Install AWF binary - awfInstall := generateAWFInstallationStep(awfVersion) - steps = append(steps, awfInstall) + if firewallConfig != nil && firewallConfig.Path != "" { + // Custom path: Validate the binary exists and is executable + copilotLog.Printf("Using custom AWF binary path: %s", firewallConfig.Path) + validationStep := generateAWFPathValidationStep(firewallConfig.Path) + steps = append(steps, validationStep) + } else { + // Default: Download and install AWF from GitHub releases + var awfVersion string + if firewallConfig != nil { + awfVersion = firewallConfig.Version + } + + // Install AWF binary + awfInstall := generateAWFInstallationStep(awfVersion) + steps = append(steps, awfInstall) + } } // Add Copilot CLI installation step after sandbox installation @@ -332,11 +341,14 @@ func (e *CopilotEngine) GetExecutionSteps(workflowData *WorkflowData, logFile st awfArgs = append(awfArgs, firewallConfig.Args...) } + // Get AWF binary path (custom or default) + awfBinary := getAWFBinaryPath(firewallConfig) + // Build the full AWF command with proper argument separation // AWF v0.2.0 uses -- to separate AWF args from the actual command // The command arguments should be passed as individual shell arguments, not as a single string command = fmt.Sprintf(`set -o pipefail -sudo -E awf %s \ +sudo -E %s %s \ -- %s \ 2>&1 | tee %s @@ -348,7 +360,7 @@ if [ -n "$AGENT_LOGS_DIR" ] && [ -d "$AGENT_LOGS_DIR" ]; then sudo mkdir -p %s sudo mv "$AGENT_LOGS_DIR"/* %s || true sudo rmdir "$AGENT_LOGS_DIR" || true -fi`, shellJoinArgs(awfArgs), copilotCommand, shellEscapeArg(logFile), shellEscapeArg(logsFolder), shellEscapeArg(logsFolder), shellEscapeArg(logsFolder)) +fi`, shellEscapeArg(awfBinary), shellJoinArgs(awfArgs), copilotCommand, shellEscapeArg(logFile), shellEscapeArg(logsFolder), shellEscapeArg(logsFolder), shellEscapeArg(logsFolder)) } else { // Run copilot command without AWF wrapper command = fmt.Sprintf(`set -o pipefail @@ -976,6 +988,55 @@ func generateAWFInstallationStep(version string) GitHubActionStep { return GitHubActionStep(stepLines) } +// resolveAWFPath resolves the AWF binary path from custom path configuration +// Absolute paths (starting with /) are returned as-is +// Relative paths are resolved against GITHUB_WORKSPACE +func resolveAWFPath(customPath string) string { + if customPath == "" { + return "/usr/local/bin/awf" + } + + if strings.HasPrefix(customPath, "/") { + return customPath // Absolute path + } + + // Relative path - resolve against GITHUB_WORKSPACE + return fmt.Sprintf("${GITHUB_WORKSPACE}/%s", customPath) +} + +// getAWFBinaryPath returns the appropriate AWF binary path for execution +// If a custom path is configured, it returns the resolved path +// Otherwise, returns "awf" to use the binary from PATH (installed in installation step) +func getAWFBinaryPath(firewallConfig *FirewallConfig) string { + if firewallConfig != nil && firewallConfig.Path != "" { + return resolveAWFPath(firewallConfig.Path) + } + return "awf" // Default (in PATH from installation step) +} + +// generateAWFPathValidationStep creates a GitHub Actions step to validate a custom AWF binary +// This step verifies that the binary exists and is executable +func generateAWFPathValidationStep(customPath string) GitHubActionStep { + resolvedPath := resolveAWFPath(customPath) + + stepLines := []string{ + " - name: Validate custom AWF binary", + " run: |", + fmt.Sprintf(" echo \"Validating custom AWF binary at: %s\"", resolvedPath), + fmt.Sprintf(" if [ ! -f %s ]; then", shellEscapeArg(resolvedPath)), + fmt.Sprintf(" echo \"Error: AWF binary not found at %s\"", resolvedPath), + " exit 1", + " fi", + fmt.Sprintf(" if [ ! -x %s ]; then", shellEscapeArg(resolvedPath)), + fmt.Sprintf(" echo \"Error: AWF binary at %s is not executable\"", resolvedPath), + " exit 1", + " fi", + fmt.Sprintf(" %s --version", shellEscapeArg(resolvedPath)), + } + + return GitHubActionStep(stepLines) +} + // generateSRTSystemDepsStep creates a GitHub Actions step to install SRT system dependencies func generateSRTSystemDepsStep() GitHubActionStep { stepLines := []string{ diff --git a/pkg/workflow/firewall.go b/pkg/workflow/firewall.go index 0938d0664c..0248c23688 100644 --- a/pkg/workflow/firewall.go +++ b/pkg/workflow/firewall.go @@ -13,6 +13,7 @@ type FirewallConfig struct { Args []string `yaml:"args,omitempty"` // Additional arguments to pass to AWF LogLevel string `yaml:"log_level,omitempty"` // AWF log level (default: "info") CleanupScript string `yaml:"cleanup_script,omitempty"` // Cleanup script path (default: "./scripts/ci/cleanup.sh") + Path string `yaml:"path,omitempty"` // Custom AWF binary path (when specified, skips downloading AWF from GitHub releases) } // isFirewallEnabled checks if AWF firewall is enabled for the workflow diff --git a/pkg/workflow/firewall_custom_path_integration_test.go b/pkg/workflow/firewall_custom_path_integration_test.go new file mode 100644 index 0000000000..4b9f552549 --- /dev/null +++ b/pkg/workflow/firewall_custom_path_integration_test.go @@ -0,0 +1,205 @@ +package workflow + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestFirewallCustomPathIntegration tests that workflows with custom firewall paths compile correctly +func TestFirewallCustomPathIntegration(t *testing.T) { + t.Run("workflow with absolute path compiles correctly", func(t *testing.T) { + // Create a temporary test directory + testDir := t.TempDir() + workflowsDir := filepath.Join(testDir, ".github", "workflows") + err := os.MkdirAll(workflowsDir, 0755) + if err != nil { + t.Fatalf("Failed to create workflows directory: %v", err) + } + + // Create a test workflow with custom AWF path + workflowContent := `--- +name: Test Custom AWF Path (Absolute) +on: + workflow_dispatch: +engine: copilot +tools: + github: + mode: remote +network: + allowed: + - defaults + - "*.example.com" + firewall: + path: /custom/path/to/awf + log-level: debug +permissions: + issues: read + pull-requests: read +--- +Test workflow with custom AWF binary path. +` + workflowPath := filepath.Join(workflowsDir, "test-custom-awf-path.md") + err = os.WriteFile(workflowPath, []byte(workflowContent), 0644) + if err != nil { + t.Fatalf("Failed to write workflow file: %v", err) + } + + // Compile the workflow + compiler := NewCompiler(false, "", "test-custom-awf-path") + compiler.SetSkipValidation(true) + if err := compiler.CompileWorkflow(workflowPath); err != nil { + t.Fatalf("Failed to compile workflow: %v", err) + } + + // Read the compiled lock file + lockPath := strings.TrimSuffix(workflowPath, ".md") + ".lock.yml" + lockContent, err := os.ReadFile(lockPath) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + + lockStr := string(lockContent) + + // Verify the custom path is used + if !strings.Contains(lockStr, "Validate custom AWF binary") { + t.Error("Expected lock file to contain 'Validate custom AWF binary' step") + } + + if !strings.Contains(lockStr, "/custom/path/to/awf") { + t.Error("Expected lock file to contain custom AWF path '/custom/path/to/awf'") + } + + // Verify the install step is NOT present + if strings.Contains(lockStr, "Install awf binary") { + t.Error("Expected lock file NOT to contain 'Install awf binary' step when custom path is specified") + } + + // Verify the execution step uses the custom path + // Note: The path is shell-escaped only if it contains special chars + if !strings.Contains(lockStr, "sudo -E /custom/path/to/awf") && + !strings.Contains(lockStr, "sudo -E '/custom/path/to/awf'") { + t.Error("Expected execution step to use custom AWF path") + } + }) + + t.Run("workflow with relative path compiles correctly", func(t *testing.T) { + // Create a temporary test directory + testDir := t.TempDir() + workflowsDir := filepath.Join(testDir, ".github", "workflows") + err := os.MkdirAll(workflowsDir, 0755) + if err != nil { + t.Fatalf("Failed to create workflows directory: %v", err) + } + + // Create a test workflow with relative AWF path + workflowContent := `--- +name: Test Custom AWF Path (Relative) +on: + workflow_dispatch: +engine: copilot +tools: + github: + mode: remote +network: + allowed: + - defaults + firewall: + path: bin/my-custom-awf +permissions: + issues: read + pull-requests: read +--- +Test workflow with relative custom AWF binary path. +` + workflowPath := filepath.Join(workflowsDir, "test-relative-awf-path.md") + err = os.WriteFile(workflowPath, []byte(workflowContent), 0644) + if err != nil { + t.Fatalf("Failed to write workflow file: %v", err) + } + + // Compile the workflow + compiler := NewCompiler(false, "", "test-relative-awf-path") + compiler.SetSkipValidation(true) + if err := compiler.CompileWorkflow(workflowPath); err != nil { + t.Fatalf("Failed to compile workflow: %v", err) + } + + // Read the compiled lock file + lockPath := strings.TrimSuffix(workflowPath, ".md") + ".lock.yml" + lockContent, err := os.ReadFile(lockPath) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + + lockStr := string(lockContent) + + // Verify the relative path is resolved against GITHUB_WORKSPACE + if !strings.Contains(lockStr, "${GITHUB_WORKSPACE}/bin/my-custom-awf") { + t.Error("Expected lock file to contain resolved relative path '${GITHUB_WORKSPACE}/bin/my-custom-awf'") + } + }) + + t.Run("workflow without custom path uses default installation", func(t *testing.T) { + // Create a temporary test directory + testDir := t.TempDir() + workflowsDir := filepath.Join(testDir, ".github", "workflows") + err := os.MkdirAll(workflowsDir, 0755) + if err != nil { + t.Fatalf("Failed to create workflows directory: %v", err) + } + + // Create a test workflow without custom AWF path + workflowContent := `--- +name: Test Default AWF Installation +on: + workflow_dispatch: +engine: copilot +tools: + github: + mode: remote +network: + allowed: + - defaults + firewall: + log-level: info +permissions: + issues: read + pull-requests: read +--- +Test workflow with default AWF installation. +` + workflowPath := filepath.Join(workflowsDir, "test-default-awf.md") + err = os.WriteFile(workflowPath, []byte(workflowContent), 0644) + if err != nil { + t.Fatalf("Failed to write workflow file: %v", err) + } + + // Compile the workflow + compiler := NewCompiler(false, "", "test-default-awf") + compiler.SetSkipValidation(true) + if err := compiler.CompileWorkflow(workflowPath); err != nil { + t.Fatalf("Failed to compile workflow: %v", err) + } + + // Read the compiled lock file + lockPath := strings.TrimSuffix(workflowPath, ".md") + ".lock.yml" + lockContent, err := os.ReadFile(lockPath) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + + lockStr := string(lockContent) + + // Verify the install step is present + if !strings.Contains(lockStr, "Install awf binary") { + t.Error("Expected lock file to contain 'Install awf binary' step when no custom path is specified") + } + + // Verify the validation step is NOT present + if strings.Contains(lockStr, "Validate custom AWF binary") { + t.Error("Expected lock file NOT to contain 'Validate custom AWF binary' step when no custom path") + } + }) +} diff --git a/pkg/workflow/firewall_custom_path_test.go b/pkg/workflow/firewall_custom_path_test.go new file mode 100644 index 0000000000..d7d4ccf847 --- /dev/null +++ b/pkg/workflow/firewall_custom_path_test.go @@ -0,0 +1,373 @@ +package workflow + +import ( + "strings" + "testing" +) + +// TestResolveAWFPath tests the resolveAWFPath helper function +func TestResolveAWFPath(t *testing.T) { + tests := []struct { + name string + customPath string + expected string + }{ + { + name: "empty path returns default", + customPath: "", + expected: "/usr/local/bin/awf", + }, + { + name: "absolute path is returned as-is", + customPath: "/custom/path/to/awf", + expected: "/custom/path/to/awf", + }, + { + name: "relative path is resolved against GITHUB_WORKSPACE", + customPath: "bin/awf", + expected: "${GITHUB_WORKSPACE}/bin/awf", + }, + { + name: "relative path with subdirectory", + customPath: "tools/binaries/awf-custom", + expected: "${GITHUB_WORKSPACE}/tools/binaries/awf-custom", + }, + { + name: "root relative path", + customPath: "awf", + expected: "${GITHUB_WORKSPACE}/awf", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := resolveAWFPath(tt.customPath) + if result != tt.expected { + t.Errorf("resolveAWFPath(%q) = %q, expected %q", tt.customPath, result, tt.expected) + } + }) + } +} + +// TestGetAWFBinaryPath tests the getAWFBinaryPath helper function +func TestGetAWFBinaryPath(t *testing.T) { + tests := []struct { + name string + firewallConfig *FirewallConfig + expected string + }{ + { + name: "nil config returns default", + firewallConfig: nil, + expected: "awf", + }, + { + name: "empty path returns default", + firewallConfig: &FirewallConfig{Path: ""}, + expected: "awf", + }, + { + name: "custom absolute path is resolved", + firewallConfig: &FirewallConfig{Path: "/custom/path/to/awf"}, + expected: "/custom/path/to/awf", + }, + { + name: "custom relative path is resolved", + firewallConfig: &FirewallConfig{Path: "bin/awf"}, + expected: "${GITHUB_WORKSPACE}/bin/awf", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := getAWFBinaryPath(tt.firewallConfig) + if result != tt.expected { + t.Errorf("getAWFBinaryPath() = %q, expected %q", result, tt.expected) + } + }) + } +} + +// TestGenerateAWFPathValidationStep tests the generateAWFPathValidationStep function +func TestGenerateAWFPathValidationStep(t *testing.T) { + tests := []struct { + name string + customPath string + expectedContains []string + unexpectedContains []string + }{ + { + name: "absolute path validation step", + customPath: "/custom/path/to/awf", + expectedContains: []string{ + "Validate custom AWF binary", + "/custom/path/to/awf", + "if [ ! -f", + "if [ ! -x", + "--version", + }, + }, + { + name: "relative path validation step", + customPath: "bin/awf", + expectedContains: []string{ + "Validate custom AWF binary", + "${GITHUB_WORKSPACE}/bin/awf", + "if [ ! -f", + "if [ ! -x", + "--version", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + step := generateAWFPathValidationStep(tt.customPath) + stepContent := strings.Join(step, "\n") + + for _, expected := range tt.expectedContains { + if !strings.Contains(stepContent, expected) { + t.Errorf("Expected step to contain %q, but it didn't.\nStep content:\n%s", expected, stepContent) + } + } + + for _, unexpected := range tt.unexpectedContains { + if strings.Contains(stepContent, unexpected) { + t.Errorf("Expected step NOT to contain %q, but it did.\nStep content:\n%s", unexpected, stepContent) + } + } + }) + } +} + +// TestCustomPathInstallationSteps tests that GetInstallationSteps generates the correct steps +// when a custom path is specified +func TestCustomPathInstallationSteps(t *testing.T) { + t.Run("custom path generates validation step instead of install step", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ + ID: "copilot", + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{ + Enabled: true, + Path: "/custom/awf/binary", + }, + }, + } + + engine := NewCopilotEngine() + steps := engine.GetInstallationSteps(workflowData) + + // Check that steps are generated + if len(steps) == 0 { + t.Fatal("Expected at least one installation step") + } + + // Join all steps to check content + var allStepsContent string + for _, step := range steps { + allStepsContent += strings.Join(step, "\n") + "\n" + } + + // Should contain validation step + if !strings.Contains(allStepsContent, "Validate custom AWF binary") { + t.Error("Expected 'Validate custom AWF binary' step") + } + + // Should NOT contain install step + if strings.Contains(allStepsContent, "Install awf binary") { + t.Error("Should NOT contain 'Install awf binary' step when custom path is specified") + } + + // Should NOT contain curl download + if strings.Contains(allStepsContent, "curl -L https://github.com/githubnext/gh-aw-firewall") { + t.Error("Should NOT contain curl download when custom path is specified") + } + }) + + t.Run("no custom path generates install step", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ + ID: "copilot", + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{ + Enabled: true, + }, + }, + } + + engine := NewCopilotEngine() + steps := engine.GetInstallationSteps(workflowData) + + // Join all steps to check content + var allStepsContent string + for _, step := range steps { + allStepsContent += strings.Join(step, "\n") + "\n" + } + + // Should contain install step + if !strings.Contains(allStepsContent, "Install awf binary") { + t.Error("Expected 'Install awf binary' step") + } + + // Should NOT contain validation step + if strings.Contains(allStepsContent, "Validate custom AWF binary") { + t.Error("Should NOT contain 'Validate custom AWF binary' step when no custom path") + } + }) +} + +// TestCustomPathExecutionSteps tests that GetExecutionSteps uses the correct AWF binary path +func TestCustomPathExecutionSteps(t *testing.T) { + t.Run("custom path uses resolved path in execution", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ + ID: "copilot", + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{ + Enabled: true, + Path: "/custom/awf/binary", + }, + }, + } + + engine := NewCopilotEngine() + steps := engine.GetExecutionSteps(workflowData, "test.log") + + if len(steps) == 0 { + t.Fatal("Expected at least one execution step") + } + + stepContent := strings.Join(steps[0], "\n") + + // Should use the custom binary path + if !strings.Contains(stepContent, "/custom/awf/binary") { + t.Error("Expected command to use custom AWF binary path '/custom/awf/binary'") + } + }) + + t.Run("relative path is resolved against GITHUB_WORKSPACE in execution", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ + ID: "copilot", + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{ + Enabled: true, + Path: "bin/awf", + }, + }, + } + + engine := NewCopilotEngine() + steps := engine.GetExecutionSteps(workflowData, "test.log") + + if len(steps) == 0 { + t.Fatal("Expected at least one execution step") + } + + stepContent := strings.Join(steps[0], "\n") + + // Should use the resolved relative path + if !strings.Contains(stepContent, "${GITHUB_WORKSPACE}/bin/awf") { + t.Error("Expected command to use resolved path '${GITHUB_WORKSPACE}/bin/awf'") + } + }) + + t.Run("no custom path uses default awf binary", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ + ID: "copilot", + }, + NetworkPermissions: &NetworkPermissions{ + Firewall: &FirewallConfig{ + Enabled: true, + }, + }, + } + + engine := NewCopilotEngine() + steps := engine.GetExecutionSteps(workflowData, "test.log") + + if len(steps) == 0 { + t.Fatal("Expected at least one execution step") + } + + stepContent := strings.Join(steps[0], "\n") + + // Should use default 'awf' command (from PATH installed in installation step) + // The command is shell-escaped so it appears as 'awf' in the output + if !strings.Contains(stepContent, "awf --env-all") { + t.Error("Expected command to use default 'awf' binary from PATH") + } + }) +} + +// TestFirewallConfigPathExtraction tests that the path field is correctly extracted from frontmatter +func TestFirewallConfigPathExtraction(t *testing.T) { + compiler := &Compiler{} + + tests := []struct { + name string + firewallObj any + expectedPath string + }{ + { + name: "path is extracted from object", + firewallObj: map[string]any{ + "path": "/custom/path/to/awf", + }, + expectedPath: "/custom/path/to/awf", + }, + { + name: "relative path is extracted", + firewallObj: map[string]any{ + "path": "bin/awf", + }, + expectedPath: "bin/awf", + }, + { + name: "path with other fields", + firewallObj: map[string]any{ + "path": "/custom/awf", + "log-level": "debug", + "version": "v1.0.0", + }, + expectedPath: "/custom/awf", + }, + { + name: "no path field", + firewallObj: map[string]any{}, + expectedPath: "", + }, + { + name: "boolean firewall (no path)", + firewallObj: true, + expectedPath: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config := compiler.extractFirewallConfig(tt.firewallObj) + if config == nil { + if tt.expectedPath != "" { + t.Errorf("Expected config with path %q, got nil config", tt.expectedPath) + } + return + } + + if config.Path != tt.expectedPath { + t.Errorf("Expected path %q, got %q", tt.expectedPath, config.Path) + } + }) + } +} diff --git a/pkg/workflow/frontmatter_extraction.go b/pkg/workflow/frontmatter_extraction.go index 4c6e5bd8d7..a0b11cefa5 100644 --- a/pkg/workflow/frontmatter_extraction.go +++ b/pkg/workflow/frontmatter_extraction.go @@ -689,6 +689,13 @@ func (c *Compiler) extractFirewallConfig(firewall any) *FirewallConfig { } } + // Extract path if present + if path, hasPath := firewallObj["path"]; hasPath { + if pathStr, ok := path.(string); ok { + config.Path = pathStr + } + } + return config }