From 5199712df054f9451e539e26010017bd7d2ccf9d Mon Sep 17 00:00:00 2001 From: "Jiaxiao (mossaka) Zhou" Date: Tue, 31 Mar 2026 18:37:20 +0000 Subject: [PATCH 01/10] feat: auto-generate --allow-host-service-ports from services: port mappings (#23756) When a workflow uses GitHub Actions services: with port mappings, the agent running inside AWF's isolated network cannot reach service containers without explicit port configuration. The compiler now automatically detects service ports and emits ${{ job.services..ports[''] }} expressions as --allow-host-service-ports in the AWF command. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/workflow/awf_helpers.go | 10 + .../compiler_orchestrator_workflow.go | 12 + pkg/workflow/compiler_types.go | 1 + pkg/workflow/service_ports.go | 197 ++++++++++++ pkg/workflow/service_ports_test.go | 304 ++++++++++++++++++ 5 files changed, 524 insertions(+) create mode 100644 pkg/workflow/service_ports.go create mode 100644 pkg/workflow/service_ports_test.go diff --git a/pkg/workflow/awf_helpers.go b/pkg/workflow/awf_helpers.go index f97884d9b68..ca0647c1d50 100644 --- a/pkg/workflow/awf_helpers.go +++ b/pkg/workflow/awf_helpers.go @@ -89,6 +89,16 @@ func BuildAWFCommand(config AWFCommandConfig) string { ghAwDir, ghAwDir, ghAwDir, ghAwDir, ) + // Add --allow-host-service-ports for services with port mappings. + // This is appended as a raw (expandable) arg because the value contains + // ${{ job.services..ports[''] }} expressions that include single quotes. + // These expressions are resolved by the GitHub Actions runner before shell execution, + // so they must not be shell-escaped. + if config.WorkflowData != nil && config.WorkflowData.ServicePortExpressions != "" { + expandableArgs += fmt.Sprintf(` --allow-host-service-ports "%s"`, config.WorkflowData.ServicePortExpressions) + awfHelpersLog.Printf("Added --allow-host-service-ports with %s", config.WorkflowData.ServicePortExpressions) + } + // Wrap engine command in shell (command already includes any internal setup like npm PATH) shellWrappedCommand := WrapCommandInShell(config.EngineCommand) diff --git a/pkg/workflow/compiler_orchestrator_workflow.go b/pkg/workflow/compiler_orchestrator_workflow.go index 27705687863..fbe599dc200 100644 --- a/pkg/workflow/compiler_orchestrator_workflow.go +++ b/pkg/workflow/compiler_orchestrator_workflow.go @@ -515,6 +515,18 @@ func (c *Compiler) processAndMergeServices(frontmatter map[string]any, workflowD } } } + + // Extract service port expressions for AWF --allow-host-service-ports + if workflowData.Services != "" { + expressions, warnings := ExtractServicePortExpressions(workflowData.Services) + workflowData.ServicePortExpressions = expressions + for _, w := range warnings { + orchestratorWorkflowLog.Printf("Warning: %s", w) + } + if expressions != "" { + orchestratorWorkflowLog.Printf("Extracted service port expressions: %s", expressions) + } + } } // mergeJobsFromYAMLImports merges jobs from imported YAML workflows with main workflow jobs diff --git a/pkg/workflow/compiler_types.go b/pkg/workflow/compiler_types.go index f81bc0beee9..1dc770ea241 100644 --- a/pkg/workflow/compiler_types.go +++ b/pkg/workflow/compiler_types.go @@ -435,6 +435,7 @@ type WorkflowData struct { ConcurrencyJobDiscriminator string // optional discriminator expression appended to job-level concurrency groups (from concurrency.job-discriminator) IsDetectionRun bool // true when this WorkflowData is used for inline threat detection (not the main agent run) EngineConfigSteps []map[string]any // steps returned by engine.RenderConfig — prepended before execution steps + ServicePortExpressions string // comma-separated ${{ job.services..ports[''] }} expressions for AWF --allow-host-service-ports } // BaseSafeOutputConfig holds common configuration fields for all safe output types diff --git a/pkg/workflow/service_ports.go b/pkg/workflow/service_ports.go new file mode 100644 index 00000000000..cc8020c113e --- /dev/null +++ b/pkg/workflow/service_ports.go @@ -0,0 +1,197 @@ +// This file provides helper functions for extracting service port mappings from +// GitHub Actions services: configuration and generating ${{ job.services..ports[''] }} +// expressions for AWF's --allow-host-service-ports flag. +// +// When a workflow uses GitHub Actions services: with port mappings (e.g., PostgreSQL, Redis), +// the compiled workflow runs the agent inside AWF's isolated network. The agent cannot reach +// service containers without explicit --allow-host-service-ports configuration. This file +// automatically detects service ports and generates the necessary expressions. + +package workflow + +import ( + "fmt" + "sort" + "strconv" + "strings" + + "github.com/github/gh-aw/pkg/logger" + goyaml "gopkg.in/yaml.v3" +) + +var servicePortsLog = logger.New("workflow:service_ports") + +// maxPortRangeExpansion is the maximum number of ports to expand from a range specification. +// This prevents accidentally generating thousands of expressions from a large port range. +const maxPortRangeExpansion = 32 + +// ExtractServicePortExpressions parses the services: YAML string from WorkflowData.Services +// and returns a comma-separated string of ${{ job.services..ports[''] }} expressions +// for all TCP port mappings found. +// +// The returned string is suitable for passing as --allow-host-service-ports to AWF. +// Returns empty string if no services or no port mappings are found. +// +// Parameters: +// - servicesYAML: Raw YAML string from WorkflowData.Services (includes "services:" wrapper) +// +// Returns: +// - expressions: Comma-separated ${{ }} expressions for all service ports +// - warnings: Any warnings generated during parsing (e.g., UDP ports, services without ports) +func ExtractServicePortExpressions(servicesYAML string) (string, []string) { + if servicesYAML == "" { + return "", nil + } + + servicePortsLog.Print("Extracting service port expressions from services YAML") + + // Parse the services YAML + var wrapper map[string]any + if err := goyaml.Unmarshal([]byte(servicesYAML), &wrapper); err != nil { + servicePortsLog.Printf("Failed to parse services YAML: %v", err) + return "", nil + } + + services, ok := wrapper["services"].(map[string]any) + if !ok { + servicePortsLog.Print("No services map found in YAML") + return "", nil + } + + var expressions []string + var warnings []string + + // Sort service IDs for deterministic output + serviceIDs := make([]string, 0, len(services)) + for id := range services { + serviceIDs = append(serviceIDs, id) + } + sort.Strings(serviceIDs) + + for _, serviceID := range serviceIDs { + serviceConfig := services[serviceID] + svcMap, ok := serviceConfig.(map[string]any) + if !ok { + servicePortsLog.Printf("Service %s is not a map, skipping", serviceID) + continue + } + + ports, hasPorts := svcMap["ports"] + if !hasPorts { + warnings = append(warnings, fmt.Sprintf("service %q has no ports mapping; it will not be reachable from the AWF sandbox", serviceID)) + servicePortsLog.Printf("Service %s has no ports, skipping", serviceID) + continue + } + + portsList, ok := ports.([]any) + if !ok { + servicePortsLog.Printf("Service %s ports is not a list, skipping", serviceID) + continue + } + + for _, portSpec := range portsList { + containerPorts, portWarnings := parsePortSpec(portSpec) + for _, w := range portWarnings { + warnings = append(warnings, fmt.Sprintf("service %q: %s", serviceID, w)) + } + for _, cp := range containerPorts { + expr := fmt.Sprintf("${{ job.services.%s.ports['%d'] }}", serviceID, cp) + expressions = append(expressions, expr) + } + } + } + + if len(expressions) == 0 { + servicePortsLog.Print("No service port expressions generated") + return "", warnings + } + + result := strings.Join(expressions, ",") + servicePortsLog.Printf("Generated %d service port expressions", len(expressions)) + return result, warnings +} + +// parsePortSpec parses a single port specification and returns the container port(s). +// Supports formats: +// - "5432:5432" (host:container) +// - "5432" (container only, dynamic host port) +// - "49152:5432" (remapped host port) +// - "5432/tcp" (explicit TCP) +// - "5432/udp" (skipped with warning) +// - "6000-6010:6000-6010" (range) +// - 5432 (integer) +// +// Returns container port numbers and any warnings. +func parsePortSpec(spec any) ([]int, []string) { + var portStr string + switch v := spec.(type) { + case int: + return []int{v}, nil + case float64: + // YAML may parse unquoted numbers as float64 + return []int{int(v)}, nil + case string: + portStr = v + default: + return nil, []string{fmt.Sprintf("unsupported port spec type %T: %v", spec, spec)} + } + + portStr = strings.TrimSpace(portStr) + if portStr == "" { + return nil, nil + } + + // Check for protocol suffix + protocol := "tcp" + if idx := strings.LastIndex(portStr, "/"); idx != -1 { + protocol = strings.ToLower(portStr[idx+1:]) + portStr = portStr[:idx] + } + + if protocol == "udp" { + return nil, []string{fmt.Sprintf("UDP port %q skipped; AWF only supports TCP", portStr)} + } + + // Split host:container + var containerPart string + if idx := strings.Index(portStr, ":"); idx != -1 { + containerPart = portStr[idx+1:] + } else { + containerPart = portStr + } + + // Check for port range (e.g., "6000-6010") + if idx := strings.Index(containerPart, "-"); idx != -1 { + startStr := containerPart[:idx] + endStr := containerPart[idx+1:] + + start, err1 := strconv.Atoi(startStr) + end, err2 := strconv.Atoi(endStr) + if err1 != nil || err2 != nil { + return nil, []string{fmt.Sprintf("invalid port range %q", containerPart)} + } + + if end < start { + return nil, []string{fmt.Sprintf("invalid port range %q: end < start", containerPart)} + } + + count := end - start + 1 + if count > maxPortRangeExpansion { + return nil, []string{fmt.Sprintf("port range %q expands to %d ports, exceeding cap of %d", containerPart, count, maxPortRangeExpansion)} + } + + ports := make([]int, 0, count) + for p := start; p <= end; p++ { + ports = append(ports, p) + } + return ports, nil + } + + // Single port + port, err := strconv.Atoi(containerPart) + if err != nil { + return nil, []string{fmt.Sprintf("invalid port number %q", containerPart)} + } + + return []int{port}, nil +} diff --git a/pkg/workflow/service_ports_test.go b/pkg/workflow/service_ports_test.go new file mode 100644 index 00000000000..8a548d94c85 --- /dev/null +++ b/pkg/workflow/service_ports_test.go @@ -0,0 +1,304 @@ +//go:build !integration + +package workflow + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParsePortSpec(t *testing.T) { + tests := []struct { + name string + spec any + expectedPorts []int + warnContains string + }{ + { + name: "explicit host:container mapping", + spec: "5432:5432", + expectedPorts: []int{5432}, + }, + { + name: "container port only (dynamic host)", + spec: "5432", + expectedPorts: []int{5432}, + }, + { + name: "remapped host port", + spec: "49152:5432", + expectedPorts: []int{5432}, + }, + { + name: "explicit TCP protocol", + spec: "5432/tcp", + expectedPorts: []int{5432}, + }, + { + name: "UDP port skipped", + spec: "5432/udp", + expectedPorts: nil, + warnContains: "UDP", + }, + { + name: "integer port spec", + spec: 5432, + expectedPorts: []int{5432}, + }, + { + name: "float64 port spec (YAML parsing)", + spec: float64(5432), + expectedPorts: []int{5432}, + }, + { + name: "port range", + spec: "6000-6002:6000-6002", + expectedPorts: []int{6000, 6001, 6002}, + }, + { + name: "port range container only", + spec: "6000-6002", + expectedPorts: []int{6000, 6001, 6002}, + }, + { + name: "host:container with TCP suffix", + spec: "5432:5432/tcp", + expectedPorts: []int{5432}, + }, + { + name: "invalid port number", + spec: "abc", + expectedPorts: nil, + warnContains: "invalid port number", + }, + { + name: "invalid port range (end < start)", + spec: "6010-6000", + expectedPorts: nil, + warnContains: "end < start", + }, + { + name: "port range exceeding cap", + spec: "1000-2000", + expectedPorts: nil, + warnContains: "exceeding cap", + }, + { + name: "unsupported type", + spec: true, + expectedPorts: nil, + warnContains: "unsupported port spec type", + }, + { + name: "empty string", + spec: "", + expectedPorts: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ports, warnings := parsePortSpec(tt.spec) + assert.Equal(t, tt.expectedPorts, ports) + + if tt.warnContains != "" { + require.NotEmpty(t, warnings, "expected a warning containing %q", tt.warnContains) + found := false + for _, w := range warnings { + if servicePortsContains(w, tt.warnContains) { + found = true + break + } + } + assert.True(t, found, "expected warning containing %q, got %v", tt.warnContains, warnings) + } + }) + } +} + +func servicePortsContains(s, substr string) bool { + return len(s) >= len(substr) && servicePortsSearchSubstring(s, substr) +} + +func servicePortsSearchSubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} + +func TestExtractServicePortExpressions(t *testing.T) { + tests := []struct { + name string + servicesYAML string + expectedResult string + expectedWarnings []string + }{ + { + name: "empty services YAML", + servicesYAML: "", + expectedResult: "", + }, + { + name: "single service with single port", + servicesYAML: `services: + postgres: + image: postgres:15 + ports: + - 5432:5432 +`, + expectedResult: "${{ job.services.postgres.ports['5432'] }}", + }, + { + name: "multiple services with ports", + servicesYAML: `services: + postgres: + image: postgres:15 + ports: + - 5432:5432 + redis: + image: redis:7 + ports: + - 6379:6379 +`, + expectedResult: "${{ job.services.postgres.ports['5432'] }},${{ job.services.redis.ports['6379'] }}", + }, + { + name: "service with multiple ports", + servicesYAML: `services: + mydb: + image: mydb:latest + ports: + - 5432:5432 + - 8080:8080 +`, + expectedResult: "${{ job.services.mydb.ports['5432'] }},${{ job.services.mydb.ports['8080'] }}", + }, + { + name: "service without ports emits warning", + servicesYAML: `services: + postgres: + image: postgres:15 +`, + expectedResult: "", + expectedWarnings: []string{"service \"postgres\" has no ports mapping"}, + }, + { + name: "mixed services: some with ports, some without", + servicesYAML: `services: + postgres: + image: postgres:15 + ports: + - 5432:5432 + redis: + image: redis:7 +`, + expectedResult: "${{ job.services.postgres.ports['5432'] }}", + expectedWarnings: []string{"service \"redis\" has no ports mapping"}, + }, + { + name: "UDP port skipped with warning", + servicesYAML: `services: + myservice: + image: myservice:latest + ports: + - 5432:5432/udp +`, + expectedResult: "", + expectedWarnings: []string{"UDP"}, + }, + { + name: "port range expansion", + servicesYAML: `services: + myservice: + image: myservice:latest + ports: + - 6000-6002:6000-6002 +`, + expectedResult: "${{ job.services.myservice.ports['6000'] }},${{ job.services.myservice.ports['6001'] }},${{ job.services.myservice.ports['6002'] }}", + }, + { + name: "dynamic host port (container port only)", + servicesYAML: `services: + postgres: + image: postgres:15 + ports: + - 5432 +`, + expectedResult: "${{ job.services.postgres.ports['5432'] }}", + }, + { + name: "remapped host port uses container port in expression", + servicesYAML: `services: + postgres: + image: postgres:15 + ports: + - 49152:5432 +`, + expectedResult: "${{ job.services.postgres.ports['5432'] }}", + }, + { + name: "invalid YAML returns empty", + servicesYAML: "not: valid: yaml: [", + expectedResult: "", + }, + { + name: "integer port values", + servicesYAML: `services: + postgres: + image: postgres:15 + ports: + - 5432 +`, + expectedResult: "${{ job.services.postgres.ports['5432'] }}", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, warnings := ExtractServicePortExpressions(tt.servicesYAML) + assert.Equal(t, tt.expectedResult, result) + + if tt.expectedWarnings != nil { + for _, expectedWarning := range tt.expectedWarnings { + found := false + for _, w := range warnings { + if servicePortsContains(w, expectedWarning) { + found = true + break + } + } + assert.True(t, found, "expected warning containing %q, got %v", expectedWarning, warnings) + } + } + }) + } +} + +func TestExtractServicePortExpressions_DeterministicOrder(t *testing.T) { + // Run multiple times to verify deterministic ordering + servicesYAML := `services: + zeta: + image: zeta:latest + ports: + - 1111:1111 + alpha: + image: alpha:latest + ports: + - 2222:2222 + middle: + image: middle:latest + ports: + - 3333:3333 +` + expected := "${{ job.services.alpha.ports['2222'] }},${{ job.services.middle.ports['3333'] }},${{ job.services.zeta.ports['1111'] }}" + + for i := range 10 { + result, _ := ExtractServicePortExpressions(servicesYAML) + assert.Equal(t, expected, result, "iteration %d: order should be deterministic (alphabetical by service ID)", i) + } +} From 51a51ae684118ea0be19434f162b4377e03a4ce7 Mon Sep 17 00:00:00 2001 From: "Jiaxiao (mossaka) Zhou" Date: Tue, 31 Mar 2026 19:19:34 +0000 Subject: [PATCH 02/10] fix: address review feedback on service ports implementation - Switch from gopkg.in/yaml.v3 to goccy/go-yaml for consistency with the rest of pkg/workflow, adding uint64/int64 type cases for goccy's integer decoding behavior - Replace strings.Index with strings.Cut to satisfy the modernize linter - Surface invalid ports format as a user-visible warning instead of only logging at debug level - Replace custom substring search helpers with strings.Contains - Add test cases for hyphenated service IDs and invalid ports format Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/workflow/service_ports.go | 21 ++++++++++------- pkg/workflow/service_ports_test.go | 38 ++++++++++++++++++------------ 2 files changed, 35 insertions(+), 24 deletions(-) diff --git a/pkg/workflow/service_ports.go b/pkg/workflow/service_ports.go index cc8020c113e..2c2e35534b4 100644 --- a/pkg/workflow/service_ports.go +++ b/pkg/workflow/service_ports.go @@ -16,7 +16,7 @@ import ( "strings" "github.com/github/gh-aw/pkg/logger" - goyaml "gopkg.in/yaml.v3" + "github.com/goccy/go-yaml" ) var servicePortsLog = logger.New("workflow:service_ports") @@ -47,7 +47,7 @@ func ExtractServicePortExpressions(servicesYAML string) (string, []string) { // Parse the services YAML var wrapper map[string]any - if err := goyaml.Unmarshal([]byte(servicesYAML), &wrapper); err != nil { + if err := yaml.Unmarshal([]byte(servicesYAML), &wrapper); err != nil { servicePortsLog.Printf("Failed to parse services YAML: %v", err) return "", nil } @@ -86,6 +86,7 @@ func ExtractServicePortExpressions(servicesYAML string) (string, []string) { portsList, ok := ports.([]any) if !ok { servicePortsLog.Printf("Service %s ports is not a list, skipping", serviceID) + warnings = append(warnings, fmt.Sprintf("service %q has an invalid ports mapping (expected a list); it will not be reachable from the AWF sandbox", serviceID)) continue } @@ -127,8 +128,13 @@ func parsePortSpec(spec any) ([]int, []string) { switch v := spec.(type) { case int: return []int{v}, nil + case uint64: + // goccy/go-yaml decodes unquoted integers as uint64 + return []int{int(v)}, nil + case int64: + return []int{int(v)}, nil case float64: - // YAML may parse unquoted numbers as float64 + // Some YAML libraries parse unquoted numbers as float64 return []int{int(v)}, nil case string: portStr = v @@ -154,17 +160,14 @@ func parsePortSpec(spec any) ([]int, []string) { // Split host:container var containerPart string - if idx := strings.Index(portStr, ":"); idx != -1 { - containerPart = portStr[idx+1:] + if _, after, found := strings.Cut(portStr, ":"); found { + containerPart = after } else { containerPart = portStr } // Check for port range (e.g., "6000-6010") - if idx := strings.Index(containerPart, "-"); idx != -1 { - startStr := containerPart[:idx] - endStr := containerPart[idx+1:] - + if startStr, endStr, found := strings.Cut(containerPart, "-"); found { start, err1 := strconv.Atoi(startStr) end, err2 := strconv.Atoi(endStr) if err1 != nil || err2 != nil { diff --git a/pkg/workflow/service_ports_test.go b/pkg/workflow/service_ports_test.go index 8a548d94c85..9178b16e58b 100644 --- a/pkg/workflow/service_ports_test.go +++ b/pkg/workflow/service_ports_test.go @@ -3,6 +3,7 @@ package workflow import ( + "strings" "testing" "github.com/stretchr/testify/assert" @@ -107,7 +108,7 @@ func TestParsePortSpec(t *testing.T) { require.NotEmpty(t, warnings, "expected a warning containing %q", tt.warnContains) found := false for _, w := range warnings { - if servicePortsContains(w, tt.warnContains) { + if strings.Contains(w, tt.warnContains) { found = true break } @@ -118,19 +119,6 @@ func TestParsePortSpec(t *testing.T) { } } -func servicePortsContains(s, substr string) bool { - return len(s) >= len(substr) && servicePortsSearchSubstring(s, substr) -} - -func servicePortsSearchSubstring(s, substr string) bool { - for i := 0; i <= len(s)-len(substr); i++ { - if s[i:i+len(substr)] == substr { - return true - } - } - return false -} - func TestExtractServicePortExpressions(t *testing.T) { tests := []struct { name string @@ -256,6 +244,26 @@ func TestExtractServicePortExpressions(t *testing.T) { `, expectedResult: "${{ job.services.postgres.ports['5432'] }}", }, + { + name: "hyphenated service ID", + servicesYAML: `services: + my-postgres: + image: postgres:15 + ports: + - 5432:5432 +`, + expectedResult: "${{ job.services.my-postgres.ports['5432'] }}", + }, + { + name: "invalid ports format (not a list) emits warning", + servicesYAML: `services: + postgres: + image: postgres:15 + ports: 5432 +`, + expectedResult: "", + expectedWarnings: []string{"invalid ports mapping"}, + }, } for _, tt := range tests { @@ -267,7 +275,7 @@ func TestExtractServicePortExpressions(t *testing.T) { for _, expectedWarning := range tt.expectedWarnings { found := false for _, w := range warnings { - if servicePortsContains(w, expectedWarning) { + if strings.Contains(w, expectedWarning) { found = true break } From 4ed5092b684229aeccaa026d066824c99e845e88 Mon Sep 17 00:00:00 2001 From: "Jiaxiao (mossaka) Zhou" Date: Tue, 31 Mar 2026 19:27:29 +0000 Subject: [PATCH 03/10] fix: surface service port warnings to stderr for user visibility Emit service port warnings through console.FormatWarningMessage to stderr, matching the existing warning pattern used by action_pins.go and other compilation warnings in the package. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/workflow/compiler_orchestrator_workflow.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/workflow/compiler_orchestrator_workflow.go b/pkg/workflow/compiler_orchestrator_workflow.go index fbe599dc200..ced3dc48d41 100644 --- a/pkg/workflow/compiler_orchestrator_workflow.go +++ b/pkg/workflow/compiler_orchestrator_workflow.go @@ -4,8 +4,10 @@ import ( "encoding/json" "fmt" "maps" + "os" "strings" + "github.com/github/gh-aw/pkg/console" "github.com/github/gh-aw/pkg/logger" "github.com/github/gh-aw/pkg/parser" "github.com/goccy/go-yaml" @@ -522,6 +524,7 @@ func (c *Compiler) processAndMergeServices(frontmatter map[string]any, workflowD workflowData.ServicePortExpressions = expressions for _, w := range warnings { orchestratorWorkflowLog.Printf("Warning: %s", w) + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(w)) } if expressions != "" { orchestratorWorkflowLog.Printf("Extracted service port expressions: %s", expressions) From 6634b449af8cdcf484c8bdec1818a29bd409abc3 Mon Sep 17 00:00:00 2001 From: "Jiaxiao (mossaka) Zhou" Date: Tue, 31 Mar 2026 19:44:13 +0000 Subject: [PATCH 04/10] test: add smoke test for service ports with Redis Adds a smoke-service-ports workflow that validates the --allow-host-service-ports feature end-to-end. The workflow declares a Redis service container and instructs the agent to connect to Redis on localhost:6379, verifying PING/PONG, SET/GET round-trip, and INFO server queries. Closes part of #23756 (smoke test validation). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../workflows/smoke-service-ports.lock.yml | 1184 +++++++++++++++++ .github/workflows/smoke-service-ports.md | 89 ++ 2 files changed, 1273 insertions(+) create mode 100644 .github/workflows/smoke-service-ports.lock.yml create mode 100644 .github/workflows/smoke-service-ports.md diff --git a/.github/workflows/smoke-service-ports.lock.yml b/.github/workflows/smoke-service-ports.lock.yml new file mode 100644 index 00000000000..5d503a833ea --- /dev/null +++ b/.github/workflows/smoke-service-ports.lock.yml @@ -0,0 +1,1184 @@ +# ___ _ _ +# / _ \ | | (_) +# | |_| | __ _ ___ _ __ | |_ _ ___ +# | _ |/ _` |/ _ \ '_ \| __| |/ __| +# | | | | (_| | __/ | | | |_| | (__ +# \_| |_/\__, |\___|_| |_|\__|_|\___| +# __/ | +# _ _ |___/ +# | | | | / _| | +# | | | | ___ _ __ _ __| |_| | _____ ____ +# | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___| +# \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \ +# \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/ +# +# This file was automatically generated by gh-aw. DO NOT EDIT. +# +# To update this file, edit the corresponding .md file and run: +# gh aw compile +# Not all edits will cause changes to this file. +# +# For more information: https://github.github.com/gh-aw/introduction/overview/ +# +# Smoke test to validate --allow-host-service-ports with Redis service container +# +# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"ae34c194b622281e356eaea165500ebe16130384aea9af1cd9505aa7104a0414","strict":true,"agent_id":"copilot"} + +name: "Smoke Service Ports" +"on": + pull_request: + # names: # Label filtering applied via job conditions + # - smoke # Label filtering applied via job conditions + types: + - labeled + schedule: + - cron: "18 */12 * * *" + workflow_dispatch: + inputs: + aw_context: + default: "" + description: Agent caller context (used internally by Agentic Workflows). + required: false + type: string + +permissions: {} + +concurrency: + group: "gh-aw-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref || github.run_id }}" + cancel-in-progress: true + +run-name: "Smoke Service Ports" + +jobs: + activation: + needs: pre_activation + if: > + needs.pre_activation.outputs.activated == 'true' && ((github.event_name != 'pull_request' || github.event.pull_request.head.repo.id == github.repository_id) && + (github.event_name != 'pull_request' || github.event.action != 'labeled' || github.event.label.name == 'smoke')) + runs-on: ubuntu-slim + permissions: + contents: read + discussions: write + issues: write + pull-requests: write + outputs: + body: ${{ steps.sanitized.outputs.body }} + comment_id: ${{ steps.add-comment.outputs.comment-id }} + comment_repo: ${{ steps.add-comment.outputs.comment-repo }} + comment_url: ${{ steps.add-comment.outputs.comment-url }} + lockdown_check_failed: ${{ steps.generate_aw_info.outputs.lockdown_check_failed == 'true' }} + model: ${{ steps.generate_aw_info.outputs.model }} + secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} + text: ${{ steps.sanitized.outputs.text }} + title: ${{ steps.sanitized.outputs.title }} + steps: + - name: Checkout actions folder + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + repository: github/gh-aw + sparse-checkout: | + actions + persist-credentials: false + - name: Setup Scripts + uses: ./actions/setup + with: + destination: ${{ runner.temp }}/gh-aw/actions + - name: Generate agentic run info + id: generate_aw_info + env: + GH_AW_INFO_ENGINE_ID: "copilot" + GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI" + GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || 'auto' }} + GH_AW_INFO_VERSION: "latest" + GH_AW_INFO_AGENT_VERSION: "latest" + GH_AW_INFO_WORKFLOW_NAME: "Smoke Service Ports" + GH_AW_INFO_EXPERIMENTAL: "false" + GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true" + GH_AW_INFO_STAGED: "false" + GH_AW_INFO_ALLOWED_DOMAINS: '["defaults","github"]' + GH_AW_INFO_FIREWALL_ENABLED: "true" + GH_AW_INFO_AWF_VERSION: "v0.25.1" + GH_AW_INFO_AWMG_VERSION: "" + GH_AW_INFO_FIREWALL_TYPE: "squid" + GH_AW_COMPILED_STRICT: "true" + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_aw_info.cjs'); + await main(core, context); + - name: Validate COPILOT_GITHUB_TOKEN secret + id: validate-secret + run: ${RUNNER_TEMP}/gh-aw/actions/validate_multi_secret.sh COPILOT_GITHUB_TOKEN 'GitHub Copilot CLI' https://github.github.com/gh-aw/reference/engines/#github-copilot-default + env: + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + - name: Checkout .github and .agents folders + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + sparse-checkout: | + .github + .agents + actions/setup + sparse-checkout-cone-mode: true + fetch-depth: 1 + - name: Check workflow file timestamps + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_WORKFLOW_FILE: "smoke-service-ports.lock.yml" + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/check_workflow_timestamp_api.cjs'); + await main(); + - name: Compute current body text + id: sanitized + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/compute_text.cjs'); + await main(); + - name: Add comment with workflow run link + id: add-comment + if: github.event_name == 'issues' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment' || github.event_name == 'discussion' || github.event_name == 'discussion_comment' || github.event_name == 'pull_request' && github.event.pull_request.head.repo.id == github.repository_id + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_WORKFLOW_NAME: "Smoke Service Ports" + GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🔌 *Service ports validation by [{workflow_name}]({run_url})*{history_link}\",\"runStarted\":\"🔌 Starting service ports validation... [{workflow_name}]({run_url}) is testing Redis connectivity...\",\"runSuccess\":\"✅ Service ports validation passed! [{workflow_name}]({run_url}) confirms agent can reach Redis.\",\"runFailure\":\"❌ Service ports validation failed! [{workflow_name}]({run_url}) could not reach Redis: {status}\"}" + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/add_workflow_run_comment.cjs'); + await main(); + - name: Create prompt with built-in context + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_SAFE_OUTPUTS: ${{ runner.temp }}/gh-aw/safeoutputs/outputs.jsonl + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + # poutine:ignore untrusted_checkout_exec + run: | + bash ${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh + { + cat << 'GH_AW_PROMPT_102bb7d969c921ff_EOF' + + GH_AW_PROMPT_102bb7d969c921ff_EOF + cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md" + cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md" + cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md" + cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md" + cat << 'GH_AW_PROMPT_102bb7d969c921ff_EOF' + + Tools: add_comment(max:2), missing_tool, missing_data, noop + + + The following GitHub context information is available for this workflow: + {{#if __GH_AW_GITHUB_ACTOR__ }} + - **actor**: __GH_AW_GITHUB_ACTOR__ + {{/if}} + {{#if __GH_AW_GITHUB_REPOSITORY__ }} + - **repository**: __GH_AW_GITHUB_REPOSITORY__ + {{/if}} + {{#if __GH_AW_GITHUB_WORKSPACE__ }} + - **workspace**: __GH_AW_GITHUB_WORKSPACE__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ }} + - **issue-number**: #__GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ }} + - **discussion-number**: #__GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ }} + - **pull-request-number**: #__GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_COMMENT_ID__ }} + - **comment-id**: __GH_AW_GITHUB_EVENT_COMMENT_ID__ + {{/if}} + {{#if __GH_AW_GITHUB_RUN_ID__ }} + - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ + {{/if}} + + + GH_AW_PROMPT_102bb7d969c921ff_EOF + cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md" + cat << 'GH_AW_PROMPT_102bb7d969c921ff_EOF' + + GH_AW_PROMPT_102bb7d969c921ff_EOF + cat << 'GH_AW_PROMPT_102bb7d969c921ff_EOF' + {{#runtime-import .github/workflows/smoke-service-ports.md}} + GH_AW_PROMPT_102bb7d969c921ff_EOF + } > "$GH_AW_PROMPT" + - name: Interpolate variables and render templates + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/interpolate_prompt.cjs'); + await main(); + - name: Substitute placeholders + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: ${{ needs.pre_activation.outputs.activated }} + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + + const substitutePlaceholders = require('${{ runner.temp }}/gh-aw/actions/substitute_placeholders.cjs'); + + // Call the substitution function + return await substitutePlaceholders({ + file: process.env.GH_AW_PROMPT, + substitutions: { + GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR, + GH_AW_GITHUB_EVENT_COMMENT_ID: process.env.GH_AW_GITHUB_EVENT_COMMENT_ID, + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: process.env.GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER, + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: process.env.GH_AW_GITHUB_EVENT_ISSUE_NUMBER, + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: process.env.GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER, + GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY, + GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID, + GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE, + GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: process.env.GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED + } + }); + - name: Validate prompt placeholders + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + # poutine:ignore untrusted_checkout_exec + run: bash ${RUNNER_TEMP}/gh-aw/actions/validate_prompt_placeholders.sh + - name: Print prompt + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + # poutine:ignore untrusted_checkout_exec + run: bash ${RUNNER_TEMP}/gh-aw/actions/print_prompt_summary.sh + - name: Upload activation artifact + if: success() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + with: + name: activation + path: | + /tmp/gh-aw/aw_info.json + /tmp/gh-aw/aw-prompts/prompt.txt + retention-days: 1 + + agent: + needs: activation + runs-on: ubuntu-latest + services: + redis: + image: redis:7 + options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 + ports: + - 6379:6379 + permissions: + contents: read + issues: read + pull-requests: read + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + GH_AW_ASSETS_ALLOWED_EXTS: "" + GH_AW_ASSETS_BRANCH: "" + GH_AW_ASSETS_MAX_SIZE_KB: 0 + GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs + GH_AW_WORKFLOW_ID_SANITIZED: smokeserviceports + outputs: + checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }} + has_patch: ${{ steps.collect_output.outputs.has_patch }} + inference_access_error: ${{ steps.detect-inference-error.outputs.inference_access_error || 'false' }} + model: ${{ needs.activation.outputs.model }} + output: ${{ steps.collect_output.outputs.output }} + output_types: ${{ steps.collect_output.outputs.output_types }} + steps: + - name: Checkout actions folder + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + repository: github/gh-aw + sparse-checkout: | + actions + persist-credentials: false + - name: Setup Scripts + uses: ./actions/setup + with: + destination: ${{ runner.temp }}/gh-aw/actions + - name: Set runtime paths + id: set-runtime-paths + run: | + echo "GH_AW_SAFE_OUTPUTS=${RUNNER_TEMP}/gh-aw/safeoutputs/outputs.jsonl" >> "$GITHUB_OUTPUT" + echo "GH_AW_SAFE_OUTPUTS_CONFIG_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" >> "$GITHUB_OUTPUT" + echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json" >> "$GITHUB_OUTPUT" + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Create gh-aw temp directory + run: bash ${RUNNER_TEMP}/gh-aw/actions/create_gh_aw_tmp_dir.sh + - name: Configure gh CLI for GitHub Enterprise + run: bash ${RUNNER_TEMP}/gh-aw/actions/configure_gh_for_ghe.sh + env: + GH_TOKEN: ${{ github.token }} + - name: Configure Git credentials + env: + REPO_NAME: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + git config --global am.keepcr true + # Re-authenticate git with GitHub token + SERVER_URL_STRIPPED="${SERVER_URL#https://}" + git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + echo "Git configured with standard GitHub Actions identity" + - name: Checkout PR branch + id: checkout-pr + if: | + github.event.pull_request || github.event.issue.pull_request + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + with: + github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); + await main(); + - name: Install GitHub Copilot CLI + run: ${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh latest + env: + GH_HOST: github.com + - name: Install AWF binary + run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.25.1 + - name: Determine automatic lockdown mode for GitHub MCP Server + id: determine-automatic-lockdown + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} + GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} + with: + script: | + const determineAutomaticLockdown = require('${{ runner.temp }}/gh-aw/actions/determine_automatic_lockdown.cjs'); + await determineAutomaticLockdown(github, context, core); + - name: Download container images + run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.25.1 ghcr.io/github/gh-aw-firewall/api-proxy:0.25.1 ghcr.io/github/gh-aw-firewall/squid:0.25.1 ghcr.io/github/gh-aw-mcpg:v0.2.6 ghcr.io/github/github-mcp-server:v0.32.0 node:lts-alpine + - name: Write Safe Outputs Config + run: | + mkdir -p ${RUNNER_TEMP}/gh-aw/safeoutputs + mkdir -p /tmp/gh-aw/safeoutputs + mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs + cat > ${RUNNER_TEMP}/gh-aw/safeoutputs/config.json << 'GH_AW_SAFE_OUTPUTS_CONFIG_6860efc01d4c77d0_EOF' + {"add_comment":{"hide_older_comments":true,"max":2},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"}} + GH_AW_SAFE_OUTPUTS_CONFIG_6860efc01d4c77d0_EOF + - name: Write Safe Outputs Tools + run: | + cat > ${RUNNER_TEMP}/gh-aw/safeoutputs/tools_meta.json << 'GH_AW_SAFE_OUTPUTS_TOOLS_META_8b6fd3f8b6acdea6_EOF' + { + "description_suffixes": { + "add_comment": " CONSTRAINTS: Maximum 2 comment(s) can be added." + }, + "repo_params": {}, + "dynamic_tools": [] + } + GH_AW_SAFE_OUTPUTS_TOOLS_META_8b6fd3f8b6acdea6_EOF + cat > ${RUNNER_TEMP}/gh-aw/safeoutputs/validation.json << 'GH_AW_SAFE_OUTPUTS_VALIDATION_6b591b2c928c1050_EOF' + { + "add_comment": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "item_number": { + "issueOrPRNumber": true + }, + "repo": { + "type": "string", + "maxLength": 256 + } + } + }, + "missing_data": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "context": { + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "data_type": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "reason": { + "type": "string", + "sanitize": true, + "maxLength": 256 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + GH_AW_SAFE_OUTPUTS_VALIDATION_6b591b2c928c1050_EOF + node ${RUNNER_TEMP}/gh-aw/actions/generate_safe_outputs_tools.cjs + - name: Generate Safe Outputs MCP Server Config + id: safe-outputs-config + run: | + # Generate a secure random API key (360 bits of entropy, 40+ chars) + # Mask immediately to prevent timing vulnerabilities + API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + echo "::add-mask::${API_KEY}" + + PORT=3001 + + # Set outputs for next steps + { + echo "safe_outputs_api_key=${API_KEY}" + echo "safe_outputs_port=${PORT}" + } >> "$GITHUB_OUTPUT" + + echo "Safe Outputs MCP server will run on port ${PORT}" + + - name: Start Safe Outputs MCP HTTP Server + id: safe-outputs-start + env: + DEBUG: '*' + GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-config.outputs.safe_outputs_port }} + GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-config.outputs.safe_outputs_api_key }} + GH_AW_SAFE_OUTPUTS_TOOLS_PATH: ${{ runner.temp }}/gh-aw/safeoutputs/tools.json + GH_AW_SAFE_OUTPUTS_CONFIG_PATH: ${{ runner.temp }}/gh-aw/safeoutputs/config.json + GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs + run: | + # Environment variables are set above to prevent template injection + export DEBUG + export GH_AW_SAFE_OUTPUTS_PORT + export GH_AW_SAFE_OUTPUTS_API_KEY + export GH_AW_SAFE_OUTPUTS_TOOLS_PATH + export GH_AW_SAFE_OUTPUTS_CONFIG_PATH + export GH_AW_MCP_LOG_DIR + + bash ${RUNNER_TEMP}/gh-aw/actions/start_safe_outputs_server.sh + + - name: Start MCP Gateway + id: start-mcp-gateway + env: + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-start.outputs.api_key }} + GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-start.outputs.port }} + GITHUB_MCP_GUARD_MIN_INTEGRITY: ${{ steps.determine-automatic-lockdown.outputs.min_integrity }} + GITHUB_MCP_GUARD_REPOS: ${{ steps.determine-automatic-lockdown.outputs.repos }} + GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + run: | + set -eo pipefail + mkdir -p /tmp/gh-aw/mcp-config + + # Export gateway environment variables for MCP config and gateway script + export MCP_GATEWAY_PORT="80" + export MCP_GATEWAY_DOMAIN="host.docker.internal" + MCP_GATEWAY_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + echo "::add-mask::${MCP_GATEWAY_API_KEY}" + export MCP_GATEWAY_API_KEY + export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads" + mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}" + export MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD="524288" + export DEBUG="*" + + export GH_AW_ENGINE="copilot" + export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.2.6' + + mkdir -p /home/runner/.copilot + cat << GH_AW_MCP_CONFIG_c222985d02e378a2_EOF | bash ${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.sh + { + "mcpServers": { + "github": { + "type": "stdio", + "container": "ghcr.io/github/github-mcp-server:v0.32.0", + "env": { + "GITHUB_HOST": "\${GITHUB_SERVER_URL}", + "GITHUB_PERSONAL_ACCESS_TOKEN": "\${GITHUB_MCP_SERVER_TOKEN}", + "GITHUB_READ_ONLY": "1", + "GITHUB_TOOLSETS": "context,repos,issues,pull_requests" + }, + "guard-policies": { + "allow-only": { + "min-integrity": "$GITHUB_MCP_GUARD_MIN_INTEGRITY", + "repos": "$GITHUB_MCP_GUARD_REPOS" + } + } + }, + "safeoutputs": { + "type": "http", + "url": "http://host.docker.internal:$GH_AW_SAFE_OUTPUTS_PORT", + "headers": { + "Authorization": "\${GH_AW_SAFE_OUTPUTS_API_KEY}" + }, + "guard-policies": { + "write-sink": { + "accept": [ + "*" + ] + } + } + } + }, + "gateway": { + "port": $MCP_GATEWAY_PORT, + "domain": "${MCP_GATEWAY_DOMAIN}", + "apiKey": "${MCP_GATEWAY_API_KEY}", + "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}" + } + } + GH_AW_MCP_CONFIG_c222985d02e378a2_EOF + - name: Download activation artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: activation + path: /tmp/gh-aw + - name: Clean git credentials + continue-on-error: true + run: bash ${RUNNER_TEMP}/gh-aw/actions/clean_git_credentials.sh + - name: Execute GitHub Copilot CLI + id: agentic_execution + # Copilot CLI tool arguments (sorted): + timeout-minutes: 5 + run: | + set -o pipefail + touch /tmp/gh-aw/agent-step-summary.md + # 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" --allow-host-service-ports "${{ job.services.redis.ports['6379'] }}" --env-all --allow-domains '*.githubusercontent.com,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,codeload.github.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,docs.github.com,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.blog,github.com,github.githubassets.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.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.1 --skip-pull --enable-api-proxy \ + -- /bin/bash -c '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --allow-all-tools --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt "$(cat /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 }} + COPILOT_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || '' }} + GH_AW_MCP_CONFIG: /home/runner/.copilot/mcp-config.json + GH_AW_PHASE: agent + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + GH_AW_VERSION: dev + GITHUB_API_URL: ${{ github.api_url }} + GITHUB_AW: true + GITHUB_HEAD_REF: ${{ github.head_ref }} + GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + GITHUB_REF_NAME: ${{ github.ref_name }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md + GITHUB_WORKSPACE: ${{ github.workspace }} + GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com + GIT_AUTHOR_NAME: github-actions[bot] + GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com + GIT_COMMITTER_NAME: github-actions[bot] + XDG_CONFIG_HOME: /home/runner + - name: Detect inference access error + id: detect-inference-error + if: always() + continue-on-error: true + run: bash ${RUNNER_TEMP}/gh-aw/actions/detect_inference_access_error.sh + - name: Configure Git credentials + env: + REPO_NAME: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + git config --global am.keepcr true + # Re-authenticate git with GitHub token + SERVER_URL_STRIPPED="${SERVER_URL#https://}" + git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + echo "Git configured with standard GitHub Actions identity" + - name: Copy Copilot session state files to logs + if: always() + continue-on-error: true + run: | + # Copy Copilot session state files to logs folder for artifact collection + # This ensures they are in /tmp/gh-aw/ where secret redaction can scan them + SESSION_STATE_DIR="$HOME/.copilot/session-state" + LOGS_DIR="/tmp/gh-aw/sandbox/agent/logs" + + if [ -d "$SESSION_STATE_DIR" ]; then + echo "Copying Copilot session state files from $SESSION_STATE_DIR to $LOGS_DIR" + mkdir -p "$LOGS_DIR" + cp -v "$SESSION_STATE_DIR"/*.jsonl "$LOGS_DIR/" 2>/dev/null || true + echo "Session state files copied successfully" + else + echo "No session-state directory found at $SESSION_STATE_DIR" + fi + - name: Stop MCP Gateway + if: always() + continue-on-error: true + env: + MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }} + MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }} + GATEWAY_PID: ${{ steps.start-mcp-gateway.outputs.gateway-pid }} + run: | + bash ${RUNNER_TEMP}/gh-aw/actions/stop_mcp_gateway.sh "$GATEWAY_PID" + - name: Redact secrets in logs + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/redact_secrets.cjs'); + await main(); + env: + GH_AW_SECRET_NAMES: 'COPILOT_GITHUB_TOKEN,GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN' + SECRET_COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + SECRET_GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} + SECRET_GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} + SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Append agent step summary + if: always() + run: bash ${RUNNER_TEMP}/gh-aw/actions/append_agent_step_summary.sh + - name: Copy Safe Outputs + if: always() + env: + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + run: | + mkdir -p /tmp/gh-aw + cp "$GH_AW_SAFE_OUTPUTS" /tmp/gh-aw/safeoutputs.jsonl 2>/dev/null || true + - name: Ingest agent output + id: collect_output + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,127.0.0.1,::1,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,app.renovatebot.com,appveyor.com,archive.ubuntu.com,azure.archive.ubuntu.com,badgen.net,circleci.com,codacy.com,codeclimate.com,codecov.io,codeload.github.com,coveralls.io,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,deepsource.io,docs.github.com,drone.io,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.blog,github.com,github.githubassets.com,host.docker.internal,img.shields.io,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,localhost,objects.githubusercontent.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,readthedocs.io,readthedocs.org,registry.npmjs.org,renovatebot.com,s.symcb.com,s.symcd.com,security.ubuntu.com,semaphoreci.com,shields.io,snyk.io,sonarcloud.io,sonarqube.com,telemetry.enterprise.githubcopilot.com,travis-ci.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_API_URL: ${{ github.api_url }} + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/collect_ndjson_output.cjs'); + await main(); + - name: Parse agent logs for step summary + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: /tmp/gh-aw/sandbox/agent/logs/ + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_copilot_log.cjs'); + await main(); + - name: Parse MCP Gateway logs for step summary + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_mcp_gateway_log.cjs'); + await main(); + - name: Print firewall logs + if: always() + continue-on-error: true + env: + AWF_LOGS_DIR: /tmp/gh-aw/sandbox/firewall/logs + run: | + # Fix permissions on firewall logs so they can be uploaded as artifacts + # AWF runs with sudo, creating files owned by root + sudo chmod -R a+r /tmp/gh-aw/sandbox/firewall/logs 2>/dev/null || true + # Only run awf logs summary if awf command exists (it may not be installed if workflow failed before install step) + if command -v awf &> /dev/null; then + awf logs summary | tee -a "$GITHUB_STEP_SUMMARY" + else + echo 'AWF binary not installed, skipping firewall log summary' + fi + - name: Write agent output placeholder if missing + if: always() + run: | + if [ ! -f /tmp/gh-aw/agent_output.json ]; then + echo '{"items":[]}' > /tmp/gh-aw/agent_output.json + fi + - name: Upload agent artifacts + if: always() + continue-on-error: true + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + with: + name: agent + path: | + /tmp/gh-aw/aw-prompts/prompt.txt + /tmp/gh-aw/sandbox/agent/logs/ + /tmp/gh-aw/redacted-urls.log + /tmp/gh-aw/mcp-logs/ + /tmp/gh-aw/agent-stdio.log + /tmp/gh-aw/agent/ + /tmp/gh-aw/safeoutputs.jsonl + /tmp/gh-aw/agent_output.json + /tmp/gh-aw/aw-*.patch + if-no-files-found: ignore + - name: Upload firewall audit logs + if: always() + continue-on-error: true + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + with: + name: firewall-audit-logs + path: | + /tmp/gh-aw/sandbox/firewall/logs/ + /tmp/gh-aw/sandbox/firewall/audit/ + if-no-files-found: ignore + + conclusion: + needs: + - activation + - agent + - detection + - safe_outputs + if: always() && (needs.agent.result != 'skipped' || needs.activation.outputs.lockdown_check_failed == 'true') + runs-on: ubuntu-slim + permissions: + contents: read + discussions: write + issues: write + pull-requests: write + concurrency: + group: "gh-aw-conclusion-smoke-service-ports" + cancel-in-progress: false + outputs: + noop_message: ${{ steps.noop.outputs.noop_message }} + tools_reported: ${{ steps.missing_tool.outputs.tools_reported }} + total_count: ${{ steps.missing_tool.outputs.total_count }} + steps: + - name: Checkout actions folder + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + repository: github/gh-aw + sparse-checkout: | + actions + persist-credentials: false + - name: Setup Scripts + uses: ./actions/setup + with: + destination: ${{ runner.temp }}/gh-aw/actions + - name: Download agent output artifact + id: download-agent-output + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: agent + path: /tmp/gh-aw/ + - name: Setup agent output environment variable + id: setup-agent-output-env + if: steps.download-agent-output.outcome == 'success' + run: | + mkdir -p /tmp/gh-aw/ + find "/tmp/gh-aw/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" + - name: Process No-Op Messages + id: noop + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_NOOP_MAX: "1" + GH_AW_WORKFLOW_NAME: "Smoke Service Ports" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/noop.cjs'); + await main(); + - name: Record Missing Tool + id: missing_tool + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_NAME: "Smoke Service Ports" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/missing_tool.cjs'); + await main(); + - name: Handle Agent Failure + id: handle_agent_failure + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_NAME: "Smoke Service Ports" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_WORKFLOW_ID: "smoke-service-ports" + GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.activation.outputs.secret_verification_result }} + GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }} + GH_AW_INFERENCE_ACCESS_ERROR: ${{ needs.agent.outputs.inference_access_error }} + GH_AW_LOCKDOWN_CHECK_FAILED: ${{ needs.activation.outputs.lockdown_check_failed }} + GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🔌 *Service ports validation by [{workflow_name}]({run_url})*{history_link}\",\"runStarted\":\"🔌 Starting service ports validation... [{workflow_name}]({run_url}) is testing Redis connectivity...\",\"runSuccess\":\"✅ Service ports validation passed! [{workflow_name}]({run_url}) confirms agent can reach Redis.\",\"runFailure\":\"❌ Service ports validation failed! [{workflow_name}]({run_url}) could not reach Redis: {status}\"}" + GH_AW_GROUP_REPORTS: "false" + GH_AW_FAILURE_REPORT_AS_ISSUE: "true" + GH_AW_TIMEOUT_MINUTES: "5" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_agent_failure.cjs'); + await main(); + - name: Handle No-Op Message + id: handle_noop_message + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_NAME: "Smoke Service Ports" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_NOOP_MESSAGE: ${{ steps.noop.outputs.noop_message }} + GH_AW_NOOP_REPORT_AS_ISSUE: "true" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_noop_message.cjs'); + await main(); + - name: Update reaction comment with completion status + id: conclusion + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_COMMENT_ID: ${{ needs.activation.outputs.comment_id }} + GH_AW_COMMENT_REPO: ${{ needs.activation.outputs.comment_repo }} + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_WORKFLOW_NAME: "Smoke Service Ports" + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_DETECTION_CONCLUSION: ${{ needs.detection.outputs.detection_conclusion }} + GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🔌 *Service ports validation by [{workflow_name}]({run_url})*{history_link}\",\"runStarted\":\"🔌 Starting service ports validation... [{workflow_name}]({run_url}) is testing Redis connectivity...\",\"runSuccess\":\"✅ Service ports validation passed! [{workflow_name}]({run_url}) confirms agent can reach Redis.\",\"runFailure\":\"❌ Service ports validation failed! [{workflow_name}]({run_url}) could not reach Redis: {status}\"}" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/notify_comment_error.cjs'); + await main(); + + detection: + needs: agent + if: always() && needs.agent.result != 'skipped' + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + detection_conclusion: ${{ steps.detection_conclusion.outputs.conclusion }} + detection_success: ${{ steps.detection_conclusion.outputs.success }} + steps: + - name: Checkout actions folder + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + repository: github/gh-aw + sparse-checkout: | + actions + persist-credentials: false + - name: Setup Scripts + uses: ./actions/setup + with: + destination: ${{ runner.temp }}/gh-aw/actions + - name: Download agent output artifact + id: download-agent-output + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: agent + path: /tmp/gh-aw/ + - name: Setup agent output environment variable + id: setup-agent-output-env + if: steps.download-agent-output.outcome == 'success' + run: | + mkdir -p /tmp/gh-aw/ + find "/tmp/gh-aw/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" + # --- Threat Detection --- + - name: Download container images + run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.25.1 ghcr.io/github/gh-aw-firewall/api-proxy:0.25.1 ghcr.io/github/gh-aw-firewall/squid:0.25.1 + - name: Check if detection needed + id: detection_guard + if: always() + env: + OUTPUT_TYPES: ${{ needs.agent.outputs.output_types }} + HAS_PATCH: ${{ needs.agent.outputs.has_patch }} + run: | + if [[ -n "$OUTPUT_TYPES" || "$HAS_PATCH" == "true" ]]; then + echo "run_detection=true" >> "$GITHUB_OUTPUT" + echo "Detection will run: output_types=$OUTPUT_TYPES, has_patch=$HAS_PATCH" + else + echo "run_detection=false" >> "$GITHUB_OUTPUT" + echo "Detection skipped: no agent outputs or patches to analyze" + fi + - name: Clear MCP configuration for detection + if: always() && steps.detection_guard.outputs.run_detection == 'true' + run: | + rm -f /tmp/gh-aw/mcp-config/mcp-servers.json + rm -f /home/runner/.copilot/mcp-config.json + rm -f "$GITHUB_WORKSPACE/.gemini/settings.json" + - name: Prepare threat detection files + if: always() && steps.detection_guard.outputs.run_detection == 'true' + run: | + mkdir -p /tmp/gh-aw/threat-detection/aw-prompts + cp /tmp/gh-aw/aw-prompts/prompt.txt /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt 2>/dev/null || true + cp /tmp/gh-aw/agent_output.json /tmp/gh-aw/threat-detection/agent_output.json 2>/dev/null || true + for f in /tmp/gh-aw/aw-*.patch; do + [ -f "$f" ] && cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true + done + echo "Prepared threat detection files:" + ls -la /tmp/gh-aw/threat-detection/ 2>/dev/null || true + - name: Setup threat detection + if: always() && steps.detection_guard.outputs.run_detection == 'true' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + WORKFLOW_NAME: "Smoke Service Ports" + WORKFLOW_DESCRIPTION: "Smoke test to validate --allow-host-service-ports with Redis service container" + HAS_PATCH: ${{ needs.agent.outputs.has_patch }} + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/setup_threat_detection.cjs'); + await main(); + - name: Ensure threat-detection directory and log + if: always() && steps.detection_guard.outputs.run_detection == 'true' + run: | + mkdir -p /tmp/gh-aw/threat-detection + touch /tmp/gh-aw/threat-detection/detection.log + - name: Install GitHub Copilot CLI + run: ${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh latest + env: + GH_HOST: github.com + - name: Install AWF binary + run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.25.1 + - name: Execute GitHub Copilot CLI + if: always() && steps.detection_guard.outputs.run_detection == 'true' + id: detection_agentic_execution + # Copilot CLI tool arguments (sorted): + timeout-minutes: 20 + run: | + set -o pipefail + touch /tmp/gh-aw/agent-step-summary.md + # 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 --allow-domains api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,github.com,host.docker.internal,telemetry.enterprise.githubcopilot.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.1 --skip-pull --enable-api-proxy \ + -- /bin/bash -c '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --allow-all-tools --add-dir "${GITHUB_WORKSPACE}" --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log + env: + COPILOT_AGENT_RUNNER_TYPE: STANDALONE + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + COPILOT_MODEL: ${{ vars.GH_AW_MODEL_DETECTION_COPILOT || '' }} + GH_AW_PHASE: detection + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_VERSION: dev + GITHUB_API_URL: ${{ github.api_url }} + GITHUB_AW: true + GITHUB_HEAD_REF: ${{ github.head_ref }} + GITHUB_REF_NAME: ${{ github.ref_name }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md + GITHUB_WORKSPACE: ${{ github.workspace }} + GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com + GIT_AUTHOR_NAME: github-actions[bot] + GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com + GIT_COMMITTER_NAME: github-actions[bot] + XDG_CONFIG_HOME: /home/runner + - name: Upload threat detection log + if: always() && steps.detection_guard.outputs.run_detection == 'true' + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + with: + name: detection + path: /tmp/gh-aw/threat-detection/detection.log + if-no-files-found: ignore + - name: Parse and conclude threat detection + id: detection_conclusion + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + RUN_DETECTION: ${{ steps.detection_guard.outputs.run_detection }} + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_threat_detection_results.cjs'); + await main(); + + pre_activation: + if: > + (github.event_name != 'pull_request' || github.event.pull_request.head.repo.id == github.repository_id) && + (github.event_name != 'pull_request' || github.event.action != 'labeled' || github.event.label.name == 'smoke') + runs-on: ubuntu-slim + permissions: + contents: read + outputs: + activated: ${{ steps.check_membership.outputs.is_team_member == 'true' }} + matched_command: '' + steps: + - name: Checkout actions folder + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + repository: github/gh-aw + sparse-checkout: | + actions + persist-credentials: false + - name: Setup Scripts + uses: ./actions/setup + with: + destination: ${{ runner.temp }}/gh-aw/actions + - name: Check team membership for workflow + id: check_membership + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_REQUIRED_ROLES: "admin,maintainer,write" + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/check_membership.cjs'); + await main(); + + safe_outputs: + needs: + - agent + - detection + if: (!cancelled()) && needs.agent.result != 'skipped' && needs.detection.result == 'success' + runs-on: ubuntu-slim + permissions: + contents: read + discussions: write + issues: write + pull-requests: write + timeout-minutes: 15 + env: + GH_AW_CALLER_WORKFLOW_ID: "${{ github.repository }}/smoke-service-ports" + GH_AW_ENGINE_ID: "copilot" + GH_AW_ENGINE_MODEL: ${{ needs.agent.outputs.model }} + GH_AW_SAFE_OUTPUT_MESSAGES: "{\"footer\":\"\\u003e 🔌 *Service ports validation by [{workflow_name}]({run_url})*{history_link}\",\"runStarted\":\"🔌 Starting service ports validation... [{workflow_name}]({run_url}) is testing Redis connectivity...\",\"runSuccess\":\"✅ Service ports validation passed! [{workflow_name}]({run_url}) confirms agent can reach Redis.\",\"runFailure\":\"❌ Service ports validation failed! [{workflow_name}]({run_url}) could not reach Redis: {status}\"}" + GH_AW_WORKFLOW_ID: "smoke-service-ports" + GH_AW_WORKFLOW_NAME: "Smoke Service Ports" + outputs: + code_push_failure_count: ${{ steps.process_safe_outputs.outputs.code_push_failure_count }} + code_push_failure_errors: ${{ steps.process_safe_outputs.outputs.code_push_failure_errors }} + comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }} + comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }} + create_discussion_error_count: ${{ steps.process_safe_outputs.outputs.create_discussion_error_count }} + create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }} + process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }} + process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} + steps: + - name: Checkout actions folder + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + repository: github/gh-aw + sparse-checkout: | + actions + persist-credentials: false + - name: Setup Scripts + uses: ./actions/setup + with: + destination: ${{ runner.temp }}/gh-aw/actions + - name: Download agent output artifact + id: download-agent-output + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: agent + path: /tmp/gh-aw/ + - name: Setup agent output environment variable + id: setup-agent-output-env + if: steps.download-agent-output.outcome == 'success' + run: | + mkdir -p /tmp/gh-aw/ + find "/tmp/gh-aw/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" + - name: Configure GH_HOST for enterprise compatibility + id: ghes-host-config + shell: bash + run: | + # Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct + # GitHub instance (GHES/GHEC). On github.com this is a harmless no-op. + GH_HOST="${GITHUB_SERVER_URL#https://}" + GH_HOST="${GH_HOST#http://}" + echo "GH_HOST=${GH_HOST}" >> "$GITHUB_ENV" + - name: Process Safe Outputs + id: process_safe_outputs + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,127.0.0.1,::1,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,app.renovatebot.com,appveyor.com,archive.ubuntu.com,azure.archive.ubuntu.com,badgen.net,circleci.com,codacy.com,codeclimate.com,codecov.io,codeload.github.com,coveralls.io,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,deepsource.io,docs.github.com,drone.io,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.blog,github.com,github.githubassets.com,host.docker.internal,img.shields.io,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,localhost,objects.githubusercontent.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,readthedocs.io,readthedocs.org,registry.npmjs.org,renovatebot.com,s.symcb.com,s.symcd.com,security.ubuntu.com,semaphoreci.com,shields.io,snyk.io,sonarcloud.io,sonarqube.com,telemetry.enterprise.githubcopilot.com,travis-ci.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com" + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_API_URL: ${{ github.api_url }} + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"hide_older_comments\":true,\"max\":2},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"}}" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/safe_output_handler_manager.cjs'); + await main(); + - name: Upload Safe Output Items + if: always() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + with: + name: safe-output-items + path: /tmp/gh-aw/safe-output-items.jsonl + if-no-files-found: ignore + diff --git a/.github/workflows/smoke-service-ports.md b/.github/workflows/smoke-service-ports.md new file mode 100644 index 00000000000..cd1a48ac0c4 --- /dev/null +++ b/.github/workflows/smoke-service-ports.md @@ -0,0 +1,89 @@ +--- +description: Smoke test to validate --allow-host-service-ports with Redis service container +on: + workflow_dispatch: + schedule: every 12h + pull_request: + types: [labeled] + names: ["smoke"] + status-comment: true +permissions: + contents: read + issues: read + pull-requests: read +name: Smoke Service Ports +engine: copilot +strict: true +services: + redis: + image: redis:7 + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 +network: + allowed: + - defaults + - github +tools: + bash: + - "*" +safe-outputs: + allowed-domains: [default-safe-outputs] + add-comment: + hide-older-comments: true + max: 2 + messages: + footer: "> 🔌 *Service ports validation by [{workflow_name}]({run_url})*{history_link}" + run-started: "🔌 Starting service ports validation... [{workflow_name}]({run_url}) is testing Redis connectivity..." + run-success: "✅ Service ports validation passed! [{workflow_name}]({run_url}) confirms agent can reach Redis." + run-failure: "❌ Service ports validation failed! [{workflow_name}]({run_url}) could not reach Redis: {status}" +timeout-minutes: 5 +--- + +# Smoke Test: Service Ports (Redis) + +**Purpose:** Validate that the `--allow-host-service-ports` feature works end-to-end. The compiler should have automatically detected the Redis service port and configured AWF to allow traffic to it. You do NOT need to configure anything special — just use Redis normally. + +**IMPORTANT:** Keep all outputs concise. Report each test with a pass/fail status. + +## Required Tests + +1. **Redis PING**: Run `redis-cli -h localhost -p 6379 ping` or `echo PING | nc localhost 6379` and verify the response contains `PONG`. + +2. **Redis SET/GET**: Write a value to Redis and read it back: + - `redis-cli -h localhost -p 6379 SET smoke_test "service-ports-ok"` + - `redis-cli -h localhost -p 6379 GET smoke_test` + - Verify the returned value is `service-ports-ok` + +3. **Redis INFO**: Run `redis-cli -h localhost -p 6379 INFO server | head -5` to verify we can query Redis server info. + +## Output Requirements + +Add a **concise comment** to the pull request (if triggered by PR) with: + +- Each test with a pass/fail status +- Overall status: PASS or FAIL +- Note whether `redis-cli` was available or if `nc`/netcat was used as fallback + +Example: +``` +## Service Ports Smoke Test (Redis) + +| Test | Status | +|------|--------| +| Redis PING | ✅ PONG received | +| Redis SET/GET | ✅ Value round-tripped | +| Redis INFO | ✅ Server info retrieved | + +**Result:** 3/3 tests passed ✅ +``` + +**Important**: If no action is needed after completing your analysis, you **MUST** call the `noop` safe-output tool with a brief explanation. + +```json +{"noop": {"message": "No action needed: [brief explanation of what was analyzed and why]"}} +``` From 7aa64051cb9c3b5b4c88f6a41c0ce2b9bfc7e005 Mon Sep 17 00:00:00 2001 From: "Jiaxiao (mossaka) Zhou" Date: Tue, 31 Mar 2026 20:37:53 +0000 Subject: [PATCH 05/10] =?UTF-8?q?fix:=20address=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20bracket=20notation,=20port=20validation,=20warning?= =?UTF-8?q?=20count?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use bracket notation (job.services['id']) for all service IDs to handle hyphens and other special characters in GitHub Actions expressions - Add port range validation (1-65535) for all port spec types - Reject non-integer float64 values and unknown protocols (not just UDP) - Increment compiler warning count for service port warnings - Add test cases for out-of-range ports, unknown protocols, non-integer floats - Recompile smoke-service-ports lock file with updated expression format Co-Authored-By: Claude Opus 4.6 (1M context) --- .../workflows/smoke-service-ports.lock.yml | 2 +- .../compiler_orchestrator_workflow.go | 1 + pkg/workflow/compiler_types.go | 2 +- pkg/workflow/service_ports.go | 51 +++++++++++++++-- pkg/workflow/service_ports_test.go | 56 +++++++++++++++---- 5 files changed, 94 insertions(+), 18 deletions(-) diff --git a/.github/workflows/smoke-service-ports.lock.yml b/.github/workflows/smoke-service-ports.lock.yml index 5d503a833ea..8ea59e3c0e2 100644 --- a/.github/workflows/smoke-service-ports.lock.yml +++ b/.github/workflows/smoke-service-ports.lock.yml @@ -606,7 +606,7 @@ jobs: set -o pipefail touch /tmp/gh-aw/agent-step-summary.md # 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" --allow-host-service-ports "${{ job.services.redis.ports['6379'] }}" --env-all --allow-domains '*.githubusercontent.com,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,codeload.github.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,docs.github.com,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.blog,github.com,github.githubassets.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.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.1 --skip-pull --enable-api-proxy \ + 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" --allow-host-service-ports "${{ job.services['redis'].ports['6379'] }}" --env-all --allow-domains '*.githubusercontent.com,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,codeload.github.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,docs.github.com,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.blog,github.com,github.githubassets.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,lfs.github.com,objects.githubusercontent.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.1 --skip-pull --enable-api-proxy \ -- /bin/bash -c '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --allow-all-tools --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log env: COPILOT_AGENT_RUNNER_TYPE: STANDALONE diff --git a/pkg/workflow/compiler_orchestrator_workflow.go b/pkg/workflow/compiler_orchestrator_workflow.go index ced3dc48d41..805d975078d 100644 --- a/pkg/workflow/compiler_orchestrator_workflow.go +++ b/pkg/workflow/compiler_orchestrator_workflow.go @@ -525,6 +525,7 @@ func (c *Compiler) processAndMergeServices(frontmatter map[string]any, workflowD for _, w := range warnings { orchestratorWorkflowLog.Printf("Warning: %s", w) fmt.Fprintln(os.Stderr, console.FormatWarningMessage(w)) + c.IncrementWarningCount() } if expressions != "" { orchestratorWorkflowLog.Printf("Extracted service port expressions: %s", expressions) diff --git a/pkg/workflow/compiler_types.go b/pkg/workflow/compiler_types.go index 1dc770ea241..c22b5ea0601 100644 --- a/pkg/workflow/compiler_types.go +++ b/pkg/workflow/compiler_types.go @@ -435,7 +435,7 @@ type WorkflowData struct { ConcurrencyJobDiscriminator string // optional discriminator expression appended to job-level concurrency groups (from concurrency.job-discriminator) IsDetectionRun bool // true when this WorkflowData is used for inline threat detection (not the main agent run) EngineConfigSteps []map[string]any // steps returned by engine.RenderConfig — prepended before execution steps - ServicePortExpressions string // comma-separated ${{ job.services..ports[''] }} expressions for AWF --allow-host-service-ports + ServicePortExpressions string // comma-separated ${{ job.services[''].ports[''] }} expressions for AWF --allow-host-service-ports } // BaseSafeOutputConfig holds common configuration fields for all safe output types diff --git a/pkg/workflow/service_ports.go b/pkg/workflow/service_ports.go index 2c2e35534b4..36fad13f541 100644 --- a/pkg/workflow/service_ports.go +++ b/pkg/workflow/service_ports.go @@ -1,5 +1,5 @@ // This file provides helper functions for extracting service port mappings from -// GitHub Actions services: configuration and generating ${{ job.services..ports[''] }} +// GitHub Actions services: configuration and generating ${{ job.services[''].ports[''] }} // expressions for AWF's --allow-host-service-ports flag. // // When a workflow uses GitHub Actions services: with port mappings (e.g., PostgreSQL, Redis), @@ -25,13 +25,22 @@ var servicePortsLog = logger.New("workflow:service_ports") // This prevents accidentally generating thousands of expressions from a large port range. const maxPortRangeExpansion = 32 +// minPort and maxPort define the valid TCP/UDP port range. +const ( + minPort = 1 + maxPort = 65535 +) + // ExtractServicePortExpressions parses the services: YAML string from WorkflowData.Services -// and returns a comma-separated string of ${{ job.services..ports[''] }} expressions +// and returns a comma-separated string of ${{ job.services[''].ports[''] }} expressions // for all TCP port mappings found. // // The returned string is suitable for passing as --allow-host-service-ports to AWF. // Returns empty string if no services or no port mappings are found. // +// Bracket notation (job.services['id']) is used for all service IDs to correctly handle +// IDs containing hyphens, digits-first names, or other characters invalid in dot-notation. +// // Parameters: // - servicesYAML: Raw YAML string from WorkflowData.Services (includes "services:" wrapper) // @@ -96,7 +105,8 @@ func ExtractServicePortExpressions(servicesYAML string) (string, []string) { warnings = append(warnings, fmt.Sprintf("service %q: %s", serviceID, w)) } for _, cp := range containerPorts { - expr := fmt.Sprintf("${{ job.services.%s.ports['%d'] }}", serviceID, cp) + escapedServiceID := strings.ReplaceAll(serviceID, "'", "''") + expr := fmt.Sprintf("${{ job.services['%s'].ports['%d'] }}", escapedServiceID, cp) expressions = append(expressions, expr) } } @@ -127,15 +137,33 @@ func parsePortSpec(spec any) ([]int, []string) { var portStr string switch v := spec.(type) { case int: + if v < minPort || v > maxPort { + return nil, []string{fmt.Sprintf("port %d is outside valid range %d-%d", v, minPort, maxPort)} + } return []int{v}, nil case uint64: // goccy/go-yaml decodes unquoted integers as uint64 - return []int{int(v)}, nil + p := int(v) + if p < minPort || p > maxPort { + return nil, []string{fmt.Sprintf("port %d is outside valid range %d-%d", p, minPort, maxPort)} + } + return []int{p}, nil case int64: - return []int{int(v)}, nil + p := int(v) + if p < minPort || p > maxPort { + return nil, []string{fmt.Sprintf("port %d is outside valid range %d-%d", p, minPort, maxPort)} + } + return []int{p}, nil case float64: // Some YAML libraries parse unquoted numbers as float64 - return []int{int(v)}, nil + p := int(v) + if float64(p) != v { + return nil, []string{fmt.Sprintf("port %v is not an integer", v)} + } + if p < minPort || p > maxPort { + return nil, []string{fmt.Sprintf("port %d is outside valid range %d-%d", p, minPort, maxPort)} + } + return []int{p}, nil case string: portStr = v default: @@ -157,6 +185,9 @@ func parsePortSpec(spec any) ([]int, []string) { if protocol == "udp" { return nil, []string{fmt.Sprintf("UDP port %q skipped; AWF only supports TCP", portStr)} } + if protocol != "tcp" { + return nil, []string{fmt.Sprintf("unsupported protocol %q for port %q; AWF only supports TCP", protocol, portStr)} + } // Split host:container var containerPart string @@ -183,6 +214,10 @@ func parsePortSpec(spec any) ([]int, []string) { return nil, []string{fmt.Sprintf("port range %q expands to %d ports, exceeding cap of %d", containerPart, count, maxPortRangeExpansion)} } + if start < minPort || end > maxPort { + return nil, []string{fmt.Sprintf("port range %q contains ports outside valid range %d-%d", containerPart, minPort, maxPort)} + } + ports := make([]int, 0, count) for p := start; p <= end; p++ { ports = append(ports, p) @@ -196,5 +231,9 @@ func parsePortSpec(spec any) ([]int, []string) { return nil, []string{fmt.Sprintf("invalid port number %q", containerPart)} } + if port < minPort || port > maxPort { + return nil, []string{fmt.Sprintf("port %d is outside valid range %d-%d", port, minPort, maxPort)} + } + return []int{port}, nil } diff --git a/pkg/workflow/service_ports_test.go b/pkg/workflow/service_ports_test.go index 9178b16e58b..f78d2a0afe8 100644 --- a/pkg/workflow/service_ports_test.go +++ b/pkg/workflow/service_ports_test.go @@ -97,6 +97,42 @@ func TestParsePortSpec(t *testing.T) { spec: "", expectedPorts: nil, }, + { + name: "port zero is out of range", + spec: "0", + expectedPorts: nil, + warnContains: "outside valid range", + }, + { + name: "port above 65535", + spec: "70000", + expectedPorts: nil, + warnContains: "outside valid range", + }, + { + name: "integer port zero is out of range", + spec: 0, + expectedPorts: nil, + warnContains: "outside valid range", + }, + { + name: "unknown protocol skipped", + spec: "5432/sctp", + expectedPorts: nil, + warnContains: "unsupported protocol", + }, + { + name: "float64 non-integer rejected", + spec: float64(5432.5), + expectedPorts: nil, + warnContains: "not an integer", + }, + { + name: "port range with out-of-range values", + spec: "65530-65540", + expectedPorts: nil, + warnContains: "outside valid range", + }, } for _, tt := range tests { @@ -139,7 +175,7 @@ func TestExtractServicePortExpressions(t *testing.T) { ports: - 5432:5432 `, - expectedResult: "${{ job.services.postgres.ports['5432'] }}", + expectedResult: "${{ job.services['postgres'].ports['5432'] }}", }, { name: "multiple services with ports", @@ -153,7 +189,7 @@ func TestExtractServicePortExpressions(t *testing.T) { ports: - 6379:6379 `, - expectedResult: "${{ job.services.postgres.ports['5432'] }},${{ job.services.redis.ports['6379'] }}", + expectedResult: "${{ job.services['postgres'].ports['5432'] }},${{ job.services['redis'].ports['6379'] }}", }, { name: "service with multiple ports", @@ -164,7 +200,7 @@ func TestExtractServicePortExpressions(t *testing.T) { - 5432:5432 - 8080:8080 `, - expectedResult: "${{ job.services.mydb.ports['5432'] }},${{ job.services.mydb.ports['8080'] }}", + expectedResult: "${{ job.services['mydb'].ports['5432'] }},${{ job.services['mydb'].ports['8080'] }}", }, { name: "service without ports emits warning", @@ -185,7 +221,7 @@ func TestExtractServicePortExpressions(t *testing.T) { redis: image: redis:7 `, - expectedResult: "${{ job.services.postgres.ports['5432'] }}", + expectedResult: "${{ job.services['postgres'].ports['5432'] }}", expectedWarnings: []string{"service \"redis\" has no ports mapping"}, }, { @@ -207,7 +243,7 @@ func TestExtractServicePortExpressions(t *testing.T) { ports: - 6000-6002:6000-6002 `, - expectedResult: "${{ job.services.myservice.ports['6000'] }},${{ job.services.myservice.ports['6001'] }},${{ job.services.myservice.ports['6002'] }}", + expectedResult: "${{ job.services['myservice'].ports['6000'] }},${{ job.services['myservice'].ports['6001'] }},${{ job.services['myservice'].ports['6002'] }}", }, { name: "dynamic host port (container port only)", @@ -217,7 +253,7 @@ func TestExtractServicePortExpressions(t *testing.T) { ports: - 5432 `, - expectedResult: "${{ job.services.postgres.ports['5432'] }}", + expectedResult: "${{ job.services['postgres'].ports['5432'] }}", }, { name: "remapped host port uses container port in expression", @@ -227,7 +263,7 @@ func TestExtractServicePortExpressions(t *testing.T) { ports: - 49152:5432 `, - expectedResult: "${{ job.services.postgres.ports['5432'] }}", + expectedResult: "${{ job.services['postgres'].ports['5432'] }}", }, { name: "invalid YAML returns empty", @@ -242,7 +278,7 @@ func TestExtractServicePortExpressions(t *testing.T) { ports: - 5432 `, - expectedResult: "${{ job.services.postgres.ports['5432'] }}", + expectedResult: "${{ job.services['postgres'].ports['5432'] }}", }, { name: "hyphenated service ID", @@ -252,7 +288,7 @@ func TestExtractServicePortExpressions(t *testing.T) { ports: - 5432:5432 `, - expectedResult: "${{ job.services.my-postgres.ports['5432'] }}", + expectedResult: "${{ job.services['my-postgres'].ports['5432'] }}", }, { name: "invalid ports format (not a list) emits warning", @@ -303,7 +339,7 @@ func TestExtractServicePortExpressions_DeterministicOrder(t *testing.T) { ports: - 3333:3333 ` - expected := "${{ job.services.alpha.ports['2222'] }},${{ job.services.middle.ports['3333'] }},${{ job.services.zeta.ports['1111'] }}" + expected := "${{ job.services['alpha'].ports['2222'] }},${{ job.services['middle'].ports['3333'] }},${{ job.services['zeta'].ports['1111'] }}" for i := range 10 { result, _ := ExtractServicePortExpressions(servicesYAML) From 4938adf1e70d881d95cdcf8cfa7739c784023b9e Mon Sep 17 00:00:00 2001 From: "Jiaxiao (mossaka) Zhou" Date: Tue, 31 Mar 2026 21:19:25 +0000 Subject: [PATCH 06/10] fix: build AWF from source in smoke-service-ports test The --allow-host-service-ports flag was added to AWF's main branch but not yet released in v0.25.1. The smoke test needs AWF built from source to validate this feature. Replaces install_awf_binary.sh with a checkout-and-build step, and locally builds container images tagged as 0.25.1 so --skip-pull uses them. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../workflows/smoke-service-ports.lock.yml | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/.github/workflows/smoke-service-ports.lock.yml b/.github/workflows/smoke-service-ports.lock.yml index 8ea59e3c0e2..ea78cacd584 100644 --- a/.github/workflows/smoke-service-ports.lock.yml +++ b/.github/workflows/smoke-service-ports.lock.yml @@ -369,8 +369,25 @@ jobs: run: ${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh latest env: GH_HOST: github.com - - name: Install AWF binary - run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.25.1 + - name: Build AWF from source (requires --allow-host-service-ports) + run: | + git clone --depth 1 https://x-access-token:${{ secrets.GH_AW_GITHUB_TOKEN }}@github.com/github/gh-aw-firewall.git /tmp/gh-aw-firewall + cd /tmp/gh-aw-firewall + npm ci + npm run build + # Install as a Node.js entrypoint with a bash wrapper + sudo tee /usr/local/bin/awf > /dev/null <<'WRAPPER' + #!/usr/bin/env bash + exec node /tmp/gh-aw-firewall/dist/cli.js "$@" + WRAPPER + sudo chmod +x /usr/local/bin/awf + awf --version + - name: Build AWF container images locally + run: | + cd /tmp/gh-aw-firewall + docker build -t ghcr.io/github/gh-aw-firewall/squid:0.25.1 containers/squid/ + docker build -t ghcr.io/github/gh-aw-firewall/agent:0.25.1 containers/agent/ + docker build -t ghcr.io/github/gh-aw-firewall/api-proxy:0.25.1 containers/api-proxy/ - name: Determine automatic lockdown mode for GitHub MCP Server id: determine-automatic-lockdown uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 @@ -382,7 +399,7 @@ jobs: const determineAutomaticLockdown = require('${{ runner.temp }}/gh-aw/actions/determine_automatic_lockdown.cjs'); await determineAutomaticLockdown(github, context, core); - name: Download container images - run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.25.1 ghcr.io/github/gh-aw-firewall/api-proxy:0.25.1 ghcr.io/github/gh-aw-firewall/squid:0.25.1 ghcr.io/github/gh-aw-mcpg:v0.2.6 ghcr.io/github/github-mcp-server:v0.32.0 node:lts-alpine + run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-mcpg:v0.2.6 ghcr.io/github/github-mcp-server:v0.32.0 node:lts-alpine - name: Write Safe Outputs Config run: | mkdir -p ${RUNNER_TEMP}/gh-aw/safeoutputs From b23bcb5f8e6f4c3dfa2571b6827daf9530a31a95 Mon Sep 17 00:00:00 2001 From: "Jiaxiao (mossaka) Zhou" Date: Tue, 31 Mar 2026 21:30:21 +0000 Subject: [PATCH 07/10] fix: use host.docker.internal for Redis in smoke test Inside AWF's sandbox container, localhost resolves to the container itself. Service containers run on the host, so the agent must connect via host.docker.internal to reach Redis. Updated prompt instructions and recompiled lock file. Re-applied AWF source build since recompile regenerates the lock file. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/smoke-service-ports.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/smoke-service-ports.md b/.github/workflows/smoke-service-ports.md index cd1a48ac0c4..49d143ab5dd 100644 --- a/.github/workflows/smoke-service-ports.md +++ b/.github/workflows/smoke-service-ports.md @@ -46,20 +46,20 @@ timeout-minutes: 5 # Smoke Test: Service Ports (Redis) -**Purpose:** Validate that the `--allow-host-service-ports` feature works end-to-end. The compiler should have automatically detected the Redis service port and configured AWF to allow traffic to it. You do NOT need to configure anything special — just use Redis normally. +**Purpose:** Validate that the `--allow-host-service-ports` feature works end-to-end. The compiler should have automatically detected the Redis service port and configured AWF to allow traffic to it. -**IMPORTANT:** Keep all outputs concise. Report each test with a pass/fail status. +**IMPORTANT:** Inside AWF's sandbox, you must connect to services via `host.docker.internal` (not `localhost`). The service containers run on the host, and AWF routes traffic through the host gateway. Since the workflow maps port 6379:6379, port 6379 should work. Keep all outputs concise. ## Required Tests -1. **Redis PING**: Run `redis-cli -h localhost -p 6379 ping` or `echo PING | nc localhost 6379` and verify the response contains `PONG`. +1. **Redis PING**: Run `redis-cli -h host.docker.internal -p 6379 ping` or `echo PING | nc host.docker.internal 6379` and verify the response contains `PONG`. 2. **Redis SET/GET**: Write a value to Redis and read it back: - - `redis-cli -h localhost -p 6379 SET smoke_test "service-ports-ok"` - - `redis-cli -h localhost -p 6379 GET smoke_test` + - `redis-cli -h host.docker.internal -p 6379 SET smoke_test "service-ports-ok"` + - `redis-cli -h host.docker.internal -p 6379 GET smoke_test` - Verify the returned value is `service-ports-ok` -3. **Redis INFO**: Run `redis-cli -h localhost -p 6379 INFO server | head -5` to verify we can query Redis server info. +3. **Redis INFO**: Run `redis-cli -h host.docker.internal -p 6379 INFO server | head -5` to verify we can query Redis server info. ## Output Requirements From 047ebd0747c32eb0cd61055f267dbec77ddf6321 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 02:38:37 +0000 Subject: [PATCH 08/10] Agent-Logs-Url: https://github.com/github/gh-aw/sessions/2c937a36-d2a7-438a-9fa7-b3a86d41180a Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../workflows/smoke-service-ports.lock.yml | 23 +++---------------- 1 file changed, 3 insertions(+), 20 deletions(-) diff --git a/.github/workflows/smoke-service-ports.lock.yml b/.github/workflows/smoke-service-ports.lock.yml index ea78cacd584..8ea59e3c0e2 100644 --- a/.github/workflows/smoke-service-ports.lock.yml +++ b/.github/workflows/smoke-service-ports.lock.yml @@ -369,25 +369,8 @@ jobs: run: ${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh latest env: GH_HOST: github.com - - name: Build AWF from source (requires --allow-host-service-ports) - run: | - git clone --depth 1 https://x-access-token:${{ secrets.GH_AW_GITHUB_TOKEN }}@github.com/github/gh-aw-firewall.git /tmp/gh-aw-firewall - cd /tmp/gh-aw-firewall - npm ci - npm run build - # Install as a Node.js entrypoint with a bash wrapper - sudo tee /usr/local/bin/awf > /dev/null <<'WRAPPER' - #!/usr/bin/env bash - exec node /tmp/gh-aw-firewall/dist/cli.js "$@" - WRAPPER - sudo chmod +x /usr/local/bin/awf - awf --version - - name: Build AWF container images locally - run: | - cd /tmp/gh-aw-firewall - docker build -t ghcr.io/github/gh-aw-firewall/squid:0.25.1 containers/squid/ - docker build -t ghcr.io/github/gh-aw-firewall/agent:0.25.1 containers/agent/ - docker build -t ghcr.io/github/gh-aw-firewall/api-proxy:0.25.1 containers/api-proxy/ + - name: Install AWF binary + run: bash ${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh v0.25.1 - name: Determine automatic lockdown mode for GitHub MCP Server id: determine-automatic-lockdown uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 @@ -399,7 +382,7 @@ jobs: const determineAutomaticLockdown = require('${{ runner.temp }}/gh-aw/actions/determine_automatic_lockdown.cjs'); await determineAutomaticLockdown(github, context, core); - name: Download container images - run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-mcpg:v0.2.6 ghcr.io/github/github-mcp-server:v0.32.0 node:lts-alpine + run: bash ${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.25.1 ghcr.io/github/gh-aw-firewall/api-proxy:0.25.1 ghcr.io/github/gh-aw-firewall/squid:0.25.1 ghcr.io/github/gh-aw-mcpg:v0.2.6 ghcr.io/github/github-mcp-server:v0.32.0 node:lts-alpine - name: Write Safe Outputs Config run: | mkdir -p ${RUNNER_TEMP}/gh-aw/safeoutputs From 816a57a5bf50e803435577a67e691d2c6e6dd00d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 03:35:55 +0000 Subject: [PATCH 09/10] fix: run smoke-service-ports daily instead of on pull requests Agent-Logs-Url: https://github.com/github/gh-aw/sessions/bee817a0-958d-47c6-9430-42675e50b67e Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../workflows/smoke-service-ports.lock.yml | 65 +++++++------------ .github/workflows/smoke-service-ports.md | 5 +- 2 files changed, 23 insertions(+), 47 deletions(-) diff --git a/.github/workflows/smoke-service-ports.lock.yml b/.github/workflows/smoke-service-ports.lock.yml index 8ea59e3c0e2..d037c0bd8a7 100644 --- a/.github/workflows/smoke-service-ports.lock.yml +++ b/.github/workflows/smoke-service-ports.lock.yml @@ -22,17 +22,12 @@ # # Smoke test to validate --allow-host-service-ports with Redis service container # -# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"ae34c194b622281e356eaea165500ebe16130384aea9af1cd9505aa7104a0414","strict":true,"agent_id":"copilot"} +# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"f87ff09b47856fe5762f2eb7eddf0eee24e2dd583dd1b53a14f1ed9fef367457","strict":true,"agent_id":"copilot"} name: "Smoke Service Ports" "on": - pull_request: - # names: # Label filtering applied via job conditions - # - smoke # Label filtering applied via job conditions - types: - - labeled schedule: - - cron: "18 */12 * * *" + - cron: "28 21 * * *" workflow_dispatch: inputs: aw_context: @@ -44,17 +39,14 @@ name: "Smoke Service Ports" permissions: {} concurrency: - group: "gh-aw-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref || github.run_id }}" - cancel-in-progress: true + group: "gh-aw-${{ github.workflow }}" run-name: "Smoke Service Ports" jobs: activation: needs: pre_activation - if: > - needs.pre_activation.outputs.activated == 'true' && ((github.event_name != 'pull_request' || github.event.pull_request.head.repo.id == github.repository_id) && - (github.event_name != 'pull_request' || github.event.action != 'labeled' || github.event.label.name == 'smoke')) + if: needs.pre_activation.outputs.activated == 'true' runs-on: ubuntu-slim permissions: contents: read @@ -62,15 +54,12 @@ jobs: issues: write pull-requests: write outputs: - body: ${{ steps.sanitized.outputs.body }} comment_id: ${{ steps.add-comment.outputs.comment-id }} comment_repo: ${{ steps.add-comment.outputs.comment-repo }} comment_url: ${{ steps.add-comment.outputs.comment-url }} lockdown_check_failed: ${{ steps.generate_aw_info.outputs.lockdown_check_failed == 'true' }} model: ${{ steps.generate_aw_info.outputs.model }} secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} - text: ${{ steps.sanitized.outputs.text }} - title: ${{ steps.sanitized.outputs.title }} steps: - name: Checkout actions folder uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -133,15 +122,6 @@ jobs: setupGlobals(core, github, context, exec, io); const { main } = require('${{ runner.temp }}/gh-aw/actions/check_workflow_timestamp_api.cjs'); await main(); - - name: Compute current body text - id: sanitized - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('${{ runner.temp }}/gh-aw/actions/compute_text.cjs'); - await main(); - name: Add comment with workflow run link id: add-comment if: github.event_name == 'issues' || github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment' || github.event_name == 'discussion' || github.event_name == 'discussion_comment' || github.event_name == 'pull_request' && github.event.pull_request.head.repo.id == github.repository_id @@ -171,14 +151,14 @@ jobs: run: | bash ${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh { - cat << 'GH_AW_PROMPT_102bb7d969c921ff_EOF' + cat << 'GH_AW_PROMPT_adf052b962132837_EOF' - GH_AW_PROMPT_102bb7d969c921ff_EOF + GH_AW_PROMPT_adf052b962132837_EOF cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md" cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md" cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md" cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md" - cat << 'GH_AW_PROMPT_102bb7d969c921ff_EOF' + cat << 'GH_AW_PROMPT_adf052b962132837_EOF' Tools: add_comment(max:2), missing_tool, missing_data, noop @@ -210,14 +190,14 @@ jobs: {{/if}} - GH_AW_PROMPT_102bb7d969c921ff_EOF + GH_AW_PROMPT_adf052b962132837_EOF cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md" - cat << 'GH_AW_PROMPT_102bb7d969c921ff_EOF' + cat << 'GH_AW_PROMPT_adf052b962132837_EOF' - GH_AW_PROMPT_102bb7d969c921ff_EOF - cat << 'GH_AW_PROMPT_102bb7d969c921ff_EOF' + GH_AW_PROMPT_adf052b962132837_EOF + cat << 'GH_AW_PROMPT_adf052b962132837_EOF' {{#runtime-import .github/workflows/smoke-service-ports.md}} - GH_AW_PROMPT_102bb7d969c921ff_EOF + GH_AW_PROMPT_adf052b962132837_EOF } > "$GH_AW_PROMPT" - name: Interpolate variables and render templates uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 @@ -297,6 +277,8 @@ jobs: contents: read issues: read pull-requests: read + concurrency: + group: "gh-aw-copilot-${{ github.workflow }}" env: DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} GH_AW_ASSETS_ALLOWED_EXTS: "" @@ -388,12 +370,12 @@ jobs: mkdir -p ${RUNNER_TEMP}/gh-aw/safeoutputs mkdir -p /tmp/gh-aw/safeoutputs mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs - cat > ${RUNNER_TEMP}/gh-aw/safeoutputs/config.json << 'GH_AW_SAFE_OUTPUTS_CONFIG_6860efc01d4c77d0_EOF' + cat > ${RUNNER_TEMP}/gh-aw/safeoutputs/config.json << 'GH_AW_SAFE_OUTPUTS_CONFIG_540b8636a24c7a08_EOF' {"add_comment":{"hide_older_comments":true,"max":2},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"}} - GH_AW_SAFE_OUTPUTS_CONFIG_6860efc01d4c77d0_EOF + GH_AW_SAFE_OUTPUTS_CONFIG_540b8636a24c7a08_EOF - name: Write Safe Outputs Tools run: | - cat > ${RUNNER_TEMP}/gh-aw/safeoutputs/tools_meta.json << 'GH_AW_SAFE_OUTPUTS_TOOLS_META_8b6fd3f8b6acdea6_EOF' + cat > ${RUNNER_TEMP}/gh-aw/safeoutputs/tools_meta.json << 'GH_AW_SAFE_OUTPUTS_TOOLS_META_66bf068d6618c38e_EOF' { "description_suffixes": { "add_comment": " CONSTRAINTS: Maximum 2 comment(s) can be added." @@ -401,8 +383,8 @@ jobs: "repo_params": {}, "dynamic_tools": [] } - GH_AW_SAFE_OUTPUTS_TOOLS_META_8b6fd3f8b6acdea6_EOF - cat > ${RUNNER_TEMP}/gh-aw/safeoutputs/validation.json << 'GH_AW_SAFE_OUTPUTS_VALIDATION_6b591b2c928c1050_EOF' + GH_AW_SAFE_OUTPUTS_TOOLS_META_66bf068d6618c38e_EOF + cat > ${RUNNER_TEMP}/gh-aw/safeoutputs/validation.json << 'GH_AW_SAFE_OUTPUTS_VALIDATION_736754397af27d75_EOF' { "add_comment": { "defaultMax": 1, @@ -480,7 +462,7 @@ jobs: } } } - GH_AW_SAFE_OUTPUTS_VALIDATION_6b591b2c928c1050_EOF + GH_AW_SAFE_OUTPUTS_VALIDATION_736754397af27d75_EOF node ${RUNNER_TEMP}/gh-aw/actions/generate_safe_outputs_tools.cjs - name: Generate Safe Outputs MCP Server Config id: safe-outputs-config @@ -548,7 +530,7 @@ jobs: export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.2.6' mkdir -p /home/runner/.copilot - cat << GH_AW_MCP_CONFIG_c222985d02e378a2_EOF | bash ${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.sh + cat << GH_AW_MCP_CONFIG_dd21a14b63d12243_EOF | bash ${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.sh { "mcpServers": { "github": { @@ -589,7 +571,7 @@ jobs: "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}" } } - GH_AW_MCP_CONFIG_c222985d02e378a2_EOF + GH_AW_MCP_CONFIG_dd21a14b63d12243_EOF - name: Download activation artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -1061,9 +1043,6 @@ jobs: await main(); pre_activation: - if: > - (github.event_name != 'pull_request' || github.event.pull_request.head.repo.id == github.repository_id) && - (github.event_name != 'pull_request' || github.event.action != 'labeled' || github.event.label.name == 'smoke') runs-on: ubuntu-slim permissions: contents: read diff --git a/.github/workflows/smoke-service-ports.md b/.github/workflows/smoke-service-ports.md index 49d143ab5dd..b0e303b9170 100644 --- a/.github/workflows/smoke-service-ports.md +++ b/.github/workflows/smoke-service-ports.md @@ -2,10 +2,7 @@ description: Smoke test to validate --allow-host-service-ports with Redis service container on: workflow_dispatch: - schedule: every 12h - pull_request: - types: [labeled] - names: ["smoke"] + schedule: daily status-comment: true permissions: contents: read From fc65fca1b870eb4c256cb9a407ec80321cdd9310 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 04:10:53 +0000 Subject: [PATCH 10/10] refactor: add typed structs for services YAML and integration tests for service ports Agent-Logs-Url: https://github.com/github/gh-aw/sessions/1a8413b1-7fb1-4e16-ba81-98cae2550ebb Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../compile_service_ports_integration_test.go | 153 ++++++++++++++++++ pkg/cli/workflows/test-service-ports.md | 24 +++ pkg/workflow/service_ports.go | 43 +++-- 3 files changed, 207 insertions(+), 13 deletions(-) create mode 100644 pkg/cli/compile_service_ports_integration_test.go create mode 100644 pkg/cli/workflows/test-service-ports.md diff --git a/pkg/cli/compile_service_ports_integration_test.go b/pkg/cli/compile_service_ports_integration_test.go new file mode 100644 index 00000000000..5e7ace1a3b5 --- /dev/null +++ b/pkg/cli/compile_service_ports_integration_test.go @@ -0,0 +1,153 @@ +//go:build integration + +package cli + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// TestCompileServicePortsWorkflow compiles the canonical test-service-ports.md workflow +// and verifies that the generated lock file contains --allow-host-service-ports with the +// correct ${{ job.services[''].ports[''] }} expressions for every service port. +func TestCompileServicePortsWorkflow(t *testing.T) { + setup := setupIntegrationTest(t) + defer setup.cleanup() + + srcPath := filepath.Join(projectRoot, "pkg/cli/workflows/test-service-ports.md") + dstPath := filepath.Join(setup.workflowsDir, "test-service-ports.md") + + srcContent, err := os.ReadFile(srcPath) + if err != nil { + t.Fatalf("Failed to read source workflow %s: %v", srcPath, err) + } + if err := os.WriteFile(dstPath, srcContent, 0644); err != nil { + t.Fatalf("Failed to write workflow to test dir: %v", err) + } + + cmd := exec.Command(setup.binaryPath, "compile", dstPath) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("Compile failed: %v\nOutput: %s", err, string(output)) + } + + lockFilePath := filepath.Join(setup.workflowsDir, "test-service-ports.lock.yml") + lockContent, err := os.ReadFile(lockFilePath) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + lock := string(lockContent) + + // The compiler must emit --allow-host-service-ports + if !strings.Contains(lock, "--allow-host-service-ports") { + t.Errorf("Lock file missing --allow-host-service-ports\nLock content:\n%s", lock) + } + + // Bracket-notation expressions must be present for both services + for _, expr := range []string{ + "job.services['postgres'].ports['5432']", + "job.services['redis'].ports['6379']", + } { + if !strings.Contains(lock, expr) { + t.Errorf("Lock file missing expected expression %q\nLock content:\n%s", expr, lock) + } + } + + t.Logf("test-service-ports.md compiled successfully; --allow-host-service-ports verified") +} + +// TestCompileServicePorts_NoServices verifies that a workflow with no services block +// compiles without errors and does NOT emit --allow-host-service-ports. +func TestCompileServicePorts_NoServices(t *testing.T) { + setup := setupIntegrationTest(t) + defer setup.cleanup() + + testWorkflow := `--- +on: + workflow_dispatch: +permissions: + contents: read +engine: copilot +--- + +# No Services Workflow + +This workflow has no services block and should not include --allow-host-service-ports. +` + testPath := filepath.Join(setup.workflowsDir, "no-services.md") + if err := os.WriteFile(testPath, []byte(testWorkflow), 0644); err != nil { + t.Fatalf("Failed to write workflow: %v", err) + } + + cmd := exec.Command(setup.binaryPath, "compile", testPath) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("Compile failed: %v\nOutput: %s", err, string(output)) + } + + lockFilePath := filepath.Join(setup.workflowsDir, "no-services.lock.yml") + lockContent, err := os.ReadFile(lockFilePath) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + + if strings.Contains(string(lockContent), "--allow-host-service-ports") { + t.Errorf("Lock file should NOT contain --allow-host-service-ports when no services are defined") + } +} + +// TestCompileServicePorts_HyphenatedServiceID verifies that service IDs containing +// hyphens are emitted with bracket notation (not dot notation) in the compiled lock file. +func TestCompileServicePorts_HyphenatedServiceID(t *testing.T) { + setup := setupIntegrationTest(t) + defer setup.cleanup() + + testWorkflow := `--- +on: + workflow_dispatch: +permissions: + contents: read +engine: copilot +services: + my-postgres: + image: postgres:15 + ports: + - 5432:5432 +--- + +# Hyphenated Service ID Workflow + +Verifies bracket notation for hyphenated service IDs. +` + testPath := filepath.Join(setup.workflowsDir, "hyphenated-service.md") + if err := os.WriteFile(testPath, []byte(testWorkflow), 0644); err != nil { + t.Fatalf("Failed to write workflow: %v", err) + } + + cmd := exec.Command(setup.binaryPath, "compile", testPath) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("Compile failed: %v\nOutput: %s", err, string(output)) + } + + lockFilePath := filepath.Join(setup.workflowsDir, "hyphenated-service.lock.yml") + lockContent, err := os.ReadFile(lockFilePath) + if err != nil { + t.Fatalf("Failed to read lock file: %v", err) + } + lock := string(lockContent) + + // Must use bracket notation, not dot notation + bracketNotation := "job.services['my-postgres'].ports['5432']" + dotNotation := "job.services.my-postgres.ports" + + if !strings.Contains(lock, bracketNotation) { + t.Errorf("Lock file missing bracket-notation expression %q\nLock content:\n%s", bracketNotation, lock) + } + if strings.Contains(lock, dotNotation) { + t.Errorf("Lock file must NOT use dot notation for hyphenated service IDs; found %q\nLock content:\n%s", dotNotation, lock) + } +} diff --git a/pkg/cli/workflows/test-service-ports.md b/pkg/cli/workflows/test-service-ports.md new file mode 100644 index 00000000000..1ac3a9f4741 --- /dev/null +++ b/pkg/cli/workflows/test-service-ports.md @@ -0,0 +1,24 @@ +--- +on: + workflow_dispatch: +permissions: + contents: read +engine: copilot +services: + postgres: + image: postgres:15 + ports: + - 5432:5432 + redis: + image: redis:7 + ports: + - 6379:6379 +--- + +# Test Service Ports + +This workflow tests that the compiler automatically generates `--allow-host-service-ports` +from `services:` port mappings. + +Expected: the compiled lock file includes `--allow-host-service-ports` with expressions for +both PostgreSQL (port 5432) and Redis (port 6379). diff --git a/pkg/workflow/service_ports.go b/pkg/workflow/service_ports.go index 36fad13f541..b4ac5995dfb 100644 --- a/pkg/workflow/service_ports.go +++ b/pkg/workflow/service_ports.go @@ -31,6 +31,25 @@ const ( maxPort = 65535 ) +// servicesYAMLWrapper is the top-level YAML wrapper for a services: block. +// It provides typed access to the service container map while the YAML is parsed +// via goccy/go-yaml with field-level annotations. +type servicesYAMLWrapper struct { + Services map[string]*serviceContainerConfig `yaml:"services"` +} + +// serviceContainerConfig represents a single GitHub Actions service container. +// Only the Ports field is consumed for port-expression generation; all other +// container fields (image, env, options, volumes, …) are intentionally omitted. +// +// Ports is declared as any because GitHub Actions allows the ports list to contain +// both string values ("5432:5432") and bare integers (5432), and the YAML may also +// omit the field entirely (nil) or supply a non-list scalar, which triggers a +// compile-time warning. +type serviceContainerConfig struct { + Ports any `yaml:"ports"` +} + // ExtractServicePortExpressions parses the services: YAML string from WorkflowData.Services // and returns a comma-separated string of ${{ job.services[''].ports[''] }} expressions // for all TCP port mappings found. @@ -54,15 +73,15 @@ func ExtractServicePortExpressions(servicesYAML string) (string, []string) { servicePortsLog.Print("Extracting service port expressions from services YAML") - // Parse the services YAML - var wrapper map[string]any + // Parse the services YAML into typed structs so field access is explicit + // and does not rely on map[string]any type assertions. + var wrapper servicesYAMLWrapper if err := yaml.Unmarshal([]byte(servicesYAML), &wrapper); err != nil { servicePortsLog.Printf("Failed to parse services YAML: %v", err) return "", nil } - services, ok := wrapper["services"].(map[string]any) - if !ok { + if wrapper.Services == nil { servicePortsLog.Print("No services map found in YAML") return "", nil } @@ -71,28 +90,26 @@ func ExtractServicePortExpressions(servicesYAML string) (string, []string) { var warnings []string // Sort service IDs for deterministic output - serviceIDs := make([]string, 0, len(services)) - for id := range services { + serviceIDs := make([]string, 0, len(wrapper.Services)) + for id := range wrapper.Services { serviceIDs = append(serviceIDs, id) } sort.Strings(serviceIDs) for _, serviceID := range serviceIDs { - serviceConfig := services[serviceID] - svcMap, ok := serviceConfig.(map[string]any) - if !ok { - servicePortsLog.Printf("Service %s is not a map, skipping", serviceID) + svc := wrapper.Services[serviceID] + if svc == nil { + servicePortsLog.Printf("Service %s is nil, skipping", serviceID) continue } - ports, hasPorts := svcMap["ports"] - if !hasPorts { + if svc.Ports == nil { warnings = append(warnings, fmt.Sprintf("service %q has no ports mapping; it will not be reachable from the AWF sandbox", serviceID)) servicePortsLog.Printf("Service %s has no ports, skipping", serviceID) continue } - portsList, ok := ports.([]any) + portsList, ok := svc.Ports.([]any) if !ok { servicePortsLog.Printf("Service %s ports is not a list, skipping", serviceID) warnings = append(warnings, fmt.Sprintf("service %q has an invalid ports mapping (expected a list); it will not be reachable from the AWF sandbox", serviceID))