Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions .github/workflows/smoke-crush.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

60 changes: 52 additions & 8 deletions pkg/workflow/crush_engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down
40 changes: 40 additions & 0 deletions pkg/workflow/crush_engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down