diff --git a/.github/workflows/smoke-ci.lock.yml b/.github/workflows/smoke-ci.lock.yml index 8c4ab34898..fa390f3a1c 100644 --- a/.github/workflows/smoke-ci.lock.yml +++ b/.github/workflows/smoke-ci.lock.yml @@ -412,6 +412,8 @@ jobs: setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); await main(); + - name: Install AWF binary + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.25.24 - name: Determine automatic lockdown mode for GitHub MCP Server id: determine-automatic-lockdown uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 @@ -695,13 +697,22 @@ jobs: timeout-minutes: 5 run: | set -o pipefail + mkdir -p /tmp/gh-aw + umask 0177 + cat > /tmp/gh-aw/engine-command.sh <<'GH_AW_ENGINE_COMMAND_EOF' + #!/usr/bin/env bash + set -eo pipefail + bash -lc 'if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then safeoutputs add_comment --body "✅ smoke-ci: safeoutputs CLI comment only run (${GITHUB_RUN_ID})"; else echo "smoke-ci: push event - no PR context, skipping add_comment"; fi' "$@" + + GH_AW_ENGINE_COMMAND_EOF + chmod 700 /tmp/gh-aw/engine-command.sh touch /tmp/gh-aw/agent-step-summary.md GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true) export GH_AW_NODE_BIN (umask 177 && touch /tmp/gh-aw/agent-stdio.log) # shellcheck disable=SC1003 sudo -E awf --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --env-all --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --allow-domains api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --image-tag 0.25.24 --skip-pull --enable-api-proxy \ - -- /bin/bash -c 'export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && 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" ${RUNNER_TEMP}/gh-aw/actions/copilot_driver.cjs bash -lc '\''if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then safeoutputs add_comment --body "✅ smoke-ci: safeoutputs CLI comment only run (${GITHUB_RUN_ID})"; else echo "smoke-ci: push event - no PR context, skipping add_comment"; fi'\'' --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log + -- /bin/bash -c 'export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && 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" ${RUNNER_TEMP}/gh-aw/actions/copilot_driver.cjs /tmp/gh-aw/engine-command.sh --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log env: COPILOT_AGENT_RUNNER_TYPE: STANDALONE COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} diff --git a/pkg/workflow/copilot_engine_execution.go b/pkg/workflow/copilot_engine_execution.go index aa3a8e309f..68d000c29c 100644 --- a/pkg/workflow/copilot_engine_execution.go +++ b/pkg/workflow/copilot_engine_execution.go @@ -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 @@ -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 @@ -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, @@ -260,9 +269,7 @@ 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"}), @@ -270,10 +277,14 @@ func (e *CopilotEngine) GetExecutionSteps(workflowData *WorkflowData, logFile st } 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 @@ -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.) diff --git a/pkg/workflow/copilot_engine_installation.go b/pkg/workflow/copilot_engine_installation.go index c1b064b9b7..1b13a8ad4b 100644 --- a/pkg/workflow/copilot_engine_installation.go +++ b/pkg/workflow/copilot_engine_installation.go @@ -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) + } copilotInstallLog.Printf("Skipping installation steps: custom command specified (%s)", workflowData.EngineConfig.Command) return []GitHubActionStep{} } diff --git a/pkg/workflow/copilot_engine_test.go b/pkg/workflow/copilot_engine_test.go index c42d61f186..b1c0db64da 100644 --- a/pkg/workflow/copilot_engine_test.go +++ b/pkg/workflow/copilot_engine_test.go @@ -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. @@ -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) { @@ -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