Skip to content

Commit cef88f1

Browse files
authored
Preserve && in AWF config JSON embedded in lock workflows (#30700)
1 parent 61bec41 commit cef88f1

5 files changed

Lines changed: 89 additions & 14 deletions

File tree

pkg/jsonutil/json.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package jsonutil
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"strings"
7+
)
8+
9+
// MarshalCompactNoHTMLEscape marshals a value to compact JSON without HTML escaping.
10+
// It trims the trailing newline emitted by json.Encoder.
11+
func MarshalCompactNoHTMLEscape(v any) (string, error) {
12+
var buf bytes.Buffer
13+
encoder := json.NewEncoder(&buf)
14+
encoder.SetEscapeHTML(false)
15+
if err := encoder.Encode(v); err != nil {
16+
return "", err
17+
}
18+
19+
return strings.TrimSuffix(buf.String(), "\n"), nil
20+
}

pkg/jsonutil/json_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
//go:build !integration
2+
3+
package jsonutil
4+
5+
import (
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func TestMarshalCompactNoHTMLEscape(t *testing.T) {
13+
input := map[string]string{
14+
"expr": "${{ env.MCP_ENV == 'staging' && env.MCP_URL_STAGING || env.MCP_URL_PROD }}",
15+
}
16+
17+
result, err := MarshalCompactNoHTMLEscape(input)
18+
require.NoError(t, err, "marshal should succeed")
19+
20+
assert.Contains(t, result, "&&", "expected expression operators to be preserved")
21+
assert.NotContains(t, result, "\\u0026", "expected '&' to not be HTML-escaped")
22+
assert.NotContains(t, result, "\n", "expected compact JSON without trailing newline")
23+
}

pkg/parser/frontmatter_hash.go

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
package parser
22

33
import (
4-
"bytes"
54
"crypto/sha256"
65
"encoding/hex"
7-
"encoding/json"
86
"errors"
97
"fmt"
108
"os"
@@ -13,6 +11,7 @@ import (
1311
"sort"
1412
"strings"
1513

14+
"github.com/github/gh-aw/pkg/jsonutil"
1615
"github.com/github/gh-aw/pkg/logger"
1716
"github.com/github/gh-aw/pkg/typeutil"
1817
)
@@ -38,15 +37,7 @@ var DefaultFileReader FileReader = os.ReadFile
3837
// marshalJSONWithoutHTMLEscape marshals a value to JSON without HTML escaping
3938
// This matches JavaScript's JSON.stringify behavior
4039
func marshalJSONWithoutHTMLEscape(v any) (string, error) {
41-
var buf bytes.Buffer
42-
enc := json.NewEncoder(&buf)
43-
enc.SetEscapeHTML(false)
44-
if err := enc.Encode(v); err != nil {
45-
return "", err
46-
}
47-
// Remove the trailing newline that Encoder adds
48-
result := buf.String()
49-
return strings.TrimSuffix(result, "\n"), nil
40+
return jsonutil.MarshalCompactNoHTMLEscape(v)
5041
}
5142

5243
// marshalSorted recursively marshals data with sorted keys

pkg/workflow/awf_config.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import (
5555
"sync"
5656

5757
"github.com/github/gh-aw/pkg/constants"
58+
"github.com/github/gh-aw/pkg/jsonutil"
5859
"github.com/github/gh-aw/pkg/logger"
5960
"github.com/santhosh-tekuri/jsonschema/v6"
6061
)
@@ -283,12 +284,12 @@ func BuildAWFConfigJSON(config AWFCommandConfig) (string, error) {
283284
awfConfigLog.Printf("Container section: image_tag=%s", awfImageTag)
284285
}
285286

286-
jsonBytes, err := json.Marshal(awfConfig)
287+
jsonStr, err := jsonutil.MarshalCompactNoHTMLEscape(awfConfig)
287288
if err != nil {
288289
return "", fmt.Errorf("failed to marshal AWF config to JSON: %w", err)
289290
}
290-
jsonStr := string(jsonBytes)
291-
awfConfigLog.Printf("AWF config JSON generated: %d bytes", len(jsonBytes))
291+
292+
awfConfigLog.Printf("AWF config JSON generated: %d bytes", len(jsonStr))
292293

293294
if err := validateAWFConfigJSON(jsonStr); err != nil {
294295
return "", fmt.Errorf("generated AWF config failed schema validation: %w", err)

pkg/workflow/awf_config_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,25 @@ func TestBuildAWFConfigJSON(t *testing.T) {
195195
assert.NotContains(t, jsonStr, "\n", "JSON output should not contain newlines (must be compact)")
196196
assert.NotContains(t, jsonStr, " ", "JSON output should not contain indentation")
197197
})
198+
199+
t.Run("github actions expressions preserve && operators in allowDomains", func(t *testing.T) {
200+
config := AWFCommandConfig{
201+
EngineName: "copilot",
202+
AllowedDomains: "${{ env.MCP_ENV == 'staging' && env.MCP_URL_STAGING || env.MCP_URL_PROD }}",
203+
WorkflowData: &WorkflowData{
204+
EngineConfig: &EngineConfig{ID: "copilot"},
205+
NetworkPermissions: &NetworkPermissions{
206+
Firewall: &FirewallConfig{Enabled: true},
207+
},
208+
},
209+
}
210+
211+
jsonStr, err := BuildAWFConfigJSON(config)
212+
require.NoError(t, err, "BuildAWFConfigJSON should not return an error")
213+
214+
assert.Contains(t, jsonStr, "&&", "JSON output should preserve && in GitHub Actions expressions")
215+
assert.NotContains(t, jsonStr, "\\u0026", "JSON output should not HTML-escape '&' characters")
216+
})
198217
}
199218

200219
// TestBuildAWFConfigSchemaURL verifies that buildAWFConfigSchemaURL returns a release-pinned
@@ -416,6 +435,27 @@ func TestBuildAWFCommand_UsesConfigFile(t *testing.T) {
416435
assert.Contains(t, command, `"enabled":true`, "config JSON should have apiProxy enabled")
417436
}
418437

438+
func TestBuildAWFCommand_PreservesGitHubExpressionOperatorsInConfigJSON(t *testing.T) {
439+
config := AWFCommandConfig{
440+
EngineName: "copilot",
441+
EngineCommand: "copilot --prompt-file /tmp/prompt.txt",
442+
LogFile: "/tmp/gh-aw/agent-stdio.log",
443+
AllowedDomains: "${{ env.MCP_ENV == 'staging' && env.MCP_URL_STAGING || env.MCP_URL_PROD }}",
444+
WorkflowData: &WorkflowData{
445+
EngineConfig: &EngineConfig{ID: "copilot"},
446+
NetworkPermissions: &NetworkPermissions{
447+
Firewall: &FirewallConfig{Enabled: true},
448+
},
449+
},
450+
}
451+
452+
command := BuildAWFCommand(config)
453+
454+
assert.Contains(t, command, "env.MCP_ENV == 'staging'", "expected full GitHub Actions expression to be preserved")
455+
assert.Contains(t, command, "&&", "expected AWF config JSON in command to preserve &&")
456+
assert.NotContains(t, command, "\\u0026", "expected AWF config JSON in command to not HTML-escape '&'")
457+
}
458+
419459
// TestBuildAWFCommand_ConfigFileWithPathSetup verifies that the config file write command
420460
// is correctly integrated with the path setup section.
421461
func TestBuildAWFCommand_ConfigFileWithPathSetup(t *testing.T) {

0 commit comments

Comments
 (0)