Skip to content

Commit b8e0b8a

Browse files
authored
fix: expose safe-outputs.actions custom action tools to agent MCP toolset (#26291)
1 parent 549223d commit b8e0b8a

10 files changed

Lines changed: 146 additions & 30 deletions

.github/workflows/smoke-codex.lock.yml

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/workflow/compile_outputs_allowed_labels_test.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/github/gh-aw/pkg/stringutil"
1212

1313
"github.com/github/gh-aw/pkg/testutil"
14+
"github.com/stretchr/testify/require"
1415
)
1516

1617
func TestAllowedLabelsConfigParsing(t *testing.T) {
@@ -302,7 +303,8 @@ This workflow tests that allowed-labels are included in safe outputs config JSON
302303
}
303304

304305
// Generate safe outputs config
305-
configJSON := generateSafeOutputsConfig(workflowData)
306+
configJSON, err := generateSafeOutputsConfig(workflowData)
307+
require.NoError(t, err, "generateSafeOutputsConfig should not return an error")
306308

307309
// Verify that allowed_labels is in the config
308310
if !strings.Contains(configJSON, `"allowed_labels"`) {

pkg/workflow/dispatch_repository_test.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -478,11 +478,12 @@ func TestDispatchRepositoryConfigSerialization(t *testing.T) {
478478
},
479479
}
480480

481-
configJSON := generateSafeOutputsConfig(workflowData)
481+
configJSON, err := generateSafeOutputsConfig(workflowData)
482+
require.NoError(t, err, "generateSafeOutputsConfig should not return an error")
482483
require.NotEmpty(t, configJSON, "Config JSON should not be empty")
483484

484485
var config map[string]any
485-
err := json.Unmarshal([]byte(configJSON), &config)
486+
err = json.Unmarshal([]byte(configJSON), &config)
486487
require.NoError(t, err, "Config JSON should be valid")
487488

488489
dispatchRepo, ok := config["dispatch_repository"].(map[string]any)

pkg/workflow/dispatch_workflow_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -381,7 +381,8 @@ This workflow dispatches to different workflow types.
381381
"yml-test should use .yml extension")
382382

383383
// Generate safe outputs config to verify workflow_files is included
384-
configJSON := generateSafeOutputsConfig(workflowData)
384+
configJSON, err := generateSafeOutputsConfig(workflowData)
385+
require.NoError(t, err, "generateSafeOutputsConfig should not return an error")
385386
require.NotEmpty(t, configJSON, "Config JSON should not be empty")
386387

387388
// Parse config to verify workflow_files is present

