{"type":"tool.execution_complete","timestamp":"2026-08-01T06:25:50.976Z","data":{"toolName":"bash","mcpServerName":"","success":true,"result":{"content":"{\"additions\":127,\"body\":\"For non-Copilot engines (Claude, Codex, etc.), secrets in stdio/container custom MCP server `env:` blocks were substituted as bare `${VAR}` inside an unquoted heredoc. Bash expands those before the MCP gateway parses the config, so any secret containing `\\\"` or `\\\\` silently corrupts the JSON and the **Start MCP Gateway** step fails.\\n\\n## Root cause\\n\\n`renderCustomMCPEnvVars` had separate branches for Copilot (`\\\\${VAR}`, heredoc-safe) and non-Copilot (`${VAR}`, bash-expanded). The Copilot path was correct; the non-Copilot path was not.\\n\\n## Changes\\n\\n- **`mcp_config_custom.go`** — collapsed both branches into a single call to `ReplaceTemplateExpressionsWithEnvVars`, which produces `\\\\${VAR}` for all JSON engines. Bash strips the leading `\\\\` in the heredoc, leaving a literal `${VAR}` that the gateway resolves from its own environment after the JSON is parsed. Removed the now-unused `requiresCopilotFields` param from `renderCustomMCPEnvVars`. Updated doc comment with the heredoc injection rationale.\\n\\n- **`mcp_config_copilot_test.go`** — added `TestRenderCustomMCPEnvVars_NonCopilotSecretsEscaped` covering non-Copilot secrets, Copilot secrets, and fallback expressions (`${{ secrets.X || 'default' }}`), asserting `\\\\${VAR}` and asserting the absence of `${VAR}`.\\n\\n## Before / after\\n\\n```\\n# Before (non-Copilot): bash expands ${PROMETHEUS_PASSWORD} → raw value spliced into JSON\\n\\\"env\\\": {\\n \\\"PROMETHEUS_PASSWORD\\\": \\\"${PROMETHEUS_PASSWORD}\\\" ← unquoted heredoc expands this\\n}\\n\\n# After: literal \\\\${PROMETHEUS_PASSWORD} survives heredoc intact as valid JSON\\n\\\"env\\\": {\\n \\\"PROMETHEUS_PASSWORD\\\": \\\"\\\\${PROMETHEUS_PASSWORD}\\\" ← gateway substitutes safely\\n}\\n```\\n\\nThe `\\\\${VAR}` pattern was already used correctly for HTTP header secrets and the built-in safe-outputs MCP server in the same generated file; this aligns the custom env block path with the rest.\\n\\n<!-- START COPILOT CODING AGENT SUFFIX -->\\n\\n- Fixes #49424\",\"changedFiles\":2,\"deletions\":12,\"files\":[{\"path\":\"pkg/workflow/mcp_config_copilot_test.go\",\"additions\":111,\"deletions\":0,\"changeType\":\"MODIFIED\"},{\"path\":\"pkg/workflow/mcp_config_custom.go\",\"additions\":16,\"deletions\":12,\"changeType\":\"MODIFIED\"}],\"headRefName\":\"copilot/fix-stdio-container-env-secrets\",\"headRefOid\":\"b9ca441354ffefa90a01abec8bb31656ba995fb5\",\"number\":49450,\"title\":\"fix: escape MCP env secrets with \\\\${VAR} for non-Copilot engines to prevent heredoc JSON corruption\"}\n---\n176 /tmp/gh-aw/agent/pr-diff.patch\n---\n[\n {\n \"body\": \"This removes the only production call to `ReplaceSecretsWithBashVars`, but that exported helper remains documented as being used for non-Copilot MCP env blocks (`secret_extraction.go:165-168`). Please\",\n \"id\": 3694593524,\n \"line\": 81,\n \"path\": \"pkg/workflow/mcp_config_custom.go\",\n \"user\": \"Copilot\"\n }\n]\n---cache---\nspdd-daily\n<shellId: 11 completed with exit code 0>","detailedContent":"{\"additions\":127,\"body\":\"For non-Copilot engines (Claude, Codex, etc.), secrets in stdio/container custom MCP server `env:` blocks were substituted as bare `${VAR}` inside an unquoted heredoc. Bash expands those before the MCP gateway parses the config, so any secret containing `\\\"` or `\\\\` silently corrupts the JSON and the **Start MCP Gateway** step fails.\\n\\n## Root cause\\n\\n`renderCustomMCPEnvVars` had separate branches for Copilot (`\\\\${VAR}`, heredoc-safe) and non-Copilot (`${VAR}`, bash-expanded). The Copilot path was correct; the non-Copilot path was not.\\n\\n## Changes\\n\\n- **`mcp_config_custom.go`** — collapsed both branches into a single call to `ReplaceTemplateExpressionsWithEnvVars`, which produces `\\\\${VAR}` for all JSON engines. Bash strips the leading `\\\\` in the heredoc, leaving a literal `${VAR}` that the gateway resolves from its own environment after the JSON is parsed. Removed the now-unused `requiresCopilotFields` param from `renderCustomMCPEnvVars`. Updated doc comment with the heredoc injection rationale.\\n\\n- **`mcp_config_copilot_test.go`** — added `TestRenderCustomMCPEnvVars_NonCopilotSecretsEscaped` covering non-Copilot secrets, Copilot secrets, and fallback expressions (`${{ secrets.X || 'default' }}`), asserting `\\\\${VAR}` and asserting the absence of `${VAR}`.\\n\\n## Before / after\\n\\n```\\n# Before (non-Copilot): bash expands ${PROMETHEUS_PASSWORD} → raw value spliced into JSON\\n\\\"env\\\": {\\n \\\"PROMETHEUS_PASSWORD\\\": \\\"${PROMETHEUS_PASSWORD}\\\" ← unquoted heredoc expands this\\n}\\n\\n# After: literal \\\\${PROMETHEUS_PASSWORD} survives heredoc intact as valid JSON\\n\\\"env\\\": {\\n \\\"PROMETHEUS_PASSWORD\\\": \\\"\\\\${PROMETHEUS_PASSWORD}\\\" ← gateway substitutes safely\\n}\\n```\\n\\nThe `\\\\${VAR}` pattern was already used correctly for HTTP header secrets and the built-in safe-outputs MCP server in the same generated file; this aligns the custom env block path with the rest.\\n\\n<!-- START COPILOT CODING AGENT SUFFIX -->\\n\\n- Fixes #49424\",\"changedFiles\":2,\"deletions\":12,\"files\":[{\"path\":\"pkg/workflow/mcp_config_copilot_test.go\",\"additions\":111,\"deletions\":0,\"changeType\":\"MODIFIED\"},{\"path\":\"pkg/workflow/mcp_config_custom.go\",\"additions\":16,\"deletions\":12,\"changeType\":\"MODIFIED\"}],\"headRefName\":\"copilot/fix-stdio-container-env-secrets\",\"headRefOid\":\"b9ca441354ffefa90a01abec8bb31656ba995fb5\",\"number\":49450,\"title\":\"fix: escape MCP env secrets with \\\\${VAR} for non-Copilot engines to prevent heredoc JSON corruption\"}\n---\n176 /tmp/gh-aw/agent/pr-diff.patch\n---\n[\n {\n \"body\": \"This removes the only production call to `ReplaceSecretsWithBashVars`, but that exported helper remains documented as being used for non-Copilot MCP env blocks (`secret_extraction.go:165-168`). Please\",\n \"id\": 3694593524,\n \"line\": 81,\n \"path\": \"pkg/workflow/mcp_config_custom.go\",\n \"user\": \"Copilot\"\n }\n]\n---cache---\nspdd-daily\n<shellId: 11 completed with exit code 0>"}}}
{"type":"assistant.message","timestamp":"2026-08-01T06:25:55.528Z","data":{"content":""}}
{"type":"tool.execution_start","timestamp":"2026-08-01T06:25:55.531Z","data":{"toolName":"view","mcpServerName":""}}
{"type":"tool.execution_complete","timestamp":"2026-08-01T06:25:55.585Z","data":{"toolName":"view","mcpServerName":"","success":true,"result":{"content":"1. diff --git a/pkg/workflow/mcp_config_copilot_test.go b/pkg/workflow/mcp_config_copilot_test.go\n2. index 5d953df9320..22c78c9b09c 100644\n3. --- a/pkg/workflow/mcp_config_copilot_test.go\n4. +++ b/pkg/workflow/mcp_config_copilot_test.go\n5. @@ -203,6 +203,117 @@ func TestRenderSharedMCPConfig_ToolsFieldGeneration(t *testing.T) {\n6. \t}\n7. }\n8. \n9. +// TestRenderCustomMCPEnvVars_NonCopilotSecretsEscaped verifies that for non-Copilot\n10. +// JSON engines, secrets in custom MCP server env blocks are rendered as \\${VAR}\n11. +// (backslash-escaped) rather than ${VAR} (unescaped). Unescaped references would\n12. +// be expanded by bash inside the unquoted heredoc that carries the MCP gateway\n13. +// JSON config -- a secret containing '\"' or '\\' would corrupt the JSON and cause\n14. +// the gateway to fail before the agent runs. Backslash-escaping keeps the JSON\n15. +// valid regardless of the secret's runtime value.\n16. +func TestRenderCustomMCPEnvVars_NonCopilotSecretsEscaped(t *testing.T) {\n17. +\ttests := []struct {\n18. +\t\tname string\n19. +\t\ttoolConfig map[string]any\n20. +\t\trenderer MCPConfigRenderer\n21. +\t\texpectedContent []string\n22. +\t\tunexpectedContent []string\n23. +\t}{\n24. +\t\t{\n25. +\t\t\tname: \"Non-Copilot stdio container - secret in env uses backslash-escaped var\",\n26. +\t\t\ttoolConfig: map[string]any{\n27. +\t\t\t\t\"type\": \"stdio\",\n28. +\t\t\t\t\"container\": \"some/image:latest\",\n29. +\t\t\t\t\"env\": map[string]any{\n30. +\t\t\t\t\t\"MY_TOKEN\": \"${{ secrets.MY_TOKEN }}\",\n31. +\t\t\t\t},\n32. +\t\t\t},\n33. +\t\t\trenderer: MCPConfigRenderer{\n34. +\t\t\t\tIndentLevel: \" \",\n35. +\t\t\t\tFormat: \"json\",\n36. +\t\t\t\tRequiresCopilotFields: false,\n37. +\t\t\t},\n38. +\t\t\t// Secret must be rendered as \\${MY_TOKEN} so the unquoted heredoc\n39. +\t\t\t// leaves a literal ${MY_TOKEN} string in the JSON (valid JSON).\n40. +\t\t\texpectedContent: []string{\n41. +\t\t\t\t`\"MY_TOKEN\": \"\\${MY_TOKEN}\"`,\n42. +\t\t\t},\n43. +\t\t\t// Must NOT appear as an unescaped bash variable reference -- that\n44. +\t\t\t// would let bash splice the raw secret value into the JSON.\n45. +\t\t\tunexpectedContent: []string{\n46. +\t\t\t\t`\"MY_TOKEN\": \"${MY_TOKEN}\"`,\n47. +\t\t\t},\n48. +\t\t},\n49. +\t\t{\n50. +\t\t\tname: \"Copilot stdio - secret in env also uses backslash-escaped var\",\n51. +\t\t\ttoolConfig: map[string]any{\n52. +\t\t\t\t\"type\": \"stdio\",\n53. +\t\t\t\t\"container\": \"some/image:latest\",\n54. +\t\t\t\t\"env\": map[string]any{\n55. +\t\t\t\t\t\"MY_TOKEN\": \"${{ secrets.MY_TOKEN }}\",\n56. +\t\t\t\t},\n57. +\t\t\t\t\"allowed\": []string{\"*\"},\n58. +\t\t\t},\n59. +\t\t\trenderer: MCPConfigRenderer{\n60. +\t\t\t\tIndentLevel: \" \",\n61. +\t\t\t\tFormat: \"json\",\n62. +\t\t\t\tRequiresCopilotFields: true,\n63. +\t\t\t},\n64. +\t\t\texpectedContent: []string{\n65. +\t\t\t\t`\"MY_TOKEN\": \"\\${MY_TOKEN}\"`,\n66. +\t\t\t},\n67. +\t\t\tunexpectedContent: []string{\n68. +\t\t\t\t`\"MY_TOKEN\": \"${MY_TOKEN}\"`,\n69. +\t\t\t},\n70. +\t\t},\n71. +\t\t{\n72. +\t\t\tname: \"Non-Copilot stdio container - secret with fallback in env uses backslash-escaped var\",\n73. +\t\t\ttoolConfig: map[string]any{\n74. +\t\t\t\t\"type\": \"stdio\",\n75. +\t\t\t\t\"container\": \"some/image:latest\",\n76. +\t\t\t\t\"env\": map[string]any{\n77. +\t\t\t\t\t\"DD_SITE\": \"${{ secrets.DD_SITE || 'datadoghq.com' }}\",\n78. +\t\t\t\t},\n79. +\t\t\t},\n80. +\t\t\trenderer: MCPConfigRenderer{\n81. +\t\t\t\tIndentLevel: \" \",\n82. +\t\t\t\tFormat: \"json\",\n83. +\t\t\t\tRequiresCopilotFields: false,\n84. +\t\t\t},\n85. +\t\t\texpectedContent: []string{\n86. +\t\t\t\t`\"DD_SITE\": \"\\${DD_SITE}\"`,\n87. +\t\t\t},\n88. +\t\t\tunexpectedContent: []string{\n89. +\t\t\t\t`\"DD_SITE\": \"${DD_SITE}\"`,\n90. +\t\t\t},\n91. +\t\t},\n92. +\t}\n93. +\n94. +\tfor _, tt := range tests {\n95. +\t\tt.Run(tt.name, func(t *testing.T) {\n96. +\t\t\tvar output strings.Builder\n97. +\n98. +\t\t\terr := renderSharedMCPConfig(&output, \"test-tool\", tt.toolConfig, tt.renderer)\n99. +\t\t\tif err != nil {\n100. +\t\t\t\tt.Fatalf(\"renderSharedMCPConfig failed: %v\", err)\n101. +\t\t\t}\n102. +\n103. +\t\t\tresult := output.String()\n104. +\n105. +\t\t\tfor _, expected := range tt.expectedContent {\n106. +\t\t\t\tif !strings.Contains(result, expected) {\n107. +\t\t\t\t\tt.Errorf(\"Expected content not found: %q\\nActual output:\\n%s\", expected, result)\n108. +\t\t\t\t}\n109. +\t\t\t}\n110. +\n111. +\t\t\tfor _, unexpected := range tt.unexpectedContent {\n112. +\t\t\t\tif strings.Contains(result, unexpected) {\n113. +\t\t\t\t\tt.Errorf(\"Unexpected content found: %q\\nActual output:\\n%s\", unexpected, result)\n114. +\t\t\t\t}\n115. +\t\t\t}\n116. +\t\t})\n117. +\t}\n118. +}\n119. +\n120. func TestRenderSharedMCPConfig_TypeConversion(t *testing.T) {\n121. \ttests := []struct {\n122. \t\tname string\n123. diff --git a/pkg/workflow/mcp_config_custom.go b/pkg/workflow/mcp_config_custom.go\n124. index b4f2d05c575..ccbe1b92230 100644\n125. --- a/pkg/workflow/mcp_config_custom.go\n126. +++ b/pkg/workflow/mcp_config_custom.go\n127. @@ -54,10 +54,16 @@ func renderCustomMCPConfigWrapperWithContext(yaml *strings.Builder, toolName str\n128. //\n129. // For TOML output, GitHub Actions template expressions are rewritten to direct\n130. // ${VAR} references because Codex config expects shell-style environment\n131. -// expansion. For JSON output, Copilot uses escaped \\${VAR} passthrough syntax,\n132. -// while non-Copilot engines use bash variable substitution to avoid embedding\n133. -// secret expressions directly in the generated run block.\n134. -func renderCustomMCPEnvVars(env map[string]string, tomlFormat, requiresCopilotFields bool) map[string]string {\n135. +// expansion. For JSON output, both Copilot and non-Copilot engines use the\n136. +// escaped \\${VAR} passthrough syntax. The MCP gateway JSON config is written\n137. +// inside an unquoted heredoc, so any unescaped ${VAR} reference would be\n138. +// expanded by bash before the gateway sees it — splicing the raw secret bytes\n139. +// into JSON text. A secret value containing a '\"' or '\\' character would\n140. +// corrupt the JSON. Backslash-escaping (\\${VAR}) prevents the heredoc from\n141. +// expanding the variable; bash only strips the leading backslash, leaving the\n142. +// literal ${VAR} string in the JSON, which the gateway then resolves safely\n143. +// from its own environment (RGS-008 compliance).\n144. +func renderCustomMCPEnvVars(env map[string]string, tomlFormat bool) map[string]string {\n145. \trenderedEnv := make(map[string]string, len(env))\n146. \tfor envKey, envValue := range env {\n147. \t\tif tomlFormat {\n148. @@ -67,14 +73,12 @@ func renderCustomMCPEnvVars(env map[string]string, tomlFormat, requiresCopilotFi\n149. \t\t\tenvValue = strings.ReplaceAll(envValue, \"${{ env.\", \"${\")\n150. \t\t\tenvValue = strings.ReplaceAll(envValue, \"${{ github.workspace }}\", \"${GITHUB_WORKSPACE}\")\n151. \t\t\tenvValue = strings.ReplaceAll(envValue, \" }}\", \"}\")\n152. -\t\t} else if requiresCopilotFields {\n153. -\t\t\t// For Copilot, replace all template expressions with \\${VAR} syntax.\n154. -\t\t\tenvValue = ReplaceTemplateExpressionsWithEnvVars(envValue)\n155. \t\t} else {\n156. -\t\t\t// For non-Copilot engines, replace secrets with ${VAR} bash expansion so\n157. -\t\t\t// they are never directly interpolated in the run block (RGS-008). The\n158. -\t\t\t// env vars are injected into the step env block by collectMCPEnvironmentVariables.\n159. -\t\t\tenvValue = ReplaceSecretsWithBashVars(envValue)\n160. +\t\t\t// For both Copilot and non-Copilot JSON engines, replace all template\n161. +\t\t\t// expressions with \\${VAR} passthrough syntax. This keeps raw secret values\n162. +\t\t\t// out of the heredoc (RGS-008) and produces valid JSON regardless of the\n163. +\t\t\t// characters contained in the secret at runtime.\n164. +\t\t\tenvValue = ReplaceTemplateExpressionsWithEnvVars(envValue)\n165. \t\t}\n166. \t\trenderedEnv[envKey] = envValue\n167. \t}\n168. @@ -338,7 +342,7 @@ func renderMCPMapProperty(yaml *strings.Builder, property string, isLast bool, m\n169. }\n170. \n171. func renderMCPEnvMap(yaml *strings.Builder, isLast bool, mcpConfig *parser.RegistryMCPServerConfig, renderer MCPConfigRenderer, headerSecrets map[string]string) {\n172. -\trenderedEnv := renderCustomMCPEnvVars(mcpConfig.Env, renderer.Format == \"toml\", renderer.RequiresCopilotFields)\n173. +\trenderedEnv := renderCustomMCPEnvVars(mcpConfig.Env, renderer.Format == \"toml\")\n174. \tif renderer.Format == \"toml\" {\n175. \t\twriteTOMLInlineStringMapSection(yaml, renderer.IndentLevel, \"env\", renderedEnv)\n176. \t\treturn\n177. ","detailedContent":"\ndiff --git a/tmp/gh-aw/agent/pr-diff.patch b/tmp/gh-aw/agent/pr-diff.patch\nindex 0000000..0000000 100644\n--- a/tmp/gh-aw/agent/pr-diff.patch\n+++ b/tmp/gh-aw/agent/pr-diff.patch\n@@ -1,177 +1,177 @@\n diff --git a/pkg/workflow/mcp_config_copilot_test.go b/pkg/workflow/mcp_config_copilot_test.go\n index 5d953df9320..22c78c9b09c 100644\n --- a/pkg/workflow/mcp_config_copilot_test.go\n +++ b/pkg/workflow/mcp_config_copilot_test.go\n @@ -203,6 +203,117 @@ func TestRenderSharedMCPConfig_ToolsFieldGeneration(t *testing.T) {\n \t}\n }\n \n +// TestRenderCustomMCPEnvVars_NonCopilotSecretsEscaped verifies that for non-Copilot\n +// JSON engines, secrets in custom MCP server env blocks are rendered as \\${VAR}\n +// (backslash-escaped) rather than ${VAR} (unescaped). Unescaped references would\n +// be expanded by bash inside the unquoted heredoc that carries the MCP gateway\n +// JSON config -- a secret containing '\"' or '\\' would corrupt the JSON and cause\n +// the gateway to fail before the agent runs. Backslash-escaping keeps the JSON\n +// valid regardless of the secret's runtime value.\n +func TestRenderCustomMCPEnvVars_NonCopilotSecretsEscaped(t *testing.T) {\n +\ttests := []struct {\n +\t\tname string\n +\t\ttoolConfig map[string]any\n +\t\trenderer MCPConfigRenderer\n +\t\texpectedContent []string\n +\t\tunexpectedContent []string\n +\t}{\n +\t\t{\n +\t\t\tname: \"Non-Copilot stdio container - secret in env uses backslash-escaped var\",\n +\t\t\ttoolConfig: map[string]any{\n +\t\t\t\t\"type\": \"stdio\",\n +\t\t\t\t\"container\": \"some/image:latest\",\n +\t\t\t\t\"env\": map[string]any{\n +\t\t\t\t\t\"MY_TOKEN\": \"${{ secrets.MY_TOKEN }}\",\n +\t\t\t\t},\n +\t\t\t},\n +\t\t\trenderer: MCPConfigRenderer{\n +\t\t\t\tIndentLevel: \" \",\n +\t\t\t\tFormat: \"json\",\n +\t\t\t\tRequiresCopilotFields: false,\n +\t\t\t},\n +\t\t\t// Secret must be rendered as \\${MY_TOKEN} so the unquoted heredoc\n +\t\t\t// leaves a literal ${MY_TOKEN} string in the JSON (valid JSON).\n +\t\t\texpectedContent: []string{\n +\t\t\t\t`\"MY_TOKEN\": \"\\${MY_TOKEN}\"`,\n +\t\t\t},\n +\t\t\t// Must NOT appear as an unescaped bash variable reference -- that\n +\t\t\t// would let bash splice the raw secret value into the JSON.\n +\t\t\tunexpectedContent: []string{\n +\t\t\t\t`\"MY_TOKEN\": \"${MY_TOKEN}\"`,\n +\t\t\t},\n +\t\t},\n +\t\t{\n +\t\t\tname: \"Copilot stdio - secret in env also uses backslash-escaped var\",\n +\t\t\ttoolConfig: map[string]any{\n +\t\t\t\t\"type\": \"stdio\",\n +\t\t\t\t\"container\": \"some/image:latest\",\n +\t\t\t\t\"env\": map[string]any{\n +\t\t\t\t\t\"MY_TOKEN\": \"${{ secrets.MY_TOKEN }}\",\n +\t\t\t\t},\n +\t\t\t\t\"allowed\": []string{\"*\"},\n +\t\t\t},\n +\t\t\trenderer: MCPConfigRenderer{\n +\t\t\t\tIndentLevel: \" \",\n +\t\t\t\tFormat: \"json\",\n +\t\t\t\tRequiresCopilotFields: true,\n +\t\t\t},\n +\t\t\texpectedContent: []string{\n +\t\t\t\t`\"MY_TOKEN\": \"\\${MY_TOKEN}\"`,\n +\t\t\t},\n +\t\t\tunexpectedContent: []string{\n +\t\t\t\t`\"MY_TOKEN\": \"${MY_TOKEN}\"`,\n +\t\t\t},\n +\t\t},\n +\t\t{\n +\t\t\tname: \"Non-Copilot stdio container - secret with fallback in env uses backslash-escaped var\",\n +\t\t\ttoolConfig: map[string]any{\n +\t\t\t\t\"type\": \"stdio\",\n +\t\t\t\t\"container\": \"some/image:latest\",\n +\t\t\t\t\"env\": map[string]any{\n +\t\t\t\t\t\"DD_SITE\": \"${{ secrets.DD_SITE || 'datadoghq.com' }}\",\n +\t\t\t\t},\n +\t\t\t},\n +\t\t\trenderer: MCPConfigRenderer{\n +\t\t\t\tIndentLevel: \" \",\n +\t\t\t\tFormat: \"json\",\n +\t\t\t\tRequiresCopilotFields: false,\n +\t\t\t},\n +\t\t\texpectedContent: []string{\n +\t\t\t\t`\"DD_SITE\": \"\\${DD_SITE}\"`,\n +\t\t\t},\n +\t\t\tunexpectedContent: []string{\n +\t\t\t\t`\"DD_SITE\": \"${DD_SITE}\"`,\n +\t\t\t},\n +\t\t},\n +\t}\n +\n +\tfor _, tt := range tests {\n +\t\tt.Run(tt.name, func(t *testing.T) {\n +\t\t\tvar output strings.Builder\n +\n +\t\t\terr := renderSharedMCPConfig(&output, \"test-tool\", tt.toolConfig, tt.renderer)\n +\t\t\tif err != nil {\n +\t\t\t\tt.Fatalf(\"renderSharedMCPConfig failed: %v\", err)\n +\t\t\t}\n +\n +\t\t\tresult := output.String()\n +\n +\t\t\tfor _, expected := range tt.expectedContent {\n +\t\t\t\tif !strings.Contains(result, expected) {\n +\t\t\t\t\tt.Errorf(\"Expected content not found: %q\\nActual output:\\n%s\", expected, result)\n +\t\t\t\t}\n +\t\t\t}\n +\n +\t\t\tfor _, unexpected := range tt.unexpectedContent {\n +\t\t\t\tif strings.Contains(result, unexpected) {\n +\t\t\t\t\tt.Errorf(\"Unexpected content found: %q\\nActual output:\\n%s\", unexpected, result)\n +\t\t\t\t}\n +\t\t\t}\n +\t\t})\n +\t}\n +}\n +\n func TestRenderSharedMCPConfig_TypeConversion(t *testing.T) {\n \ttests := []struct {\n \t\tname string\n diff --git a/pkg/workflow/mcp_config_custom.go b/pkg/workflow/mcp_config_custom.go\n index b4f2d05c575..ccbe1b92230 100644\n --- a/pkg/workflow/mcp_config_custom.go\n +++ b/pkg/workflow/mcp_config_custom.go\n @@ -54,10 +54,16 @@ func renderCustomMCPConfigWrapperWithContext(yaml *strings.Builder, toolName str\n //\n // For TOML output, GitHub Actions template expressions are rewritten to direct\n // ${VAR} references because Codex config expects shell-style environment\n -// expansion. For JSON output, Copilot uses escaped \\${VAR} passthrough syntax,\n -// while non-Copilot engines use bash variable substitution to avoid embedding\n -// secret expressions directly in the generated run block.\n -func renderCustomMCPEnvVars(env map[string]string, tomlFormat, requiresCopilotFields bool) map[string]string {\n +// expansion. For JSON output, both Copilot and non-Copilot engines use the\n +// escaped \\${VAR} passthrough syntax. The MCP gateway JSON config is written\n +// inside an unquoted heredoc, so any unescaped ${VAR} reference would be\n +// expanded by bash before the gateway sees it — splicing the raw secret bytes\n +// into JSON text. A secret value containing a '\"' or '\\' character would\n +// corrupt the JSON. Backslash-escaping (\\${VAR}) prevents the heredoc from\n +// expanding the variable; bash only strips the leading backslash, leaving the\n +// literal ${VAR} string in the JSON, which the gateway then resolves safely\n +// from its own environment (RGS-008 compliance).\n +func renderCustomMCPEnvVars(env map[string]string, tomlFormat bool) map[string]string {\n \trenderedEnv := make(map[string]string, len(env))\n \tfor envKey, envValue := range env {\n \t\tif tomlFormat {\n @@ -67,14 +73,12 @@ func renderCustomMCPEnvVars(env map[string]string, tomlFormat, requiresCopilotFi\n \t\t\tenvValue = strings.ReplaceAll(envValue, \"${{ env.\", \"${\")\n \t\t\tenvValue = strings.ReplaceAll(envValue, \"${{ github.workspace }}\", \"${GITHUB_WORKSPACE}\")\n \t\t\tenvValue = strings.ReplaceAll(envValue, \" }}\", \"}\")\n -\t\t} else if requiresCopilotFields {\n -\t\t\t// For Copilot, replace all template expressions with \\${VAR} syntax.\n -\t\t\tenvValue = ReplaceTemplateExpressionsWithEnvVars(envValue)\n \t\t} else {\n -\t\t\t// For non-Copilot engines, replace secrets with ${VAR} bash expansion so\n -\t\t\t// they are never directly interpolated in the run block (RGS-008). The\n -\t\t\t// env vars are injected into the step env block by collectMCPEnvironmentVariables.\n -\t\t\tenvValue = ReplaceSecretsWithBashVars(envValue)\n +\t\t\t// For both Copilot and non-Copilot JSON engines, replace all template\n +\t\t\t// expressions with \\${VAR} passthrough syntax. This keeps raw secret values\n +\t\t\t// out of the heredoc (RGS-008) and produces valid JSON regardless of the\n +\t\t\t// characters contained in the secret at runtime.\n +\t\t\tenvValue = ReplaceTemplateExpressionsWithEnvVars(envValue)\n \t\t}\n \t\trenderedEnv[envKey] = envValue\n \t}\n @@ -338,7 +342,7 @@ func renderMCPMapProperty(yaml *strings.Builder, property string, isLast bool, m\n }\n \n func renderMCPEnvMap(yaml *strings.Builder, isLast bool, mcpConfig *parser.RegistryMCPServerConfig, renderer MCPConfigRenderer, headerSecrets map[string]string) {\n -\trenderedEnv := renderCustomMCPEnvVars(mcpConfig.Env, renderer.Format == \"toml\", renderer.RequiresCopilotFields)\n +\trenderedEnv := renderCustomMCPEnvVars(mcpConfig.Env, renderer.Format == \"toml\")\n \tif renderer.Format == \"toml\" {\n \t\twriteTOMLInlineStringMapSection(yaml, renderer.IndentLevel, \"env\", renderedEnv)\n \t\treturn\n \n"}}}
{"type":"assistant.message","timestamp":"2026-08-01T06:26:12.953Z","data":{"content":""}}
{"type":"tool.execution_start","timestamp":"2026-08-01T06:26:12.957Z","data":{"toolName":"task","mcpServerName":""}}
{"type":"subagent.started","timestamp":"2026-08-01T06:26:12.965Z","data":{"agentName":"general-purpose","agentDisplayName":"General Purpose Agent","toolCallId":"toolu_01Rx3W5kmkRUPFVAAxvCugZe"}}
{"type":"tool.execution_complete","timestamp":"2026-08-01T06:26:12.971Z","data":{"toolName":"task","mcpServerName":"","success":true,"result":{"content":"Agent started in background with agent_id: grumpy-coder-3. You'll be notified when it completes. Tell the user you're waiting and end your response, or continue unrelated work until notified. The agent supports multi-turn conversations — use write_agent to send follow-up messages.","detailedContent":"Prompt to general-purpose agent (grumpy-coder-3):\nYou are a highly critical, grumpy senior Go reviewer doing FIRST-PASS issue mining on a pull request diff in the repo github/gh-aw (Go codebase at /home/runner/work/gh-aw/gh-aw).\n\nPR #49450: \"fix: escape MCP env secrets with \\${VAR} for non-Copilot engines to prevent heredoc JSON corruption\"\n\nContext: `renderCustomMCPEnvVars` in pkg/workflow/mcp_config_custom.go previously had two branches: Copilot engines used `ReplaceTemplateExpressionsWithEnvVars` (producing `\\${VAR}`), non-Copilot engines used `ReplaceSecretsWithBashVars` (producing `${VAR}`, expanded by bash in an unquoted heredoc — a bug because secrets containing `\"` or `\\` corrupt JSON). The fix collapses both branches to always use `ReplaceTemplateExpressionsWithEnvVars`, and removes the now-unused `requiresCopilotFields` parameter from `renderCustomMCPEnvVars`.\n\nThe diff (176 lines) is at /tmp/gh-aw/agent/pr-diff.patch — read it directly. It touches:\n1. pkg/workflow/mcp_config_copilot_test.go — new test TestRenderCustomMCPEnvVars_NonCopilotSecretsEscaped\n2. pkg/workflow/mcp_config_custom.go — renderCustomMCPEnvVars signature change and body change\n\nYour job:\n1. Read /tmp/gh-aw/agent/pr-diff.patch.\n2. Read the full current content of pkg/workflow/mcp_config_custom.go and pkg/workflow/mcp_config_copilot_test.go in the repo to get full context around the changed lines (not just the diff hunk).\n3. Also check pkg/workflow/secret_extraction.go — specifically the ReplaceSecretsWithBashVars and ReplaceTemplateExpressionsWithEnvVars functions and their doc comments — to see if ReplaceSecretsWithBashVars is now completely unused in production code (only used in tests), and if its doc comment is now stale/misleading given this PR removes its only production call site.\n4. Search the whole repo for other callers of ReplaceSecretsWithBashVars and requiresCopilotFields-like patterns to make sure no other place still relies on the removed behavior (e.g. collectMCPEnvironmentVariables which the removed code comment mentioned \"env vars are injected into the step env block by collectMCPEnvironmentVariables\" — verify this still holds true for the new unified code path, i.e., check that the env var injection mechanism still functions correctly for non-Copilot engines now that they use \\${VAR} instead of ${VAR}).\n5. Look for logic errors, edge cases, dead code, stale comments, weak test coverage in the new test (e.g., does it cover TOML/Codex path, does it cover the `RequiresCopilotFields` field even though it no longer affects behavior — is that field now entirely vestigial/dead and should be removed elsewhere, check its other usages), and any regressions in behavior for Codex/TOML engines.\n\nOutput STRICT JSONL — one JSON object per line, each with fields:\n{\"severity\": \"critical|high|medium|low\", \"file\": \"path/relative/to/repo\", \"line\": <line number in the file, best estimate>, \"issue\": \"short description\", \"impact\": \"why it matters\"}\n\nOnly report issues within the actual changed lines/functions of this diff (mcp_config_custom.go's renderCustomMCPEnvVars/renderMCPEnvMap, and the new test file), or directly caused by this change (e.g., stale docs on ReplaceSecretsWithBashVars now that it's unused in production, or a now-vestigial RequiresCopilotFields field). Do not comment on unrelated pre-existing code. Do not include markdown fences or any prose outside the JSONL lines. If no valid issues found, output nothing."}}}
[copilot-sdk-driver] [sdk-driver] error: Execution failed: Error: No model available. Check policy enablement under GitHub Settings > Copilot
{"type":"subagent.completed","timestamp":"2026-08-01T06:26:13.026Z","data":{"agentName":"general-purpose","toolCallId":"toolu_01Rx3W5kmkRUPFVAAxvCugZe"}}
Workflow Failure
Workflow: PR Code Quality Reviewer
Branch: copilot/fix-stdio-container-env-secrets
Run: https://github.com/github/gh-aw/actions/runs/30683628677
Pull Request: #49450
Warning
Engine Failure: The
copilotengine terminated unexpectedly.Last agent output:
Action Required
Assign this issue to an agent to debug and fix the issue.
Debug with any coding agent
Use this prompt with any coding agent (GitHub Copilot, Claude, Gemini, etc.):
Manually invoke the agent
Debug this workflow failure using your favorite Agent CLI and the
agentic-workflowsprompt.agentic-workflowsskill from.github/skills/agentic-workflows/SKILL.mdor https://github.com/github/gh-aw/blob/main/.github/skills/agentic-workflows/SKILL.mddebug the agentic workflow pr-code-quality-reviewer failure in https://github.com/github/gh-aw/actions/runs/30683628677Tip
Stop reporting this workflow as a failure
To stop a workflow from creating failure issues, set
report-failure-as-issue: falsein its frontmatter: