From cede82cc329dcb69ca7c3634fe341e977cac9dbd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Feb 2026 00:57:21 +0000 Subject: [PATCH 1/4] Initial plan From 8eebdad830c6640686e1f587502d281a100ce997 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Feb 2026 01:22:55 +0000 Subject: [PATCH 2/4] fix: automatically override agentic engine token when engine.env is provided in Gemini engine Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/gemini_engine.go | 15 ++++++++++++ pkg/workflow/gemini_engine_test.go | 38 ++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/pkg/workflow/gemini_engine.go b/pkg/workflow/gemini_engine.go index ed9a052461..69fa4f3bf8 100644 --- a/pkg/workflow/gemini_engine.go +++ b/pkg/workflow/gemini_engine.go @@ -2,6 +2,7 @@ package workflow import ( "fmt" + "maps" "github.com/github/gh-aw/pkg/constants" "github.com/github/gh-aw/pkg/logger" @@ -278,6 +279,20 @@ func (e *GeminiEngine) GetExecutionSteps(workflowData *WorkflowData, logFile str env[constants.GeminiCLIModelEnvVar] = workflowData.EngineConfig.Model } + // Add custom environment variables from engine config. + // This allows users to override the default engine token expression (e.g. + // GEMINI_API_KEY: ${{ secrets.MY_ORG_GEMINI_KEY }}) via engine.env. + if workflowData.EngineConfig != nil && len(workflowData.EngineConfig.Env) > 0 { + maps.Copy(env, workflowData.EngineConfig.Env) + } + + // Add custom environment variables from agent config + agentConfig := getAgentConfig(workflowData) + if agentConfig != nil && len(agentConfig.Env) > 0 { + maps.Copy(env, agentConfig.Env) + geminiLog.Printf("Added %d custom env vars from agent config", len(agentConfig.Env)) + } + // Generate the execution step stepLines := []string{ " - name: Execute Gemini CLI", diff --git a/pkg/workflow/gemini_engine_test.go b/pkg/workflow/gemini_engine_test.go index 4c74e1e360..9a33dd24d2 100644 --- a/pkg/workflow/gemini_engine_test.go +++ b/pkg/workflow/gemini_engine_test.go @@ -259,6 +259,44 @@ func TestGeminiEngineExecution(t *testing.T) { assert.Contains(t, stepContent, "GEMINI_MODEL: gemini-2.0-flash", "Should set GEMINI_MODEL when model is explicitly configured") }) + t.Run("engine env overrides default token expression", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ + Env: map[string]string{ + "GEMINI_API_KEY": "${{ secrets.MY_ORG_GEMINI_KEY }}", + }, + }, + } + + steps := engine.GetExecutionSteps(workflowData, "/tmp/test.log") + require.Len(t, steps, 2, "Should generate settings step and execution step") + + stepContent := strings.Join(steps[1], "\n") + + // The user-provided value should override the default token expression + assert.Contains(t, stepContent, "GEMINI_API_KEY: ${{ secrets.MY_ORG_GEMINI_KEY }}", "engine.env should override the default GEMINI_API_KEY expression") + assert.NotContains(t, stepContent, "GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}", "Default GEMINI_API_KEY expression should be replaced by engine.env") + }) + + t.Run("engine env adds custom non-secret env vars", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ + Env: map[string]string{ + "CUSTOM_VAR": "custom-value", + }, + }, + } + + steps := engine.GetExecutionSteps(workflowData, "/tmp/test.log") + require.Len(t, steps, 2, "Should generate settings step and execution step") + + stepContent := strings.Join(steps[1], "\n") + + assert.Contains(t, stepContent, "CUSTOM_VAR: custom-value", "engine.env non-secret vars should be included") + }) + t.Run("settings step is first", func(t *testing.T) { workflowData := &WorkflowData{ Name: "test-workflow", From 65478c4501d4542f4c6976e8128b2ef4318c2190 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Feb 2026 01:54:37 +0000 Subject: [PATCH 3/4] test: add engine.env token override tests for Copilot, Claude, and Codex engines Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/claude_engine_test.go | 52 +++++++++++++++++++++++++++++ pkg/workflow/codex_engine_test.go | 52 +++++++++++++++++++++++++++++ pkg/workflow/copilot_engine_test.go | 52 +++++++++++++++++++++++++++++ 3 files changed, 156 insertions(+) diff --git a/pkg/workflow/claude_engine_test.go b/pkg/workflow/claude_engine_test.go index 5ed7476a11..51b546b3af 100644 --- a/pkg/workflow/claude_engine_test.go +++ b/pkg/workflow/claude_engine_test.go @@ -523,3 +523,55 @@ func TestClaudeEngineSkipInstallationWithCommand(t *testing.T) { t.Errorf("Expected 0 installation steps when command is specified, got %d", len(steps)) } } + +func TestClaudeEngineEnvOverridesTokenExpression(t *testing.T) { + engine := NewClaudeEngine() + + t.Run("engine env overrides default token expression", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ + Env: map[string]string{ + "ANTHROPIC_API_KEY": "${{ secrets.MY_ORG_ANTHROPIC_KEY }}", + }, + }, + } + + steps := engine.GetExecutionSteps(workflowData, "/tmp/gh-aw/test.log") + if len(steps) != 1 { + t.Fatalf("Expected 1 step, got %d", len(steps)) + } + + stepContent := strings.Join([]string(steps[0]), "\n") + + // engine.env override should replace the default token expression + if !strings.Contains(stepContent, "ANTHROPIC_API_KEY: ${{ secrets.MY_ORG_ANTHROPIC_KEY }}") { + t.Errorf("Expected engine.env to override ANTHROPIC_API_KEY, got:\n%s", stepContent) + } + if strings.Contains(stepContent, "ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}") { + t.Errorf("Default ANTHROPIC_API_KEY expression should be replaced by engine.env override, got:\n%s", stepContent) + } + }) + + t.Run("engine env adds extra environment variables", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ + Env: map[string]string{ + "CUSTOM_VAR": "custom-value", + }, + }, + } + + steps := engine.GetExecutionSteps(workflowData, "/tmp/gh-aw/test.log") + if len(steps) != 1 { + t.Fatalf("Expected 1 step, got %d", len(steps)) + } + + stepContent := strings.Join([]string(steps[0]), "\n") + + if !strings.Contains(stepContent, "CUSTOM_VAR: custom-value") { + t.Errorf("Expected engine.env to add CUSTOM_VAR, got:\n%s", stepContent) + } + }) +} diff --git a/pkg/workflow/codex_engine_test.go b/pkg/workflow/codex_engine_test.go index d185d4f7d4..cb53b4e925 100644 --- a/pkg/workflow/codex_engine_test.go +++ b/pkg/workflow/codex_engine_test.go @@ -770,3 +770,55 @@ func TestCodexEngineSkipInstallationWithCommand(t *testing.T) { t.Errorf("Expected 0 installation steps when command is specified, got %d", len(steps)) } } + +func TestCodexEngineEnvOverridesTokenExpression(t *testing.T) { + engine := NewCodexEngine() + + t.Run("engine env overrides default token expression", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ + Env: map[string]string{ + "CODEX_API_KEY": "${{ secrets.MY_ORG_CODEX_KEY }}", + }, + }, + } + + steps := engine.GetExecutionSteps(workflowData, "/tmp/gh-aw/test.log") + if len(steps) != 1 { + t.Fatalf("Expected 1 step, got %d", len(steps)) + } + + stepContent := strings.Join([]string(steps[0]), "\n") + + // engine.env override should replace the default token expression + if !strings.Contains(stepContent, "CODEX_API_KEY: ${{ secrets.MY_ORG_CODEX_KEY }}") { + t.Errorf("Expected engine.env to override CODEX_API_KEY, got:\n%s", stepContent) + } + if strings.Contains(stepContent, "CODEX_API_KEY: ${{ secrets.CODEX_API_KEY || secrets.OPENAI_API_KEY }}") { + t.Errorf("Default CODEX_API_KEY expression should be replaced by engine.env override, got:\n%s", stepContent) + } + }) + + t.Run("engine env adds extra environment variables", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ + Env: map[string]string{ + "CUSTOM_VAR": "custom-value", + }, + }, + } + + steps := engine.GetExecutionSteps(workflowData, "/tmp/gh-aw/test.log") + if len(steps) != 1 { + t.Fatalf("Expected 1 step, got %d", len(steps)) + } + + stepContent := strings.Join([]string(steps[0]), "\n") + + if !strings.Contains(stepContent, "CUSTOM_VAR: custom-value") { + t.Errorf("Expected engine.env to add CUSTOM_VAR, got:\n%s", stepContent) + } + }) +} diff --git a/pkg/workflow/copilot_engine_test.go b/pkg/workflow/copilot_engine_test.go index 553851931f..57ef5762d4 100644 --- a/pkg/workflow/copilot_engine_test.go +++ b/pkg/workflow/copilot_engine_test.go @@ -1505,3 +1505,55 @@ func TestGenerateCopilotSessionFileCopyStep(t *testing.T) { t.Error("Step should be marked continue-on-error") } } + +func TestCopilotEngineEnvOverridesTokenExpression(t *testing.T) { + engine := NewCopilotEngine() + + t.Run("engine env overrides default token expression", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ + Env: map[string]string{ + "COPILOT_GITHUB_TOKEN": "${{ secrets.MY_ORG_COPILOT_TOKEN }}", + }, + }, + } + + steps := engine.GetExecutionSteps(workflowData, "/tmp/gh-aw/test.log") + if len(steps) != 1 { + t.Fatalf("Expected 1 step, got %d", len(steps)) + } + + stepContent := strings.Join([]string(steps[0]), "\n") + + // engine.env override should replace the default token expression + if !strings.Contains(stepContent, "COPILOT_GITHUB_TOKEN: ${{ secrets.MY_ORG_COPILOT_TOKEN }}") { + t.Errorf("Expected engine.env to override COPILOT_GITHUB_TOKEN, got:\n%s", stepContent) + } + if strings.Contains(stepContent, "COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}") { + t.Errorf("Default COPILOT_GITHUB_TOKEN expression should be replaced by engine.env override, got:\n%s", stepContent) + } + }) + + t.Run("engine env adds extra environment variables", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ + Env: map[string]string{ + "CUSTOM_VAR": "custom-value", + }, + }, + } + + steps := engine.GetExecutionSteps(workflowData, "/tmp/gh-aw/test.log") + if len(steps) != 1 { + t.Fatalf("Expected 1 step, got %d", len(steps)) + } + + stepContent := strings.Join([]string(steps[0]), "\n") + + if !strings.Contains(stepContent, "CUSTOM_VAR: custom-value") { + t.Errorf("Expected engine.env to add CUSTOM_VAR, got:\n%s", stepContent) + } + }) +} From 133f576b984744b4852e0dc09016cc7fe12970c2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Feb 2026 02:16:59 +0000 Subject: [PATCH 4/4] fix: propagate engine.env overrides to secret validation step Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/agentic_engine.go | 17 ++- pkg/workflow/claude_engine.go | 1 + pkg/workflow/copilot_engine_installation.go | 1 + pkg/workflow/engine_helpers.go | 11 ++ pkg/workflow/gemini_engine.go | 1 + pkg/workflow/secret_validation_test.go | 134 +++++++++++++++++++- 6 files changed, 161 insertions(+), 4 deletions(-) diff --git a/pkg/workflow/agentic_engine.go b/pkg/workflow/agentic_engine.go index 5e983cff59..9028b57508 100644 --- a/pkg/workflow/agentic_engine.go +++ b/pkg/workflow/agentic_engine.go @@ -439,7 +439,10 @@ func GenerateSecretValidationStep(secretName, engineName, docsURL string) GitHub // secretNames: slice of secret names to validate (e.g., []string{"CODEX_API_KEY", "OPENAI_API_KEY"}) // engineName: the display name of the engine (e.g., "Codex") // docsURL: URL to the documentation page for setting up the secret -func GenerateMultiSecretValidationStep(secretNames []string, engineName, docsURL string) GitHubActionStep { +// envOverrides: optional map of env var key to expression override (from engine.env); when set, +// the overridden expression is used instead of the default "${{ secrets.KEY }}" so the +// validation step checks the user-provided secret reference rather than the default one. +func GenerateMultiSecretValidationStep(secretNames []string, engineName, docsURL string, envOverrides map[string]string) GitHubActionStep { if len(secretNames) == 0 { // This is a programming error - engine configurations should always provide secrets // Log the error and return empty step to avoid breaking compilation @@ -463,9 +466,17 @@ func GenerateMultiSecretValidationStep(secretNames []string, engineName, docsURL " env:", } - // Add env section with all secrets + // Add env section with all secrets. When engine.env provides an override for a key, + // use that expression (e.g. "${{ secrets.MY_ORG_TOKEN }}") so the validation step + // validates the user-supplied secret instead of the default one. for _, secretName := range secretNames { - stepLines = append(stepLines, fmt.Sprintf(" %s: ${{ secrets.%s }}", secretName, secretName)) + expr := fmt.Sprintf("${{ secrets.%s }}", secretName) + if envOverrides != nil { + if override, ok := envOverrides[secretName]; ok { + expr = override + } + } + stepLines = append(stepLines, fmt.Sprintf(" %s: %s", secretName, expr)) } return GitHubActionStep(stepLines) diff --git a/pkg/workflow/claude_engine.go b/pkg/workflow/claude_engine.go index d9df8b4173..1f7b54b6fd 100644 --- a/pkg/workflow/claude_engine.go +++ b/pkg/workflow/claude_engine.go @@ -94,6 +94,7 @@ func (e *ClaudeEngine) GetInstallationSteps(workflowData *WorkflowData) []GitHub config.Secrets, config.Name, config.DocsURL, + getEngineEnvOverrides(workflowData), ) steps = append(steps, secretValidation) diff --git a/pkg/workflow/copilot_engine_installation.go b/pkg/workflow/copilot_engine_installation.go index c15fcaad7b..bb2e0d1121 100644 --- a/pkg/workflow/copilot_engine_installation.go +++ b/pkg/workflow/copilot_engine_installation.go @@ -60,6 +60,7 @@ func (e *CopilotEngine) GetInstallationSteps(workflowData *WorkflowData) []GitHu config.Secrets, config.Name, config.DocsURL, + getEngineEnvOverrides(workflowData), ) steps = append(steps, secretValidation) diff --git a/pkg/workflow/engine_helpers.go b/pkg/workflow/engine_helpers.go index eaa58ff181..255b096dfd 100644 --- a/pkg/workflow/engine_helpers.go +++ b/pkg/workflow/engine_helpers.go @@ -61,6 +61,16 @@ type EngineInstallConfig struct { InstallStepName string } +// getEngineEnvOverrides returns the engine.env map from workflowData, or nil if not set. +// This is used to pass user-provided env overrides to steps such as secret validation, +// so that overridden token expressions are used instead of the default "${{ secrets.KEY }}". +func getEngineEnvOverrides(workflowData *WorkflowData) map[string]string { + if workflowData == nil || workflowData.EngineConfig == nil { + return nil + } + return workflowData.EngineConfig.Env +} + // GetBaseInstallationSteps returns the common installation steps for an engine. // This includes secret validation and npm package installation steps that are // shared across all engines. @@ -81,6 +91,7 @@ func GetBaseInstallationSteps(config EngineInstallConfig, workflowData *Workflow config.Secrets, config.Name, config.DocsURL, + getEngineEnvOverrides(workflowData), ) steps = append(steps, secretValidation) diff --git a/pkg/workflow/gemini_engine.go b/pkg/workflow/gemini_engine.go index 69fa4f3bf8..232ad15c55 100644 --- a/pkg/workflow/gemini_engine.go +++ b/pkg/workflow/gemini_engine.go @@ -112,6 +112,7 @@ func (e *GeminiEngine) GetInstallationSteps(workflowData *WorkflowData) []GitHub config.Secrets, config.Name, config.DocsURL, + getEngineEnvOverrides(workflowData), ) steps = append(steps, secretValidation) diff --git a/pkg/workflow/secret_validation_test.go b/pkg/workflow/secret_validation_test.go index 7811cf071e..c8124b53fd 100644 --- a/pkg/workflow/secret_validation_test.go +++ b/pkg/workflow/secret_validation_test.go @@ -111,7 +111,7 @@ func TestGenerateMultiSecretValidationStep(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - step := GenerateMultiSecretValidationStep(tt.secretNames, tt.engineName, tt.docsURL) + step := GenerateMultiSecretValidationStep(tt.secretNames, tt.engineName, tt.docsURL, nil) stepContent := strings.Join(step, "\n") for _, want := range tt.wantStrings { @@ -215,3 +215,135 @@ func TestCodexEngineHasSecretValidation(t *testing.T) { t.Error("Should pass both CODEX_API_KEY and OPENAI_API_KEY to the script") } } + +func TestGenerateMultiSecretValidationStepWithEnvOverrides(t *testing.T) { + t.Run("override replaces default secret expression", func(t *testing.T) { + overrides := map[string]string{ + "COPILOT_GITHUB_TOKEN": "${{ secrets.MY_ORG_COPILOT_TOKEN }}", + } + step := GenerateMultiSecretValidationStep( + []string{"COPILOT_GITHUB_TOKEN"}, + "GitHub Copilot CLI", + "https://docs.example.com", + overrides, + ) + stepContent := strings.Join(step, "\n") + + if !strings.Contains(stepContent, "COPILOT_GITHUB_TOKEN: ${{ secrets.MY_ORG_COPILOT_TOKEN }}") { + t.Errorf("Expected overridden expression in validation step env, got:\n%s", stepContent) + } + if strings.Contains(stepContent, "COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}") { + t.Errorf("Default expression should be replaced by override, got:\n%s", stepContent) + } + }) + + t.Run("nil overrides uses default secret expressions", func(t *testing.T) { + step := GenerateMultiSecretValidationStep( + []string{"COPILOT_GITHUB_TOKEN"}, + "GitHub Copilot CLI", + "https://docs.example.com", + nil, + ) + stepContent := strings.Join(step, "\n") + + if !strings.Contains(stepContent, "COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}") { + t.Errorf("Expected default expression when overrides is nil, got:\n%s", stepContent) + } + }) + + t.Run("partial override only replaces matching keys", func(t *testing.T) { + overrides := map[string]string{ + "CODEX_API_KEY": "${{ secrets.MY_ORG_CODEX_KEY }}", + } + step := GenerateMultiSecretValidationStep( + []string{"CODEX_API_KEY", "OPENAI_API_KEY"}, + "Codex", + "https://docs.example.com", + overrides, + ) + stepContent := strings.Join(step, "\n") + + if !strings.Contains(stepContent, "CODEX_API_KEY: ${{ secrets.MY_ORG_CODEX_KEY }}") { + t.Errorf("Expected overridden CODEX_API_KEY expression, got:\n%s", stepContent) + } + if !strings.Contains(stepContent, "OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}") { + t.Errorf("Expected default OPENAI_API_KEY expression (not overridden), got:\n%s", stepContent) + } + }) +} + +func TestValidationStepUsesEngineEnvOverride(t *testing.T) { + tests := []struct { + name string + engine CodingAgentEngine + tokenKey string + overrideSecret string + }{ + { + name: "Copilot engine validation uses engine.env override", + engine: NewCopilotEngine(), + tokenKey: "COPILOT_GITHUB_TOKEN", + overrideSecret: "MY_ORG_COPILOT_TOKEN", + }, + { + name: "Claude engine validation uses engine.env override", + engine: NewClaudeEngine(), + tokenKey: "ANTHROPIC_API_KEY", + overrideSecret: "MY_ORG_ANTHROPIC_KEY", + }, + { + name: "Codex engine validation uses engine.env override", + engine: NewCodexEngine(), + tokenKey: "CODEX_API_KEY", + overrideSecret: "MY_ORG_CODEX_KEY", + }, + { + name: "Gemini engine validation uses engine.env override", + engine: NewGeminiEngine(), + tokenKey: "GEMINI_API_KEY", + overrideSecret: "MY_ORG_GEMINI_KEY", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ + Env: map[string]string{ + tt.tokenKey: fmt.Sprintf("${{ secrets.%s }}", tt.overrideSecret), + }, + }, + } + + steps := tt.engine.GetInstallationSteps(workflowData) + if len(steps) < 1 { + t.Fatal("Expected at least one installation step") + } + + // Find the validate-secret step + var validationStep string + for _, step := range steps { + content := strings.Join(step, "\n") + if strings.Contains(content, "id: validate-secret") { + validationStep = content + break + } + } + if validationStep == "" { + t.Fatal("Expected to find a validate-secret step") + } + + // The validation step should use the overridden secret expression + expectedExpr := fmt.Sprintf("%s: ${{ secrets.%s }}", tt.tokenKey, tt.overrideSecret) + if !strings.Contains(validationStep, expectedExpr) { + t.Errorf("Validation step should use overridden secret expression %q, got:\n%s", expectedExpr, validationStep) + } + // The default expression should NOT be present + defaultExpr := fmt.Sprintf("%s: ${{ secrets.%s }}", tt.tokenKey, tt.tokenKey) + if strings.Contains(validationStep, defaultExpr) { + t.Errorf("Validation step should NOT use default expression %q when engine.env overrides it, got:\n%s", defaultExpr, validationStep) + } + }) + } +}