diff --git a/pkg/workflow/checkout_manager.go b/pkg/workflow/checkout_manager.go index 1a72e83ad4..3d7fee38c6 100644 --- a/pkg/workflow/checkout_manager.go +++ b/pkg/workflow/checkout_manager.go @@ -1,6 +1,7 @@ package workflow import ( + "fmt" "strings" "github.com/github/gh-aw/pkg/logger" @@ -283,6 +284,25 @@ func (cm *CheckoutManager) HasAppAuth() bool { return false } +// CheckoutAppTokenOutputs returns a map of activation job output names to their +// step token expressions, for all checkouts using app authentication. +// Output names follow the pattern "checkout_app_token_{index}". +// Step ID expressions follow the pattern "steps.checkout-app-token-{index}.outputs.token". +// These outputs are set by the activation job so the agent job can reference them as +// needs.activation.outputs.checkout_app_token_{index}. +func (cm *CheckoutManager) CheckoutAppTokenOutputs() map[string]string { + outputs := make(map[string]string) + for i, entry := range cm.ordered { + if entry.githubApp == nil { + continue + } + outputName := fmt.Sprintf("checkout_app_token_%d", i) + stepID := fmt.Sprintf("checkout-app-token-%d", i) + outputs[outputName] = fmt.Sprintf("${{ steps.%s.outputs.token }}", stepID) + } + return outputs +} + // HasExternalRootCheckout returns true if any checkout entry targets an external // repository (non-empty repository field) and writes to the workspace root (empty path). // When such a checkout exists, the workspace root is replaced with the external diff --git a/pkg/workflow/checkout_manager_test.go b/pkg/workflow/checkout_manager_test.go index b87e8944ed..dfd8ef7fa3 100644 --- a/pkg/workflow/checkout_manager_test.go +++ b/pkg/workflow/checkout_manager_test.go @@ -890,7 +890,7 @@ func TestDefaultCheckoutWithAppAuth(t *testing.T) { }) lines := cm.GenerateDefaultCheckoutStep(false, "", getPin) combined := strings.Join(lines, "") - assert.Contains(t, combined, "steps.checkout-app-token-0.outputs.token", "checkout should reference app token step") + assert.Contains(t, combined, "needs.activation.outputs.checkout_app_token_0", "checkout should reference app token step") }) } @@ -908,7 +908,7 @@ func TestAdditionalCheckoutWithAppAuth(t *testing.T) { }) lines := cm.GenerateAdditionalCheckoutSteps(getPin) combined := strings.Join(lines, "") - assert.Contains(t, combined, "steps.checkout-app-token-1.outputs.token", "additional checkout should reference app token at index 1") + assert.Contains(t, combined, "needs.activation.outputs.checkout_app_token_1", "additional checkout should reference app token at index 1") assert.Contains(t, combined, "other/repo", "should reference the additional repo") }) } diff --git a/pkg/workflow/checkout_step_generator.go b/pkg/workflow/checkout_step_generator.go index 9ca81ed995..6216c8cbc9 100644 --- a/pkg/workflow/checkout_step_generator.go +++ b/pkg/workflow/checkout_step_generator.go @@ -33,6 +33,8 @@ func (cm *CheckoutManager) GenerateCheckoutAppTokenSteps(c *Compiler, permission // GenerateCheckoutAppTokenInvalidationSteps generates token invalidation steps // for all checkout entries that use app authentication. +// The tokens were minted in the activation job and are referenced via +// needs.activation.outputs.checkout_app_token_{index}. func (cm *CheckoutManager) GenerateCheckoutAppTokenInvalidationSteps(c *Compiler) []string { var steps []string for i, entry := range cm.ordered { @@ -41,9 +43,9 @@ func (cm *CheckoutManager) GenerateCheckoutAppTokenInvalidationSteps(c *Compiler } checkoutManagerLog.Printf("Generating app token invalidation step for checkout index=%d", i) rawSteps := c.buildGitHubAppTokenInvalidationStep() - stepID := fmt.Sprintf("checkout-app-token-%d", i) + outputName := fmt.Sprintf("checkout_app_token_%d", i) for _, step := range rawSteps { - modified := strings.ReplaceAll(step, "steps.safe-outputs-app-token.outputs.token", "steps."+stepID+".outputs.token") + modified := strings.ReplaceAll(step, "steps.safe-outputs-app-token.outputs.token", "needs.activation.outputs."+outputName) // Update step name to indicate it's for checkout modified = strings.ReplaceAll(modified, "Invalidate GitHub App token", fmt.Sprintf("Invalidate checkout app token (%d)", i)) steps = append(steps, modified) @@ -159,7 +161,7 @@ func (cm *CheckoutManager) GenerateDefaultCheckoutStep( if override.githubApp != nil { // The default checkout is always at index 0 in the ordered list //nolint:gosec // G101: False positive - this is a GitHub Actions expression template placeholder, not a hardcoded credential - effectiveOverrideToken = "${{ steps.checkout-app-token-0.outputs.token }}" + effectiveOverrideToken = "${{ needs.activation.outputs.checkout_app_token_0 }}" } if effectiveOverrideToken != "" { fmt.Fprintf(&sb, " token: %s\n", effectiveOverrideToken) @@ -226,7 +228,7 @@ func generateCheckoutStepLines(entry *resolvedCheckout, index int, getActionPin effectiveToken := entry.token if entry.githubApp != nil { //nolint:gosec // G101: False positive - this is a GitHub Actions expression template placeholder, not a hardcoded credential - effectiveToken = fmt.Sprintf("${{ steps.checkout-app-token-%d.outputs.token }}", index) + effectiveToken = fmt.Sprintf("${{ needs.activation.outputs.checkout_app_token_%d }}", index) } if effectiveToken != "" { fmt.Fprintf(&sb, " token: %s\n", effectiveToken) @@ -314,7 +316,7 @@ func generateFetchStepLines(entry *resolvedCheckout, index int) string { token := entry.token if entry.githubApp != nil { //nolint:gosec // G101: False positive - this is a GitHub Actions expression template placeholder, not a hardcoded credential - token = fmt.Sprintf("${{ steps.checkout-app-token-%d.outputs.token }}", index) + token = fmt.Sprintf("${{ needs.activation.outputs.checkout_app_token_%d }}", index) } if token == "" { token = getEffectiveGitHubToken("") diff --git a/pkg/workflow/compiler_activation_job.go b/pkg/workflow/compiler_activation_job.go index 5116359db9..f8fbcedf37 100644 --- a/pkg/workflow/compiler_activation_job.go +++ b/pkg/workflow/compiler_activation_job.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "maps" "strings" "github.com/github/gh-aw/pkg/constants" @@ -120,6 +121,32 @@ func (c *Compiler) buildActivationJob(data *WorkflowData, preActivationJobCreate outputs["activation_app_token_minting_failed"] = "${{ steps.activation-app-token.outcome == 'failure' }}" } + // Mint the GitHub MCP app token in the activation job so that the agent job never + // receives the app-id / private-key secrets. The minted token is exposed as a job + // output and consumed by the agent job via needs.activation.outputs.github_mcp_app_token. + if data.ParsedTools != nil && data.ParsedTools.GitHub != nil && data.ParsedTools.GitHub.GitHubApp != nil { + steps = append(steps, c.generateGitHubMCPAppTokenMintingSteps(data)...) + outputs["github_mcp_app_token"] = "${{ steps.github-mcp-app-token.outputs.token }}" + } + + // Mint checkout app tokens in the activation job so that the agent job never + // receives the app-id / private-key secrets. Each token is exposed as a job output + // and consumed by the agent job via needs.activation.outputs.checkout_app_token_{index}. + checkoutMgrForActivation := NewCheckoutManager(data.CheckoutConfigs) + if checkoutMgrForActivation.HasAppAuth() { + compilerActivationJobLog.Print("Generating checkout app token minting steps in activation job") + var checkoutPermissions *Permissions + if data.Permissions != "" { + parser := NewPermissionsParser(data.Permissions) + checkoutPermissions = parser.ToPermissions() + } else { + checkoutPermissions = NewPermissions() + } + checkoutAppTokenSteps := checkoutMgrForActivation.GenerateCheckoutAppTokenSteps(c, checkoutPermissions) + steps = append(steps, checkoutAppTokenSteps...) + maps.Copy(outputs, checkoutMgrForActivation.CheckoutAppTokenOutputs()) + } + // Add reaction step right after generate_aw_info so it is shown to the user as fast as // possible. generate_aw_info runs first so its data is captured even if the reaction fails. // This runs in the activation job so it can use any configured github-token or github-app. diff --git a/pkg/workflow/compiler_github_mcp_steps.go b/pkg/workflow/compiler_github_mcp_steps.go index 7ece23965a..501d0cf37c 100644 --- a/pkg/workflow/compiler_github_mcp_steps.go +++ b/pkg/workflow/compiler_github_mcp_steps.go @@ -81,14 +81,18 @@ func (c *Compiler) generateGitHubMCPLockdownDetectionStep(yaml *strings.Builder, yaml.WriteString(" await determineAutomaticLockdown(github, context, core);\n") } -// generateGitHubMCPAppTokenMintingStep generates a step to mint a GitHub App token for GitHub MCP server -// This step is added when: -// - GitHub tool is enabled with app configuration -// The step mints an installation access token with permissions matching the agent job permissions -func (c *Compiler) generateGitHubMCPAppTokenMintingStep(yaml *strings.Builder, data *WorkflowData) { +// generateGitHubMCPAppTokenMintingSteps returns the YAML steps to mint a GitHub App token +// for the GitHub MCP server. The steps are generated with id: github-mcp-app-token and +// permissions derived from the agent job's declared permissions plus any extra permissions +// configured under tools.github.github-app.permissions. +// +// The returned steps are intended to be added to the activation job so that the +// app-id / private-key secrets never reach the agent job. The minted token is then +// consumed in the agent job via needs.activation.outputs.github_mcp_app_token. +func (c *Compiler) generateGitHubMCPAppTokenMintingSteps(data *WorkflowData) []string { // Check if GitHub tool has app configuration if data.ParsedTools == nil || data.ParsedTools.GitHub == nil || data.ParsedTools.GitHub.GitHubApp == nil { - return + return nil } app := data.ParsedTools.GitHub.GitHubApp @@ -125,18 +129,20 @@ func (c *Compiler) generateGitHubMCPAppTokenMintingStep(yaml *strings.Builder, d } // Generate the token minting step using the existing helper from safe_outputs_app.go - steps := c.buildGitHubAppTokenMintStep(app, permissions, "") + rawSteps := c.buildGitHubAppTokenMintStep(app, permissions, "") - // Modify the step ID to differentiate from safe-outputs app token - // Replace "safe-outputs-app-token" with "github-mcp-app-token" - for _, step := range steps { - modifiedStep := strings.ReplaceAll(step, "id: safe-outputs-app-token", "id: github-mcp-app-token") - yaml.WriteString(modifiedStep) + // Replace the default step ID with github-mcp-app-token to differentiate it from + // the safe-outputs app token. + var steps []string + for _, step := range rawSteps { + steps = append(steps, strings.ReplaceAll(step, "id: safe-outputs-app-token", "id: github-mcp-app-token")) } + return steps } // generateGitHubMCPAppTokenInvalidationStep generates a step to invalidate the GitHub App token for GitHub MCP server -// This step always runs (even on failure) to ensure tokens are properly cleaned up +// This step always runs (even on failure) to ensure tokens are properly cleaned up. +// The token was minted in the activation job and is referenced via needs.activation.outputs.github_mcp_app_token. func (c *Compiler) generateGitHubMCPAppTokenInvalidationStep(yaml *strings.Builder, data *WorkflowData) { // Check if GitHub tool has app configuration if data.ParsedTools == nil || data.ParsedTools.GitHub == nil || data.ParsedTools.GitHub.GitHubApp == nil { @@ -145,14 +151,22 @@ func (c *Compiler) generateGitHubMCPAppTokenInvalidationStep(yaml *strings.Build githubConfigLog.Print("Generating GitHub App token invalidation step for GitHub MCP server") - // Generate the token invalidation step using the existing helper from safe_outputs_app.go - steps := c.buildGitHubAppTokenInvalidationStep() + // The token was minted in the activation job; reference it via needs.activation.outputs. + const tokenExpr = "needs.activation.outputs.github_mcp_app_token" - // Modify the step references to use github-mcp-app-token instead of safe-outputs-app-token - for _, step := range steps { - modifiedStep := strings.ReplaceAll(step, "steps.safe-outputs-app-token.outputs.token", "steps.github-mcp-app-token.outputs.token") - yaml.WriteString(modifiedStep) - } + yaml.WriteString(" - name: Invalidate GitHub App token\n") + fmt.Fprintf(yaml, " if: always() && %s != ''\n", tokenExpr) + yaml.WriteString(" env:\n") + fmt.Fprintf(yaml, " TOKEN: ${{ %s }}\n", tokenExpr) + yaml.WriteString(" run: |\n") + yaml.WriteString(" echo \"Revoking GitHub App installation token...\"\n") + yaml.WriteString(" # GitHub CLI will auth with the token being revoked.\n") + yaml.WriteString(" gh api \\\n") + yaml.WriteString(" --method DELETE \\\n") + yaml.WriteString(" -H \"Authorization: token $TOKEN\" \\\n") + yaml.WriteString(" /installation/token || echo \"Token revoke may already be expired.\"\n") + yaml.WriteString(" \n") + yaml.WriteString(" echo \"Token invalidation step complete.\"\n") } // generateParseGuardVarsStep generates a step that parses the blocked-users, trusted-users, and diff --git a/pkg/workflow/compiler_main_job.go b/pkg/workflow/compiler_main_job.go index 38da664426..f1348d73b6 100644 --- a/pkg/workflow/compiler_main_job.go +++ b/pkg/workflow/compiler_main_job.go @@ -1,6 +1,7 @@ package workflow import ( + "errors" "fmt" "maps" "slices" @@ -67,8 +68,16 @@ func (c *Compiler) buildMainJob(data *WorkflowData, activationJobCreated bool) ( return nil, fmt.Errorf("failed to generate main job steps: %w", err) } - // Split the steps content into individual step entries + // Compiler invariant: the agent job must not mint GitHub App tokens. + // All token minting (create-github-app-token) must happen in the activation job so that + // app-id / private-key secrets never reach the agent's environment. Fail fast during + // compilation if this invariant is violated to catch regressions early. stepsContent := stepBuilder.String() + if strings.Contains(stepsContent, "create-github-app-token") { + return nil, errors.New("compiler invariant violated: agent job contains a GitHub App token minting step (create-github-app-token); token minting must only occur in the activation job") + } + + // Split the steps content into individual step entries if stepsContent != "" { steps = append(steps, stepsContent) } diff --git a/pkg/workflow/compiler_yaml_main_job.go b/pkg/workflow/compiler_yaml_main_job.go index 886d2a0c9a..6d9ee44133 100644 --- a/pkg/workflow/compiler_yaml_main_job.go +++ b/pkg/workflow/compiler_yaml_main_job.go @@ -28,23 +28,6 @@ func (c *Compiler) generateMainJobSteps(yaml *strings.Builder, data *WorkflowDat checkoutMgr.SetCrossRepoTargetRepo("${{ needs.activation.outputs.target_repo }}") } - // Generate GitHub App token minting steps for checkouts with app auth - // These must be emitted BEFORE the checkout steps that reference them - if checkoutMgr.HasAppAuth() { - compilerYamlLog.Print("Generating checkout app token minting steps") - var permissions *Permissions - if data.Permissions != "" { - parser := NewPermissionsParser(data.Permissions) - permissions = parser.ToPermissions() - } else { - permissions = NewPermissions() - } - appTokenSteps := checkoutMgr.GenerateCheckoutAppTokenSteps(c, permissions) - for _, step := range appTokenSteps { - yaml.WriteString(step) - } - } - // Add checkout step first if needed if needsCheckout { // Emit the default workspace checkout, applying any user-supplied overrides @@ -283,9 +266,6 @@ func (c *Compiler) generateMainJobSteps(yaml *strings.Builder, data *WorkflowDat // Add step to parse blocked-users and approval-labels guard variables into JSON arrays c.generateParseGuardVarsStep(yaml, data) - // Add GitHub MCP app token minting step if configured - c.generateGitHubMCPAppTokenMintingStep(yaml, data) - // Stop DIFC proxy before starting the MCP gateway. The proxy must be stopped first // to avoid double-filtering: the gateway uses the same guard policy for the agent phase. c.generateStopDIFCProxyStep(yaml, data) diff --git a/pkg/workflow/copilot_engine_execution.go b/pkg/workflow/copilot_engine_execution.go index ebf901de69..1718d92904 100644 --- a/pkg/workflow/copilot_engine_execution.go +++ b/pkg/workflow/copilot_engine_execution.go @@ -294,9 +294,11 @@ COPILOT_CLI_INSTRUCTION="$(cat /tmp/gh-aw/aw-prompts/prompt.txt)" } if hasGitHubTool(workflowData.ParsedTools) { - // If GitHub App is configured, use the app token (overrides custom and default tokens) + // If GitHub App is configured, use the app token minted in the activation job. + // The token is passed via needs.activation.outputs to keep app-id/private-key + // secrets out of the agent job. if workflowData.ParsedTools != nil && workflowData.ParsedTools.GitHub != nil && workflowData.ParsedTools.GitHub.GitHubApp != nil { - env["GITHUB_MCP_SERVER_TOKEN"] = "${{ steps.github-mcp-app-token.outputs.token }}" + env["GITHUB_MCP_SERVER_TOKEN"] = "${{ needs.activation.outputs.github_mcp_app_token }}" } else { customGitHubToken := getGitHubToken(workflowData.Tools["github"]) // Use effective token with precedence: custom > default diff --git a/pkg/workflow/github_mcp_app_token_test.go b/pkg/workflow/github_mcp_app_token_test.go index af0e86bcc6..fdde488778 100644 --- a/pkg/workflow/github_mcp_app_token_test.go +++ b/pkg/workflow/github_mcp_app_token_test.go @@ -96,7 +96,7 @@ Test workflow with GitHub MCP app token minting. require.NoError(t, err, "Failed to read lock file") lockContent := string(content) - // Verify token minting step is present + // Verify token minting step is present in the activation job assert.Contains(t, lockContent, "Generate GitHub App token", "Token minting step should be present") assert.Contains(t, lockContent, "actions/create-github-app-token", "Should use create-github-app-token action") assert.Contains(t, lockContent, "id: github-mcp-app-token", "Should use github-mcp-app-token as step ID") @@ -107,13 +107,16 @@ Test workflow with GitHub MCP app token minting. assert.Contains(t, lockContent, "permission-contents: read", "Should include contents read permission") assert.Contains(t, lockContent, "permission-issues: read", "Should include issues read permission") - // Verify token invalidation step is present + // Verify token is exposed as an activation job output + assert.Contains(t, lockContent, "github_mcp_app_token: ${{ steps.github-mcp-app-token.outputs.token }}", "Activation job should expose github_mcp_app_token output") + + // Verify token invalidation step is present in the agent job and references activation output assert.Contains(t, lockContent, "Invalidate GitHub App token", "Token invalidation step should be present") assert.Contains(t, lockContent, "if: always()", "Invalidation step should always run") - assert.Contains(t, lockContent, "steps.github-mcp-app-token.outputs.token", "Should reference github-mcp-app-token output") + assert.Contains(t, lockContent, "needs.activation.outputs.github_mcp_app_token", "Invalidation step should reference activation output") - // Verify the app token is used for GitHub MCP Server - assert.Contains(t, lockContent, "GITHUB_MCP_SERVER_TOKEN: ${{ steps.github-mcp-app-token.outputs.token }}", "Should use app token for GitHub MCP Server") + // Verify the app token is consumed from activation outputs in the agent job + assert.Contains(t, lockContent, "GITHUB_MCP_SERVER_TOKEN: ${{ needs.activation.outputs.github_mcp_app_token }}", "Should use activation output token for GitHub MCP Server") } // TestGitHubMCPAppTokenAndGitHubTokenMutuallyExclusive tests that setting both app and github-token is rejected @@ -188,18 +191,21 @@ Test app token with remote GitHub MCP Server. require.NoError(t, err, "Failed to read lock file") lockContent := string(content) - // Verify token minting step is present + // Verify token minting step is present in the activation job assert.Contains(t, lockContent, "Generate GitHub App token", "Token minting step should be present") assert.Contains(t, lockContent, "id: github-mcp-app-token", "Should use github-mcp-app-token as step ID") - // Verify the app token is used in the authorization header for remote mode - // The token should be in the HTTP config's Authorization header - if strings.Contains(lockContent, `"Authorization": "Bearer ${{ steps.github-mcp-app-token.outputs.token }}"`) { - // Success - app token is used - t.Log("App token correctly used in remote mode Authorization header") + // Verify the activation job exposes the token as an output + assert.Contains(t, lockContent, "github_mcp_app_token: ${{ steps.github-mcp-app-token.outputs.token }}", "Activation job should expose github_mcp_app_token output") + + // Verify the app token from activation outputs is used in the agent job + // The token should be referenced via needs.activation.outputs.github_mcp_app_token + if strings.Contains(lockContent, `"Authorization": "Bearer ${{ needs.activation.outputs.github_mcp_app_token }}"`) { + // Success - app token from activation is used in Authorization header + t.Log("App token from activation correctly used in remote mode Authorization header") } else { // Also check for the env var reference pattern used by Claude engine - assert.Contains(t, lockContent, "GITHUB_MCP_SERVER_TOKEN: ${{ steps.github-mcp-app-token.outputs.token }}", "Should use app token for GitHub MCP Server in remote mode") + assert.Contains(t, lockContent, "GITHUB_MCP_SERVER_TOKEN: ${{ needs.activation.outputs.github_mcp_app_token }}", "Should use activation output token for GitHub MCP Server in remote mode") } } @@ -306,9 +312,9 @@ Test that determine-automatic-lockdown is generated even when app is configured. assert.Contains(t, lockContent, "GITHUB_MCP_GUARD_MIN_INTEGRITY: ${{ steps.determine-automatic-lockdown.outputs.min_integrity }}", "Guard min-integrity env var should reference lockdown step output") assert.Contains(t, lockContent, "GITHUB_MCP_GUARD_REPOS: ${{ steps.determine-automatic-lockdown.outputs.repos }}", "Guard repos env var should reference lockdown step output") - // App token should still be minted and used + // App token should still be minted (in activation job) and consumed via activation outputs assert.Contains(t, lockContent, "id: github-mcp-app-token", "GitHub App token step should still be generated") - assert.Contains(t, lockContent, "GITHUB_MCP_SERVER_TOKEN: ${{ steps.github-mcp-app-token.outputs.token }}", "App token should be used for MCP server") + assert.Contains(t, lockContent, "GITHUB_MCP_SERVER_TOKEN: ${{ needs.activation.outputs.github_mcp_app_token }}", "App token from activation should be used for MCP server") } // TestGitHubMCPAppTokenWithDependabotToolset tests that permission-vulnerability-alerts is included @@ -529,3 +535,104 @@ Test that write is rejected in tools.github.github-app.permissions. assert.Contains(t, err.Error(), `"write" is not allowed`, "Error should mention that write is not allowed") assert.Contains(t, err.Error(), "members", "Error should mention the offending scope") } + +// TestAgentJobDoesNotMintGitHubAppTokens verifies the compiler invariant that no +// GitHub App token minting step (create-github-app-token) appears in the agent job. +// All minting must happen in the activation job so that app-id / private-key secrets +// never reach the agent's environment. +func TestAgentJobDoesNotMintGitHubAppTokens(t *testing.T) { + tests := []struct { + name string + markdown string + }{ + { + name: "tools.github.github-app token not minted in agent job", + markdown: `--- +on: issues +permissions: + contents: read + issues: read +strict: false +tools: + github: + mode: local + github-app: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} +--- + +Test workflow - MCP app token must not be minted in agent job. +`, + }, + { + name: "checkout.github-app token not minted in agent job", + markdown: `--- +on: issues +permissions: + contents: read +strict: false +checkout: + repository: myorg/private-repo + path: private + github-app: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} +--- + +Test workflow - checkout app token must not be minted in agent job. +`, + }, + { + name: "top-level github-app fallback for checkout not minted in agent job", + markdown: `--- +on: issues +permissions: + contents: read +strict: false +github-app: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} +checkout: + repository: myorg/private-repo + path: private +--- + +Test workflow - top-level github-app checkout token must not be minted in agent job. +`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + compiler := NewCompilerWithVersion("1.0.0") + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.md") + err := os.WriteFile(testFile, []byte(tt.markdown), 0644) + require.NoError(t, err, "Failed to write test file") + + err = compiler.CompileWorkflow(testFile) + require.NoError(t, err, "Workflow should compile successfully") + + lockFile := strings.TrimSuffix(testFile, ".md") + ".lock.yml" + content, err := os.ReadFile(lockFile) + require.NoError(t, err, "Failed to read lock file") + lockContent := string(content) + + // Locate the agent job section (after " agent:" and before the next top-level job) + agentJobStart := strings.Index(lockContent, "\n agent:\n") + require.NotEqual(t, -1, agentJobStart, "Agent job should be present") + + // Find the next top-level job after agent (or end of file) + nextJobStart := strings.Index(lockContent[agentJobStart+len("\n agent:\n"):], "\n ") + var agentJobContent string + if nextJobStart == -1 { + agentJobContent = lockContent[agentJobStart:] + } else { + agentJobContent = lockContent[agentJobStart : agentJobStart+len("\n agent:\n")+nextJobStart] + } + + assert.NotContains(t, agentJobContent, "create-github-app-token", + "Agent job must not mint GitHub App tokens; minting must be in activation job") + }) + } +} diff --git a/pkg/workflow/mcp_environment.go b/pkg/workflow/mcp_environment.go index b2f50b7555..28d963e43e 100644 --- a/pkg/workflow/mcp_environment.go +++ b/pkg/workflow/mcp_environment.go @@ -69,10 +69,12 @@ func collectMCPEnvironmentVariables(tools map[string]any, mcpTools []string, wor // Check if GitHub App is configured for token minting appConfigured := hasGitHubApp(githubTool) - // If GitHub App is configured, use the app token (overrides other tokens) + // If GitHub App is configured, use the app token minted in the activation job. + // The token is passed via needs.activation.outputs to keep app-id/private-key + // secrets out of the agent job. if appConfigured { - mcpEnvironmentLog.Print("Using GitHub App token for GitHub MCP server (overrides custom and default tokens)") - envVars["GITHUB_MCP_SERVER_TOKEN"] = "${{ steps.github-mcp-app-token.outputs.token }}" + mcpEnvironmentLog.Print("Using GitHub App token from activation job for GitHub MCP server (overrides custom and default tokens)") + envVars["GITHUB_MCP_SERVER_TOKEN"] = "${{ needs.activation.outputs.github_mcp_app_token }}" } else { // Otherwise, use custom token or default fallback customGitHubToken := getGitHubToken(githubTool)