From e83a7999bf0ae6a8f4ccedc2ecd253caaabba19d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:33:17 +0000 Subject: [PATCH 1/7] Initial plan From dda8f8b44046b063684f05987d44ad7e5093e3ab Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:45:02 +0000 Subject: [PATCH 2/7] Avoid oversized expression-bearing tools meta block Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .../setup/js/generate_safe_outputs_tools.cjs | 12 +++++- .../js/generate_safe_outputs_tools.test.cjs | 21 ++++++++++ pkg/workflow/mcp_setup_safe_outputs.go | 41 ++++++++++++++++++- pkg/workflow/mcp_setup_safe_outputs_test.go | 31 ++++++++++++++ 4 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 pkg/workflow/mcp_setup_safe_outputs_test.go diff --git a/actions/setup/js/generate_safe_outputs_tools.cjs b/actions/setup/js/generate_safe_outputs_tools.cjs index 1b6fb25a4d9..0326a6c9eea 100644 --- a/actions/setup/js/generate_safe_outputs_tools.cjs +++ b/actions/setup/js/generate_safe_outputs_tools.cjs @@ -201,6 +201,16 @@ function applyAssignMilestoneAlternativeRequirements(tool) { schema.anyOf = [{ required: ["milestone_number"] }, { required: ["milestone_title"] }]; } +/** + * Resolve ${ENV_VAR} placeholders inside a string from process.env. + * Unresolved placeholders are left unchanged. + * @param {string} value + * @returns {string} + */ +function resolveEnvStringPlaceholders(value) { + return value.replace(/\$\{([A-Z_][A-Z0-9_]*)\}/g, (match, envName) => process.env[envName] ?? match); +} + async function main() { const toolsSourcePath = process.env.GH_AW_SAFE_OUTPUTS_TOOLS_SOURCE_PATH || `${process.env.RUNNER_TEMP}/gh-aw/actions/safe_outputs_tools.json`; const configPath = process.env.GH_AW_SAFE_OUTPUTS_CONFIG_PATH || `${process.env.RUNNER_TEMP}/gh-aw/safeoutputs/config.json`; @@ -210,7 +220,7 @@ async function main() { // Write JSON payloads from env vars if provided (replaces heredoc-based file writing) if (process.env.GH_AW_TOOLS_META_JSON) { try { - fs.writeFileSync(toolsMetaPath, process.env.GH_AW_TOOLS_META_JSON); + fs.writeFileSync(toolsMetaPath, resolveEnvStringPlaceholders(process.env.GH_AW_TOOLS_META_JSON)); } catch (err) { throw new Error(`${ERR_SYSTEM}: Failed to write file ${toolsMetaPath}: ${getErrorMessage(err)}`, { cause: err }); } diff --git a/actions/setup/js/generate_safe_outputs_tools.test.cjs b/actions/setup/js/generate_safe_outputs_tools.test.cjs index b16372298f6..9ee4a70437f 100644 --- a/actions/setup/js/generate_safe_outputs_tools.test.cjs +++ b/actions/setup/js/generate_safe_outputs_tools.test.cjs @@ -232,6 +232,27 @@ describe("generate_safe_outputs_tools", () => { expect(result).toHaveLength(0); }); + it("resolves env placeholders in GH_AW_TOOLS_META_JSON", () => { + fs.writeFileSync(configPath, JSON.stringify({ create_issue: { max: 1 } })); + const metaFromEnv = JSON.stringify({ + description_suffixes: { + create_issue: " TARGET: ${GH_AW_INPUT_TARGET_REPO}", + }, + repo_params: {}, + dynamic_tools: [], + }); + + runScript({ + GH_AW_TOOLS_META_JSON: metaFromEnv, + GH_AW_INPUT_TARGET_REPO: "github/gh-aw", + }); + + const result = JSON.parse(fs.readFileSync(outputPath, "utf8")); + const createIssueTool = result.find((/** @type {{name: string, description: string}} */ t) => t.name === "create_issue"); + expect(createIssueTool).toBeDefined(); + expect(createIssueTool.description).toContain("TARGET: github/gh-aw"); + }); + it("ignores non-tool config keys when filtering", () => { // dispatch_workflow and max_bot_mentions are not tool names in source file fs.writeFileSync( diff --git a/pkg/workflow/mcp_setup_safe_outputs.go b/pkg/workflow/mcp_setup_safe_outputs.go index 4864301e5e0..18379e71f95 100644 --- a/pkg/workflow/mcp_setup_safe_outputs.go +++ b/pkg/workflow/mcp_setup_safe_outputs.go @@ -75,6 +75,7 @@ func generateSafeOutputsSetup(c *Compiler, yaml *strings.Builder, safeOutputConf mcpSetupGeneratorLog.Printf("Error generating tools meta JSON: %v", err) toolsMetaJSON = `{"description_suffixes":{},"repo_params":{},"dynamic_tools":[]}` } + sanitizedToolsMetaJSON, toolsMetaEnvKeys, toolsMetaEnvValues := buildToolsMetaRuntimeData(toolsMetaJSON) var enabledTypes []string if safeOutputConfig != "" { @@ -106,13 +107,14 @@ func generateSafeOutputsSetup(c *Compiler, yaml *strings.Builder, safeOutputConf yaml.WriteString(" - name: Generate Safe Outputs Tools\n") yaml.WriteString(" env:\n") yaml.WriteString(" GH_AW_TOOLS_META_JSON: |\n") - for line := range strings.SplitSeq(toolsMetaJSON, "\n") { + for line := range strings.SplitSeq(sanitizedToolsMetaJSON, "\n") { yaml.WriteString(" " + line + "\n") } yaml.WriteString(" GH_AW_VALIDATION_JSON: |\n") for line := range strings.SplitSeq(validationConfigJSON, "\n") { yaml.WriteString(" " + line + "\n") } + writeStepEnvVars(yaml, toolsMetaEnvKeys, toolsMetaEnvValues) fmt.Fprintf(yaml, " uses: %s\n", getCachedActionPin("actions/github-script", workflowData)) yaml.WriteString(" with:\n") yaml.WriteString(" script: |\n") @@ -151,6 +153,43 @@ func buildSafeOutputsConfigRuntimeData(safeOutputConfig string) (string, []strin return sanitizedConfig, envKeys, envValues } +func buildToolsMetaRuntimeData(toolsMetaJSON string) (string, []string, map[string]string) { + envValues := make(map[string]string) + if toolsMetaJSON == "" { + return toolsMetaJSON, nil, envValues + } + + extractor := NewExpressionExtractor() + expressionEnvVars := make(map[string]string) + expressions := ExpressionPatternDotAll.FindAllStringSubmatch(toolsMetaJSON, -1) + for _, match := range expressions { + if len(match) < 2 { + continue + } + expr := match[0] + content := strings.TrimSpace(match[1]) + if content == "" { + continue + } + if _, exists := expressionEnvVars[expr]; !exists { + expressionEnvVars[expr] = extractor.generateEnvVarName(content) + } + } + + if len(expressionEnvVars) == 0 { + return toolsMetaJSON, nil, envValues + } + + sanitizedToolsMeta := toolsMetaJSON + for _, expr := range sliceutil.SortedKeys(expressionEnvVars) { + envName := expressionEnvVars[expr] + envValues[envName] = expr + sanitizedToolsMeta = strings.ReplaceAll(sanitizedToolsMeta, expr, "${"+envName+"}") + } + + return sanitizedToolsMeta, sliceutil.SortedKeys(envValues), envValues +} + func writeStepEnvVars(yaml *strings.Builder, envKeys []string, envValues map[string]string) { for _, varName := range envKeys { yaml.WriteString(" " + varName + ": " + envValues[varName] + "\n") diff --git a/pkg/workflow/mcp_setup_safe_outputs_test.go b/pkg/workflow/mcp_setup_safe_outputs_test.go new file mode 100644 index 00000000000..ad2d3ee041f --- /dev/null +++ b/pkg/workflow/mcp_setup_safe_outputs_test.go @@ -0,0 +1,31 @@ +//go:build !integration + +package workflow + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildToolsMetaRuntimeDataExtractsExpressions(t *testing.T) { + input := `{"dynamic_tools":[{"inputSchema":{"properties":{"repo":{"default":"${{ inputs.target_repo }}"}}}}]}` + + sanitized, envKeys, envValues := buildToolsMetaRuntimeData(input) + + require.Len(t, envKeys, 1) + assert.Equal(t, "${{ inputs.target_repo }}", envValues[envKeys[0]]) + assert.Contains(t, sanitized, `"default":"${`+envKeys[0]+`}"`) + assert.NotContains(t, sanitized, "${{ inputs.target_repo }}") +} + +func TestBuildToolsMetaRuntimeDataWithoutExpressions(t *testing.T) { + input := `{"dynamic_tools":[]}` + + sanitized, envKeys, envValues := buildToolsMetaRuntimeData(input) + + assert.Equal(t, input, sanitized) + assert.Nil(t, envKeys) + assert.Empty(t, envValues) +} From b4510fa427b94a6ff7f1577b9bcfdcbc72f95254 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:51:11 +0000 Subject: [PATCH 3/7] Handle tools-meta expressions via runtime placeholders Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/mcp_setup_safe_outputs.go | 6 +++--- pkg/workflow/mcp_setup_safe_outputs_test.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/workflow/mcp_setup_safe_outputs.go b/pkg/workflow/mcp_setup_safe_outputs.go index 18379e71f95..8dff81208c4 100644 --- a/pkg/workflow/mcp_setup_safe_outputs.go +++ b/pkg/workflow/mcp_setup_safe_outputs.go @@ -154,9 +154,8 @@ func buildSafeOutputsConfigRuntimeData(safeOutputConfig string) (string, []strin } func buildToolsMetaRuntimeData(toolsMetaJSON string) (string, []string, map[string]string) { - envValues := make(map[string]string) if toolsMetaJSON == "" { - return toolsMetaJSON, nil, envValues + return toolsMetaJSON, nil, nil } extractor := NewExpressionExtractor() @@ -177,9 +176,10 @@ func buildToolsMetaRuntimeData(toolsMetaJSON string) (string, []string, map[stri } if len(expressionEnvVars) == 0 { - return toolsMetaJSON, nil, envValues + return toolsMetaJSON, nil, nil } + envValues := make(map[string]string, len(expressionEnvVars)) sanitizedToolsMeta := toolsMetaJSON for _, expr := range sliceutil.SortedKeys(expressionEnvVars) { envName := expressionEnvVars[expr] diff --git a/pkg/workflow/mcp_setup_safe_outputs_test.go b/pkg/workflow/mcp_setup_safe_outputs_test.go index ad2d3ee041f..519f0a9d5ad 100644 --- a/pkg/workflow/mcp_setup_safe_outputs_test.go +++ b/pkg/workflow/mcp_setup_safe_outputs_test.go @@ -27,5 +27,5 @@ func TestBuildToolsMetaRuntimeDataWithoutExpressions(t *testing.T) { assert.Equal(t, input, sanitized) assert.Nil(t, envKeys) - assert.Empty(t, envValues) + assert.Nil(t, envValues) } From 8b6b0ba6721fd7a0d1bcfc3bd9f752b2dbc2ce9b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:08:40 +0000 Subject: [PATCH 4/7] Add regression tests for tools-meta placeholder handling Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/contribution-check.lock.yml | 3 +- .../smoke-copilot-aoai-apikey.lock.yml | 5 ++- .../smoke-copilot-aoai-entra.lock.yml | 5 ++- .github/workflows/smoke-copilot-arm.lock.yml | 5 ++- .github/workflows/smoke-copilot.lock.yml | 5 ++- .../workflows/squad-implement-worker.lock.yml | 5 ++- .github/workflows/squad.lock.yml | 5 ++- .../js/generate_safe_outputs_tools.test.cjs | 43 +++++++++++++++++++ pkg/workflow/mcp_setup_safe_outputs_test.go | 29 +++++++++++++ 9 files changed, 92 insertions(+), 13 deletions(-) diff --git a/.github/workflows/contribution-check.lock.yml b/.github/workflows/contribution-check.lock.yml index 5870f2391f9..435be4530c3 100644 --- a/.github/workflows/contribution-check.lock.yml +++ b/.github/workflows/contribution-check.lock.yml @@ -637,7 +637,7 @@ jobs: GH_AW_TOOLS_META_JSON: | { "description_suffixes": { - "add_comment": " CONSTRAINTS: Maximum 10 comment(s) can be added. Target: *. Comments will be added in repository \"${{ vars.TARGET_REPOSITORY }}\". Supports reply_to_id for discussion threading.", + "add_comment": " CONSTRAINTS: Maximum 10 comment(s) can be added. Target: *. Comments will be added in repository \"${GH_AW_VARS_TARGET_REPOSITORY}\". Supports reply_to_id for discussion threading.", "add_labels": " CONSTRAINTS: Maximum 4 label(s) can be added. Only these labels are allowed: [\"spam\" \"needs-work\" \"outdated\" \"lgtm\"]. Target: *.", "create_issue": " CONSTRAINTS: Maximum 1 issue(s) can be created. Title will be prefixed with \"[Contribution Check Report]\". Labels [\"contribution-report\"] will be automatically added." }, @@ -815,6 +815,7 @@ jobs: } } } + GH_AW_VARS_TARGET_REPOSITORY: ${{ vars.TARGET_REPOSITORY }} uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | diff --git a/.github/workflows/smoke-copilot-aoai-apikey.lock.yml b/.github/workflows/smoke-copilot-aoai-apikey.lock.yml index 45d10666f27..a20d9aaf6f0 100644 --- a/.github/workflows/smoke-copilot-aoai-apikey.lock.yml +++ b/.github/workflows/smoke-copilot-aoai-apikey.lock.yml @@ -850,7 +850,7 @@ jobs: }, { "_workflow_name": "haiku-printer", - "description": "Dispatch the 'haiku-printer' workflow with workflow_dispatch trigger. This workflow must support workflow_dispatch and be in .github/workflows/ directory in the same repository. Use the 'ref' parameter to target a specific branch or tag (allowed patterns: refs/heads/${{ github.event.repository.default_branch }}).", + "description": "Dispatch the 'haiku-printer' workflow with workflow_dispatch trigger. This workflow must support workflow_dispatch and be in .github/workflows/ directory in the same repository. Use the 'ref' parameter to target a specific branch or tag (allowed patterns: refs/heads/${GH_AW_GITHUB_EVENT_REPOSITORY_DEFAULT_BRANCH}).", "inputSchema": { "additionalProperties": false, "properties": { @@ -859,7 +859,7 @@ jobs: "type": "string" }, "ref": { - "description": "The git ref (branch, tag, or SHA) to dispatch the workflow on. Must match one of the configured allowed ref patterns: refs/heads/${{ github.event.repository.default_branch }}. If omitted, the dispatching workflow's ref is used.", + "description": "The git ref (branch, tag, or SHA) to dispatch the workflow on. Must match one of the configured allowed ref patterns: refs/heads/${GH_AW_GITHUB_EVENT_REPOSITORY_DEFAULT_BRANCH}. If omitted, the dispatching workflow's ref is used.", "type": "string" } }, @@ -1317,6 +1317,7 @@ jobs: } } } + GH_AW_GITHUB_EVENT_REPOSITORY_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | diff --git a/.github/workflows/smoke-copilot-aoai-entra.lock.yml b/.github/workflows/smoke-copilot-aoai-entra.lock.yml index 8b20228e48c..11fc31f95e2 100644 --- a/.github/workflows/smoke-copilot-aoai-entra.lock.yml +++ b/.github/workflows/smoke-copilot-aoai-entra.lock.yml @@ -866,7 +866,7 @@ jobs: }, { "_workflow_name": "haiku-printer", - "description": "Dispatch the 'haiku-printer' workflow with workflow_dispatch trigger. This workflow must support workflow_dispatch and be in .github/workflows/ directory in the same repository. Use the 'ref' parameter to target a specific branch or tag (allowed patterns: refs/heads/${{ github.event.repository.default_branch }}).", + "description": "Dispatch the 'haiku-printer' workflow with workflow_dispatch trigger. This workflow must support workflow_dispatch and be in .github/workflows/ directory in the same repository. Use the 'ref' parameter to target a specific branch or tag (allowed patterns: refs/heads/${GH_AW_GITHUB_EVENT_REPOSITORY_DEFAULT_BRANCH}).", "inputSchema": { "additionalProperties": false, "properties": { @@ -875,7 +875,7 @@ jobs: "type": "string" }, "ref": { - "description": "The git ref (branch, tag, or SHA) to dispatch the workflow on. Must match one of the configured allowed ref patterns: refs/heads/${{ github.event.repository.default_branch }}. If omitted, the dispatching workflow's ref is used.", + "description": "The git ref (branch, tag, or SHA) to dispatch the workflow on. Must match one of the configured allowed ref patterns: refs/heads/${GH_AW_GITHUB_EVENT_REPOSITORY_DEFAULT_BRANCH}. If omitted, the dispatching workflow's ref is used.", "type": "string" } }, @@ -1333,6 +1333,7 @@ jobs: } } } + GH_AW_GITHUB_EVENT_REPOSITORY_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | diff --git a/.github/workflows/smoke-copilot-arm.lock.yml b/.github/workflows/smoke-copilot-arm.lock.yml index 76f887cd6cf..089e3f8c98d 100644 --- a/.github/workflows/smoke-copilot-arm.lock.yml +++ b/.github/workflows/smoke-copilot-arm.lock.yml @@ -766,7 +766,7 @@ jobs: }, { "_workflow_name": "haiku-printer", - "description": "Dispatch the 'haiku-printer' workflow with workflow_dispatch trigger. This workflow must support workflow_dispatch and be in .github/workflows/ directory in the same repository. Use the 'ref' parameter to target a specific branch or tag (allowed patterns: refs/heads/${{ github.event.repository.default_branch }}).", + "description": "Dispatch the 'haiku-printer' workflow with workflow_dispatch trigger. This workflow must support workflow_dispatch and be in .github/workflows/ directory in the same repository. Use the 'ref' parameter to target a specific branch or tag (allowed patterns: refs/heads/${GH_AW_GITHUB_EVENT_REPOSITORY_DEFAULT_BRANCH}).", "inputSchema": { "additionalProperties": false, "properties": { @@ -775,7 +775,7 @@ jobs: "type": "string" }, "ref": { - "description": "The git ref (branch, tag, or SHA) to dispatch the workflow on. Must match one of the configured allowed ref patterns: refs/heads/${{ github.event.repository.default_branch }}. If omitted, the dispatching workflow's ref is used.", + "description": "The git ref (branch, tag, or SHA) to dispatch the workflow on. Must match one of the configured allowed ref patterns: refs/heads/${GH_AW_GITHUB_EVENT_REPOSITORY_DEFAULT_BRANCH}. If omitted, the dispatching workflow's ref is used.", "type": "string" } }, @@ -1088,6 +1088,7 @@ jobs: } } } + GH_AW_GITHUB_EVENT_REPOSITORY_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | diff --git a/.github/workflows/smoke-copilot.lock.yml b/.github/workflows/smoke-copilot.lock.yml index 1d6fefe6aa6..a755c045ab3 100644 --- a/.github/workflows/smoke-copilot.lock.yml +++ b/.github/workflows/smoke-copilot.lock.yml @@ -866,7 +866,7 @@ jobs: }, { "_workflow_name": "haiku-printer", - "description": "Dispatch the 'haiku-printer' workflow with workflow_dispatch trigger. This workflow must support workflow_dispatch and be in .github/workflows/ directory in the same repository. Use the 'ref' parameter to target a specific branch or tag (allowed patterns: refs/heads/${{ github.event.repository.default_branch }}).", + "description": "Dispatch the 'haiku-printer' workflow with workflow_dispatch trigger. This workflow must support workflow_dispatch and be in .github/workflows/ directory in the same repository. Use the 'ref' parameter to target a specific branch or tag (allowed patterns: refs/heads/${GH_AW_GITHUB_EVENT_REPOSITORY_DEFAULT_BRANCH}).", "inputSchema": { "additionalProperties": false, "properties": { @@ -875,7 +875,7 @@ jobs: "type": "string" }, "ref": { - "description": "The git ref (branch, tag, or SHA) to dispatch the workflow on. Must match one of the configured allowed ref patterns: refs/heads/${{ github.event.repository.default_branch }}. If omitted, the dispatching workflow's ref is used.", + "description": "The git ref (branch, tag, or SHA) to dispatch the workflow on. Must match one of the configured allowed ref patterns: refs/heads/${GH_AW_GITHUB_EVENT_REPOSITORY_DEFAULT_BRANCH}. If omitted, the dispatching workflow's ref is used.", "type": "string" } }, @@ -1333,6 +1333,7 @@ jobs: } } } + GH_AW_GITHUB_EVENT_REPOSITORY_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | diff --git a/.github/workflows/squad-implement-worker.lock.yml b/.github/workflows/squad-implement-worker.lock.yml index a203c8ac811..0d8189ef264 100644 --- a/.github/workflows/squad-implement-worker.lock.yml +++ b/.github/workflows/squad-implement-worker.lock.yml @@ -618,7 +618,7 @@ jobs: "dynamic_tools": [ { "_workflow_name": "squad", - "description": "Dispatch the 'squad' workflow with workflow_dispatch trigger. This workflow must support workflow_dispatch and be in .github/workflows/ directory in the same repository. Use the 'ref' parameter to target a specific branch or tag (allowed patterns: refs/heads/${{ github.event.repository.default_branch }}).", + "description": "Dispatch the 'squad' workflow with workflow_dispatch trigger. This workflow must support workflow_dispatch and be in .github/workflows/ directory in the same repository. Use the 'ref' parameter to target a specific branch or tag (allowed patterns: refs/heads/${GH_AW_GITHUB_EVENT_REPOSITORY_DEFAULT_BRANCH}).", "inputSchema": { "additionalProperties": false, "properties": { @@ -636,7 +636,7 @@ jobs: "type": "string" }, "ref": { - "description": "The git ref (branch, tag, or SHA) to dispatch the workflow on. Must match one of the configured allowed ref patterns: refs/heads/${{ github.event.repository.default_branch }}. If omitted, the dispatching workflow's ref is used.", + "description": "The git ref (branch, tag, or SHA) to dispatch the workflow on. Must match one of the configured allowed ref patterns: refs/heads/${GH_AW_GITHUB_EVENT_REPOSITORY_DEFAULT_BRANCH}. If omitted, the dispatching workflow's ref is used.", "type": "string" } }, @@ -846,6 +846,7 @@ jobs: } } } + GH_AW_GITHUB_EVENT_REPOSITORY_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | diff --git a/.github/workflows/squad.lock.yml b/.github/workflows/squad.lock.yml index 541b918163b..93ef36463b3 100644 --- a/.github/workflows/squad.lock.yml +++ b/.github/workflows/squad.lock.yml @@ -665,7 +665,7 @@ jobs: "dynamic_tools": [ { "_workflow_name": "squad-implement-worker", - "description": "Dispatch the 'squad-implement-worker' workflow with workflow_dispatch trigger. This workflow must support workflow_dispatch and be in .github/workflows/ directory in the same repository. Use the 'ref' parameter to target a specific branch or tag (allowed patterns: refs/heads/${{ github.event.repository.default_branch }}).", + "description": "Dispatch the 'squad-implement-worker' workflow with workflow_dispatch trigger. This workflow must support workflow_dispatch and be in .github/workflows/ directory in the same repository. Use the 'ref' parameter to target a specific branch or tag (allowed patterns: refs/heads/${GH_AW_GITHUB_EVENT_REPOSITORY_DEFAULT_BRANCH}).", "inputSchema": { "additionalProperties": false, "properties": { @@ -678,7 +678,7 @@ jobs: "type": "string" }, "ref": { - "description": "The git ref (branch, tag, or SHA) to dispatch the workflow on. Must match one of the configured allowed ref patterns: refs/heads/${{ github.event.repository.default_branch }}. If omitted, the dispatching workflow's ref is used.", + "description": "The git ref (branch, tag, or SHA) to dispatch the workflow on. Must match one of the configured allowed ref patterns: refs/heads/${GH_AW_GITHUB_EVENT_REPOSITORY_DEFAULT_BRANCH}. If omitted, the dispatching workflow's ref is used.", "type": "string" } }, @@ -1378,6 +1378,7 @@ jobs: } } } + GH_AW_GITHUB_EVENT_REPOSITORY_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | diff --git a/actions/setup/js/generate_safe_outputs_tools.test.cjs b/actions/setup/js/generate_safe_outputs_tools.test.cjs index 9ee4a70437f..d770dc4fca1 100644 --- a/actions/setup/js/generate_safe_outputs_tools.test.cjs +++ b/actions/setup/js/generate_safe_outputs_tools.test.cjs @@ -253,6 +253,49 @@ describe("generate_safe_outputs_tools", () => { expect(createIssueTool.description).toContain("TARGET: github/gh-aw"); }); + it("resolves multiple distinct env placeholders in GH_AW_TOOLS_META_JSON", () => { + fs.writeFileSync(configPath, JSON.stringify({ create_issue: { max: 1 } })); + const metaFromEnv = JSON.stringify({ + description_suffixes: { + create_issue: " TARGET: ${GH_AW_INPUT_TARGET_REPO} OWNER: ${GH_AW_GITHUB_REPOSITORY_OWNER}", + }, + repo_params: {}, + dynamic_tools: [], + }); + + runScript({ + GH_AW_TOOLS_META_JSON: metaFromEnv, + GH_AW_INPUT_TARGET_REPO: "github/gh-aw", + GH_AW_GITHUB_REPOSITORY_OWNER: "github", + }); + + const result = JSON.parse(fs.readFileSync(outputPath, "utf8")); + const createIssueTool = result.find((/** @type {{name: string, description: string}} */ t) => t.name === "create_issue"); + expect(createIssueTool).toBeDefined(); + expect(createIssueTool.description).toContain("TARGET: github/gh-aw"); + expect(createIssueTool.description).toContain("OWNER: github"); + }); + + it("leaves unresolved placeholders in GH_AW_TOOLS_META_JSON unchanged", () => { + fs.writeFileSync(configPath, JSON.stringify({ create_issue: { max: 1 } })); + const metaFromEnv = JSON.stringify({ + description_suffixes: { + create_issue: " TARGET: ${GH_AW_INPUT_MISSING}", + }, + repo_params: {}, + dynamic_tools: [], + }); + + runScript({ + GH_AW_TOOLS_META_JSON: metaFromEnv, + }); + + const result = JSON.parse(fs.readFileSync(outputPath, "utf8")); + const createIssueTool = result.find((/** @type {{name: string, description: string}} */ t) => t.name === "create_issue"); + expect(createIssueTool).toBeDefined(); + expect(createIssueTool.description).toContain("TARGET: ${GH_AW_INPUT_MISSING}"); + }); + it("ignores non-tool config keys when filtering", () => { // dispatch_workflow and max_bot_mentions are not tool names in source file fs.writeFileSync( diff --git a/pkg/workflow/mcp_setup_safe_outputs_test.go b/pkg/workflow/mcp_setup_safe_outputs_test.go index 519f0a9d5ad..b3c0bb94176 100644 --- a/pkg/workflow/mcp_setup_safe_outputs_test.go +++ b/pkg/workflow/mcp_setup_safe_outputs_test.go @@ -3,6 +3,7 @@ package workflow import ( + "strings" "testing" "github.com/stretchr/testify/assert" @@ -29,3 +30,31 @@ func TestBuildToolsMetaRuntimeDataWithoutExpressions(t *testing.T) { assert.Nil(t, envKeys) assert.Nil(t, envValues) } + +func TestBuildToolsMetaRuntimeDataWithMultipleDistinctExpressions(t *testing.T) { + input := `{"description_suffixes":{"create_issue":"${{ inputs.target_repo }}"},"repo_params":{"owner":"${{ github.repository_owner }}"},"dynamic_tools":[]}` + + sanitized, envKeys, envValues := buildToolsMetaRuntimeData(input) + + require.Len(t, envKeys, 2) + assert.NotContains(t, sanitized, "${{ inputs.target_repo }}") + assert.NotContains(t, sanitized, "${{ github.repository_owner }}") + seen := map[string]bool{} + for _, key := range envKeys { + seen[envValues[key]] = true + assert.Contains(t, sanitized, "${"+key+"}") + } + assert.True(t, seen["${{ inputs.target_repo }}"]) + assert.True(t, seen["${{ github.repository_owner }}"]) +} + +func TestBuildToolsMetaRuntimeDataDedupesRepeatedExpression(t *testing.T) { + input := `{"description_suffixes":{"a":"${{ inputs.target_repo }}","b":"${{ inputs.target_repo }}"},"dynamic_tools":[]}` + + sanitized, envKeys, envValues := buildToolsMetaRuntimeData(input) + + require.Len(t, envKeys, 1) + assert.Equal(t, "${{ inputs.target_repo }}", envValues[envKeys[0]]) + assert.NotContains(t, sanitized, "${{ inputs.target_repo }}") + assert.Equal(t, 2, strings.Count(sanitized, "${"+envKeys[0]+"}")) +} From ff5bae3c0f58744131e13527805d46475c0d4448 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:58:32 +0000 Subject: [PATCH 5/7] Fix prettier formatting in eslint-factory test file Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- eslint-factory/src/rules/no-exec-interpolated-command.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eslint-factory/src/rules/no-exec-interpolated-command.test.ts b/eslint-factory/src/rules/no-exec-interpolated-command.test.ts index cdf75492446..761f5606fb4 100644 --- a/eslint-factory/src/rules/no-exec-interpolated-command.test.ts +++ b/eslint-factory/src/rules/no-exec-interpolated-command.test.ts @@ -179,7 +179,7 @@ describe("no-exec-interpolated-command", () => { }, // execApi parameter-alias with identifier args (still array-shaped by convention) — flagged { - code: "function run(execApi, branchName) { execApi.exec(\"git checkout \" + branchName, args); }", + code: 'function run(execApi, branchName) { execApi.exec("git checkout " + branchName, args); }', errors: [{ messageId: "interpolatedCommand", data: { kind: "dynamic string concatenation", method: "exec" } }], }, ], From e403f67078ca7a2f537841665f530238fc6adae6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:15:01 +0000 Subject: [PATCH 6/7] Fix JSON-safe placeholder resolution and expression decoding Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- .../setup/js/generate_safe_outputs_tools.cjs | 13 ++++++++-- .../js/generate_safe_outputs_tools.test.cjs | 25 +++++++++++++++++++ pkg/workflow/mcp_setup_safe_outputs.go | 15 ++++++++++- pkg/workflow/mcp_setup_safe_outputs_test.go | 21 ++++++++++++++++ 4 files changed, 71 insertions(+), 3 deletions(-) diff --git a/actions/setup/js/generate_safe_outputs_tools.cjs b/actions/setup/js/generate_safe_outputs_tools.cjs index 0326a6c9eea..b997341b1db 100644 --- a/actions/setup/js/generate_safe_outputs_tools.cjs +++ b/actions/setup/js/generate_safe_outputs_tools.cjs @@ -202,13 +202,22 @@ function applyAssignMilestoneAlternativeRequirements(tool) { } /** - * Resolve ${ENV_VAR} placeholders inside a string from process.env. + * Resolve ${ENV_VAR} placeholders inside a JSON string from process.env. + * Replacement values are escaped as JSON string content so quotes, backslashes, and + * newlines in the resolved value do not corrupt the surrounding JSON document. * Unresolved placeholders are left unchanged. * @param {string} value * @returns {string} */ function resolveEnvStringPlaceholders(value) { - return value.replace(/\$\{([A-Z_][A-Z0-9_]*)\}/g, (match, envName) => process.env[envName] ?? match); + return value.replace(/\$\{([A-Z_][A-Z0-9_]*)\}/g, (match, envName) => { + const envValue = process.env[envName]; + if (envValue === undefined) { + return match; + } + // JSON.stringify wraps the value in quotes; strip them to get escaped string content. + return JSON.stringify(envValue).slice(1, -1); + }); } async function main() { diff --git a/actions/setup/js/generate_safe_outputs_tools.test.cjs b/actions/setup/js/generate_safe_outputs_tools.test.cjs index d770dc4fca1..733d1a19647 100644 --- a/actions/setup/js/generate_safe_outputs_tools.test.cjs +++ b/actions/setup/js/generate_safe_outputs_tools.test.cjs @@ -296,6 +296,31 @@ describe("generate_safe_outputs_tools", () => { expect(createIssueTool.description).toContain("TARGET: ${GH_AW_INPUT_MISSING}"); }); + it("escapes quotes, backslashes, and newlines when resolving GH_AW_TOOLS_META_JSON placeholders", () => { + fs.writeFileSync(configPath, JSON.stringify({ create_issue: { max: 1 } })); + const metaFromEnv = JSON.stringify({ + description_suffixes: { + create_issue: " TARGET: ${GH_AW_INPUT_TARGET_REPO}", + }, + repo_params: {}, + dynamic_tools: [], + }); + + runScript({ + GH_AW_TOOLS_META_JSON: metaFromEnv, + GH_AW_INPUT_TARGET_REPO: 'a"b\\c\nd', + }); + + // The written tools_meta.json must remain valid JSON despite the unsafe characters. + const writtenMeta = JSON.parse(fs.readFileSync(toolsMetaPath, "utf8")); + expect(writtenMeta.description_suffixes.create_issue).toContain('a"b\\c\nd'); + + const result = JSON.parse(fs.readFileSync(outputPath, "utf8")); + const createIssueTool = result.find((/** @type {{name: string, description: string}} */ t) => t.name === "create_issue"); + expect(createIssueTool).toBeDefined(); + expect(createIssueTool.description).toContain('TARGET: a"b\\c\nd'); + }); + it("ignores non-tool config keys when filtering", () => { // dispatch_workflow and max_bot_mentions are not tool names in source file fs.writeFileSync( diff --git a/pkg/workflow/mcp_setup_safe_outputs.go b/pkg/workflow/mcp_setup_safe_outputs.go index 8dff81208c4..768400dfb61 100644 --- a/pkg/workflow/mcp_setup_safe_outputs.go +++ b/pkg/workflow/mcp_setup_safe_outputs.go @@ -183,13 +183,26 @@ func buildToolsMetaRuntimeData(toolsMetaJSON string) (string, []string, map[stri sanitizedToolsMeta := toolsMetaJSON for _, expr := range sliceutil.SortedKeys(expressionEnvVars) { envName := expressionEnvVars[expr] - envValues[envName] = expr + envValues[envName] = decodeJSONStringFragment(expr) sanitizedToolsMeta = strings.ReplaceAll(sanitizedToolsMeta, expr, "${"+envName+"}") } return sanitizedToolsMeta, sliceutil.SortedKeys(envValues), envValues } +// decodeJSONStringFragment decodes a fragment extracted from a larger JSON-encoded string +// (e.g. an expression matched inside a JSON string value). encoding/json escapes characters +// such as <, >, & as \u003c, \u003e, \u0026 and also escapes quotes/backslashes/control +// characters; this reverses that encoding so the fragment can be used verbatim as a step env +// value. If the fragment cannot be decoded as JSON string content, it is returned unchanged. +func decodeJSONStringFragment(fragment string) string { + var decoded string + if err := json.Unmarshal([]byte(`"`+fragment+`"`), &decoded); err != nil { + return fragment + } + return decoded +} + func writeStepEnvVars(yaml *strings.Builder, envKeys []string, envValues map[string]string) { for _, varName := range envKeys { yaml.WriteString(" " + varName + ": " + envValues[varName] + "\n") diff --git a/pkg/workflow/mcp_setup_safe_outputs_test.go b/pkg/workflow/mcp_setup_safe_outputs_test.go index b3c0bb94176..ebf98e71d1c 100644 --- a/pkg/workflow/mcp_setup_safe_outputs_test.go +++ b/pkg/workflow/mcp_setup_safe_outputs_test.go @@ -3,6 +3,7 @@ package workflow import ( + "encoding/json" "strings" "testing" @@ -58,3 +59,23 @@ func TestBuildToolsMetaRuntimeDataDedupesRepeatedExpression(t *testing.T) { assert.NotContains(t, sanitized, "${{ inputs.target_repo }}") assert.Equal(t, 2, strings.Count(sanitized, "${"+envKeys[0]+"}")) } + +func TestBuildToolsMetaRuntimeDataDecodesHTMLEscapedExpression(t *testing.T) { + rawJSON, err := json.Marshal(map[string]any{ + "description_suffixes": map[string]string{ + "create_issue": "${{ inputs.enabled && 'yes' || 'no' }}", + }, + "dynamic_tools": []any{}, + }) + require.NoError(t, err) + input := string(rawJSON) + // Confirm encoding/json HTML-escaped the expression as expected by this test. + require.Contains(t, input, `\u0026\u0026`) + + sanitized, envKeys, envValues := buildToolsMetaRuntimeData(input) + + require.Len(t, envKeys, 1) + assert.Equal(t, "${{ inputs.enabled && 'yes' || 'no' }}", envValues[envKeys[0]]) + assert.NotContains(t, envValues[envKeys[0]], `\u0026`) + assert.Contains(t, sanitized, "${"+envKeys[0]+"}") +} From 900b17ffa422b7aa84ee3be183de70391be75e26 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:16:41 +0000 Subject: [PATCH 7/7] Document invariant for decodeJSONStringFragment Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- pkg/workflow/mcp_setup_safe_outputs.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pkg/workflow/mcp_setup_safe_outputs.go b/pkg/workflow/mcp_setup_safe_outputs.go index 768400dfb61..502590d4eb6 100644 --- a/pkg/workflow/mcp_setup_safe_outputs.go +++ b/pkg/workflow/mcp_setup_safe_outputs.go @@ -195,6 +195,12 @@ func buildToolsMetaRuntimeData(toolsMetaJSON string) (string, []string, map[stri // such as <, >, & as \u003c, \u003e, \u0026 and also escapes quotes/backslashes/control // characters; this reverses that encoding so the fragment can be used verbatim as a step env // value. If the fragment cannot be decoded as JSON string content, it is returned unchanged. +// +// Callers must only pass fragments that are themselves valid JSON string interior content +// (i.e. extracted from inside a JSON string value, with balanced escape sequences and no raw +// unescaped quotes). Since toolsMetaJSON is produced by encoding/json and the expression regex +// only matches within an already-encoded string value, this invariant holds for the caller in +// this file. func decodeJSONStringFragment(fragment string) string { var decoded string if err := json.Unmarshal([]byte(`"`+fragment+`"`), &decoded); err != nil {