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
124 changes: 117 additions & 7 deletions pkg/workflow/copilot_engine_installation.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,23 @@
package workflow

import (
"path/filepath"
"strings"

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

var copilotInstallLog = logger.New("workflow:copilot_engine_installation")

type copilotSDKInstallSpec struct {
runtimeID string
stepName string
command string
}

const workspaceCommandPrefix = `cd "${GITHUB_WORKSPACE}" && `

// GetSecretValidationStep returns the secret validation step for the Copilot engine.
// Returns an empty step if:
// - permissions.copilot-requests is set to write (uses GitHub Actions token instead), or
Expand Down Expand Up @@ -61,14 +72,23 @@ func (e *CopilotEngine) GetSecretValidationStep(workflowData *WorkflowData) GitH
// runtime installation steps required for harness execution.
func (e *CopilotEngine) GetInstallationSteps(workflowData *WorkflowData) []GitHubActionStep {
copilotInstallLog.Printf("Generating installation steps for Copilot engine: workflow=%s", workflowData.Name)
sdkInstallStep := buildCopilotSDKInstallStep(workflowData)

// Skip standard Copilot CLI installation if custom command is specified.
if workflowData.EngineConfig != nil && workflowData.EngineConfig.Command != "" {
// Keep firewall runtime installation when firewall is enabled, since the
// custom engine command still runs inside the AWF harness.
if isFirewallEnabled(workflowData) {
copilotInstallLog.Printf("Skipping Copilot CLI installation: custom command specified (%s); keeping AWF runtime installation because firewall is enabled", workflowData.EngineConfig.Command)
return BuildNpmEngineInstallStepsWithAWF([]GitHubActionStep{}, workflowData)
var steps []GitHubActionStep
if len(sdkInstallStep) > 0 {
steps = append(steps, sdkInstallStep)
}
return BuildNpmEngineInstallStepsWithAWF(steps, workflowData)
}
Comment on lines +83 to +88
if len(sdkInstallStep) > 0 {
copilotInstallLog.Printf("Skipping Copilot CLI installation: custom command specified (%s); keeping Copilot SDK install step", workflowData.EngineConfig.Command)
return []GitHubActionStep{sdkInstallStep}
}
copilotInstallLog.Printf("Skipping installation steps: custom command specified (%s)", workflowData.EngineConfig.Command)
return []GitHubActionStep{}
Expand All @@ -94,18 +114,108 @@ func (e *CopilotEngine) GetInstallationSteps(workflowData *WorkflowData) []GitHu
// Use the installer script for global installation
copilotInstallLog.Print("Using new installer script for Copilot installation")
npmSteps := GenerateCopilotInstallerSteps(copilotVersion, "Install GitHub Copilot CLI")
if workflowData.EngineConfig != nil && workflowData.EngineConfig.CopilotSDK {
copilotInstallLog.Printf("copilot-sdk enabled; adding @github/copilot-sdk install step: version=%s", constants.DefaultCopilotSDKVersion)
npmSteps = append(npmSteps, GitHubActionStep{
" - name: Install GitHub Copilot SDK",
" run: cd \"${GITHUB_WORKSPACE}\" && npm install --ignore-scripts --no-save @github/copilot-sdk@" + string(constants.DefaultCopilotSDKVersion),
})
if len(sdkInstallStep) > 0 {
npmSteps = append(npmSteps, sdkInstallStep)
}
steps := BuildNpmEngineInstallStepsWithAWF(npmSteps, workflowData)

return steps
}

func buildCopilotSDKInstallStep(workflowData *WorkflowData) GitHubActionStep {
if workflowData == nil || workflowData.EngineConfig == nil || !workflowData.EngineConfig.CopilotSDK {
return GitHubActionStep{}
}
spec := getCopilotSDKInstallSpec(workflowData.EngineConfig.Command)
copilotInstallLog.Printf("copilot-sdk enabled; runtime=%s; install command=%s", spec.runtimeID, spec.command)
return GitHubActionStep{
" - name: " + spec.stepName,
" run: " + spec.command,
}
}

func getCopilotSDKInstallSpec(command string) copilotSDKInstallSpec {
runtimeID := detectRuntimeFromCopilotCommand(command)
version := string(constants.DefaultCopilotSDKVersion)

spec := copilotSDKInstallSpec{
runtimeID: runtimeID,
stepName: "Install GitHub Copilot SDK (Node.js)",
command: workspaceCommandPrefix + "npm install --ignore-scripts --no-save @github/copilot-sdk@" + version,
}

switch runtimeID {
case "python":
spec.stepName = "Install GitHub Copilot SDK (Python)"
spec.command = workspaceCommandPrefix + "pip install --disable-pip-version-check github-copilot-sdk==" + version
case "go":
spec.stepName = "Install GitHub Copilot SDK (Go)"
spec.command = workspaceCommandPrefix + "go get github.com/github/copilot-sdk/go@v" + version
case "rust":
spec.stepName = "Install GitHub Copilot SDK (Rust)"
spec.command = workspaceCommandPrefix + "cargo add github-copilot-sdk@" + version
case "dotnet":
spec.stepName = "Install GitHub Copilot SDK (.NET)"
spec.command = workspaceCommandPrefix + "dotnet add package GitHub.Copilot.SDK --version " + version
case "java":
spec.stepName = "Install GitHub Copilot SDK (Java)"
spec.command = workspaceCommandPrefix + "mvn -q org.apache.maven.plugins:maven-dependency-plugin:3.8.1:get -Dartifact=com.github:copilot-sdk-java:" + version
}

return spec
}

func detectRuntimeFromCopilotCommand(command string) string {
token := firstCommandToken(command)
if token == "" {
return "node"
}

runtime, found := commandToRuntime[token]
if found && runtime.ID != "" {
return runtime.ID
}

switch token {
case "ts-node":
return "node"
case "cargo", "rustc":
return "rust"
case "mvnw":
return "java"
}
return "node"
}

func firstCommandToken(command string) string {
fields := strings.Fields(command)
if len(fields) == 0 {
return ""
}
token := normalizeCommandToken(fields[0])
if token != "env" {
return token
}
// Shell-form commands sometimes start with `env` wrappers:
// env FOO=bar python app.py
// Skip env assignments/flags and return the first executable token.
for _, field := range fields[1:] {
if strings.Contains(field, "=") || strings.HasPrefix(field, "-") {
continue
}
return normalizeCommandToken(field)
}
return ""
}

func normalizeCommandToken(token string) string {
trimmed := strings.Trim(token, `"'`)
if trimmed == "" {
return ""
}
return strings.ToLower(filepath.Base(trimmed))
}

// generateAWFInstallationStep creates a GitHub Actions step to install the AWF binary
// with SHA256 checksum verification to protect against supply chain attacks.
//
Expand Down
123 changes: 122 additions & 1 deletion pkg/workflow/copilot_engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ func TestCopilotEngineInstallationSteps(t *testing.T) {
t.Fatalf("Expected 2 installation steps with copilot-sdk enabled, got %d", len(stepsWithSDK))
}
sdkInstallStep := strings.Join(stepsWithSDK[1], "\n")
if !strings.Contains(sdkInstallStep, "name: Install GitHub Copilot SDK") {
if !strings.Contains(sdkInstallStep, "name: Install GitHub Copilot SDK (Node.js)") {
t.Fatalf("Expected SDK install step name, got:\n%s", sdkInstallStep)
}
expectedSDKInstall := "cd \"${GITHUB_WORKSPACE}\" && npm install --ignore-scripts --no-save @github/copilot-sdk@" + string(constants.DefaultCopilotSDKVersion)
Expand Down Expand Up @@ -1890,6 +1890,127 @@ func TestCopilotEngineSkipInstallationWithCommand(t *testing.T) {
}
}

func TestCopilotEngineInstallationWithCommandAndCopilotSDK(t *testing.T) {
engine := NewCopilotEngine()

tests := []struct {
name string
command string
expectedName string
expectedRun string
withFirewall bool
expectedSteps int
}{
{
name: "node command uses npm sdk install",
command: "node ./agent.js",
expectedName: "name: Install GitHub Copilot SDK (Node.js)",
expectedRun: "npm install --ignore-scripts --no-save @github/copilot-sdk@" + string(constants.DefaultCopilotSDKVersion),
expectedSteps: 1,
},
{
name: "python command uses pip sdk install",
command: "python3 main.py",
expectedName: "name: Install GitHub Copilot SDK (Python)",
expectedRun: "pip install --disable-pip-version-check github-copilot-sdk==" + string(constants.DefaultCopilotSDKVersion),
expectedSteps: 1,
},
{
name: "go command uses go get sdk install",
command: "go run ./cmd/agent",
expectedName: "name: Install GitHub Copilot SDK (Go)",
expectedRun: "go get github.com/github/copilot-sdk/go@v" + string(constants.DefaultCopilotSDKVersion),
expectedSteps: 1,
},
{
name: "rust command uses cargo sdk install",
command: "cargo run --bin agent",
expectedName: "name: Install GitHub Copilot SDK (Rust)",
expectedRun: "cargo add github-copilot-sdk@" + string(constants.DefaultCopilotSDKVersion),
expectedSteps: 1,
},
{
name: "dotnet command uses nuget sdk install",
command: "dotnet run --project src/Agent",
expectedName: "name: Install GitHub Copilot SDK (.NET)",
expectedRun: "dotnet add package GitHub.Copilot.SDK --version " + string(constants.DefaultCopilotSDKVersion),
expectedSteps: 1,
},
{
name: "java command uses maven sdk install",
command: "mvn test",
expectedName: "name: Install GitHub Copilot SDK (Java)",
expectedRun: "mvn -q org.apache.maven.plugins:maven-dependency-plugin:3.8.1:get -Dartifact=com.github:copilot-sdk-java:" + string(constants.DefaultCopilotSDKVersion),
expectedSteps: 1,
},
{
name: "runtime manager java command uses java sdk install",
command: "gradle test",
expectedName: "name: Install GitHub Copilot SDK (Java)",
expectedRun: "mvn -q org.apache.maven.plugins:maven-dependency-plugin:3.8.1:get -Dartifact=com.github:copilot-sdk-java:" + string(constants.DefaultCopilotSDKVersion),
expectedSteps: 1,
},
{
name: "unsupported runtime falls back to node sdk install",
command: "bun run agent.ts",
expectedName: "name: Install GitHub Copilot SDK (Node.js)",
expectedRun: "npm install --ignore-scripts --no-save @github/copilot-sdk@" + string(constants.DefaultCopilotSDKVersion),
expectedSteps: 1,
},
{
name: "env wrapper command is detected",
command: "env FOO=bar python script.py",
expectedName: "name: Install GitHub Copilot SDK (Python)",
expectedRun: "pip install --disable-pip-version-check github-copilot-sdk==" + string(constants.DefaultCopilotSDKVersion),
expectedSteps: 1,
},
{
name: "custom command with firewall keeps awf and sdk installs",
command: "python script.py",
expectedName: "name: Install GitHub Copilot SDK (Python)",
expectedRun: "pip install --disable-pip-version-check github-copilot-sdk==" + string(constants.DefaultCopilotSDKVersion),
withFirewall: true,
expectedSteps: 2,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
workflowData := &WorkflowData{
EngineConfig: &EngineConfig{
Command: tt.command,
CopilotSDK: true,
},
}
if tt.withFirewall {
workflowData.NetworkPermissions = &NetworkPermissions{
Firewall: &FirewallConfig{Enabled: true},
}
}

steps := engine.GetInstallationSteps(workflowData)
if len(steps) != tt.expectedSteps {
t.Fatalf("Expected %d installation steps, got %d", tt.expectedSteps, len(steps))
}

sdkStepContent := strings.Join(steps[0], "\n")
if !strings.Contains(sdkStepContent, tt.expectedName) {
t.Fatalf("Expected SDK install step name %q, got:\n%s", tt.expectedName, sdkStepContent)
}
if !strings.Contains(sdkStepContent, tt.expectedRun) {
t.Fatalf("Expected SDK install command %q, got:\n%s", tt.expectedRun, sdkStepContent)
}

if tt.withFirewall {
awfStepContent := strings.Join(steps[1], "\n")
if !strings.Contains(awfStepContent, "Install AWF binary") {
t.Fatalf("Expected AWF installation step with firewall enabled, got:\n%s", awfStepContent)
}
}
})
}
}

// TestGenerateCopilotSessionFileCopyStep verifies the generated step copies session state files.
func TestGenerateCopilotSessionFileCopyStep(t *testing.T) {
step := generateCopilotSessionFileCopyStep()
Expand Down
Loading