pkg/workflow/mcp_setup_generator.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,11 @@ func (c *Compiler) generateMCPSetup(yaml *strings.Builder, tools map[string]any,
129129
// Generate safe-outputs configuration once to avoid duplicate computation
130130
var safeOutputConfig string
131131
if HasSafeOutputsEnabled(workflowData.SafeOutputs) {
132-
safeOutputConfig = generateSafeOutputsConfig(workflowData)
132+
var err error
133+
safeOutputConfig, err = generateSafeOutputsConfig(workflowData)
134+
if err != nil {
135+
return fmt.Errorf("failed to generate safe outputs config: %w", err)
136+
}
133137
}
134138

135139
// Sort tools to ensure stable code generation

pkg/workflow/safe_jobs_test.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ package workflow
55
import (
66
"strings"
77
"testing"
8+
9+
"github.com/stretchr/testify/require"
810
)
911

1012
func TestParseSafeJobsConfig(t *testing.T) {
@@ -392,7 +394,8 @@ func TestSafeJobsInSafeOutputsConfig(t *testing.T) {
392394
},
393395
}
394396

395-
configJSON := generateSafeOutputsConfig(workflowData)
397+
configJSON, err := generateSafeOutputsConfig(workflowData)
398+
require.NoError(t, err, "generateSafeOutputsConfig should not return an error")
396399

397400
if configJSON == "" {
398401
t.Fatal("Expected safe-outputs config JSON to be generated")

pkg/workflow/safe_outputs_config_generation.go

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,10 @@ import (
2525
// generateSafeOutputsConfig generates the JSON configuration for the safe-outputs
2626
// MCP server. Standard handler configs are sourced from handlerRegistry to ensure
2727
// they stay in sync with GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG.
28-
func generateSafeOutputsConfig(data *WorkflowData) string {
28+
func generateSafeOutputsConfig(data *WorkflowData) (string, error) {
2929
if data.SafeOutputs == nil {
3030
safeOutputsConfigLog.Print("No safe outputs configuration found, returning empty config")
31-
return ""
31+
return "", nil
3232
}
3333
safeOutputsConfigLog.Print("Generating safe outputs configuration for workflow")
3434

@@ -109,6 +109,26 @@ func generateSafeOutputsConfig(data *WorkflowData) string {
109109
}
110110
}
111111

112+
// Safe-actions configuration: custom GitHub Actions exposed as safe output tools.
113+
// The normalized action names are added as config keys so both MCP server implementations
114+
// recognise them as enabled tools (the tool schema is already in tools.json via
115+
// tools_meta.json; the MCP server just needs to see the name in config.json).
116+
if len(data.SafeOutputs.Actions) > 0 {
117+
safeOutputsConfigLog.Printf("Processing %d safe action configurations", len(data.SafeOutputs.Actions))
118+
for actionName := range data.SafeOutputs.Actions {
119+
normalizedName := stringutil.NormalizeSafeOutputIdentifier(actionName)
120+
if _, exists := safeOutputsConfig[normalizedName]; exists {
121+
return "", fmt.Errorf(
122+
"safe-outputs action %q has a normalized name %q that conflicts with an existing safe outputs config entry; rename the action to avoid the conflict",
123+
actionName,
124+
normalizedName,
125+
)
126+
}
127+
safeOutputsConfigLog.Printf("Adding safe action to config: %s (normalized: %s)", actionName, normalizedName)
128+
safeOutputsConfig[normalizedName] = true
129+
}
130+
}
131+
112132
// Mentions configuration: controls which @mentions are allowed in AI output.
113133
// This is consumed by the ingestion step, not by standard handlers.
114134
if data.SafeOutputs.Mentions != nil {
@@ -165,11 +185,11 @@ func generateSafeOutputsConfig(data *WorkflowData) string {
165185
}
166186

167187
if len(safeOutputsConfig) == 0 {
168-
return ""
188+
return "", nil
169189
}
170190
configJSON, _ := json.Marshal(safeOutputsConfig)
171191
safeOutputsConfigLog.Printf("Safe outputs config generation complete: %d tool types configured", len(safeOutputsConfig))
172-
return string(configJSON)
192+
return string(configJSON), nil
173193
}
174194

175195
// generateCustomJobToolDefinition creates an MCP tool definition for a custom safe-output job.

pkg/workflow/safe_outputs_config_generation_test.go

Lines changed: 96 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ jobs:
4343
},
4444
}
4545

46-
result := generateSafeOutputsConfig(data)
46+
result, err := generateSafeOutputsConfig(data)
47+
require.NoError(t, err, "generateSafeOutputsConfig should not return an error")
4748
require.NotEmpty(t, result, "Expected non-empty config")
4849

4950
var parsed map[string]any
@@ -59,6 +60,70 @@ jobs:
5960
assert.Equal(t, ".lock.yml", workflowFiles["ci"], "ci should map to .lock.yml")
6061
}
6162

63+
// TestGenerateSafeOutputsConfigActions tests that generateSafeOutputsConfig includes custom
64+
// action tool names as enabled keys so both MCP server implementations register them.
65+
func TestGenerateSafeOutputsConfigActions(t *testing.T) {
66+
data := &WorkflowData{
67+
SafeOutputs: &SafeOutputsConfig{
68+
Actions: map[string]*SafeOutputActionConfig{
69+
"upload_report": {
70+
Uses: "actions/upload-artifact@v4",
71+
Description: "Upload the report",
72+
},
73+
"publish-results": {
74+
Uses: "owner/action@v1",
75+
Description: "Publish results",
76+
},
77+
},
78+
},
79+
}
80+
81+
result, err := generateSafeOutputsConfig(data)
82+
require.NoError(t, err, "generateSafeOutputsConfig should not return an error")
83+
require.NotEmpty(t, result, "Expected non-empty config")
84+
85+
var parsed map[string]any
86+
require.NoError(t, json.Unmarshal([]byte(result), &parsed), "Result must be valid JSON")
87+
88+
// Each action tool should appear as a truthy key in config.json so the MCP server
89+
// registers it. Names are normalized (hyphens converted to underscores).
90+
uploadVal, hasUploadReport := parsed["upload_report"]
91+
assert.True(t, hasUploadReport, "Expected upload_report key in config")
92+
assert.Equal(t, true, uploadVal, "upload_report value should be true")
93+
94+
publishVal, hasPublishResults := parsed["publish_results"]
95+
assert.True(t, hasPublishResults, "Expected publish_results key in config (hyphen normalized to underscore)")
96+
assert.Equal(t, true, publishVal, "publish_results value should be true")
97+
}
98+
99+
// TestGenerateSafeOutputsConfigActionsCollisionReturnsError tests that a custom action
100+
// whose normalized name collides with an existing built-in handler key returns an error.
101+
func TestGenerateSafeOutputsConfigActionsCollisionReturnsError(t *testing.T) {
102+
trueVal := "true"
103+
data := &WorkflowData{
104+
SafeOutputs: &SafeOutputsConfig{
105+
// add_labels is a built-in handler that produces a real config object.
106+
AddLabels: &AddLabelsConfig{
107+
Allowed: []string{"bug"},
108+
},
109+
// A custom action whose normalized name matches the built-in "add_labels" key.
110+
Actions: map[string]*SafeOutputActionConfig{
111+
"add-labels": {
112+
Uses: "owner/some-action@v1",
113+
Description: "Should trigger a collision error",
114+
},
115+
},
116+
// Ensure at least one handler is set to make config non-empty.
117+
NoOp: &NoOpConfig{BaseSafeOutputConfig: BaseSafeOutputConfig{Max: &trueVal}},
118+
},
119+
}
120+
121+
_, err := generateSafeOutputsConfig(data)
122+
require.Error(t, err, "Expected an error when a custom action name collides with a built-in handler key")
123+
assert.Contains(t, err.Error(), "add-labels", "Error should mention the conflicting action name")
124+
assert.Contains(t, err.Error(), "add_labels", "Error should mention the conflicting normalized name")
125+
}
126+
62127
// TestGenerateSafeOutputsConfigMissingToolWithIssue tests the missing_tool config.
63128
// The legacy create_missing_tool_issue sub-key is no longer generated; only missing_tool is present.
64129
func TestGenerateSafeOutputsConfigMissingToolWithIssue(t *testing.T) {
@@ -73,7 +138,8 @@ func TestGenerateSafeOutputsConfigMissingToolWithIssue(t *testing.T) {
73138
},
74139
}
75140

76-
result := generateSafeOutputsConfig(data)
141+
result, err := generateSafeOutputsConfig(data)
142+
require.NoError(t, err, "generateSafeOutputsConfig should not return an error")
77143
require.NotEmpty(t, result, "Expected non-empty config")
78144

79145
var parsed map[string]any
@@ -105,7 +171,8 @@ func TestGenerateSafeOutputsConfigMentions(t *testing.T) {
105171
},
106172
}
107173

