Skip to content
Merged
13 changes: 12 additions & 1 deletion .github/workflows/smoke-ci.lock.yml

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

40 changes: 34 additions & 6 deletions pkg/workflow/copilot_engine_execution.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import (

var copilotExecLog = logger.New("workflow:copilot_engine_execution")

const customEngineCommandScriptPath = "/tmp/gh-aw/engine-command.sh"
const nodeRuntimeResolutionCommand = `GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || echo node)"; fi; "$GH_AW_NODE_EXEC"`

// GetExecutionSteps returns the GitHub Actions steps for executing GitHub Copilot CLI
Expand Down Expand Up @@ -164,9 +165,11 @@ func (e *CopilotEngine) GetExecutionSteps(workflowData *WorkflowData, logFile st

// Determine which command to use (once for both sandbox and non-sandbox modes)
var commandName string
var customCommandScriptSetup string
if workflowData.EngineConfig != nil && workflowData.EngineConfig.Command != "" {
commandName = workflowData.EngineConfig.Command
copilotExecLog.Printf("Using custom command: %s", commandName)
commandName = customEngineCommandScriptPath
customCommandScriptSetup = buildEngineCommandScriptSetup(workflowData.EngineConfig.Command)
copilotExecLog.Printf("Using serialized custom command script: %s", commandName)
} else if sandboxEnabled {
// AWF - use the installed binary directly
// The binary is mounted into the AWF container from /usr/local/bin/copilot
Expand Down Expand Up @@ -242,6 +245,12 @@ func (e *CopilotEngine) GetExecutionSteps(workflowData *WorkflowData, logFile st
if mcpCLIPath := GetMCPCLIPathSetup(workflowData); mcpCLIPath != "" {
engineCommand = fmt.Sprintf("%s && %s", mcpCLIPath, copilotCommand)
}
pathSetup := "touch " + AgentStepSummaryPath + "\n" +
"GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true)\n" +
"export GH_AW_NODE_BIN"
if customCommandScriptSetup != "" {
pathSetup = customCommandScriptSetup + "\n" + pathSetup
}
command = BuildAWFCommand(AWFCommandConfig{
EngineName: "copilot",
EngineCommand: engineCommand,
Expand All @@ -260,20 +269,22 @@ func (e *CopilotEngine) GetExecutionSteps(workflowData *WorkflowData, logFile st
// preserves the variable and AWF's --env-all forwards it into the container,
// where the execution command validates GH_AW_NODE_BIN and falls back to
// command -v node when the path does not exist in the container.
PathSetup: "touch " + AgentStepSummaryPath + "\n" +
"GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true)\n" +
"export GH_AW_NODE_BIN",
PathSetup: pathSetup,
// Exclude every env var whose step-env value is a secret so the agent
// cannot read raw token values via bash tools (env / printenv).
ExcludeEnvVarNames: ComputeAWFExcludeEnvVarNames(workflowData, []string{"COPILOT_GITHUB_TOKEN"}),
})
} else {
// Run copilot command without AWF wrapper.
// Prepend a touch command to create the agent step summary file before copilot runs.
preCommandSetup := mkdirCommands.String()
if customCommandScriptSetup != "" {
preCommandSetup = customCommandScriptSetup + "\n" + preCommandSetup
}
command = fmt.Sprintf(`set -o pipefail
touch %s
(umask 177 && touch %s)
%s%s 2>&1 | tee %s`, AgentStepSummaryPath, logFile, mkdirCommands.String(), copilotCommand, logFile)
%s%s 2>&1 | tee %s`, AgentStepSummaryPath, logFile, preCommandSetup, copilotCommand, logFile)
}

// Use COPILOT_GITHUB_TOKEN: when the copilot-requests feature is enabled, use the GitHub
Expand Down Expand Up @@ -545,6 +556,23 @@ func extractAddDirPaths(args []string) []string {
return dirs
}

func buildEngineCommandScriptSetup(command string) string {
// engine.command intentionally accepts shell-form commands from trusted workflow
// configuration authored in-repo; preserve shell semantics and forward driver args.
scriptContent := fmt.Sprintf("#!/usr/bin/env bash\nset -eo pipefail\n%s \"$@\"\n", command)
heredocDelimiter := "GH_AW_ENGINE_COMMAND_EOF"
for strings.Contains(scriptContent, heredocDelimiter) {
heredocDelimiter += "_X"
}

return fmt.Sprintf(`mkdir -p /tmp/gh-aw
umask 0177
cat > %s <<'%s'
%s
%s
chmod 700 %s`, customEngineCommandScriptPath, heredocDelimiter, scriptContent, heredocDelimiter, customEngineCommandScriptPath)
}

// generateCopilotSessionFileCopyStep generates a step to copy the entire Copilot
// session-state directory from ~/.copilot/session-state/ to /tmp/gh-aw/sandbox/agent/logs/
// This ensures all session files (events.jsonl, session.db, plan.md, checkpoints, etc.)
Expand Down
13 changes: 10 additions & 3 deletions pkg/workflow/copilot_engine_installation.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,20 @@ func (e *CopilotEngine) GetSecretValidationStep(workflowData *WorkflowData) GitH
// 2. Sandbox installation (AWF, if needed)
// 3. Copilot CLI installation
//
// If a custom command is specified in the engine configuration, this function returns
// an empty list of steps, skipping the standard installation process.
// If a custom command is specified in the engine configuration, this function skips
// standard Copilot CLI installation. When firewall is enabled, it still returns AWF
// 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)

// Skip installation if custom command is specified
// 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)
}
Comment on lines 56 to +62
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

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

The log line says "Skipping installation steps" when engine.command is set, but in the firewall-enabled branch the function still returns AWF installation steps. Consider updating the log message to reflect the actual behavior (e.g., skipping Copilot CLI install but still installing AWF runtime when firewall is enabled) to avoid misleading diagnostics.

See below for a potential fix:

// If a custom command is specified in the engine configuration, this function skips
// the standard Copilot CLI installation. When the firewall is enabled, it still
// returns the AWF runtime installation steps required to run the custom command
// inside the AWF harness.
func (e *CopilotEngine) GetInstallationSteps(workflowData *WorkflowData) []GitHubActionStep {
	copilotInstallLog.Printf("Generating installation steps for Copilot engine: workflow=%s", workflowData.Name)

	// Skip standard Copilot CLI installation if a 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)
		}
		copilotInstallLog.Printf("Skipping installation steps: custom command specified (%s)", workflowData.EngineConfig.Command)

Copilot uses AI. Check for mistakes.
copilotInstallLog.Printf("Skipping installation steps: custom command specified (%s)", workflowData.EngineConfig.Command)
return []GitHubActionStep{}
}
Expand Down
69 changes: 69 additions & 0 deletions pkg/workflow/copilot_engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1568,6 +1568,24 @@ func TestCopilotEngineSkipInstallationWithCommand(t *testing.T) {
if len(steps) != 0 {
t.Errorf("Expected 0 installation steps when command is specified, got %d", len(steps))
}

// Test with custom command + firewall - should still install AWF runtime
workflowData = &WorkflowData{
EngineConfig: &EngineConfig{Command: "/usr/local/bin/custom-copilot"},
NetworkPermissions: &NetworkPermissions{
Firewall: &FirewallConfig{Enabled: true},
},
}
steps = engine.GetInstallationSteps(workflowData)

if len(steps) == 0 {
t.Fatal("Expected installation steps when firewall is enabled with custom command")
}

installContent := strings.Join([]string(steps[0]), "\n")
if !strings.Contains(installContent, "Install AWF binary") {
t.Errorf("Expected AWF installation step when firewall is enabled with custom command, got:\n%s", installContent)
}
}

// TestGenerateCopilotSessionFileCopyStep verifies the generated step copies session state files.
Expand Down Expand Up @@ -1714,6 +1732,34 @@ func TestCopilotEngineDriverScript(t *testing.T) {
t.Run("CopilotEngine implements DriverProvider interface", func(t *testing.T) {
var _ DriverProvider = engine
})

t.Run("Execution serializes engine.command into shell script", func(t *testing.T) {
workflowData := &WorkflowData{
Name: "test-workflow",
EngineConfig: &EngineConfig{
ID: "copilot",
Command: `bash -lc 'echo custom command'`,
},
Tools: make(map[string]any),
}

steps := engine.GetExecutionSteps(workflowData, "/tmp/gh-aw/agent-stdio.log")
if len(steps) == 0 {
t.Fatal("Expected at least one step")
}

stepContent := strings.Join([]string(steps[0]), "\n")

if !strings.Contains(stepContent, "copilot_driver.cjs /tmp/gh-aw/engine-command.sh") {
t.Errorf("Expected driver to run serialized engine command script, got:\n%s", stepContent)
}
if !strings.Contains(stepContent, "cat > /tmp/gh-aw/engine-command.sh <<'GH_AW_ENGINE_COMMAND_EOF'") {
t.Errorf("Expected step to serialize engine.command into script via heredoc, got:\n%s", stepContent)
}
if !strings.Contains(stepContent, "GH_AW_ENGINE_COMMAND_EOF") {
t.Errorf("Expected step to include heredoc delimiter for script serialization, got:\n%s", stepContent)
}
})
}

func TestCopilotEngineNoAskUser(t *testing.T) {
Expand Down Expand Up @@ -1810,6 +1856,29 @@ func TestCopilotEngineNoAskUser(t *testing.T) {
}
}

func TestBuildEngineCommandScriptSetup(t *testing.T) {
setup := buildEngineCommandScriptSetup("/usr/local/bin/custom-copilot")

if !strings.Contains(setup, "umask 0177") {
t.Fatalf("Expected restrictive umask in script setup, got:\n%s", setup)
}
if !strings.Contains(setup, "chmod 700 /tmp/gh-aw/engine-command.sh") {
t.Fatalf("Expected owner-only execute permissions, got:\n%s", setup)
}
if !strings.Contains(setup, "cat > /tmp/gh-aw/engine-command.sh <<'GH_AW_ENGINE_COMMAND_EOF'") {
t.Fatalf("Expected heredoc-based script materialization, got:\n%s", setup)
}
if !strings.Contains(setup, "set -eo pipefail") {
t.Fatalf("Expected script strict mode without -u, got:\n%s", setup)
}
if strings.Contains(setup, "set -euo pipefail") {
t.Fatalf("Expected script strict mode to drop -u, got:\n%s", setup)
}
if !strings.Contains(setup, `/usr/local/bin/custom-copilot "$@"`) {
t.Fatalf("Expected custom command to forward driver args, got:\n%s", setup)
}
}

func TestCopilotSupportsNoAskUser(t *testing.T) {
tests := []struct {
name string
Expand Down