Skip to content
Closed
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
10 changes: 10 additions & 0 deletions .changeset/patch-support-awf-path.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
81 changes: 71 additions & 10 deletions pkg/workflow/copilot_engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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{
Expand Down
1 change: 1 addition & 0 deletions pkg/workflow/firewall.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
205 changes: 205 additions & 0 deletions pkg/workflow/firewall_custom_path_integration_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
})
}
Loading
Loading