108-
result := generateSafeOutputsConfig(data)
174+
result, err := generateSafeOutputsConfig(data)
175+
require.NoError(t, err, "generateSafeOutputsConfig should not return an error")
109176
require.NotEmpty(t, result, "Expected non-empty config")
110177

111178
var parsed map[string]any
@@ -347,7 +414,8 @@ func TestGenerateSafeOutputsConfigAddLabelsBlocked(t *testing.T) {
347414
},
348415
}
349416

350-
result := generateSafeOutputsConfig(data)
417+
result, err := generateSafeOutputsConfig(data)
418+
require.NoError(t, err, "generateSafeOutputsConfig should not return an error")
351419
require.NotEmpty(t, result, "Expected non-empty config")
352420

353421
var parsed map[string]any
@@ -386,7 +454,8 @@ func TestGenerateSafeOutputsConfigCreatePullRequestTargetRepo(t *testing.T) {
386454
},
387455
}
388456

389-
result := generateSafeOutputsConfig(data)
457+
result, err := generateSafeOutputsConfig(data)
458+
require.NoError(t, err, "generateSafeOutputsConfig should not return an error")
390459
require.NotEmpty(t, result, "Expected non-empty config")
391460

392461
var parsed map[string]any
@@ -429,7 +498,8 @@ func TestGenerateSafeOutputsConfigCreatePullRequestBackwardCompat(t *testing.T)
429498
},
430499
}
431500

432-
result := generateSafeOutputsConfig(data)
501+
result, err := generateSafeOutputsConfig(data)
502+
require.NoError(t, err, "generateSafeOutputsConfig should not return an error")
433503
require.NotEmpty(t, result, "Expected non-empty config")
434504

435505
var parsed map[string]any
@@ -462,7 +532,8 @@ func TestGenerateSafeOutputsConfigCreatePullRequestAutoCloseIssue(t *testing.T)
462532
},
463533
}
464534

465-
result := generateSafeOutputsConfig(data)
535+
result, err := generateSafeOutputsConfig(data)
536+
require.NoError(t, err, "generateSafeOutputsConfig should not return an error")
466537
require.NotEmpty(t, result, "Expected non-empty config")
467538

468539
var parsed map[string]any
@@ -487,7 +558,8 @@ func TestGenerateSafeOutputsConfigCreatePullRequestAutoCloseIssueExpression(t *t
487558
},
488559
}
489560

490-
result := generateSafeOutputsConfig(data)
561+
result, err := generateSafeOutputsConfig(data)
562+
require.NoError(t, err, "generateSafeOutputsConfig should not return an error")
491563
require.NotEmpty(t, result, "Expected non-empty config")
492564

