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
6 changes: 5 additions & 1 deletion pkg/workflow/copilot_engine_execution.go
Original file line number Diff line number Diff line change
Expand Up @@ -491,9 +491,13 @@ func (e *CopilotEngine) buildCopilotAWFPathSetup(workflowData *WorkflowData, cus
if customCommandScriptSetup != "" {
pathSetup = customCommandScriptSetup + "\n" + pathSetup
}
homeExport := ""
if isArcDindTopology(workflowData) {
homeExport = fmt.Sprintf("export HOME=%s\n", awfArcDindHomePathExpr)
}
// Write the Copilot settings file before AWF starts. The file is created on the host and mounted
// into the container, where the Copilot CLI reads it to disable the rubber-duck sub-agent.
return buildCopilotSettingsCleanupAndExitCodeTrap() + buildCopilotSettingsSetup(buildCopilotSettingsContent(workflowData), customCommandScriptSetup != "") + buildCopilotMCPConfigExport(workflowData) + pathSetup
return homeExport + buildCopilotSettingsCleanupAndExitCodeTrap() + buildCopilotSettingsSetup(buildCopilotSettingsContent(workflowData), customCommandScriptSetup != "") + buildCopilotMCPConfigExport(workflowData) + pathSetup
}

func (e *CopilotEngine) buildCopilotDirectCommand(workflowData *WorkflowData, copilotCommand, customCommandScriptSetup, mkdirCommands, logFile string) string {
Expand Down
96 changes: 96 additions & 0 deletions pkg/workflow/copilot_home_expansion_test.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
//go:build !integration

// Tests guarding the $HOME-based shell expansion logic that resolves the
// Copilot CLI config directory at runtime (instead of the hard-coded
// /home/runner that broke self-hosted and containerized runners).
Expand All @@ -7,12 +9,19 @@
// 1. String-level assertions on the helpers in copilot_mcp.go and
// copilot_engine_execution.go to lock the generated snippets so any future
// regression flips a focused test rather than only the broader goldens.
//
// 2. Bash integration tests that actually execute the generated snippets
// under a few HOME values to confirm:
// - $HOME expands as expected
// - quoting survives a HOME containing spaces and other special chars
// - the EXIT trap fires and uses the runtime HOME, not the definition-time HOME
// - the rubber-duck settings file is written to the resolved path
//
// 3. ARC/DinD producer-consumer path alignment: the "Start MCP Gateway" step
// (producer) and the Copilot execution step (consumer) must both override
// HOME to the same daemon-visible writable path before any $HOME-derived
// config paths are evaluated, since GitHub Actions step-local exports do
// not persist between steps.
package workflow

import (
Expand Down Expand Up @@ -354,3 +363,90 @@ func TestBashIntegration_RenderMCPConfig_MkdirPath(t *testing.T) {
})
}
}

// TestArcDind_ProducerConsumerHomeParity is a regression guard for the ARC/DinD
// producer-consumer HOME path mismatch.
//
// In arc-dind topology, the "Start MCP Gateway" step (producer) writes the Copilot
// MCP config to $HOME/.copilot/mcp-config.json, and the Copilot execution step
// (consumer) reads it via GH_AW_MCP_CONFIG=$HOME/.copilot/mcp-config.json.
// GitHub Actions step-local exports do not persist between steps, so each step must
// independently override HOME to the daemon-visible writable path
// (${RUNNER_TEMP}/gh-aw/home) before any $HOME-derived paths are evaluated.
// Without this, the producer writes to /home/runner/.copilot/mcp-config.json while
// the consumer looks for the file at ${RUNNER_TEMP}/gh-aw/home/.copilot/mcp-config.json.
func TestArcDind_ProducerConsumerHomeParity(t *testing.T) {
arcDindHome := "export HOME=${RUNNER_TEMP}/gh-aw/home"
arcDindMkdir := `mkdir -p "$HOME/.copilot"`

workflowData := &WorkflowData{
Name: "arc-dind-home-parity",
EngineConfig: &EngineConfig{
ID: "copilot",
},
RunnerConfig: &RunnerConfig{
Topology: RunnerTopologyArcDind,
},
Tools: map[string]any{"github": map[string]any{}},
NetworkPermissions: &NetworkPermissions{
Firewall: &FirewallConfig{
Enabled: true,
},
},
}

// --- Producer: "Start MCP Gateway" step ---
// generateMCPGatewaySetup emits the step that calls RenderMCPConfig,
// which writes $HOME/.copilot/mcp-config.json.
ensureDefaultMCPGatewayConfig(workflowData)
var gatewayYAML strings.Builder
err := generateMCPGatewaySetup(
&gatewayYAML,
workflowData.Tools,
[]string{"github"},
NewCopilotEngine(),
workflowData,
false,
nil,
)
require.NoError(t, err)
gatewayStep := gatewayYAML.String()

// The producer step must override HOME before creating the .copilot directory.
gatewayHomeIdx := strings.Index(gatewayStep, arcDindHome)
if gatewayHomeIdx < 0 {
t.Fatalf("Start MCP Gateway step must export arc-dind HOME before mkdir:\n%s", gatewayStep)
}
gatewayMkdirIdx := strings.Index(gatewayStep, arcDindMkdir)
if gatewayMkdirIdx < 0 {
t.Fatalf("Start MCP Gateway step must contain mkdir -p \"$HOME/.copilot\":\n%s", gatewayStep)
}
if gatewayHomeIdx > gatewayMkdirIdx {
t.Fatalf("Start MCP Gateway: HOME export must come before mkdir -p $HOME/.copilot:\n%s", gatewayStep)
}

// --- Consumer: Copilot execution step ---
engine := NewCopilotEngine()
steps := engine.GetExecutionSteps(workflowData, "test.log")
stepContent := requireCopilotExecutionStep(t, steps)

// The consumer step must also override HOME before GH_AW_MCP_CONFIG is exported.
execHomeIdx := strings.Index(stepContent, arcDindHome)
if execHomeIdx < 0 {
t.Fatalf("Copilot execution step must export arc-dind HOME:\n%s", stepContent)
}
mcpConfigExport := `export GH_AW_MCP_CONFIG="$HOME/.copilot/mcp-config.json"`
execMCPConfigIdx := strings.Index(stepContent, mcpConfigExport)
if execMCPConfigIdx < 0 {
t.Fatalf("Copilot execution step must contain GH_AW_MCP_CONFIG export:\n%s", stepContent)
}
if execHomeIdx > execMCPConfigIdx {
t.Fatalf("Copilot execution step: HOME export must come before GH_AW_MCP_CONFIG export:\n%s", stepContent)
}

// Both steps must use $HOME-based paths (no literal /home/runner).
assert.NotContains(t, gatewayStep, "/home/runner/.copilot",
"Start MCP Gateway step must not embed a literal /home/runner/.copilot")
assert.NotContains(t, stepContent, "/home/runner/.copilot",
"Copilot execution step must not embed a literal /home/runner/.copilot")
}
18 changes: 18 additions & 0 deletions pkg/workflow/copilot_mcp.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package workflow

