From dee38a4a3625dd9d69a537a5521a3ded5039b05d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 08:23:24 +0000 Subject: [PATCH 1/2] Initial plan From 7b88ed03480e7426a2319a19147edd701af062b6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 08:47:15 +0000 Subject: [PATCH 2/2] fix: resolve Crush EROFS install failure by redirecting npm prefix to writable RUNNER_TEMP The @charmland/crush npm package lazily installs its native binary to bin/ within the package directory on first run. With `npm install -g`, the package ends up in /opt/hostedtoolcache/node/.../lib/node_modules/ which is read-only on newer GitHub Actions runners, causing EROFS failures. Fix: - Add `crushGlobalPrefix` constant pointing to ${RUNNER_TEMP}/gh-aw/crush-global - Replace BuildStandardNpmEngineInstallSteps with a custom buildCrushInstallStep that uses `--prefix "${RUNNER_TEMP}/gh-aw/crush-global"` to redirect the npm global installation to a writable directory, and appends the bin dir to $GITHUB_PATH - In the AWF (firewall) execution path, add explicit PATH setup for the crush bin dir so it's found inside the AWF container (sudo secure_path strips $GITHUB_PATH) - Update crush_engine_test.go with assertions for writable prefix and PATH setup - Recompile all 205 workflow lock files The Gemini API_KEY_INVALID failure is a purely operational issue (expired secret) with no code-level fix possible. Closes #29458 Agent-Logs-Url: https://github.com/github/gh-aw/sessions/ce69b917-3a39-4558-8762-08e75bb2785a Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com> --- .github/workflows/smoke-crush.lock.yml | 16 +++++-- pkg/workflow/crush_engine.go | 60 ++++++++++++++++++++++---- pkg/workflow/crush_engine_test.go | 40 +++++++++++++++++ 3 files changed, 104 insertions(+), 12 deletions(-) diff --git a/.github/workflows/smoke-crush.lock.yml b/.github/workflows/smoke-crush.lock.yml index 60e79f6a9d5..7028e7ae37f 100644 --- a/.github/workflows/smoke-crush.lock.yml +++ b/.github/workflows/smoke-crush.lock.yml @@ -443,7 +443,11 @@ jobs: - name: Install AWF binary run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.25.29 - name: Install Crush CLI - run: npm install --ignore-scripts -g @charmland/crush@0.59.0 + run: | + CRUSH_PREFIX="${RUNNER_TEMP}/gh-aw/crush-global" + mkdir -p "${CRUSH_PREFIX}" + npm install --ignore-scripts --global --prefix "${CRUSH_PREFIX}" @charmland/crush@0.59.0 + echo "${CRUSH_PREFIX}/bin" >> "${GITHUB_PATH}" - name: Determine automatic lockdown mode for GitHub MCP Server id: determine-automatic-lockdown uses: actions/github-script@373c709c69115d41ff229c7e5df9f8788daa9553 # v9 @@ -914,7 +918,7 @@ jobs: printf '%s\n' '{"$schema":"https://github.com/github/gh-aw-firewall/schemas/awf-config.v1.json","network":{"allowDomains":["*.githubusercontent.com","api.anthropic.com","api.snapcraft.io","archive.ubuntu.com","azure.archive.ubuntu.com","charm.land","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","ts-crl.ws.symantec.com","ts-ocsp.ws.symantec.com","www.googleapis.com"]},"apiProxy":{"enabled":true},"container":{"imageTag":"0.25.29,squid=sha256:8a71ad9e40454051672312917e51567abfb8251d7c294d086c48f63d84e4cb53,agent=sha256:e68f37e36962dcb3f3d1de680a49bc2302cefd001b941a7dc377155ec7ce42f4,agent-act=sha256:97b4cc14dc2123a45b9d5b9927489f66882dec5857de6afc0e5bab257be92ef1,api-proxy=sha256:d1219e4110684402aabbeb5a43858f26790c9d0be210581cf3f7a521bd2c87b6,cli-proxy=sha256:29917488eb90a01ff9544ffeeb5cc26434a8ea16d69ae8972f5f6be0e567e276"}}' > "${RUNNER_TEMP}/gh-aw/awf-config.json" && cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json # shellcheck disable=SC1003 sudo -E awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --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 --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --allow-host-ports 80,443,8080 --skip-pull \ - -- /bin/bash -c 'export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && export PATH="$(find /opt/hostedtoolcache /home/runner/work/_tool -maxdepth 4 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && crush run --verbose "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log + -- /bin/bash -c 'export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && export PATH="${RUNNER_TEMP}/gh-aw/crush-global/bin:$PATH" && export PATH="$(find /opt/hostedtoolcache /home/runner/work/_tool -maxdepth 4 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && crush run --verbose "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} ANTHROPIC_BASE_URL: http://host.docker.internal:10000 @@ -1345,7 +1349,11 @@ jobs: - name: Install AWF binary run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.25.29 - name: Install Crush CLI - run: npm install --ignore-scripts -g @charmland/crush@0.59.0 + run: | + CRUSH_PREFIX="${RUNNER_TEMP}/gh-aw/crush-global" + mkdir -p "${CRUSH_PREFIX}" + npm install --ignore-scripts --global --prefix "${CRUSH_PREFIX}" @charmland/crush@0.59.0 + echo "${CRUSH_PREFIX}/bin" >> "${GITHUB_PATH}" - name: Write Crush Config if: always() && steps.detection_guard.outputs.run_detection == 'true' continue-on-error: true @@ -1371,7 +1379,7 @@ jobs: printf '%s\n' '{"$schema":"https://github.com/github/gh-aw-firewall/schemas/awf-config.v1.json","network":{"allowDomains":["api.anthropic.com","charm.land","github.com","host.docker.internal","raw.githubusercontent.com","registry.npmjs.org"]},"apiProxy":{"enabled":true},"container":{"imageTag":"0.25.29,squid=sha256:8a71ad9e40454051672312917e51567abfb8251d7c294d086c48f63d84e4cb53,agent=sha256:e68f37e36962dcb3f3d1de680a49bc2302cefd001b941a7dc377155ec7ce42f4,agent-act=sha256:97b4cc14dc2123a45b9d5b9927489f66882dec5857de6afc0e5bab257be92ef1,api-proxy=sha256:d1219e4110684402aabbeb5a43858f26790c9d0be210581cf3f7a521bd2c87b6,cli-proxy=sha256:29917488eb90a01ff9544ffeeb5cc26434a8ea16d69ae8972f5f6be0e567e276"}}' > "${RUNNER_TEMP}/gh-aw/awf-config.json" && cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json # shellcheck disable=SC1003 sudo -E awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --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 --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --allow-host-ports 80,443,8080 --skip-pull \ - -- /bin/bash -c 'export PATH="$(find /opt/hostedtoolcache /home/runner/work/_tool -maxdepth 4 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && crush run --verbose "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log + -- /bin/bash -c 'export PATH="${RUNNER_TEMP}/gh-aw/crush-global/bin:$PATH" && export PATH="$(find /opt/hostedtoolcache /home/runner/work/_tool -maxdepth 4 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && crush run --verbose "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} ANTHROPIC_BASE_URL: http://host.docker.internal:10000 diff --git a/pkg/workflow/crush_engine.go b/pkg/workflow/crush_engine.go index c72e94cb762..21c98c9edd9 100644 --- a/pkg/workflow/crush_engine.go +++ b/pkg/workflow/crush_engine.go @@ -10,6 +10,11 @@ import ( var crushLog = logger.New("workflow:crush_engine") +// crushGlobalPrefix is the writable npm install prefix used for the Crush CLI binary. +// The default npm global prefix in hostedtoolcache is read-only on newer GitHub Actions +// runners; ${RUNNER_TEMP}/gh-aw is always writable. +const crushGlobalPrefix = `${RUNNER_TEMP}/gh-aw/crush-global` + // CrushEngine represents the Crush CLI agentic engine. // Crush is a provider-agnostic, open-source AI coding agent with broader BYOK // (Bring Your Own Key) support, but gh-aw currently supports a subset of @@ -59,16 +64,51 @@ func (e *CrushEngine) GetInstallationSteps(workflowData *WorkflowData) []GitHubA return []GitHubActionStep{} } - npmSteps := BuildStandardNpmEngineInstallSteps( - "@charmland/crush", - string(constants.DefaultCrushVersion), - "Install Crush CLI", - "crush", - workflowData, - ) + // Use version from engine config if provided, otherwise default to pinned version + version := string(constants.DefaultCrushVersion) + if workflowData.EngineConfig != nil && workflowData.EngineConfig.Version != "" { + version = workflowData.EngineConfig.Version + } + + npmSteps := []GitHubActionStep{ + GenerateNodeJsSetupStep(), + e.buildCrushInstallStep(version), + } return BuildNpmEngineInstallStepsWithAWF(npmSteps, workflowData) } +// buildCrushInstallStep creates a GitHub Actions step that installs the Crush CLI +// into a writable directory (crushGlobalPrefix) to avoid EROFS errors. +// +// @charmland/crush lazily installs its native binary to bin/ within the package +// directory on first run. The default npm global prefix in hostedtoolcache +// (/opt/hostedtoolcache/node/.../lib/node_modules) is read-only on newer GitHub +// Actions runners, causing EROFS failures. Installing with an explicit --prefix +// pointing to crushGlobalPrefix ensures the binary installation succeeds on a +// writable filesystem, and the bin directory is added to $GITHUB_PATH for +// subsequent steps. +func (e *CrushEngine) buildCrushInstallStep(version string) GitHubActionStep { + var versionArg string + var env map[string]string + + if ExpressionPattern.MatchString(version) { + // Version is a GitHub Actions expression — pass via env var to prevent injection. + versionArg = `@"${ENGINE_VERSION}"` + env = map[string]string{"ENGINE_VERSION": version} + } else { + versionArg = "@" + version + } + + installCmd := fmt.Sprintf(`CRUSH_PREFIX="%s" +mkdir -p "${CRUSH_PREFIX}" +npm install --ignore-scripts --global --prefix "${CRUSH_PREFIX}" @charmland/crush%s +echo "${CRUSH_PREFIX}/bin" >> "${GITHUB_PATH}"`, crushGlobalPrefix, versionArg) + + stepLines := []string{" - name: Install Crush CLI"} + stepLines = FormatStepWithCommandAndEnv(stepLines, installCmd, env) + return GitHubActionStep(stepLines) +} + // GetSecretValidationStep returns the secret validation step for the Crush engine. // Returns an empty step if copilot-requests feature is enabled (uses GitHub Actions token). func (e *CrushEngine) GetSecretValidationStep(workflowData *WorkflowData) GitHubActionStep { @@ -154,7 +194,11 @@ func (e *CrushEngine) GetExecutionSteps(workflowData *WorkflowData, logFile stri } npmPathSetup := GetNpmBinPathSetup() - crushCommandWithPath := fmt.Sprintf("%s && %s", npmPathSetup, crushCommand) + // Prepend the writable crush install directory to PATH inside the AWF container. + // sudo's secure_path can strip $GITHUB_PATH additions, so we set the path + // explicitly here (same pattern as GetMCPCLIPathSetup). + crushBinPathSetup := fmt.Sprintf(`export PATH="%s/bin:$PATH"`, crushGlobalPrefix) + crushCommandWithPath := fmt.Sprintf("%s && %s && %s", crushBinPathSetup, npmPathSetup, crushCommand) if mcpCLIPath := GetMCPCLIPathSetup(workflowData); mcpCLIPath != "" { crushCommandWithPath = fmt.Sprintf("%s && %s", mcpCLIPath, crushCommandWithPath) } diff --git a/pkg/workflow/crush_engine_test.go b/pkg/workflow/crush_engine_test.go index ef931cf2ca4..bffabe792d8 100644 --- a/pkg/workflow/crush_engine_test.go +++ b/pkg/workflow/crush_engine_test.go @@ -153,6 +153,17 @@ func TestCrushEngine(t *testing.T) { func TestCrushEngineInstallation(t *testing.T) { engine := NewCrushEngine() + // findInstallStep returns the "Install Crush CLI" step from the list, or nil. + findInstallStep := func(steps []GitHubActionStep) string { + for _, step := range steps { + content := strings.Join(step, "\n") + if strings.Contains(content, "Install Crush CLI") { + return content + } + } + return "" + } + t.Run("standard installation", func(t *testing.T) { workflowData := &WorkflowData{ Name: "test-workflow", @@ -163,6 +174,33 @@ func TestCrushEngineInstallation(t *testing.T) { // Should have at least: Node.js setup + Install Crush assert.GreaterOrEqual(t, len(steps), 2, "Should have at least 2 installation steps") + + // Verify install step uses writable RUNNER_TEMP prefix to avoid EROFS errors + installStepContent := findInstallStep(steps) + require.NotEmpty(t, installStepContent, "Should have an 'Install Crush CLI' step") + assert.Contains(t, installStepContent, `${RUNNER_TEMP}/gh-aw/crush-global`, "Should install to writable RUNNER_TEMP directory") + assert.Contains(t, installStepContent, "--prefix", "Should use --prefix to redirect npm global install") + assert.Contains(t, installStepContent, "--ignore-scripts", "Should use --ignore-scripts for supply chain security") + assert.Contains(t, installStepContent, `${GITHUB_PATH}`, "Should add crush bin dir to GITHUB_PATH for subsequent steps") + }) + + t.Run("custom version", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ + Version: "1.2.3", + }, + } + + steps := engine.GetInstallationSteps(workflowData) + require.NotEmpty(t, steps, "Should generate installation steps") + + installStepContent := findInstallStep(steps) + require.NotEmpty(t, installStepContent, "Should have an 'Install Crush CLI' step") + assert.Contains(t, installStepContent, "1.2.3", "Should use version from engine config") + assert.Contains(t, installStepContent, `${RUNNER_TEMP}/gh-aw/crush-global`, "Should still install to writable RUNNER_TEMP directory with custom version") + assert.Contains(t, installStepContent, "--prefix", "Should use --prefix with custom version") + assert.Contains(t, installStepContent, `${GITHUB_PATH}`, "Should add crush bin dir to GITHUB_PATH with custom version") }) t.Run("custom command skips installation", func(t *testing.T) { @@ -390,6 +428,8 @@ func TestCrushEngineFirewallIntegration(t *testing.T) { assert.Contains(t, stepContent, "allowDomains", "Should include allowDomains in config JSON") assert.Contains(t, stepContent, `"enabled":true`, "Should include apiProxy enabled in config JSON") assert.Contains(t, stepContent, "GITHUB_COPILOT_BASE_URL: http://host.docker.internal:10002", "Should route copilot/* fallback through Copilot LLM gateway URL") + // Should include crush bin path setup so the binary is found inside the AWF container + assert.Contains(t, stepContent, `export PATH="${RUNNER_TEMP}/gh-aw/crush-global/bin:$PATH"`, "Should add writable crush bin directory to PATH inside AWF container") }) t.Run("firewall enabled adds mounted MCP CLI path setup", func(t *testing.T) {