493565
var parsed map[string]any
@@ -510,7 +582,8 @@ func TestGenerateSafeOutputsConfigCreatePullRequestAutoCloseIssueOmittedByDefaul
510582
},
511583
}
512584

513-
result := generateSafeOutputsConfig(data)
585+
result, err := generateSafeOutputsConfig(data)
586+
require.NoError(t, err, "generateSafeOutputsConfig should not return an error")
514587
require.NotEmpty(t, result, "Expected non-empty config")
515588

516589
var parsed map[string]any
@@ -546,7 +619,8 @@ func TestGenerateSafeOutputsConfigRepoMemory(t *testing.T) {
546619
},
547620
}
548621

549-
result := generateSafeOutputsConfig(data)
622+
result, err := generateSafeOutputsConfig(data)
623+
require.NoError(t, err, "generateSafeOutputsConfig should not return an error")
550624
require.NotEmpty(t, result, "Expected non-empty config")
551625

552626
var parsed map[string]any
@@ -586,7 +660,8 @@ func TestGenerateSafeOutputsConfigNoRepoMemory(t *testing.T) {
586660
RepoMemoryConfig: nil,
587661
}
588662

589-
result := generateSafeOutputsConfig(data)
663+
result, err := generateSafeOutputsConfig(data)
664+
require.NoError(t, err, "generateSafeOutputsConfig should not return an error")
590665
require.NotEmpty(t, result, "Expected non-empty config")
591666

592667
var parsed map[string]any
@@ -609,7 +684,8 @@ func TestGenerateSafeOutputsConfigEmptyRepoMemory(t *testing.T) {
609684
},
610685
}
611686

612-
result := generateSafeOutputsConfig(data)
687+
result, err := generateSafeOutputsConfig(data)
688+
require.NoError(t, err, "generateSafeOutputsConfig should not return an error")
613689
require.NotEmpty(t, result, "Expected non-empty config")
614690

615691
var parsed map[string]any
@@ -632,7 +708,8 @@ func TestGenerateSafeOutputsConfigReplyToPullRequestReviewComment(t *testing.T)
632708
},
633709
}
634710

635-
result := generateSafeOutputsConfig(data)
711+
result, err := generateSafeOutputsConfig(data)
712+
require.NoError(t, err, "generateSafeOutputsConfig should not return an error")
636713
require.NotEmpty(t, result, "Expected non-empty config")
637714

638715
var parsed map[string]any
@@ -661,7 +738,8 @@ func TestGenerateSafeOutputsConfigReplyToPullRequestReviewCommentWithTarget(t *t
661738
},
662739
}
663740

664-
result := generateSafeOutputsConfig(data)
741+
result, err := generateSafeOutputsConfig(data)
742+
require.NoError(t, err, "generateSafeOutputsConfig should not return an error")
665743
require.NotEmpty(t, result, "Expected non-empty config")
666744

667745
var parsed map[string]any
@@ -705,7 +783,8 @@ func TestGenerateSafeOutputsConfigClosePullRequest(t *testing.T) {
705783
},
706784
}
707785

708-
result := generateSafeOutputsConfig(data)
786+
result, err := generateSafeOutputsConfig(data)
787+
require.NoError(t, err, "generateSafeOutputsConfig should not return an error")
709788
require.NotEmpty(t, result, "Expected non-empty config")
710789

711790
var parsed map[string]any
@@ -743,7 +822,8 @@ func TestGenerateSafeOutputsConfigClosePullRequestStaged(t *testing.T) {
743822
},
744823
}
745824

746-
result := generateSafeOutputsConfig(data)
825+
result, err := generateSafeOutputsConfig(data)
826+
require.NoError(t, err, "generateSafeOutputsConfig should not return an error")
747827
require.NotEmpty(t, result, "Expected non-empty config")
748828

749829
var parsed map[string]any

pkg/workflow/safe_outputs_mentions_test.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ package workflow
55
import (
66
"encoding/json"
77
"testing"
8+
9+
"github.com/stretchr/testify/require"
810
)
911

1012
func TestParseMentionsConfig_Boolean(t *testing.T) {
@@ -216,9 +218,10 @@ func TestGenerateSafeOutputsConfig_WithMentions(t *testing.T) {
216218
},
217219
}
218220

219-
configJSON := generateSafeOutputsConfig(data)
221+
configJSON, err := generateSafeOutputsConfig(data)
222+
require.NoError(t, err, "generateSafeOutputsConfig should not return an error")
220223
var parsed map[string]any
221-
err := json.Unmarshal([]byte(configJSON), &parsed)
224+
err = json.Unmarshal([]byte(configJSON), &parsed)
222225
if err != nil {
223226
t.Fatalf("Failed to parse config JSON: %v", err)
224227
}

0 commit comments

Comments
 (0)