import (
"fmt"
"strings"

"github.com/github/gh-aw/pkg/logger"
Expand All @@ -18,6 +19,23 @@ func copilotMCPToolFilter(toolName string) bool {
func (e *CopilotEngine) RenderMCPConfig(yaml *strings.Builder, tools map[string]any, mcpTools []string, workflowData *WorkflowData) error {
copilotMCPLog.Printf("Rendering MCP config for Copilot engine: mcpTools=%d", len(mcpTools))

// For ARC/DinD topology with firewall enabled, override HOME to the daemon-visible
// writable path before creating the .copilot config directory. GitHub Actions steps
// do not inherit step-local exports from preceding steps, so each step that uses
// $HOME-based paths must set HOME independently.
//
// The condition mirrors buildCopilotAWFPathSetup in copilot_engine_execution.go, which
// sets HOME only for arc-dind and is only called when the firewall is enabled. This
// ensures the producer (MCP gateway step) and the consumer (Copilot execution step)
// both resolve $HOME to ${RUNNER_TEMP}/gh-aw/home, so they write and read the MCP
// config from the same path: ${RUNNER_TEMP}/gh-aw/home/.copilot/mcp-config.json.
//
// When the firewall is disabled, both steps use the runner's default HOME (/home/runner),
// and no override is needed because they already agree on the path.
if isArcDindTopology(workflowData) && isFirewallEnabled(workflowData) {
fmt.Fprintf(yaml, " export HOME=%s\n", awfArcDindHomePathExpr)
}

// Create the Copilot CLI config directory under the runtime $HOME. The Copilot CLI
// resolves its config dir as ~/.copilot, which is /home/runner/.copilot on standard
// GitHub-hosted runners but may differ on self-hosted or containerized runners.
Expand Down
33 changes: 33 additions & 0 deletions pkg/workflow/firewall_args_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@ func TestFirewallArgsInCopilotEngine(t *testing.T) {
RunnerConfig: &RunnerConfig{
Topology: RunnerTopologyArcDind,
},
Tools: map[string]any{"github": map[string]any{}},
NetworkPermissions: &NetworkPermissions{
Firewall: &FirewallConfig{
Enabled: true,
Expand Down Expand Up @@ -345,6 +346,38 @@ func TestFirewallArgsInCopilotEngine(t *testing.T) {
if strings.Contains(stepContent, "/usr/local/bin/copilot") {
t.Error("Expected arc-dind command not to reference /usr/local/bin/copilot")
}

homeExport := "export HOME=${RUNNER_TEMP}/gh-aw/home"
firstHomeExportIdx := strings.Index(stepContent, homeExport)
if firstHomeExportIdx < 0 {
t.Fatalf("Expected path setup to export HOME for arc-dind:\n%s", stepContent)
}
if strings.Count(stepContent, homeExport) < 2 {
t.Fatalf("Expected both path-setup and engine-command HOME exports for arc-dind:\n%s", stepContent)
}

settingsSetupIdx := strings.Index(stepContent, `mkdir -p "$HOME/.copilot"`)
if settingsSetupIdx < 0 {
t.Fatalf("Expected Copilot settings setup to be present:\n%s", stepContent)
}
xdgExportIdx := strings.Index(stepContent, `export XDG_CONFIG_HOME="$HOME"`)
if xdgExportIdx < 0 {
t.Fatalf("Expected XDG_CONFIG_HOME export to be present:\n%s", stepContent)
}
mcpConfigExportIdx := strings.Index(stepContent, `export GH_AW_MCP_CONFIG="$HOME/.copilot/mcp-config.json"`)
if mcpConfigExportIdx < 0 {
t.Fatalf("Expected GH_AW_MCP_CONFIG export when MCP is enabled:\n%s", stepContent)
}

if firstHomeExportIdx > settingsSetupIdx {
t.Fatalf("Expected arc-dind HOME export to run before Copilot settings setup:\n%s", stepContent)
}
if firstHomeExportIdx > xdgExportIdx {
t.Fatalf("Expected arc-dind HOME export to run before XDG_CONFIG_HOME export:\n%s", stepContent)
}
if firstHomeExportIdx > mcpConfigExportIdx {
t.Fatalf("Expected arc-dind HOME export to run before GH_AW_MCP_CONFIG export:\n%s", stepContent)
}
})

t.Run("AWF command includes allow-urls with ssl-bump enabled", func(t *testing.T) {
Expand Down
Loading